diff --git a/main.go b/main.go index 31ad670..dc4c04a 100644 --- a/main.go +++ b/main.go @@ -392,6 +392,20 @@ func parseConfig() { } c.Client.MaxIdleConnDuration = idleDuration + // Retry backoff delays (milliseconds). Configurable so tests can set very + // short values without burning seconds per attempt. + retryBaseDelay := getDetailsFromEnv("CLIENT_RETRY_BASE_DELAY_MS", 500) + if retryBaseDelay < 1 { + retryBaseDelay = 500 + } + c.Client.RetryBaseDelayMs = retryBaseDelay + + retryMaxDelay := getDetailsFromEnv("CLIENT_RETRY_MAX_DELAY_MS", 10000) + if retryMaxDelay < retryBaseDelay { + retryMaxDelay = retryBaseDelay + } + c.Client.RetryMaxDelayMs = retryMaxDelay + // Secure by default: TLS verification is enabled unless explicitly disabled c.Client.DisableTLSVerify = getDetailsFromEnv("CLIENT_DISABLE_TLS_VERIFY", false) diff --git a/main_test.go b/main_test.go index 67d6a35..e518d4c 100644 --- a/main_test.go +++ b/main_test.go @@ -40,6 +40,13 @@ func (suite *Tests) SetupTest() { libpack_cache.New(5 * time.Minute) parseConfig() + // Use very short retry delays in tests so that retry-exhausting requests + // (e.g. 7 attempts × backoff) complete in milliseconds instead of ~25s each. + cfgMutex.Lock() + cfg.Client.RetryBaseDelayMs = 10 + cfg.Client.RetryMaxDelayMs = 10 + cfgMutex.Unlock() + // Create context with cancel for cleanup suite.ctx, suite.cancel = context.WithCancel(context.Background()) suite.apiDone = make(chan struct{}) diff --git a/proxy.go b/proxy.go index 43acac6..78ba61a 100644 --- a/proxy.go +++ b/proxy.go @@ -477,7 +477,16 @@ func effectiveCircuitBackoff(base time.Duration, multiplier float64, maxBackoff } exponent := float64(trips - 1) - effective := time.Duration(float64(base) * math.Pow(multiplier, exponent)) + raw := float64(base) * math.Pow(multiplier, exponent) + + // On amd64 the float64->int64 conversion for +Inf saturates to MinInt64 + // rather than MaxInt64, so the subsequent "< base" guard would return base + // instead of the hard ceiling. Detect overflow before the cast. + if math.IsInf(raw, 1) || math.IsNaN(raw) { + return maxSaneCircuitBackoff + } + + effective := time.Duration(raw) if effective < base { // Overflow or float rounding pushed us below base; never shorten. return base @@ -937,17 +946,31 @@ func performProxyRequestWithEnhancedRetries(c fiber.Ctx, proxyURL string, backen var initialDelay time.Duration var maxDelayTime time.Duration + // Read per-request base and max delay from config; fall back to coded + // defaults when zero (e.g. during very early startup before parseConfig). + baseDelayMs := cfg.Client.RetryBaseDelayMs + if baseDelayMs <= 0 { + baseDelayMs = 500 + } + maxDelayMs := cfg.Client.RetryMaxDelayMs + if maxDelayMs <= 0 { + maxDelayMs = 10000 + } + if backendUnhealthy { // Backend is known to be unhealthy, fail fast // Circuit breaker should handle this, so reduce retries attempts = 3 - initialDelay = 500 * time.Millisecond - maxDelayTime = 5 * time.Second + initialDelay = time.Duration(baseDelayMs) * time.Millisecond + maxDelayTime = time.Duration(maxDelayMs/2) * time.Millisecond + if maxDelayTime < initialDelay { + maxDelayTime = initialDelay + } } else { // Normal retry strategy attempts = 7 - initialDelay = 500 * time.Millisecond - maxDelayTime = 10 * time.Second + initialDelay = time.Duration(baseDelayMs) * time.Millisecond + maxDelayTime = time.Duration(maxDelayMs) * time.Millisecond } return retry.Do( diff --git a/server_handlers_test.go b/server_handlers_test.go index c939c88..d76db77 100644 --- a/server_handlers_test.go +++ b/server_handlers_test.go @@ -788,12 +788,18 @@ func TestStartHTTPProxy_ErrorHandlerConfigured(t *testing.T) { 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 @@ -807,6 +813,8 @@ func TestStartHTTPProxy_ErrorHandlerConfigured(t *testing.T) { 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 diff --git a/struct_config.go b/struct_config.go index a9399bd..f13f7c7 100644 --- a/struct_config.go +++ b/struct_config.go @@ -42,19 +42,21 @@ type config struct { PerUserCacheDisabled bool // Disable per-user cache isolation (SECURITY RISK - not recommended) } Client struct { - GQLClient *graphql.BaseClient - FastProxyClient *fasthttp.Client - JWTUserClaimPath string - JWTRoleClaimPath string - RoleFromHeader string - proxy string - ClientTimeout int - MaxConnsPerHost int - ReadTimeout int - WriteTimeout int - MaxIdleConnDuration int - RoleRateLimit bool - DisableTLSVerify bool + GQLClient *graphql.BaseClient + FastProxyClient *fasthttp.Client + JWTUserClaimPath string + JWTRoleClaimPath string + RoleFromHeader string + proxy string + ClientTimeout int + MaxConnsPerHost int + ReadTimeout int + WriteTimeout int + MaxIdleConnDuration int + RetryBaseDelayMs int // initial backoff delay in milliseconds (default 500) + RetryMaxDelayMs int // maximum backoff delay in milliseconds (default 10000) + RoleRateLimit bool + DisableTLSVerify bool } Server struct { HostGraphQL string