Skip to content

fix(gt-i18n): keep translations loaded after the cache expires - #36

Open
Kevin-Liu-01 wants to merge 1 commit into
mainfrom
k/gt-i18n-cache-expiry
Open

Kevin-Liu-01 wants to merge 1 commit into
mainfrom
k/gt-i18n-cache-expiry

Conversation

@Kevin-Liu-01

@Kevin-Liu-01 Kevin-Liu-01 commented Oct 12, 2026 •

Copy link
Copy Markdown

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 through get_translations_sync(), which returned {} once an entry was older than cache_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:

  • After an entry expires, the cache keeps serving the last loaded translations while one reload runs in the background. A reload that fails keeps the previous translations. Only a locale's first load makes its callers wait.
  • get_translations_sync() returns the last loaded translations even after they expire.
  • I18nManager gains get_translations_blocking() and load_all_translations_blocking() for synchronous code such as Flask, and a branch_id argument.
  • The CDN loader defaults to https://cdn.gtx.dev. Before, a manager without cache_url gave it an empty base URL.
  • The CDN loader appends /{version_id} and ?branchId={branch_id}, the same URL as the JS SDK's createRemoteTranslationLoader, and raises on an HTTP error or an empty response.
  • I18nManager(cache_url="") turns CDN loading off, and load_gt_config() maps _branchId to branch_id.
  • gt-i18n requires generaltranslation>=0.1.0 and generaltranslation-supported-locales>=0.0.6.

Size: 8 files, +527/-48. Product code is +182/-46, tests are +338, pyproject.toml is +2/-2 and the changeset is +5.

Decisions

  • Locks are per locale, and the background reload runs on a daemon thread. A reload scheduled as a task on the caller's event loop died when a one-off asyncio.run() ended, so a thread outlives it.
  • First-load waiters are shielded, so cancelling one request does not cancel the load for the others.
  • Loads are deduplicated only on the same event loop. Threads that each run their own asyncio.run() each load.
  • A synchronous load_translations callback runs on a worker thread, off the event loop.
  • For the CDN, an empty {} counts as a failed load, because the edge answers backend errors with HTTP 200 and {} (apps/edge/src/index.ts in gt-cloud).
  • A failed attempt counts as a load, so the next retry waits for the next expiry.
  • The 60 s default stays, and initialize_gt still does not expose cache_expiry_time.
  • With this PR alone, translations loaded at startup no longer revert after 60 s. Flask and FastAPI start loading each configured locale per request, which also triggers the background reload, only with the next PR.

Test plan

Run on this branch alone (k/gt-i18n-cache-expiry at 0332c94, on origin/main c3fe22b, still the latest main on 2026-10-11), with Python 3.12.13:

  • uv lock --check and uv sync --all-packages --locked: the lockfile does not change
  • ruff 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 in test_translations_manager.py and 7 in the new test_remote_loader.py.
  • With the four gt-i18n source files reverted to origin/main, 19 of the 21 new tests fail. The two that pass on both check that concurrent first loads share one load and that the loader defaults to the CDN when called directly.

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

  1. fix(release): publish every package version missing from PyPI (fix(release): publish every package version missing from PyPI #35 (k/publish-missing-versions)), which must merge before the release PR.
  2. This PR, then fix(gt-flask,gt-fastapi): load translations for every configured locale (fix(gt-flask,gt-fastapi): load translations for every configured locale #37 (k/flask-fastapi-configured-locales)), which is stacked on it.
  3. sampo's release PR, chore(release): bump versions and changelogs. It bumps gt-i18n, gt-flask and gt-fastapi to 0.3.1 and publishes them with the catch-up versions generaltranslation 0.1.0, generaltranslation-intl-messageformat 0.0.5 and generaltranslation-supported-locales 0.0.6.
  4. The docs PR (generaltranslation/content, docs(python): match the quickstart to the fixed SDK content#622 (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

RetriggerConfidence Score: 3/5

Fix the async-loader loop change and competing reload paths before merging.

Fix All in Claude CodeFindings

  1. P1 Async loaders stop refreshing ▶
  2. P1 Reloads overwrite newer translations ▶
Fix with agent prompt
### Issue 1
packages/gt-i18n/src/gt_i18n/i18n_manager/_translations_manager.py:115-116
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.

### Issue 2
packages/gt-i18n/src/gt_i18n/i18n_manager/_translations_manager.py:124
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.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Keeps loaded translations available after cache expiry and reload failures.

  • Expired translations stay available while the cache reloads.
  • Synchronous callers can load translations without awaiting.
  • CDN requests use the default host and selected translation version.

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 --> Return
Loading

Reviews (1) · Last reviewed commit: "fix(gt-i18n): keep translations loaded a..." · Reviewed by Greptile

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>
Comment on lines +115 to +116
# The reload runs on a thread, so it outlives this call's event loop.
self._refresh_in_background(locale)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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

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.

Fix in Claude Code Fix in Codex

"""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]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Fix in Claude Code Fix in Codex

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant