Skip to content

feat: Smartling connector enhancements (rate-limit fix, auto-authorization, session recovery) - #662

Draft
markdaugherty wants to merge 24 commits into
adobe:mainfrom
markdaugherty:smartling-connector-enhancements
Draft

feat: Smartling connector enhancements (rate-limit fix, auto-authorization, session recovery)#662
markdaugherty wants to merge 24 commits into
adobe:mainfrom
markdaugherty:smartling-connector-enhancements

Conversation

@markdaugherty

@markdaugherty markdaugherty commented Aug 12, 2026

Copy link
Copy Markdown
Member

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 transient 5xx failed 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, honors Retry-After, configurable) and wires every Smartling fetch call 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's setupService() built this._service via a spread/copy of this.project.options.service, taken once when the Translate view connects, before any job exists. Smartling's sendAllLanguages later sets jobUid directly on the original object; the copy never saw it, so getStatusAll crashed 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 on service rather than per-lang.

2. Auto-authorization config option (fixes #150)

Smartling's job-batches-api already supports an authorize flag on batch creation, previously hardcoded to false (requiring manual authorization in Smartling's dashboard before translation starts). Adds translation.service.{env}.autoAuthorize ("yes" to enable) as a site-level config value, read at send time and passed through to createBatch.

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 — no jobUid dependency at all.
  • Smartling's own documented progress formula: floor, never round up to 100% unless truly complete (their own test case: 99.9999% must report 99%), and a fully-excluded file (nothing left to translate) is 100%. totalStringCount is 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.
  • Reports the real percentage as status when incomplete (e.g. "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 getJobProgress as 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/refreshExpiresIn Smartling returns in favor of a hardcoded interval.

  • Replaced the fixed-interval refresh polling with a self-rescheduling loop that tracks Smartling's real expiresIn and falls back to a full re-authenticate when a refresh fails, so jobs spanning multiple sessions keep working without a manual reconnect.
  • saveItems built its download request (including the Authorization header) 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/uploadFiles did if (!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 calls sendMessage({ type: 'error', text }), matching Trados's existing pattern. uploadFiles also didn't check resp.ok at all before this — it does now, per file.
  • Even with that fix, the error still didn't render: translate.js's handleSendAll unconditionally calls checkAndSaveLangs right after the connector call returns, which immediately overwrites the message with "Checking for languages to save" and then clears it to undefined at the end — before the user ever sees it. This isn't Smartling-specific: Trados has the identical sendMessage({ type: 'error' })-then-return pattern and hits the same clobbering. Fixed by skipping checkAndSaveLangs when the last message set was type error, using _message as 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. getStatusAll unconditionally set status = '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 by saveLangItemsToDa after it actually saved to DA) back to 'translated' on every poll, which made checkAndSaveLangs treat it as newly-finished and re-download/re-save it every time.

Fixed by treating 'complete' as terminal — mirrors a guard GLaaS's determineStatus already has ("Respect existing final statuses"). Trados has the identical bug in its own getStatusAll; deferred, not fixed in this PR.

7. Implement cancelTranslation

Smartling had no cancelTranslation at all — it only existed as an empty stub on the sample connector. Since translate.js's canCancel getter 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-level cancelJob endpoint. cancelJob's request body has no locale scoping at all and cancels every locale in the job; since sendAllLanguages bundles every target language into one shared job, using cancelJob for a single-language cancel would have also killed every other in-progress language sharing that job. removeLocaleFromJob is scoped to exactly one locale.

  • On a 202 (async removal), polls GET .../processes/{processUid} (getJobAsyncProcessStatus) every 2s (~60s cap) until processState is COMPLETED or FAILED, rather than optimistically reporting success.
  • On success, sets lang.translation.status = 'cancelled' locally (Smartling's file/status endpoint has no "cancelled" concept, so nothing meaningful to poll for status-wise afterward).
  • On failure (request-level or an async process reporting FAILED/timing out), reuses the extractErrorMessage envelope parser from Update dependency @web/dev-server-import-maps to v0.2.1 - autoclosed #5.
  • Extended chore(deps): update dependency eslint to v8.57.1 - autoclosed #6's 'complete'-is-terminal guard in getStatusAll to 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 handleSendAll had 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-spin naming from nx2/styles/buttons.css (defined there but never actually wired to anything) with the currentcolor border 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 _cancelingLangs Set so one row can be busy independently of the others.

9. Switch getStatusAll to getJobProgress

Replaced the file-scoped getFileTranslationStatusAllLocales approach from #3 (one API call per file, plus our own floor/excluded-string formula) with Smartling's job-scoped getJobProgress (GET jobs-api/v3/projects/{projectId}/jobs/{translationJobUid}/progress): one API call for the whole job, consuming Smartling's own precomputed percentComplete per 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:

  • Now requires service.jobUid.value (already tracked for the job's whole lifecycle) — guarded, no-ops if missing.
  • translation.translated is no longer a per-file count, since this endpoint reports one job-wide percentage per locale, not a per-file breakdown. It's now 0 or urls.length — the "N of M files translated" UI column only ever shows 0 or 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-jobUid skip, '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, 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 (has translation, not 'cancelled', not 'complete') used consistently by renderCancelLang, incompleteLangs, and the with-cancel grid-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 (getStatusAll skips 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 on this.incompleteLangs — the same predicate #10 already uses to drive Cancel-button visibility — so it hides once there's nothing left to check.

Tests

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:

  • Confirmed real machine-translated content landed at /fr/... and /de/... paths.
  • Confirmed via the Smartling API directly that auto-authorization worked: firstAuthorizedDate populated 16 seconds after job creation, with zero manual intervention.
  • Verified the final status-reporting logic directly 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%).
  • Caught and fixed a real bug before shipping (totalStringCount assumed per-locale, actually file-level) by checking the implementation against a live API response rather than only the schema/docs.

Ready for review.

Mark Daugherty 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.
Mark Daugherty and others added 5 commits August 12, 2026 15:40
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>
@markdaugherty markdaugherty changed the title feat: Smartling connector enhancements (rate-limit fix + auto-authorization) feat: Smartling connector enhancements (rate-limit fix, auto-authorization, session recovery) Aug 13, 2026
claude and others added 7 commits August 13, 2026 14:22
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
markdaugherty marked this pull request as draft August 13, 2026 21:47
@markdaugherty

Copy link
Copy Markdown
Member Author

Converted back to Draft - more local testing needed for the scope of the changes

Mark Daugherty and others added 4 commits August 14, 2026 15:28
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>
Mark Daugherty and others added 3 commits August 14, 2026 16:38
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>
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.

Smartling Auto Authorization

2 participants