From ff35d49cdd3564e5e30aa5244985cd34c362eeff Mon Sep 17 00:00:00 2001 From: Navin Karavade Date: Wed, 4 Feb 2026 14:56:01 +0000 Subject: [PATCH 1/3] Add OpenRouter circuit breaker and retry with exponential backoff (503 fallback) --- gateway/go.mod | 1 + gateway/go.sum | 2 ++ gateway/main.go | 66 ++++++++++++++++++++++------------- gateway/openrouter_breaker.go | 20 +++++++++++ gateway/openrouter_retry.go | 26 ++++++++++++++ 5 files changed, 90 insertions(+), 25 deletions(-) create mode 100644 gateway/openrouter_breaker.go create mode 100644 gateway/openrouter_retry.go diff --git a/gateway/go.mod b/gateway/go.mod index ce8713c..6f4870c 100644 --- a/gateway/go.mod +++ b/gateway/go.mod @@ -9,6 +9,7 @@ require ( github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 github.com/redis/go-redis/v9 v9.17.2 + github.com/sony/gobreaker v1.0.0 github.com/stretchr/testify v1.11.1 ) diff --git a/gateway/go.sum b/gateway/go.sum index d45e21e..c6a0b32 100644 --- a/gateway/go.sum +++ b/gateway/go.sum @@ -81,6 +81,8 @@ github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4Vi github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= +github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= diff --git a/gateway/main.go b/gateway/main.go index 58213a7..a940f1e 100644 --- a/gateway/main.go +++ b/gateway/main.go @@ -28,6 +28,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/joho/godotenv" + "github.com/sony/gobreaker" ) type PaymentContext struct { @@ -54,6 +55,12 @@ type SummarizeRequest struct { Text string `json:"text"` } +type VerifyResponseInternal struct { + IsValid bool + RecoveredAddress string + Error string +} + func validateConfig() error { required := []string{ "OPENROUTER_API_KEY", @@ -69,6 +76,7 @@ func validateConfig() error { } return nil } + func main() { // Try loading .env from current directory first, then fallback to parent err := godotenv.Load(".env") @@ -326,10 +334,19 @@ func handleSummarize(c *gin.Context) { // 3. Call AI Service summary, err := callOpenRouter(c.Request.Context(), req.Text) if err != nil { + // ⭐ Circuit breaker open → 503 + if err == gobreaker.ErrOpenState { + c.JSON(503, gin.H{"error": "Service Unavailable"}) + return + } + + // Existing timeout handling if errors.Is(err, context.DeadlineExceeded) || c.Request.Context().Err() == context.DeadlineExceeded { c.JSON(504, gin.H{"error": "Gateway Timeout", "message": "AI request timed out"}) return } + + // Fallback c.JSON(500, gin.H{"error": "AI Service Failed", "details": err.Error()}) return } @@ -499,6 +516,7 @@ func getChainID() int { // the model (defaults to "z-ai/glm-4.5-air:free" if unset). func callOpenRouter(ctx context.Context, text string) (string, error) { apiKey := os.Getenv("OPENROUTER_API_KEY") + model := os.Getenv("OPENROUTER_MODEL") if model == "" { model = "z-ai/glm-4.5-air:free" @@ -517,54 +535,52 @@ func callOpenRouter(ctx context.Context, text string) (string, error) { if openRouterURL == "" { openRouterURL = "https://openrouter.ai/api/v1/chat/completions" } + req, err := http.NewRequestWithContext(ctx, "POST", openRouterURL, bytes.NewBuffer(reqBody)) if err != nil { return "", fmt.Errorf("failed to create OpenRouter request: %w", err) } + req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Content-Type", "application/json") - // VIBE FIX: Pass Correlation ID to AI Service - // (Assuming the context has it, though OpenRouter might not use it, it's good practice) - if cid, ok := ctx.Value(correlationIDKey).(string); ok { // Changed to use correlationIDKey + if cid, ok := ctx.Value(correlationIDKey).(string); ok { req.Header.Set("X-Correlation-ID", cid) } - // Use http.DefaultClient and rely on ctx for cancellation/timeouts. - resp, err := http.DefaultClient.Do(req) + // ✅ Circuit breaker + retry wrapper + result, err := openRouterCB.Execute(func() (interface{}, error) { + return doRequestWithRetry(req) + }) + if err != nil { + if err == gobreaker.ErrOpenState { + return "", gobreaker.ErrOpenState + } + if errors.Is(err, context.DeadlineExceeded) || ctx.Err() == context.DeadlineExceeded { return "", context.DeadlineExceeded } + return "", err } + + resp := result.(*http.Response) defer resp.Body.Close() - var result map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + var resultBody map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&resultBody); err != nil { return "", fmt.Errorf("failed to decode AI response: %w", err) } - choices, ok := result["choices"].([]interface{}) + choices, ok := resultBody["choices"].([]interface{}) if !ok || len(choices) == 0 { - log.Printf("OpenRouter response: %+v", result) - return "", fmt.Errorf("invalid response from AI provider: no choices") + return "", fmt.Errorf("invalid response from AI provider") } - choice, ok := choices[0].(map[string]interface{}) - if !ok { - return "", fmt.Errorf("invalid response from AI provider: malformed choice") - } - - message, ok := choice["message"].(map[string]interface{}) - if !ok { - return "", fmt.Errorf("invalid response from AI provider: malformed message") - } - - content, ok := message["content"].(string) - if !ok { - return "", fmt.Errorf("invalid response from AI provider: missing content") - } + choice := choices[0].(map[string]interface{}) + message := choice["message"].(map[string]interface{}) + content := message["content"].(string) return content, nil } @@ -1055,4 +1071,4 @@ var checkOpenRouterHealth = func() string { return "degraded" } return "ok" -} +} \ No newline at end of file diff --git a/gateway/openrouter_breaker.go b/gateway/openrouter_breaker.go new file mode 100644 index 0000000..1c89b7d --- /dev/null +++ b/gateway/openrouter_breaker.go @@ -0,0 +1,20 @@ +package main + +import ( + "time" + + "github.com/sony/gobreaker" +) + +var openRouterCB *gobreaker.CircuitBreaker + +func init() { + openRouterCB = gobreaker.NewCircuitBreaker(gobreaker.Settings{ + Name: "OpenRouter", + MaxRequests: 5, + Timeout: 30 * time.Second, + ReadyToTrip: func(c gobreaker.Counts) bool { + return c.ConsecutiveFailures >= 3 + }, + }) +} diff --git a/gateway/openrouter_retry.go b/gateway/openrouter_retry.go new file mode 100644 index 0000000..f6ccb91 --- /dev/null +++ b/gateway/openrouter_retry.go @@ -0,0 +1,26 @@ +package main + +import ( + "fmt" + "net/http" + "time" +) + +func doRequestWithRetry(req *http.Request) (*http.Response, error) { + + backoff := 200 * time.Millisecond + + for i := 0; i < 3; i++ { + + resp, err := http.DefaultClient.Do(req) + + if err == nil && resp.StatusCode < 500 { + return resp, nil + } + + time.Sleep(backoff) + backoff *= 2 + } + + return nil, fmt.Errorf("retry failed") +} From ed15fc7ef7a6b7c43bf92813324436a611351d70 Mon Sep 17 00:00:00 2001 From: Navin Karavade Date: Wed, 4 Feb 2026 15:03:04 +0000 Subject: [PATCH 2/3] Ignore package-lock.json --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d0857f1..a4a12fa 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ docs agent/ issues/ +package-lock.json From b4ae711d0d031e9d2baa1ad97039cb19f44c55f7 Mon Sep 17 00:00:00 2001 From: Navin Karavade Date: Wed, 4 Feb 2026 19:17:47 +0000 Subject: [PATCH 3/3] Fix OpenRouter resilience: safe type assertions, body reset on retry, close response bodies, context-aware backoff --- gateway/main.go | 31 +++++++++++++++------- gateway/openrouter_retry.go | 53 +++++++++++++++++++++++++++++++------ 2 files changed, 66 insertions(+), 18 deletions(-) diff --git a/gateway/main.go b/gateway/main.go index a940f1e..6fa2102 100644 --- a/gateway/main.go +++ b/gateway/main.go @@ -89,7 +89,7 @@ func main() { } if err := validateConfig(); err != nil { fmt.Println("[Error] Missing required environment variables:") - fmt.Println(" -", err.Error()) + fmt.Println(" -", err.Error()) fmt.Println() fmt.Println("Copy .env.example to .env and fill in the required values.") fmt.Println("See README.md for more configuration details.") @@ -97,16 +97,16 @@ func main() { } fmt.Println("[OK] Configuration validated") if port := os.Getenv("PORT"); port != "" { - fmt.Printf(" - Port: %s\n", port) + fmt.Printf(" - Port: %s\n", port) } if model := os.Getenv("MODEL"); model != "" { - fmt.Printf(" - Model: %s\n", model) + fmt.Printf(" - Model: %s\n", model) } if verifier := os.Getenv("VERIFIER_URL"); verifier != "" { - fmt.Printf(" - Verifier: %s\n", verifier) + fmt.Printf(" - Verifier: %s\n", verifier) } if chainID := os.Getenv("CHAIN_ID"); chainID != "" { - fmt.Printf(" - Chain ID: %s\n", chainID) + fmt.Printf(" - Chain ID: %s\n", chainID) } if os.Getenv("PORT") == "" { fmt.Println("[WARN] PORT not set, using default: 3000") @@ -541,7 +541,7 @@ func callOpenRouter(ctx context.Context, text string) (string, error) { return "", fmt.Errorf("failed to create OpenRouter request: %w", err) } - req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Authorization", "Bearer " + apiKey) req.Header.Set("Content-Type", "application/json") if cid, ok := ctx.Value(correlationIDKey).(string); ok { @@ -578,9 +578,20 @@ func callOpenRouter(ctx context.Context, text string) (string, error) { return "", fmt.Errorf("invalid response from AI provider") } - choice := choices[0].(map[string]interface{}) - message := choice["message"].(map[string]interface{}) - content := message["content"].(string) + choice, ok := choices[0].(map[string]interface{}) + if !ok { + return "", fmt.Errorf("invalid response structure: choice") + } + + message, ok := choice["message"].(map[string]interface{}) + if !ok { + return "", fmt.Errorf("invalid response structure: message") + } + + content, ok := message["content"].(string) + if !ok { + return "", fmt.Errorf("invalid response structure: content") + } return content, nil } @@ -1059,7 +1070,7 @@ var checkOpenRouterHealth = func() string { if err != nil { return "unreachable" } - req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Authorization", "Bearer " + apiKey) resp, err := http.DefaultClient.Do(req) if err != nil { diff --git a/gateway/openrouter_retry.go b/gateway/openrouter_retry.go index f6ccb91..962fa0f 100644 --- a/gateway/openrouter_retry.go +++ b/gateway/openrouter_retry.go @@ -7,20 +7,57 @@ import ( ) func doRequestWithRetry(req *http.Request) (*http.Response, error) { - backoff := 200 * time.Millisecond - - for i := 0; i < 3; i++ { + maxRetries := 3 + + for i := 0; i < maxRetries; i++ { + + // ------------------------------------------------ + // Reset request body (http.Client consumes it once) + // ------------------------------------------------ + if req.GetBody != nil { + body, err := req.GetBody() + if err != nil { + return nil, err + } + req.Body = body + } resp, err := http.DefaultClient.Do(req) - if err == nil && resp.StatusCode < 500 { - return resp, nil + // ------------------------------------------------ + // SUCCESS CASES + // ------------------------------------------------ + if err == nil { + + // 4xx → client error → DO NOT RETRY + if resp.StatusCode < 500 { + return resp, nil + } + + // 5xx → retry → close body first to avoid leak + resp.Body.Close() + } + + // ------------------------------------------------ + // LAST ATTEMPT → exit + // ------------------------------------------------ + if i == maxRetries-1 { + break + } + + // ------------------------------------------------ + // Context-aware backoff sleep + // Stops immediately if request is cancelled/timeout + // ------------------------------------------------ + select { + case <-time.After(backoff): + case <-req.Context().Done(): + return nil, req.Context().Err() } - time.Sleep(backoff) - backoff *= 2 + backoff *= 2 // exponential backoff } - return nil, fmt.Errorf("retry failed") + return nil, fmt.Errorf("retry failed after %d attempts", maxRetries) }