summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-04 16:11:14 +0300
committerPaul Buetow <paul@buetow.org>2026-04-04 16:11:14 +0300
commitedf1d5d96dba96f62eb660a2bb440e8d31ab8882 (patch)
tree8b6d1c2dac079a2a73df0e573b048fd6acf63282 /internal
parent2d6cd1a4d388b22396827cbf151aa2990ca6cd5a (diff)
feat: progressive retry backoff for rate-limited image generation
Replace fixed 10s retry pause with pageRetryBase × attempt (15s→30s→45s→60s) and increase max retries from 3 to 5. Extracted shared retryPage() helper used by both generateStoryPage and generatePageWithRetry to keep retry logic DRY. Gallery pages that fail due to Gemini rate limits now recover automatically instead of being silently skipped after just 30 seconds of retries. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal')
-rw-r--r--internal/story/artist.go69
1 files changed, 38 insertions, 31 deletions
diff --git a/internal/story/artist.go b/internal/story/artist.go
index 4f9ff4c..3e6b191 100644
--- a/internal/story/artist.go
+++ b/internal/story/artist.go
@@ -29,11 +29,15 @@ const (
// pageMaxRetries is the number of times a story page generation is retried
// before being skipped. Gemini image generation occasionally returns no data
- // due to transient safety filter hits or API hiccups; a retry usually succeeds.
- pageMaxRetries = 3
+ // due to transient safety filter hits, API hiccups, or rate limiting; a retry
+ // after a pause usually succeeds. 5 attempts with progressive backoff gives
+ // the rate-limiter enough time to recover without burning the whole quota.
+ pageMaxRetries = 5
- // pageRetryPause is the wait between story page retries.
- pageRetryPause = 10 * time.Second
+ // pageRetryBase is multiplied by the attempt number to produce a progressive
+ // backoff: 15 s → 30 s → 45 s → 60 s. The growing pause lets the tool
+ // recover from rate-limit windows automatically instead of failing silently.
+ pageRetryBase = 15 * time.Second
// comicPageAspectRatio: 16:9 is the closest supported widescreen ratio for
// the ThinkPad X1 Gen 9 (2560×1600 / 16:10), filling the display with minimal
@@ -274,45 +278,48 @@ func (a *Artist) DrawComicPages(storyText, prebuiltBible, titleSlug string, entr
}
// generateStoryPage attempts to generate a single story page up to pageMaxRetries
-// times. It returns the saved path and image bytes on success, or empty strings
-// after all retries are exhausted (non-fatal — the caller continues with the next
-// page so the PDF is never aborted by a single transient API failure).
+// times using progressive backoff (pageRetryBase × attempt). Growing pauses let
+// rate-limit windows clear automatically. Non-fatal on exhaustion — the caller
+// continues so the PDF is never aborted by a single transient failure.
func (a *Artist) generateStoryPage(prompt, fileName string, pageNum int, refs [][]byte) (string, []byte) {
fmt.Printf(" Generating story page %d/%d...\n", pageNum, storyPageCount)
- for attempt := 1; attempt <= pageMaxRetries; attempt++ {
- p, pageBytes, err := a.generateSinglePage(prompt, fileName, refs)
- if err == nil {
- return p, pageBytes
- }
- if attempt < pageMaxRetries {
- fmt.Printf(" Story page %d attempt %d failed (%v), retrying in %s...\n",
- pageNum, attempt, err, pageRetryPause)
- time.Sleep(pageRetryPause)
- } else {
- fmt.Printf(" Warning: story page %d failed after %d attempts: %v\n",
- pageNum, pageMaxRetries, err)
- }
- }
- return "", nil
+ return a.retryPage(pageMaxRetries, func(attempt int) (string, []byte, error) {
+ return a.generateSinglePage(prompt, fileName, refs)
+ }, fmt.Sprintf("story page %d", pageNum))
}
-// generatePageWithRetry attempts to generate a single comic page (cover or back
-// cover) up to pageMaxRetries times. Returns the saved path and image bytes on
-// success, or ("", nil) after all retries are exhausted (non-fatal).
+// generatePageWithRetry attempts to generate a single comic page (cover, gallery,
+// or back cover) up to pageMaxRetries times with progressive backoff. Non-fatal.
func (a *Artist) generatePageWithRetry(prompt, fileName string, refs [][]byte, label string) (string, []byte) {
fmt.Printf(" Generating %s...\n", label)
- for attempt := 1; attempt <= pageMaxRetries; attempt++ {
- p, imgBytes, err := a.generateSinglePage(prompt, fileName, refs)
+ return a.retryPage(pageMaxRetries, func(attempt int) (string, []byte, error) {
+ return a.generateSinglePage(prompt, fileName, refs)
+ }, label)
+}
+
+// retryPage is the shared retry loop used by generateStoryPage and
+// generatePageWithRetry. Each failed attempt waits pageRetryBase × attempt
+// before the next try, giving rate-limit windows time to clear:
+//
+// attempt 1 fails → wait 15 s
+// attempt 2 fails → wait 30 s
+// attempt 3 fails → wait 45 s
+// attempt 4 fails → wait 60 s
+// attempt 5 fails → log warning, return ("", nil)
+func (a *Artist) retryPage(maxAttempts int, generateFn func(attempt int) (string, []byte, error), label string) (string, []byte) {
+ for attempt := 1; attempt <= maxAttempts; attempt++ {
+ p, imgBytes, err := generateFn(attempt)
if err == nil {
return p, imgBytes
}
- if attempt < pageMaxRetries {
+ if attempt < maxAttempts {
+ pause := pageRetryBase * time.Duration(attempt)
fmt.Printf(" Warning: %s attempt %d/%d failed (%v), retrying in %s...\n",
- label, attempt, pageMaxRetries, err, pageRetryPause)
- time.Sleep(pageRetryPause)
+ label, attempt, maxAttempts, err, pause)
+ time.Sleep(pause)
} else {
fmt.Printf(" Warning: %s failed after %d attempts: %v\n",
- label, pageMaxRetries, err)
+ label, maxAttempts, err)
}
}
return "", nil