From f7deedf50f6fa00c7dfe989ed381989e202b3057 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 8 Apr 2026 10:14:40 +0300 Subject: feat: add gobreaker circuit breakers for OpenAI and Gemini API calls Introduce internal/apicircuit with sony/gobreaker: trip after five consecutive failures, 45s open state, 2m count reset, half-open trial cap. context.Canceled is not counted as a failure for breaker stats. Wrap OpenAI TTS, Gemini TTS, OpenAI DALL-E/chat (image path), and Nano Banana Gemini GenerateContent calls. HTTP timeouts remain unchanged in httpctx. Made-with: Cursor --- internal/image/nanobanana.go | 33 ++++++++++++++++++++------------- internal/image/openai.go | 11 ++++++++--- 2 files changed, 28 insertions(+), 16 deletions(-) (limited to 'internal/image') diff --git a/internal/image/nanobanana.go b/internal/image/nanobanana.go index 7694069..4d12d08 100644 --- a/internal/image/nanobanana.go +++ b/internal/image/nanobanana.go @@ -17,6 +17,7 @@ import ( "google.golang.org/genai" + "codeberg.org/snonux/totalrecall/internal/apicircuit" "codeberg.org/snonux/totalrecall/internal/config" "codeberg.org/snonux/totalrecall/internal/httpctx" ) @@ -382,12 +383,14 @@ func (c *NanoBananaClient) generateSceneDescription(ctx context.Context, bulgari func (c *NanoBananaClient) generateText(ctx context.Context, model, systemPrompt, userPrompt string, temperature float32, maxOutputTokens int32) (string, error) { temp := temperature - resp, err := c.client.Models.GenerateContent(ctx, model, []*genai.Content{ - genai.NewContentFromText(userPrompt, genai.RoleUser), - }, &genai.GenerateContentConfig{ - SystemInstruction: genai.NewContentFromText(systemPrompt, genai.RoleUser), - Temperature: &temp, - MaxOutputTokens: maxOutputTokens, + resp, err := apicircuit.GeminiNanoBanana(func() (*genai.GenerateContentResponse, error) { + return c.client.Models.GenerateContent(ctx, model, []*genai.Content{ + genai.NewContentFromText(userPrompt, genai.RoleUser), + }, &genai.GenerateContentConfig{ + SystemInstruction: genai.NewContentFromText(systemPrompt, genai.RoleUser), + Temperature: &temp, + MaxOutputTokens: maxOutputTokens, + }) }) if err != nil { return "", fmt.Errorf("gemini API error: %w", err) @@ -412,9 +415,11 @@ func (c *NanoBananaClient) generateImage(ctx context.Context, prompt, aspectRati }, } - resp, err := c.client.Models.GenerateContent(ctx, c.modelName(), []*genai.Content{ - genai.NewContentFromText(prompt, genai.RoleUser), - }, cfg) + resp, err := apicircuit.GeminiNanoBanana(func() (*genai.GenerateContentResponse, error) { + return c.client.Models.GenerateContent(ctx, c.modelName(), []*genai.Content{ + genai.NewContentFromText(prompt, genai.RoleUser), + }, cfg) + }) if err != nil { return nil, "", &SearchError{ Provider: nanoBananaSource, @@ -466,10 +471,12 @@ func (c *NanoBananaClient) generateImageWithRefs(ctx context.Context, prompt, as ) parts = append(parts, &genai.Part{Text: refNote + prompt}) - resp, err := c.client.Models.GenerateContent(ctx, c.modelName(), - []*genai.Content{{Role: string(genai.RoleUser), Parts: parts}}, - cfg, - ) + resp, err := apicircuit.GeminiNanoBanana(func() (*genai.GenerateContentResponse, error) { + return c.client.Models.GenerateContent(ctx, c.modelName(), + []*genai.Content{{Role: string(genai.RoleUser), Parts: parts}}, + cfg, + ) + }) if err != nil { return nil, "", &SearchError{ Provider: nanoBananaSource, diff --git a/internal/image/openai.go b/internal/image/openai.go index 9fb8148..1bd5b2d 100644 --- a/internal/image/openai.go +++ b/internal/image/openai.go @@ -12,6 +12,7 @@ import ( "github.com/sashabaranov/go-openai" + "codeberg.org/snonux/totalrecall/internal/apicircuit" "codeberg.org/snonux/totalrecall/internal/httpctx" ) @@ -154,8 +155,10 @@ func (c *OpenAIClient) Search(ctx context.Context, opts *SearchOptions) ([]Searc req.Style = c.style } - // Generate the image - resp, err := c.client.CreateImage(ctx, req) + // Generate the image (circuit breaker limits load when OpenAI is unhealthy). + resp, err := apicircuit.OpenAIImage(func() (openai.ImageResponse, error) { + return c.client.CreateImage(ctx, req) + }) if err != nil { return nil, &SearchError{ Provider: "openai", @@ -295,7 +298,9 @@ func (c *OpenAIClient) generateSceneDescription(ctx context.Context, bulgarianWo MaxTokens: 100, } - resp, err := c.client.CreateChatCompletion(ctx, req) + resp, err := apicircuit.OpenAIImage(func() (openai.ChatCompletionResponse, error) { + return c.client.CreateChatCompletion(ctx, req) + }) if err != nil { return "", fmt.Errorf("scene generation failed: %w", err) } -- cgit v1.2.3