Skip to content

fix(figma-icons-fetcher): give HTTP an explicit timeout and one retry path - #11

Merged
leonid merged 1 commit into
mainfrom
fix/figma-fetcher-timeout-retry
Aug 21, 2026
Merged

leonid merged 1 commit into
mainfrom
fix/figma-fetcher-timeout-retry

Conversation

@leonid

@leonid leonid commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Type of change

  • Bug fix (a non-breaking change that fixes an issue)

Description

pull-icons failed with:

✗ Error: fetch failed
Cause: Headers Timeout Error

which reads like a dead network or a bad token, and was neither. Measured against
the real file:

request result
api.figma.com, unauthenticated HTTP 403 in 0.3 s — network fine
/v1/me with the configured token HTTP 200 in 0.97 s — token fine
/v1/files/:key/nodes, cold exceeded the default budget; a retry landed at 122 s, 7.24 MB
the same call, warm HTTP 200 in 0.6 s

Figma buffers that entire node tree server-side before sending a single response
header, so the wait is for the first byte — which is why the error names headers
rather than the body. Node's default headersTimeout gives up first.

That default cannot be raised through globalThis.fetch, so this adds undici as
a direct dependency (already present transitively; this workspace is private and
never published) and routes requests through an explicit Agent.

src/http.ts — one HTTP layer for both call sites

Previously the REST client had no timeout and no retry, and the image CDN had
retry but no timeout. Now both go through one place:

  • Timeout — headersTimeout and bodyTimeout each get a 10-minute budget,
    overridable via FIGMA_FETCHER_TIMEOUT_MS. A non-numeric or non-positive
    override falls back to the default rather than passing 0 through: in undici
    0 means no timeout, and a silent hang is worse than a slow failure.
  • Retry — five attempts, 500 ms exponential backoff, transient only. This is
    download-image.ts's existing policy moved rather than reinvented, so there
    is one implementation instead of two, and the CDN path gains the timeout it
    never had.
  • Fail fast where retrying cannot help — a definitive 4xx (bad token, wrong
    file key) throws on the first attempt rather than reporting the same thing five
    attempts and ~7 s later.

The mistake the first version made

Worth calling out, because it made one case worse before it made it better: with
no network at all, the retry loop spent 7.5 minutes backing off before
reporting ENOTFOUND. isRetryableError walks the cause chain — undici reports
a generic fetch failed at the surface — and treats an unreachable host, an
invalid URL and a bad certificate as fatal. EAI_AGAIN is deliberately not in
that set: it is the transient sibling of ENOTFOUND and does come good.

A latent bug this exposed

fetchWrapper used to json() the response body whatever the status. A 403 from
a bad token therefore became { data: { err: … } }, which downstream code read as
a malformed file — so a rejected request was reported as a missing node. Non-OK
statuses now reject.

Verification

  • 21 new specs, 111 in this workspace (from 90). undici's fetch is mocked:
    the specs assert when a request is repeated and when it is abandoned, which is
    decided before any socket opens. Observing a real headers timeout would mean
    waiting minutes for the condition the change exists to survive, so the budget is
    asserted through resolveTimeoutMs instead.
  • Negative-controlled — widening isTransientStatus so every 4xx looks
    transient fails two specs; restoring it returns 21/21.
  • pull-icons runs end to end through the new layer: 495 icons, 10 manifests,
    11.6 s warm.
  • pnpm -r typecheck, pnpm -r lint, pnpm format:check all clean.

Not addressed here

Every pull-icons run also dirties packages/icons-svg/src/figma/solid-multi.json
— the generator always writes expanded JSON, but Prettier keeps that one-entry
array on a single line. The lint-staged pre-commit hook silently fixes it, which
is why it has gone unnoticed. Left alone as unrelated to this fix.

Checklist

  • I have added or updated tests to cover my changes;
  • I have performed a self-review of my code;
  • My code follows the style guidelines of this project;
  • My changes generate no new warnings.

… path

`pull-icons` failed with `fetch failed / Cause: Headers Timeout Error`, which
reads like a dead network or a bad token and was neither. Measured against the
real file:

- `api.figma.com` unauthenticated: HTTP 403 in 0.3 s, so the network was fine.
- `/v1/me` with the configured token: HTTP 200 in 0.97 s, so the token was fine.
- `/v1/files/:key/nodes` cold: exceeded the default budget. A retry landed at
  122 s for 7.24 MB of JSON.
- The same call warm: HTTP 200 in 0.6 s.

Figma buffers that whole node tree server-side before sending a single response
header, so the wait is for the first byte. Node's default `headersTimeout` gives
up first, and it cannot be raised through `globalThis.fetch` — hence `undici` as
a direct dependency (already in the lockfile transitively; this workspace is
private and never published) and an explicit `Agent`.

`src/http.ts` is now the only HTTP layer, used by both the REST client and the
image CDN:

- Timeout. `headersTimeout` and `bodyTimeout` both get a 10-minute budget,
  overridable with `FIGMA_FETCHER_TIMEOUT_MS`. A non-numeric or non-positive
  override falls back to the default rather than passing `0` through, which in
  undici means *no* timeout — a silent hang is worse than a slow failure.
- Retry. Five attempts, 500 ms exponential backoff, transient only. This is
  `download-image.ts`'s original policy, moved rather than reinvented, so there
  is one implementation instead of two and the CDN path gains the timeout it
  never had.
- Fail fast where retrying cannot help. A definitive 4xx (a bad token, a wrong
  file key) throws on the first attempt instead of reporting the same thing five
  attempts later.

The last point is there because the first version of this change made things
worse in one case: with no network at all, the retry loop spent 7.5 minutes
before reporting `ENOTFOUND`. `isRetryableError` walks the `cause` chain, since
undici reports `fetch failed` at the surface, and treats an unreachable host as
fatal. `EAI_AGAIN` is deliberately excluded from that set: it is the transient
sibling and does come good.

Also fixes a latent bug this exposed. `fetchWrapper` used to `json()` the body
whatever the status, so a 403 became `{ data: { err: … } }` that downstream code
read as a malformed *file* — reporting a missing node instead of a rejected
request. Non-OK statuses now reject.

Verified: 21 new specs (111 total in this workspace, from 90), typecheck and lint
clean, and `pull-icons` runs end to end through the new layer — 495 icons, 10
manifests, 11.6 s warm. The fail-fast branch is negative-controlled: widening
`isTransientStatus` to treat every 4xx as transient fails two specs.
@leonid
leonid merged commit 769af0b into main Aug 21, 2026
7 checks passed
@leonid
leonid deleted the fix/figma-fetcher-timeout-retry branch August 21, 2026 17:42
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