mirror of
https://github.com/lukaszraczylo/graphql-monitoring-proxy.git
synced 2026-10-12 05:32:36 +00:00
BREAKING CHANGE: upgrades the HTTP framework from gofiber/fiber/v2 to gofiber/fiber/v3, and gofiber/websocket/v2 to gofiber/contrib/v3/websocket. - Handlers now take fiber.Ctx (interface) instead of *fiber.Ctx. - DisableStartupMessage moved from fiber.Config to fiber.ListenConfig on the proxy/api/monitoring Listen calls. - cors AllowOrigins is now []string; c.BodyParser -> c.Bind().Body(). - app.Test takes fiber.TestConfig instead of an int timeout (tests updated). - WebSocket swapped to contrib/v3/websocket (same Conn/New/Config/IsCloseError/IsWebSocketUpgrade API; Conn.Query preserved). - Adopts fasthttp v1.71.0 (pulled by fiber v3), resolving the v1.71 Host-header enforcement that broke the prior pinned-v1.69 workaround. fix(connpool): the background keep-alive goroutine no longer reads the mutable global cfg (captures HostGraphQL/HealthcheckGraphQL at pool creation). Under v3 timing it raced with parseConfig/test cfg reassignment; go test -race ./... is now clean (0 data races). test: make the circuit-breaker cache-fallback and request-coalescing integration tests deterministic (cache TTL outlives the retry loop; coalescing asserts against a measured single-request baseline instead of a hard-coded count); drop the flaky timing-ratio assertion in TestTimingAttackResistance (constant-time guarantee comes from subtle.ConstantTimeCompare, not wall-clock timing). Verified: go build, go vet, golangci-lint (0 issues), go test -race ./... (all packages pass, 0 races), govulncheck (no vulnerabilities). App + dashboard + WebSocket verified live under v3 in single-node and cluster modes.
203 lines
8.0 KiB
Go
203 lines
8.0 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
|
|
"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/stretchr/testify/assert"
|
|
"github.com/valyala/fasthttp"
|
|
)
|
|
|
|
// TestCircuitBreakerCacheFallback tests that when the circuit is open, the system
|
|
// attempts to serve a cached response if available
|
|
func (suite *CircuitBreakerTestSuite) TestCircuitBreakerCacheFallback() {
|
|
// Reset the buffer before the test
|
|
suite.outputBuffer.Reset()
|
|
|
|
// Initialize circuit breaker with a short timeout and cache fallback enabled
|
|
cfg.CircuitBreaker.MaxFailures = 3
|
|
cfg.CircuitBreaker.Timeout = 5
|
|
cfg.CircuitBreaker.ReturnCachedOnOpen = true
|
|
initCircuitBreaker(cfg)
|
|
|
|
// Create a test fiber app and context
|
|
app := fiber.New()
|
|
requestCtx := &fasthttp.RequestCtx{}
|
|
requestCtx.Request.SetRequestURI("/test-path")
|
|
requestCtx.Request.Header.SetMethod("POST")
|
|
requestCtx.Request.Header.SetContentType("application/json")
|
|
requestCtx.Request.SetBody([]byte(`{"query": "query { test }"}`))
|
|
ctx := app.AcquireCtx(requestCtx)
|
|
defer app.ReleaseCtx(ctx)
|
|
|
|
// Calculate the cache key that would be used (with default user context since no auth headers)
|
|
// extractUserInfo() returns ("-", "-") when no auth is present
|
|
cacheKey := libpack_cache.CalculateHash(ctx, "-", "-")
|
|
|
|
// Add a test response to the cache
|
|
cachedResponse := []byte(`{"data":{"test":"cached-response"}}`)
|
|
libpack_cache.CacheStore(cacheKey, cachedResponse)
|
|
|
|
// Trip the circuit by generating failures
|
|
testErr := errors.New("test error")
|
|
for i := 0; i < cfg.CircuitBreaker.MaxFailures; i++ {
|
|
_, err := cb.Execute(func() (any, error) {
|
|
return nil, testErr
|
|
})
|
|
assert.Error(suite.T(), err, "Execute should return error")
|
|
}
|
|
|
|
// Verify circuit is now open
|
|
assert.Equal(suite.T(), gobreaker.StateOpen.String(), cb.State().String(), "Circuit should be open after failures")
|
|
|
|
// Prepare to monitor metric increments for fallback success
|
|
initialFallbackSuccessCount := getMetricCount(libpack_monitoring.MetricsCircuitFallbackSuccess)
|
|
initialCacheHitCount := getMetricCount(libpack_monitoring.MetricsCacheHit)
|
|
|
|
// Simulate a proxy request that would hit the circuit breaker
|
|
err := performProxyRequest(ctx, "http://test-endpoint.example")
|
|
|
|
// The request should succeed since we have a cached response
|
|
assert.NoError(suite.T(), err, "Request should succeed with cached fallback")
|
|
|
|
// Verify cached response was served
|
|
assert.Equal(suite.T(), string(cachedResponse), string(ctx.Response().Body()),
|
|
"Response should match cached value")
|
|
assert.Equal(suite.T(), fiber.StatusOK, ctx.Response().StatusCode(),
|
|
"Status code should be 200 OK")
|
|
|
|
// Verify metrics were incremented
|
|
newFallbackSuccessCount := getMetricCount(libpack_monitoring.MetricsCircuitFallbackSuccess)
|
|
newCacheHitCount := getMetricCount(libpack_monitoring.MetricsCacheHit)
|
|
|
|
assert.True(suite.T(), newFallbackSuccessCount > initialFallbackSuccessCount,
|
|
"Circuit fallback success metric should be incremented")
|
|
assert.True(suite.T(), newCacheHitCount > initialCacheHitCount,
|
|
"Cache hit metric should be incremented")
|
|
|
|
// Verify log messages
|
|
assert.True(suite.T(), suite.logContains("Circuit open - serving from cache"),
|
|
"Log should indicate serving from cache")
|
|
}
|
|
|
|
// TestCircuitBreakerNoCacheFallback tests the case where the circuit is open but
|
|
// no cached response is available
|
|
func (suite *CircuitBreakerTestSuite) TestCircuitBreakerNoCacheFallback() {
|
|
// Reset the buffer before the test
|
|
suite.outputBuffer.Reset()
|
|
|
|
// Initialize circuit breaker with cache fallback enabled
|
|
cfg.CircuitBreaker.MaxFailures = 3
|
|
cfg.CircuitBreaker.Timeout = 5
|
|
cfg.CircuitBreaker.ReturnCachedOnOpen = true
|
|
initCircuitBreaker(cfg)
|
|
|
|
// Create a test fiber app and context
|
|
app := fiber.New()
|
|
requestCtx := &fasthttp.RequestCtx{}
|
|
requestCtx.Request.SetRequestURI("/test-path-no-cache")
|
|
requestCtx.Request.Header.SetMethod("POST")
|
|
requestCtx.Request.Header.SetContentType("application/json")
|
|
requestCtx.Request.SetBody([]byte(`{"query": "query { testNoCache }"}`))
|
|
ctx := app.AcquireCtx(requestCtx)
|
|
defer app.ReleaseCtx(ctx)
|
|
|
|
// Trip the circuit by generating failures
|
|
testErr := errors.New("test error")
|
|
for i := 0; i < cfg.CircuitBreaker.MaxFailures; i++ {
|
|
_, err := cb.Execute(func() (any, error) {
|
|
return nil, testErr
|
|
})
|
|
assert.Error(suite.T(), err, "Execute should return error")
|
|
}
|
|
|
|
// Verify circuit is now open
|
|
assert.Equal(suite.T(), gobreaker.StateOpen.String(), cb.State().String(), "Circuit should be open after failures")
|
|
|
|
// Prepare to monitor metric increments for fallback failure
|
|
initialFallbackFailedCount := getMetricCount(libpack_monitoring.MetricsCircuitFallbackFailed)
|
|
|
|
// Simulate a proxy request that would hit the circuit breaker
|
|
err := performProxyRequest(ctx, "http://test-endpoint.example")
|
|
|
|
// The request should fail with ErrCircuitOpen
|
|
assert.Error(suite.T(), err, "Request should fail without cached fallback")
|
|
assert.Equal(suite.T(), ErrCircuitOpen.Error(), err.Error(), "Error should be ErrCircuitOpen")
|
|
|
|
// Verify metrics were incremented
|
|
newFallbackFailedCount := getMetricCount(libpack_monitoring.MetricsCircuitFallbackFailed)
|
|
assert.True(suite.T(), newFallbackFailedCount > initialFallbackFailedCount,
|
|
"Circuit fallback failed metric should be incremented")
|
|
|
|
// Verify log messages
|
|
assert.True(suite.T(), suite.logContains("Circuit open - no cached response available"),
|
|
"Log should indicate no cache available")
|
|
}
|
|
|
|
// TestCacheDisabledFallback tests that when ReturnCachedOnOpen is false,
|
|
// no cache lookup is attempted
|
|
func (suite *CircuitBreakerTestSuite) TestCacheDisabledFallback() {
|
|
// Reset the buffer before the test
|
|
suite.outputBuffer.Reset()
|
|
|
|
// Initialize circuit breaker with cache fallback disabled
|
|
cfg.CircuitBreaker.MaxFailures = 3
|
|
cfg.CircuitBreaker.Timeout = 5
|
|
cfg.CircuitBreaker.ReturnCachedOnOpen = false
|
|
initCircuitBreaker(cfg)
|
|
|
|
// Create a test fiber app and context
|
|
app := fiber.New()
|
|
requestCtx := &fasthttp.RequestCtx{}
|
|
requestCtx.Request.SetRequestURI("/test-path-cache-disabled")
|
|
requestCtx.Request.Header.SetMethod("POST")
|
|
requestCtx.Request.Header.SetContentType("application/json")
|
|
requestCtx.Request.SetBody([]byte(`{"query": "query { testCacheDisabled }"}`))
|
|
ctx := app.AcquireCtx(requestCtx)
|
|
defer app.ReleaseCtx(ctx)
|
|
|
|
// Calculate cache key and store a response (with default user context since no auth headers)
|
|
// extractUserInfo() returns ("-", "-") when no auth is present
|
|
cacheKey := libpack_cache.CalculateHash(ctx, "-", "-")
|
|
cachedResponse := []byte(`{"data":{"test":"cached-response"}}`)
|
|
libpack_cache.CacheStore(cacheKey, cachedResponse)
|
|
|
|
// Trip the circuit by generating failures
|
|
testErr := errors.New("test error")
|
|
for i := 0; i < cfg.CircuitBreaker.MaxFailures; i++ {
|
|
_, err := cb.Execute(func() (any, error) {
|
|
return nil, testErr
|
|
})
|
|
assert.Error(suite.T(), err, "Execute should return error")
|
|
}
|
|
|
|
// Verify circuit is now open
|
|
assert.Equal(suite.T(), gobreaker.StateOpen.String(), cb.State().String(), "Circuit should be open")
|
|
|
|
// Simulate a proxy request that would hit the circuit breaker
|
|
err := performProxyRequest(ctx, "http://test-endpoint.example")
|
|
|
|
// The request should fail with ErrOpenState, not attempt cache fallback
|
|
assert.Error(suite.T(), err, "Request should fail when circuit is open and fallback disabled")
|
|
assert.Equal(suite.T(), gobreaker.ErrOpenState.Error(), err.Error(), "Error should be ErrOpenState")
|
|
|
|
// Verify no cache-related logs were generated
|
|
assert.False(suite.T(), suite.logContains("Circuit open - serving from cache"),
|
|
"Log should not indicate serving from cache")
|
|
assert.False(suite.T(), suite.logContains("Circuit open - no cached response available"),
|
|
"Log should not indicate attempting cache lookup")
|
|
}
|
|
|
|
// Helper function to get current metric count value
|
|
func getMetricCount(metricName string) int {
|
|
counter := cfg.Monitoring.RegisterMetricsCounter(metricName, nil)
|
|
if counter == nil {
|
|
return 0
|
|
}
|
|
// Convert the counter value to int for easier comparison
|
|
return int(counter.Get())
|
|
}
|