From 420fecc18d5b26b0c684d81266eee9e6738e092a Mon Sep 17 00:00:00 2001 From: abujalance Date: Mon, 24 Aug 2026 19:14:18 +0200 Subject: [PATCH] feat(client): rate limit guidance and backoff-aware retries Document the per-endpoint limits, the 429 shape and the local-draft save pattern, and add a hard rule against autosaving to the platform on every change. Expose RequestError.retryAfter, back off between retries with jitter, honour Retry-After, and stop retrying 4xx that cannot succeed. --- .changeset/tidy-pandas-shake.md | 15 ++++ CLAUDE.md | 13 +++ docs/ai-quickstart.md | 5 ++ docs/rate-limits.md | 123 +++++++++++++++++++++++++++++ resources/AGENTS.md | 12 ++- src/core/client.test.ts | 136 ++++++++++++++++++++++++++++++++ src/core/client.ts | 82 ++++++++++++++++--- src/core/request-error.test.ts | 23 ++++++ src/core/request-error.ts | 38 ++++++++- 9 files changed, 436 insertions(+), 11 deletions(-) create mode 100644 .changeset/tidy-pandas-shake.md create mode 100644 docs/rate-limits.md diff --git a/.changeset/tidy-pandas-shake.md b/.changeset/tidy-pandas-shake.md new file mode 100644 index 0000000..011158e --- /dev/null +++ b/.changeset/tidy-pandas-shake.md @@ -0,0 +1,15 @@ +--- +'@thatopen/services': minor +--- + +Rate limit guidance and a backoff-aware retry policy. + +- `docs/rate-limits.md` documents the per-endpoint limits, the `429` body, and the local-draft + save pattern (keep work in progress in `localStorage` / IndexedDB, write on an explicit save). +- `resources/AGENTS.md` gains a hard rule against autosaving to the platform on every change, + so assistants stop building write-per-keystroke loops. +- `RequestError.retryAfter` exposes the wait in seconds, read from `Retry-After` or + `details.retryAfter`. +- Retries now back off exponentially with jitter and honour `Retry-After`. Only network + failures, `429` and `5xx` are retried — other `4xx` fail immediately instead of being + repeated. Retries remain off by default. diff --git a/CLAUDE.md b/CLAUDE.md index dd4cce7..52fa9c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,19 @@ Examples of comment-worthy behavior: Local `.thatopen` (project root) takes priority over global `~/.thatopen/config.json`. The `resolveConfig()` helper in `src/cli/lib/config.ts` handles this — use it, don't re-implement. +## Rate limits + +`docs/rate-limits.md` is the user-facing page: per-endpoint limits, the `429` shape, and the +local-draft save pattern. The numbers are **copied from the backend**, so they go stale silently +— the source of truth is the `@Throttle` decorators in `platform_backend-api` +(`src/api/**/**.controller.ts`) plus the `ThrottlerModule.forRoot` default in `src/app.module.ts`. +Re-check them whenever a limit changes, and keep the hard rule at the top of `resources/AGENTS.md` +in sync with them. + +Client-side: `EngineServicesClient` retries only network failures, `429` and `5xx`, with +exponential backoff plus jitter, honouring `Retry-After`. `RequestError.retryAfter` carries the +wait in seconds (header first, then `details.retryAfter`). Retries stay off by default (`retries: 0`). + ## Backend permissions contract When a request includes a `projectId`, the backend validates that the resource belongs to that project and the caller has permission there — regardless of access in other projects. This enforcement is server-side and invisible in the client code. diff --git a/docs/ai-quickstart.md b/docs/ai-quickstart.md index 04b80dd..fa20a16 100644 --- a/docs/ai-quickstart.md +++ b/docs/ai-quickstart.md @@ -283,4 +283,9 @@ component, once added to a project, is then triggered by an app or an automation four-point plan and waiting for approval to do what was just requested costs the user a whole turn and buys nothing. Stop and ask only when you are about to change files you did not create, when the request can be read two ways that mean different work, or when the next step is destructive. +- **Never save to the platform on every change.** Drafts belong in `localStorage` / + IndexedDB; the platform gets an explicit save. Writes are capped at 30 per minute and a + `429` loses the write. See + `node_modules/@thatopen/services/docs/rate-limits.md` before you write any save, sync or + polling code. - The scaffold already works — **extend it, don't replace it.** diff --git a/docs/rate-limits.md b/docs/rate-limits.md new file mode 100644 index 0000000..a4112e8 --- /dev/null +++ b/docs/rate-limits.md @@ -0,0 +1,123 @@ +# Rate limits, and how to save data without hitting them + +The platform API is rate limited. Every app and cloud component shares the same +budget, so **how often you write decides whether your app works**. This page has +the numbers, the failure mode, and the pattern to use instead. + +> **The one rule:** never send a request on every change the user makes. No +> autosave-per-keystroke, no write inside a render loop, no polling loop that +> hammers a list endpoint. Keep work-in-progress in the browser and talk to the +> server on an explicit save. + +--- + +## The numbers + +Limits are per **rolling 60-second window**, counted per user (JWT), or per API +token owner, or per IP — whichever identifies the caller. + +| Endpoint | Client method | Limit | +|---|---|---| +| `POST /api/item` | `createFile`, `createComponent`, `createApp` | **30 / min** | +| `POST /api/item/:id/version` | `updateFile` / `updateComponent` **with a `file`** | **30 / min** | +| `PUT /api/item/:id/version/:tag/metadata` | `updateFileVersionMetadata` | 30 / min | +| `DELETE /api/item/:id/version/:tag/metadata` | `deleteFileVersionMetadata` | 30 / min | +| `PUT /api/item/:id/version/:tag/archive` \| `/recover` | `archiveVersion`, `recoverVersion` | 30 / min | +| `POST /api/item/hidden` \| `/hidden/batch` | `createHiddenFile`, `createHiddenFilesBatch` | 30 / min | +| `POST /api/processor/:id/execute` | `executeComponent` | 20 / min | +| `GET /api/item/folder/:id/download`, `POST /api/item/batch/download` | `downloadFolder` | 10 / min | +| `POST /api/item/batch/versions` \| `/batch/version-metadata` \| `/batch/folders` | `listVersionsBatch`, `getFileVersionMetadataBatch`, `getFoldersBatch` | 60 / min | +| `POST /api/item/hidden/signed-url/batch` | `getHiddenFileSignedUrlsBatch` | 100 / min | +| `GET /api/item/hidden/:id/download` | `downloadHiddenFile` | 3000 / min | +| Everything else | — | 100 / min | + +Two things follow from the table: + +- **30 writes per minute is one write every two seconds.** An autosave tied to + user input passes that in a few seconds of typing or dragging. +- **Reads are cheap, but not free.** A viewer that mints one signed URL per tile + will exhaust 100/min quickly — use `getHiddenFileSignedUrlsBatch`, which signs + up to `STORAGE_BATCH_MAX` files per request. + +## What a rate-limited response looks like + +Status `429`, with a `Retry-After` header (seconds) and this body: + +```json +{ + "statusCode": 429, + "message": "Rate limit exceeded: max 30 requests per 60s for this endpoint. Retry after 12s.", + "code": "RATE_LIMITED", + "details": { "limit": 30, "windowSeconds": 60, "retryAfter": 12, "scope": "user" } +} +``` + +The client surfaces it as a `RequestError`: + +```ts +import { RequestError } from '@thatopen/services'; + +try { + await client.updateFile(fileId, { file: blob, versionTag: tag }); +} catch (err) { + if (err instanceof RequestError && err.status === 429) { + // err.code === 'RATE_LIMITED' + // err.retryAfter — seconds to wait, from Retry-After or details.retryAfter + showToast(`Saving is paused for ${err.retryAfter}s — your work is kept locally.`); + return; + } + throw err; +} +``` + +**A 429 means the write did not happen.** Nothing was saved. If the user's only +copy of the change was in that request, it is gone — which is the real reason +the local-draft pattern below matters. + +## The pattern: local drafts, explicit saves + +Keep every intermediate state in the browser. Write to the platform only when +the user asks for it. + +```ts +const draftKey = `draft:${fileId}`; + +function onChange(state: unknown) { + localStorage.setItem(draftKey, JSON.stringify({ state, at: Date.now() })); +} + +async function onSave(state: unknown) { + const blob = new Blob([JSON.stringify(state)], { type: 'application/json' }); + await client.updateFile(fileId, { file: blob, versionTag: `v${Date.now()}` }); + localStorage.removeItem(draftKey); +} +``` + +On load, if a draft exists for the file, offer to restore it. That gives crash +recovery — the thing autosave was really for — at zero requests. + +Rules of thumb: + +- **Explicit save**, or a timer no faster than **once every 30 seconds**, and + only when something actually changed. +- **One request per save**, not one per changed object. Batch the whole document. +- **Never save while a save is in flight.** Keep a flag per file, and drop or + queue the second save. Overlapping writes also create versions that are hard + to reconcile. +- **Big or binary work-in-progress** belongs in IndexedDB, not `localStorage` + (about 5 MB per origin). +- There is **no draft-write method in the client on purpose.** Local storage is + the draft store; the platform stores versions the user chose to keep. + +## Retries + +The client does not retry by default. When you turn retries on, it backs off +exponentially, adds jitter, and honours `Retry-After`: + +```ts +const client = new EngineServicesClient(token, apiUrl, { retries: 3 }); +``` + +Only network failures, `429` and `5xx` are retried. Other `4xx` fail straight +away, because repeating them cannot help. Never write your own immediate retry +loop around a 429 — that is what turns a throttled request into an outage. diff --git a/resources/AGENTS.md b/resources/AGENTS.md index cb6d34f..fe82a5c 100644 --- a/resources/AGENTS.md +++ b/resources/AGENTS.md @@ -19,6 +19,7 @@ Do this before answering any question or writing any code. These are compact des | Platform built-ins | `node_modules/@thatopen/services/docs/builtin/paths.json` | | Platform client API | `node_modules/@thatopen/services/docs/client/paths.json` | | CLI commands | `node_modules/@thatopen/services/docs/cli/paths.json` | +| Rate limits + how to save data | `node_modules/@thatopen/services/docs/rate-limits.md` | | Engine components (`OBC`, `OBF`) | `https://raw.githubusercontent.com/ThatOpen/engine_components/refs/heads/main/examples/paths.json` | | Fragments (`FRAGS`) | `https://raw.githubusercontent.com/ThatOpen/engine_fragment/refs/heads/main/examples/paths.json` | | UI components (`BUI`) | `https://raw.githubusercontent.com/ThatOpen/engine_ui-components/refs/heads/main/examples/paths.json` — **skip** entries whose path contains `packages/obc` or `bim-grid` | @@ -38,4 +39,13 @@ Once you have these, you know everything available on the platform. Only then fe ## Hard rules (always apply) -1. **All UI must be built with Lit**, using the web components from `@thatopen/ui` (`BUI`) — `bim-button`, `bim-panel`, `bim-panel-section`, `bim-toolbar`, `bim-dropdown`, `bim-input`, and the rest of `packages/core`. Always consult the design system before writing any UI: `https://raw.githubusercontent.com/ThatOpen/engine_ui-components/refs/heads/main/DESIGN.md`. \ No newline at end of file +1. **Never write to the platform on every change.** No autosave per keystroke, + per drag, or inside a render loop. Keep work in progress in `localStorage` / + IndexedDB and call the platform on an **explicit user save** (or a timer no + faster than once every 30 seconds). Writes are capped at **30 per minute**; + crossing that returns `429` and the write is **lost**, not queued. + If the user asks for autosave, build it against local storage and say so. + Read `node_modules/@thatopen/services/docs/rate-limits.md` before writing any + save, sync, or polling code — it has the per-endpoint limits and the pattern. + +2. **All UI must be built with Lit**, using the web components from `@thatopen/ui` (`BUI`) — `bim-button`, `bim-panel`, `bim-panel-section`, `bim-toolbar`, `bim-dropdown`, `bim-input`, and the rest of `packages/core`. Always consult the design system before writing any UI: `https://raw.githubusercontent.com/ThatOpen/engine_ui-components/refs/heads/main/DESIGN.md`. \ No newline at end of file diff --git a/src/core/client.test.ts b/src/core/client.test.ts index bc5ef50..77d1a05 100644 --- a/src/core/client.test.ts +++ b/src/core/client.test.ts @@ -32,6 +32,22 @@ function errorResponse(status: number, message = 'Bad Request'): Response { } as unknown as Response; } +function throttledResponse(retryAfter?: string): Response { + const body = JSON.stringify({ + message: 'Rate limit exceeded: max 30 requests per 60s for this endpoint.', + code: 'RATE_LIMITED', + details: { limit: 30, windowSeconds: 60, retryAfter: 12, scope: 'user' }, + }); + return { + ok: false, + status: 429, + statusText: 'Too Many Requests', + headers: { get: (name: string) => (name === 'Retry-After' ? retryAfter ?? null : null) }, + text: async () => body, + json: async () => JSON.parse(body), + } as unknown as Response; +} + function getCall( fetchMock: Mock, index = 0, @@ -574,3 +590,123 @@ describe('EngineServicesClient — HTTP contract', () => { }); }); }); + +describe('EngineServicesClient — retry policy', () => { + let fetchMock: Mock; + + beforeEach(() => { + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof fetch; + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + async function runWithTimers(promise: Promise): Promise { + const settled = promise.then( + (value) => ({ ok: true as const, value }), + (error: unknown) => ({ ok: false as const, error }), + ); + await vi.runAllTimersAsync(); + const result = await settled; + if (!result.ok) throw result.error; + return result.value; + } + + it('does not retry a 4xx that is not a rate limit', async () => { + fetchMock.mockResolvedValue(errorResponse(404, 'Not Found')); + const client = new EngineServicesClient(TOKEN, API, { retries: 3 }); + + await expect(runWithTimers(client.listFiles())).rejects.toMatchObject({ + status: 404, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('retries a 429 and succeeds on the next attempt', async () => { + fetchMock + .mockResolvedValueOnce(throttledResponse('2')) + .mockResolvedValueOnce(okResponse([{ _id: 'file-1' }])); + const client = new EngineServicesClient(TOKEN, API, { retries: 2 }); + + const files = await runWithTimers(client.listFiles()); + + expect(files).toEqual([{ _id: 'file-1' }]); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('waits for the Retry-After window before retrying a 429', async () => { + fetchMock + .mockResolvedValueOnce(throttledResponse('2')) + .mockResolvedValueOnce(okResponse([])); + const client = new EngineServicesClient(TOKEN, API, { retries: 1 }); + + const pending = client.listFiles(); + const settled = pending.then(() => 'done'); + + await vi.advanceTimersByTimeAsync(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1500); + expect(fetchMock).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(3000); + expect(fetchMock).toHaveBeenCalledTimes(2); + await expect(settled).resolves.toBe('done'); + }); + + it('gives up after the configured number of retries', async () => { + fetchMock.mockResolvedValue(throttledResponse('1')); + const client = new EngineServicesClient(TOKEN, API, { retries: 2 }); + + await expect(runWithTimers(client.listFiles())).rejects.toMatchObject({ + status: 429, + }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('retries server errors and network failures', async () => { + fetchMock + .mockRejectedValueOnce(new TypeError('Failed to fetch')) + .mockResolvedValueOnce(errorResponse(503, 'Service Unavailable')) + .mockResolvedValueOnce(okResponse([])); + const client = new EngineServicesClient(TOKEN, API, { retries: 3 }); + + await expect(runWithTimers(client.listFiles())).resolves.toEqual([]); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('does not retry when retries are left at the default of 0', async () => { + fetchMock.mockResolvedValue(throttledResponse('1')); + const client = new EngineServicesClient(TOKEN, API); + + await expect(runWithTimers(client.listFiles())).rejects.toMatchObject({ + status: 429, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('exposes retryAfter, code and details from a throttled response', async () => { + fetchMock.mockResolvedValue(throttledResponse('7')); + const client = new EngineServicesClient(TOKEN, API); + + await expect(runWithTimers(client.listFiles())).rejects.toMatchObject({ + status: 429, + code: 'RATE_LIMITED', + retryAfter: 7, + details: { limit: 30, windowSeconds: 60, retryAfter: 12, scope: 'user' }, + }); + }); + + it('falls back to details.retryAfter when the header is missing', async () => { + fetchMock.mockResolvedValue(throttledResponse(undefined)); + const client = new EngineServicesClient(TOKEN, API); + + await expect(runWithTimers(client.listFiles())).rejects.toMatchObject({ + retryAfter: 12, + }); + }); +}); diff --git a/src/core/client.ts b/src/core/client.ts index 755e371..bec1c12 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -50,6 +50,59 @@ const ITEM_TYPE_FILE = 'FILE'; const ITEM_TYPE_COMPONENT = 'TOOL'; const ITEM_TYPE_APP = 'APP'; +const RETRY_BASE_DELAY_MS = 500; +const RETRY_MAX_DELAY_MS = 30_000; +const RETRY_JITTER_RATIO = 0.25; + +const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Reads `Retry-After` (seconds, or an HTTP date) from a response. Returns + * `undefined` when the header is absent, unparseable, or when the runtime + * hands over a response-like object without headers. + */ +function parseRetryAfter(response: Response): number | undefined { + const raw = response.headers?.get?.('Retry-After'); + if (!raw) return undefined; + const seconds = Number(raw); + if (Number.isFinite(seconds)) return Math.max(0, seconds); + const timestamp = Date.parse(raw); + if (Number.isNaN(timestamp)) return undefined; + return Math.max(0, (timestamp - Date.now()) / 1000); +} + +/** + * Only network failures, rate limits and server errors are worth repeating. + * Retrying a 400/401/403/404 burns quota on a request that cannot succeed — + * and on 429 it is what turns a rate limit into an outage. + */ +function isRetryable(error: unknown): boolean { + if (!(error instanceof RequestError)) return true; + return error.status === 429 || error.status >= 500; +} + +/** + * Exponential backoff with jitter, overridden by the server's `Retry-After` + * when it sent one. Capped at {@link RETRY_MAX_DELAY_MS} so a long server-side + * window never parks a request for minutes. + */ +function retryDelayMs(error: unknown, attempt: number): number { + const backoff = Math.min( + RETRY_BASE_DELAY_MS * 2 ** attempt, + RETRY_MAX_DELAY_MS, + ); + const jitter = backoff * RETRY_JITTER_RATIO * Math.random(); + const serverDelay = + error instanceof RequestError && error.retryAfter != null + ? error.retryAfter * 1000 + : undefined; + if (serverDelay != null) { + return Math.min(serverDelay, RETRY_MAX_DELAY_MS) + jitter; + } + return backoff + jitter; +} + /** * Minimal shape of an OBC.Components-like object. * Avoids hard-coupling to `@thatopen/components` at the public API level. @@ -132,7 +185,13 @@ export type DownloadItemFileParams = { /** Configuration options for the {@link EngineServicesClient} constructor. */ export type EngineServicesClientProps = { - /** Number of automatic retries on request failure. Default: 0. */ + /** + * Number of automatic retries on request failure. Default: 0. + * + * Retries are spaced with exponential backoff plus jitter, and honour the + * server's `Retry-After` when it sends one. Only network failures, 429s and + * 5xx are retried — other 4xx fail immediately. + */ retries?: number; /** * If true, sends the token as an `Authorization: Bearer` header instead of @@ -255,7 +314,9 @@ export class EngineServicesClient { } /** - * Sets the number of automatic retries for failed requests. + * Sets the number of automatic retries for failed requests. Retries use + * exponential backoff with jitter and honour `Retry-After`; only network + * failures, 429s and 5xx are retried. * @param retries - Number of retries (0 = no retries). */ setRetries(retries: number) { @@ -331,9 +392,10 @@ export class EngineServicesClient { | 'application/x-www-form-urlencoded'; retries?: number; responseType?: 'json' | 'blob'; + retryAttempt?: number; }, ): Promise { - const { body, query, contentType, retries, responseType } = + const { body, query, contentType, retries, responseType, retryAttempt } = requestData || {}; const url = this.#buildUrl(path); @@ -367,6 +429,7 @@ export class EngineServicesClient { response.status, response.statusText, textResponse, + parseRetryAfter(response), ); } @@ -379,16 +442,17 @@ export class EngineServicesClient { .then((data) => data as T) .catch(() => undefined as T); } catch (e) { - let retriesAmmount = retries != null ? retries : this.retries; - if (retriesAmmount) { - retriesAmmount = retriesAmmount - 1; + const retriesLeft = retries != null ? retries : this.retries; + if (retriesLeft > 0 && isRetryable(e)) { + const attempt = retryAttempt ?? 0; + await sleep(retryDelayMs(e, attempt)); return await this.#requestApi(method, path, { ...requestData, - retries: retriesAmmount, + retries: retriesLeft - 1, + retryAttempt: attempt + 1, }); - } else { - throw e; } + throw e; } } diff --git a/src/core/request-error.test.ts b/src/core/request-error.test.ts index 30e4f0d..ee0abb6 100644 --- a/src/core/request-error.test.ts +++ b/src/core/request-error.test.ts @@ -57,6 +57,29 @@ describe('RequestError', () => { expect(err.code).toBeUndefined(); }); + it('keeps the retryAfter passed by the client', () => { + const err = new RequestError(429, 'Too Many Requests', '', 12); + expect(err.retryAfter).toBe(12); + }); + + it('falls back to details.retryAfter when none is passed', () => { + const err = new RequestError(429, 'Too Many Requests', JSON.stringify({ + message: 'Rate limit exceeded', + code: 'RATE_LIMITED', + details: { limit: 30, windowSeconds: 60, retryAfter: 42 }, + })); + expect(err.code).toBe('RATE_LIMITED'); + expect(err.retryAfter).toBe(42); + }); + + it('leaves retryAfter undefined when neither source has one', () => { + const err = new RequestError(429, 'Too Many Requests', JSON.stringify({ + message: 'Rate limit exceeded', + details: { retryAfter: 'soon' }, + })); + expect(err.retryAfter).toBeUndefined(); + }); + it('is an instance of Error and RequestError with the right name', () => { const err = new RequestError(403, 'Forbidden', ''); expect(err).toBeInstanceOf(Error); diff --git a/src/core/request-error.ts b/src/core/request-error.ts index fd48aff..cde96e2 100644 --- a/src/core/request-error.ts +++ b/src/core/request-error.ts @@ -22,6 +22,19 @@ function parseErrorBody(body: string): { return {}; } +/** + * Reads `details.retryAfter` (seconds) from a parsed error body. The platform + * puts it there on `RATE_LIMITED` responses so the value survives proxies that + * strip the `Retry-After` header. + */ +function retryAfterFromDetails(details: unknown): number | undefined { + if (!details || typeof details !== 'object') return undefined; + const value = (details as Record).retryAfter; + return typeof value === 'number' && Number.isFinite(value) && value >= 0 + ? value + : undefined; +} + /** * Error thrown by {@link EngineServicesClient} when the platform API responds * with a non-2xx status. Exposes the HTTP `status` and — when the API returns a @@ -39,6 +52,17 @@ function parseErrorBody(body: string): { * } * } * ``` + * + * @example Rate limiting + * ```ts + * catch (err) { + * if (err instanceof RequestError && err.status === 429) { + * console.log(err.code); // "RATE_LIMITED" + * console.log(err.details); // { limit, windowSeconds, retryAfter, scope } + * console.log(err.retryAfter); // seconds to wait before trying again + * } + * } + * ``` */ export class RequestError extends Error { readonly status: number; @@ -46,7 +70,18 @@ export class RequestError extends Error { readonly details?: unknown; readonly body: string; - constructor(status: number, statusText: string, body: string) { + /** + * Seconds to wait before retrying, taken from the `Retry-After` header or + * from `details.retryAfter`. Only present on rate-limited (429) responses. + */ + readonly retryAfter?: number; + + constructor( + status: number, + statusText: string, + body: string, + retryAfter?: number, + ) { const parsed = parseErrorBody(body); super(parsed.message ?? `${statusText || 'Request failed'} (${status})`); this.name = 'RequestError'; @@ -54,5 +89,6 @@ export class RequestError extends Error { this.code = parsed.code; this.details = parsed.details; this.body = body; + this.retryAfter = retryAfter ?? retryAfterFromDetails(parsed.details); } }