Errors & pitfalls
How StitchAPI errors work, how to tell failures apart at runtime, and an index of every documented failure.
When a stitch can't produce a result, it fails with a StitchError — an Error
subclass exported from stitchapi, carrying a human-readable message, an
optional .status (the upstream HTTP status), and .attempts (how many tries
the runtime made before giving up). When the failure came from an HTTP response it
also carries .body (the parsed error payload — an API's { error: "…" }) and
.url (the final request URL, after redirects); both are undefined for
transport/internal errors. Await a stitch and that error is what catch receives —
narrow it with instanceof:
import { , } from 'stitchapi';
const = ({
: 'https://api.example.com',
: '/me',
});
try {
await ();
} catch () {
if ( instanceof ) {
// .body is the parsed error payload; .url is the final request URL.
.(., ., ., ., .);
}
}instanceof holds while the error and the check share one copy of
stitchapi. Two copies can meet in one process: a CommonJS build, where each
stitchapi/* subpath carries its own copy of the class, or two installed
versions. Where that can happen, check with isStitchError(e) instead. It is
true for StitchError and every subclass, RateLimitError included, and it is
the check every @stitchapi/* host adapter's stitchError.is starts from:
import { } from 'stitchapi';
function (: unknown): string {
return () ? `upstream ${. ?? 'unreachable'}` : 'bug';
}The guard is strict: an Error that only borrows name = 'StitchError' is not
a stitch error to it, so build test doubles with new StitchError(…). The host
adapters also accept an Error with that name or RateLimitError, so a failure
from a copy of stitchapi that predates the guard still gets the host's generic
default body instead of reaching the framework's own error handling.
.body carries the upstream error payload only to the awaited / .safe()
caller — it rides a non-enumerable channel and is never written to a trace
sink, so an { error: "…" } payload can't leak into a JSONL/console log. If a
non-2xx is actually expected control flow (a 404 you fall back from), see
verdict.accept — it returns the body
as a normal result instead of throwing.
JSON.stringify(err) — and a logger that serializes through it — emits a
StitchErrorResult, { name, message, status?, attempts, url? }, built to be
logged: message is included, and both it and url have URL credentials
scrubbed — userinfo removed, secret query values (api_key, access_token, …)
replaced with REDACTED. A transport error for a malformed URL quotes the whole
request URL in its message, which is why the message is scrubbed too. .body,
.cause and a RateLimitError's raw .response (headers, set-cookie
included) are left out, because they are unredacted upstream data; a
RateLimitError adds retryAfter. Read err.body and err.url directly when
you mean to.
What that does not cover: a secret the upstream or an adapter wrote into
message in a shape that is not a URL (a bare token in an error text), and any
logger that does not call toJSON.
Anti-pattern: don't assume every logger goes through JSON.stringify.
pino's err serializer copies each enumerable field of the error itself, so
logger.error({ err }) there still writes err.body. Log the fields you
want — logger.error({ err: err.toJSON() }) — when the body may carry
customer data or a token.
If you read the event stream instead of awaiting,
the same failure arrives as an error event carrying
{ name, message, status?, attempts, at } — attempts is how many tries the
runtime made before giving up, at is when it gave up.
A transport error's message carries no URL credential. A transport
writes the URL it called into its own error text — Failed to parse URL from http://host:99999/v1?api_key=…, or a DNS failure quoting the request — and
with apiKey({ in: 'query' }) that URL holds the key. The engine scrubs it
once, where the throw becomes a StitchError: in every URL — absolute,
schemeless or relative — the userinfo is dropped and the value of every secret-named
query parameter becomes REDACTED, so ?api_key=… reads
?api_key=REDACTED. A relative path (/v1?key=…) and a schemeless
//user:pw@host/x are scrubbed too. The same goes for the text a surface
writes when it rejects a response (a GraphQL errors message that echoes a
URL), and for the retry progress detail. Every consumer of the message
gets the scrubbed text — the awaited error, .safe(), the error event,
stitch serve, the MCP tool result, an AI SDK tool failure the model reads,
and the trace sinks.
What it does not touch is the original: the error the transport threw rides on
.cause, unmodified, and .url is the response's final URL as the transport
reported it. Code that logs err.cause, or console.error(err) (which prints
the cause chain), shows the raw text, and so do the onError / onRetry
hooks. Decide whether that log line may hold a key before you write it.
Handle failure without throwing
await and .unwrap() throw on failure. When you'd rather branch than wrap the
call in try/catch, .safe() consumes the call and never throws — it resolves
to a discriminated { ok, data, error }, where error is the same StitchError,
or null on success:
import { } from 'stitchapi';
const = ({ : 'https://api.example.com', : '/me' });
const { , } = await .();
if () .(., .);
else .(); // the validated result; error is null here.unwrap() is the explicit throwing twin of .safe() — the value, or a thrown
StitchError, exactly like awaiting the bare call. See
the event stream for the iterate-instead-of-await
view of the same outcomes.
Catalog IDs are documentation, not runtime values
Each catalog page below is filed under a stable catalog ID
(STITCH_VALIDATION, STITCH_DRIFT, and so on), and the ID's page slug is its
URL. Slugs are an API: once a page is published its slug is never renamed, only
added-and-redirected, so a link written a year ago still resolves.
These IDs name documentation pages — they are not values the runtime
puts on the error. A thrown StitchError has no .code property, so
err.code is undefined and if (err.code === 'STITCH_DRIFT') never
matches. Use the IDs to refer to a failure mode (in this catalog, in the
docs MCP, in an issue report); to branch in code, use the fields
below.
Telling failures apart at runtime
Every failure arrives as a StitchError except the delegate-backoff rate limit,
which is its own exported RateLimitError. So narrow with instanceof first,
then branch on .status and .attempts — together they separate every failure
mode in the catalog:
| What you see | What happened |
|---|---|
attempts === 0 | No request was made — input validation (status undefined) or an open circuit (status 503). |
status is a 2xx | The response arrived and broke its contract — an output schema/drift failure, or GraphQL errors. |
status is non-2xx | The upstream rejected it, after any retries — a 401 auth wall, a 5xx, and so on. |
status undefined, attempts ≥ 1 | The request went out but never produced a response — a timeout or a transport error. |
import { , , } from 'stitchapi';
const = ({ : 'https://api.example.com', : '/report' });
try {
await ();
} catch () {
if ( instanceof ) {
// The one catalog entry that IS a runtime identity of its own. It
// subclasses StitchError, so this arm MUST come before the one below —
// otherwise the generic arm swallows it.
.('back off for', .);
} else if ( instanceof ) {
if (. === 0) {
// Nothing left the process: bad input, or the breaker is open.
.(. === 503 ? 'circuit open' : 'bad input');
} else if (. !== && . < 300) {
// A 2xx that still failed: the body broke the declared contract.
.('contract violation', .);
} else {
.('upstream failed', . ?? 'no response');
}
}
}.message is human-readable, not a contract — today it is
invalid <slot>: <issue> for input validation,
contract violation (drift) for a response that failed output,
timed out after <n>ms, circuit open, GraphQL: <message>, or
HTTP <status>. Match on it only as a last resort, and expect the wording
to change.
Two catalog IDs share one runtime failure: a plain output schema failure
(STITCH_VALIDATION) and a hard drift()
failure (STITCH_DRIFT) both throw the same contract violation (drift)
StitchError. The thrown error does not say which path broke — that detail
rides the drift events on the event stream,
where a hard failure appears as a finding with level: 'error' and
change: 'invalid':
import { , } from 'stitchapi';
import { } from 'zod';
const = ({
: 'https://api.example.com',
: '/user',
: (.({ : .() })),
});
for await (const of ().()) {
if (. === 'drift' && .. === 'error') {
// The structured signal: which path broke, and how.
.(.., .., ..);
}
}Every documented failure
Debugging one of these with an agent? Connect the docs
MCP and it can pull any error page on demand
— search_docs('STITCH_DRIFT'), search_docs('rate limit') — instead of
guessing from stale training data.
STITCH_VALIDATION
A response failed its output schema — the shape you asked for isn't the shape you got.
STITCH_DRIFT
A response drifted from the schema past the level you allowed, so the stitch refused it.
STITCH_AUTH_WALL
Authentication failed, or a soft 200 login wall was hit and couldn't be refreshed.
STITCH_TIMEOUT
A call exceeded its timeout budget before a response arrived.
STITCH_CIRCUIT_OPEN
The circuit breaker is open after repeated failures and is short-circuiting calls.
STITCH_GRAPHQL
A GraphQL-over-HTTP response came back 200 but carried an errors
array.
RateLimitError
A delegate-backoff stitch surfaced a rate-limit response for an outer gate to back off on, instead of retrying it.