Beta
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.
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.
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.
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.
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 }),
})
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 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.