Compare commits

...

9 Commits

Author SHA1 Message Date
lukaszraczylo 4ef42e5781 fixup! Github pages + benchmarks. 2025-12-07 16:49:50 +00:00
lukaszraczylo 996d29b57b Github pages + benchmarks. 2025-12-07 16:46:13 +00:00
lukaszraczylo 7c80d6adaa Create CNAME 2025-12-07 16:45:33 +00:00
lukaszraczylo 31fc3ae3d9 Switch to goreleaser. 2025-12-07 16:13:33 +00:00
lukaszraczylo da8ec5f21d Add LRU cache support. 2025-12-03 10:22:33 +00:00
lukaszraczylo 3d80f457d3 Update go.mod and go.sum
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-03 03:24:59 +00:00
lukaszraczylo 09c3e4cd95 Update go.mod and go.sum
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-02 03:26:31 +00:00
lukaszraczylo d07ee4090c Dashboard update. 2025-11-29 15:53:05 +00:00
lukaszraczylo b1045b8bc2 Retry budget. 2025-11-29 15:36:17 +00:00
24 changed files with 1550 additions and 334 deletions
+8 -64
View File
@@ -5,69 +5,13 @@ on:
schedule:
- cron: "0 3 * * *"
env:
GO_VERSION: ">=1.21"
permissions:
contents: write
actions: write
jobs:
# This job is responsible for preparation of the build
# environment variables.
prepare:
name: Preparing build context
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Install Go
uses: actions/setup-go@v5
id: cache
with:
go-version: ${{env.GO_VERSION}}
cache-dependency-path: "**/*.sum"
- name: Go get dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: |
go get ./...
# This job is responsible for running tests and linting the codebase
test:
name: "Unit testing"
runs-on: ubuntu-latest
container: golang:1
needs: [prepare]
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Ensure full history is checked out
token: ${{ secrets.GHCR_TOKEN }}
- name: Install Go
uses: actions/setup-go@v5
with:
go-version: ${{env.GO_VERSION}}
cache-dependency-path: "**/*.sum"
- name: Install dependencies
run: |
apt-get update
apt-get install ca-certificates make -y
update-ca-certificates
go mod tidy
go get -u -v ./...
go mod tidy -v
- name: Run unit tests
run: |
CI_RUN=${CI} make test
git config --global --add safe.directory /__w/graphql-monitoring-proxy/graphql-monitoring-proxy
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "Update go.mod and go.sum"
commit_options: "--no-verify --signoff"
file_pattern: "go.mod go.sum"
autoupdate:
uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main
with:
go-version: "1.24"
release-workflow: "release.yaml"
+2 -2
View File
@@ -105,5 +105,5 @@ jobs:
summary-always: true
# auto-push only if it's on main branch
auto-push: false
gh-pages-branch: "gh-pages"
benchmark-data-dir-path: "docs"
gh-pages-branch: "main"
benchmark-data-dir-path: "docs/bench"
+60
View File
@@ -0,0 +1,60 @@
name: Release
on:
workflow_dispatch:
push:
paths-ignore:
- "**.md"
- "**/release.yaml"
- "static/**"
- "docs/**"
branches:
- main
permissions:
contents: write
packages: write
deployments: write
jobs:
release:
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
with:
go-version: "1.24"
docker-enabled: true
secrets: inherit
benchmark:
name: Publish Benchmarks
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: Run benchmarks
run: go test -bench=. -benchmem ./... -run=^# | tee output.txt
- name: Store benchmark result
uses: benchmark-action/github-action-benchmark@v1
with:
tool: "go"
output-file-path: output.txt
fail-on-alert: true
github-token: ${{ secrets.GITHUB_TOKEN }}
comment-on-alert: true
summary-always: true
auto-push: false
benchmark-data-dir-path: "docs/bench"
- name: Push benchmark results
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add docs/bench
git diff --staged --quiet || git commit -m "Update benchmark results"
git push
-72
View File
@@ -1,72 +0,0 @@
name: Test and release
on:
workflow_dispatch:
push:
paths-ignore:
- "**/**.md"
- "**/**.yaml"
- "static/**"
branches:
- "main"
env:
GO_VERSION: ">=1.21"
permissions:
# deployments permission to deploy GitHub pages website
deployments: write
# contents permission to update benchmark contents in gh-pages branch
contents: write
jobs:
shared:
uses: telegram-bot-app/ci-scripts/.github/workflows/build-test-publish-inject.yaml@main
with:
enable-code-scans: false
should-deploy: false
secrets:
ghcr-token: ${{ secrets.GHCR_TOKEN }}
test:
name: "Benchmarking the results"
needs: [shared]
runs-on: ubuntu-latest
container: golang:1
# container: github/super-linter:v4
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Go
uses: actions/setup-go@v5
with:
go-version: ${{env.GO_VERSION}}
cache-dependency-path: "**/*.sum"
- name: Install dependencies
run: |
apt-get update
apt-get install ca-certificates make -y
update-ca-certificates
go mod tidy
git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Run benchmark
run: |
go test -bench=. -benchmem ./... -run=^# | tee output.txt
- name: Store benchmark result
uses: benchmark-action/github-action-benchmark@v1
with:
tool: "go"
output-file-path: output.txt
fail-on-alert: true
github-token: ${{ secrets.GITHUB_TOKEN }}
comment-on-alert: true
summary-always: true
# auto-push only if it's on main branch
auto-push: true
gh-pages-branch: "gh-pages"
benchmark-data-dir-path: "docs"
+67
View File
@@ -0,0 +1,67 @@
version: 2
before:
hooks:
- go mod tidy
builds:
- id: graphql-proxy
main: .
binary: graphql-proxy
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ldflags:
- -s -w
archives:
- id: graphql-proxy
formats: [tar.gz]
name_template: "graphql-proxy-{{ .Os }}-{{ .Arch }}"
format_overrides:
- goos: windows
formats: [zip]
files:
- LICENSE
- README.md
checksum:
name_template: "graphql-proxy-checksums.txt"
algorithm: sha256
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
- '^Merge'
- '^WIP'
- '^Update go.mod'
release:
github:
owner: lukaszraczylo
name: graphql-monitoring-proxy
name_template: "version {{.Version}}"
draft: false
prerelease: auto
dockers_v2:
- images:
- "ghcr.io/lukaszraczylo/graphql-monitoring-proxy"
tags:
- "{{ .Version }}"
- "latest"
platforms:
- linux/amd64
- linux/arm64
dockerfile: Dockerfile.goreleaser
extra_files:
- static/app
+6
View File
@@ -0,0 +1,6 @@
FROM gcr.io/distroless/base-debian12:nonroot
ARG TARGETPLATFORM
WORKDIR /go/src/app
COPY --chmod=777 --chown=nonroot:nonroot static/app /go/src/app
COPY ${TARGETPLATFORM}/graphql-proxy /go/src/app/graphql-proxy
ENTRYPOINT ["/go/src/app/graphql-proxy"]
+27
View File
@@ -155,6 +155,7 @@ You can still use the non-prefixed environment variables in the spirit of the ba
| `CACHE_TTL` | The cache TTL | `60` |
| `CACHE_MAX_MEMORY_SIZE` | Maximum memory size for cache in MB | `100` |
| `CACHE_MAX_ENTRIES` | Maximum number of entries in cache | `10000` |
| `CACHE_USE_LRU` | Use LRU eviction algorithm (see [Cache Eviction](#cache-eviction-algorithms)) | `false` |
| `CACHE_PER_USER_DISABLED` | **⚠️ SECURITY**: Disable per-user cache isolation | `false` (**DO NOT** set to `true` in multi-user apps) |
| `ENABLE_REDIS_CACHE` | Enable distributed Redis cache | `false` |
| `CACHE_REDIS_URL` | URL to redis server / cluster endpoint | `localhost:6379` |
@@ -439,6 +440,32 @@ These features ensure the cache runs efficiently even under high load and with l
Since version `0.5.30` the cache is gzipped in the memory, which should optimise the memory usage quite significantly.
Since version `0.15.48` the you can also use the distributed Redis cache.
#### Cache Eviction Algorithms
The proxy supports two cache eviction strategies:
**Standard (default):** Uses Go's `sync.Map` with approximate eviction. When memory limits are reached, entries are evicted based on iteration order (pseudo-random). This is memory-efficient and has excellent concurrent read performance.
**LRU (Least Recently Used):** Uses a proper LRU algorithm with a linked list to track access order. When limits are reached, the least recently accessed entries are evicted first. Enable with `CACHE_USE_LRU=true`.
| Feature | Standard | LRU |
|---------|----------|-----|
| Eviction order | Pseudo-random | Least recently used |
| Read performance | Excellent | Good |
| Memory tracking | Approximate | Precise |
| Best for | High read throughput | Cache hit optimization |
*LRU cache configuration:*
```bash
GMP_ENABLE_GLOBAL_CACHE=true
GMP_CACHE_TTL=300
GMP_CACHE_USE_LRU=true
GMP_CACHE_MAX_MEMORY_SIZE=200
GMP_CACHE_MAX_ENTRIES=5000
```
Use LRU when cache hit rate is critical and you want to ensure frequently accessed data stays cached. Use Standard (default) for maximum read throughput with less memory overhead.
#### Read-only endpoint
You can now specify the read-only GraphQL endpoint by setting the `HOST_GRAPHQL_READONLY` environment variable. The default value is empty, preventing the proxy from using the read-only endpoint for the queries and directing all the requests to the main endpoint specified as `HOST_GRAPHQL`. If the `HOST_GRAPHQL_READONLY` is set, the proxy will use the read-only endpoint for the queries with the `query` type and the main endpoint for the `mutation` type queries. Format of the read-only endpoint is the same as `HOST_GRAPHQL` endpoint, for example `http://localhost:8080/`.
+59 -20
View File
@@ -1277,15 +1277,34 @@
stateEl.classList.remove('loading');
let badgeClass = 'badge-info';
if (data.state === 'closed') badgeClass = 'badge-success';
else if (data.state === 'open') badgeClass = 'badge-danger';
else if (data.state === 'half-open') badgeClass = 'badge-warning';
let stateText = data.state || 'unknown';
stateEl.innerHTML = `<span class="badge ${badgeClass}">${data.state || 'Unknown'}</span>`;
// For cluster mode, determine state from instance counts
if (data.instances_open !== undefined) {
// Cluster mode data
if (data.instances_open > 0) {
stateText = `${data.instances_open} open`;
badgeClass = 'badge-danger';
} else if (data.instances_halfopen > 0) {
stateText = `${data.instances_halfopen} half-open`;
badgeClass = 'badge-warning';
} else if (data.instances_closed > 0) {
stateText = `${data.instances_closed} closed`;
badgeClass = 'badge-success';
}
} else {
// Single instance mode
if (data.state === 'closed') badgeClass = 'badge-success';
else if (data.state === 'open') badgeClass = 'badge-danger';
else if (data.state === 'half-open') badgeClass = 'badge-warning';
}
stateEl.innerHTML = `<span class="badge ${badgeClass}">${stateText}</span>`;
document.getElementById('cb-enabled').textContent = data.enabled ? 'Yes' : 'No';
if (data.counts) {
// Single instance mode with detailed counts
document.getElementById('cb-total-requests').textContent =
(data.counts.requests || 0).toLocaleString();
document.getElementById('cb-total-successes').textContent =
@@ -1296,6 +1315,14 @@
(data.counts.consecutive_successes || 0).toLocaleString();
document.getElementById('cb-consecutive-failures').textContent =
(data.counts.consecutive_failures || 0).toLocaleString();
} else if (data.instances_open !== undefined) {
// Cluster mode - show instance distribution instead
const total = (data.instances_open || 0) + (data.instances_closed || 0) + (data.instances_halfopen || 0);
document.getElementById('cb-total-requests').textContent = total + ' instances';
document.getElementById('cb-total-successes').textContent = (data.instances_closed || 0).toLocaleString();
document.getElementById('cb-total-failures').textContent = (data.instances_open || 0).toLocaleString();
document.getElementById('cb-consecutive-successes').textContent = '--';
document.getElementById('cb-consecutive-failures').textContent = '--';
}
if (data.config) {
@@ -1307,23 +1334,31 @@
function updateCoalescing(data) {
document.getElementById('coalescing-rate').textContent =
(data.backend_savings_pct || 0).toFixed(1) + '%';
document.getElementById('coalescing-total').textContent =
(data.total_requests || 0).toLocaleString();
document.getElementById('coalescing-primary').textContent =
(data.primary_requests || 0).toLocaleString();
document.getElementById('coalescing-coalesced').textContent =
(data.coalesced_requests || 0).toLocaleString();
// Handle both single instance (total_requests) and cluster mode (total_coalesced + total_primary)
const totalRequests = data.total_requests ||
((data.total_coalesced_requests || 0) + (data.total_primary_requests || 0));
document.getElementById('coalescing-total').textContent = totalRequests.toLocaleString();
// Handle both single instance and cluster mode field names
const primaryRequests = data.primary_requests || data.total_primary_requests || 0;
document.getElementById('coalescing-primary').textContent = primaryRequests.toLocaleString();
const coalescedRequests = data.coalesced_requests || data.total_coalesced_requests || 0;
document.getElementById('coalescing-coalesced').textContent = coalescedRequests.toLocaleString();
document.getElementById('coalescing-savings').textContent =
(data.backend_savings_pct || 0).toFixed(1) + '%';
}
function updateRetryBudget(data) {
document.getElementById('retry-tokens').textContent =
data.current_tokens || '--';
document.getElementById('retry-current-tokens').textContent =
data.current_tokens || '--';
document.getElementById('retry-max-tokens').textContent =
data.max_tokens || '--';
// Use explicit undefined check to handle 0 values correctly
const currentTokens = data.current_tokens !== undefined ? data.current_tokens : '--';
const maxTokens = data.max_tokens !== undefined ? data.max_tokens : '--';
document.getElementById('retry-tokens').textContent = currentTokens;
document.getElementById('retry-current-tokens').textContent = currentTokens;
document.getElementById('retry-max-tokens').textContent = maxTokens;
document.getElementById('retry-total').textContent =
(data.total_attempts || 0).toLocaleString();
document.getElementById('retry-denied').textContent =
@@ -1333,13 +1368,17 @@
}
function updateWebSocket(data) {
document.getElementById('ws-connections').textContent =
data.active_connections || 0;
// Handle both single instance (active_connections) and cluster mode (total_connections)
const connections = data.active_connections !== undefined ? data.active_connections :
(data.total_connections !== undefined ? data.total_connections : 0);
document.getElementById('ws-connections').textContent = connections;
}
function updateConnections(data) {
document.getElementById('pool-connections').textContent =
data.active_connections || 0;
// Handle both single instance (active_connections) and cluster mode (total_active)
const connections = data.active_connections !== undefined ? data.active_connections :
(data.total_active !== undefined ? data.total_active : 0);
document.getElementById('pool-connections').textContent = connections;
}
async function resetCoalescing() {
+1 -1
View File
@@ -429,7 +429,7 @@ func (ad *AdminDashboard) getWebSocketStats(c *fiber.Ctx) error {
// clearCache clears the cache
func (ad *AdminDashboard) clearCache(c *fiber.Ctx) error {
// TODO: Implement cache clearing
libpack_cache.CacheClear()
return c.JSON(map[string]interface{}{
"success": true,
"message": "Cache cleared successfully",
+2
View File
@@ -214,6 +214,7 @@ func TestAdminDashboard_GetCacheStats(t *testing.T) {
CacheRedisEnable bool
CacheMaxMemorySize int
CacheMaxEntries int
CacheUseLRU bool
GraphQLQueryCacheSize int
PerUserCacheDisabled bool
}{
@@ -221,6 +222,7 @@ func TestAdminDashboard_GetCacheStats(t *testing.T) {
CacheTTL: 60,
CacheMaxMemorySize: 100,
CacheMaxEntries: 10000,
CacheUseLRU: false,
PerUserCacheDisabled: false,
},
}
+28 -27
View File
@@ -27,6 +27,7 @@ type CacheConfig struct {
Memory struct {
MaxMemorySize int64 `json:"max_memory_size"` // Maximum memory size in bytes
MaxEntries int64 `json:"max_entries"` // Maximum number of entries
UseLRU bool `json:"use_lru"` // Use LRU eviction algorithm instead of random eviction
}
TTL int `json:"ttl"`
IncludeUserContext bool `json:"include_user_context"` // Include user ID and role in cache key
@@ -96,16 +97,6 @@ func CalculateHash(c *fiber.Ctx, userID string, userRole string) string {
return strutil.Md5(cacheKeyData)
}
// CalculateHashLegacy generates a cache hash using only the request body (DEPRECATED).
// This function exists for backward compatibility only and should NOT be used
// in production multi-user applications as it creates a security vulnerability
// where users can see each other's cached data.
//
// Deprecated: Use CalculateHash with user context instead.
func CalculateHashLegacy(c *fiber.Ctx) string {
return strutil.Md5(c.Body())
}
func EnableCache(cfg *CacheConfig) {
if cfg.Logger == nil {
cfg.Logger = libpack_logger.New()
@@ -134,34 +125,41 @@ func EnableCache(cfg *CacheConfig) {
cfg.Client = libpack_cache_redis.NewCacheWrapper(redisClient, cfg.Logger)
}
} else {
// Calculate memory and entry limits
maxMemory := cfg.Memory.MaxMemorySize
if maxMemory <= 0 {
maxMemory = libpack_cache_memory.DefaultMaxMemorySize
}
maxEntries := cfg.Memory.MaxEntries
if maxEntries <= 0 {
maxEntries = libpack_cache_memory.DefaultMaxCacheSize
}
cacheType := "standard"
if cfg.Memory.UseLRU {
cacheType = "LRU"
}
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Using in-memory cache",
Pairs: map[string]interface{}{
"max_memory_size_bytes": cfg.Memory.MaxMemorySize,
"max_entries": cfg.Memory.MaxEntries,
"type": cacheType,
"max_memory_size_bytes": maxMemory,
"max_entries": maxEntries,
},
})
// Use memory size and entry limits if configured, otherwise use defaults
if cfg.Memory.MaxMemorySize > 0 || cfg.Memory.MaxEntries > 0 {
maxMemory := cfg.Memory.MaxMemorySize
if maxMemory <= 0 {
maxMemory = libpack_cache_memory.DefaultMaxMemorySize
}
maxEntries := cfg.Memory.MaxEntries
if maxEntries <= 0 {
maxEntries = libpack_cache_memory.DefaultMaxCacheSize
}
if cfg.Memory.UseLRU {
// Use LRU cache with proper eviction algorithm
cfg.Client = libpack_cache_memory.NewLRUMemoryCache(maxMemory, maxEntries)
} else {
// Use standard sync.Map-based cache
cfg.Client = libpack_cache_memory.NewWithSize(
time.Duration(cfg.TTL)*time.Second,
maxMemory,
maxEntries,
)
} else {
// Backward compatibility
cfg.Client = libpack_cache_memory.New(time.Duration(cfg.TTL) * time.Second)
}
}
config = cfg
@@ -271,6 +269,9 @@ func CacheGetQueries() int64 {
}
func CacheClear() {
if !IsCacheInitialized() {
return
}
config.Client.Clear()
cacheStats = &CacheStats{}
}
+5
View File
@@ -279,3 +279,8 @@ func (c *LRUMemoryCache) GetMemoryUsage() int64 {
func (c *LRUMemoryCache) GetMaxMemorySize() int64 {
return c.maxMemorySize
}
// CountQueries returns the number of entries in the cache
func (c *LRUMemoryCache) CountQueries() int64 {
return atomic.LoadInt64(&c.currentCount)
}
+343
View File
@@ -0,0 +1,343 @@
package libpack_cache_memory
import (
"fmt"
"sync"
"testing"
"time"
"github.com/stretchr/testify/suite"
)
type LRUMemoryCacheTestSuite struct {
suite.Suite
}
func TestLRUMemoryCacheTestSuite(t *testing.T) {
suite.Run(t, new(LRUMemoryCacheTestSuite))
}
func (suite *LRUMemoryCacheTestSuite) TestNewLRUMemoryCache() {
cache := NewLRUMemoryCache(1024*1024, 100) // 1MB, 100 entries
suite.NotNil(cache)
suite.Equal(int64(0), cache.CountQueries())
suite.Equal(int64(0), cache.GetMemoryUsage())
suite.Equal(int64(1024*1024), cache.GetMaxMemorySize())
}
func (suite *LRUMemoryCacheTestSuite) TestSetAndGet() {
cache := NewLRUMemoryCache(1024*1024, 100)
// Set a value
cache.Set("key1", []byte("value1"), 5*time.Second)
// Get the value
val, found := cache.Get("key1")
suite.True(found)
suite.Equal([]byte("value1"), val)
// Get non-existent key
val, found = cache.Get("nonexistent")
suite.False(found)
suite.Nil(val)
}
func (suite *LRUMemoryCacheTestSuite) TestUpdateExisting() {
cache := NewLRUMemoryCache(1024*1024, 100)
cache.Set("key1", []byte("value1"), 5*time.Second)
cache.Set("key1", []byte("value2"), 5*time.Second)
val, found := cache.Get("key1")
suite.True(found)
suite.Equal([]byte("value2"), val)
suite.Equal(int64(1), cache.CountQueries())
}
func (suite *LRUMemoryCacheTestSuite) TestDelete() {
cache := NewLRUMemoryCache(1024*1024, 100)
cache.Set("key1", []byte("value1"), 5*time.Second)
suite.Equal(int64(1), cache.CountQueries())
cache.Delete("key1")
suite.Equal(int64(0), cache.CountQueries())
val, found := cache.Get("key1")
suite.False(found)
suite.Nil(val)
// Delete non-existent key should not panic
cache.Delete("nonexistent")
}
func (suite *LRUMemoryCacheTestSuite) TestClear() {
cache := NewLRUMemoryCache(1024*1024, 100)
cache.Set("key1", []byte("value1"), 5*time.Second)
cache.Set("key2", []byte("value2"), 5*time.Second)
cache.Set("key3", []byte("value3"), 5*time.Second)
suite.Equal(int64(3), cache.CountQueries())
cache.Clear()
suite.Equal(int64(0), cache.CountQueries())
suite.Equal(int64(0), cache.GetMemoryUsage())
_, found := cache.Get("key1")
suite.False(found)
}
func (suite *LRUMemoryCacheTestSuite) TestExpiration() {
cache := NewLRUMemoryCache(1024*1024, 100)
cache.Set("key1", []byte("value1"), 100*time.Millisecond)
// Should exist immediately
val, found := cache.Get("key1")
suite.True(found)
suite.Equal([]byte("value1"), val)
// Wait for expiration
time.Sleep(150 * time.Millisecond)
// Should be expired
val, found = cache.Get("key1")
suite.False(found)
suite.Nil(val)
}
func (suite *LRUMemoryCacheTestSuite) TestEvictionByCount() {
cache := NewLRUMemoryCache(1024*1024, 3) // Max 3 entries
cache.Set("key1", []byte("value1"), 5*time.Second)
cache.Set("key2", []byte("value2"), 5*time.Second)
cache.Set("key3", []byte("value3"), 5*time.Second)
// All 3 should exist
_, found := cache.Get("key1")
suite.True(found)
_, found = cache.Get("key2")
suite.True(found)
_, found = cache.Get("key3")
suite.True(found)
// Add 4th entry - should evict oldest (key1)
cache.Set("key4", []byte("value4"), 5*time.Second)
suite.Equal(int64(3), cache.CountQueries())
// key1 should be evicted (it was least recently used)
_, found = cache.Get("key1")
suite.False(found)
// Others should still exist
_, found = cache.Get("key2")
suite.True(found)
_, found = cache.Get("key3")
suite.True(found)
_, found = cache.Get("key4")
suite.True(found)
}
func (suite *LRUMemoryCacheTestSuite) TestLRUOrder() {
cache := NewLRUMemoryCache(1024*1024, 3) // Max 3 entries
cache.Set("key1", []byte("value1"), 5*time.Second)
cache.Set("key2", []byte("value2"), 5*time.Second)
cache.Set("key3", []byte("value3"), 5*time.Second)
// Access key1 to make it recently used
cache.Get("key1")
// Add key4 - should evict key2 (now least recently used)
cache.Set("key4", []byte("value4"), 5*time.Second)
// key2 should be evicted
_, found := cache.Get("key2")
suite.False(found)
// key1 should still exist (was accessed recently)
_, found = cache.Get("key1")
suite.True(found)
}
func (suite *LRUMemoryCacheTestSuite) TestEvictionByMemory() {
// Small memory limit - 500 bytes
cache := NewLRUMemoryCache(500, 100)
// Each entry has ~64 bytes overhead + key + value
cache.Set("key1", []byte("value1"), 5*time.Second)
cache.Set("key2", []byte("value2"), 5*time.Second)
cache.Set("key3", []byte("value3"), 5*time.Second)
// Add large entry that should trigger eviction
largeValue := make([]byte, 200)
cache.Set("large", largeValue, 5*time.Second)
// Memory should be under limit
suite.LessOrEqual(cache.GetMemoryUsage(), int64(500))
}
func (suite *LRUMemoryCacheTestSuite) TestCompression() {
cache := NewLRUMemoryCache(1024*1024, 100)
// Create a compressible value (> 1KB to trigger compression)
largeValue := make([]byte, 2048)
for i := range largeValue {
largeValue[i] = 'A' // Highly compressible
}
cache.Set("compressed", largeValue, 5*time.Second)
// Should be able to retrieve it correctly
val, found := cache.Get("compressed")
suite.True(found)
suite.Equal(largeValue, val)
}
func (suite *LRUMemoryCacheTestSuite) TestGetStats() {
cache := NewLRUMemoryCache(1024*1024, 100)
cache.Set("key1", []byte("value1"), 5*time.Second)
cache.Set("key2", []byte("value2"), 5*time.Second)
stats := cache.GetStats()
suite.Equal(int64(2), stats["entries"])
suite.Equal(int64(1024*1024), stats["max_memory"])
suite.Equal(int64(100), stats["max_entries"])
suite.NotNil(stats["memory_bytes"])
suite.NotNil(stats["fill_percent"])
}
func (suite *LRUMemoryCacheTestSuite) TestConcurrentAccess() {
cache := NewLRUMemoryCache(10*1024*1024, 1000)
const numGoroutines = 50
const numOperations = 500
var wg sync.WaitGroup
wg.Add(numGoroutines * 3) // readers, writers, deleters
// Writers
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
key := fmt.Sprintf("key-%d-%d", id, j)
value := []byte(fmt.Sprintf("value-%d-%d", id, j))
cache.Set(key, value, 5*time.Second)
}
}(i)
}
// Readers
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
key := fmt.Sprintf("key-%d-%d", id, j)
cache.Get(key)
}
}(i)
}
// Deleters
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
key := fmt.Sprintf("key-%d-%d", id, j%100)
cache.Delete(key)
}
}(i)
}
wg.Wait()
}
func (suite *LRUMemoryCacheTestSuite) TestCleanExpiredEntries() {
cache := NewLRUMemoryCache(1024*1024, 100)
cache.Set("expire1", []byte("value1"), 50*time.Millisecond)
cache.Set("expire2", []byte("value2"), 50*time.Millisecond)
cache.Set("keep", []byte("value3"), 5*time.Second)
suite.Equal(int64(3), cache.CountQueries())
// Wait for some to expire
time.Sleep(100 * time.Millisecond)
// Clean expired entries
cache.CleanExpiredEntries()
// Only "keep" should remain
suite.Equal(int64(1), cache.CountQueries())
_, found := cache.Get("keep")
suite.True(found)
}
func (suite *LRUMemoryCacheTestSuite) TestCountQueries() {
cache := NewLRUMemoryCache(1024*1024, 100)
suite.Equal(int64(0), cache.CountQueries())
cache.Set("key1", []byte("value1"), 5*time.Second)
suite.Equal(int64(1), cache.CountQueries())
cache.Set("key2", []byte("value2"), 5*time.Second)
suite.Equal(int64(2), cache.CountQueries())
cache.Delete("key1")
suite.Equal(int64(1), cache.CountQueries())
cache.Clear()
suite.Equal(int64(0), cache.CountQueries())
}
// Benchmarks
func BenchmarkLRUMemoryCacheSet(b *testing.B) {
cache := NewLRUMemoryCache(100*1024*1024, 100000)
value := []byte("benchmark-value")
b.ResetTimer()
for i := 0; i < b.N; i++ {
key := fmt.Sprintf("key-%d", i)
cache.Set(key, value, 5*time.Second)
}
}
func BenchmarkLRUMemoryCacheGet(b *testing.B) {
cache := NewLRUMemoryCache(100*1024*1024, 100000)
value := []byte("benchmark-value")
// Pre-populate
for i := 0; i < 10000; i++ {
key := fmt.Sprintf("key-%d", i)
cache.Set(key, value, 5*time.Minute)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
key := fmt.Sprintf("key-%d", i%10000)
cache.Get(key)
}
}
func BenchmarkLRUMemoryCacheConcurrent(b *testing.B) {
cache := NewLRUMemoryCache(100*1024*1024, 100000)
value := []byte("benchmark-value")
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
key := fmt.Sprintf("key-%d", i)
if i%2 == 0 {
cache.Set(key, value, 5*time.Second)
} else {
cache.Get(key)
}
i++
}
})
}
+1
View File
@@ -0,0 +1 @@
graphql-monitoring-proxy.raczylo.com
+586
View File
@@ -0,0 +1,586 @@
<!doctype html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GraphQL Monitoring Proxy - High-Performance GraphQL Gateway</title>
<meta
name="description"
content="High-performance GraphQL proxy with monitoring, caching, circuit breaker, rate limiting, and security features. Zero cost monitoring at 30k req/s."
/>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class'
}
</script>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<style>
body { font-family: "Inter", sans-serif; }
code, pre { font-family: "JetBrains Mono", monospace; }
.theme-transition {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
.animate-fade-in-up { animation: fadeInUp 0.6s ease-out; }
.animate-float { animation: float 3s ease-in-out infinite; }
.glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dark .glass {
background: rgba(17, 24, 39, 0.7);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.gradient-text {
background: linear-gradient(135deg, #e879f9 0%, #818cf8 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.dark .gradient-text {
background: linear-gradient(135deg, #f0abfc 0%, #a5b4fc 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.shadow-modern { box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.1); }
.dark .shadow-modern { box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.4); }
html { scroll-behavior: smooth; }
</style>
<script>
if (localStorage.theme === "dark" || (!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
</script>
</head>
<body class="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 theme-transition">
<!-- Navigation -->
<nav class="fixed w-full glass shadow-modern z-50 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="flex justify-between h-16 items-center">
<a href="#" class="flex items-center hover:opacity-80 transition-opacity duration-300 gap-2">
<i class="fas fa-diagram-project text-2xl gradient-text"></i>
<span class="text-xl font-bold gradient-text">graphql-monitoring-proxy</span>
</a>
<div class="hidden md:flex space-x-6">
<a href="#features" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Features</a>
<a href="#installation" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Installation</a>
<a href="#configuration" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Configuration</a>
<a href="#endpoints" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Endpoints</a>
</div>
<div class="flex items-center space-x-4">
<button id="theme-toggle" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="Toggle theme">
<i class="fas fa-moon dark:hidden text-xl"></i>
<i class="fas fa-sun hidden dark:inline text-xl"></i>
</button>
<a href="https://github.com/lukaszraczylo/graphql-monitoring-proxy" target="_blank" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="View on GitHub">
<i class="fab fa-github text-xl"></i>
</a>
<button id="mobile-menu-toggle" class="md:hidden text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="Toggle menu">
<i class="fas fa-bars text-xl" id="menu-open-icon"></i>
<i class="fas fa-times text-xl hidden" id="menu-close-icon"></i>
</button>
</div>
</div>
</div>
<div id="mobile-menu" class="hidden md:hidden border-t border-gray-200 dark:border-gray-700">
<div class="px-4 py-3 space-y-1 bg-white dark:bg-gray-800">
<a href="#features" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Features</a>
<a href="#installation" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Installation</a>
<a href="#configuration" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Configuration</a>
<a href="#endpoints" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Endpoints</a>
</div>
</div>
</nav>
<!-- Hero Section -->
<section class="relative pt-24 sm:pt-32 pb-12 sm:pb-20 overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-br from-fuchsia-50 via-violet-50 to-indigo-50 dark:from-gray-900 dark:via-fuchsia-900/20 dark:to-indigo-900/20 theme-transition"></div>
<div class="absolute top-0 -left-4 w-72 h-72 bg-fuchsia-300 dark:bg-fuchsia-500 rounded-full mix-blend-multiply dark:mix-blend-soft-light filter blur-xl opacity-20 animate-float"></div>
<div class="absolute top-0 -right-4 w-72 h-72 bg-violet-300 dark:bg-violet-500 rounded-full mix-blend-multiply dark:mix-blend-soft-light filter blur-xl opacity-20 animate-float" style="animation-delay: 1s;"></div>
<div class="absolute -bottom-8 left-20 w-72 h-72 bg-indigo-300 dark:bg-indigo-500 rounded-full mix-blend-multiply dark:mix-blend-soft-light filter blur-xl opacity-20 animate-float" style="animation-delay: 2s;"></div>
<div class="relative max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center">
<div class="mb-8 sm:mb-10 flex justify-center animate-fade-in-up">
<div class="text-8xl sm:text-9xl animate-float">
<i class="fas fa-diagram-project gradient-text"></i>
</div>
</div>
<h1 class="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-gray-100 mb-4 sm:mb-6 leading-tight animate-fade-in-up" style="animation-delay: 0.1s;">
GraphQL Monitoring<br /><span class="gradient-text">Proxy</span>
</h1>
<p class="text-base sm:text-lg md:text-xl text-gray-600 dark:text-gray-300 mb-8 sm:mb-10 max-w-2xl mx-auto leading-relaxed px-4 animate-fade-in-up" style="animation-delay: 0.2s;">
High-performance GraphQL gateway with monitoring, caching, circuit breaker, rate limiting, and security. Tested at 30k req/s using 10MB RAM.
</p>
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4 justify-center mb-8 sm:mb-12 px-4 animate-fade-in-up" style="animation-delay: 0.3s;">
<a href="#installation" class="group relative bg-gradient-to-r from-fuchsia-500 to-indigo-600 hover:from-fuchsia-600 hover:to-indigo-700 text-white px-8 py-3 rounded-lg font-medium transition-all duration-300 min-h-[48px] flex items-center justify-center shadow-lg hover:shadow-xl hover:scale-105">
<span class="relative z-10">Get Started</span>
</a>
<a href="https://github.com/lukaszraczylo/graphql-monitoring-proxy" class="group glass hover:shadow-lg text-gray-900 dark:text-gray-100 px-8 py-3 rounded-lg font-medium transition-all duration-300 min-h-[48px] flex items-center justify-center hover:scale-105">
<i class="fab fa-github mr-2"></i>View on GitHub
</a>
</div>
<div class="flex flex-wrap justify-center gap-2 sm:gap-4 text-sm px-4">
<img src="https://img.shields.io/github/v/release/lukaszraczylo/graphql-monitoring-proxy" alt="Version" class="h-5" />
<img src="https://img.shields.io/github/license/lukaszraczylo/graphql-monitoring-proxy" alt="License" class="h-5" />
<img src="https://goreportcard.com/badge/github.com/lukaszraczylo/graphql-monitoring-proxy" alt="Go Report" class="h-5" />
</div>
<div class="mt-12 sm:mt-16 max-w-3xl mx-auto px-4 animate-fade-in-up" style="animation-delay: 0.4s;">
<div class="relative group">
<div class="absolute -inset-1 bg-gradient-to-r from-fuchsia-500 to-indigo-600 rounded-xl blur opacity-25 group-hover:opacity-50 transition duration-500"></div>
<div class="relative bg-gray-900 rounded-xl p-6 text-left">
<div class="flex items-center gap-2 mb-4">
<div class="w-3 h-3 rounded-full bg-red-500"></div>
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
<div class="w-3 h-3 rounded-full bg-green-500"></div>
<span class="ml-2 text-gray-400 text-sm">terminal</span>
</div>
<pre class="text-gray-100 text-sm sm:text-base overflow-x-auto"><code><span class="text-gray-400"># Run with Docker</span>
<span class="text-fuchsia-400">$</span> docker pull ghcr.io/lukaszraczylo/graphql-monitoring-proxy:latest
<span class="text-gray-400"># Configure and run</span>
<span class="text-fuchsia-400">$</span> docker run -p 8080:8080 -p 9393:9393 \
-e GMP_HOST_GRAPHQL=http://your-graphql:4000/ \
ghcr.io/lukaszraczylo/graphql-monitoring-proxy:latest</code></pre>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Features Section -->
<section id="features" class="py-12 sm:py-16 md:py-20 bg-white dark:bg-gray-900 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center mb-8 sm:mb-12">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Features</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Enterprise-grade GraphQL gateway at zero cost</p>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-fuchsia-500 to-fuchsia-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-chart-line text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Prometheus Metrics</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Full observability with query timing, user tracking, and operation metrics</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-violet-500 to-violet-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-bolt text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Smart Caching</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Memory-aware caching with LRU eviction, compression, and Redis support</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-indigo-500 to-indigo-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-shield-halved text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Circuit Breaker</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Automatic failure detection with graceful degradation and cached fallbacks</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-rose-500 to-rose-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-gauge-high text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Rate Limiting</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Role-based rate limiting with JWT extraction and burst control</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-layer-group text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Request Coalescing</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Deduplicate concurrent identical queries, reducing backend load 50-80%</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-plug text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">WebSocket Support</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Native GraphQL subscriptions with bidirectional proxying</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-cyan-500 to-cyan-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-eye-slash text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Introspection Blocking</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Block schema introspection with configurable allowlists</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-orange-500 to-orange-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-satellite-dish text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">OpenTelemetry Tracing</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Distributed tracing with configurable OTLP collector endpoint</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-pink-500 to-pink-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-desktop text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Admin Dashboard</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Real-time web UI for monitoring health, metrics, and controls</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Installation Section -->
<section id="installation" class="py-12 sm:py-16 md:py-20 bg-gray-50 dark:bg-gray-800 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center mb-8 sm:mb-12">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Installation</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Deploy in seconds</p>
</div>
<div class="max-w-3xl mx-auto space-y-6">
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3 flex items-center">
<i class="fab fa-docker mr-2 text-blue-500"></i>
Docker
</h3>
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto"><code>docker pull ghcr.io/lukaszraczylo/graphql-monitoring-proxy:latest</code></pre>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3 flex items-center">
<i class="fas fa-download mr-2 text-fuchsia-500"></i>
Binary Download
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-3">Download from the <a href="https://github.com/lukaszraczylo/graphql-monitoring-proxy/releases/latest" class="text-fuchsia-600 dark:text-fuchsia-400 hover:underline">releases page</a>.</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Supported: Darwin ARM64/AMD64, Linux ARM64/AMD64, Windows AMD64</p>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3 flex items-center">
<i class="fas fa-dharmachakra mr-2 text-indigo-500"></i>
Kubernetes
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-3">Example manifests available in the repository:</p>
<ul class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
<li><a href="https://github.com/lukaszraczylo/graphql-monitoring-proxy/blob/main/static/kubernetes-deployment.yaml" class="text-fuchsia-600 dark:text-fuchsia-400 hover:underline">Standalone deployment</a></li>
<li><a href="https://github.com/lukaszraczylo/graphql-monitoring-proxy/blob/main/static/kubernetes-single-deployment.yaml" class="text-fuchsia-600 dark:text-fuchsia-400 hover:underline">Combined deployment</a></li>
<li><a href="https://github.com/lukaszraczylo/graphql-monitoring-proxy/blob/main/static/kubernetes-single-deployment-with-ro.yaml" class="text-fuchsia-600 dark:text-fuchsia-400 hover:underline">Combined with read-only replica</a></li>
</ul>
</div>
</div>
</div>
</section>
<!-- Endpoints Section -->
<section id="endpoints" class="py-12 sm:py-16 md:py-20 bg-white dark:bg-gray-900 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center mb-8 sm:mb-12">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Endpoints</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Available HTTP endpoints</p>
</div>
<div class="max-w-3xl mx-auto">
<div class="glass p-6 rounded-xl">
<div class="space-y-4">
<div class="flex items-start gap-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<code class="text-fuchsia-600 dark:text-fuchsia-400 font-medium whitespace-nowrap">:8080/*</code>
<span class="text-gray-600 dark:text-gray-400">GraphQL passthrough endpoint</span>
</div>
<div class="flex items-start gap-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<code class="text-fuchsia-600 dark:text-fuchsia-400 font-medium whitespace-nowrap">:8080/admin</code>
<span class="text-gray-600 dark:text-gray-400">Admin dashboard UI</span>
</div>
<div class="flex items-start gap-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<code class="text-fuchsia-600 dark:text-fuchsia-400 font-medium whitespace-nowrap">:9393/metrics</code>
<span class="text-gray-600 dark:text-gray-400">Prometheus metrics endpoint</span>
</div>
<div class="flex items-start gap-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<code class="text-fuchsia-600 dark:text-fuchsia-400 font-medium whitespace-nowrap">:8080/healthz</code>
<span class="text-gray-600 dark:text-gray-400">Health check endpoint</span>
</div>
<div class="flex items-start gap-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<code class="text-fuchsia-600 dark:text-fuchsia-400 font-medium whitespace-nowrap">:8080/livez</code>
<span class="text-gray-600 dark:text-gray-400">Liveness probe endpoint</span>
</div>
<div class="flex items-start gap-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<code class="text-fuchsia-600 dark:text-fuchsia-400 font-medium whitespace-nowrap">:9090/api/*</code>
<span class="text-gray-600 dark:text-gray-400">Management API (ban/unban, cache, circuit breaker)</span>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Configuration Section -->
<section id="configuration" class="py-12 sm:py-16 md:py-20 bg-gray-50 dark:bg-gray-800 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center mb-8 sm:mb-12">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Configuration</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Environment variables (prefix with GMP_ recommended)</p>
</div>
<div class="max-w-4xl mx-auto space-y-6">
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
<i class="fas fa-server mr-2 text-fuchsia-500"></i>
Core Settings
</h3>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
<th class="pb-2 pr-4">Variable</th>
<th class="pb-2 pr-4">Description</th>
<th class="pb-2">Default</th>
</tr>
</thead>
<tbody class="text-gray-600 dark:text-gray-300">
<tr class="border-b border-gray-100 dark:border-gray-700">
<td class="py-2 pr-4"><code class="text-fuchsia-600 dark:text-fuchsia-400">HOST_GRAPHQL</code></td>
<td class="py-2 pr-4">GraphQL backend URL</td>
<td class="py-2">http://localhost/</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-700">
<td class="py-2 pr-4"><code class="text-fuchsia-600 dark:text-fuchsia-400">PORT_GRAPHQL</code></td>
<td class="py-2 pr-4">Proxy listen port</td>
<td class="py-2">8080</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-700">
<td class="py-2 pr-4"><code class="text-fuchsia-600 dark:text-fuchsia-400">MONITORING_PORT</code></td>
<td class="py-2 pr-4">Metrics endpoint port</td>
<td class="py-2">9393</td>
</tr>
<tr>
<td class="py-2 pr-4"><code class="text-fuchsia-600 dark:text-fuchsia-400">LOG_LEVEL</code></td>
<td class="py-2 pr-4">Logging level</td>
<td class="py-2">info</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
<i class="fas fa-bolt mr-2 text-violet-500"></i>
Caching
</h3>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
<th class="pb-2 pr-4">Variable</th>
<th class="pb-2 pr-4">Description</th>
<th class="pb-2">Default</th>
</tr>
</thead>
<tbody class="text-gray-600 dark:text-gray-300">
<tr class="border-b border-gray-100 dark:border-gray-700">
<td class="py-2 pr-4"><code class="text-fuchsia-600 dark:text-fuchsia-400">ENABLE_GLOBAL_CACHE</code></td>
<td class="py-2 pr-4">Enable memory cache</td>
<td class="py-2">false</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-700">
<td class="py-2 pr-4"><code class="text-fuchsia-600 dark:text-fuchsia-400">CACHE_TTL</code></td>
<td class="py-2 pr-4">Cache TTL in seconds</td>
<td class="py-2">60</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-700">
<td class="py-2 pr-4"><code class="text-fuchsia-600 dark:text-fuchsia-400">CACHE_MAX_MEMORY_SIZE</code></td>
<td class="py-2 pr-4">Max memory in MB</td>
<td class="py-2">100</td>
</tr>
<tr>
<td class="py-2 pr-4"><code class="text-fuchsia-600 dark:text-fuchsia-400">ENABLE_REDIS_CACHE</code></td>
<td class="py-2 pr-4">Enable Redis cache</td>
<td class="py-2">false</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center">
<i class="fas fa-shield-halved mr-2 text-indigo-500"></i>
Circuit Breaker
</h3>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
<th class="pb-2 pr-4">Variable</th>
<th class="pb-2 pr-4">Description</th>
<th class="pb-2">Default</th>
</tr>
</thead>
<tbody class="text-gray-600 dark:text-gray-300">
<tr class="border-b border-gray-100 dark:border-gray-700">
<td class="py-2 pr-4"><code class="text-fuchsia-600 dark:text-fuchsia-400">ENABLE_CIRCUIT_BREAKER</code></td>
<td class="py-2 pr-4">Enable circuit breaker</td>
<td class="py-2">false</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-700">
<td class="py-2 pr-4"><code class="text-fuchsia-600 dark:text-fuchsia-400">CIRCUIT_MAX_FAILURES</code></td>
<td class="py-2 pr-4">Failures before trip</td>
<td class="py-2">10</td>
</tr>
<tr>
<td class="py-2 pr-4"><code class="text-fuchsia-600 dark:text-fuchsia-400">CIRCUIT_TIMEOUT_SECONDS</code></td>
<td class="py-2 pr-4">Recovery timeout</td>
<td class="py-2">60</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="text-center mt-8">
<a href="https://github.com/lukaszraczylo/graphql-monitoring-proxy#configuration" class="inline-flex items-center text-fuchsia-600 dark:text-fuchsia-400 hover:underline font-medium">
View all configuration options
<i class="fas fa-arrow-right ml-2"></i>
</a>
</div>
</div>
</div>
</section>
<!-- Performance Section -->
<section class="py-12 sm:py-16 md:py-20 bg-white dark:bg-gray-900 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center mb-8 sm:mb-12">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Performance</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Battle-tested at scale</p>
</div>
<div class="max-w-3xl mx-auto">
<div class="grid sm:grid-cols-3 gap-4 text-center">
<div class="glass p-6 rounded-xl">
<div class="text-4xl font-bold gradient-text mb-2">100k+</div>
<p class="text-sm text-gray-600 dark:text-gray-400">Requests/second</p>
</div>
<div class="glass p-6 rounded-xl">
<div class="text-4xl font-bold gradient-text mb-2">10MB</div>
<p class="text-sm text-gray-600 dark:text-gray-400">RAM usage</p>
</div>
<div class="glass p-6 rounded-xl">
<div class="text-4xl font-bold gradient-text mb-2">0.1%</div>
<p class="text-sm text-gray-600 dark:text-gray-400">CPU usage</p>
</div>
</div>
<div class="mt-8 text-center">
<a href="bench/" class="inline-flex items-center text-fuchsia-600 dark:text-fuchsia-400 hover:underline font-medium">
View benchmarks
<i class="fas fa-arrow-right ml-2"></i>
</a>
</div>
</div>
</div>
</section>
<!-- Footer -->
<footer class="py-8 bg-gray-100 dark:bg-gray-800 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="flex flex-col sm:flex-row justify-between items-center gap-4">
<div class="flex items-center gap-2">
<i class="fas fa-diagram-project text-xl gradient-text"></i>
<span class="font-semibold gradient-text">graphql-monitoring-proxy</span>
</div>
<div class="flex items-center gap-6">
<a href="https://github.com/lukaszraczylo/graphql-monitoring-proxy" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">
<i class="fab fa-github text-xl"></i>
</a>
<a href="https://github.com/lukaszraczylo/graphql-monitoring-proxy/issues" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 text-sm">
Issues
</a>
<a href="https://github.com/lukaszraczylo/graphql-monitoring-proxy/releases" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 text-sm">
Releases
</a>
</div>
<p class="text-gray-500 dark:text-gray-400 text-sm">MIT License</p>
</div>
</div>
</footer>
<script>
// Theme toggle
document.getElementById('theme-toggle').addEventListener('click', function() {
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.theme = 'light';
} else {
document.documentElement.classList.add('dark');
localStorage.theme = 'dark';
}
});
// Mobile menu toggle
document.getElementById('mobile-menu-toggle').addEventListener('click', function() {
const menu = document.getElementById('mobile-menu');
const openIcon = document.getElementById('menu-open-icon');
const closeIcon = document.getElementById('menu-close-icon');
menu.classList.toggle('hidden');
openIcon.classList.toggle('hidden');
closeIcon.classList.toggle('hidden');
});
// Close mobile menu when clicking a link
document.querySelectorAll('#mobile-menu a').forEach(link => {
link.addEventListener('click', () => {
document.getElementById('mobile-menu').classList.add('hidden');
document.getElementById('menu-open-icon').classList.remove('hidden');
document.getElementById('menu-close-icon').classList.add('hidden');
});
});
</script>
</body>
</html>
-109
View File
@@ -106,117 +106,8 @@ func (e *ProxyError) WithMetadata(key string, value interface{}) *ProxyError {
return e
}
// Common error constructors
// NewConnectionError creates a connection-related error
func NewConnectionError(err error) *ProxyError {
code := ErrCodeConnectionRefused
if err != nil {
errStr := err.Error()
if contains(errStr, "reset") {
code = ErrCodeConnectionReset
}
}
return NewProxyError(code, "Failed to connect to backend", 502, true).
WithCause(err)
}
// NewTimeoutError creates a timeout error
func NewTimeoutError(err error) *ProxyError {
return NewProxyError(ErrCodeTimeout, "Request timed out", 504, false).
WithCause(err)
}
// NewCircuitOpenError creates a circuit breaker open error
func NewCircuitOpenError() *ProxyError {
return NewProxyError(ErrCodeCircuitOpen, "Service temporarily unavailable due to circuit breaker", 503, false).
WithDetails("The backend service is currently experiencing issues. Please try again later.")
}
// NewRateLimitError creates a rate limit error
func NewRateLimitError(userID, role string) *ProxyError {
return NewProxyError(ErrCodeRateLimited, "Rate limit exceeded", 429, false).
WithDetails("You have exceeded the rate limit for your role").
WithMetadata("user_id", userID).
WithMetadata("role", role)
}
// NewBackendError creates a backend error from status code
func NewBackendError(statusCode int, body string) *ProxyError {
code := ErrCodeBackendError
message := "Backend returned an error"
retryable := false
switch {
case statusCode == 429:
code = ErrCodeRateLimited
message = "Backend rate limit exceeded"
retryable = true
case statusCode == 503:
code = ErrCodeServiceUnavailable
message = "Backend service unavailable"
retryable = true
case statusCode == 502 || statusCode == 504:
code = ErrCodeBadGateway
message = "Bad gateway"
retryable = true
case statusCode >= 500:
code = ErrCodeBackendError
message = "Backend server error"
retryable = true
case statusCode == 404:
code = ErrCodeNotFound
message = "Resource not found"
case statusCode == 403:
code = ErrCodeForbidden
message = "Access forbidden"
case statusCode == 401:
code = ErrCodeUnauthorized
message = "Unauthorized"
case statusCode >= 400:
code = ErrCodeInvalidRequest
message = "Invalid request"
}
return NewProxyError(code, message, statusCode, retryable).
WithMetadata("backend_status", statusCode).
WithMetadata("backend_body", truncateString(body, 500))
}
// NewInvalidResponseError creates an invalid response error
func NewInvalidResponseError(details string) *ProxyError {
return NewProxyError(ErrCodeInvalidResponse, "Backend returned invalid response", 502, false).
WithDetails(details)
}
// NewInternalError creates an internal error
func NewInternalError(err error) *ProxyError {
return NewProxyError(ErrCodeInternalError, "Internal proxy error", 500, false).
WithCause(err)
}
// NewContextCanceledError creates a context canceled error
func NewContextCanceledError() *ProxyError {
return NewProxyError(ErrCodeContextCanceled, "Request canceled", 499, false).
WithDetails("The request was canceled by the client")
}
// Helper functions
func contains(s, substr string) bool {
return len(s) > 0 && len(substr) > 0 && len(s) >= len(substr) && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || containsMiddle(s, substr)))
}
func containsMiddle(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
+4 -4
View File
@@ -20,7 +20,7 @@ require (
github.com/lukaszraczylo/ask v0.0.0-20240916204100-6e9ef53a62d9
github.com/lukaszraczylo/go-ratecounter v0.1.12
github.com/lukaszraczylo/go-simple-graphql v1.2.89
github.com/redis/go-redis/v9 v9.17.1
github.com/redis/go-redis/v9 v9.17.2
github.com/sony/gobreaker v1.0.0
github.com/stretchr/testify v1.11.1
github.com/valyala/fasthttp v1.68.0
@@ -47,7 +47,7 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
@@ -67,8 +67,8 @@ require (
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+8 -8
View File
@@ -64,8 +64,8 @@ github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -84,8 +84,8 @@ github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byF
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.17.1 h1:7tl732FjYPRT9H9aNfyTwKg9iTETjWjGKEJ2t/5iWTs=
github.com/redis/go-redis/v9 v9.17.1/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/savsgio/gotils v0.0.0-20250924091648-bce9a52d7761 h1:McifyVxygw1d67y6vxUqls2D46J8W9nrki9c8c0eVvE=
@@ -144,10 +144,10 @@ golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846 h1:ZdyUkS9po3H7G0tuh955QVyyotWvOD4W0aEapeGeUYk=
google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846/go.mod h1:Fk4kyraUvqD7i5H6S43sj2W98fbZa75lpZz/eUyhfO0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 h1:Wgl1rcDNThT+Zn47YyCXOXyX/COgMTIdhJ717F0l4xk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
+249
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
@@ -783,3 +784,251 @@ func (suite *Tests) TestRequestCoalescingIntegration() {
}
})
}
// TestRetryBudgetIntegration tests that retry budget correctly limits retry attempts
func (suite *Tests) TestRetryBudgetIntegration() {
// Initialize a retry budget with limited tokens for testing
budgetCtx := context.Background()
testBudget := NewRetryBudgetWithContext(budgetCtx, RetryBudgetConfig{
MaxTokens: 3, // Only allow 3 retries total
TokensPerSecond: 0, // Don't refill during test
Enabled: true,
}, cfg.Logger)
// Replace global retry budget
originalBudget := retryBudget
retryBudget = testBudget
defer func() {
testBudget.Shutdown()
retryBudget = originalBudget
}()
suite.Run("retry_budget_limits_retries", func() {
testBudget.Reset()
// Verify retry budget is set and works correctly
rb := GetRetryBudget()
suite.NotNil(rb, "Retry budget should be set")
suite.True(rb.enabled, "Retry budget should be enabled")
suite.T().Logf("Retry budget: enabled=%v, tokens=%d", rb.enabled, rb.currentTokens.Load())
// Test that AllowRetry consumes tokens correctly
initialTokens := rb.currentTokens.Load()
suite.Equal(int64(3), initialTokens, "Should start with 3 tokens")
// First 3 retries should be allowed
suite.True(rb.AllowRetry(), "First retry should be allowed")
suite.True(rb.AllowRetry(), "Second retry should be allowed")
suite.True(rb.AllowRetry(), "Third retry should be allowed")
// Fourth retry should be denied (tokens exhausted)
suite.False(rb.AllowRetry(), "Fourth retry should be denied - budget exhausted")
// Verify stats
stats := rb.GetStats()
suite.Equal(int64(4), stats["total_attempts"].(int64), "Should have 4 total attempts")
suite.Equal(int64(3), stats["allowed_retries"].(int64), "Should have 3 allowed retries")
suite.Equal(int64(1), stats["denied_retries"].(int64), "Should have 1 denied retry")
suite.T().Logf("Retry budget stats: total=%d, allowed=%d, denied=%d",
stats["total_attempts"], stats["allowed_retries"], stats["denied_retries"])
})
suite.Run("retry_budget_exhaustion", func() {
// Create a new budget with only 1 token
testBudget.Shutdown()
budgetCtx2 := context.Background()
testBudget2 := NewRetryBudgetWithContext(budgetCtx2, RetryBudgetConfig{
MaxTokens: 1, // Only allow 1 retry
TokensPerSecond: 0, // Don't refill
Enabled: true,
}, cfg.Logger)
retryBudget = testBudget2
defer func() {
testBudget2.Shutdown()
}()
// Test budget exhaustion with 1 token
rb := GetRetryBudget()
suite.NotNil(rb, "Retry budget should be set")
suite.Equal(int64(1), rb.currentTokens.Load(), "Should start with 1 token")
// First retry should be allowed
suite.True(rb.AllowRetry(), "First retry should be allowed")
// Second retry should be denied (only 1 token available)
suite.False(rb.AllowRetry(), "Second retry should be denied - budget exhausted")
// Verify stats
stats := rb.GetStats()
suite.Equal(int64(2), stats["total_attempts"].(int64), "Should have 2 total attempts")
suite.Equal(int64(1), stats["allowed_retries"].(int64), "Should have 1 allowed retry")
suite.Equal(int64(1), stats["denied_retries"].(int64), "Should have 1 denied retry")
suite.T().Logf("Retry budget stats: total=%d, allowed=%d, denied=%d",
stats["total_attempts"], stats["allowed_retries"], stats["denied_retries"])
})
}
// TestConnectionPoolStatsIntegration tests that connection pool stats are tracked
func (suite *Tests) TestConnectionPoolStatsIntegration() {
// Save original config
originalClient := cfg.Client.FastProxyClient
originalHostGraphQL := cfg.Server.HostGraphQL
originalCoalescing := cfg.RequestCoalescing.Enable
// Restore after test
defer func() {
cfg.Client.FastProxyClient = originalClient
cfg.Server.HostGraphQL = originalHostGraphQL
cfg.RequestCoalescing.Enable = originalCoalescing
}()
// Disable request coalescing for accurate tracking
cfg.RequestCoalescing.Enable = false
suite.Run("connection_success_tracked", func() {
// Create test server that succeeds
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"data":{"test":"success"}}`))
}))
defer server.Close()
cfg.Server.HostGraphQL = server.URL
cfg.Client.ClientTimeout = 5
cfg.Client.FastProxyClient = createFasthttpClient(cfg)
// Initialize connection pool
InitializeConnectionPool(cfg.Client.FastProxyClient)
defer ShutdownConnectionPool()
poolMgr := GetConnectionPoolManager()
suite.NotNil(poolMgr, "Connection pool manager should be initialized")
// Get stats before
statsBefore := poolMgr.GetConnectionStats()
successBefore := statsBefore["total_connections"].(int64)
// Make a successful request
reqCtx := &fasthttp.RequestCtx{}
reqCtx.Request.SetRequestURI("/graphql")
reqCtx.Request.Header.SetMethod("POST")
reqCtx.Request.Header.Set("Content-Type", "application/json")
reqCtx.Request.SetBody([]byte(`{"query": "query { test }"}`))
ctx := suite.app.AcquireCtx(reqCtx)
err := proxyTheRequest(ctx, cfg.Server.HostGraphQL)
suite.app.ReleaseCtx(ctx)
suite.Nil(err, "Request should succeed")
// Get stats after
statsAfter := poolMgr.GetConnectionStats()
successAfter := statsAfter["total_connections"].(int64)
suite.Greater(successAfter, successBefore,
"Total connections should increase after successful request")
})
suite.Run("connection_failure_tracked_on_5xx", func() {
// Create test server that returns 503
// Note: 503 triggers retry which records failures
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"errors":[{"message":"Service unavailable"}]}`))
}))
defer server.Close()
cfg.Server.HostGraphQL = server.URL
cfg.Client.ClientTimeout = 2
cfg.Client.FastProxyClient = createFasthttpClient(cfg)
// Initialize connection pool
InitializeConnectionPool(cfg.Client.FastProxyClient)
defer ShutdownConnectionPool()
poolMgr := GetConnectionPoolManager()
suite.NotNil(poolMgr, "Connection pool manager should be initialized")
// Get stats before
statsBefore := poolMgr.GetConnectionStats()
failuresBefore := statsBefore["connection_failures"].(int64)
// Make a failing request (503 is retryable, so it will retry and track failures)
reqCtx := &fasthttp.RequestCtx{}
reqCtx.Request.SetRequestURI("/graphql")
reqCtx.Request.Header.SetMethod("POST")
reqCtx.Request.Header.Set("Content-Type", "application/json")
reqCtx.Request.SetBody([]byte(`{"query": "query { fail }"}`))
ctx := suite.app.AcquireCtx(reqCtx)
_ = proxyTheRequest(ctx, cfg.Server.HostGraphQL)
suite.app.ReleaseCtx(ctx)
// Get stats after - should have failures from retry attempts
statsAfter := poolMgr.GetConnectionStats()
failuresAfter := statsAfter["connection_failures"].(int64)
suite.Greater(failuresAfter, failuresBefore,
"Connection failures should increase after 5xx responses that trigger retries")
suite.T().Logf("Connection failures: before=%d, after=%d",
failuresBefore, failuresAfter)
})
suite.Run("stats_reflect_request_outcomes", func() {
// This test verifies that connection stats properly reflect the
// combination of successes and failures over multiple requests
// Start with a fresh server
var requestCount atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
count := requestCount.Add(1)
// First 2 requests succeed, rest fail
if count <= 2 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"data":{"test":"success"}}`))
} else {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"errors":[{"message":"Error"}]}`))
}
}))
defer server.Close()
cfg.Server.HostGraphQL = server.URL
cfg.Client.ClientTimeout = 2
cfg.Client.FastProxyClient = createFasthttpClient(cfg)
// Initialize connection pool
InitializeConnectionPool(cfg.Client.FastProxyClient)
defer ShutdownConnectionPool()
poolMgr := GetConnectionPoolManager()
suite.NotNil(poolMgr, "Connection pool manager should be initialized")
// Make 2 successful requests
for i := 0; i < 2; i++ {
reqCtx := &fasthttp.RequestCtx{}
reqCtx.Request.SetRequestURI("/graphql")
reqCtx.Request.Header.SetMethod("POST")
reqCtx.Request.Header.Set("Content-Type", "application/json")
reqCtx.Request.SetBody([]byte(`{"query": "query { test }"}`))
ctx := suite.app.AcquireCtx(reqCtx)
_ = proxyTheRequest(ctx, cfg.Server.HostGraphQL)
suite.app.ReleaseCtx(ctx)
}
// Get stats after successes
statsAfterSuccess := poolMgr.GetConnectionStats()
totalConnections := statsAfterSuccess["total_connections"].(int64)
suite.GreaterOrEqual(totalConnections, int64(2),
"Should have at least 2 successful connections tracked")
suite.T().Logf("Total connections after 2 successful requests: %d", totalConnections)
})
}
+8
View File
@@ -131,6 +131,7 @@ func parseConfig() {
c.Cache.CacheTTL = getDetailsFromEnv("CACHE_TTL", 60)
c.Cache.CacheMaxMemorySize = getDetailsFromEnv("CACHE_MAX_MEMORY_SIZE", 100) // Default 100MB
c.Cache.CacheMaxEntries = getDetailsFromEnv("CACHE_MAX_ENTRIES", 10000) // Default 10000 entries
c.Cache.CacheUseLRU = getDetailsFromEnv("CACHE_USE_LRU", false) // Use LRU eviction algorithm
// GraphQL query parsing cache - auto-calculate based on CPU cores if not set
c.Cache.GraphQLQueryCacheSize = getDetailsFromEnv("GRAPHQL_QUERY_CACHE_SIZE", runtime.GOMAXPROCS(0)*250)
@@ -390,9 +391,16 @@ func parseConfig() {
// Memory cache configurations
cacheConfig.Memory.MaxMemorySize = int64(cfg.Cache.CacheMaxMemorySize) * 1024 * 1024 // Convert MB to bytes
cacheConfig.Memory.MaxEntries = int64(cfg.Cache.CacheMaxEntries)
cacheConfig.Memory.UseLRU = cfg.Cache.CacheUseLRU
cacheType := "standard"
if cfg.Cache.CacheUseLRU {
cacheType = "LRU"
}
cfg.Logger.Info(&libpack_logging.LogMessage{
Message: "Configuring memory cache with limits",
Pairs: map[string]interface{}{
"type": cacheType,
"max_memory_mb": cfg.Cache.CacheMaxMemorySize,
"max_entries": cfg.Cache.CacheMaxEntries,
},
+17
View File
@@ -519,7 +519,10 @@ func (ma *MetricsAggregator) aggregateStats(instances []InstanceMetrics) map[str
totalRetryAllowed int64
totalRetryDenied int64
totalRetryAttempts int64
totalCurrentTokens int64
totalMaxTokens int64
retryBudgetEnabled = false
retryTokensPerSec float64 // Use max tokens_per_sec from any instance
// Circuit breaker stats
cbOpenCount int
@@ -645,6 +648,17 @@ func (ma *MetricsAggregator) aggregateStats(instances []InstanceMetrics) map[str
if attempts, ok := instance.RetryBudget["total_attempts"].(float64); ok {
totalRetryAttempts += int64(attempts)
}
if currentTokens, ok := instance.RetryBudget["current_tokens"].(float64); ok {
totalCurrentTokens += int64(currentTokens)
}
if maxTokens, ok := instance.RetryBudget["max_tokens"].(float64); ok {
totalMaxTokens += int64(maxTokens)
}
if tokensPerSec, ok := instance.RetryBudget["tokens_per_sec"].(float64); ok {
if tokensPerSec > retryTokensPerSec {
retryTokensPerSec = tokensPerSec
}
}
}
// Aggregate circuit breaker stats
@@ -748,6 +762,9 @@ func (ma *MetricsAggregator) aggregateStats(instances []InstanceMetrics) map[str
"denied_retries": totalRetryDenied,
"total_attempts": totalRetryAttempts,
"denial_rate_pct": retryDenialRate,
"current_tokens": totalCurrentTokens,
"max_tokens": totalMaxTokens,
"tokens_per_sec": retryTokensPerSec,
},
"circuit_breaker": map[string]interface{}{
"enabled": circuitBreakerEnabled,
-4
View File
@@ -9,7 +9,3 @@ func (ms *MetricsSetup) RegisterDefaultMetrics() {
ms.RegisterMetricsCounter(MetricsCacheMiss, nil)
ms.RegisterMetricsCounter(MetricsQueriesCached, nil)
}
func (ms *MetricsSetup) RegisterGoMetrics() {
// TODO: metrics.WriteProcessMetrics(ms.metrics_set)
}
+68 -23
View File
@@ -454,19 +454,36 @@ func executeProxyAttempt(c *fiber.Ctx, proxyURL string) error {
return retry.Unrecoverable(fmt.Errorf("fiber context became nil during retry"))
}
// Get connection pool manager for stats tracking
poolMgr := GetConnectionPoolManager()
// Execute the proxy request
if err := doProxyRequestWithTimeout(c, proxyURL, cfg.Client.FastProxyClient); err != nil {
proxyErr := doProxyRequestWithTimeout(c, proxyURL, cfg.Client.FastProxyClient)
if proxyErr != nil {
// Check if this is a connection error
if isConnectionError(err) {
if isConnectionError(proxyErr) {
notifyHealthManager(false)
return err // Connection errors are retryable
// Track connection failure
if poolMgr != nil {
poolMgr.RecordConnectionFailure()
}
return proxyErr // Connection errors are retryable
}
// Check if this is a timeout error - don't retry timeouts
if isTimeoutError(err) {
return retry.Unrecoverable(err)
if isTimeoutError(proxyErr) {
return retry.Unrecoverable(proxyErr)
}
return err
// Check if this is a retryable HTTP error (e.g., 503)
// These indicate the server responded but with an error status
if strings.Contains(proxyErr.Error(), "non-200 response") {
// Track as a failure for retryable HTTP errors
if poolMgr != nil {
poolMgr.RecordConnectionFailure()
}
}
return proxyErr
}
// Safety check before accessing response
@@ -481,10 +498,18 @@ func executeProxyAttempt(c *fiber.Ctx, proxyURL string) error {
if err == nil {
// Success case
notifyHealthManager(true)
// Track successful connection
if poolMgr != nil {
poolMgr.RecordConnectionSuccess()
}
return nil
}
if shouldRetry {
// Track connection failure for retryable errors (5xx, etc)
if poolMgr != nil {
poolMgr.RecordConnectionFailure()
}
return err // Retryable error
}
@@ -541,31 +566,51 @@ func performProxyRequestWithEnhancedRetries(c *fiber.Ctx, proxyURL string, backe
retry.LastErrorOnly(true),
retry.RetryIf(func(err error) bool {
// Don't retry if context is cancelled or context is nil
defer func() {
// Recover from any panic when accessing context
if r := recover(); r != nil {
// If we panic, don't retry
return
}
}()
if c == nil {
return false
}
// Try to safely access the context
ctx := c.Context()
if ctx == nil {
// Safely check if context is done/cancelled
// Note: fasthttp.RequestCtx.Done() can panic if not properly initialized
// If we panic, don't retry (maintains backward compatibility with test behavior)
shouldRetry := true
func() {
defer func() {
if r := recover(); r != nil {
// If we panic accessing context, don't retry
// This typically happens in test scenarios with mock contexts
shouldRetry = false
}
}()
ctx := c.Context()
if ctx == nil {
return
}
select {
case <-ctx.Done():
shouldRetry = false
default:
}
}()
if !shouldRetry {
return false
}
// Check if context is done/cancelled
select {
case <-ctx.Done():
return false
default:
return true
// Check retry budget before allowing retry
if rb := GetRetryBudget(); rb != nil {
if !rb.AllowRetry() {
cfg.Logger.Warning(&libpack_logger.LogMessage{
Message: "Retry denied by budget",
Pairs: map[string]interface{}{
"path": c.Path(),
"error": err.Error(),
},
})
return false
}
}
return true
}),
)
}
+1
View File
@@ -44,6 +44,7 @@ type config struct {
CacheRedisEnable bool
CacheMaxMemorySize int
CacheMaxEntries int
CacheUseLRU bool // Use LRU eviction algorithm instead of random eviction
GraphQLQueryCacheSize int // Max number of parsed GraphQL queries to cache
PerUserCacheDisabled bool // Disable per-user cache isolation (SECURITY RISK - not recommended)
}