feat: Smartling connector enhancements (rate-limit fix, auto-authorization, session recovery) - #662
Draft
markdaugherty wants to merge 24 commits into
Draft
Conversation
added 4 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.
Smartling's job-batches-api already supports an authorize flag on batch
creation (previously hardcoded to false, requiring manual authorization
in Smartling's own dashboard before translation work starts). Reads a
new translation.service.{env}.autoAuthorize site config value ("yes" to
enable) and passes it through to createBatch, so a job's content is
immediately authorized for translation once its batch finishes
processing.
Site-level config rather than a per-project UI toggle, since the
existing custom-fields mechanism (translation.service.custom.*) only
supports dropdowns/textareas, and this reads more like a fixed
site-wide policy than a per-project choice.
This was referenced Aug 12, 2026
markdaugherty
marked this pull request as ready for review
August 12, 2026 19:03
Found while verifying auto-authorization end-to-end: the app showed a per-language status left over from send (effectively "created") while the job was genuinely in progress on Smartling's side, because getStatusAll only ever set lang.translation.status when a language was fully translated — otherwise it left whatever sendAllLanguages had set untouched. Smartling's file/progress response has no single status enum per locale, only percentComplete plus per-workflow-step word/string counts (Processing, Translation, Published, ...). Rather than hardcode a generic "in progress" string, this now reports the name of whichever step currently holds the content for that locale, falling back to a generic label only if Smartling hasn't reported any step activity yet.
Replaces the job-scoped /file/progress polling (needed jobUid, and my own ad-hoc "active workflow step name" derivation from a previous commit) with Smartling's actual recommended approach, per their "Checking File Translation Status" support article: - Poll getFileTranslationStatusAllLocales (files-api/v2/.../file/status) instead — no jobUid dependency at all, one call per url (same call shape as before, just a different, documented endpoint). - Compute progress using Smartling's own documented formula: floor, never round up to 100% unless truly complete (their own test case: 99.9999% must report 99%), and treat a fully-excluded file as 100% (nothing left to translate). totalStringCount is file-level in their response, not repeated per locale item, so it's read once per file and combined with each item's own completed/excluded counts. - Report the real percentage as the status when incomplete (e.g. "62% translated") instead of a fabricated label — Smartling's model here is fundamentally numeric, not enum-based, so there's no better status word to show. Verified against the real API: computed percentages for a real file's 10 locales matched expectations exactly (2 completed target locales at 100%, 8 non-target locales at 0%).
Falls back to a full re-authentication when a refresh fails (Smartling caps a token pair's session at 12h regardless of refresh count, so long-running jobs eventually hit a refresh that 401s forever). Also tracks the API's actual expiresIn instead of a hardcoded interval, and stops saveItems from reusing a token snapshot taken before a batch of downloads started. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
createJob/createBatch/uploadFiles now parse Smartling's documented error envelope and report a message via sendMessage instead of returning null on a non-ok response - matches Trados's existing pattern. Also fixes translate.js's handleSendAll, which unconditionally ran checkAndSaveLangs right after sending, clobbering any error message before it could render; this affected every connector, not just Smartling. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
getStatusAll unconditionally set status to 'translated' whenever every url was 100% translated on Smartling's side. Since Smartling reports 100% indefinitely once done, this reverted a lang's 'complete' status (set by saveLangItemsToDa after it was actually saved to DA) back to 'translated' on every subsequent status check, causing checkAndSaveLangs to re-download and re-save already-completed languages on every "Get status" click. Fixed by treating 'complete' as terminal, matching the guard GLaaS's determineStatus already has. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every async action button (Connect, Get status, Translate all, Cancel project, Copy all, per-language Cancel) now disables and shows a spinner while its handler is in flight, instead of giving no feedback during the request. Reuses/combines the two spinner patterns already in the repo rather than adding a third: the .nx-loading-spinner/da-spin naming from nx2/styles/buttons.css (defined but never wired to anything) with the currentcolor border technique from ew-actions.js's spinner, needed since these buttons render with different text colors per variant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirmed the correct endpoint against Smartling's official OpenAPI
spec before implementing: DELETE
.../jobs/{translationJobUid}/locales/{targetLocaleId}
(removeLocaleFromJob), not the job-level cancelJob endpoint, which
would cancel every other language sharing the same job since
sendAllLanguages bundles all target languages into one job. Polls the
returned process to completion on a 202 response.
Smartling previously had no cancelTranslation at all, so translate.js's
canCancel getter was always false and the Cancel buttons never
rendered for Smartling projects.
Also extends getStatusAll's existing "don't revert completed
languages" guard to cover 'cancelled', since the next status poll would
otherwise undo a fresh cancel the same way it was re-triggering saves
for completed languages.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
markdaugherty
marked this pull request as draft
August 13, 2026 21:47
Member
Author
|
Converted back to |
Replaces one file-status API call per file (plus our own floor/excluded-string formula) with Smartling's job-scoped getJobProgress: one call for the whole job, consuming Smartling's own precomputed percentComplete per locale directly. Explored as an alternative for the reduced call volume on larger batches. Now requires service.jobUid.value (no-ops if missing), and skips the call entirely once every lang is already complete/cancelled. translation.translated is no longer a per-file count - this endpoint reports one job-wide percentage per locale, not a per-file breakdown - so it's 0 or urls.length rather than a partial count. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
renderCancelLang's guard only excluded 'cancelled' status, never 'complete', so a completed language's Cancel button stayed visible as long as some other language in the project was still in progress. Separately, incompleteLangs counted a language as cancellable even with no translation object at all (never sent), so "Cancel project" could show with nothing actually cancellable. Extracted a single canCancelLang(lang) predicate used consistently by renderCancelLang, incompleteLangs, and the with-cancel grid-column class, so they can't drift out of sync with each other again. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Once every sent language is complete/cancelled, Get status was already a functional no-op (getStatusAll skips its own API call when there's nothing non-terminal left) but still showed, spun, and triggered a redundant DA save on click. Gated the button on incompleteLangs - the same predicate already driving Cancel-button visibility. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.
Fixes #150
Summary
Combines related Smartling connector enhancements (previously #657 and #661, closed in favor of this consolidated PR):
1. Retry on 429/5xx to avoid batch-job failures
The Smartling connector had no retry/backoff logic anywhere, so any
429(rate limit) or transient5xxfailed the call outright. Multiple spots make this especially likely during larger batch jobs (status polling and file downloads loop per url; downloads also run 5-at-a-time concurrently).Per Smartling's own docs, they enforce two separate limits (request-rate and concurrent-request), scoped per user/project/account depending on endpoint. Recommended handling is exponential backoff with jitter, capped at 30–60s, 5–10 max retries.
Adds a shared
nx/blocks/loc/utils/fetchWithRetry.js(exponential backoff + jitter, honorsRetry-After, configurable) and wires every Smartlingfetchcall through it. Also used by Lionbridge's connector (#656), which was updated to share this utility instead of maintaining its own copy.Bug fix found during live verification:
translate.js'ssetupService()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 checking status 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.2. Auto-authorization config option (fixes #150)
Smartling's
job-batches-apialready supports anauthorizeflag on batch creation, previously hardcoded tofalse(requiring manual authorization in Smartling's dashboard before translation starts). Addstranslation.service.{env}.autoAuthorize("yes" to enable) as a site-level config value, read at send time and passed through tocreateBatch.3. Correct, well-founded status reporting
Originally attempted to derive a status from
/file/progress's workflow-step breakdown — but per Smartling's own "Checking File Translation Status" support article, that's not the recommended approach. Switched to their actual recommended endpoint and formula:getFileTranslationStatusAllLocales(files-api/v2/projects/{projectId}/file/status) instead of the job-scoped/file/progress— nojobUiddependency at all.totalStringCountis file-level in their response (not repeated per locale), so it's read once per file and combined with each locale's own completed/excluded counts."62% translated") rather than a fabricated label — Smartling's model here is fundamentally numeric, not enum-based.Superseded by #9 below — after this shipped, explored
getJobProgressas a lower-call-volume alternative and switched to it.4. Session-expiry recovery, stale-token download fix
Per Smartling's Authentication docs, a token pair's session caps at 12 hours regardless of refresh count — eventually the refresh call itself starts failing with 401, even though the original credentials still work. The connector's refresh loop had no recovery path for that, and separately ignored the actual
expiresIn/refreshExpiresInSmartling returns in favor of a hardcoded interval.expiresInand falls back to a full re-authenticate when a refresh fails, so jobs spanning multiple sessions keep working without a manual reconnect.saveItemsbuilt its download request (including theAuthorizationheader) once up front and reused it for an entire batch of downloads, unlike every other function in the file, which reads the current token at each call site — fixed to build it per-download so a background token rotation mid-batch doesn't leave later downloads using a stale token.Surfaced via a documentation-driven review of the connector against Smartling's Authentication/Rate-Limits/Best-Practices docs, rather than a live bug report.
5. Surface connector errors instead of failing silently
Found live: starting a translation produced a 400 (language mismatch) that never showed up in the UI. Two separate bugs:
createJob/createBatch/uploadFilesdidif (!resp.ok) return null;and discarded the response body, so the actual reason (e.g."Invalid locales [fr-FR]") was never read. Now parses Smartling's documented error envelope (response.errors[].message— confirmed universal across every endpoint per their Error Handling docs) and callssendMessage({ type: 'error', text }), matching Trados's existing pattern.uploadFilesalso didn't checkresp.okat all before this — it does now, per file.translate.js'shandleSendAllunconditionally callscheckAndSaveLangsright after the connector call returns, which immediately overwrites the message with"Checking for languages to save"and then clears it toundefinedat the end — before the user ever sees it. This isn't Smartling-specific: Trados has the identicalsendMessage({ type: 'error' })-then-return pattern and hits the same clobbering. Fixed by skippingcheckAndSaveLangswhen the last message set was typeerror, using_messageas the connector-agnostic signal rather than changing every connector's return contract.6. Stop re-saving languages already completed to DA
Found live: languages already fully saved to DA were getting re-saved on every subsequent "Get status" click, instead of only once on the In Progress → Complete transition.
getStatusAllunconditionally setstatus = 'translated'whenever every url was 100% on Smartling's side — but Smartling reports 100% indefinitely once done, so this reverted a lang's'complete'(set bysaveLangItemsToDaafter it actually saved to DA) back to'translated'on every poll, which madecheckAndSaveLangstreat it as newly-finished and re-download/re-save it every time.Fixed by treating
'complete'as terminal — mirrors a guard GLaaS'sdetermineStatusalready has ("Respect existing final statuses"). Trados has the identical bug in its owngetStatusAll; deferred, not fixed in this PR.7. Implement cancelTranslation
Smartling had no
cancelTranslationat all — it only existed as an empty stub on thesampleconnector. Sincetranslate.js'scanCancelgetter checks!!connector.cancelTranslation, the Cancel buttons never rendered for Smartling projects at all.Confirmed the correct endpoint against Smartling's official OpenAPI spec (
github.com/Smartling/api-docs) before implementing, rather than guessing:DELETE /jobs-api/v3/projects/{projectId}/jobs/{translationJobUid}/locales/{targetLocaleId}(removeLocaleFromJob) — not the job-levelcancelJobendpoint.cancelJob's request body has no locale scoping at all and cancels every locale in the job; sincesendAllLanguagesbundles every target language into one shared job, usingcancelJobfor a single-language cancel would have also killed every other in-progress language sharing that job.removeLocaleFromJobis scoped to exactly one locale.202(async removal), pollsGET .../processes/{processUid}(getJobAsyncProcessStatus) every 2s (~60s cap) untilprocessStateisCOMPLETEDorFAILED, rather than optimistically reporting success.lang.translation.status = 'cancelled'locally (Smartling's file/status endpoint has no "cancelled" concept, so nothing meaningful to poll for status-wise afterward).FAILED/timing out), reuses theextractErrorMessageenvelope parser from Update dependency @web/dev-server-import-maps to v0.2.1 - autoclosed #5.'complete'-is-terminal guard ingetStatusAllto also cover'cancelled', since the next status poll would otherwise undo a fresh cancel the same way it was re-triggering saves for completed languages.8. Loading spinners on action buttons
Every async action button in the Translate view (Connect, Get status, Translate all, Cancel project, Copy all, per-language Cancel) now disables and shows a spinner while its handler is in flight — previously only
handleSendAllhad a busy flag, and it wasn't even wired to any visual feedback. Reused/combined the two spinner patterns already in the repo instead of adding a third: the.nx-loading-spinner/da-spinnaming fromnx2/styles/buttons.css(defined there but never actually wired to anything) with thecurrentcolorborder technique from the one spinner that's actually shipping (nx2/blocks/ew-actions/ew-actions.js) — needed since these buttons render with different text colors per variant (white on.accent, gray on.primary.outline). Per-language Cancel uses a_cancelingLangsSet so one row can be busy independently of the others.9. Switch getStatusAll to getJobProgress
Replaced the file-scoped
getFileTranslationStatusAllLocalesapproach from #3 (one API call per file, plus our own floor/excluded-string formula) with Smartling's job-scopedgetJobProgress(GET jobs-api/v3/projects/{projectId}/jobs/{translationJobUid}/progress): one API call for the whole job, consuming Smartling's own precomputedpercentCompleteper locale directly instead of reimplementing their formula ourselves. Explored specifically for the reduced call volume on larger batches — a 20-file job goes from 20 status calls to 1.Trade-offs, both intentional:
service.jobUid.value(already tracked for the job's whole lifecycle) — guarded, no-ops if missing.translation.translatedis no longer a per-file count, since this endpoint reports one job-wide percentage per locale, not a per-file breakdown. It's now0orurls.length— the "N of M files translated" UI column only ever shows0or the full count until 100%, never a partial number.Also added a genuine efficiency win beyond parity: skips the API call entirely once every lang is already
'complete'/'cancelled', rather than fetching and discarding the result.Rewrote all 6 affected tests for the new endpoint/response shape, plus 2 new ones (no-
jobUidskip,'cancelled'-guard — the latter was a gap even in the previous suite; only the'complete'case had a dedicated test).10. Hide Cancel buttons once nothing's left to cancel
renderCancelLang's guard only excluded'cancelled'status, never'complete', so a completed language's per-row Cancel button stayed visible as long as some other language in the project was still in progress. Separately,incompleteLangscounted a language as cancellable even with notranslationobject at all (never sent), so "Cancel project" could show with nothing actually cancellable.Extracted a single
canCancelLang(lang)predicate (hastranslation, not'cancelled', not'complete') used consistently byrenderCancelLang,incompleteLangs, and thewith-cancelgrid-column class, so they can't drift out of sync with each other again.11. Hide Get status once nothing's incomplete
Once every sent language is
'complete'/'cancelled', "Get status" was already a functional no-op (getStatusAllskips its own API call when there's nothing non-terminal left, per #9) but still showed, spun, and triggered a redundant DA save on click. Gated the button onthis.incompleteLangs— the same predicate#10already uses to drive Cancel-button visibility — so it hides once there's nothing left to check.Tests
test/loc/utils/fetchWithRetry.test.js: retry-then-succeed on 429/503, gives up aftermaxRetries, ignores non-retryable statuses, custom predicate.getStatusAll/saveItemsretry a 429 and succeed.'yes', explicitly'no'.VALIDATION_ERROR/Invalid locales [fr-FR]response shape hit live: job-creation failure, batch-creation failure, per-file upload failure. Thetranslate.jsmessage-clobbering half of Update dependency @web/dev-server-import-maps to v0.2.1 - autoclosed #5 has no automated coverage —translate.jshas no existing test harness to extend — verified manually instead.'complete'stays'complete'after another status poll, even though Smartling still reports 100%.COMPLETED, async 202→FAILED, request-level failure. All resolve on the first poll so none incur real wait time.translate.jsto extend — verified manually.'complete', doesn't revert'cancelled'(new), retries a 429.translate.jstest-harness gap as chore(deps): update babel monorepo - autoclosed #8 — verified by code review of every call site of the new shared predicate.npm test(1223 tests) and lint pass.Verification
Full end-to-end through the real DA Translate app UI against a real test site (
scdemos/smartling-demo) and a real Smartling project (project-scoped API token), including a 3-page, 2-language (French + German) batch:/fr/...and/de/...paths.firstAuthorizedDatepopulated 16 seconds after job creation, with zero manual intervention.Ready for review.