fix: retry Smartling API calls on 429/5xx to avoid batch-job failures - #657
Closed
markdaugherty wants to merge 3 commits into
Closed
fix: retry Smartling API calls on 429/5xx to avoid batch-job failures#657markdaugherty wants to merge 3 commits into
markdaugherty wants to merge 3 commits into
Conversation
added 3 commits
August 11, 2026 15:10
The Smartling connector had no retry/backoff anywhere, so any 429 (rate limit) or transient 5xx from Smartling's API failed the call outright. Two spots make this likely during batch jobs: getStatusAll polls /file/progress once per url in a tight sequential loop, and saveItems downloads translated files 5-at-a-time via a concurrency-5 Queue — Smartling enforces both a request-rate limit and a separate concurrent- request limit, so either path can trip a 429. Adds a shared nx/blocks/loc/utils/fetchWithRetry.js (exponential backoff + jitter, honoring Retry-After when present, configurable retry count/ delay/retryable-status predicate) and wires every fetch in the Smartling connector through it. Intended to also replace Lionbridge's inline copy of the same logic (nx/blocks/loc/connectors/lionbridge/index.js, in adobe#656) once that lands, rather than maintain two copies.
Found while verifying the Smartling rate-limit fix end-to-end: getStatusAll
threw "Cannot read properties of undefined (reading 'value')" reading
service.jobUid.value, in the same session right after a successful send
(no reload in between).
setupService built this._service via a spread of
this.project.options.service, taken once when the Translate view first
connects, before any job exists. Smartling's sendAllLanguages later sets
jobUid directly on the original options.service object; since
this._service was a shallow copy, it never saw that mutation. A full
page reload masked this by rebuilding this._service fresh from the
now-persisted project, which is presumably why it went unnoticed.
Now augments and keeps a reference to the same service object instead
of copying it, matching how connectors already mutate it in place
(e.g. Lionbridge's service.jobId = { value: jobId }). Not
Smartling-specific — this affects every connector that stores request
state on the service object rather than per-lang.
markdaugherty
marked this pull request as ready for review
August 12, 2026 18:51
This was referenced Aug 12, 2026
Member
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The Smartling connector had no retry/backoff logic anywhere, so any
429(rate limit) or transient5xxfrom Smartling's API failed the call outright. Two spots make this especially likely during larger batch jobs:getStatusAllpolls/file/progressonce per url in a tight sequential loop, no delay, no retry.saveItemsdownloads translated files 5-at-a-time via a concurrency-5Queue.Per Smartling's own docs (via search — the page itself sits behind a Cloudflare challenge), they enforce two separate limits: a request-rate limit and a concurrent-request limit, scoped per user/project/account depending on endpoint. Recommended handling is exponential backoff with jitter, capped at 30–60s, 5–10 max retries.
Changes
nx/blocks/loc/utils/fetchWithRetry.js: exponential backoff + jitter, honorsRetry-Afterwhen present, configurablemaxRetries/baseDelayMs/maxDelayMs/isRetryable(defaults: 5 retries, 500ms base, 30s cap, retries on 429 and 5xx).fetchcall in the Smartling connector now goes through it.nx/blocks/loc/views/translate/translate.js):setupService()builtthis._servicevia a spread/copy ofthis.project.options.service, taken once when the Translate view connects, before any job exists. Smartling'ssendAllLanguageslater setsjobUiddirectly on the original object; the copy never saw it, sogetStatusAllcrashed (Cannot read properties of undefined (reading 'value')) if you checked status in the same session without a page reload in between. Fixed to augment/reference the same object instead of copying it. Not Smartling-specific — affects any connector storing request state onservicerather than per-lang.Tests
test/loc/utils/fetchWithRetry.test.js: retry-then-succeed on 429 (honoringRetry-After) and on 503 (exponential backoff), gives up aftermaxRetries, ignores non-retryable statuses, customisRetryableoverride.getStatusAllandsaveItemsactually retry a 429 and succeed on the next attempt.npm test(1204 tests) and lint pass.Verification
Full end-to-end through the real DA Translate app UI (
da.live/apps/loc) against a real test site (scdemos/smartling-demo— new GitHub repo + AEM Code Sync + DA content) and a real Smartling project (project-scoped API token, per Smartling's own recommendation for dev/testing): created a project, sent a real page for translation, checked status, and confirmed real machine-translated French content landed at/fr/test-page.html(not just an echo — genuinely translated by Smartling).Also confirmed along the way that Smartling's connector must go through the
https://translate.da.liveproxy (neverapi.smartling.comdirectly from the browser — that hits a CORS block), whichresolveOrigin's legacy-rewrite logic already assumes but isn't otherwise documented anywhere.Marking as draft pending final review pass.