From f6355e268482d4c7a785e503d55fd92d1b4910e9 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 19 Aug 2025 18:45:41 +0000 Subject: [PATCH 1/4] feat: add retry logic for overloaded LLM errors Implements a retry mechanism for when the LLM service returns a 503 "overloaded" error. The changes include: - Retrying the API call up to two times (three attempts total). - A backoff delay that increases with each retry. - Specific error checking for `*genai.APIError` with a 503 status code and "overloaded" message. - Logging of LLM API errors to aid in future debugging. --- internal/llm/llm.go | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/internal/llm/llm.go b/internal/llm/llm.go index 7aa7bc7..9cb66d1 100644 --- a/internal/llm/llm.go +++ b/internal/llm/llm.go @@ -3,6 +3,9 @@ package llm import ( "context" "errors" + "log" + "strings" + "time" "google.golang.org/genai" ) @@ -38,10 +41,31 @@ func (s *GeminiService) GenerateContent(ctx context.Context, prompt string, sche return "", errors.New("LLM service is disabled: no API key provided") } - resp, err := s.client.GenerateContent(ctx, s.modelName, genai.Text(prompt), &genai.GenerateContentConfig{ - ResponseMIMEType: "application/json", - ResponseSchema: schema, - }) + var resp *genai.GenerateContentResponse + var err error + + for i := 0; i < 3; i++ { + resp, err = s.client.GenerateContent(ctx, s.modelName, genai.Text(prompt), &genai.GenerateContentConfig{ + ResponseMIMEType: "application/json", + ResponseSchema: schema, + }) + if err == nil { + break + } + + var apiError *genai.APIError + if errors.As(err, &apiError) { + log.Printf("LLM API error: Code=%d, Status=%s, Message=%s", apiError.Code, apiError.Status, apiError.Message) + if apiError.Code == 503 && strings.Contains(apiError.Message, "overloaded") { + log.Printf("Retrying LLM call (%d/2)...", i) + time.Sleep(time.Second * time.Duration(i+1)) // Wait 1, 2 seconds + continue + } + } + + return "", err + } + if err != nil { return "", err } @@ -53,4 +77,4 @@ func (s *GeminiService) GenerateContent(ctx context.Context, prompt string, sche // The API returns JSON as a Text part when a schema is provided textPart := resp.Text() return textPart, nil -} +} \ No newline at end of file -- 2.54.0 From 1c49f731da0c7b62d232242f5c46b591342aa581 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 19 Aug 2025 18:48:42 +0000 Subject: [PATCH 2/4] feat: convert summary newlines to line breaks Adds a template function to convert newline characters in the digest summary to HTML `
` tags, preserving the intended formatting. The new `nl2br` function also ensures the summary content is HTML-escaped to prevent XSS vulnerabilities. --- internal/templates/entries.gohtml | 2 +- internal/templates/funcs.go | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/templates/entries.gohtml b/internal/templates/entries.gohtml index 62e5395..f2e3c44 100644 --- a/internal/templates/entries.gohtml +++ b/internal/templates/entries.gohtml @@ -29,7 +29,7 @@

Summary

-

{{.Summary}}

+

{{nl2br .Summary}}

diff --git a/internal/templates/funcs.go b/internal/templates/funcs.go index 476fa71..ca2eaae 100644 --- a/internal/templates/funcs.go +++ b/internal/templates/funcs.go @@ -14,6 +14,7 @@ var staticFS embed.FS func FuncMap() template.FuncMap { return template.FuncMap{ "include": include, + "nl2br": nl2br, } } @@ -32,3 +33,8 @@ func include(filename string) (any, error) { return template.HTML(b), nil } } + +func nl2br(s string) template.HTML { + return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(s), "\n", "
")) +} + -- 2.54.0 From fd67ec3723f0483b2c482ac1ef12b7da6566289f Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 19 Aug 2025 18:54:45 +0000 Subject: [PATCH 3/4] refactor: consolidate template functions - Moves the `htmlEscape` function from `templates.go` to `funcs.go` to centralize all template functions. - Renames `nl2br` to `textToHTML` for clarity. - Updates the templates to use the new function name. --- internal/templates/entries.gohtml | 2 +- internal/templates/funcs.go | 10 +++++++--- internal/templates/templates.go | 6 +----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/templates/entries.gohtml b/internal/templates/entries.gohtml index f2e3c44..266ff33 100644 --- a/internal/templates/entries.gohtml +++ b/internal/templates/entries.gohtml @@ -29,7 +29,7 @@

Summary

-

{{nl2br .Summary}}

+

{{textToHTML .Summary}}

diff --git a/internal/templates/funcs.go b/internal/templates/funcs.go index ca2eaae..f461bd5 100644 --- a/internal/templates/funcs.go +++ b/internal/templates/funcs.go @@ -13,8 +13,9 @@ var staticFS embed.FS func FuncMap() template.FuncMap { return template.FuncMap{ - "include": include, - "nl2br": nl2br, + "include": include, + "textToHTML": textToHTML, + "htmlEscape": htmlEscape, } } @@ -34,7 +35,10 @@ func include(filename string) (any, error) { } } -func nl2br(s string) template.HTML { +func textToHTML(s string) template.HTML { return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(s), "\n", "
")) } +func htmlEscape(s string) template.HTML { + return template.HTML(s) +} \ No newline at end of file diff --git a/internal/templates/templates.go b/internal/templates/templates.go index 4caeddf..5ca0762 100644 --- a/internal/templates/templates.go +++ b/internal/templates/templates.go @@ -27,11 +27,7 @@ func init() { archiveTemplateName := "entries.gohtml" emailTemplateName := "email.gotxt" - ArchiveTemplate, err = htmlTemplate.New(archiveTemplateName).Funcs(FuncMap()).Funcs(htmlTemplate.FuncMap{ - "htmlEscape": func(s string) htmlTemplate.HTML { - return htmlTemplate.HTML(s) - }, - }).ParseFS(embedFS, archiveTemplateName) + ArchiveTemplate, err = htmlTemplate.New(archiveTemplateName).Funcs(FuncMap()).ParseFS(embedFS, archiveTemplateName) if err != nil { log.Fatalf("Error parsing archive template: %v", err) -- 2.54.0 From 3fd354956057a354fbe6c2462657e1a8a5b91edd Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 19 Aug 2025 19:00:04 +0000 Subject: [PATCH 4/4] refactor: abstract LLM retry logic - Extracts the LLM API call retry logic into a private `generateContentWithRetry` method for improved readability and separation of concerns. - Defines a `maxRetries` constant for the retry limit. --- internal/llm/llm.go | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/internal/llm/llm.go b/internal/llm/llm.go index 9cb66d1..031f88a 100644 --- a/internal/llm/llm.go +++ b/internal/llm/llm.go @@ -10,7 +10,10 @@ import ( "google.golang.org/genai" ) -const GeminiModel = "gemini-1.5-flash" +const ( + GeminiModel = "gemini-1.5-flash" + maxRetries = 3 +) type modelClient interface { GenerateContent(ctx context.Context, model string, parts []*genai.Content, config *genai.GenerateContentConfig) (*genai.GenerateContentResponse, error) @@ -36,36 +39,41 @@ func NewGeminiService(apiKey string) (*GeminiService, error) { return &GeminiService{client: client.Models, modelName: GeminiModel}, nil } -func (s *GeminiService) GenerateContent(ctx context.Context, prompt string, schema *genai.Schema) (string, error) { - if s.client == nil { - return "", errors.New("LLM service is disabled: no API key provided") - } - +func (s *GeminiService) generateContentWithRetry(ctx context.Context, prompt string, schema *genai.Schema) (*genai.GenerateContentResponse, error) { var resp *genai.GenerateContentResponse var err error - for i := 0; i < 3; i++ { + for i := 0; i < maxRetries; i++ { resp, err = s.client.GenerateContent(ctx, s.modelName, genai.Text(prompt), &genai.GenerateContentConfig{ ResponseMIMEType: "application/json", ResponseSchema: schema, }) if err == nil { - break + return resp, nil } var apiError *genai.APIError if errors.As(err, &apiError) { log.Printf("LLM API error: Code=%d, Status=%s, Message=%s", apiError.Code, apiError.Status, apiError.Message) if apiError.Code == 503 && strings.Contains(apiError.Message, "overloaded") { - log.Printf("Retrying LLM call (%d/2)...", i) + log.Printf("Retrying LLM call (%d/%d)...", i+1, maxRetries-1) time.Sleep(time.Second * time.Duration(i+1)) // Wait 1, 2 seconds continue } } - return "", err + return nil, err } + return nil, err +} + +func (s *GeminiService) GenerateContent(ctx context.Context, prompt string, schema *genai.Schema) (string, error) { + if s.client == nil { + return "", errors.New("LLM service is disabled: no API key provided") + } + + resp, err := s.generateContentWithRetry(ctx, prompt, schema) if err != nil { return "", err } @@ -77,4 +85,4 @@ func (s *GeminiService) GenerateContent(ctx context.Context, prompt string, sche // The API returns JSON as a Text part when a schema is provided textPart := resp.Text() return textPart, nil -} \ No newline at end of file +} -- 2.54.0