diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-08 09:53:17 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-08 09:53:17 +0300 |
| commit | cd4265b8f87811db758c89bab5e62daa6c39337a (patch) | |
| tree | 94c0a129b4b6ee9132baaa81eb3fbc96b083cbd4 /internal | |
| parent | 7e7d34bfc0aec2aa8dff4cefa720e4a9b42d95d8 (diff) | |
feat(httpctx): add timeouts for OpenAI, Gemini, and HTTP downloads
Introduce internal/httpctx with non-zero http.Client timeouts for go-openai
and google.golang.org/genai, shared image download client, and
WithTimeoutUnlessSet for operation-level deadlines when callers use Background.
Wire NewOpenAIClient/NewGenAIClient everywhere clients are constructed.
Apply Search timeouts for DALL-E and Nano Banana, provider audio timeouts,
model-list timeouts, Veo operation timeouts, story page download context,
and single-word CLI processing cap.
Made-with: Cursor
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/audio/gemini_provider.go | 7 | ||||
| -rw-r--r-- | internal/audio/openai_provider.go | 7 | ||||
| -rw-r--r-- | internal/cli/video_runner.go | 1 | ||||
| -rw-r--r-- | internal/httpctx/httpctx.go | 96 | ||||
| -rw-r--r-- | internal/httpctx/httpctx_test.go | 55 | ||||
| -rw-r--r-- | internal/image/nanobanana.go | 7 | ||||
| -rw-r--r-- | internal/image/openai.go | 13 | ||||
| -rw-r--r-- | internal/models/lister.go | 15 | ||||
| -rw-r--r-- | internal/phonetic/fetcher.go | 5 | ||||
| -rw-r--r-- | internal/processor/processor.go | 5 | ||||
| -rw-r--r-- | internal/story/artist.go | 8 | ||||
| -rw-r--r-- | internal/story/generator.go | 3 | ||||
| -rw-r--r-- | internal/story/narrator.go | 5 | ||||
| -rw-r--r-- | internal/translation/translator.go | 5 | ||||
| -rw-r--r-- | internal/video/veo.go | 10 |
15 files changed, 219 insertions, 23 deletions
diff --git a/internal/audio/gemini_provider.go b/internal/audio/gemini_provider.go index 5100c5e..b573a02 100644 --- a/internal/audio/gemini_provider.go +++ b/internal/audio/gemini_provider.go @@ -12,6 +12,8 @@ import ( "strings" "google.golang.org/genai" + + "codeberg.org/snonux/totalrecall/internal/httpctx" ) const ( @@ -44,7 +46,7 @@ func NewGeminiProvider(config GeminiAudioConfig, outputFormat string) (Provider, return nil, errors.New("google API key is required") } - client, err := genai.NewClient(context.Background(), &genai.ClientConfig{ + client, err := httpctx.NewGenAIClient(context.Background(), &genai.ClientConfig{ APIKey: normalized.APIKey, Backend: genai.BackendGeminiAPI, }) @@ -60,6 +62,9 @@ func NewGeminiProvider(config GeminiAudioConfig, outputFormat string) (Provider, // GenerateAudio generates audio using Gemini TTS and writes it to the output file. func (p *GeminiProvider) GenerateAudio(ctx context.Context, text string, outputFile string) error { + ctx, cancel := httpctx.WithTimeoutUnlessSet(ctx, httpctx.GenAIHTTPTimeout) + defer cancel() + if err := ValidateBulgarianText(text); err != nil { return err } diff --git a/internal/audio/openai_provider.go b/internal/audio/openai_provider.go index e6212f8..5b59a3e 100644 --- a/internal/audio/openai_provider.go +++ b/internal/audio/openai_provider.go @@ -10,6 +10,8 @@ import ( "strings" "github.com/sashabaranov/go-openai" + + "codeberg.org/snonux/totalrecall/internal/httpctx" ) // Compile-time check that OpenAIProvider implements the Provider interface. @@ -31,7 +33,7 @@ func NewOpenAIProvider(config OpenAIAudioConfig, outputFormat string) (Provider, } return &OpenAIProvider{ - client: openai.NewClient(config.Key), + client: httpctx.NewOpenAIClient(config.Key), config: config, outputFormat: outputFormat, }, nil @@ -39,6 +41,9 @@ func NewOpenAIProvider(config OpenAIAudioConfig, outputFormat string) (Provider, // GenerateAudio generates audio using OpenAI TTS func (p *OpenAIProvider) GenerateAudio(ctx context.Context, text string, outputFile string) error { + ctx, cancel := httpctx.WithTimeoutUnlessSet(ctx, httpctx.OpenAIHTTPTimeout) + defer cancel() + // Validate Bulgarian text if err := ValidateBulgarianText(text); err != nil { return err diff --git a/internal/cli/video_runner.go b/internal/cli/video_runner.go index 96cd7cd..aab23ae 100644 --- a/internal/cli/video_runner.go +++ b/internal/cli/video_runner.go @@ -34,6 +34,7 @@ func GenerateSelectedVideos(apiKey string, selectedPaths []string) error { for _, imgPath := range selectedPaths { fmt.Printf("Generating video for: %s\n", imgPath) + // GenerateVideoFromPath applies an operation-level deadline when ctx has none. mp4Path, err := gen.GenerateVideoFromPath(ctx, imgPath) if err != nil { return fmt.Errorf("cli: generating video for %s: %w", imgPath, err) diff --git a/internal/httpctx/httpctx.go b/internal/httpctx/httpctx.go new file mode 100644 index 0000000..2a65f50 --- /dev/null +++ b/internal/httpctx/httpctx.go @@ -0,0 +1,96 @@ +// Package httpctx provides HTTP client defaults and context helpers for +// outbound API calls. go-openai uses http.Client{} with zero timeout by +// default; google.golang.org/genai can also run without an explicit client +// deadline. This package sets consistent per-request HTTP timeouts and +// optional operation-level context deadlines when callers pass Background. +package httpctx + +import ( + "context" + "net/http" + "time" + + "github.com/sashabaranov/go-openai" + "google.golang.org/genai" +) + +const ( + // OpenAIHTTPTimeout bounds each go-openai HTTP round-trip (TTS, chat, + // images). Without this, the default client has no timeout. + OpenAIHTTPTimeout = 15 * time.Minute + + // GenAIHTTPTimeout bounds each Google GenAI SDK HTTP request (Gemini text, + // image, TTS, Veo polling, file download). + GenAIHTTPTimeout = 30 * time.Minute + + // ImageDownloadTimeout limits fetches of remote image URLs (e.g. DALL-E + // temporary URLs, HTTP image links). + ImageDownloadTimeout = 60 * time.Second + + // OperationTimeoutDefault caps a full high-level operation (e.g. one image + // Search including scene + generation) when the caller did not set a deadline. + OperationTimeoutDefault = 15 * time.Minute + + // ListModelsTimeout bounds model-listing CLI calls. + ListModelsTimeout = 3 * time.Minute + + // StoryPageImageTimeout bounds a single comic page image pipeline (search + + // download) when no parent deadline exists. + StoryPageImageTimeout = 25 * time.Minute + + // VeoCLIPerVideoTimeout bounds one gallery-to-MP4 Veo run (start + poll + + // download) when the CLI passes Background. + VeoCLIPerVideoTimeout = 25 * time.Minute + + // SingleWordProcessTimeout caps ProcessWordWithTranslation when the CLI + // uses an unbounded context (batch processing already applies per-word + // timeouts elsewhere). + SingleWordProcessTimeout = 10 * time.Minute +) + +// OpenAIHTTPClient returns an http.Client for go-openai DefaultConfig. +func OpenAIHTTPClient() *http.Client { + return &http.Client{Timeout: OpenAIHTTPTimeout} +} + +// GenAIHTTPClient returns an http.Client for google.golang.org/genai. +func GenAIHTTPClient() *http.Client { + return &http.Client{Timeout: GenAIHTTPTimeout} +} + +// ImageDownloadHTTPClient returns a client for generic image URL downloads. +func ImageDownloadHTTPClient() *http.Client { + return &http.Client{Timeout: ImageDownloadTimeout} +} + +// NewOpenAIClient creates a go-openai client whose HTTP transport has a deadline. +func NewOpenAIClient(token string) *openai.Client { + cfg := openai.DefaultConfig(token) + cfg.HTTPClient = OpenAIHTTPClient() + return openai.NewClientWithConfig(cfg) +} + +// NewGenAIClient wraps genai.NewClient, setting HTTPClient when the config does +// not supply one so outbound requests never rely on an unbounded default. +func NewGenAIClient(ctx context.Context, cfg *genai.ClientConfig) (*genai.Client, error) { + if cfg == nil { + cfg = &genai.ClientConfig{} + } + merged := *cfg + if merged.HTTPClient == nil { + merged.HTTPClient = GenAIHTTPClient() + } + return genai.NewClient(ctx, &merged) +} + +// WithTimeoutUnlessSet returns a child context with timeout d when ctx has no +// deadline. If ctx already has a deadline, it returns ctx and a no-op cancel. +func WithTimeoutUnlessSet(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) { + if ctx == nil { + ctx = context.Background() + } + if _, ok := ctx.Deadline(); ok { + return ctx, func() {} + } + return context.WithTimeout(ctx, d) +} diff --git a/internal/httpctx/httpctx_test.go b/internal/httpctx/httpctx_test.go new file mode 100644 index 0000000..6cd6521 --- /dev/null +++ b/internal/httpctx/httpctx_test.go @@ -0,0 +1,55 @@ +package httpctx + +import ( + "context" + "testing" + "time" +) + +func TestWithTimeoutUnlessSet_AlreadyHasDeadline(t *testing.T) { + t.Parallel() + + parent, cancel := context.WithTimeout(context.Background(), time.Hour) + defer cancel() + + ctx, childCancel := WithTimeoutUnlessSet(parent, time.Nanosecond) + defer childCancel() + + if ctx != parent { + t.Fatal("expected same context when parent already has deadline") + } + + deadline, ok := ctx.Deadline() + if !ok { + t.Fatal("expected deadline on context") + } + if time.Until(deadline) < 30*time.Minute { + t.Fatalf("expected parent ~1h deadline preserved, got %v", time.Until(deadline)) + } +} + +func TestWithTimeoutUnlessSet_NoDeadline(t *testing.T) { + t.Parallel() + + ctx, cancel := WithTimeoutUnlessSet(context.Background(), 50*time.Millisecond) + defer cancel() + + deadline, ok := ctx.Deadline() + if !ok { + t.Fatal("expected deadline") + } + if time.Until(deadline) > time.Second { + t.Fatalf("deadline too far: %v", deadline) + } +} + +func TestWithTimeoutUnlessSet_NilUsesBackground(t *testing.T) { + t.Parallel() + + ctx, cancel := WithTimeoutUnlessSet(nil, 50*time.Millisecond) + defer cancel() + + if err := ctx.Err(); err != nil { + t.Fatalf("context should not be done: %v", err) + } +} diff --git a/internal/image/nanobanana.go b/internal/image/nanobanana.go index ac55f3b..34a83ba 100644 --- a/internal/image/nanobanana.go +++ b/internal/image/nanobanana.go @@ -16,6 +16,8 @@ import ( "time" "google.golang.org/genai" + + "codeberg.org/snonux/totalrecall/internal/httpctx" ) const ( @@ -52,7 +54,7 @@ type NanoBananaClient struct { // (ImageSearcher + AttributionProvider). var _ ImageClient = (*NanoBananaClient)(nil) -var newNanoBananaClient = genai.NewClient +var newNanoBananaClient = httpctx.NewGenAIClient var nanoBananaGenerateText = func(ctx context.Context, c *NanoBananaClient, model, systemPrompt, userPrompt string, temperature float32, maxOutputTokens int32) (string, error) { return c.generateText(ctx, model, systemPrompt, userPrompt, temperature, maxOutputTokens) } @@ -84,6 +86,9 @@ func NewNanoBananaClient(config *NanoBananaConfig) *NanoBananaClient { // Search generates an educational image for the Bulgarian word using Nano Banana. func (c *NanoBananaClient) Search(ctx context.Context, opts *SearchOptions) ([]SearchResult, error) { + ctx, cancel := httpctx.WithTimeoutUnlessSet(ctx, httpctx.OperationTimeoutDefault) + defer cancel() + if err := c.ensureReady(); err != nil { return nil, err } diff --git a/internal/image/openai.go b/internal/image/openai.go index c277406..9fb8148 100644 --- a/internal/image/openai.go +++ b/internal/image/openai.go @@ -11,16 +11,16 @@ import ( "time" "github.com/sashabaranov/go-openai" + + "codeberg.org/snonux/totalrecall/internal/httpctx" ) // Compile-time check that OpenAIClient implements the full ImageClient interface // (ImageSearcher + AttributionProvider). var _ ImageClient = (*OpenAIClient)(nil) -// imageHTTPClient is a shared HTTP client with a generous timeout for image -// downloads. http.DefaultClient has no timeout, which can block goroutines -// indefinitely on slow or unresponsive servers. -var imageHTTPClient = &http.Client{Timeout: 60 * time.Second} +// imageHTTPClient is a shared HTTP client with a timeout for image downloads. +var imageHTTPClient = httpctx.ImageDownloadHTTPClient() // OpenAIClient implements ImageSearcher for OpenAI DALL-E image generation type OpenAIClient struct { @@ -52,7 +52,7 @@ func NewOpenAIClient(config *OpenAIConfig) *OpenAIClient { return &OpenAIClient{} } - client := openai.NewClient(config.APIKey) + client := httpctx.NewOpenAIClient(config.APIKey) // Set defaults if config.Model == "" { @@ -82,6 +82,9 @@ func NewOpenAIClient(config *OpenAIConfig) *OpenAIClient { // Search generates an image for the Bulgarian word using DALL-E func (c *OpenAIClient) Search(ctx context.Context, opts *SearchOptions) ([]SearchResult, error) { + ctx, cancel := httpctx.WithTimeoutUnlessSet(ctx, httpctx.OperationTimeoutDefault) + defer cancel() + if c.client == nil { return nil, &SearchError{ Provider: "openai", diff --git a/internal/models/lister.go b/internal/models/lister.go index ca3d3a2..2f05988 100644 --- a/internal/models/lister.go +++ b/internal/models/lister.go @@ -10,6 +10,8 @@ import ( "github.com/sashabaranov/go-openai" "google.golang.org/genai" + + "codeberg.org/snonux/totalrecall/internal/httpctx" ) type openAIModelLister interface { @@ -43,11 +45,11 @@ func NewLister(openAIKey, geminiKey string, out io.Writer) *Lister { } if lister.openAIKey != "" { - lister.openAIClient = openai.NewClient(lister.openAIKey) + lister.openAIClient = httpctx.NewOpenAIClient(lister.openAIKey) } if lister.geminiKey != "" { - client, err := genai.NewClient(context.Background(), &genai.ClientConfig{ + client, err := httpctx.NewGenAIClient(context.Background(), &genai.ClientConfig{ APIKey: lister.geminiKey, }) if err != nil { @@ -97,7 +99,10 @@ func (l *Lister) printOpenAIModels() error { return fmt.Errorf("OpenAI client not initialized") } - models, err := l.openAIClient.ListModels(context.Background()) + ctx, cancel := context.WithTimeout(context.Background(), httpctx.ListModelsTimeout) + defer cancel() + + models, err := l.openAIClient.ListModels(ctx) if err != nil { return fmt.Errorf("failed to list OpenAI models: %w", err) } @@ -193,7 +198,9 @@ func (l *Lister) printGeminiModels() error { return fmt.Errorf("gemini client not initialized") } - ctx := context.Background() + ctx, cancel := context.WithTimeout(context.Background(), httpctx.ListModelsTimeout) + defer cancel() + config := &genai.ListModelsConfig{ QueryBase: genai.Ptr(true), } diff --git a/internal/phonetic/fetcher.go b/internal/phonetic/fetcher.go index aa821b5..3e02052 100644 --- a/internal/phonetic/fetcher.go +++ b/internal/phonetic/fetcher.go @@ -14,6 +14,7 @@ import ( "google.golang.org/genai" appconfig "codeberg.org/snonux/totalrecall/internal/config" + "codeberg.org/snonux/totalrecall/internal/httpctx" ) const ( @@ -58,7 +59,7 @@ type Fetcher struct { geminiInitErr error } -var newGeminiClient = genai.NewClient +var newGeminiClient = httpctx.NewGenAIClient var fetchOpenAIPhonetic = func(ctx context.Context, client *openai.Client, word string) (string, error) { req := openai.ChatCompletionRequest{ @@ -122,7 +123,7 @@ func NewFetcher(config *Config) *Fetcher { switch fetcher.provider { case ProviderOpenAI: if fetcher.openAIKey != "" { - fetcher.openAIClient = openai.NewClient(fetcher.openAIKey) + fetcher.openAIClient = httpctx.NewOpenAIClient(fetcher.openAIKey) } case ProviderGemini: if fetcher.googleAPIKey != "" { diff --git a/internal/processor/processor.go b/internal/processor/processor.go index 67dc897..433ef28 100644 --- a/internal/processor/processor.go +++ b/internal/processor/processor.go @@ -15,6 +15,7 @@ import ( "codeberg.org/snonux/totalrecall/internal/batch" "codeberg.org/snonux/totalrecall/internal/cli" "codeberg.org/snonux/totalrecall/internal/gui" + "codeberg.org/snonux/totalrecall/internal/httpctx" "codeberg.org/snonux/totalrecall/internal/image" "codeberg.org/snonux/totalrecall/internal/phonetic" "codeberg.org/snonux/totalrecall/internal/store" @@ -239,7 +240,9 @@ func (p *Processor) ProcessSingleWord(word string) error { // ProcessWordWithTranslation processes a word with an optional provided English // translation, using the default en-bg card type. func (p *Processor) ProcessWordWithTranslation(word, providedTranslation string) error { - return p.ProcessWordWithTranslationAndType(context.Background(), word, providedTranslation, internal.CardTypeEnBg) + ctx, cancel := context.WithTimeout(context.Background(), httpctx.SingleWordProcessTimeout) + defer cancel() + return p.ProcessWordWithTranslationAndType(ctx, word, providedTranslation, internal.CardTypeEnBg) } // ProcessWordWithTranslationAndType processes a word with optional provided diff --git a/internal/story/artist.go b/internal/story/artist.go index 4ae0a6f..8e55d33 100644 --- a/internal/story/artist.go +++ b/internal/story/artist.go @@ -12,6 +12,7 @@ import ( "google.golang.org/genai" "codeberg.org/snonux/totalrecall/internal/batch" + "codeberg.org/snonux/totalrecall/internal/httpctx" "codeberg.org/snonux/totalrecall/internal/image" ) @@ -384,7 +385,7 @@ func (a *Artist) resolveHelperTexts(storyText, prebuiltBible string) (bible, blu return bible, "" } - client, err := genai.NewClient(context.Background(), &genai.ClientConfig{APIKey: a.apiKey}) + client, err := httpctx.NewGenAIClient(context.Background(), &genai.ClientConfig{APIKey: a.apiKey}) if err != nil { fmt.Printf(" Warning: Gemini client failed for blurb (%v)\n", err) return bible, "" @@ -447,7 +448,10 @@ func (a *Artist) generateSinglePage(prompt, fileNamePattern string, refs [][]byt MaxSizeBytes: 20 * 1024 * 1024, }) - _, savedPath, err := downloader.DownloadBestMatchWithOptions(context.Background(), opts) + pageCtx, pageCancel := context.WithTimeout(context.Background(), httpctx.StoryPageImageTimeout) + defer pageCancel() + + _, savedPath, err := downloader.DownloadBestMatchWithOptions(pageCtx, opts) if err != nil { return "", nil, err } diff --git a/internal/story/generator.go b/internal/story/generator.go index 5c4c671..bac5d82 100644 --- a/internal/story/generator.go +++ b/internal/story/generator.go @@ -10,6 +10,7 @@ import ( "google.golang.org/genai" "codeberg.org/snonux/totalrecall/internal/batch" + "codeberg.org/snonux/totalrecall/internal/httpctx" ) const ( @@ -150,7 +151,7 @@ func NewGenerator(config *Config) *Generator { g.textModel = config.TextModel } - client, err := genai.NewClient(context.Background(), &genai.ClientConfig{ + client, err := httpctx.NewGenAIClient(context.Background(), &genai.ClientConfig{ APIKey: config.APIKey, }) if err != nil { diff --git a/internal/story/narrator.go b/internal/story/narrator.go index be3c21c..b8c7341 100644 --- a/internal/story/narrator.go +++ b/internal/story/narrator.go @@ -13,6 +13,7 @@ import ( "google.golang.org/genai" "codeberg.org/snonux/totalrecall/internal/audio" + "codeberg.org/snonux/totalrecall/internal/httpctx" ) const ( @@ -235,7 +236,7 @@ func (n *Narrator) buildIntro(storyText string) string { if n.apiKey == "" { return "" } - client, err := genai.NewClient(context.Background(), &genai.ClientConfig{APIKey: n.apiKey}) + client, err := httpctx.NewGenAIClient(context.Background(), &genai.ClientConfig{APIKey: n.apiKey}) if err != nil { fmt.Printf(" Warning: intro text generation failed: %v\n", err) return "" @@ -310,7 +311,7 @@ func (n *Narrator) buildConclusion(storyText string) string { return "" } - client, err := genai.NewClient(context.Background(), &genai.ClientConfig{APIKey: n.apiKey}) + client, err := httpctx.NewGenAIClient(context.Background(), &genai.ClientConfig{APIKey: n.apiKey}) if err != nil { fmt.Printf(" Warning: conclusion text generation failed: %v\n", err) return "" diff --git a/internal/translation/translator.go b/internal/translation/translator.go index 179e137..84ca326 100644 --- a/internal/translation/translator.go +++ b/internal/translation/translator.go @@ -12,6 +12,7 @@ import ( "google.golang.org/genai" appconfig "codeberg.org/snonux/totalrecall/internal/config" + "codeberg.org/snonux/totalrecall/internal/httpctx" ) const ( @@ -59,7 +60,7 @@ type Translator struct { geminiModel string } -var newGeminiClient = genai.NewClient +var newGeminiClient = httpctx.NewGenAIClient // NewTranslator creates a new translator instance from the provided config. func NewTranslator(config *Config) *Translator { @@ -77,7 +78,7 @@ func NewTranslator(config *Config) *Translator { } if normalized.OpenAIKey != "" { - translator.openAIClient = openai.NewClient(normalized.OpenAIKey) + translator.openAIClient = httpctx.NewOpenAIClient(normalized.OpenAIKey) } if normalized.GoogleAPIKey != "" { diff --git a/internal/video/veo.go b/internal/video/veo.go index 018c06a..05fd0f3 100644 --- a/internal/video/veo.go +++ b/internal/video/veo.go @@ -13,6 +13,8 @@ import ( "time" "google.golang.org/genai" + + "codeberg.org/snonux/totalrecall/internal/httpctx" ) const ( @@ -48,7 +50,7 @@ type VeoGenerator struct { // newGenaiClient is the constructor used in production and can be replaced in // unit tests to inject a mock transport. -var newGenaiClient = genai.NewClient +var newGenaiClient = httpctx.NewGenAIClient // NewVeoGenerator creates a new VeoGenerator backed by the Gemini API. // It returns an error if the API key is empty or the SDK client cannot be @@ -82,6 +84,9 @@ func NewVeoGenerator(apiKey string) (*VeoGenerator, error) { // outputDir is where the output MP4 will be written. // pageNum selects which gallery page to animate (1-based). func (g *VeoGenerator) GenerateVideoFromGallery(ctx context.Context, galleryPath string, outputDir string, pageNum int) (string, error) { + ctx, cancel := httpctx.WithTimeoutUnlessSet(ctx, httpctx.VeoCLIPerVideoTimeout) + defer cancel() + imgPath, imgBytes, err := loadGalleryImage(galleryPath, pageNum) if err != nil { return "", err @@ -109,6 +114,9 @@ func (g *VeoGenerator) GenerateVideoFromGallery(ctx context.Context, galleryPath // because it avoids a second glob search and always writes the video next to // its source image. func (g *VeoGenerator) GenerateVideoFromPath(ctx context.Context, imgPath string) (string, error) { + ctx, cancel := httpctx.WithTimeoutUnlessSet(ctx, httpctx.VeoCLIPerVideoTimeout) + defer cancel() + imgBytes, err := os.ReadFile(imgPath) if err != nil { return "", fmt.Errorf("veo: reading gallery image %s: %w", imgPath, err) |
