← Docs

Timeouts, cancellation, and custom fetch

How the generated TypeScript client applies per-attempt timeouts, cancellation, a custom fetch implementation, and the raw request escape hatch.

Every generated HTTP client carries the same transport controls: a per-attempt timeout, cancellation via AbortSignal, a narrow seam for swapping fetch, and — on the client preset — a raw request escape hatch for an endpoint none of the generated operations model.

Timeouts

HttpClientContext carries timeoutMs, defaulting to 60000 (one minute). Set it on the client's shared configuration, or override it per call:

const client = new PetStoreClient({ timeoutMs: 10_000 })

// overridden for one call
await client.getPet({ parameters: { petId: '1' }, timeoutMs: 30_000 })

The timer is per attempt, not per call. A request that times out and then retries gets a fresh timeoutMs on the next attempt — a slow server does not get a longer total budget than a fast one that fails and recovers, but a request that goes through three retries can still take up to timeoutMs × (attempts + 1) end to end (plus the exponential backoff delay between attempts). A timed-out attempt throws TimeoutError, which carries url and timeoutMs, and is retried under the same rule as a network error — RetryConfig.retryOnNetworkError, default true.

Streaming (text/event-stream) operations ignore timeoutMs entirely. A healthy long-lived stream must not be killed at the 60-second default; a stream only ever stops on caller cancellation (below) or a server-side failure.

Cancellation

Every operation context — streaming and paginated ones included — accepts an optional signal: AbortSignal:

const controller = new AbortController()
setTimeout(() => controller.abort(), 5_000)

await client.getPet({ parameters: { petId: '1' }, signal: controller.signal })

Internally, the caller's signal (when set) is composed with the per-attempt timeout signal via AbortSignal.any. The two cancellations are told apart deterministically: a caller abort always rethrows the underlying AbortError and is never retried, even if the timeout signal happened to fire in the same instant — user intent beats resilience. A timeout throws TimeoutError and is retried, under the same rule as any other network error.

This requires AbortSignal.any and AbortSignal.timeout, both Node 20.3+. The generated package.json declares engines: { node: ">=20" } accordingly; every modern browser has supported both for years.

Custom fetch

HttpClientContext carries an optional fetch, read by the client's built-in request implementation:

const client = new PetStoreClient({
  fetch: (url, init) => myInstrumentedFetch(url, init),
})

This is the narrow transport seam: it swaps only the function that sends the request, leaving auth, retries, the timeout composition, and hooks untouched. For swapping the entire transport — a non-fetch HTTP library, for example — use hooks.makeRequest instead, which already existed before this feature. When both are set, hooks.makeRequest wins: it replaces the whole transport by contract, so the built-in request implementation that reads fetch never runs at all.

Routing through a proxy

There is no dedicated proxy option. Route requests through a proxy by pairing fetch with undici's ProxyAgent:

import { ProxyAgent, fetch as undiciFetch } from 'undici'

const dispatcher = new ProxyAgent('http://localhost:8080')

const client = new PetStoreClient({
  fetch: (url, init) => undiciFetch(url, { ...init, dispatcher }),
})

Raw requests

The client preset's generated client class additionally exposes a raw request escape hatch, for an endpoint none of the generated operations model:

client.request({ method, path, query?, headers?, body?, signal?, timeoutMs? })
client.get(path, options?)   // and post, put, patch, delete

Both return Promise<{ data: unknown; response: HttpResponse }>. The types-only output generates no client class, so it has no raw request surface at all — there is nothing to construct it from.

A raw request goes through the exact same pipeline every generated operation does: baseUrl joining, applyAuth, additionalHeaders, additionalQueryParams, retries, the per-attempt timeout, and hooks. A non-2xx response throws through the same error path a generated operation uses — one error surface for the whole SDK.

const { data, response } = await client.get('/v1/status')
await client.post('/v1/events', { body: { type: 'ping' } })

path is joined onto baseUrl with the URL constructor — not simple string concatenation, unlike a generated operation's own path. This means URL's relative-resolution rules apply: a leading / resolves from the origin, dropping any path segment baseUrl itself carries (baseUrl: 'https://api.example.com/v1' plus path: '/status' resolves to https://api.example.com/status, not .../v1/status). Write the full path, leading slash included, the way the examples above do.

A plain-object body is JSON-serialized with a JSON Content-Type; pass an already-encoded value — a string, FormData, a Blob, URLSearchParams, or a byte source — to send anything else untouched. data parses by the response's own Content-Type header, the same way a generated operation with more than one declared response content type does: a JSON media type via response.json(), a textual type via response.text(), and anything else as a Blob. A response with no Content-Type header at all is read as JSON.

Regenerating an existing SDK

Regenerating an SDK built before this feature shipped changes its behavior: a request that used to hang indefinitely on an unresponsive server now fails with TimeoutError after 60 seconds by default. This is deliberate — the default matches what customers evaluating against a Stainless-generated SDK already expect — but it is a real behavior change, not only an addition. Set timeoutMs explicitly on the client if 60 seconds is not the right default for a particular API.