Skip to content

Introduce fetch translations timeout setting - #126

Merged
deathbird merged 5 commits into
develfrom
fetch_translation_timout
Nov 13, 2026
Merged

deathbird merged 5 commits into
develfrom
fetch_translation_timout

Conversation

@foteinigk

@foteinigk foteinigk commented Dec 10, 2025 •

Copy link
Copy Markdown
Contributor

Introduce a new configuration setting: FETCH_TRANSLATION_TIMEOUT, which allows callers to define a maximum time (in seconds) to wait when fetching translations or locales from CDS.

Previously, if CDS continued returning 202 Accepted for an extended period (e.g., when a job is stuck in a stale state), the system would keep retrying indefinitely. This new setting provides an additional safety mechanism—similar to the behavior offered by the Transifex JavaScript SDK—by enforcing an upper bound on how long we wait for CDS to produce a final result.

The default value is 0, meaning no global timeout, preserving existing behavior unless explicitly configured.


Update: per-request timeouts

The fetch requests themselves had no timeout, so an unreachable CDS still blocked for the OS TCP timeout (2+ min). That stalled txc gunicorn workers at startup and failed a staging deploy.

  • New TRANSIFEX_REQUEST_TIMEOUT / request_timeout, default (10, 60) (connect, read), applied to each fetch request.
  • Request timeouts are logged as Timeout, and that language is marked not fetched.
  • TRANSIFEX_FETCH_TIMEOUT is now one deadline for the whole fetch (languages + all translations). Languages not reached in time are skipped until the next sync.
  • Fixed the TEMEOUT typo in the README.

Note: unlike TRANSIFEX_FETCH_TIMEOUT, the request timeout is on by default. That's a small behaviour change for SDK users: a request that used to wait indefinitely now fails after 10 s connecting or 60 s without data.

@foteinigk
foteinigk force-pushed the fetch_translation_timout branch from 25bcdea to 0f0b0f9 Compare December 10, 2025 07:08
@deathbird
deathbird force-pushed the fetch_translation_timout branch from 3ccab02 to 024920d Compare November 12, 2026 21:21
Comment thread transifex/native/cds.py
return response
status = response.status_code

if status == 202:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Non-blocking: with the default fetch_timeout=0, startup still waits forever if CDS keeps answering 202. There's no deadline, so this loop polls every RETRY_DELAY_SEC without end. NativeConfig.ready() → tx.fetch_translations() never returns, and a later sync gets stuck the same way. request_timeout only limits each single request. This isn't a regression: the old loop did the same, without even the sleep, and the JS SDK also defaults fetchTimeout to 0. But it's the failure this setting was added for, and only callers who set it are protected.

Two options:

  1. Keep 0 and say so in the README (see the README comment). No behaviour change.
  2. Default it in Django only, the path that blocks startup:
TRANSIFEX_FETCH_TIMEOUT = getattr(settings,
                                  'TRANSIFEX_FETCH_TIMEOUT',
                                  120)

I'd go with 1 and set it in txc when we bump the SDK. What do you think?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Agreed, option 1: keeping 0 and documenting it (your README text, in 7b7db03). txc sets TRANSIFEX_FETCH_TIMEOUT explicitly.

Comment thread transifex/native/cds.py Outdated
# fetched if the fetch timeout cuts the loop short; then any other
# remote languages when fetching all of them
remote = set(languages)
ordered = [code for code in self.configured_language_codes

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Regression: fetch_all_langs=True with languages=None now raises. This line always iterates self.configured_language_codes; before, the fetch_all_langs path never read it. tx.init(None, token, fetch_all_langs=True) followed by tx.fetch_translations() raises TypeError: 'NoneType' object is not iterable. That's outside the try/except, so the call fails and the sync daemon logs an exception every cycle. Django always passes a list, so this hits direct SDK users.

Suggested change
ordered = [code for code in self.configured_language_codes
ordered = [code for code in (self.configured_language_codes or [])

Test:

    @patch('transifex.native.cds.requests.get')
    def test_fetch_all_langs_without_configured_languages(self, mock_get):
        mock_get.side_effect = [
            self._json_response(200, {'data': [{'code': 'el'}]}),
            self._json_response(200, {'data': {}}),
        ]
        cds_handler = CDSHandler(None, 'some_token',
                                 host='https://some.host',
                                 fetch_all_langs=True)

        assert cds_handler.fetch_translations() == {'el': (True, {})}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good catch, applied with your test in 7b7db03. This was a regression from the language-order change, thanks.

Comment thread transifex/native/cds.py Outdated
logger.error('Invalid fetch_timeout `{}`, fetch timeout '
'disabled'.format(fetch_timeout))
self.fetch_timeout = 0
self.request_timeout = request_timeout

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

An invalid request_timeout makes every fetch fail, and the log blames the response. Unlike fetch_timeout, the value is stored as-is and only checked by requests on each call. Most bad values (0, True, '30', [10, 60], a 3-tuple) become a urllib3 ValueError, which lands in except (KeyError, ValueError) and is logged as Malformed response. With fetch_timeout set, a list or string fails earlier, in min() inside _bounded_timeout, and is logged as UnknownError. Either way the languages request fails, nothing is fetched, and the app serves source strings. 0 is the easy one to hit, because TRANSIFEX_FETCH_TIMEOUT = 0 means "no limit".

Suggestion: check the value once here, with the same check requests runs. Accept a list (from JSON/YAML config). On failure, log an error and use the default. None stays "no limit", which test_none_request_timeout_parts_are_capped_by_deadline expects.

Suggested change
self.request_timeout = request_timeout
self.request_timeout = self._parse_request_timeout(request_timeout)
from urllib3.util import Timeout as Urllib3Timeout  # urllib3 ships with requests

    @staticmethod
    def _parse_request_timeout(value):
        """Return `value` as a `requests` timeout (a number or a
        (connect, read) tuple, None for no limit), or DEFAULT_REQUEST_TIMEOUT
        if it is invalid, so a bad setting is reported once here instead of
        failing every request."""
        if isinstance(value, list):  # e.g. read from JSON or YAML
            value = tuple(value)
        try:
            connect, read = value if isinstance(value, tuple) else (value,) * 2
            # The check `requests` runs on every request, done once here
            Urllib3Timeout(connect=connect, read=read)
        except (TypeError, ValueError):
            logger.error('Invalid request_timeout `{}`, using the default '
                         '`{}`'.format(value, DEFAULT_REQUEST_TIMEOUT))
            return DEFAULT_REQUEST_TIMEOUT
        return value

Test (import DEFAULT_REQUEST_TIMEOUT from transifex.native.cds):

    @patch('transifex.native.cds.logger')
    def test_invalid_request_timeout_uses_default(self, patched_logger):
        for value, expected in (
            (None, None),
            (5, 5),
            ((3, None), (3, None)),
            ([3, 30], (3, 30)),
            (0, DEFAULT_REQUEST_TIMEOUT),
            ('30', DEFAULT_REQUEST_TIMEOUT),
            (True, DEFAULT_REQUEST_TIMEOUT),
            ((1, 2, 3), DEFAULT_REQUEST_TIMEOUT),
        ):
            cds_handler = CDSHandler(['el'], 'some_token',
                                     request_timeout=value)
            assert cds_handler.request_timeout == expected
        assert patched_logger.error.call_count == 4

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good catch, applied with your test in 7b7db03.

Comment thread transifex/native/cds.py Outdated

while True:
kwargs['timeout'] = self._bounded_timeout(timeout, deadline)
response = requests.get(*args, **kwargs)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Minor: a read timeout while the body downloads is logged as ConnectionError. With the default stream=False, requests.get() reads the body itself, and Response.content re-raises urllib3's ReadTimeoutError as requests.ConnectionError. So a slow CDS is logged like an unreachable one, although the PR description says request timeouts are logged as Timeout. Converting it here fixes fetch_languages and fetch_translations in one place (plus from urllib3.exceptions import ReadTimeoutError):

Suggested change
response = requests.get(*args, **kwargs)
try:
response = requests.get(*args, **kwargs)
except requests.ConnectionError as e:
# requests reports a read timeout while downloading the body
# as ConnectionError; raise it as the Timeout it is
if e.args and isinstance(e.args[0], ReadTimeoutError):
raise requests.ReadTimeout(*e.args)
raise

Test:

    @patch('transifex.native.cds.logger')
    @patch('transifex.native.cds.requests.get')
    def test_read_timeout_in_body_is_logged_as_timeout(
        self, mock_get, patched_logger
    ):
        mock_get.side_effect = requests.ConnectionError(
            ReadTimeoutError(None, None, 'Read timed out.'))
        cds_handler = CDSHandler(['el'], 'some_token',
                                 host='https://some.host')

        assert cds_handler.fetch_translations('el') == {'el': (False, {})}
        patched_logger.error.assert_called_once_with(
            'Error retrieving translations from CDS: Timeout')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good catch, applied with your test in 7b7db03.

Comment thread README.md Outdated
Comment on lines +77 to +87
Approximate maximum time in seconds for one fetch of languages and
translations from CDS, including retries. It can be exceeded somewhat, e.g. by
DNS resolution or a host with several IP addresses. Languages are fetched in
LANGUAGES order; those not reached in time are skipped until the next sync,
or for the life of the process if TRANSIFEX_SYNC_INTERVAL is 0.
0 (default) = no global timeout.

- TRANSIFEX_REQUEST_TIMEOUT:
Timeout in seconds for each request made when fetching from CDS, either a
number or a `(connect, read)` tuple as accepted by `requests`.
Defaults to `(10, 60)`.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Docs: say that a slow response can exceed the fetch timeout, what 0 means at startup, and that the request timeout must be greater than 0. requests applies the read timeout to each socket read, not to the whole response. With 25 s left the cap is (10, 25), but a response that sends a byte every 20 s never trips it. That's unlikely with CDS, so documenting it is enough; streaming the body to enforce the deadline isn't worth the code.

Suggested change
Approximate maximum time in seconds for one fetch of languages and
translations from CDS, including retries. It can be exceeded somewhat, e.g. by
DNS resolution or a host with several IP addresses. Languages are fetched in
LANGUAGES order; those not reached in time are skipped until the next sync,
or for the life of the process if TRANSIFEX_SYNC_INTERVAL is 0.
0 (default) = no global timeout.
- TRANSIFEX_REQUEST_TIMEOUT:
Timeout in seconds for each request made when fetching from CDS, either a
number or a `(connect, read)` tuple as accepted by `requests`.
Defaults to `(10, 60)`.
Approximate maximum time in seconds for one fetch of languages and
translations from CDS, including retries. It can be exceeded, e.g. by DNS
resolution, a host with several IP addresses, or a response that keeps
sending data slowly (the read timeout applies between received bytes, not to
the whole response). Languages are fetched in LANGUAGES order; those not
reached in time are skipped until the next sync, or for the life of the
process if TRANSIFEX_SYNC_INTERVAL is 0.
0 (default) = no global timeout: if CDS keeps answering 202, the fetch, and
so startup, waits until it stops. Set it when translations are fetched at
startup, e.g. under gunicorn.
- TRANSIFEX_REQUEST_TIMEOUT:
Timeout in seconds for each request made when fetching from CDS, either a
number greater than 0 or a `(connect, read)` tuple as accepted by `requests`.
Invalid values are logged and the default is used. Defaults to `(10, 60)`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good catch, applied with your test in 7b7db03.

@foteinigk foteinigk left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

changes LGTM !! 🚀

foteinigk and others added 5 commits November 13, 2026 13:30
The fetch requests passed no timeout to requests, so an unreachable CDS
blocked the caller until the OS gave up on the TCP connection (2+ min).
At app startup this kept gunicorn workers from serving and failed their
health checks.

- Add request_timeout (TRANSIFEX_REQUEST_TIMEOUT), default (10, 60)
  seconds, to every fetch request.
- Log request timeouts as Timeout and mark the language unfetched.
- Make fetch_timeout one deadline for the whole fetch: languages not
  reached in time are skipped instead of each getting a full budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHUSdpfx9GTG3gM1CaYHhR
Review fixes for the fetch timeouts:

- A 202 still pending at the deadline raises requests.Timeout instead
  of being returned and parsed as translations, which reset the ETag
  and could store an empty translation set.
- Return statuses that are neither 202 nor a retryable 5xx, such as
  >= 600, which spun the loop without sleeping.
- Cap each request's timeout to the time left and stop before a retry
  sleep would pass the deadline, so fetch_timeout is a real bound.
- Measure the deadline with time.monotonic().
- Treat a non-numeric fetch_timeout as 0 and log it, instead of
  crashing app startup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHUSdpfx9GTG3gM1CaYHhR
Second review round on the fetch timeouts:

- A None part in request_timeout, valid for requests, crashed every
  fetch once fetch_timeout was set; it now means only the deadline
  applies.
- Fetch configured languages in LANGUAGES order rather than set order,
  so the same languages are fetched when the deadline cuts in.
- Test request_timeout and deadline against None, not truthiness, so
  0 is no longer replaced by the default.
- Test the 5xx retry sequence, a 202 then 200 under a deadline, and the
  timeout wiring through init() and the Django app config.
- README: the fetch timeout is approximate, and skipped languages wait
  for the next sync or, with TRANSIFEX_SYNC_INTERVAL 0, are not fetched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHUSdpfx9GTG3gM1CaYHhR
- Fetch with fetch_all_langs and no configured languages again; the
  language-order change made it raise TypeError.
- Check request_timeout once at construction with the same check
  requests runs, accepting lists from JSON/YAML config. Invalid values
  are logged and replaced by the default, instead of failing every
  fetch as "Malformed response".
- Raise a read timeout while downloading the body as requests.Timeout;
  requests reports it as ConnectionError.
- README: when the fetch timeout can be exceeded, what 0 means at
  startup, and that the request timeout must be greater than 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHUSdpfx9GTG3gM1CaYHhR
@foteinigk
foteinigk force-pushed the fetch_translation_timout branch from 7b7db03 to c8359f3 Compare November 13, 2026 11:30
@deathbird
deathbird merged commit 3280f79 into devel Nov 13, 2026
9 checks passed
@deathbird
deathbird deleted the fetch_translation_timout branch November 13, 2026 11:32
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.

2 participants