mirror of
https://github.com/lukaszraczylo/graphql-monitoring-proxy.git
synced 2026-11-04 08:04:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e138b44d00 | ||
|
|
3a6403c30b | ||
|
|
cff11f2b25 | ||
|
|
4c5d01aef3 | ||
|
|
6e7e2ecfae | ||
|
|
3e187790f2 | ||
|
|
ea30694dc9 | ||
|
|
c128a0f4d9 | ||
|
|
e2af5b079f | ||
|
|
10d3946f1e | ||
|
|
ab63f924a9 | ||
|
|
a0a8283b52 | ||
|
|
cf4f2785e2 | ||
|
|
6be1194ff7 | ||
|
|
1dcff9d2e2 | ||
|
|
523fc6222d | ||
|
|
9658f09521 | ||
|
|
8456860dbe | ||
|
|
5fa78e0fe8 | ||
|
|
54b83df04f | ||
|
|
899f71f4aa | ||
|
|
9ee213a018 | ||
|
|
b37ce3361d | ||
|
|
a8901321aa | ||
|
|
5febeabbf2 | ||
|
|
d32f8f2be5 | ||
|
|
0e1d22f4e7 | ||
|
|
7d3b77806a | ||
|
|
c2a9f6553f | ||
|
|
1a6bc71114 | ||
|
|
e5295a6f56 | ||
|
|
e99647e346 | ||
|
|
73962d0c9a | ||
|
|
b0c113f443 | ||
|
|
abb79535d1 | ||
|
|
4d87dee2c6 | ||
|
|
02780d942c | ||
|
|
67cc71bbf8 | ||
|
|
9b70f5693a | ||
|
|
f93a0e2865 | ||
|
|
90a6b99bb3 | ||
|
|
503d09f479 | ||
|
|
dc034fb0c7 | ||
|
|
6554da0d04 | ||
|
|
819a8e2315 | ||
|
|
c41f7907dd |
@@ -1,4 +1,4 @@
|
||||
name: Autoupdate go.mod and go.sum
|
||||
name: Update dependencies
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -6,14 +6,15 @@ on:
|
||||
- cron: "0 3 * * *"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
autoupdate:
|
||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main
|
||||
update:
|
||||
uses: lukaszraczylo/shared-actions/.github/workflows/renovate-autoupdate.yaml@main
|
||||
with:
|
||||
go-version: ">=1.26.6"
|
||||
release-workflow: "release.yaml"
|
||||
secrets: inherit
|
||||
node: false
|
||||
release-workflow: release.yaml
|
||||
|
||||
@@ -5,3 +5,6 @@ dist/
|
||||
coverage.out
|
||||
CLAUDE.md
|
||||
graphql-monitoring-proxy
|
||||
|
||||
# Lean proofs: local only, never committed
|
||||
theorems/
|
||||
|
||||
@@ -167,6 +167,7 @@ You can still use the non-prefixed environment variables in the spirit of the ba
|
||||
| `HOST_GRAPHQL` | The host to proxy the graphql endpoint | `http://localhost/` |
|
||||
| `HOST_GRAPHQL_READONLY` | The host to proxy the read-only graphql endpoint | `` |
|
||||
| `HEALTHCHECK_GRAPHQL_URL` | The URL to check the health of the graphql endpoint | `` |
|
||||
| `BACKEND_HEALTHCHECK_URL` | Optional HTTP readiness URL for startup and periodic backend checks; empty keeps the GraphQL probe | `` |
|
||||
| `JWT_USER_CLAIM_PATH` | Path to the user claim in the JWT token | `` |
|
||||
| `JWT_ROLE_CLAIM_PATH` | Path to the role claim in the JWT token | `` |
|
||||
| `JWT_VERIFY_SIGNATURE` | Verify the JWT signature before trusting its claims (see [Caching](#caching)) | `false` |
|
||||
@@ -820,7 +821,7 @@ Enable rate limiting based on user roles using the `ROLE_RATE_LIMIT` environment
|
||||
- `ROLE_RATE_LIMIT`: Enable role-based rate limiting (default: `false`)
|
||||
|
||||
**Features:**
|
||||
- **Burst control**: Optional burst limits for handling traffic spikes
|
||||
- **Burst control**: Optional `burst` caps requests in a short window (one tenth of the interval). `req` counts requests per `interval`. A `burst` at or above `req` has no effect.
|
||||
|
||||
`ratelimit.json` is loaded once at startup; changing the file requires a restart.
|
||||
|
||||
@@ -1127,6 +1128,12 @@ With the `PURGE_METRICS_ON_CRAWL` enabled, the `graphql_proxy_requests_failed`,
|
||||
|
||||
If you'd like the `/healthz` endpoint to perform actual check for the connectivity to the graphql endpoint - set the `HEALTHCHECK_GRAPHQL_URL` environment variable to the exact URL of the graphql endpoint. The query executed will be `query { __typename }` and if the response is not `200 OK` - the healthcheck will fail. Remember that the endpoint is a full URL which you'd like to check, so it should include the protocol, host and path - for example `http://localhost:8080/v1/graphql` and it's NOT the same as value of `HOST_GRAPHQL` environment variable which should provide only the host, without path, ending with slash.
|
||||
|
||||
`BACKEND_HEALTHCHECK_URL` controls a separate check: the backend health manager's startup and periodic probes. Set it to an unauthenticated readiness endpoint, for example `http://127.0.0.1:8080/ready`. The manager sends `GET` to the supplied path and query, without a GraphQL body or authentication headers. Any 2xx response is healthy; network errors, timeouts, and other status codes are unhealthy. The request timeout remains five seconds, and redirects are not followed.
|
||||
|
||||
When this variable is empty, the manager continues to send an unauthenticated `POST` containing `{"query":"{__typename}"}` to `HOST_GRAPHQL`. It preserves a supplied path, or appends `/v1/graphql` when there is no path beyond `/`. A configured readiness URL must use HTTP or HTTPS with a nonempty host and no userinfo or fragment. Invalid configuration stops startup; it does not fall back to GraphQL. Do not put secrets in the URL: probe targets are logged.
|
||||
|
||||
This setting does not change `HEALTHCHECK_GRAPHQL_URL`, the API `/healthz` check, GraphQL authorization, or headers on forwarded client requests. Keep the GraphQL endpoint protected; only the readiness endpoint must accept these unauthenticated probes.
|
||||
|
||||
#### Monitoring endpoint
|
||||
|
||||
Example metrics produced by the proxy:
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
libpack_cache "github.com/lukaszraczylo/graphql-monitoring-proxy/cache"
|
||||
libpack_config "github.com/lukaszraczylo/graphql-monitoring-proxy/config"
|
||||
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
||||
"github.com/sony/gobreaker"
|
||||
"github.com/sony/gobreaker/v2"
|
||||
)
|
||||
|
||||
var bannedUsersIDs sync.Map // key: userID string, value: reason string
|
||||
@@ -143,8 +143,13 @@ func periodicallyReloadBannedUsers(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func checkIfUserIsBanned(c fiber.Ctx, userID string) bool {
|
||||
func isUserBanned(userID string) bool {
|
||||
_, found := bannedUsersIDs.Load(userID)
|
||||
return found
|
||||
}
|
||||
|
||||
func checkIfUserIsBanned(c fiber.Ctx, userID string) bool {
|
||||
found := isUserBanned(userID)
|
||||
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Checking if user is banned",
|
||||
|
||||
+2
-2
@@ -62,7 +62,7 @@ func TestApiBackendHealth_HealthyManager_Returns200(t *testing.T) {
|
||||
defer func() { backendHealthManager = orig }()
|
||||
|
||||
// inject a healthy manager directly (bypassing sync.Once)
|
||||
mgr := NewBackendHealthManager(&fasthttp.Client{}, "http://localhost:8080", libpack_logger.New())
|
||||
mgr := NewBackendHealthManager(&fasthttp.Client{}, "http://localhost:8080", "", libpack_logger.New())
|
||||
mgr.isHealthy.Store(true)
|
||||
backendHealthManager = mgr
|
||||
|
||||
@@ -86,7 +86,7 @@ func TestApiBackendHealth_UnhealthyManager_Returns503(t *testing.T) {
|
||||
orig := backendHealthManager
|
||||
defer func() { backendHealthManager = orig }()
|
||||
|
||||
mgr := NewBackendHealthManager(&fasthttp.Client{}, "http://localhost:8080", libpack_logger.New())
|
||||
mgr := NewBackendHealthManager(&fasthttp.Client{}, "http://localhost:8080", "", libpack_logger.New())
|
||||
mgr.isHealthy.Store(false)
|
||||
backendHealthManager = mgr
|
||||
|
||||
|
||||
+65
-50
@@ -22,6 +22,8 @@ type BackendHealthManager struct {
|
||||
logger *libpack_logger.Logger
|
||||
cancel context.CancelFunc
|
||||
backendURL string
|
||||
healthCheckURL string
|
||||
useHTTPReadiness bool
|
||||
checkInterval time.Duration
|
||||
maxRetries int
|
||||
mu sync.RWMutex
|
||||
@@ -32,18 +34,41 @@ type BackendHealthManager struct {
|
||||
}
|
||||
|
||||
// NewBackendHealthManager creates a new backend health manager
|
||||
func NewBackendHealthManager(client *fasthttp.Client, backendURL string, logger *libpack_logger.Logger) *BackendHealthManager {
|
||||
func NewBackendHealthManager(client *fasthttp.Client, backendURL, readinessURL string, logger *libpack_logger.Logger) *BackendHealthManager {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
healthCheckURL := readinessURL
|
||||
if healthCheckURL == "" {
|
||||
healthCheckURL = backendGraphQLHealthURL(backendURL)
|
||||
} else {
|
||||
// Path normalization is a client setting in fasthttp. Use a separate
|
||||
// pool so readiness URLs stay exact without changing forwarded traffic.
|
||||
client = &fasthttp.Client{
|
||||
Dial: client.Dial,
|
||||
DialTimeout: client.DialTimeout,
|
||||
TLSConfig: client.TLSConfig,
|
||||
ReadTimeout: client.ReadTimeout,
|
||||
WriteTimeout: client.WriteTimeout,
|
||||
MaxIdleConnDuration: client.MaxIdleConnDuration,
|
||||
MaxConnDuration: client.MaxConnDuration,
|
||||
ReadBufferSize: client.ReadBufferSize,
|
||||
WriteBufferSize: client.WriteBufferSize,
|
||||
MaxResponseBodySize: client.MaxResponseBodySize,
|
||||
NoDefaultUserAgentHeader: client.NoDefaultUserAgentHeader,
|
||||
DisablePathNormalizing: true,
|
||||
}
|
||||
}
|
||||
return &BackendHealthManager{
|
||||
client: client,
|
||||
backendURL: backendURL,
|
||||
checkInterval: 5 * time.Second,
|
||||
maxRetries: 30, // 30 * 5s = 2.5 minutes max startup wait
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
logger: logger,
|
||||
startupProbe: true,
|
||||
readinessChan: make(chan bool, 1),
|
||||
client: client,
|
||||
backendURL: backendURL,
|
||||
healthCheckURL: healthCheckURL,
|
||||
useHTTPReadiness: readinessURL != "",
|
||||
checkInterval: 5 * time.Second,
|
||||
maxRetries: 30, // 30 * 5s = 2.5 minutes max startup wait
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
logger: logger,
|
||||
startupProbe: true,
|
||||
readinessChan: make(chan bool, 1),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +84,7 @@ func (bhm *BackendHealthManager) WaitForBackendReady(timeout time.Duration) erro
|
||||
Message: "Waiting for GraphQL backend to become ready",
|
||||
Pairs: map[string]any{
|
||||
"backend_url": bhm.backendURL,
|
||||
"check_url": bhm.healthCheckURL,
|
||||
"timeout": timeout.String(),
|
||||
},
|
||||
})
|
||||
@@ -92,7 +118,7 @@ func (bhm *BackendHealthManager) WaitForBackendReady(timeout time.Duration) erro
|
||||
}
|
||||
|
||||
// Exponential backoff (1.5x growth, capped at maxDelay), no jitter
|
||||
time.Sleep(currentDelay)
|
||||
time.Sleep(min(currentDelay, time.Until(deadline)))
|
||||
currentDelay = time.Duration(float64(currentDelay) * 1.5)
|
||||
if currentDelay > maxDelay {
|
||||
currentDelay = maxDelay
|
||||
@@ -149,43 +175,15 @@ func (bhm *BackendHealthManager) checkBackendHealth() bool {
|
||||
defer fasthttp.ReleaseRequest(req)
|
||||
defer fasthttp.ReleaseResponse(resp)
|
||||
|
||||
// Determine the health check URL
|
||||
// If backendURL is just "http://host:port" or "http://host:port/", append /v1/graphql
|
||||
// If it has a path like "/v1/graphql", use that path
|
||||
healthCheckURL := bhm.backendURL
|
||||
hasGraphQLPath := false
|
||||
|
||||
if len(bhm.backendURL) > 0 {
|
||||
// Simple check: if URL has a path component beyond just "/"
|
||||
lastSlash := -1
|
||||
protoEnd := 0
|
||||
if idx := strings.Index(bhm.backendURL, "://"); idx >= 0 {
|
||||
protoEnd = idx + 3
|
||||
}
|
||||
for i := protoEnd; i < len(bhm.backendURL); i++ {
|
||||
if bhm.backendURL[i] == '/' {
|
||||
lastSlash = i
|
||||
break
|
||||
}
|
||||
}
|
||||
// Has path if there's a slash after protocol and it's not the last char or followed by more path
|
||||
hasGraphQLPath = lastSlash >= protoEnd && lastSlash < len(bhm.backendURL)-1
|
||||
|
||||
// If no GraphQL path, append /v1/graphql (standard Hasura endpoint)
|
||||
if !hasGraphQLPath {
|
||||
// Remove trailing slash if present
|
||||
baseURL := strings.TrimSuffix(bhm.backendURL, "/")
|
||||
healthCheckURL = baseURL + "/v1/graphql"
|
||||
}
|
||||
req.SetRequestURI(bhm.healthCheckURL)
|
||||
if bhm.useHTTPReadiness {
|
||||
req.Header.SetMethod(http.MethodGet)
|
||||
} else {
|
||||
req.Header.SetMethod(http.MethodPost)
|
||||
req.Header.SetContentType("application/json")
|
||||
req.SetBodyString(`{"query":"{__typename}"}`)
|
||||
}
|
||||
|
||||
// Always send GraphQL introspection query for health check
|
||||
healthQuery := `{"query":"{__typename}"}`
|
||||
req.SetRequestURI(healthCheckURL)
|
||||
req.Header.SetMethod(http.MethodPost)
|
||||
req.Header.SetContentType("application/json")
|
||||
req.SetBody([]byte(healthQuery))
|
||||
|
||||
// Short timeout for health checks
|
||||
err := bhm.client.DoTimeout(req, resp, 5*time.Second)
|
||||
if err != nil {
|
||||
@@ -193,7 +191,7 @@ func (bhm *BackendHealthManager) checkBackendHealth() bool {
|
||||
Message: "Backend health check failed",
|
||||
Pairs: map[string]any{
|
||||
"error": err.Error(),
|
||||
"check_url": healthCheckURL,
|
||||
"check_url": bhm.healthCheckURL,
|
||||
},
|
||||
})
|
||||
return false
|
||||
@@ -207,7 +205,7 @@ func (bhm *BackendHealthManager) checkBackendHealth() bool {
|
||||
Message: "Backend returned unhealthy status",
|
||||
Pairs: map[string]any{
|
||||
"status_code": statusCode,
|
||||
"check_url": healthCheckURL,
|
||||
"check_url": bhm.healthCheckURL,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -284,6 +282,9 @@ func (bhm *BackendHealthManager) Shutdown() {
|
||||
return
|
||||
}
|
||||
bhm.cancel()
|
||||
if bhm.useHTTPReadiness {
|
||||
bhm.client.CloseIdleConnections()
|
||||
}
|
||||
if bhm.logger != nil {
|
||||
bhm.logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "Backend health manager shut down",
|
||||
@@ -298,9 +299,9 @@ var (
|
||||
)
|
||||
|
||||
// InitializeBackendHealth initializes the backend health manager
|
||||
func InitializeBackendHealth(client *fasthttp.Client, backendURL string, logger *libpack_logger.Logger) *BackendHealthManager {
|
||||
func InitializeBackendHealth(client *fasthttp.Client, backendURL, readinessURL string, logger *libpack_logger.Logger) *BackendHealthManager {
|
||||
backendHealthOnce.Do(func() {
|
||||
backendHealthManager = NewBackendHealthManager(client, backendURL, logger)
|
||||
backendHealthManager = NewBackendHealthManager(client, backendURL, readinessURL, logger)
|
||||
})
|
||||
return backendHealthManager
|
||||
}
|
||||
@@ -309,3 +310,17 @@ func InitializeBackendHealth(client *fasthttp.Client, backendURL string, logger
|
||||
func GetBackendHealthManager() *BackendHealthManager {
|
||||
return backendHealthManager
|
||||
}
|
||||
|
||||
func backendGraphQLHealthURL(backendURL string) string {
|
||||
if backendURL == "" {
|
||||
return ""
|
||||
}
|
||||
protoEnd := 0
|
||||
if idx := strings.Index(backendURL, "://"); idx >= 0 {
|
||||
protoEnd = idx + 3
|
||||
}
|
||||
if pathStart := strings.IndexByte(backendURL[protoEnd:], '/'); pathStart >= 0 && protoEnd+pathStart < len(backendURL)-1 {
|
||||
return backendURL
|
||||
}
|
||||
return strings.TrimSuffix(backendURL, "/") + "/v1/graphql"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// Pins theorems/Theorems/ReadyBackoff.lean: the startup wait never sleeps past its timeout.
|
||||
func TestWaitForBackendReady_HonoursTimeout(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
_ = ln.Close() // closed port: every probe is refused immediately
|
||||
|
||||
bhm := NewBackendHealthManager(&fasthttp.Client{}, "http://"+addr, "", libpack_logger.New())
|
||||
defer bhm.cancel()
|
||||
|
||||
const timeout = 3 * time.Second
|
||||
start := time.Now()
|
||||
if err := bhm.WaitForBackendReady(timeout); err == nil {
|
||||
t.Fatal("want error for unreachable backend")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > timeout+500*time.Millisecond {
|
||||
t.Fatalf("waited %v, want <= %v", elapsed, timeout+500*time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
func TestBackendReadinessProtectedGraphQL(t *testing.T) {
|
||||
var readyStatus atomic.Int32
|
||||
readyStatus.Store(http.StatusOK)
|
||||
var graphqlCalls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/graphql":
|
||||
graphqlCalls.Add(1)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
case "/ready":
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Empty(t, r.Header.Get("Authorization"))
|
||||
assert.Empty(t, r.Header.Get("X-Husaria-Admin-Secret"))
|
||||
assert.Empty(t, r.Header.Get("X-Hasura-Admin-Secret"))
|
||||
body, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, body)
|
||||
w.WriteHeader(int(readyStatus.Load()))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
client := &fasthttp.Client{}
|
||||
t.Cleanup(client.CloseIdleConnections)
|
||||
logger := libpack_logger.New().SetOutput(io.Discard)
|
||||
legacy := NewBackendHealthManager(client, server.URL, "", logger)
|
||||
t.Cleanup(legacy.Shutdown)
|
||||
require.False(t, legacy.checkBackendHealth(), "GraphQL must reject the unauthenticated probe")
|
||||
|
||||
manager := NewBackendHealthManager(client, server.URL, server.URL+"/ready", logger)
|
||||
t.Cleanup(manager.Shutdown)
|
||||
manager.checkInterval = 10 * time.Millisecond
|
||||
manager.StartHealthChecking()
|
||||
require.NoError(t, manager.WaitForBackendReady(time.Second))
|
||||
require.True(t, manager.IsHealthy())
|
||||
|
||||
readyStatus.Store(http.StatusServiceUnavailable)
|
||||
require.Eventually(t, func() bool { return !manager.IsHealthy() }, time.Second, 5*time.Millisecond)
|
||||
readyStatus.Store(http.StatusOK)
|
||||
require.Eventually(t, manager.IsHealthy, time.Second, 5*time.Millisecond)
|
||||
assert.Equal(t, int32(1), graphqlCalls.Load(), "readiness checks must not query protected GraphQL")
|
||||
require.False(t, legacy.checkBackendHealth(), "readiness must not bypass GraphQL authorization")
|
||||
}
|
||||
|
||||
func TestBackendReadinessExactTarget(t *testing.T) {
|
||||
const target = "/checks//ready%2Fdatabase?probe=one%2Ftwo&probe=three"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Equal(t, target, r.RequestURI)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
client := &fasthttp.Client{}
|
||||
t.Cleanup(client.CloseIdleConnections)
|
||||
manager := NewBackendHealthManager(client, "http://unused.invalid", server.URL+target, libpack_logger.New().SetOutput(io.Discard))
|
||||
t.Cleanup(manager.Shutdown)
|
||||
assert.True(t, manager.checkBackendHealth())
|
||||
}
|
||||
|
||||
func TestBackendReadinessStatus(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
status int
|
||||
healthy bool
|
||||
}{
|
||||
{name: "good OK", status: http.StatusOK, healthy: true},
|
||||
{name: "good no content", status: http.StatusNoContent, healthy: true},
|
||||
{name: "edge final success", status: 299, healthy: true},
|
||||
{name: "bad redirect", status: http.StatusFound},
|
||||
{name: "bad unauthorized", status: http.StatusUnauthorized},
|
||||
{name: "bad unavailable", status: http.StatusServiceUnavailable},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/ready" {
|
||||
t.Error("readiness probe followed a redirect")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Location", "/redirected")
|
||||
w.WriteHeader(tc.status)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
client := &fasthttp.Client{}
|
||||
t.Cleanup(client.CloseIdleConnections)
|
||||
manager := NewBackendHealthManager(client, server.URL, server.URL+"/ready", libpack_logger.New().SetOutput(io.Discard))
|
||||
t.Cleanup(manager.Shutdown)
|
||||
assert.Equal(t, tc.healthy, manager.checkBackendHealth())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendReadinessNetworkFailure(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
server.Close()
|
||||
client := &fasthttp.Client{}
|
||||
t.Cleanup(client.CloseIdleConnections)
|
||||
manager := NewBackendHealthManager(client, server.URL, server.URL+"/ready", libpack_logger.New().SetOutput(io.Discard))
|
||||
t.Cleanup(manager.Shutdown)
|
||||
assert.False(t, manager.checkBackendHealth())
|
||||
}
|
||||
|
||||
func TestBackendReadinessTimeout(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
<-release
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
t.Cleanup(func() { close(release) })
|
||||
client := &fasthttp.Client{ReadTimeout: 20 * time.Millisecond}
|
||||
t.Cleanup(client.CloseIdleConnections)
|
||||
manager := NewBackendHealthManager(client, server.URL, server.URL+"/ready", libpack_logger.New().SetOutput(io.Discard))
|
||||
t.Cleanup(manager.Shutdown)
|
||||
assert.False(t, manager.checkBackendHealth())
|
||||
}
|
||||
|
||||
func TestBackendReadinessLegacyGraphQL(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
configuredPath string
|
||||
requestURI string
|
||||
}{
|
||||
{name: "good bare host", requestURI: "/v1/graphql"},
|
||||
{name: "edge root path", configuredPath: "/", requestURI: "/v1/graphql"},
|
||||
{name: "good custom path and query", configuredPath: "/graphql?health=1", requestURI: "/graphql?health=1"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, tc.requestURI, r.RequestURI)
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
assert.Empty(t, r.Header.Get("Authorization"))
|
||||
body, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.JSONEq(t, `{"query":"{__typename}"}`, string(body))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
client := &fasthttp.Client{}
|
||||
t.Cleanup(client.CloseIdleConnections)
|
||||
manager := NewBackendHealthManager(client, server.URL+tc.configuredPath, "", libpack_logger.New().SetOutput(io.Discard))
|
||||
t.Cleanup(manager.Shutdown)
|
||||
assert.True(t, manager.checkBackendHealth())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func readinessURLWithUserinfo(user *url.Userinfo) string {
|
||||
u := url.URL{Scheme: "http", Host: "example.test", Path: "/ready", User: user}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func TestBackendReadinessURLValidation(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
url string
|
||||
valid bool
|
||||
}{
|
||||
{name: "edge unset", valid: true},
|
||||
{name: "good HTTP", url: "http://127.0.0.1:8080/ready", valid: true},
|
||||
{name: "good HTTPS query", url: "https://example.test/check?database=primary", valid: true},
|
||||
{name: "good IPv6", url: "http://[::1]:8080/ready", valid: true},
|
||||
{name: "bad relative", url: "/ready"},
|
||||
{name: "bad scheme", url: "ftp://example.test/ready"},
|
||||
{name: "bad missing host", url: "http:///ready"},
|
||||
{name: "bad port only host", url: "http://:8080/ready"},
|
||||
{name: "bad opaque", url: "http:ready"},
|
||||
{name: "bad userinfo", url: readinessURLWithUserinfo(url.UserPassword("operator", "secret"))},
|
||||
{name: "bad user only", url: readinessURLWithUserinfo(url.User("operator"))},
|
||||
{name: "bad escaped userinfo", url: strings.Replace(readinessURLWithUserinfo(url.UserPassword("operator", "%zz")), "%25zz", "%zz", 1)},
|
||||
{name: "bad path escape", url: "http://example.test/%zz"},
|
||||
{name: "bad port", url: "http://example.test:invalid/ready"},
|
||||
{name: "bad port range", url: "http://example.test:65536/ready"},
|
||||
{name: "bad fragment", url: "http://example.test/ready#ignored"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateBackendHealthcheckURL(tc.url)
|
||||
if tc.valid {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), tc.url)
|
||||
assert.NotContains(t, err.Error(), "operator")
|
||||
assert.NotContains(t, err.Error(), "secret")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendReadinessInvalidConfigStopsStartup(t *testing.T) {
|
||||
const helper = "GMP_TEST_INVALID_READINESS_CONFIG"
|
||||
if os.Getenv(helper) == "1" {
|
||||
parseConfig()
|
||||
t.Fatal("invalid readiness configuration did not stop startup")
|
||||
}
|
||||
command := exec.Command(os.Args[0], "-test.run=^TestBackendReadinessInvalidConfigStopsStartup$")
|
||||
for _, variable := range os.Environ() {
|
||||
if !strings.HasPrefix(variable, "GMP_BACKEND_HEALTHCHECK_URL=") && !strings.HasPrefix(variable, helper+"=") {
|
||||
command.Env = append(command.Env, variable)
|
||||
}
|
||||
}
|
||||
command.Env = append(command.Env, helper+"=1", "GMP_BACKEND_HEALTHCHECK_URL="+readinessURLWithUserinfo(url.UserPassword("operator", "secret")))
|
||||
output, err := command.CombinedOutput()
|
||||
var exitError *exec.ExitError
|
||||
require.ErrorAs(t, err, &exitError)
|
||||
assert.Equal(t, 1, exitError.ExitCode())
|
||||
assert.Contains(t, string(output), "Invalid BACKEND_HEALTHCHECK_URL")
|
||||
assert.NotContains(t, string(output), "operator")
|
||||
assert.NotContains(t, string(output), "secret")
|
||||
}
|
||||
Vendored
+2
-1
@@ -158,7 +158,8 @@ func (c *Cache) Set(key string, value []byte, ttl time.Duration) {
|
||||
c.evictToFreeMemory(memoryToFree)
|
||||
} else if atomic.LoadInt64(&c.entryCount) >= c.maxCacheSize {
|
||||
// Fall back to count-based eviction for backward compatibility
|
||||
c.evictOldest(int(c.maxCacheSize / 10)) // Evict 10% of entries
|
||||
// 10% of entries, at least one: max/10 is 0 for caps below 10.
|
||||
c.evictOldest(int(max(1, c.maxCacheSize/10)))
|
||||
}
|
||||
|
||||
// A non-positive TTL is clamped to defaultTTL so this backend expires
|
||||
|
||||
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
package libpack_cache_memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// Pins theorems/Theorems/MemCacheCap.lean: every cap >= 1 bounds the entry count.
|
||||
func TestSet_SmallEntryCapIsEnforced(t *testing.T) {
|
||||
for _, maxEntries := range []int64{1, 2, 5, 9, 10, 25} {
|
||||
t.Run(fmt.Sprintf("cap_%d", maxEntries), func(t *testing.T) {
|
||||
c := NewWithSize(time.Minute, DefaultMaxMemorySize, maxEntries)
|
||||
defer c.Shutdown()
|
||||
for i := 0; i < 100; i++ {
|
||||
c.Set(fmt.Sprintf("k%d", i), []byte("v"), time.Minute)
|
||||
assert.LessOrEqual(t, c.CountQueries(), maxEntries)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Vendored
+35
-9
@@ -32,6 +32,9 @@ func escapeGlobPattern(s string) string {
|
||||
return globEscaper.Replace(s)
|
||||
}
|
||||
|
||||
// scanCountBatch is the SCAN COUNT hint used when counting keys.
|
||||
const scanCountBatch = 1000
|
||||
|
||||
type RedisConfig struct {
|
||||
ctx context.Context
|
||||
client *redis.Client
|
||||
@@ -80,7 +83,15 @@ type RedisClientConfig struct {
|
||||
// entry.
|
||||
const defaultTTL = 60 * time.Second
|
||||
|
||||
// DefaultKeyPrefix namespaces cache keys when no prefix is configured, so Clear
|
||||
// and CountQueries never touch keys of other applications on a shared Redis.
|
||||
const DefaultKeyPrefix = "gmp:"
|
||||
|
||||
func New(redisClientConfig *RedisClientConfig) (*RedisConfig, error) {
|
||||
prefix := redisClientConfig.Prefix
|
||||
if prefix == "" {
|
||||
prefix = DefaultKeyPrefix
|
||||
}
|
||||
c := &RedisConfig{
|
||||
client: redis.NewClient(&redis.Options{
|
||||
Addr: redisClientConfig.RedisServer,
|
||||
@@ -88,7 +99,7 @@ func New(redisClientConfig *RedisClientConfig) (*RedisConfig, error) {
|
||||
DB: redisClientConfig.RedisDB,
|
||||
}),
|
||||
ctx: context.Background(),
|
||||
prefix: redisClientConfig.Prefix,
|
||||
prefix: prefix,
|
||||
builderPool: &sync.Pool{
|
||||
New: func() any {
|
||||
return &strings.Builder{}
|
||||
@@ -163,12 +174,28 @@ func (c *RedisConfig) Clear() error {
|
||||
// literally instead of being interpreted as a wildcard, which would
|
||||
// otherwise undercount or overcount keys in admin-dashboard stats.
|
||||
func (c *RedisConfig) CountQueries() (int64, error) {
|
||||
pattern := escapeGlobPattern(c.prefix) + "*"
|
||||
keys, err := c.client.Keys(c.ctx, pattern).Result()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return c.scanCount(escapeGlobPattern(c.prefix) + "*")
|
||||
}
|
||||
|
||||
// scanCount counts keys matching pattern with a cursor SCAN. KEYS builds the
|
||||
// whole reply in memory on the server and blocks it, which spiked a shared
|
||||
// Dragonfly to its memory limit when the prefix is empty.
|
||||
func (c *RedisConfig) scanCount(pattern string) (int64, error) {
|
||||
var (
|
||||
cursor uint64
|
||||
total int64
|
||||
)
|
||||
for {
|
||||
keys, next, err := c.client.Scan(c.ctx, cursor, pattern, scanCountBatch).Result()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("redis: scan at cursor %d: %w", cursor, err)
|
||||
}
|
||||
total += int64(len(keys))
|
||||
cursor = next
|
||||
if cursor == 0 {
|
||||
return total, nil
|
||||
}
|
||||
}
|
||||
return int64(len(keys)), nil
|
||||
}
|
||||
|
||||
// CountQueriesWithPattern returns the number of keys owned by this cache
|
||||
@@ -177,12 +204,11 @@ func (c *RedisConfig) CountQueries() (int64, error) {
|
||||
// itself is left as given so a caller can still use glob metacharacters
|
||||
// intentionally in the suffix it supplies.
|
||||
func (c *RedisConfig) CountQueriesWithPattern(pattern string) (int, error) {
|
||||
fullPattern := escapeGlobPattern(c.prefix) + pattern
|
||||
keys, err := c.client.Keys(c.ctx, fullPattern).Result()
|
||||
total, err := c.scanCount(escapeGlobPattern(c.prefix) + pattern)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(keys), nil
|
||||
return int(total), nil
|
||||
}
|
||||
|
||||
// GetMemoryUsage returns an approximation of memory usage for Redis.
|
||||
|
||||
Vendored
+26
-18
@@ -2,6 +2,8 @@ package libpack_cache_redis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
@@ -125,19 +127,9 @@ func TestClear_NormalPrefix_StillClearsAllOwnedKeys(t *testing.T) {
|
||||
// intercept DEL commands without a rewrite of RedisConfig into an
|
||||
// interface-backed mock, which the task's infra allows.
|
||||
//
|
||||
// Every DEL call is made to fail (not just one). A test that lets only one
|
||||
// batch's DEL succeed was tried first, and does not work against miniredis:
|
||||
// its SCAN implementation uses an index-based cursor over a sorted key
|
||||
// snapshot (cmd_generic.go cmdScan), which is invalidated as soon as a
|
||||
// preceding batch actually deletes keys mid-scan (the very "scan, then
|
||||
// delete this batch" loop Clear uses), causing miniredis to abort the SCAN
|
||||
// early with a synthetic empty result before Clear's loop reaches the next
|
||||
// batch. Real Redis's SCAN cursor (reverse binary iteration) is documented
|
||||
// to be robust against concurrent deletion during a single full iteration
|
||||
// and would not exhibit this; it is a miniredis simplification, not a
|
||||
// RedisConfig defect. Failing every batch's DEL sidesteps it (no batch is
|
||||
// ever actually deleted, so the key set never shrinks between scans) while
|
||||
// still proving Clear does not stop scanning after the first DEL error.
|
||||
// Every DEL call is made to fail so the key set never shrinks between
|
||||
// scans. SCAN is paginated by the hook itself because miniredis ignores
|
||||
// COUNT and returns every key in one batch.
|
||||
func TestClear_AllBatchesDelFail_AttemptsEveryBatchAndAggregatesErrors(t *testing.T) {
|
||||
s, err := miniredis.Run()
|
||||
require.NoError(t, err)
|
||||
@@ -146,10 +138,7 @@ func TestClear_AllBatchesDelFail_AttemptsEveryBatchAndAggregatesErrors(t *testin
|
||||
rc, err := New(&RedisClientConfig{RedisServer: s.Addr(), Prefix: "pfx:"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// miniredis SCAN batches deterministically by COUNT over the sorted,
|
||||
// match-filtered key set (see cmd_generic.go cmdScan), so 250 owned keys
|
||||
// with a SCAN COUNT of 100 (as used by Clear) yields exactly 3 batches:
|
||||
// [0:100), [100:200), [200:250).
|
||||
// 250 owned keys with a SCAN page size of 100 yield exactly 3 batches.
|
||||
const totalKeys = 250
|
||||
const wantBatches = 3
|
||||
for i := 0; i < totalKeys; i++ {
|
||||
@@ -158,7 +147,26 @@ func TestClear_AllBatchesDelFail_AttemptsEveryBatchAndAggregatesErrors(t *testin
|
||||
}
|
||||
|
||||
var seen int32
|
||||
s.Server().SetPreHook(func(c *miniredis_server.Peer, cmd string, _ ...string) bool {
|
||||
const scanPage = 100
|
||||
s.Server().SetPreHook(func(c *miniredis_server.Peer, cmd string, args ...string) bool {
|
||||
if cmd == "SCAN" {
|
||||
cursor, convErr := strconv.Atoi(args[0])
|
||||
if convErr != nil {
|
||||
c.WriteError("ERR invalid cursor")
|
||||
return true
|
||||
}
|
||||
keys := s.Keys()
|
||||
sort.Strings(keys)
|
||||
end := min(cursor+scanPage, len(keys))
|
||||
next := end
|
||||
if end == len(keys) {
|
||||
next = 0
|
||||
}
|
||||
c.WriteLen(2)
|
||||
c.WriteBulk(strconv.Itoa(next))
|
||||
c.WriteStrings(keys[cursor:end])
|
||||
return true
|
||||
}
|
||||
if cmd != "DEL" {
|
||||
return false // let miniredis handle everything else normally
|
||||
}
|
||||
|
||||
Vendored
+82
@@ -0,0 +1,82 @@
|
||||
package libpack_cache_redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
redis "github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type commandRecorder struct {
|
||||
mu sync.Mutex
|
||||
seen []string
|
||||
}
|
||||
|
||||
func (r *commandRecorder) DialHook(next redis.DialHook) redis.DialHook {
|
||||
return func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return next(ctx, network, addr)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *commandRecorder) ProcessHook(next redis.ProcessHook) redis.ProcessHook {
|
||||
return func(ctx context.Context, cmd redis.Cmder) error {
|
||||
r.mu.Lock()
|
||||
r.seen = append(r.seen, strings.ToLower(cmd.Name()))
|
||||
r.mu.Unlock()
|
||||
return next(ctx, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *commandRecorder) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.ProcessPipelineHook {
|
||||
return next
|
||||
}
|
||||
|
||||
func (r *commandRecorder) count(name string) int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
n := 0
|
||||
for _, s := range r.seen {
|
||||
if s == name {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestCount_UsesScanNotKeys guards against reintroducing KEYS, which builds
|
||||
// the whole key list server-side and spiked a shared Dragonfly to its limit.
|
||||
func TestCount_UsesScanNotKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, err := miniredis.Run()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(s.Close)
|
||||
|
||||
rc, err := New(&RedisClientConfig{RedisServer: s.Addr()})
|
||||
require.NoError(t, err)
|
||||
|
||||
const total = 2500
|
||||
for i := 0; i < total; i++ {
|
||||
require.NoError(t, rc.Set(fmt.Sprintf("k%d", i), []byte("v"), 0))
|
||||
}
|
||||
|
||||
rec := &commandRecorder{}
|
||||
rc.client.AddHook(rec)
|
||||
|
||||
count, err := rc.CountQueries()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(total), count)
|
||||
|
||||
patternCount, err := rc.CountQueriesWithPattern("k1*")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1111, patternCount, "k1, k10-k19, k100-k199, k1000-k1999")
|
||||
|
||||
assert.Zero(t, rec.count("keys"), "counting must not issue KEYS")
|
||||
assert.GreaterOrEqual(t, rec.count("scan"), 2, "counting must issue SCAN")
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package libpack_cache_redis
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Pins theorems/Theorems/RedisClear.lean: with no configured prefix, Clear must still spare foreign keys.
|
||||
func TestClear_DefaultPrefixSparesForeignKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, err := miniredis.Run()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(s.Close)
|
||||
|
||||
require.NoError(t, s.Set("other-app:session", "keep"))
|
||||
|
||||
c, err := New(&RedisClientConfig{RedisServer: s.Addr()})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.Set("q1", []byte("v"), 0))
|
||||
|
||||
require.NoError(t, c.Clear())
|
||||
|
||||
assert.True(t, s.Exists("other-app:session"), "foreign key was deleted")
|
||||
_, found, err := c.Get("q1")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, found, "own key must be cleared")
|
||||
}
|
||||
Vendored
+4
-4
@@ -131,11 +131,11 @@ func (suite *RedisConfigSuite) TestGetKeys() {
|
||||
err = suite.redisConfig.Set("otherkey", []byte("othervalue"), ttl)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
keys, _ := suite.redisConfig.client.Keys(suite.redisConfig.ctx, "testkey*").Result()
|
||||
expectedKeys := []string{"testkey1", "testkey2"}
|
||||
keys, _ := suite.redisConfig.client.Keys(suite.redisConfig.ctx, DefaultKeyPrefix+"testkey*").Result()
|
||||
expectedKeys := []string{DefaultKeyPrefix + "testkey1", DefaultKeyPrefix + "testkey2"}
|
||||
assert.ElementsMatch(suite.T(), expectedKeys, keys)
|
||||
|
||||
suite.redisConfig.client.Del(suite.redisConfig.ctx, "testkey1", "testkey2", "otherkey")
|
||||
suite.redisConfig.client.Del(suite.redisConfig.ctx, DefaultKeyPrefix+"testkey1", DefaultKeyPrefix+"testkey2", DefaultKeyPrefix+"otherkey")
|
||||
}
|
||||
|
||||
func (suite *RedisConfigSuite) TestGetKeysCount() {
|
||||
@@ -154,5 +154,5 @@ func (suite *RedisConfigSuite) TestGetKeysCount() {
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.Equal(suite.T(), int64(3), count3)
|
||||
|
||||
suite.redisConfig.client.Del(suite.redisConfig.ctx, "testkey1", "testkey2", "otherkey")
|
||||
suite.redisConfig.client.Del(suite.redisConfig.ctx, DefaultKeyPrefix+"testkey1", DefaultKeyPrefix+"testkey2", DefaultKeyPrefix+"otherkey")
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
libpack_cache "github.com/lukaszraczylo/graphql-monitoring-proxy/cache"
|
||||
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
|
||||
"github.com/sony/gobreaker"
|
||||
"github.com/sony/gobreaker/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/sony/gobreaker"
|
||||
"github.com/sony/gobreaker/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
libpack_cache_memory "github.com/lukaszraczylo/graphql-monitoring-proxy/cache/memory"
|
||||
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
||||
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
|
||||
"github.com/sony/gobreaker"
|
||||
"github.com/sony/gobreaker/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/valyala/fasthttp"
|
||||
@@ -497,7 +497,7 @@ func (suite *CircuitBreakerTestSuite) TestCircuitBreakerFailureRatioWindow() {
|
||||
func (suite *CircuitBreakerTestSuite) TestCircuitBackoffResetsOnSuccessfulRecovery() {
|
||||
cbMutex.Lock()
|
||||
cbBackoff.reset()
|
||||
cb = gobreaker.NewCircuitBreaker(gobreaker.Settings{
|
||||
cb = gobreaker.NewCircuitBreaker[any](gobreaker.Settings{
|
||||
Name: "recovery-test",
|
||||
MaxRequests: 1,
|
||||
Timeout: 5 * time.Millisecond,
|
||||
@@ -682,7 +682,7 @@ func TestCircuitBackoffGateBlocks(t *testing.T) {
|
||||
testCfg.CircuitBreaker.BackoffMultiplier = tt.multiplier
|
||||
testCfg.CircuitBreaker.MaxBackoffTimeout = int(tt.maxBackoff / time.Second)
|
||||
|
||||
breaker := gobreaker.NewCircuitBreaker(gobreaker.Settings{Name: "gate-test"})
|
||||
breaker := gobreaker.NewCircuitBreaker[any](gobreaker.Settings{Name: "gate-test"})
|
||||
now := tripTime.Add(tt.elapsed)
|
||||
|
||||
got := circuitBackoffGateBlocks(testCfg, breaker, now)
|
||||
@@ -725,10 +725,10 @@ func TestCircuitBackoffLongerClosedDoorWithHigherMultiplier(t *testing.T) {
|
||||
cfgProgressive.CircuitBreaker.BackoffMultiplier = 2.0
|
||||
|
||||
seed()
|
||||
blockedAtDefault := circuitBackoffGateBlocks(cfgDefault, gobreaker.NewCircuitBreaker(gobreaker.Settings{Name: "default"}), now)
|
||||
blockedAtDefault := circuitBackoffGateBlocks(cfgDefault, gobreaker.NewCircuitBreaker[any](gobreaker.Settings{Name: "default"}), now)
|
||||
|
||||
seed()
|
||||
blockedAtProgressive := circuitBackoffGateBlocks(cfgProgressive, gobreaker.NewCircuitBreaker(gobreaker.Settings{Name: "progressive"}), now)
|
||||
blockedAtProgressive := circuitBackoffGateBlocks(cfgProgressive, gobreaker.NewCircuitBreaker[any](gobreaker.Settings{Name: "progressive"}), now)
|
||||
|
||||
assert.False(t, blockedAtDefault, "multiplier=1.0 (default): base has already elapsed, gate must match today's behaviour and not block")
|
||||
assert.True(t, blockedAtProgressive, "multiplier=2.0: effective backoff has not yet elapsed at the same instant, gate must still block -- strictly longer closed-door time than the default")
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// Pins theorems/Theorems/CoalesceAlias.lean: waiters never alias the primary's response buffer.
|
||||
func TestSnapshotCoalescedResponse_OwnsBody(t *testing.T) {
|
||||
app := fiber.New()
|
||||
fctx := &fasthttp.RequestCtx{}
|
||||
c := app.AcquireCtx(fctx)
|
||||
defer app.ReleaseCtx(c)
|
||||
|
||||
c.Response().SetStatusCode(201)
|
||||
c.Response().SetBody([]byte(`{"user":"alice"}`))
|
||||
|
||||
snap := snapshotCoalescedResponse(c)
|
||||
|
||||
c.Response().SetBody([]byte(`{"user":"bobby"}`)) // buffer reused by a later request
|
||||
|
||||
assert.Equal(t, 201, snap.StatusCode)
|
||||
assert.Equal(t, `{"user":"alice"}`, string(snap.Body))
|
||||
}
|
||||
@@ -77,16 +77,8 @@ func (suite *ConnectionResilienceTestSuite) TearDownTest() {
|
||||
|
||||
// TestBackendHealthManager tests the backend health monitoring
|
||||
func (suite *ConnectionResilienceTestSuite) TestBackendHealthManager() {
|
||||
suite.Run("initialization", func() {
|
||||
healthMgr := NewBackendHealthManager(cfg.Client.FastProxyClient, cfg.Server.HostGraphQL, cfg.Logger)
|
||||
suite.NotNil(healthMgr)
|
||||
suite.Equal(cfg.Server.HostGraphQL, healthMgr.backendURL)
|
||||
suite.Equal(5*time.Second, healthMgr.checkInterval)
|
||||
suite.Equal(30, healthMgr.maxRetries)
|
||||
})
|
||||
|
||||
suite.Run("health check success", func() {
|
||||
healthMgr := NewBackendHealthManager(cfg.Client.FastProxyClient, cfg.Server.HostGraphQL, cfg.Logger)
|
||||
healthMgr := NewBackendHealthManager(cfg.Client.FastProxyClient, cfg.Server.HostGraphQL, "", cfg.Logger)
|
||||
isHealthy := healthMgr.checkBackendHealth()
|
||||
suite.True(isHealthy)
|
||||
suite.GreaterOrEqual(suite.mockServerCalled.Load(), int32(1))
|
||||
@@ -94,13 +86,13 @@ func (suite *ConnectionResilienceTestSuite) TestBackendHealthManager() {
|
||||
|
||||
suite.Run("health check failure", func() {
|
||||
// Use invalid URL to simulate failure
|
||||
healthMgr := NewBackendHealthManager(cfg.Client.FastProxyClient, "http://invalid-url:99999", cfg.Logger)
|
||||
healthMgr := NewBackendHealthManager(cfg.Client.FastProxyClient, "http://invalid-url:99999", "", cfg.Logger)
|
||||
isHealthy := healthMgr.checkBackendHealth()
|
||||
suite.False(isHealthy)
|
||||
})
|
||||
|
||||
suite.Run("startup readiness with healthy backend", func() {
|
||||
healthMgr := NewBackendHealthManager(cfg.Client.FastProxyClient, cfg.Server.HostGraphQL, cfg.Logger)
|
||||
healthMgr := NewBackendHealthManager(cfg.Client.FastProxyClient, cfg.Server.HostGraphQL, "", cfg.Logger)
|
||||
err := healthMgr.WaitForBackendReady(10 * time.Second)
|
||||
suite.NoError(err)
|
||||
suite.True(healthMgr.IsHealthy())
|
||||
@@ -108,7 +100,7 @@ func (suite *ConnectionResilienceTestSuite) TestBackendHealthManager() {
|
||||
|
||||
suite.Run("startup readiness timeout", func() {
|
||||
// Use invalid URL to simulate backend not ready
|
||||
healthMgr := NewBackendHealthManager(cfg.Client.FastProxyClient, "http://invalid-url:99999", cfg.Logger)
|
||||
healthMgr := NewBackendHealthManager(cfg.Client.FastProxyClient, "http://invalid-url:99999", "", cfg.Logger)
|
||||
err := healthMgr.WaitForBackendReady(2 * time.Second)
|
||||
suite.Error(err)
|
||||
suite.Contains(err.Error(), "did not become ready")
|
||||
@@ -177,7 +169,7 @@ func (suite *ConnectionResilienceTestSuite) TestConnectionPoolManager() {
|
||||
func (suite *ConnectionResilienceTestSuite) TestIntegratedHealthManagement() {
|
||||
suite.Run("global initialization", func() {
|
||||
// Initialize global instances
|
||||
healthMgr := InitializeBackendHealth(cfg.Client.FastProxyClient, cfg.Server.HostGraphQL, cfg.Logger)
|
||||
healthMgr := InitializeBackendHealth(cfg.Client.FastProxyClient, cfg.Server.HostGraphQL, "", cfg.Logger)
|
||||
poolMgr := NewConnectionPoolManager(cfg.Client.FastProxyClient)
|
||||
|
||||
// Set global instances
|
||||
@@ -194,7 +186,7 @@ func (suite *ConnectionResilienceTestSuite) TestIntegratedHealthManagement() {
|
||||
// and may have already fired earlier in the process (e.g. via parseConfig in
|
||||
// another test), in which case it returns whatever the global currently is —
|
||||
// which TearDownTest above just nilled.
|
||||
healthMgr := NewBackendHealthManager(cfg.Client.FastProxyClient, cfg.Server.HostGraphQL, cfg.Logger)
|
||||
healthMgr := NewBackendHealthManager(cfg.Client.FastProxyClient, cfg.Server.HostGraphQL, "", cfg.Logger)
|
||||
backendHealthManager = healthMgr
|
||||
|
||||
// Start health checking
|
||||
@@ -272,7 +264,7 @@ func TestConnectionResilienceSuite(t *testing.T) {
|
||||
// periodic checking — otherwise the backend could never be marked healthy
|
||||
// again even after it recovers.
|
||||
func (suite *ConnectionResilienceTestSuite) TestHealthChecksStartAfterStartupTimeout() {
|
||||
bhm := NewBackendHealthManager(cfg.Client.FastProxyClient, "http://invalid-url:99999", cfg.Logger)
|
||||
bhm := NewBackendHealthManager(cfg.Client.FastProxyClient, "http://invalid-url:99999", "", cfg.Logger)
|
||||
err := bhm.WaitForBackendReady(200 * time.Millisecond)
|
||||
suite.Error(err, "expected timeout for unreachable backend")
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ func TestCoverageMicro_BackendHealth(t *testing.T) {
|
||||
client := &fasthttp.Client{}
|
||||
|
||||
t.Run("updateHealthStatus healthy→unhealthy transition", func(t *testing.T) {
|
||||
bhm := NewBackendHealthManager(client, "http://localhost:9999", logger)
|
||||
bhm := NewBackendHealthManager(client, "http://localhost:9999", "", logger)
|
||||
defer bhm.Shutdown()
|
||||
|
||||
// Start healthy
|
||||
@@ -203,7 +203,7 @@ func TestCoverageMicro_BackendHealth(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("updateHealthStatus unhealthy→healthy resets counter", func(t *testing.T) {
|
||||
bhm := NewBackendHealthManager(client, "http://localhost:9999", logger)
|
||||
bhm := NewBackendHealthManager(client, "http://localhost:9999", "", logger)
|
||||
defer bhm.Shutdown()
|
||||
|
||||
bhm.isHealthy.Store(false)
|
||||
@@ -219,7 +219,7 @@ func TestCoverageMicro_BackendHealth(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("GetLastHealthCheck round-trip", func(t *testing.T) {
|
||||
bhm := NewBackendHealthManager(client, "http://localhost:9999", logger)
|
||||
bhm := NewBackendHealthManager(client, "http://localhost:9999", "", logger)
|
||||
defer bhm.Shutdown()
|
||||
|
||||
before := time.Now()
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/sony/gobreaker"
|
||||
"github.com/sony/gobreaker/v2"
|
||||
)
|
||||
|
||||
// Error codes for structured error responses
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/sony/gobreaker"
|
||||
"github.com/sony/gobreaker/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ import (
|
||||
const (
|
||||
initialDelay = 60 * time.Second
|
||||
cleanupInterval = 1 * time.Hour
|
||||
|
||||
// minRetentionDays guards against a cutoff at or after NOW(), which would delete all event history.
|
||||
minRetentionDays = 1
|
||||
)
|
||||
|
||||
// Use parameterized queries to prevent SQL injection
|
||||
@@ -46,6 +49,10 @@ func enableHasuraEventCleaner(ctx context.Context) error {
|
||||
logger := cfg.Logger
|
||||
cfgMutex.RUnlock()
|
||||
|
||||
if clearOlderThan < minRetentionDays {
|
||||
return fmt.Errorf("HASURA_EVENT_CLEANER_OLDER_THAN must be at least %d day, got %d: event cleaner not started", minRetentionDays, clearOlderThan)
|
||||
}
|
||||
|
||||
logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "Event cleaner enabled",
|
||||
Pairs: map[string]any{"interval_in_days": clearOlderThan},
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// Pins theorems/Theorems/EventCleaner.lean: a retention below one day would delete all event history.
|
||||
func TestEnableHasuraEventCleaner_RejectsNonPositiveRetention(t *testing.T) {
|
||||
for _, days := range []int{0, -1, -30} {
|
||||
cfgMutex.Lock()
|
||||
cfg = &config{Logger: libpack_logger.New()}
|
||||
cfg.HasuraEventCleaner.Enable = true
|
||||
cfg.HasuraEventCleaner.EventMetadataDb = "postgres://fake:[email protected]:1/fake"
|
||||
cfg.HasuraEventCleaner.ClearOlderThan = days
|
||||
cfgMutex.Unlock()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
err := enableHasuraEventCleaner(ctx)
|
||||
cancel()
|
||||
assert.Error(t, err, "days=%d", days)
|
||||
}
|
||||
}
|
||||
@@ -2,78 +2,78 @@ module github.com/lukaszraczylo/graphql-monitoring-proxy
|
||||
|
||||
go 1.26.0
|
||||
|
||||
toolchain go1.26.6
|
||||
toolchain go1.27.3
|
||||
|
||||
require (
|
||||
github.com/MicahParks/keyfunc/v3 v3.8.2
|
||||
github.com/VictoriaMetrics/metrics v1.44.0
|
||||
github.com/alicebob/miniredis/v2 v2.33.0
|
||||
github.com/avast/retry-go/v4 v4.7.0
|
||||
github.com/goccy/go-json v0.10.6
|
||||
github.com/gofiber/contrib/v3/websocket v1.2.6
|
||||
github.com/gofiber/fiber/v3 v3.5.0
|
||||
github.com/VictoriaMetrics/metrics v1.44.1
|
||||
github.com/alicebob/miniredis/v2 v2.40.0
|
||||
github.com/avast/retry-go/v5 v5.0.0
|
||||
github.com/goccy/go-json v0.12.0
|
||||
github.com/gofiber/contrib/v3/websocket v1.2.10
|
||||
github.com/gofiber/fiber/v3 v3.6.1
|
||||
github.com/gofrs/flock v0.13.1
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gookit/goutil v0.8.0
|
||||
github.com/gookit/goutil v0.8.6
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/graphql-go/graphql v0.8.1
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/jackc/pgx/v5 v5.12.0
|
||||
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/lukaszraczylo/oss-telemetry v0.2.3
|
||||
github.com/redis/go-redis/v9 v9.22.0
|
||||
github.com/sony/gobreaker v1.0.0
|
||||
github.com/lukaszraczylo/oss-telemetry v0.2.5
|
||||
github.com/redis/go-redis/v9 v9.23.0
|
||||
github.com/sony/gobreaker/v2 v2.4.0
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/valyala/fasthttp v1.74.0
|
||||
go.opentelemetry.io/otel v1.46.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0
|
||||
go.opentelemetry.io/otel/sdk v1.46.0
|
||||
go.opentelemetry.io/otel/trace v1.46.0
|
||||
github.com/valyala/fasthttp v1.77.0
|
||||
go.opentelemetry.io/otel v1.47.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0
|
||||
go.opentelemetry.io/otel/sdk v1.47.0
|
||||
go.opentelemetry.io/otel/trace v1.47.0
|
||||
go.uber.org/automaxprocs v1.6.0
|
||||
google.golang.org/grpc v1.84.0
|
||||
google.golang.org/grpc v1.85.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/MicahParks/jwkset v0.11.3 // indirect
|
||||
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect
|
||||
github.com/avast/retry-go/v4 v4.7.0 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/fasthttp/websocket v1.5.12 // indirect
|
||||
github.com/go-logr/logr v1.4.4 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/gofiber/schema v1.8.8 // indirect
|
||||
github.com/gofiber/utils/v2 v2.6.1 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 // indirect
|
||||
github.com/gofiber/schema v1.9.2 // indirect
|
||||
github.com/gofiber/utils/v2 v2.6.3 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.33.0 // indirect
|
||||
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.20.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.3 // indirect
|
||||
github.com/klauspost/compress v1.20.3 // indirect
|
||||
github.com/mattn/go-colorable v0.1.16 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/molecule-man/go-brrr v1.1.1 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/molecule-man/go-brrr v1.3.1 // indirect
|
||||
github.com/philhofer/fwd v1.2.1 // indirect
|
||||
github.com/savsgio/gotils v0.0.0-20250924091648-bce9a52d7761 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/tinylib/msgp v1.6.5 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fastrand v1.1.0 // indirect
|
||||
github.com/valyala/histogram v1.2.0 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.2 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.46.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 // indirect
|
||||
go.opentelemetry.io/otel/log v1.47.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.47.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.11.1 // indirect
|
||||
go.uber.org/atomic v1.12.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/net v0.59.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/term v0.46.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.6 // indirect
|
||||
golang.org/x/crypto v0.58.0 // indirect
|
||||
golang.org/x/net v0.62.0 // indirect
|
||||
golang.org/x/sync v0.24.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
golang.org/x/term v0.47.0 // indirect
|
||||
golang.org/x/text v0.43.0 // indirect
|
||||
golang.org/x/time v0.16.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260921155816-b14227669459 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260921155816-b14227669459 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20261026165753-2ffab32bce7e // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
)
|
||||
|
||||
@@ -2,14 +2,14 @@ github.com/MicahParks/jwkset v0.11.3 h1:Phli4RdTDdIdLXZpuO7abkwZyzIk0RDTUPVVBHPR
|
||||
github.com/MicahParks/jwkset v0.11.3/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
|
||||
github.com/MicahParks/keyfunc/v3 v3.8.2 h1:eydEwk/pBAVrDIpmFfB/gkCcrp++xQ7YYXirrI2zlWE=
|
||||
github.com/MicahParks/keyfunc/v3 v3.8.2/go.mod h1:T4snFPe26GwMg45bBAdM5P6qWQyLxZHLwBhxR/9PnCs=
|
||||
github.com/VictoriaMetrics/metrics v1.44.0 h1:Fr8yqQSV+ZfYaDD/anqk1E8e9YPgfleSleJmAI0M0Tw=
|
||||
github.com/VictoriaMetrics/metrics v1.44.0/go.mod h1:xDM82ULLYCYdFRgQ2JBxi8Uf1+8En1So9YUwlGTOqTc=
|
||||
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk=
|
||||
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
|
||||
github.com/alicebob/miniredis/v2 v2.33.0 h1:uvTF0EDeu9RLnUEG27Db5I68ESoIxTiXbNUiji6lZrA=
|
||||
github.com/alicebob/miniredis/v2 v2.33.0/go.mod h1:MhP4a3EU7aENRi9aO+tHfTBZicLqQevyi/DJpoj6mi0=
|
||||
github.com/VictoriaMetrics/metrics v1.44.1 h1:OMyGrMkhI3hnq+IpJ1gZK9VhXcsOjUX1CoOFv//bJ0I=
|
||||
github.com/VictoriaMetrics/metrics v1.44.1/go.mod h1:xDM82ULLYCYdFRgQ2JBxi8Uf1+8En1So9YUwlGTOqTc=
|
||||
github.com/alicebob/miniredis/v2 v2.40.0 h1:6IgTQbvm2JiMYZ8yur/acNJzVjtSOWCelxXbk/LUBXc=
|
||||
github.com/alicebob/miniredis/v2 v2.40.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/avast/retry-go/v4 v4.7.0 h1:yjDs35SlGvKwRNSykujfjdMxMhMQQM0TnIjJaHB+Zio=
|
||||
github.com/avast/retry-go/v4 v4.7.0/go.mod h1:ZMPDa3sY2bKgpLtap9JRUgk2yTAba7cgiFhqxY2Sg6Q=
|
||||
github.com/avast/retry-go/v5 v5.0.0 h1:kf1Qc2UsTZ4qq8elDymqfbISvkyMuhgRxuJqX2NHP7k=
|
||||
github.com/avast/retry-go/v5 v5.0.0/go.mod h1://d+usmKWio1agtZfS1H/ltTqwtIfBnRq9zEwjc3eH8=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
@@ -21,25 +21,25 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE=
|
||||
github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg=
|
||||
github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo=
|
||||
github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/fxamacker/cbor/v2 v2.9.6 h1:NHZPxuUjJXQjX9itA785y/5NKSlI4HQ+S7noZsmI7hs=
|
||||
github.com/fxamacker/cbor/v2 v2.9.6/go.mod h1:4EaZp6ClVunETjQ4GHmGNzysDK91Lr+r6Yz6tJqzVCw=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-json v0.12.0 h1:pD+oWouAiz4yruwXIt/iT+lOMeyhr1aTtxzaMP/RgVI=
|
||||
github.com/goccy/go-json v0.12.0/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY=
|
||||
github.com/goccy/go-reflect v1.2.0 h1:O0T8rZCuNmGXewnATuKYnkL0xm6o8UNOJZd/gOkb9ms=
|
||||
github.com/goccy/go-reflect v1.2.0/go.mod h1:n0oYZn8VcV2CkWTxi8B9QjkCoq6GTtCEdfmR66YhFtE=
|
||||
github.com/gofiber/contrib/v3/websocket v1.2.6 h1:5XcHejNCq460uLSdDAAp11RIrDGMHax07d0zWUZXgYc=
|
||||
github.com/gofiber/contrib/v3/websocket v1.2.6/go.mod h1:QHnpSvNDsoVHrfSvnMl9ptx6x4l83moCtiT+4XM++nw=
|
||||
github.com/gofiber/fiber/v3 v3.5.0 h1:dk7TOUH6DXJGtOLsN2XEG+0ZML7cznzHILTVozbNEK8=
|
||||
github.com/gofiber/fiber/v3 v3.5.0/go.mod h1:GOVDTW+gjJvfe0iJyVujbQ1Lnx+JUjFySJRI/9/xX/w=
|
||||
github.com/gofiber/schema v1.8.8 h1:md5t65+Z9LttOuj7FdMcQ63D2+6GniT2ganLXwvTJM4=
|
||||
github.com/gofiber/schema v1.8.8/go.mod h1:Hzf0L/fTPP1IwOAwKipKflm9C2722aebnSKJCMOgb1I=
|
||||
github.com/gofiber/utils/v2 v2.6.1 h1:x3zsS/oUQz0ia2Zx8XXtG/7naR5a+/95FRO8mwQ5QQ4=
|
||||
github.com/gofiber/utils/v2 v2.6.1/go.mod h1:tc9KqdRAQZRD/u0mN2d1ZHROGheSZv+0FcC9cg5OEdw=
|
||||
github.com/gofiber/contrib/v3/websocket v1.2.10 h1:tUyRcHSH9GUsh6WO03Xn+Kjvt3850W6raF6WPngdJLE=
|
||||
github.com/gofiber/contrib/v3/websocket v1.2.10/go.mod h1:O6MOrNHCVdCrrstdLrYmRIL0eJy2N87Wa/y2dszuxvQ=
|
||||
github.com/gofiber/fiber/v3 v3.6.1 h1:wYjbgqpJm6VErIVLSGzGpX7lLZyTNgElsj8nQ1i+iFo=
|
||||
github.com/gofiber/fiber/v3 v3.6.1/go.mod h1:+nxjBal3gw0EPf3gqK5EmtxaxTL01+zbSTagFletlDI=
|
||||
github.com/gofiber/schema v1.9.2 h1:Gzw2PF1K5DpWgG2L51/9t9dkmxw4kqK6auKMvgD9mIU=
|
||||
github.com/gofiber/schema v1.9.2/go.mod h1:219nYAgzIEBWl/aHCyhQtFoXBvfLjsC6+FI3DUN1+Ck=
|
||||
github.com/gofiber/utils/v2 v2.6.3 h1:VlnOB89rFnonlXoYOkESOkXtIkAw4CBBwpBHu+/a/YQ=
|
||||
github.com/gofiber/utils/v2 v2.6.3/go.mod h1:Mq3OXwyBPxORb3jPafy6Swki4/TJJW9TDPHeerXkXpg=
|
||||
github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo=
|
||||
github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
@@ -50,124 +50,124 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gookit/goutil v0.8.0 h1:efZWxfesXw8+5tQfTfRMSIC6A0ax527/H+A/aIiaSrw=
|
||||
github.com/gookit/goutil v0.8.0/go.mod h1:vJS9HXctYTCLtCsZot5L5xF+O1oR17cDYO9R0HxBmnU=
|
||||
github.com/gookit/goutil v0.8.6 h1:8LfEg9xVDdlQ1yWzk0seLslJCUPt8YnZQo5v0ycGs3E=
|
||||
github.com/gookit/goutil v0.8.6/go.mod h1:vJS9HXctYTCLtCsZot5L5xF+O1oR17cDYO9R0HxBmnU=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/graphql-go/graphql v0.8.1 h1:p7/Ou/WpmulocJeEx7wjQy611rtXGQaAcXGqanuMMgc=
|
||||
github.com/graphql-go/graphql v0.8.1/go.mod h1:nKiHzRM0qopJEwCITUuIsxk9PlVlwIiiI8pnJEhordQ=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 h1:Bd7KaOxzULLxtZ/K5s1aLbWhR0+5RToO65TXHsf3bqQ=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0/go.mod h1:nN7ts3dFXKtCZWc//yfkpcQNKJABg16/uDVAZpLDalo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.33.0 h1:5T5Ns3kMpWr+z3s5ZA0e7ljVyWNVNxK76sHPAxi5D1k=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.33.0/go.mod h1:oWNYw09cH8wl8fFgC5hKlBLxR4XfKy2KQpP8MJjeCcc=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
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.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
||||
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/jackc/pgx/v5 v5.12.0 h1:hWwax8YiGT0YiN50U1oIDn5kgR7KC2tG3rmavwpPkxY=
|
||||
github.com/jackc/pgx/v5 v5.12.0/go.mod h1:u2Fi6no03HCSBYE+ZVdLxbwdu3xyi2vU+K/YNJioSds=
|
||||
github.com/jackc/puddle/v2 v2.2.3 h1:f8HoREISb3l5eO9coPgK7U3hKLhWGokOFVcThl5jrao=
|
||||
github.com/jackc/puddle/v2 v2.2.3/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4=
|
||||
github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||
github.com/lukaszraczylo/ask v0.0.0-20240916204100-6e9ef53a62d9 h1:pL8B9mjv6RPUfKYYGm/uJ7QL6Ndf+z+OEl0qJE6KmEc=
|
||||
github.com/lukaszraczylo/ask v0.0.0-20240916204100-6e9ef53a62d9/go.mod h1:M+UVdyqZs++xtEPrascaVmZdOMhCnxjZ2SgH+xHpR0c=
|
||||
github.com/lukaszraczylo/go-ratecounter v0.1.12 h1:VO6hHYGw/Jy9JUizXf/bS0AI2QX1ueWWAWckMFVJ/w4=
|
||||
github.com/lukaszraczylo/go-ratecounter v0.1.12/go.mod h1:TqXEOCtFJStk1i0tkipprv1kiDHGon1MVUisjSTBSKM=
|
||||
github.com/lukaszraczylo/go-simple-graphql v1.2.89 h1:Xbu1Ny+a0lT2Sr2SaSC8mcHmGQDwGD4TJKk4DDd+PwA=
|
||||
github.com/lukaszraczylo/go-simple-graphql v1.2.89/go.mod h1:PxQYblQDZISmYYj8sNfazAWxAOh1rhAtU208y+uPV8s=
|
||||
github.com/lukaszraczylo/oss-telemetry v0.2.3 h1:xoDtBqeZGmXj7IteiE1M5WMuzeoqag58qEleI0Cf2Ms=
|
||||
github.com/lukaszraczylo/oss-telemetry v0.2.3/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/lukaszraczylo/oss-telemetry v0.2.5 h1:W6Qgwp7IY4yBRO361MmnEAmM59motdQpbAFAx8v6fJM=
|
||||
github.com/lukaszraczylo/oss-telemetry v0.2.5/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
|
||||
github.com/mattn/go-colorable v0.1.16 h1:+2YjeAe7xP1OQbZW1jERPWWCDLlNqFNCvjiqGiAt84A=
|
||||
github.com/mattn/go-colorable v0.1.16/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/molecule-man/go-brrr v1.1.1 h1:KYwusQhjtV3smALnhmafZf4OBrh7vP4p8O47DyUmmuY=
|
||||
github.com/molecule-man/go-brrr v1.1.1/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/molecule-man/go-brrr v1.3.1 h1:EwaJUzRIlp/dFmIpzq4J17CA5Zi5OHo2vOGmu09hF1w=
|
||||
github.com/molecule-man/go-brrr v1.3.1/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4=
|
||||
github.com/philhofer/fwd v1.2.1 h1:UtpS73q8bFgy/yH3gCq8LSNvj5gv/FowNOiWZa7uKwQ=
|
||||
github.com/philhofer/fwd v1.2.1/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
|
||||
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
|
||||
github.com/redis/go-redis/v9 v9.23.0 h1:/TTVdJa8BRNsybwwGGnmiYktHNlUXtbOQxlWpHBPL3s=
|
||||
github.com/redis/go-redis/v9 v9.23.0/go.mod h1:EWP2UUk+XDaI1s5nQB5mablZN6vccXlmBQTyfnF87D0=
|
||||
github.com/savsgio/gotils v0.0.0-20250924091648-bce9a52d7761 h1:McifyVxygw1d67y6vxUqls2D46J8W9nrki9c8c0eVvE=
|
||||
github.com/savsgio/gotils v0.0.0-20250924091648-bce9a52d7761/go.mod h1:Vi9gvHvTw4yCUHIznFl5TPULS7aXwgaTByGeBY75Wko=
|
||||
github.com/shamaton/msgpack/v3 v3.2.3 h1:0r0aZtrXDFOPpwWJTEOpFq+Ih/JmMLm0pqLN5mSilrw=
|
||||
github.com/shamaton/msgpack/v3 v3.2.3/go.mod h1:sgBYvEiyz8JR1NC3yGRoPVME9xXovpnh3l/plW1nfRo=
|
||||
github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ=
|
||||
github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
|
||||
github.com/shamaton/msgpack/v3 v3.2.6 h1:i8I++0ehiIO8yK4+ZU6j5MIWFz5e8J6EeJvdM/qeyTw=
|
||||
github.com/shamaton/msgpack/v3 v3.2.6/go.mod h1:sgBYvEiyz8JR1NC3yGRoPVME9xXovpnh3l/plW1nfRo=
|
||||
github.com/sony/gobreaker/v2 v2.4.0 h1:g2KJRW1Ubty3+ZOcSEUN7K+REQJdN6yo6XvaML+jptg=
|
||||
github.com/sony/gobreaker/v2 v2.4.0/go.mod h1:pTyFJgcZ3h2tdQVLZZruK2C0eoFL1fb/G83wK1ZQl+s=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/tinylib/msgp v1.6.5 h1:iAH6XTP7BpzErjBZlujJQXxA+GmRi2YWs6M4KdLtNbE=
|
||||
github.com/tinylib/msgp v1.6.5/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.74.0 h1:wMS9fnO2QTALozYx5pId2Vi7ZwU/epUkY8i/KPWCHoU=
|
||||
github.com/valyala/fasthttp v1.74.0/go.mod h1:3ARmLamUcw7ElxVtC8PXaGzQ6VEuvnetlkrwIklQBSE=
|
||||
github.com/valyala/fasthttp v1.77.0 h1:6mxBJdHabRmU4Cmw79Uqhgvjzsp6pjOaQk2t8COflzo=
|
||||
github.com/valyala/fasthttp v1.77.0/go.mod h1:m8oTPC8riDDky07invpl+Gz3RixnAN5BbwyUuVB5H2o=
|
||||
github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G8=
|
||||
github.com/valyala/fastrand v1.1.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ=
|
||||
github.com/valyala/histogram v1.2.0 h1:wyYGAZZt3CpwUiIb9AU/Zbllg1llXyrtApRS815OLoQ=
|
||||
github.com/valyala/histogram v1.2.0/go.mod h1:Hb4kBwb4UxsaNbbbh+RRz8ZR6pdodR57tzWUS3BUzXY=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
|
||||
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
|
||||
go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 h1:w53CDeOA/Kurp7yRsegSr6pbbr759dOvJ+yNmWM6Hxs=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0/go.mod h1:BOmGMCbAtvcJiSJ+hLuhgPLdDbimnraSl8irz3iY8sY=
|
||||
go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8=
|
||||
go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o=
|
||||
go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI=
|
||||
go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4=
|
||||
go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
|
||||
go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
|
||||
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
|
||||
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
|
||||
go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM=
|
||||
go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 h1:julhjPeUH/q/7hinbSdDdqt5h7Zw9YWmRlWRhI0jd54=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0/go.mod h1:Ao2mz688LH/tFf0yMAenidq6k2YNSx6SIY2q6jDACck=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 h1:UFpxOpYPmMNUtOWhdb+nC1WELIgVf8z9higURrbzYU4=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0/go.mod h1:YBGjxe3lt0jtXoEXxGrGhv/jM7oUBUDVK7zhAvNyEjc=
|
||||
go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI=
|
||||
go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw=
|
||||
go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY=
|
||||
go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU=
|
||||
go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I=
|
||||
go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw=
|
||||
go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM=
|
||||
go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0=
|
||||
go.opentelemetry.io/proto/otlp v1.11.1 h1:SCrPqH9NE5CmBrOqpjNCiT0X5mGwMyGERXdogfR7Yjw=
|
||||
go.opentelemetry.io/proto/otlp v1.11.1/go.mod h1:wq8W4aSf6bOy8RZyESOeQFr9Pu7wydWX/TY4sgvrTPg=
|
||||
go.uber.org/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE=
|
||||
go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk=
|
||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
||||
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
go.yaml.in/yaml/v3 v3.0.6 h1:zcDfvjXEqTQmpjniUBLc91xYJMB2c4QOFxEF/LCEckM=
|
||||
go.yaml.in/yaml/v3 v3.0.6/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||
golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
|
||||
golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
|
||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
|
||||
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
|
||||
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
|
||||
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
|
||||
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
|
||||
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
|
||||
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
|
||||
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260921155816-b14227669459 h1:GS9OIt/j7c8bvBjYNgnKQysVfmV7e4jM0H8ZK95G4t8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260921155816-b14227669459/go.mod h1:PX5/4vemwVoXtwEcRDWwcR1/r0qrosfx3qoVADMwnVE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260921155816-b14227669459 h1:b0xCahf3FK2m2Cv0p4vTozGPWncCvLfwV86UNg8xWU8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260921155816-b14227669459/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc=
|
||||
google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0=
|
||||
google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20261026165753-2ffab32bce7e h1:e789sLZjeTdc02ZCQGTSzHOu0fR73+dwPQVKUp67Z88=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20261026165753-2ffab32bce7e/go.mod h1:vHV3wJM9WZyfV7GyB+OQGq5dWVrFwCDxapPK4WlRD88=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b h1:6WNWHZk91dp4gZ25642OALkuG1UVNZbwENJD19DnB8k=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc=
|
||||
google.golang.org/grpc v1.85.0 h1:BPBCfuVRQ1qvBhKDOq9uvQllMvO59rKsYhXpp7Dd8vw=
|
||||
google.golang.org/grpc v1.85.0/go.mod h1:0rQrOD58yjal/0EaVbGBANindixsCZNQPDV3VKtaKuQ=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
libpack_cache "github.com/lukaszraczylo/graphql-monitoring-proxy/cache"
|
||||
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
|
||||
"github.com/sony/gobreaker"
|
||||
"github.com/sony/gobreaker/v2"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
|
||||
@@ -246,6 +246,33 @@ func validateJWTClaimPath(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBackendHealthcheckURL(rawURL string) error {
|
||||
if rawURL == "" {
|
||||
return nil
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
// Parse errors can include credentials from the rejected URL.
|
||||
return fmt.Errorf("must be a valid HTTP or HTTPS URL")
|
||||
}
|
||||
if (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.Opaque != "" {
|
||||
return fmt.Errorf("must use HTTP or HTTPS with a nonempty host")
|
||||
}
|
||||
if u.User != nil {
|
||||
return fmt.Errorf("must not contain userinfo or credentials")
|
||||
}
|
||||
if u.Fragment != "" {
|
||||
return fmt.Errorf("must not contain a fragment")
|
||||
}
|
||||
if port := u.Port(); port != "" {
|
||||
n, err := strconv.Atoi(port)
|
||||
if err != nil || n < 1 || n > 65535 {
|
||||
return fmt.Errorf("must use a port between 1 and 65535")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseConfig loads and parses the configuration.
|
||||
func parseConfig() {
|
||||
libpack_config.PKG_NAME = "graphql_proxy"
|
||||
@@ -259,6 +286,11 @@ func parseConfig() {
|
||||
c.Server.BindAddress = getDetailsFromEnv("BIND_ADDRESS", "")
|
||||
c.Server.HostGraphQL = getDetailsFromEnv("HOST_GRAPHQL", "http://localhost/")
|
||||
c.Server.HostGraphQLReadOnly = getDetailsFromEnv("HOST_GRAPHQL_READONLY", "")
|
||||
c.Server.BackendHealthcheckURL = getDetailsFromEnv("BACKEND_HEALTHCHECK_URL", "")
|
||||
if err := validateBackendHealthcheckURL(c.Server.BackendHealthcheckURL); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "CRITICAL ERROR: Invalid BACKEND_HEALTHCHECK_URL: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Client configurations
|
||||
c.Client.JWTUserClaimPath = getDetailsFromEnv("JWT_USER_CLAIM_PATH", "")
|
||||
c.Client.JWTRoleClaimPath = getDetailsFromEnv("JWT_ROLE_CLAIM_PATH", "")
|
||||
@@ -678,7 +710,7 @@ func parseConfig() {
|
||||
|
||||
// Initialize backend health manager
|
||||
if cfg.Server.HostGraphQL != "" {
|
||||
healthMgr := InitializeBackendHealth(cfg.Client.FastProxyClient, cfg.Server.HostGraphQL, cfg.Logger)
|
||||
healthMgr := InitializeBackendHealth(cfg.Client.FastProxyClient, cfg.Server.HostGraphQL, cfg.Server.BackendHealthcheckURL, cfg.Logger)
|
||||
// Start health checking in background
|
||||
healthMgr.StartHealthChecking()
|
||||
}
|
||||
|
||||
@@ -17,13 +17,13 @@ import (
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/avast/retry-go/v4"
|
||||
"github.com/avast/retry-go/v5"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
libpack_cache "github.com/lukaszraczylo/graphql-monitoring-proxy/cache"
|
||||
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
||||
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
|
||||
libpack_tracing "github.com/lukaszraczylo/graphql-monitoring-proxy/tracing"
|
||||
"github.com/sony/gobreaker"
|
||||
"github.com/sony/gobreaker/v2"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
@@ -91,7 +91,7 @@ var circuitBreakerCountWindow = 10 * time.Minute
|
||||
|
||||
// Global circuit breaker
|
||||
var (
|
||||
cb *gobreaker.CircuitBreaker
|
||||
cb *gobreaker.CircuitBreaker[any]
|
||||
cbMutex sync.RWMutex
|
||||
)
|
||||
|
||||
@@ -240,7 +240,7 @@ func initCircuitBreaker(config *config) {
|
||||
}
|
||||
|
||||
// Initialize the circuit breaker
|
||||
cb = gobreaker.NewCircuitBreaker(cbSettings)
|
||||
cb = gobreaker.NewCircuitBreaker[any](cbSettings)
|
||||
|
||||
config.Logger.Info(&libpack_logger.LogMessage{
|
||||
Message: "Circuit breaker initialized",
|
||||
@@ -530,7 +530,7 @@ func effectiveCircuitBackoff(base time.Duration, multiplier float64, maxBackoff
|
||||
// allocation- and lock-free on the hot path. now is a parameter (not
|
||||
// time.Now() read internally) so tests can drive this deterministically
|
||||
// without wall-clock sleeps.
|
||||
func circuitBackoffGateBlocks(config *config, breaker *gobreaker.CircuitBreaker, now time.Time) bool {
|
||||
func circuitBackoffGateBlocks(config *config, breaker *gobreaker.CircuitBreaker[any], now time.Time) bool {
|
||||
trips := cbBackoff.trips()
|
||||
if trips <= 0 {
|
||||
return false
|
||||
@@ -740,11 +740,7 @@ func performProxyRequest(c fiber.Ctx, proxyURL string) error {
|
||||
}, proxyErr
|
||||
}
|
||||
|
||||
return &CoalescedResponse{
|
||||
Body: c.Response().Body(),
|
||||
StatusCode: c.Response().StatusCode(),
|
||||
// Headers intentionally left nil; not populated or read anywhere.
|
||||
}, nil
|
||||
return snapshotCoalescedResponse(c), nil
|
||||
})
|
||||
|
||||
// Check for error from rc.Do (though it typically returns nil)
|
||||
@@ -773,6 +769,17 @@ func performProxyRequest(c fiber.Ctx, proxyURL string) error {
|
||||
return performProxyRequestCore(c, proxyURL, cacheKey)
|
||||
}
|
||||
|
||||
// snapshotCoalescedResponse captures the primary's response for waiters. It
|
||||
// copies the body because fasthttp reuses the buffer once the primary handler
|
||||
// returns, while waiters can still be reading.
|
||||
func snapshotCoalescedResponse(c fiber.Ctx) *CoalescedResponse {
|
||||
return &CoalescedResponse{
|
||||
Body: bytes.Clone(c.Response().Body()),
|
||||
StatusCode: c.Response().StatusCode(),
|
||||
// Headers intentionally left nil; not populated or read anywhere.
|
||||
}
|
||||
}
|
||||
|
||||
// performProxyRequestCore executes the proxy request with retries and circuit breaker
|
||||
// This is the core implementation used by both direct calls and coalesced requests
|
||||
func performProxyRequestCore(c fiber.Ctx, proxyURL string, cacheKey string) error {
|
||||
@@ -978,10 +985,7 @@ func performProxyRequestWithEnhancedRetries(c fiber.Ctx, proxyURL string, backen
|
||||
maxDelayTime = time.Duration(maxDelayMs) * time.Millisecond
|
||||
}
|
||||
|
||||
return retry.Do(
|
||||
func() error {
|
||||
return executeProxyAttempt(c, req)
|
||||
},
|
||||
return retry.New(
|
||||
retry.Attempts(attempts),
|
||||
retry.DelayType(retry.BackOffDelay),
|
||||
retry.Delay(initialDelay),
|
||||
@@ -1056,7 +1060,9 @@ func performProxyRequestWithEnhancedRetries(c fiber.Ctx, proxyURL string, backen
|
||||
}
|
||||
return true
|
||||
}),
|
||||
)
|
||||
).Do(func() error {
|
||||
return executeProxyAttempt(c, req)
|
||||
})
|
||||
}
|
||||
|
||||
// isConnectionError checks if the error is a connection-related error
|
||||
|
||||
+42
-113
@@ -8,17 +8,17 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
goratecounter "github.com/lukaszraczylo/go-ratecounter"
|
||||
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
||||
)
|
||||
|
||||
// RateLimitConfig holds the rate limit configuration for a role
|
||||
type RateLimitConfig struct {
|
||||
RateCounterTicker *goratecounter.RateCounter
|
||||
// sustained enforces Req over the full Interval.
|
||||
sustained *attemptWindow
|
||||
// burstCounter enforces Burst over a short trailing window, independent
|
||||
// of RateCounterTicker's sustained-rate window. Nil when Burst <= 0
|
||||
// of the sustained window. Nil when Burst <= 0
|
||||
// (burst disabled). See burstWindow and checkRateLimit for semantics.
|
||||
burstCounter *burstWindow
|
||||
burstCounter *attemptWindow
|
||||
Endpoints []string `json:"endpoints,omitempty"`
|
||||
Interval time.Duration `json:"interval"`
|
||||
Req int `json:"req"`
|
||||
@@ -107,51 +107,6 @@ func burstWindowDuration(interval time.Duration) time.Duration {
|
||||
return w
|
||||
}
|
||||
|
||||
// burstWindow is a minimal, clock-injectable, trailing-window request
|
||||
// counter used to enforce RateLimitConfig.Burst independently of the
|
||||
// sustained-rate RateCounterTicker.
|
||||
//
|
||||
// [email protected] cannot provide this second, shorter window
|
||||
// cleanly: the window length lives on *RateCounter itself, not per named
|
||||
// Counter (helpers.go: GetRate -> c.parent.interval.Seconds()), so two
|
||||
// different window lengths need two separate RateCounter instances. Each
|
||||
// instance drives its own background goroutine off wall-clock time.Now with
|
||||
// no seam to inject a fake clock (helpers.go: start(), cleanUpOldValues(),
|
||||
// addValue() all call time.Now() directly). That would force any test of
|
||||
// window rollover to sleep on real time. burstWindow is implemented here
|
||||
// instead, with a swappable clock, so tests can advance time deterministically.
|
||||
type burstWindow struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
window time.Duration
|
||||
hits []time.Time
|
||||
}
|
||||
|
||||
// newBurstWindow returns a burstWindow that tracks hits over the trailing
|
||||
// duration window, using the real wall clock.
|
||||
func newBurstWindow(window time.Duration) *burstWindow {
|
||||
return &burstWindow{now: time.Now, window: window}
|
||||
}
|
||||
|
||||
// recordAndCount records a hit at the current time, drops hits older than
|
||||
// the trailing window, and returns the resulting hit count within the
|
||||
// window. Safe for concurrent use.
|
||||
func (b *burstWindow) recordAndCount() int {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
now := b.now()
|
||||
cutoff := now.Add(-b.window)
|
||||
live := b.hits[:0]
|
||||
for _, t := range b.hits {
|
||||
if t.After(cutoff) {
|
||||
live = append(live, t)
|
||||
}
|
||||
}
|
||||
live = append(live, now)
|
||||
b.hits = live
|
||||
return len(b.hits)
|
||||
}
|
||||
|
||||
// Variable to hold the current load config function - allows for testing
|
||||
var loadConfigFunc = loadConfigFromPath
|
||||
|
||||
@@ -237,14 +192,12 @@ func loadConfigFromPath(path string) error {
|
||||
|
||||
newRateLimits := make(map[string]RateLimitConfig, len(config.RateLimit))
|
||||
for key, value := range config.RateLimit {
|
||||
value.RateCounterTicker = goratecounter.NewRateCounter().WithConfig(goratecounter.RateCounterConfig{
|
||||
Interval: value.Interval,
|
||||
})
|
||||
value.sustained = newAttemptWindow(value.Interval, value.Req)
|
||||
|
||||
// Burst disabled (<=0) leaves burstCounter nil, so checkRateLimit
|
||||
// performs the Req-only check exactly as before burst existed.
|
||||
if value.Burst > 0 {
|
||||
value.burstCounter = newBurstWindow(burstWindowDuration(value.Interval))
|
||||
value.burstCounter = newAttemptWindow(burstWindowDuration(value.Interval), value.Burst)
|
||||
}
|
||||
|
||||
if cfg.LogLevel == "DEBUG" {
|
||||
@@ -279,7 +232,7 @@ func rateLimitedRequest(userID, userRole string) bool {
|
||||
// Try to get config from atomic value first for better performance
|
||||
if configInterface := rateLimitConfigAtomic.Load(); configInterface != nil {
|
||||
if config, ok := configInterface.(map[string]RateLimitConfig); ok {
|
||||
if roleConfig, exists := config[userRole]; exists && roleConfig.RateCounterTicker != nil {
|
||||
if roleConfig, exists := config[userRole]; exists && roleConfig.sustained != nil {
|
||||
return checkRateLimit(userID, userRole, roleConfig, "")
|
||||
}
|
||||
}
|
||||
@@ -290,7 +243,7 @@ func rateLimitedRequest(userID, userRole string) bool {
|
||||
roleConfig, ok := rateLimits[userRole]
|
||||
rateLimitMu.RUnlock()
|
||||
|
||||
if !ok || roleConfig.RateCounterTicker == nil {
|
||||
if !ok || roleConfig.sustained == nil {
|
||||
cfg.Logger.Warning(&libpack_logger.LogMessage{
|
||||
Message: "Rate limit role not found or ticker not initialized - defaulting to deny",
|
||||
Pairs: map[string]any{"user_role": userRole},
|
||||
@@ -304,72 +257,48 @@ func rateLimitedRequest(userID, userRole string) bool {
|
||||
|
||||
// checkRateLimit performs the actual rate limit check.
|
||||
//
|
||||
// Two independent gates apply, both must pass:
|
||||
// Two gates apply, both must pass:
|
||||
//
|
||||
// 1. Sustained: tickerRate (requests measured over the full Interval) must
|
||||
// stay at or below Req. This is the only gate when Burst <= 0, and its
|
||||
// comparison is unchanged from before the burst fix.
|
||||
// 2. Burst (only when roleConfig.burstCounter != nil, i.e. Burst > 0): the
|
||||
// raw request count within a short trailing window (see
|
||||
// burstWindowDuration), much shorter than Interval, must stay at or
|
||||
// below Burst.
|
||||
// 1. Sustained: the request count over the full Interval must stay at or
|
||||
// below Req (Req is per Interval, not per second).
|
||||
// 2. Burst (only when Burst > 0): the request count within a short trailing
|
||||
// window (see burstWindowDuration) must stay at or below Burst.
|
||||
//
|
||||
// Because the burst window is short relative to Interval, a client can
|
||||
// legally land up to Burst requests in a tight cluster - more than a
|
||||
// steady Req-paced stream would deliver in that same short span - as long
|
||||
// as the sustained gate still holds over the full Interval. That is what
|
||||
// makes burst > req a real spike allowance instead of a no-op. Burst is a
|
||||
// short-window ceiling layered ON TOP of the sustained Req rate, not a
|
||||
// replacement for it. burst < req is an unusual config: the burst gate
|
||||
// becomes the tighter, binding constraint whenever requests cluster in
|
||||
// time, but the sustained cap for evenly-spread traffic is still Req
|
||||
// alone. The pre-fix code compared the SAME sustained-window tickerRate
|
||||
// against both Burst and Req, so when burst < req its Burst check tripped
|
||||
// first and Burst was effectively the sustained cap too. This gate's
|
||||
// Req-only sustained check can therefore allow MORE through than the
|
||||
// pre-fix code did for evenly-spread traffic under a burst < req config.
|
||||
// In both cases the burst gate never allows more than Burst requests
|
||||
// within its own window (fail-safe).
|
||||
// The burst window lies inside the Interval, so its count never exceeds the
|
||||
// sustained count. Burst >= Req therefore never rejects anything the sustained
|
||||
// gate admits; Burst only takes effect when it is below Req.
|
||||
func checkRateLimit(userID, userRole string, roleConfig RateLimitConfig, endpoint string) bool {
|
||||
roleConfig.RateCounterTicker.Incr(1)
|
||||
tickerRate := roleConfig.RateCounterTicker.GetRate()
|
||||
debug := cfg.Logger.IsLevelEnabled(libpack_logger.LEVEL_DEBUG)
|
||||
|
||||
logDetails := map[string]any{
|
||||
"user_role": userRole,
|
||||
"user_id": userID,
|
||||
"rate": tickerRate,
|
||||
"config_rate": roleConfig.Req,
|
||||
"interval": roleConfig.Interval,
|
||||
"endpoint": endpoint,
|
||||
}
|
||||
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Rate limit ticker",
|
||||
Pairs: map[string]any{"log_details": logDetails},
|
||||
})
|
||||
|
||||
if tickerRate > float64(roleConfig.Req) {
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Rate limit exceeded",
|
||||
Pairs: map[string]any{"log_details": logDetails},
|
||||
})
|
||||
if roleConfig.sustained.recordAndExceeds() {
|
||||
if debug {
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Rate limit exceeded",
|
||||
Pairs: map[string]any{
|
||||
"user_role": userRole,
|
||||
"user_id": userID,
|
||||
"config_rate": roleConfig.Req,
|
||||
"interval": roleConfig.Interval,
|
||||
"endpoint": endpoint,
|
||||
},
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if roleConfig.burstCounter != nil {
|
||||
burstCount := roleConfig.burstCounter.recordAndCount()
|
||||
burstLogDetails := map[string]any{
|
||||
"user_role": userRole,
|
||||
"user_id": userID,
|
||||
"burst_count": burstCount,
|
||||
"config_burst": roleConfig.Burst,
|
||||
"endpoint": endpoint,
|
||||
}
|
||||
if burstCount > roleConfig.Burst {
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Burst limit exceeded",
|
||||
Pairs: map[string]any{"log_details": burstLogDetails},
|
||||
})
|
||||
if roleConfig.burstCounter.recordAndExceeds() {
|
||||
if debug {
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Burst limit exceeded",
|
||||
Pairs: map[string]any{
|
||||
"user_role": userRole,
|
||||
"user_id": userID,
|
||||
"config_burst": roleConfig.Burst,
|
||||
"endpoint": endpoint,
|
||||
},
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
+21
-63
@@ -10,7 +10,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
goratecounter "github.com/lukaszraczylo/go-ratecounter"
|
||||
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
||||
)
|
||||
|
||||
@@ -65,8 +64,8 @@ func (suite *Tests) Test_loadRatelimitConfig() {
|
||||
suite.Contains(rateLimits, "user")
|
||||
suite.Equal(100, rateLimits["admin"].Req)
|
||||
suite.Equal(10, rateLimits["user"].Req)
|
||||
suite.NotNil(rateLimits["admin"].RateCounterTicker)
|
||||
suite.NotNil(rateLimits["user"].RateCounterTicker)
|
||||
suite.NotNil(rateLimits["admin"].sustained)
|
||||
suite.NotNil(rateLimits["user"].sustained)
|
||||
})
|
||||
|
||||
// Test loading config from non-existent path
|
||||
@@ -154,23 +153,17 @@ func (suite *Tests) Test_rateLimitedRequest() {
|
||||
rateLimits = make(map[string]RateLimitConfig)
|
||||
|
||||
// Admin role with high limit
|
||||
adminCounter := goratecounter.NewRateCounter().WithConfig(goratecounter.RateCounterConfig{
|
||||
Interval: 1 * time.Second,
|
||||
})
|
||||
rateLimits["admin"] = RateLimitConfig{
|
||||
RateCounterTicker: adminCounter,
|
||||
Interval: 1 * time.Second,
|
||||
Req: 100,
|
||||
sustained: newAttemptWindow(1*time.Second, 100),
|
||||
Interval: 1 * time.Second,
|
||||
Req: 100,
|
||||
}
|
||||
|
||||
// User role with low limit
|
||||
userCounter := goratecounter.NewRateCounter().WithConfig(goratecounter.RateCounterConfig{
|
||||
Interval: 1 * time.Second,
|
||||
})
|
||||
rateLimits["user"] = RateLimitConfig{
|
||||
RateCounterTicker: userCounter,
|
||||
Interval: 1 * time.Second,
|
||||
Req: 2, // Set very low for testing
|
||||
sustained: newAttemptWindow(1*time.Second, 2),
|
||||
Interval: 1 * time.Second,
|
||||
Req: 2, // Set very low for testing
|
||||
}
|
||||
rateLimitMu.Unlock()
|
||||
|
||||
@@ -327,19 +320,17 @@ func (f *fakeClock) Advance(d time.Duration) {
|
||||
}
|
||||
|
||||
// newBurstTestRoleConfig mirrors the RateLimitConfig construction performed
|
||||
// by loadConfigFromPath: a real RateCounterTicker for the sustained check,
|
||||
// by loadConfigFromPath: a real sustained window for the Req check,
|
||||
// and (only when burst > 0) a burstCounter sized by burstWindowDuration.
|
||||
func newBurstTestRoleConfig(interval time.Duration, req, burst int) RateLimitConfig {
|
||||
rc := RateLimitConfig{
|
||||
RateCounterTicker: goratecounter.NewRateCounter().WithConfig(goratecounter.RateCounterConfig{
|
||||
Interval: interval,
|
||||
}),
|
||||
Interval: interval,
|
||||
Req: req,
|
||||
Burst: burst,
|
||||
sustained: newAttemptWindow(interval, req),
|
||||
Interval: interval,
|
||||
Req: req,
|
||||
Burst: burst,
|
||||
}
|
||||
if burst > 0 {
|
||||
rc.burstCounter = newBurstWindow(burstWindowDuration(interval))
|
||||
rc.burstCounter = newAttemptWindow(burstWindowDuration(interval), burst)
|
||||
}
|
||||
return rc
|
||||
}
|
||||
@@ -372,59 +363,26 @@ func Test_checkRateLimit_BurstSemantics(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("b_burst_above_req_allows_short_spike_sustained_still_capped", func(t *testing.T) {
|
||||
t.Run("b_burst_above_req_is_noop_sustained_caps_at_req", func(t *testing.T) {
|
||||
const (
|
||||
interval = 10 * time.Second
|
||||
req = 3
|
||||
burst = 6 // burst > req
|
||||
burst = 6 // burst >= req: dominated by the sustained gate
|
||||
)
|
||||
rc := newBurstTestRoleConfig(interval, req, burst)
|
||||
fc := newFakeClock(time.Now())
|
||||
rc.burstCounter.now = fc.Now
|
||||
|
||||
// A rapid cluster of `burst` requests must all be allowed, even
|
||||
// though burst (6) > req (3): the sustained gate only bounds the
|
||||
// total over the full 10s Interval (tickerRate <= 3 needs count <=
|
||||
// 30), so a small cluster like this never trips it.
|
||||
for i := 1; i <= burst; i++ {
|
||||
for i := 1; i <= req; i++ {
|
||||
if !checkRateLimit("u", "role-b", rc, "") {
|
||||
t.Fatalf("request %d in first burst: got denied, want allowed (<= Burst=%d)", i, burst)
|
||||
t.Fatalf("request %d: got denied, want allowed (<= Req=%d per Interval)", i, req)
|
||||
}
|
||||
}
|
||||
|
||||
// The (burst+1)th request in the SAME short window must be denied
|
||||
// by the burst gate specifically - not by the sustained gate,
|
||||
// which alone would still allow it (tickerRate = 7/10 = 0.7 <= 3).
|
||||
// This is the proof burst > req is not a no-op: it is the gate
|
||||
// that actually fires here.
|
||||
// Clearing the burst window must not readmit: Req is per Interval.
|
||||
fc.Advance(2 * burstWindowDuration(interval))
|
||||
if checkRateLimit("u", "role-b", rc, "") {
|
||||
t.Fatalf("request %d: got allowed, want denied by burst gate (> Burst=%d)", burst+1, burst)
|
||||
}
|
||||
|
||||
// Roll the burst window forward (fake clock only - the real
|
||||
// sustained ticker has not aged, since no real time passed) and
|
||||
// repeat clusters of `burst` requests. Each cluster alone passes
|
||||
// burst, but the cumulative sustained count keeps climbing until
|
||||
// it exceeds req*interval.Seconds() = 30, at which point the
|
||||
// sustained gate must deny regardless of how the burst window
|
||||
// resets. This proves "sustained still capped at Req".
|
||||
sustainedTripped := false
|
||||
for batch := 0; batch < 10 && !sustainedTripped; batch++ {
|
||||
fc.Advance(2 * burstWindowDuration(interval)) // clears the burst window
|
||||
for i := 1; i <= burst; i++ {
|
||||
allowed := checkRateLimit("u", "role-b", rc, "")
|
||||
rate := rc.RateCounterTicker.GetRate()
|
||||
if rate > float64(req) {
|
||||
if allowed {
|
||||
t.Fatalf("batch %d req %d: allowed with sustained rate %.2f > Req=%d", batch, i, rate, req)
|
||||
}
|
||||
sustainedTripped = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sustainedTripped {
|
||||
t.Fatalf("sustained gate never tripped after repeated bursts; expected it to cap total requests at Req over the Interval")
|
||||
t.Fatalf("request %d: got allowed, want denied by the sustained gate (Req=%d per Interval)", req+1, req)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
||||
)
|
||||
|
||||
func setupRateLimitCfg() {
|
||||
cfg = &config{}
|
||||
parseConfig()
|
||||
cfg.Logger = libpack_logger.New()
|
||||
}
|
||||
|
||||
// Pins theorems/Theorems/RateLimitUnits.lean: Req counts requests per Interval, not per second.
|
||||
func TestCheckRateLimit_ReqIsPerInterval(t *testing.T) {
|
||||
const req = 3
|
||||
setupRateLimitCfg()
|
||||
rc := newBurstTestRoleConfig(time.Minute, req, 0)
|
||||
for i := 1; i <= req; i++ {
|
||||
if !checkRateLimit("u", "r", rc, "") {
|
||||
t.Fatalf("request %d denied, want allowed (Req=%d per minute)", i, req)
|
||||
}
|
||||
}
|
||||
if checkRateLimit("u", "r", rc, "") {
|
||||
t.Fatalf("request %d allowed, want denied: Req=%d per minute", req+1, req)
|
||||
}
|
||||
}
|
||||
|
||||
// With Burst >= Req the burst gate never rejects what the sustained gate admits.
|
||||
func TestCheckRateLimit_BurstAboveReqIsNoOp(t *testing.T) {
|
||||
const req, burst = 3, 6
|
||||
setupRateLimitCfg()
|
||||
rc := newBurstTestRoleConfig(time.Minute, req, burst)
|
||||
for i := 1; i <= req; i++ {
|
||||
if !checkRateLimit("u", "r", rc, "") {
|
||||
t.Fatalf("request %d denied", i)
|
||||
}
|
||||
}
|
||||
if checkRateLimit("u", "r", rc, "") {
|
||||
t.Fatalf("request %d allowed, want denied by the sustained gate", req+1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// attemptWindow decides "more than limit attempts in the trailing window" while
|
||||
// storing only the newest limit+1 attempt times. The attempt number limit+1
|
||||
// counted back from now lies inside the window exactly when the full count
|
||||
// exceeds limit (see theorems/Theorems/SlidingWindow.lean), so memory and time
|
||||
// per attempt stay constant however hard a client floods.
|
||||
type attemptWindow struct {
|
||||
now func() time.Time
|
||||
base time.Time // monotonic origin for stored offsets
|
||||
ring []int64 // offsets from base, grows to capacity then wraps
|
||||
mu sync.Mutex
|
||||
window time.Duration
|
||||
head int // index of the oldest stored attempt once the ring is full
|
||||
size int // limit+1: attempts kept; the ring grows lazily up to this
|
||||
}
|
||||
|
||||
func newAttemptWindow(window time.Duration, limit int) *attemptWindow {
|
||||
return &attemptWindow{
|
||||
now: time.Now,
|
||||
base: time.Now(),
|
||||
window: window,
|
||||
size: max(limit, 0) + 1,
|
||||
}
|
||||
}
|
||||
|
||||
// recordAndExceeds records an attempt now and reports whether the attempts in
|
||||
// the trailing window, this one included, outnumber the limit.
|
||||
func (w *attemptWindow) recordAndExceeds() bool {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
t := int64(w.now().Sub(w.base))
|
||||
if len(w.ring) < w.size {
|
||||
w.ring = append(w.ring, t)
|
||||
} else {
|
||||
w.ring[w.head] = t
|
||||
w.head = (w.head + 1) % len(w.ring)
|
||||
}
|
||||
if len(w.ring) < w.size {
|
||||
return false
|
||||
}
|
||||
return w.ring[w.head] > t-int64(w.window)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// naiveExceeds is the exact reference: count every attempt in the trailing window.
|
||||
func naiveExceeds(attempts []time.Time, now time.Time, window time.Duration, limit int) bool {
|
||||
cutoff := now.Add(-window)
|
||||
n := 0
|
||||
for _, t := range attempts {
|
||||
if t.After(cutoff) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n > limit
|
||||
}
|
||||
|
||||
// Pins theorems/Theorems/SlidingWindow.lean: storing the newest limit+1 attempts decides like the full count.
|
||||
func TestAttemptWindow_MatchesExactCount(t *testing.T) {
|
||||
for _, limit := range []int{0, 1, 3, 10} {
|
||||
rng := rand.New(rand.NewSource(int64(limit) + 1))
|
||||
window := 10 * time.Second
|
||||
fc := newFakeClock(time.Unix(1_000_000, 0))
|
||||
w := newAttemptWindow(window, limit)
|
||||
w.now = fc.Now
|
||||
w.base = fc.Now()
|
||||
|
||||
var attempts []time.Time
|
||||
for i := 0; i < 5000; i++ {
|
||||
fc.Advance(time.Duration(rng.Intn(3000)) * time.Millisecond)
|
||||
attempts = append(attempts, fc.Now())
|
||||
want := naiveExceeds(attempts, fc.Now(), window, limit)
|
||||
if got := w.recordAndExceeds(); got != want {
|
||||
t.Fatalf("limit=%d step=%d: got %v, want %v", limit, i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttemptWindow_StorageIsBounded(t *testing.T) {
|
||||
const limit = 10
|
||||
fc := newFakeClock(time.Unix(1_000_000, 0))
|
||||
w := newAttemptWindow(time.Hour, limit)
|
||||
w.now = fc.Now
|
||||
w.base = fc.Now()
|
||||
for i := 0; i < 100_000; i++ {
|
||||
w.recordAndExceeds()
|
||||
fc.Advance(time.Millisecond)
|
||||
}
|
||||
if got := len(w.ring); got > limit+1 {
|
||||
t.Fatalf("stored %d timestamps, want <= %d", got, limit+1)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkAttemptWindow(b *testing.B) {
|
||||
w := newAttemptWindow(time.Hour, 1000)
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
w.recordAndExceeds()
|
||||
}
|
||||
}
|
||||
+27
-19
@@ -148,31 +148,39 @@ func (rb *RetryBudget) refill() {
|
||||
now := time.Now().UnixNano()
|
||||
last := rb.lastRefill.Load()
|
||||
|
||||
// Calculate elapsed time in seconds
|
||||
elapsed := float64(now-last) / float64(time.Second)
|
||||
tokensToAdd, newLast := refillStep(tokensPerSecond, last, now)
|
||||
if tokensToAdd <= 0 {
|
||||
return
|
||||
}
|
||||
if rb.lastRefill.CompareAndSwap(last, newLast) {
|
||||
for {
|
||||
current := rb.currentTokens.Load()
|
||||
newValue := current + tokensToAdd
|
||||
if newValue > maxTokens {
|
||||
newValue = maxTokens
|
||||
}
|
||||
|
||||
// Calculate tokens to add
|
||||
tokensToAdd := int64(elapsed * tokensPerSecond)
|
||||
|
||||
if tokensToAdd > 0 {
|
||||
// Update last refill time
|
||||
if rb.lastRefill.CompareAndSwap(last, now) {
|
||||
// Add tokens, capped at maxTokens
|
||||
for {
|
||||
current := rb.currentTokens.Load()
|
||||
newValue := current + tokensToAdd
|
||||
if newValue > maxTokens {
|
||||
newValue = maxTokens
|
||||
}
|
||||
|
||||
if rb.currentTokens.CompareAndSwap(current, newValue) {
|
||||
break
|
||||
}
|
||||
if rb.currentTokens.CompareAndSwap(current, newValue) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// refillStep returns the whole tokens earned between last and now (ns) and the
|
||||
// clock to store. The clock advances only by the time those tokens pay for, so
|
||||
// the fractional remainder carries into the next tick.
|
||||
func refillStep(tokensPerSecond float64, last, now int64) (int64, int64) {
|
||||
if tokensPerSecond <= 0 || now <= last {
|
||||
return 0, last
|
||||
}
|
||||
tokens := int64(float64(now-last) / float64(time.Second) * tokensPerSecond)
|
||||
if tokens <= 0 {
|
||||
return 0, last
|
||||
}
|
||||
return tokens, last + int64(float64(tokens)/tokensPerSecond*float64(time.Second))
|
||||
}
|
||||
|
||||
// GetStats returns current statistics
|
||||
func (rb *RetryBudget) GetStats() map[string]any {
|
||||
totalAttempts := rb.totalAttempts.Load()
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// Pins theorems/Theorems/RetryRefill.lean: total_added(15 tps, 100ms ticks, 10s) = 150.
|
||||
func TestRefillStep_KeepsFractionalTokens(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tps float64
|
||||
want int64
|
||||
}{
|
||||
{"15 tps", 15, 150},
|
||||
{"25 tps", 25, 250},
|
||||
{"5 tps (sub-token per tick)", 5, 50},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tick := 100 * time.Millisecond
|
||||
var last, total int64
|
||||
now := int64(tick)
|
||||
for i := 0; i < 100; i++ {
|
||||
added, newLast := refillStep(tt.tps, last, now)
|
||||
total += added
|
||||
last = newLast
|
||||
now += int64(tick)
|
||||
}
|
||||
assert.Equal(t, tt.want, total)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefillStep_NoElapsedNoChange(t *testing.T) {
|
||||
added, last := refillStep(10, 1000, 1000)
|
||||
assert.Zero(t, added)
|
||||
assert.Equal(t, int64(1000), last)
|
||||
}
|
||||
|
||||
func TestRefillStep_ZeroRateNeverAdds(t *testing.T) {
|
||||
added, last := refillStep(0, 0, int64(time.Hour))
|
||||
assert.Zero(t, added)
|
||||
assert.Zero(t, last)
|
||||
}
|
||||
+11
-6
@@ -57,17 +57,23 @@ func (r *RPSTracker) Shutdown() {
|
||||
|
||||
// sample calculates RPS since last sample
|
||||
func (r *RPSTracker) sample() {
|
||||
now := time.Now()
|
||||
nowNano := now.UnixNano()
|
||||
r.sampleCount()
|
||||
}
|
||||
|
||||
// sampleCount performs one sample and returns the requests it consumed.
|
||||
func (r *RPSTracker) sampleCount() int64 {
|
||||
nowNano := time.Now().UnixNano()
|
||||
|
||||
currentCount := r.lastCount.Load()
|
||||
lastSampleNano := r.lastSampleTime.Load()
|
||||
|
||||
if lastSampleNano == 0 {
|
||||
r.lastSampleTime.Store(nowNano)
|
||||
return
|
||||
return 0
|
||||
}
|
||||
|
||||
// Swap, not Load+Store(0): requests recorded between the two would be lost.
|
||||
currentCount := r.lastCount.Swap(0)
|
||||
|
||||
elapsed := float64(nowNano-lastSampleNano) / float64(time.Second)
|
||||
if elapsed > 0 {
|
||||
rps := float64(currentCount) / elapsed
|
||||
@@ -75,9 +81,8 @@ func (r *RPSTracker) sample() {
|
||||
r.currentRPS.Store(uint64(rps * 100))
|
||||
}
|
||||
|
||||
// Reset for next sample
|
||||
r.lastCount.Store(0)
|
||||
r.lastSampleTime.Store(nowNano)
|
||||
return currentCount
|
||||
}
|
||||
|
||||
// GetCurrentRPS returns the current requests per second
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// Pins theorems/Theorems/RpsSample.lean: no recorded request is dropped by sample().
|
||||
func TestRPSTracker_SampleKeepsConcurrentRequests(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
tr := NewRPSTracker(ctx)
|
||||
tr.Shutdown() // stop the background sampler; sample() is driven by the test
|
||||
|
||||
const writers, perWriter = 8, 20000
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < writers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < perWriter; j++ {
|
||||
tr.RecordRequest()
|
||||
}
|
||||
}()
|
||||
}
|
||||
var sampled int64
|
||||
done := make(chan struct{})
|
||||
go func() { wg.Wait(); close(done) }()
|
||||
loop:
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
break loop
|
||||
default:
|
||||
sampled += tr.sampleCount()
|
||||
}
|
||||
}
|
||||
sampled += tr.sampleCount()
|
||||
assert.Equal(t, int64(writers*perWriter), sampled)
|
||||
}
|
||||
@@ -442,6 +442,11 @@ func processGraphQLRequest(c fiber.Ctx) error {
|
||||
// never an error; the request stays anonymous, exactly like before
|
||||
// verification existed.
|
||||
func extractUserInfo(c fiber.Ctx) (string, string, error) {
|
||||
return extractUserInfoFromHeaders(func(key string) string { return c.Get(key) })
|
||||
}
|
||||
|
||||
// extractUserInfoFromHeaders resolves user id and role from request headers via get.
|
||||
func extractUserInfoFromHeaders(get func(string) string) (string, string, error) {
|
||||
extractedUserID := defaultValue
|
||||
extractedRoleName := defaultValue
|
||||
|
||||
@@ -450,7 +455,7 @@ func extractUserInfo(c fiber.Ctx) (string, string, error) {
|
||||
// Authorization header must still be checked even with no claim path
|
||||
// set, otherwise JWT_VERIFY_SIGNATURE=true alone would never call
|
||||
// Verify and the forged-token rejection below would never trigger.
|
||||
if authorization := c.Get("Authorization"); authorization != "" &&
|
||||
if authorization := get("Authorization"); authorization != "" &&
|
||||
(len(cfg.Client.JWTUserClaimPath) > 0 || len(cfg.Client.JWTRoleClaimPath) > 0 || cfg.Client.JWTVerifier != nil) {
|
||||
usr, role, err := extractClaimsFromJWTHeader(authorization)
|
||||
if err != nil {
|
||||
@@ -461,7 +466,7 @@ func extractUserInfo(c fiber.Ctx) (string, string, error) {
|
||||
|
||||
// Override role from header if configured
|
||||
if cfg.Client.RoleFromHeader != "" {
|
||||
if role := c.Get(cfg.Client.RoleFromHeader); role != "" {
|
||||
if role := get(cfg.Client.RoleFromHeader); role != "" {
|
||||
extractedRoleName = role
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
libpack_cache "github.com/lukaszraczylo/graphql-monitoring-proxy/cache"
|
||||
"github.com/sony/gobreaker"
|
||||
"github.com/sony/gobreaker/v2"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
|
||||
+5
-4
@@ -64,10 +64,11 @@ type config struct {
|
||||
DisableTLSVerify bool
|
||||
}
|
||||
Server struct {
|
||||
HostGraphQL string
|
||||
HostGraphQLReadOnly string
|
||||
HealthcheckGraphQL string
|
||||
AllowURLs []string // List of allowed URL paths for access control
|
||||
HostGraphQL string
|
||||
HostGraphQLReadOnly string
|
||||
HealthcheckGraphQL string
|
||||
BackendHealthcheckURL string
|
||||
AllowURLs []string // List of allowed URL paths for access control
|
||||
// BindAddress is the host portion of every listener's bind address
|
||||
// (GraphQL proxy, admin API, monitoring). Empty (the default)
|
||||
// preserves the existing ":port" behavior of binding all
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
@@ -231,6 +232,22 @@ func (wsp *WebSocketProxy) handleConnection(ctx context.Context, clientConn *web
|
||||
// Try to extract headers from connection_init payload (for GraphQL WebSocket protocols)
|
||||
enrichedHeaders := wsp.extractAuthFromPayload(message, headers)
|
||||
|
||||
// The upgrade request was gated on its own headers; the backend acts as the
|
||||
// identity in enrichedHeaders, so gate that one too.
|
||||
if authErr := authorizePayloadIdentity(headers, enrichedHeaders); authErr != nil {
|
||||
wsp.errors.Add(1)
|
||||
if wsp.logger != nil {
|
||||
wsp.logger.Warning(&libpack_logger.LogMessage{
|
||||
Message: "WebSocket connection_init identity rejected",
|
||||
Pairs: map[string]any{"connection_id": connectionID, "reason": authErr.Error()},
|
||||
})
|
||||
}
|
||||
_ = clientConn.WriteMessage(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.ClosePolicyViolation, authErr.Error()))
|
||||
_ = clientConn.Close() // Best-effort cleanup
|
||||
return
|
||||
}
|
||||
|
||||
// Connect to backend WebSocket with enriched headers
|
||||
backendConn, err := wsp.dialBackend(ctx, enrichedHeaders)
|
||||
if err != nil {
|
||||
@@ -586,6 +603,36 @@ func (wsp *WebSocketProxy) extractAuthFromPayload(message []byte, originalHeader
|
||||
return enrichedHeaders
|
||||
}
|
||||
|
||||
var (
|
||||
errWSUnauthorized = errors.New("invalid or expired authorization token")
|
||||
errWSBanned = errors.New("user is banned")
|
||||
errWSRateLimited = errors.New("rate limit exceeded")
|
||||
)
|
||||
|
||||
// authorizePayloadIdentity applies the ban and rate-limit checks of the upgrade
|
||||
// path to the identity that connection_init supplied. It does nothing when the
|
||||
// payload left Authorization and the role header unchanged, because the upgrade
|
||||
// request already passed those checks (and counted against the rate limit).
|
||||
func authorizePayloadIdentity(upgrade, enriched http.Header) error {
|
||||
roleHeader := cfg.Client.RoleFromHeader
|
||||
if upgrade.Get("Authorization") == enriched.Get("Authorization") &&
|
||||
(roleHeader == "" || upgrade.Get(roleHeader) == enriched.Get(roleHeader)) {
|
||||
return nil
|
||||
}
|
||||
|
||||
userID, role, err := extractUserInfoFromHeaders(enriched.Get)
|
||||
if err != nil {
|
||||
return errWSUnauthorized
|
||||
}
|
||||
if isUserBanned(userID) {
|
||||
return errWSBanned
|
||||
}
|
||||
if cfg.Client.RoleRateLimit && !rateLimitedRequest(userID, role) {
|
||||
return errWSRateLimited
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dialBackend establishes a WebSocket connection to the backend
|
||||
func (wsp *WebSocketProxy) dialBackend(ctx context.Context, headers http.Header) (*gorillaws.Conn, error) {
|
||||
// Convert http:// to ws:// or https:// to wss://
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func unsignedJWT(sub, role string) string {
|
||||
enc := base64.RawURLEncoding.EncodeToString
|
||||
return "Bearer " + enc([]byte(`{"alg":"none"}`)) + "." +
|
||||
enc([]byte(`{"sub":"`+sub+`","role":"`+role+`"}`)) + "."
|
||||
}
|
||||
|
||||
// Pins theorems/Theorems/WsIdentityGate.lean: the identity the backend acts as is the one that is gated.
|
||||
func TestAuthorizePayloadIdentity(t *testing.T) {
|
||||
cfg = &config{Logger: libpack_logger.New()}
|
||||
cfg.Client.JWTUserClaimPath = "sub"
|
||||
cfg.Client.JWTRoleClaimPath = "role"
|
||||
bannedUsersIDs.Store("banned-user", "test")
|
||||
defer bannedUsersIDs.Delete("banned-user")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
upgrade http.Header
|
||||
enriched http.Header
|
||||
wantErr error
|
||||
}{
|
||||
{"payload adds banned identity", http.Header{}, http.Header{"Authorization": {unsignedJWT("banned-user", "user")}}, errWSBanned},
|
||||
{"payload replaces clean identity with banned", http.Header{"Authorization": {unsignedJWT("ok-user", "user")}}, http.Header{"Authorization": {unsignedJWT("banned-user", "user")}}, errWSBanned},
|
||||
{"payload identity clean", http.Header{}, http.Header{"Authorization": {unsignedJWT("ok-user", "user")}}, nil},
|
||||
{"payload does not change identity", http.Header{"Authorization": {unsignedJWT("ok-user", "user")}}, http.Header{"Authorization": {unsignedJWT("ok-user", "user")}}, nil},
|
||||
{"no authorization anywhere", http.Header{}, http.Header{}, nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.ErrorIs(t, authorizePayloadIdentity(tt.upgrade, tt.enriched), tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
+3
-6
@@ -11,7 +11,6 @@ import (
|
||||
|
||||
fiber "github.com/gofiber/fiber/v3"
|
||||
gorillaws "github.com/gorilla/websocket"
|
||||
goratecounter "github.com/lukaszraczylo/go-ratecounter"
|
||||
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
||||
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -652,11 +651,9 @@ func TestHandleWebSocketOrDefault_RateLimitedUserRejected(t *testing.T) {
|
||||
rateLimitMu.Lock()
|
||||
rateLimits = map[string]RateLimitConfig{
|
||||
defaultValue: {
|
||||
RateCounterTicker: goratecounter.NewRateCounter().WithConfig(goratecounter.RateCounterConfig{
|
||||
Interval: 1 * time.Second,
|
||||
}),
|
||||
Interval: 1 * time.Second,
|
||||
Req: 0,
|
||||
sustained: newAttemptWindow(1*time.Second, 0),
|
||||
Interval: 1 * time.Second,
|
||||
Req: 0,
|
||||
},
|
||||
}
|
||||
rateLimitConfigAtomic.Store(rateLimits)
|
||||
|
||||
Reference in New Issue
Block a user