From 1a09ba0401948d5d0cb286f5cea72acd619b2331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Henrique=20Ara=C3=BAjo?= Date: Fri, 23 Oct 2026 11:33:47 -0300 Subject: [PATCH] fix: wait out Tolgee's rate limit instead of failing the import Tolgee allows 100 translation requests per user every 5 minutes by default, so a 160k-key project (320 pages) hit 429 around page 98. The client now waits as long as Tolgee's retryAfter asks (up to 2 hours per request) instead of retrying blindly, which also added strikes that get connections dropped. The Tasks page and the project strip show the wait. --- internal/server/tolgee_import.go | 2 + internal/server/tolgee_test.go | 25 +++++- internal/tolgee/tolgee.go | 86 ++++++++++++++------ web/src/lib/jobs.svelte.ts | 6 ++ web/src/lib/locales/en.json | 1 + web/src/lib/locales/pt-BR.json | 1 + web/src/routes/(app)/jobs/+page.svelte | 3 +- web/src/routes/(app)/p/[slug]/+layout.svelte | 3 +- 8 files changed, 96 insertions(+), 31 deletions(-) diff --git a/internal/server/tolgee_import.go b/internal/server/tolgee_import.go index 48a6d90..57bf2b1 100644 --- a/internal/server/tolgee_import.go +++ b/internal/server/tolgee_import.go @@ -170,6 +170,8 @@ func runTolgeeImport(ctx context.Context, j *jobRun) (any, error) { if err != nil { return nil, err } + // shown on the Tasks page as "waiting for Tolgee's rate limit" + c.OnRateLimit = func(wait time.Duration) { j.Note(fmt.Sprintf("rate_limited:%d", int(wait.Seconds()))) } result := &tolgeeImportResult{LanguagesAdded: []string{}} // languages first, so every translation has somewhere to go diff --git a/internal/server/tolgee_test.go b/internal/server/tolgee_test.go index f3cd1a6..45436d2 100644 --- a/internal/server/tolgee_test.go +++ b/internal/server/tolgee_test.go @@ -13,10 +13,11 @@ import ( ) // fakeTolgee serves a Tolgee project with n keys in three languages, paged by cursor. -// When failAt > 0, the first request for that page answers 400 once. -func fakeTolgee(t *testing.T, n int, failAt int) (*httptest.Server, *atomic.Int32) { +// When failAt > 0, the first request for that page answers 400 once; when limitAt > 0, the +// first request for that page answers 429 with a short retryAfter, like Tolgee's rate limiter. +func fakeTolgee(t *testing.T, n int, failAt int, limitAt ...int) (*httptest.Server, *atomic.Int32) { var pagesServed atomic.Int32 - var failed atomic.Bool + var failed, limited atomic.Bool mux := http.NewServeMux() auth := func(h http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -44,6 +45,11 @@ func fakeTolgee(t *testing.T, n int, failAt int) (*httptest.Server, *atomic.Int3 size, _ := strconv.Atoi(r.URL.Query().Get("size")) start, _ := strconv.Atoi(r.URL.Query().Get("cursor")) page := start / max(size, 1) + if len(limitAt) > 0 && page == limitAt[0] && !limited.Swap(true) { + w.WriteHeader(429) + fmt.Fprint(w, `{"message": "rate_limited", "retryAfter": 20, "global": false}`) + return + } if failAt > 0 && page == failAt && !failed.Swap(true) { w.WriteHeader(400) fmt.Fprint(w, `{"code": "bad"}`) @@ -167,3 +173,16 @@ func TestTolgeeImport(t *testing.T) { t.Fatalf("jobs: %v", jobs) } } + +func TestTolgeeImportWaitsOutRateLimit(t *testing.T) { + tg, pages := fakeTolgee(t, 1200, 0, 1) + _, newClient := newTestServer(t) + admin := newClient() + admin.do("POST", "/api/auth/login", map[string]string{"username": "admin", "password": DefaultAdminPassword}, 200) + admin.do("POST", "/api/auth/password", map[string]string{"current_password": DefaultAdminPassword, "new_password": "rate-limit-password"}, 200) + res := admin.do("POST", "/api/tolgee/import", map[string]any{"url": tg.URL, "api_key": "tgpak_test", "target": "new"}, 201) + job := admin.waitJob(int(res["job_id"].(float64))) + if job["status"] != "done" || job["done"].(float64) != 1200 || pages.Load() != 3 { + t.Fatalf("rate-limited import: %v (pages %d)", job, pages.Load()) + } +} diff --git a/internal/tolgee/tolgee.go b/internal/tolgee/tolgee.go index ceedd62..51576b2 100644 --- a/internal/tolgee/tolgee.go +++ b/internal/tolgee/tolgee.go @@ -23,6 +23,8 @@ type Client struct { APIKey string ProjectID int64 HTTP *http.Client + // OnRateLimit is told how long the client waits after a 429. + OnRateLimit func(wait time.Duration) } type Language struct { @@ -89,6 +91,10 @@ func (e *Error) Error() string { return fmt.Sprintf("Tolgee answered %d: %s", e.Status, e.Body) } +// MaxRateLimitWait bounds how long one request keeps waiting out Tolgee's rate limits. +// A self-hosted Tolgee allows 100 translation requests per user every 5 minutes by default. +const MaxRateLimitWait = 2 * time.Hour + func (c *Client) get(ctx context.Context, path string, q url.Values, out any) error { base := strings.TrimRight(c.BaseURL, "/") u := base + path @@ -96,14 +102,9 @@ func (c *Client) get(ctx context.Context, path string, q url.Values, out any) er u += "?" + q.Encode() } var lastErr error - for attempt := range 6 { - if attempt > 0 { - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(time.Duration(1<= 500 { + case resp.StatusCode == 429: + // wait as long as Tolgee asks; hammering it only adds strikes and gets the connection dropped + wait = retryAfter(resp, body) + if waited+wait > MaxRateLimitWait { + return &Error{429, snippet(body)} + } + waited += wait + if c.OnRateLimit != nil { + c.OnRateLimit(wait) + } + case resp.StatusCode >= 500: lastErr = &Error{resp.StatusCode, snippet(body)} - continue - } - if resp.StatusCode >= 300 { + case resp.StatusCode >= 300: return &Error{resp.StatusCode, snippet(body)} + default: + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("unexpected answer from %s: %w", path, err) + } + return nil } - if err := json.Unmarshal(body, out); err != nil { - return fmt.Errorf("unexpected answer from %s: %w", path, err) + if wait == 0 { + // network errors and server errors: a few tries with growing pauses + failures++ + if failures > 6 { + return lastErr + } + wait = time.Duration(1< 0 { + return time.Duration(hint.RetryAfter)*time.Millisecond + time.Second + } + if s, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && s > 0 { + return time.Duration(s)*time.Second + time.Second } - return lastErr + return 30 * time.Second } func snippet(b []byte) string { diff --git a/web/src/lib/jobs.svelte.ts b/web/src/lib/jobs.svelte.ts index 17b97bb..09a1377 100644 --- a/web/src/lib/jobs.svelte.ts +++ b/web/src/lib/jobs.svelte.ts @@ -69,3 +69,9 @@ export function duration(seconds: number): string { if (m < 60) return `${m} min ${seconds % 60}s`; return `${Math.floor(m / 60)} h ${m % 60} min`; } + +/** Seconds the job is waiting for a remote rate limit, from its status line. */ +export function rateLimitWait(j: Job): number | null { + const m = /^rate_limited:(\d+)$/.exec(j.message); + return m && j.status === 'running' ? Number(m[1]) : null; +} diff --git a/web/src/lib/locales/en.json b/web/src/lib/locales/en.json index 09aa9e3..ee7e322 100644 --- a/web/src/lib/locales/en.json +++ b/web/src/lib/locales/en.json @@ -134,6 +134,7 @@ "jobs.kind.tolgee.import": "Tolgee import", "jobs.progress": "Progress", "jobs.progressLabel": "{done} of {total}", + "jobs.rateLimited": "Tolgee asked to slow down: waiting {time}, then it continues on its own", "jobs.result.mt": "{n} texts translated", "jobs.result.tolgee": "{keys} keys, {texts} texts imported", "jobs.retried": "Task queued again; it continues where it stopped", diff --git a/web/src/lib/locales/pt-BR.json b/web/src/lib/locales/pt-BR.json index 7f90d1b..ce5428c 100644 --- a/web/src/lib/locales/pt-BR.json +++ b/web/src/lib/locales/pt-BR.json @@ -134,6 +134,7 @@ "jobs.kind.tolgee.import": "Importação do Tolgee", "jobs.progress": "Progresso", "jobs.progressLabel": "{done} de {total}", + "jobs.rateLimited": "O Tolgee pediu para ir mais devagar: aguardando {time} e seguindo sozinho", "jobs.result.mt": "{n} textos traduzidos", "jobs.result.tolgee": "{keys} chaves e {texts} textos importados", "jobs.retried": "Tarefa de volta na fila; ela continua de onde parou", diff --git a/web/src/routes/(app)/jobs/+page.svelte b/web/src/routes/(app)/jobs/+page.svelte index 903294a..7e718b0 100644 --- a/web/src/routes/(app)/jobs/+page.svelte +++ b/web/src/routes/(app)/jobs/+page.svelte @@ -7,7 +7,7 @@ import { ListChecks, RotateCcw, X } from '@lucide/svelte'; import { onMount } from 'svelte'; import { api, type Job } from '#lib/api.ts'; - import { watchJobs, isActive, percent, eta, duration } from '#lib/jobs.svelte.ts'; + import { watchJobs, isActive, percent, eta, duration, rateLimitWait } from '#lib/jobs.svelte.ts'; import { t, num, relativeTime, formatDate } from '#lib/i18n.svelte.ts'; let jobs = $state(null); @@ -78,6 +78,7 @@ {num(j.done)} / {num(j.total)} · {percent(j)}%{#if left !== null} · {t('jobs.eta', { time: duration(left) })}{/if} + {#if rateLimitWait(j) !== null}{t('jobs.rateLimited', { time: duration(rateLimitWait(j)!) })}{/if} {#if j.status === 'failed' && j.message}{j.message}{/if} {#if j.status === 'done' && summary(j)}{summary(j)}{/if} diff --git a/web/src/routes/(app)/p/[slug]/+layout.svelte b/web/src/routes/(app)/p/[slug]/+layout.svelte index 8c9eb20..3595821 100644 --- a/web/src/routes/(app)/p/[slug]/+layout.svelte +++ b/web/src/routes/(app)/p/[slug]/+layout.svelte @@ -1,7 +1,7 @@