Repository navigation
fix(gt-i18n): keep translations loaded after the cache expires - #36
Kevin-Liu-01 wants to merge 1 commit into
Conversation
t() reads the cache through get_translations_sync, which returned {} once
an entry was older than 60 s, and nothing reloaded it. Every locale went
back to the source text a minute after startup.
The cache now serves the last loaded translations after expiry while one
reload runs on a daemon thread, and keeps them when that reload fails.
Only a locale's first load makes callers wait. I18nManager gains
get_translations_blocking and load_all_translations_blocking for
synchronous code.
The CDN loader defaults to https://cdn.gtx.dev, appends version_id and
branch_id like the JS SDK, and treats a failed or empty response as a
failed load, because the CDN answers backend errors with 200 {}.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| # The reload runs on a thread, so it outlives this call's event loop. | ||
| self._refresh_in_background(locale) |
There was a problem hiding this comment.
Expired reads run custom async loaders on a new event loop in a daemon thread. If a loader reuses an async client or another object tied to the application's loop, its first load can succeed while every refresh fails. _load_blocking hides the error, and _store keeps renewing the old map, so translations never update.
Keep async loaders on their owning loop, with a separate path for short-lived loops.
Knowledge Base Used: Translation manager lifecycle
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/gt-i18n/src/gt_i18n/i18n_manager/_translations_manager.py
Line: 115-116
Comment:
**Async loaders stop refreshing**
Expired reads run custom async loaders on a new event loop in a daemon thread. If a loader reuses an async client or another object tied to the application's loop, its first load can succeed while every refresh fails. `_load_blocking` hides the error, and `_store` keeps renewing the old map, so translations never update.
Keep async loaders on their owning loop, with a separate path for short-lived loops.
**Knowledge Base Used:** [Translation manager lifecycle](https://app.greptile.com/generaltranslation/-/custom-context/knowledge-base/generaltranslation/gt-python/-/docs/translation-manager-lifecycle.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| """Eagerly fetch translations for all *locales*.""" | ||
| await asyncio.gather(*(self.get_translations(loc) for loc in locales)) | ||
| """Eagerly fetch translations for all *locales*, waiting for every load.""" | ||
| tasks = [self._start_load(loc) for loc in locales if self._fresh(loc) is None] |
There was a problem hiding this comment.
Reloads overwrite newer translations
If load_all_translations() runs while a request reads an expired locale, _start_load starts one load and _refresh_in_background starts another. They use separate controls for the same locale. Both write the cache without checking which result is newer, so a slower response can overwrite translations already returned by the other load.
Make both paths share the same in-flight load.
Knowledge Base Used: Translation manager lifecycle
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/gt-i18n/src/gt_i18n/i18n_manager/_translations_manager.py
Line: 124
Comment:
**Reloads overwrite newer translations**
If `load_all_translations()` runs while a request reads an expired locale, `_start_load` starts one load and `_refresh_in_background` starts another. They use separate controls for the same locale. Both write the cache without checking which result is newer, so a slower response can overwrite translations already returned by the other load.
Make both paths share the same in-flight load.
**Knowledge Base Used:** [Translation manager lifecycle](https://app.greptile.com/generaltranslation/-/custom-context/knowledge-base/generaltranslation/gt-python/-/docs/translation-manager-lifecycle.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Summary
In the published SDK (gt-i18n 0.1.0 to 0.3.0), translations go back to the source language 60 seconds after startup.
t()reads the cache throughget_translations_sync(), which returned{}once an entry was older thancache_expiry_time, and nothing reloaded it. A live check on the published 0.1.0 answered "¡Hola, mundo!" at startup and "Hello, world!" 65 s later.This PR fixes gt-i18n:
get_translations_sync()returns the last loaded translations even after they expire.I18nManagergainsget_translations_blocking()andload_all_translations_blocking()for synchronous code such as Flask, and abranch_idargument.cache_urlgave it an empty base URL./{version_id}and?branchId={branch_id}, the same URL as the JS SDK'screateRemoteTranslationLoader, and raises on an HTTP error or an empty response.I18nManager(cache_url="")turns CDN loading off, andload_gt_config()maps_branchIdtobranch_id.generaltranslation>=0.1.0andgeneraltranslation-supported-locales>=0.0.6.Size: 8 files, +527/-48. Product code is +182/-46, tests are +338,
pyproject.tomlis +2/-2 and the changeset is +5.Decisions
asyncio.run()ended, so a thread outlives it.asyncio.run()each load.load_translationscallback runs on a worker thread, off the event loop.{}counts as a failed load, because the edge answers backend errors with HTTP 200 and{}(apps/edge/src/index.tsin gt-cloud).initialize_gtstill does not exposecache_expiry_time.Test plan
Run on this branch alone (
k/gt-i18n-cache-expiryat 0332c94, on origin/main c3fe22b, still the latest main on 2026-10-11), with Python 3.12.13:uv lock --checkanduv sync --all-packages --locked: the lockfile does not changeruff check .: clean.ruff format --check .: 177 files already formatted.mypy .: no issues in 164 source files.pytest: 1,420 passed. That includes 21 new tests: 14 intest_translations_manager.pyand 7 in the newtest_remote_loader.py.Earlier, on the combined SDK fix (this PR and the next): the SDK tests also passed on Python 3.10 and 3.14, two requests 65 s apart on real wall-clock time were both answered in Spanish, and two review passes ran with every finding fixed.
Merge order
k/publish-missing-versions)), which must merge before the release PR.k/flask-fastapi-configured-locales)), which is stacked on it.k/python-quickstart-loads-translations)), after 0.3.1 is on PyPI.Ernest McCarter wrote most of gt-python, so he should review this PR.
🤖 Generated with Claude Code
Fix the async-loader loop change and competing reload paths before merging.
Fix with agent prompt
Summary
Keeps loaded translations available after cache expiry and reload failures.
PR #37 supplies framework request loading, branch forwarding, and empty URL overrides. The assessment of those integration gaps depends on merging that PR; this PR does not contain those fixes.
Kevin-Liu-01 explicitly chose worker threads for synchronous callbacks and separate first loads across event loops. Kevin-Liu-01 deferred framework request-triggered refreshes to PR #37, which adds those calls.
Diagram
%%{init: {'theme': 'neutral'}}%% flowchart TD Read[Read translations] --> Cached{Cache entry exists?} Cached -->|No| First[Wait for first load] Cached -->|Yes| Fresh{Entry still fresh?} Fresh -->|Yes| Return[Return cached translations] Fresh -->|No| Background[Start background reload] Background --> Return Background --> Result{Load succeeds?} Result -->|Yes| Replace[Replace cached translations] Result -->|No| Keep[Keep previous translations] First --> ReturnReviews (1) · Last reviewed commit: "fix(gt-i18n): keep translations loaded a..." · Reviewed by Greptile