mirror of
https://github.com/lukaszraczylo/graphql-monitoring-proxy.git
synced 2026-10-12 05:32:36 +00:00
Three bugs fixed: 1. **Test suite timeout** — The main-package test suite timed out at 120s because each failing request triggered 7 retries with exponential backoff (500ms base → 10s max ≈ 25.5s per request). Tests that trip the circuit breaker (3 requests × 25.5s = 76.5s) plus TestStartHTTPProxy_ErrorHandlerConfigured (another 25.5s) pushed cumulative time past the limit. Fix: add `Client.RetryBaseDelayMs` / `Client.RetryMaxDelayMs` config fields (env: CLIENT_RETRY_BASE_DELAY_MS / CLIENT_RETRY_MAX_DELAY_MS, defaults 500ms / 10000ms). Tests override these to 10ms so retry exhaustion completes in milliseconds. Full suite now runs in ~35s. 2. **effectiveCircuitBackoff float overflow** — On amd64, converting `+Inf` (float64) to `time.Duration` (int64) saturates to MinInt64, not MaxInt64. The existing "< base" guard then returned `base` instead of `maxSaneCircuitBackoff`, breaking the sanity ceiling for an uncapped config with a high trip count. Fix: detect `math.IsInf / math.IsNaN` before the cast and return the ceiling directly. 3. **TestStartHTTPProxy_ErrorHandlerConfigured** — standalone top-level test calling parseConfig() directly, bypassing the TestSuite override. Now sets and restores RetryBaseDelayMs/RetryMaxDelayMs explicitly. Co-authored-by: lukaszraczylo <[email protected]>
877 lines
25 KiB
Go
877 lines
25 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v3"
|
|
libpack_cache "github.com/lukaszraczylo/graphql-monitoring-proxy/cache"
|
|
"github.com/sony/gobreaker"
|
|
"github.com/valyala/fasthttp"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// AddRequestUUID
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestAddRequestUUID_SetsLocalsAndCallsNext(t *testing.T) {
|
|
app := fiber.New()
|
|
app.Use(AddRequestUUID)
|
|
|
|
var captured string
|
|
app.Get("/", func(c fiber.Ctx) error {
|
|
if v, ok := c.Locals("request_uuid").(string); ok {
|
|
captured = v
|
|
}
|
|
return c.SendStatus(200)
|
|
})
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
resp, err := app.Test(req, fiber.TestConfig{Timeout: 0})
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
_ = resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("want 200, got %d", resp.StatusCode)
|
|
}
|
|
if captured == "" {
|
|
t.Fatal("request_uuid not set in Locals")
|
|
}
|
|
// UUIDs are 36 chars (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)
|
|
if len(captured) != 36 {
|
|
t.Errorf("unexpected UUID length: %q", captured)
|
|
}
|
|
}
|
|
|
|
func TestAddRequestUUID_UniquePerRequest(t *testing.T) {
|
|
app := fiber.New()
|
|
app.Use(AddRequestUUID)
|
|
|
|
seen := make([]string, 0, 5)
|
|
app.Get("/", func(c fiber.Ctx) error {
|
|
if v, ok := c.Locals("request_uuid").(string); ok {
|
|
seen = append(seen, v)
|
|
}
|
|
return c.SendStatus(200)
|
|
})
|
|
|
|
for i := range 5 {
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
resp, err := app.Test(req, fiber.TestConfig{Timeout: 0})
|
|
if err != nil {
|
|
t.Fatalf("request %d: %v", i, err)
|
|
}
|
|
_ = resp.Body.Close()
|
|
}
|
|
|
|
set := make(map[string]struct{}, len(seen))
|
|
for _, id := range seen {
|
|
set[id] = struct{}{}
|
|
}
|
|
if len(set) != 5 {
|
|
t.Errorf("expected 5 unique UUIDs, got %d unique in %v", len(set), seen)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// healthCheck
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestHealthCheck_Returns200WithJSON(t *testing.T) {
|
|
// Ensure cfg is ready and GraphQL check is disabled via query param
|
|
parseConfig()
|
|
_ = StartMonitoringServer()
|
|
|
|
app := fiber.New()
|
|
app.Get("/health", healthCheck)
|
|
|
|
// Pass check_graphql=false to avoid real network call
|
|
req := httptest.NewRequest("GET", "/health?check_graphql=false&check_redis=false", nil)
|
|
resp, err := app.Test(req, fiber.TestConfig{Timeout: 10000 * time.Millisecond})
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("want 200, got %d", resp.StatusCode)
|
|
}
|
|
|
|
var body map[string]any
|
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
|
|
if _, ok := body["status"]; !ok {
|
|
t.Error("response missing 'status' field")
|
|
}
|
|
if _, ok := body["timestamp"]; !ok {
|
|
t.Error("response missing 'timestamp' field")
|
|
}
|
|
if body["status"] != "healthy" {
|
|
t.Errorf("want status=healthy, got %v", body["status"])
|
|
}
|
|
}
|
|
|
|
func TestHealthCheck_UnhealthyWhenGraphQLDown(t *testing.T) {
|
|
parseConfig()
|
|
_ = StartMonitoringServer()
|
|
|
|
// Point to a server that refuses connections
|
|
cfgMutex.Lock()
|
|
origHost := cfg.Server.HostGraphQL
|
|
cfg.Server.HostGraphQL = "http://127.0.0.1:1" // port 1 always refused
|
|
cfgMutex.Unlock()
|
|
defer func() {
|
|
cfgMutex.Lock()
|
|
cfg.Server.HostGraphQL = origHost
|
|
cfgMutex.Unlock()
|
|
}()
|
|
|
|
app := fiber.New()
|
|
app.Get("/health", healthCheck)
|
|
|
|
req := httptest.NewRequest("GET", "/health?check_redis=false", nil)
|
|
resp, err := app.Test(req, fiber.TestConfig{Timeout: 15000 * time.Millisecond})
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
// Should return 503 when backend is unreachable
|
|
if resp.StatusCode != fiber.StatusServiceUnavailable {
|
|
t.Fatalf("want 503, got %d", resp.StatusCode)
|
|
}
|
|
|
|
var body map[string]any
|
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if body["status"] != "unhealthy" {
|
|
t.Errorf("want unhealthy, got %v", body["status"])
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// processGraphQLRequest
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestProcessGraphQLRequest_ValidBodyProxiesToBackend(t *testing.T) {
|
|
parseConfig()
|
|
_ = StartMonitoringServer()
|
|
|
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(200)
|
|
_, _ = w.Write([]byte(`{"data":{"test":"ok"}}`))
|
|
}))
|
|
defer backend.Close()
|
|
|
|
cfgMutex.Lock()
|
|
origHost := cfg.Server.HostGraphQL
|
|
origHostRO := cfg.Server.HostGraphQLReadOnly
|
|
origCache := cfg.Cache.CacheEnable
|
|
cfg.Server.HostGraphQL = backend.URL
|
|
cfg.Server.HostGraphQLReadOnly = backend.URL
|
|
cfg.Cache.CacheEnable = false
|
|
cfgMutex.Unlock()
|
|
defer func() {
|
|
cfgMutex.Lock()
|
|
cfg.Server.HostGraphQL = origHost
|
|
cfg.Server.HostGraphQLReadOnly = origHostRO
|
|
cfg.Cache.CacheEnable = origCache
|
|
cfgMutex.Unlock()
|
|
}()
|
|
|
|
app := fiber.New()
|
|
app.Post("/*", processGraphQLRequest)
|
|
|
|
body := `{"query":"query { __typename }"}`
|
|
req := httptest.NewRequest("POST", "/v1/graphql", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := app.Test(req, fiber.TestConfig{Timeout: 10000 * time.Millisecond})
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != 200 {
|
|
t.Errorf("want 200, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestProcessGraphQLRequest_MalformedBodyStillHandled(t *testing.T) {
|
|
parseConfig()
|
|
_ = StartMonitoringServer()
|
|
|
|
// Backend that always returns 200 (malformed body is handled by proxy layer)
|
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(200)
|
|
_, _ = w.Write([]byte(`{"errors":[{"message":"parse error"}]}`))
|
|
}))
|
|
defer backend.Close()
|
|
|
|
cfgMutex.Lock()
|
|
origHost := cfg.Server.HostGraphQL
|
|
origHostRO := cfg.Server.HostGraphQLReadOnly
|
|
origCache := cfg.Cache.CacheEnable
|
|
cfg.Server.HostGraphQL = backend.URL
|
|
cfg.Server.HostGraphQLReadOnly = backend.URL
|
|
cfg.Cache.CacheEnable = false
|
|
cfgMutex.Unlock()
|
|
defer func() {
|
|
cfgMutex.Lock()
|
|
cfg.Server.HostGraphQL = origHost
|
|
cfg.Server.HostGraphQLReadOnly = origHostRO
|
|
cfg.Cache.CacheEnable = origCache
|
|
cfgMutex.Unlock()
|
|
}()
|
|
|
|
app := fiber.New()
|
|
app.Post("/*", processGraphQLRequest)
|
|
|
|
// Not valid JSON — proxy should still forward or return gracefully
|
|
body := `not-json-at-all`
|
|
req := httptest.NewRequest("POST", "/v1/graphql", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := app.Test(req, fiber.TestConfig{Timeout: 10000 * time.Millisecond})
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
// Should not panic; any 2xx or 5xx is acceptable — just must not crash
|
|
if resp.StatusCode < 100 || resp.StatusCode > 599 {
|
|
t.Errorf("unexpected status %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// handleCaching — wasCached=true path (cache hit)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestHandleCaching_CacheHitReturnsStoredResponse(t *testing.T) {
|
|
parseConfig()
|
|
_ = StartMonitoringServer()
|
|
|
|
// Enable in-memory cache
|
|
libpack_cache.EnableCache(&libpack_cache.CacheConfig{
|
|
Logger: cfg.Logger,
|
|
TTL: 60,
|
|
})
|
|
libpack_cache.CacheClear()
|
|
|
|
cfgMutex.Lock()
|
|
origEnable := cfg.Cache.CacheEnable
|
|
cfg.Cache.CacheEnable = true
|
|
cfg.Cache.CacheTTL = 60
|
|
cfgMutex.Unlock()
|
|
defer func() {
|
|
cfgMutex.Lock()
|
|
cfg.Cache.CacheEnable = origEnable
|
|
cfgMutex.Unlock()
|
|
}()
|
|
|
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(200)
|
|
_, _ = w.Write([]byte(`{"data":{"users":[]}}`))
|
|
}))
|
|
defer backend.Close()
|
|
|
|
cfgMutex.Lock()
|
|
origHost := cfg.Server.HostGraphQL
|
|
origHostRO := cfg.Server.HostGraphQLReadOnly
|
|
cfg.Server.HostGraphQL = backend.URL
|
|
cfg.Server.HostGraphQLReadOnly = backend.URL
|
|
cfgMutex.Unlock()
|
|
defer func() {
|
|
cfgMutex.Lock()
|
|
cfg.Server.HostGraphQL = origHost
|
|
cfg.Server.HostGraphQLReadOnly = origHostRO
|
|
cfgMutex.Unlock()
|
|
}()
|
|
|
|
app := fiber.New()
|
|
app.Post("/*", processGraphQLRequest)
|
|
|
|
queryBody := `{"query":"query { users { id } }"}`
|
|
|
|
// First request — cache miss, hits backend
|
|
req1 := httptest.NewRequest("POST", "/v1/graphql", strings.NewReader(queryBody))
|
|
req1.Header.Set("Content-Type", "application/json")
|
|
resp1, err := app.Test(req1, fiber.TestConfig{Timeout: 10000 * time.Millisecond})
|
|
if err != nil {
|
|
t.Fatalf("first request: %v", err)
|
|
}
|
|
_ = resp1.Body.Close()
|
|
|
|
if resp1.StatusCode != 200 {
|
|
t.Fatalf("first request want 200, got %d", resp1.StatusCode)
|
|
}
|
|
|
|
// Second identical request — should hit cache
|
|
req2 := httptest.NewRequest("POST", "/v1/graphql", strings.NewReader(queryBody))
|
|
req2.Header.Set("Content-Type", "application/json")
|
|
resp2, err := app.Test(req2, fiber.TestConfig{Timeout: 10000 * time.Millisecond})
|
|
if err != nil {
|
|
t.Fatalf("second request: %v", err)
|
|
}
|
|
_ = resp2.Body.Close()
|
|
|
|
if resp2.StatusCode != 200 {
|
|
t.Fatalf("second request want 200, got %d", resp2.StatusCode)
|
|
}
|
|
if resp2.Header.Get("X-Cache-Hit") != "true" {
|
|
t.Error("second request should have X-Cache-Hit: true header")
|
|
}
|
|
}
|
|
|
|
func TestHandleCaching_CacheMissProxiesRequest(t *testing.T) {
|
|
parseConfig()
|
|
_ = StartMonitoringServer()
|
|
|
|
libpack_cache.EnableCache(&libpack_cache.CacheConfig{
|
|
Logger: cfg.Logger,
|
|
TTL: 60,
|
|
})
|
|
libpack_cache.CacheClear()
|
|
|
|
cfgMutex.Lock()
|
|
origEnable := cfg.Cache.CacheEnable
|
|
cfg.Cache.CacheEnable = true
|
|
cfg.Cache.CacheTTL = 60
|
|
cfgMutex.Unlock()
|
|
defer func() {
|
|
cfgMutex.Lock()
|
|
cfg.Cache.CacheEnable = origEnable
|
|
cfgMutex.Unlock()
|
|
}()
|
|
|
|
backendCalled := 0
|
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
backendCalled++
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(200)
|
|
_, _ = fmt.Fprintf(w, `{"data":{"call":%d}}`, backendCalled)
|
|
}))
|
|
defer backend.Close()
|
|
|
|
cfgMutex.Lock()
|
|
origHost := cfg.Server.HostGraphQL
|
|
origHostRO := cfg.Server.HostGraphQLReadOnly
|
|
cfg.Server.HostGraphQL = backend.URL
|
|
cfg.Server.HostGraphQLReadOnly = backend.URL
|
|
cfgMutex.Unlock()
|
|
defer func() {
|
|
cfgMutex.Lock()
|
|
cfg.Server.HostGraphQL = origHost
|
|
cfg.Server.HostGraphQLReadOnly = origHostRO
|
|
cfgMutex.Unlock()
|
|
}()
|
|
|
|
app := fiber.New()
|
|
app.Post("/*", processGraphQLRequest)
|
|
|
|
// Unique query so no prior cache entry
|
|
queryBody := `{"query":"query { uniqueMissTest_12345 { id } }"}`
|
|
req := httptest.NewRequest("POST", "/v1/graphql", strings.NewReader(queryBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := app.Test(req, fiber.TestConfig{Timeout: 10000 * time.Millisecond})
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
_ = resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
t.Errorf("want 200, got %d", resp.StatusCode)
|
|
}
|
|
if resp.Header.Get("X-Cache-Hit") == "true" {
|
|
t.Error("first request should not be a cache hit")
|
|
}
|
|
if backendCalled == 0 {
|
|
t.Error("backend should have been called on cache miss")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// handleCaching — direct unit test for wasCached=true branch
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestHandleCaching_DirectCacheHitBranch(t *testing.T) {
|
|
parseConfig()
|
|
_ = StartMonitoringServer()
|
|
|
|
libpack_cache.EnableCache(&libpack_cache.CacheConfig{
|
|
Logger: cfg.Logger,
|
|
TTL: 60,
|
|
})
|
|
libpack_cache.CacheClear()
|
|
|
|
cfgMutex.Lock()
|
|
origEnable := cfg.Cache.CacheEnable
|
|
cfg.Cache.CacheEnable = true
|
|
cfg.Cache.CacheTTL = 60
|
|
cfgMutex.Unlock()
|
|
defer func() {
|
|
cfgMutex.Lock()
|
|
cfg.Cache.CacheEnable = origEnable
|
|
cfgMutex.Unlock()
|
|
}()
|
|
|
|
app := fiber.New()
|
|
|
|
var wasCachedResult bool
|
|
app.Post("/test", func(c fiber.Ctx) error {
|
|
parsedResult := &parseGraphQLQueryResult{
|
|
cacheTime: 60,
|
|
cacheRequest: true,
|
|
activeEndpoint: cfg.Server.HostGraphQL,
|
|
}
|
|
|
|
// Pre-populate the cache so lookup hits
|
|
cacheKey := libpack_cache.CalculateHash(c, "-", "-")
|
|
libpack_cache.CacheStore(cacheKey, []byte(`{"data":{"cached":true}}`))
|
|
|
|
var err error
|
|
wasCachedResult, err = handleCaching(c, parsedResult, "-", "-")
|
|
return err
|
|
})
|
|
|
|
reqCtx := &fasthttp.RequestCtx{}
|
|
reqCtx.Request.SetRequestURI("/test")
|
|
reqCtx.Request.Header.SetMethod("POST")
|
|
reqCtx.Request.Header.Set("Content-Type", "application/json")
|
|
reqCtx.Request.SetBody([]byte(`{"query":"query { cachedQuery }"}`))
|
|
|
|
ctx := app.AcquireCtx(reqCtx)
|
|
defer app.ReleaseCtx(ctx)
|
|
|
|
parsedResult := &parseGraphQLQueryResult{
|
|
cacheTime: 60,
|
|
cacheRequest: true,
|
|
activeEndpoint: cfg.Server.HostGraphQL,
|
|
}
|
|
|
|
cacheKey := libpack_cache.CalculateHash(ctx, "-", "-")
|
|
libpack_cache.CacheStore(cacheKey, []byte(`{"data":{"cached":true}}`))
|
|
|
|
wasCached, err := handleCaching(ctx, parsedResult, "-", "-")
|
|
if err != nil {
|
|
t.Fatalf("handleCaching returned error: %v", err)
|
|
}
|
|
if !wasCached {
|
|
t.Error("expected wasCached=true when cache hit")
|
|
}
|
|
_ = wasCachedResult
|
|
}
|
|
|
|
func TestHandleCaching_NoCacheEnabled_ProxiesDirect(t *testing.T) {
|
|
parseConfig()
|
|
_ = StartMonitoringServer()
|
|
|
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(200)
|
|
_, _ = w.Write([]byte(`{"data":{"noCacheTest":true}}`))
|
|
}))
|
|
defer backend.Close()
|
|
|
|
cfgMutex.Lock()
|
|
origEnable := cfg.Cache.CacheEnable
|
|
origRedis := cfg.Cache.CacheRedisEnable
|
|
origHost := cfg.Server.HostGraphQL
|
|
origHostRO := cfg.Server.HostGraphQLReadOnly
|
|
cfg.Cache.CacheEnable = false
|
|
cfg.Cache.CacheRedisEnable = false
|
|
cfg.Server.HostGraphQL = backend.URL
|
|
cfg.Server.HostGraphQLReadOnly = backend.URL
|
|
cfgMutex.Unlock()
|
|
defer func() {
|
|
cfgMutex.Lock()
|
|
cfg.Cache.CacheEnable = origEnable
|
|
cfg.Cache.CacheRedisEnable = origRedis
|
|
cfg.Server.HostGraphQL = origHost
|
|
cfg.Server.HostGraphQLReadOnly = origHostRO
|
|
cfgMutex.Unlock()
|
|
}()
|
|
|
|
app := fiber.New()
|
|
|
|
reqCtx := &fasthttp.RequestCtx{}
|
|
reqCtx.Request.SetRequestURI("/v1/graphql")
|
|
reqCtx.Request.Header.SetMethod("POST")
|
|
reqCtx.Request.Header.Set("Content-Type", "application/json")
|
|
reqCtx.Request.SetBody([]byte(`{"query":"query { noCacheTest }"}`))
|
|
|
|
fCtx := app.AcquireCtx(reqCtx)
|
|
defer app.ReleaseCtx(fCtx)
|
|
|
|
parsedResult := &parseGraphQLQueryResult{
|
|
cacheRequest: false,
|
|
cacheTime: 0,
|
|
activeEndpoint: backend.URL,
|
|
}
|
|
|
|
wasCached, err := handleCaching(fCtx, parsedResult, "-", "-")
|
|
if err != nil {
|
|
t.Fatalf("handleCaching error: %v", err)
|
|
}
|
|
if wasCached {
|
|
t.Error("expected wasCached=false when cache disabled")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// StartHTTPProxy — starts then shuts down cleanly
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestStartHTTPProxy_StartsAndShutdown(t *testing.T) {
|
|
parseConfig()
|
|
_ = StartMonitoringServer()
|
|
|
|
// Grab a free port
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("net.Listen: %v", err)
|
|
}
|
|
port := l.Addr().(*net.TCPAddr).Port
|
|
_ = l.Close()
|
|
|
|
cfgMutex.Lock()
|
|
origPort := cfg.Server.PortGraphQL
|
|
origTimeout := cfg.Client.ClientTimeout
|
|
origWS := cfg.WebSocket.Enable
|
|
origAdmin := cfg.AdminDashboard.Enable
|
|
cfg.Server.PortGraphQL = port
|
|
cfg.Client.ClientTimeout = 5
|
|
cfg.WebSocket.Enable = false
|
|
cfg.AdminDashboard.Enable = false
|
|
cfgMutex.Unlock()
|
|
|
|
t.Cleanup(func() {
|
|
cfgMutex.Lock()
|
|
cfg.Server.PortGraphQL = origPort
|
|
cfg.Client.ClientTimeout = origTimeout
|
|
cfg.WebSocket.Enable = origWS
|
|
cfg.AdminDashboard.Enable = origAdmin
|
|
cfgMutex.Unlock()
|
|
})
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
errCh <- StartHTTPProxy()
|
|
}()
|
|
|
|
// Wait for the OnListen hook to publish httpProxyApp, i.e. for fiber to
|
|
// actually be serving. A bare TCP dial is not a reliable "serving" signal:
|
|
// the OS accepts connections into the listener's backlog as soon as it is
|
|
// bound, before fiber's Serve loop starts pulling them off, so dialling a
|
|
// freshly bound listener races server startup under -race.
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
var serving bool
|
|
for time.Now().Before(deadline) {
|
|
httpProxyMu.RLock()
|
|
serving = httpProxyApp != nil
|
|
httpProxyMu.RUnlock()
|
|
if serving {
|
|
break
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if !serving {
|
|
t.Fatalf("server did not start serving on port %d within 3s", port)
|
|
}
|
|
|
|
// Send a health check to confirm it's serving
|
|
httpResp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/health?check_graphql=false&check_redis=false", port))
|
|
if err != nil {
|
|
t.Fatalf("GET /health: %v", err)
|
|
}
|
|
_ = httpResp.Body.Close()
|
|
if httpResp.StatusCode != 200 {
|
|
t.Errorf("want 200, got %d", httpResp.StatusCode)
|
|
}
|
|
|
|
// Shut down and confirm StartHTTPProxy's server.Listen returns instead of
|
|
// blocking forever, so in-flight requests get a chance to drain.
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
if err := shutdownHTTPProxy(shutdownCtx); err != nil {
|
|
t.Fatalf("shutdownHTTPProxy: %v", err)
|
|
}
|
|
|
|
select {
|
|
case err := <-errCh:
|
|
if err != nil {
|
|
t.Errorf("StartHTTPProxy returned error after shutdown: %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("StartHTTPProxy did not return after shutdownHTTPProxy")
|
|
}
|
|
}
|
|
|
|
// Test_shutdownHTTPProxy verifies the graceful-shutdown helper: it is a no-op
|
|
// when no server is running, and it stops a listening proxy server so that
|
|
// server.Listen returns (enabling a clean in-flight-request drain instead of
|
|
// process-exit cutoff).
|
|
func (suite *Tests) Test_shutdownHTTPProxy() {
|
|
// No server running: must be a safe no-op.
|
|
httpProxyMu.Lock()
|
|
original := httpProxyApp
|
|
httpProxyApp = nil
|
|
httpProxyMu.Unlock()
|
|
defer func() {
|
|
httpProxyMu.Lock()
|
|
httpProxyApp = original
|
|
httpProxyMu.Unlock()
|
|
}()
|
|
// Bound the context: if a clear-signal regression made the retry loop
|
|
// spin forever, context.Background() would hang the whole package for
|
|
// its default 10m test timeout instead of failing fast here.
|
|
noopCtx, noopCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer noopCancel()
|
|
suite.NoError(shutdownHTTPProxy(noopCtx))
|
|
|
|
// With a listening server assigned, shutdown must stop it.
|
|
app := fiber.New()
|
|
app.Get("/ping", func(c fiber.Ctx) error { return c.SendStatus(200) })
|
|
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
suite.Require().NoError(err)
|
|
|
|
// Signal off fiber's OnListen hook rather than dialling the raw listener:
|
|
// the OS accepts connections into the listener's backlog as soon as it is
|
|
// bound, before fiber's Serve loop is actually running, so a bare TCP
|
|
// dial races server startup and makes shutdownHTTPProxy's no-op check
|
|
// (fasthttp's listener isn't set yet) flaky under -race.
|
|
serving := make(chan struct{})
|
|
app.Hooks().OnListen(func(fiber.ListenData) error {
|
|
close(serving)
|
|
return nil
|
|
})
|
|
|
|
listenDone := make(chan struct{})
|
|
go func() {
|
|
defer close(listenDone)
|
|
_ = app.Listener(ln)
|
|
// Mirror StartHTTPProxy's contract: clear httpProxyApp once Listener
|
|
// returns, so shutdownHTTPProxy's retry loop (which waits for that
|
|
// clear as proof the drain actually happened) can observe completion
|
|
// instead of retrying until its context expires.
|
|
httpProxyMu.Lock()
|
|
if httpProxyApp == app {
|
|
httpProxyApp = nil
|
|
}
|
|
httpProxyMu.Unlock()
|
|
}()
|
|
|
|
select {
|
|
case <-serving:
|
|
case <-time.After(3 * time.Second):
|
|
suite.Fail("proxy server never started serving on " + ln.Addr().String())
|
|
return
|
|
}
|
|
|
|
httpProxyMu.Lock()
|
|
httpProxyApp = app
|
|
httpProxyMu.Unlock()
|
|
// Bound the context: if a clear-signal regression made the retry loop
|
|
// spin forever, context.Background() would hang the whole package for
|
|
// its default 10m test timeout instead of failing fast here.
|
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer shutdownCancel()
|
|
suite.NoError(shutdownHTTPProxy(shutdownCtx))
|
|
|
|
select {
|
|
case <-listenDone:
|
|
// Listen returned because we called Shutdown.
|
|
case <-time.After(3 * time.Second):
|
|
suite.Fail("proxy server did not shut down; in-flight requests would be cut off")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// proxyErrorHandler (C4 - circuit-open must surface as 503, not the generic
|
|
// 500 fiber's DefaultErrorHandler would return; *fiber.Error keeps its own
|
|
// code; any other error still maps to 500)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestProxyErrorHandler_MapsErrorsToStatusCodes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
wantStatus int
|
|
}{
|
|
{
|
|
name: "circuit open sentinel yields 503",
|
|
err: ErrCircuitOpen,
|
|
wantStatus: fiber.StatusServiceUnavailable,
|
|
},
|
|
{
|
|
name: "gobreaker open state yields 503",
|
|
err: gobreaker.ErrOpenState,
|
|
wantStatus: fiber.StatusServiceUnavailable,
|
|
},
|
|
{
|
|
name: "wrapped circuit open sentinel yields 503",
|
|
err: fmt.Errorf("proxy attempt failed: %w", ErrCircuitOpen),
|
|
wantStatus: fiber.StatusServiceUnavailable,
|
|
},
|
|
{
|
|
name: "generic error yields 500",
|
|
err: errors.New("boom"),
|
|
wantStatus: fiber.StatusInternalServerError,
|
|
},
|
|
{
|
|
name: "proxy error keeps its own status code",
|
|
err: NewProxyError(ErrCodeBadGateway, "bad gateway", fiber.StatusBadGateway, false),
|
|
wantStatus: fiber.StatusBadGateway,
|
|
},
|
|
{
|
|
name: "fiber.Error keeps its own code",
|
|
err: fiber.NewError(fiber.StatusTeapot, "teapot"),
|
|
wantStatus: fiber.StatusTeapot,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
app := fiber.New(fiber.Config{ErrorHandler: proxyErrorHandler})
|
|
app.Get("/err", func(c fiber.Ctx) error {
|
|
return tt.err
|
|
})
|
|
|
|
req := httptest.NewRequest("GET", "/err", nil)
|
|
resp, err := app.Test(req, fiber.TestConfig{Timeout: 5000 * time.Millisecond})
|
|
if err != nil {
|
|
t.Fatalf("app.Test: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != tt.wantStatus {
|
|
t.Errorf("want status %d, got %d", tt.wantStatus, resp.StatusCode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestStartHTTPProxy_ErrorHandlerConfigured is a narrow regression check that
|
|
// StartHTTPProxy actually wires proxyErrorHandler into the fiber app (the
|
|
// bug this fixes was a missing ErrorHandler in that Config), rather than
|
|
// only testing proxyErrorHandler in isolation above.
|
|
func TestStartHTTPProxy_ErrorHandlerConfigured(t *testing.T) {
|
|
parseConfig()
|
|
_ = StartMonitoringServer()
|
|
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("net.Listen: %v", err)
|
|
}
|
|
port := l.Addr().(*net.TCPAddr).Port
|
|
_ = l.Close()
|
|
|
|
cfgMutex.Lock()
|
|
origPort := cfg.Server.PortGraphQL
|
|
origTimeout := cfg.Client.ClientTimeout
|
|
origRetryBase := cfg.Client.RetryBaseDelayMs
|
|
origRetryMax := cfg.Client.RetryMaxDelayMs
|
|
origWS := cfg.WebSocket.Enable
|
|
origAdmin := cfg.AdminDashboard.Enable
|
|
origHost := cfg.Server.HostGraphQL
|
|
origHostRO := cfg.Server.HostGraphQLReadOnly
|
|
cfg.Server.PortGraphQL = port
|
|
cfg.Client.ClientTimeout = 5
|
|
// Use short retry delays so the 7-attempt retry loop for the unreachable
|
|
// backend completes in milliseconds instead of ~25 seconds.
|
|
cfg.Client.RetryBaseDelayMs = 10
|
|
cfg.Client.RetryMaxDelayMs = 10
|
|
cfg.WebSocket.Enable = false
|
|
cfg.AdminDashboard.Enable = false
|
|
// Point at a host that always refuses connections so proxyTheRequest
|
|
// fails and processGraphQLRequest's proxy-error path (mapped via
|
|
// StatusCodeForError, not a bare 500) gets exercised.
|
|
cfg.Server.HostGraphQL = "http://127.0.0.1:1"
|
|
cfg.Server.HostGraphQLReadOnly = "http://127.0.0.1:1"
|
|
cfgMutex.Unlock()
|
|
|
|
t.Cleanup(func() {
|
|
cfgMutex.Lock()
|
|
cfg.Server.PortGraphQL = origPort
|
|
cfg.Client.ClientTimeout = origTimeout
|
|
cfg.Client.RetryBaseDelayMs = origRetryBase
|
|
cfg.Client.RetryMaxDelayMs = origRetryMax
|
|
cfg.WebSocket.Enable = origWS
|
|
cfg.AdminDashboard.Enable = origAdmin
|
|
cfg.Server.HostGraphQL = origHost
|
|
cfg.Server.HostGraphQLReadOnly = origHostRO
|
|
cfgMutex.Unlock()
|
|
})
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
errCh <- StartHTTPProxy()
|
|
}()
|
|
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
var serving bool
|
|
for time.Now().Before(deadline) {
|
|
httpProxyMu.RLock()
|
|
serving = httpProxyApp != nil
|
|
httpProxyMu.RUnlock()
|
|
if serving {
|
|
break
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if !serving {
|
|
t.Fatalf("server did not start serving on port %d within 3s", port)
|
|
}
|
|
|
|
httpResp, err := http.Post(
|
|
fmt.Sprintf("http://127.0.0.1:%d/v1/graphql", port),
|
|
"application/json",
|
|
strings.NewReader(`{"query":"query { __typename }"}`),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("POST /v1/graphql: %v", err)
|
|
}
|
|
_ = httpResp.Body.Close()
|
|
|
|
// An unreachable backend is a generic proxy failure, not circuit-open, so
|
|
// this must stay 500 - proving the ErrorHandler wiring did not change the
|
|
// status for genuinely-500 errors.
|
|
if httpResp.StatusCode != fiber.StatusInternalServerError {
|
|
t.Errorf("want %d for unreachable backend, got %d", fiber.StatusInternalServerError, httpResp.StatusCode)
|
|
}
|
|
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
if err := shutdownHTTPProxy(shutdownCtx); err != nil {
|
|
t.Fatalf("shutdownHTTPProxy: %v", err)
|
|
}
|
|
|
|
select {
|
|
case err := <-errCh:
|
|
if err != nil {
|
|
t.Errorf("StartHTTPProxy returned error after shutdown: %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("StartHTTPProxy did not return after shutdownHTTPProxy")
|
|
}
|
|
}
|