Error handling and the response envelope
Two audiences. Part 1 is how errors are produced inside the API. Part 2 is the contract a client can rely on.
The rules distilled from this document live in AGENTS.md; this
is the reasoning behind them.
Part 1 — Contributors
One rule underpins everything: throw typed errors, never build an error
response by hand. Handlers, services, and normalizers throw; a single
onError hook (app.onError(handleGlobalError) in app.ts) catches
everything, normalizes it, logs it, and shapes the response. Don't try/catch
in a controller to format a response — let it bubble.
The flow of one request
- Validation — zod validates params. On failure the hook in
helpers/zodError.tsthrowsBadRequestErrorwith the flattened issues indetails(public — safe to show, it's just which field was wrong). You don't call it; registering the schema on the route is enough. - Controller — reads
ctx.req.valid("query" | "json"), calls the service, returnsctx.json(ok(ctx, data), 200). No error handling of its own. - Service →
UpstreamHandler.call()— any upstream failure (transport, non-2xx, or a provider error hidden in a 200 body) is translated intoServiceUnavailableError(503, retryable) orSourceError(500, terminal) for you. The upstream status/body never reach the client — seeUpstreamHandlerbelow. - Normalizer — throws
ContractError(viacontractFail(route, reason)inutils/normalizer.utils.ts) on a shape mismatch, never echoes the raw upstream body. handleGlobalError(helpers/globalErrorHandler.ts) — flattens any thrown value into{ code, status, message, retryable, details }(defaults for a non-AppErrorthrow:INTERNAL_ERROR/ 500 / not retryable /details: null), logs aterror(status ≥ 500) orwarn(4xx) witherror.contextattached for diagnostics, then callsfail()to build the body.
The public/private split
Every AppError carries two optional bags:
context— private: diagnostics, upstream status codes, provider names. Logged only, never sent to the client.details— public: safe, client-facing extras like which field failed validation. Echoed verbatim in the response.
There is no trusted/expose flag — the split is just "which field did you put
it in." Never put a raw third-party body, upstream status, or provider name in
details.
Error classes
All from @thenetaji/packages, defined in packages/errors/. Every constructor
takes (message, { cause?, context?, details?, retryable? }).
| Error class | status | code | retryable | Throw when |
|---|---|---|---|---|
BadRequestError | 400 | INVALID_REQUEST | false | Bad/malformed input (also auto-thrown by the zod hook) |
UnauthorizedError | 401 | UNAUTHORIZED | false | Missing/invalid API key (thrown by apiKeyAuth middleware) |
ForbiddenError | 403 | FORBIDDEN | false | Authenticated but not allowed |
NotFoundError | 404 | NOT_FOUND | false | Our own route or row not found. Never for a record an upstream answered as missing: that is 200 + data: null |
ValidationError | 422 | INVALID_REQUEST | false | Input parses but is semantically invalid |
SourceError | 500 | SOURCE_ERROR | false | Upstream call failed, terminal (non-retryable HTTP or provider error) |
ContractError | 500 | SOURCE_ERROR | false | Upstream body doesn't match our expected shape (context.stage: "contract") |
ServiceUnavailableError | 503 | SOURCE_UNAVAILABLE | true | Upstream transiently down/blocked/timed out/throttled |
RateLimitError | 429 | RATE_LIMITED | false | Our own rate limit tripped (infrastructure-level; not currently wired into any route) |
ConnectionError, TimeoutError | 502/504 | NETWORK_ERROR | true | Internal only (transport failures from the HTTP client). UpstreamHandler catches these and rethrows as ServiceUnavailableError — never throw them toward a client directly. |
plain AppError / unknown throw | 500 | INTERNAL_ERROR | false | Our own bug / unexpected state (the global handler's fallback) |
The one deliberate deviation
apify.controller.ts breaks "let it bubble" on purpose: it is fire-and-forget
telemetry, so it swallows ClickHouse errors, drops invalid events, and still
returns 202. Don't copy that pattern for data routes.
UpstreamHandler
Every third-party route call goes through an UpstreamHandler subclass
(apps/api/src/helpers/upstream/, base class in upstream-handler.ts).
call(url, headers?) has exactly one job: on success return the raw upstream
body; on any failure, throw the right typed error. It never returns an error to
the caller — it only throws or returns data.
Three failure paths, all in upstream-handler.ts:
- Transport failure (timeout/connection error after the HTTP client's own
retries) →
ServiceUnavailableError(503, retryable). The upstream status is never involved — there wasn't one. - Provider non-2xx →
429or5xxbecomeServiceUnavailableError(503, retryable); everything else becomesSourceError(500, terminal). The real upstream status is logged incontext(stage: "provider_http"), never echoed to the client. - A
200 OKwhose body encodes a provider failure (e.g. TikHub's envelopecodefield) → the subclass'sdetectProviderError(body)hook returns aProviderFailure { providerCode, retryable }ornull.retryabledecides 503 vs 500, same as above.
Concrete clients, all re-exported from index.ts:
worker(worker.ts) — our own scraper backend. No retries configured (retryStatuses: []), propagates request/trace context to it.tikhub(tikhub.ts) — implementsdetectProviderError: a non-200codein the JSON envelope is a provider failure (429/5xxretryable, everything else — including400— terminal). InjectsAuthorization: BearerfromTIK_API. Retries429/500/502/503/504at the transport layer. (There is no separatetikhubLinkedinhandler —linkedin-prouses the sametikhubinstance.)rapidapi(rapidapi.ts) — injectsx-rapidapi-host/x-rapidapi-key. NodetectProviderErroroverride (RapidAPI failures are HTTP-status-coded, handled by the base class).
Adding a new site: subclass UpstreamHandler, set provider, override
detectProviderError only if the provider hides failures inside a 200 body.
Normalizers — null vs throw
Shared helpers live in apps/api/src/utils/normalizer.utils.ts:
requireObject(data, route, reason), validateAndReturn(data, schema, route),
contractFail(route, reason) (throws ContractError with { route, reason }
in context), and removeTikHubInternals(data) (strips TikHub envelope
metadata — request_id, cache_*, time*, docs, support, router,
params, message*, code — before validating/returning).
The razor: null is only for a documented empty signal, decided by the
site's own contract, not a generic "couldn't parse" fallback.
An upstream that answered is a 200. When a provider or the worker replies
that the post, profile or video does not exist, the call succeeded and we paid
for it. Return 200 with data: null and details: { reason: "record_not_found", message } (ok(ctx, null, details), as in #316 and
instagram-v2 media info). Do not turn that answer into a NotFoundError
(404) or a SourceError (500): callers, Apify actors and alerts read both
as failures.
- Worker-backed sites (instagram, meta):
raw === null→ returnnull(the worker's own not-found signal — see Worker contract). tikhubsites: an envelopedata: nullis the documented not-found (seelinkedin-pro.normalizer.ts).tiktok-shopproduct detail: a non-zero envelope-levelerror_codewith no payload →null(seeisProductNotFoundintiktok-shop.normalizer.ts). This is the only not-found signal for that endpoint — any other missing field is a contract break.- Lists always return an empty array/collection on zero results — never
null. - Anything else missing or shape-invalid — a required field absent, or
validateAndReturn's zod schema failing — throwsContractErrorviacontractFail. Services do nottry/catch-swallow this intonull; a broken contract surfaces as a500.
Worker contract
worker is our own scraper backend, and it is the one upstream that is not
masked. The masking rule exists so a resold vendor's error text cannot leak and
tell a caller who we buy from; there is no vendor here, so its message,
reason and retry_after_ms are forwarded.
It signals not-found as HTTP 200 with a JSON body of null;
normalizers/services recognize raw === null and return null, which the API
wraps as { "data": null }.
The worker sends { type, reason, message, retry_after_ms, context }. type
selects a row in WORKER_ERRORS (helpers/upstream/worker.ts); context is
diagnostics and stays in logs. Every type in
apps/worker/src/api/errors.py should have a row, which is what gives it the
right status and retryability. An unmapped type no longer falls through to a
masked 500: it publishes the worker's own message under a retryable 503, because
a type we have not mapped is still a type we named.
| Worker type | Client code | Status | reason | Retry |
|---|---|---|---|---|
VALIDATION_ERROR | INVALID_REQUEST | 400 | invalid_parameter | no |
INVALID_CURSOR | INVALID_REQUEST | 400 | cursor_invalid | no |
UPSTREAM_BLOCKED | SOURCE_UNAVAILABLE | 503 | blocked_by_source | 60s |
UPSTREAM_AUTH_REQUIRED | SOURCE_ERROR | 403 | source_auth_required | no |
UPSTREAM_THROTTLED | RATE_LIMITED | 429 | source_throttled | 30s |
RATE_LIMITED | RATE_LIMITED | 429 | capacity_limited | 5s |
SESSION_UNAVAILABLE | SOURCE_UNAVAILABLE | 503 | capacity_exhausted | 10s |
MINT_FAILED | SOURCE_UNAVAILABLE | 503 | mint_failed | 30s |
UPSTREAM_TIMEOUT | SOURCE_UNAVAILABLE | 503 | source_timeout | 10s |
UPSTREAM_UNREACHABLE | SOURCE_UNAVAILABLE | 503 | source_unreachable | 10s |
FETCH_ERROR | SOURCE_UNAVAILABLE | 503 | source_rejected | 10s |
CONTRACT_ERROR | SOURCE_ERROR | 500 | parser_contract_broken | no |
NOT_IMPLEMENTED | SOURCE_ERROR | 500 | unsupported_operation | no |
PROXY_EXHAUSTED | SOURCE_UNAVAILABLE | 503 | proxy_exhausted / proxy_account_unpaid | no |
SITE_UNDER_MAINTENANCE | SOURCE_UNAVAILABLE | 503 | under_maintenance | 3600s |
PROXY_EXHAUSTED carries two reasons. proxy_account_unpaid is the proxy
answering CONNECT with 402 — our account is out of credit, and no exit
identity, retry or rotation can change that. proxy_exhausted is every other
way the tier can refuse to give us a URL. They share a type and a code
because clients act on both the same way; they differ in reason because on
2026-08-19 an unpaid balance was published to callers as "the source
challenged this request" and cost hours of anti-bot diagnosis. Neither message
is forwarded, and neither publishes details.worker: an unpaid invoice is our
infrastructure. This is the publicAs rule below, not a per-row opt-out.
UPSTREAM_BLOCKED narrows the same way, and its narrower words were in
production long before this table described them. The row's own
blocked_by_source is what an unclassified refusal publishes and stays valid;
the two below say which refusal, because they are fixed by different things:
reason | What it means | Where to look |
|---|---|---|
replay_blocked | A credential minted fine and the source refused the replay that used it | the replay IP, header consistency, pacing |
soft_block_shell | Yelp's own case: a 200 of the right size carrying no content at all | retry — it is per-request, not per-credential |
replay_blocked is the one to recognise, because it is published by nine site
route modules (clutch, indeed, yelp, wellfound, trustpilot, tripadvisor,
google_maps, glassdoor and more) and it means the mint was not the problem.
Reading it as a mint failure sends the next reader to the browser and the proxy
tier when the credential in hand was good.
soft_block_shell is the worst failure shape available and the reason it has
its own word: every check short of reading the content calls a shell a success.
See is_shell in apps/worker/src/sites/yelp/parsers.py.
source_unreachable is the opposite direction and it matters just as much:
Clutch publishes it when a request exhausts its exit-rotation budget without the
site ever answering. That is our tunnel, not their anti-bot, and it is kept
distinct for the same reason proxy_account_unpaid is — a dead proxy reported
as a block sends a reader to the wrong system.
mint_failed narrows further on every route that mints in a browser. This is
not a Google Maps-only vocabulary — the shared credential pool
(BrowserCredentialPool._mint, src/core/browser_credential.py) publishes it
for every site built on that pool (glassdoor, booking, trustpilot,
tripadvisor, yelp and more), and BrowserSessionPool._mint
(src/core/browser_fetcher.py, e.g. Indeed) publishes the reasons that apply
to a browser it keeps in the request path. The row's own word is still what an
unclassified failure publishes; the narrower ones say which half of the mint
failed, because the halves point at different suspects and are not
distinguishable from outside the container otherwise:
reason | What it means | Where to look |
|---|---|---|
mint_capture_miss | The browser issued no matching request at all | the page, the deep link, the exit IP |
mint_incomplete | Google Maps reviews' own case: it captured a request but not every load-bearing header, with no challenge involved | the capture selector |
mint_challenge_unsolved | The pool's case: a challenge ran and the proof cookie it issues is absent from the jar | the exit identity, the proxy tier |
mint_unvalidated | A complete credential replayed and the source served nothing | impersonation, header consistency, replay IP |
mint_transport | The minter service could not be reached or errored | the minter container |
Google Maps reviews has its own _mint and publishes mint_capture_miss,
mint_incomplete, mint_unvalidated and mint_transport — it has no
mint_challenge_unsolved case, because it never separates "no proof cookie"
from "missing header" the way the pool does. Every pool-backed site is the
opposite: it has no mint_incomplete case, because a captured request with a
missing proof cookie there IS the challenge-unsolved case. A route that has
not been wired to read reason off its mint exception keeps publishing
mint_failed, which stays valid.
SITE_UNDER_MAINTENANCE is the one row that is nobody's failure. The site is
parked on purpose — SITE_MAINTENANCE names it, the API refuses before it calls
the worker, and the worker refuses again for direct callers. It is deliberately
NOT enabled: false in sites.yaml, which means there is no working
implementation at all: that unmounts the route, so a caller gets a 404 saying
"this never existed", with no retry hint and nothing to bring a well-behaved
actor back. Retryable, unlike PROXY_EXHAUSTED, because a park ends without the
caller doing anything; the hour-long Retry-After is what stops that becoming a
hot loop. Its message IS forwarded — under_maintenance is not an internal
reason, and naming the parked surface is more useful to a caller than the house
sentence.
Two rows are deliberately non-retryable despite a 5xx. CONTRACT_ERROR is a
shape change — rotating identity cannot fix it, and a retryable classification
would have callers retrying a broken parser forever. PROXY_EXHAUSTED clears
only when a human tops the account up, so inviting backoff sends clients at a
wall.
Message forwarding is the default. It used to be opt-in for three rows, and
the other fifteen published the registry's sentence — so "tripadvisor: detail refused after 3 exits" reached a caller as "The data source could not complete
the request." and an operator had to read a log to learn anything. Every string
in apps/worker/src/api/errors.py is one we wrote, so it is published.
Three things still hold it back, and only three:
- A source path or a vendor name. A message matching either falls back to the row's sentence. This is the whole remaining secret: which proxy network, solver or reseller sits behind a route.
- An internal reason — one the registry marks
publicAsbecause the true slug discloses infrastructure (proxy_exhausted) or how we detect a block (replay_blocked,soft_block_shell). Those four publish the PUBLIC sibling's sentence and nodetails.worker, because publishing "our proxy account is out of credit" besidereason: "operator_action_required"would undo the swap in the same response. /fetch, which never forwards at all; echoing its SSRF validator would confirm what a prober reached.
The safe half of the worker's context is published as details.worker:
site, route, operation, status, attempt, retried. A whitelist, not a
denylist — context is where the worker is told to put everything private.
error_type is deliberately excluded even though the logs carry it: its values
are the ones that name infrastructure (proxy_out_of_credit is a real one).
The worker stamps site and route onto every AppError centrally, from the
attribution ContextVar, so a raise site does not have to remember to.
Parsing ownership differs by site: LinkedIn's worker responses are already
parsed by the worker itself, so linkedin.service.ts calls worker.call(...)
directly and there is no linkedin.normalizer.ts — the API is a thin proxy.
Instagram and Meta responses are parsed and validated in the API's own
normalizers (instagram.normalizer.ts, meta.normalizer.ts).
Route-level error docs
Every route documents 400, 429, 500, and 503 via defaultError
(utils/openapi.utils.ts), so /openapi.json and /llm.txt — and therefore
docs.toolzerhub.com, which is built from the spec — already carry this
contract. Don't hand-write per-route error docs.
Part 2 — API consumers
Defined in types/response.schema.ts, built by response.utils.ts. This is
ToolzerHub's own stable error envelope (RFC-9457-inspired, not a strict
implementation).
There is no success boolean — the HTTP status line is the source of truth.
- Success:
{ "data": ... }—datacan legitimately benull(a documented not-found for a single-item lookup). Don't treatnulldata as an error. - Error:
{ "error": { "code", "status", "message", "retryable", "details" } }code— stable category slug (catalog below); the thing to switch on in client code.status— mirrors the HTTP status.message— human, developer-facing detail. Not for end-user display, not guaranteed stable.retryable—truemeans retry with backoff is sensible (SOURCE_UNAVAILABLE,RATE_LIMITED, internalNETWORK_ERROR);falsemeans don't retry as-is.details— structured extras ornull(e.g. field-level validation issues on a 400). Third-party/upstream internals are never here.
A retryable error that knows how long to wait also sends a Retry-After header
(whole seconds, never 0), carrying the same interval as details.retryAfter.
Honour it rather than the body if you already have retry machinery that reads
the header.
Every response carries an X-Toolzer-Request-Id header (never in the body) —
quote it in support requests.
Code catalog
code | Typical status | Meaning |
|---|---|---|
INVALID_REQUEST | 400 / 422 | Bad or semantically invalid input |
UNAUTHORIZED | 401 | Missing/invalid API key |
FORBIDDEN | 403 | Not permitted |
NOT_FOUND | 404 | Resource or route not found |
RATE_LIMITED | 429 | Slow down and retry later |
SOURCE_ERROR | 500 | The upstream data source failed, terminal |
SOURCE_UNAVAILABLE | 503 | The upstream data source is down/throttled, retryable |
INTERNAL_ERROR | 500 | Something broke on our side |
NETWORK_ERROR | (internal only) | Transport failure; always surfaces as SOURCE_UNAVAILABLE by the time it reaches a client |
Client rules
- Auth: every
/v1route requires thex-api-keyheader (apiKeyAuthmiddleware,middlewares/apiKeyAuth.middleware.ts). A missing/wrong key returns401 UNAUTHORIZED. - Handle errors by
error.code, not by status alone. - Retry only when
error.retryable === true(i.e.SOURCE_UNAVAILABLEorRATE_LIMITED) — with backoff. Everything else (INVALID_REQUEST,UNAUTHORIZED,FORBIDDEN,NOT_FOUND,SOURCE_ERROR,INTERNAL_ERROR) will not succeed on retry without changing the request. { "data": null }on a lookup endpoint is a successful not-found, not an error — don't treat it as a failure or retry it.- Query params use human-friendly enums, not raw upstream codes — e.g.
LinkedIn
date_posted: "past_week",job_types: "full_time,contract",workplace: "remote"; TikTok Shopfilter_value: "5_star",sort_field: "price". Pass the enum string; the service maps it to the upstream's integer/code internally. - Discover the contract from generated docs, don't hand-maintain a client
spec:
/openapi.json(full OpenAPI doc),/llm.txt(plain-text summary meant for LLM consumption), or docs.toolzerhub.com (the rendered reference, built from the spec). - Quote
X-Toolzer-Request-Id(a response header, never in the body) when reporting an issue.