Error Handling & Status Codes
GraphQL errors
Section titled “GraphQL errors”Wetel’s API is a standard NestJS GraphQL (Apollo Server) service. Errors are not returned in the
data field — they show up in the top-level errors array of the GraphQL response, each with a
message and an extensions object.
{ "errors": [ { "message": "Agent #42 not found", "extensions": { "code": "NOT_FOUND", ... } } ]}Always branch your client’s error handling on errors[].extensions.code (or, failing that, the
exception’s HTTP-equivalent semantics below), not on parsing message text — message strings are
meant for humans and can change without notice.
A note on precision: Wetel’s backend relies on Apollo Server’s and @nestjs/graphql’s default
exception-to-extensions.code behavior, with one small addition since 2026-10-07: a “not found”
(HTTP 404 semantics) answers NOT_FOUND and a conflict (409) answers CONFLICT. Before that,
both arrived as INTERNAL_SERVER_ERROR with the real status only in extensions.status. Since 2026-10-21, an upstream failure of your own MCP server in listMcpTools answers BAD_GATEWAY (status 502), and a refused outbound URL answers BAD_REQUEST with extensions.originalError.code OUTBOUND_URL_REFUSED (see Refused URLs and an unreachable MCP server). In practice this means standard NestJS HTTP exceptions
map to predictable codes (unauthorized/forbidden-style exceptions to something like
UNAUTHENTICATED/FORBIDDEN, validation failures to BAD_USER_INPUT, unhandled errors to
INTERNAL_SERVER_ERROR), but because there’s no custom mapping layer to point to as the source of
truth, treat the exact code string as informative rather than a hard contract, and always log the
full extensions object during integration/debugging rather than hardcoding a single expected
value.
The exception vocabulary the API actually throws
Section titled “The exception vocabulary the API actually throws”Surveying the backend’s resolvers and services, errors are raised using a small, consistent set of standard NestJS exception classes — there is no bespoke error-code enum layered on top. In rough order of how often each shows up in the API:
BadRequestException— malformed input, a business-rule validation failure (e.g. a quota check, an invalid state transition). Most common exception in the codebase. For input validation,extensions.codeisBAD_REQUEST;extensions.originalError.messageis the array of constraint messages, and since 2026-10-07 the top-levelmessageis those messages joined with;(previously the genericBad Request Exception).ForbiddenException— the caller is authenticated but not allowed to do this. This is also what you get from most resolvers when no tenant is associated with the calling token (e.g. “No tenant assigned”).NotFoundException(extensions.codeNOT_FOUND) — the requested resource (agent, session, workflow, custom action, etc.) doesn’t exist, or doesn’t exist for your tenant — Wetel’s multi-tenancy model returns “not found” rather than a distinct “forbidden” for cross-tenant access attempts, so as not to confirm whether a given ID exists at all under another tenant.UnauthorizedException— authentication itself failed (missing/invalid/expired token).InternalServerErrorException— an unexpected failure the API couldn’t map to a more specific case (e.g. a downstream provider call failed unexpectedly during message processing). If you see this repeatedly for the same operation, it’s worth reporting — it usually means an edge case the API doesn’t yet handle explicitly.ConflictException(extensions.codeCONFLICT) — rare; used for a small number of state-conflict cases (e.g. attempting an operation against a resource in a state that doesn’t support it).
Rate limit refusals: TOO_MANY_REQUESTS
Section titled “Rate limit refusals: TOO_MANY_REQUESTS”Available on staging; production rollout pending. A GraphQL call refused by a rate limit answers extensions.code TOO_MANY_REQUESTS, a message such as Too many requests. Try again in about 2 minutes. and extensions.retryAfterSeconds (whole seconds to wait). extensions.status stays 429 and the HTTP status of the response is unchanged. Before this change the same refusal arrived as INTERNAL_SERVER_ERROR with a ThrottlerException message, so a client that matched on extensions.status 429 or on the message text keeps working; branch on the code and wait retryAfterSeconds before retrying. See Rate limiting.
Email not verified: EMAIL_NOT_VERIFIED
Section titled “Email not verified: EMAIL_NOT_VERIFIED”Available on staging; production rollout pending, and only when the platform has email-verification enforcement on (off by default). A short list of operations answers HTTP 403, extensions.code EMAIL_NOT_VERIFIED and the message Verify your email address first for an account whose email is not verified. Verify the address, then retry; see Email verification.
Compressed request bodies: HTTP 415
Section titled “Compressed request bodies: HTTP 415”A request with a Content-Encoding other than identity is refused with HTTP 415 and, when the application answers, {"statusCode":415,"code":"UNSUPPORTED_CONTENT_ENCODING"}; the public edge may answer a plain 415 first. It is not a GraphQL error and has no extensions.code. Not retryable as sent: resend the body without Content-Encoding. See Send plain request bodies.
Responses Wetel cannot read
Section titled “Responses Wetel cannot read”When Wetel calls a URL that you configured, it does not decompress Brotli, and its error names the refused encoding. Which calls refuse what:
| Wetel call | Asks for | Refuses |
|---|---|---|
webhook and action node requests (so Custom Actions), and their file fetches | Accept-Encoding: identity | any response with a Content-Encoding |
MCP connector calls (tool node, listMcpTools), externalSyncUrl deliveries, knowledge-base URL ingestion | Accept-Encoding: gzip, deflate | a Brotli (br) response; gzip and deflate still work |
The failure is an ordinary node or call failure: a failure edge handles it, and nodeTrace shows the error. Fix it on your side: turn off response compression for the endpoint Wetel calls, or answer with an uncompressed body when the request says Accept-Encoding: identity. A server that follows Accept-Encoding needs no change. We have not listed other outbound calls (for example calls to model providers or to Wetel’s own services) because they are not configured by you.
The specific case of a missing platform header
Section titled “The specific case of a missing platform header”A request missing the required x-huat-platform: customer header produces an error that can look
unrelated to authentication at first glance. This is a known, documented gotcha with its own
explanation and fix — see Authentication rather than duplicating it here. If you’re
seeing an unexplained rejection on every request from a new integration, check that header first.
Workflow run failures
Section titled “Workflow run failures”Failures inside a running workflow (a node erroring out mid-execution) are not the same thing as a GraphQL request error — the mutation that started the session can succeed while a later node in the workflow still fails. These surface through the event/subscription layer:
- The
NodeFailedEventsubscription event, if you’re subscribed to it. workflowRun.nodeTrace, which records what happened node-by-node (JWT-authenticated access only today).
Node trace statuses worth branching on
Section titled “Node trace statuses worth branching on”Each nodeTrace entry carries a status. Three of them mean materially different things, and are
worth handling distinctly rather than collapsing into “something failed”:
FAILED— that node threw, and nothing intercepted it, so the whole run stopped there.FAILED_HANDLED— atool,action,webhook,llm,sub_agentorset_variablenode (the last four since 2026-10-15) threw, but the graph has afailure-labeled edge, so the workflow routed down it and the run itself still completed. Not an outage; the author planned for it.BUDGET_EXCEEDED(added 2026-09-21) — the run exhausted its execution budget and the engine refused to dispatch the named node. Nothing went wrong with that node; it never ran. The run is persisted asFAILED, and the entry carriesbudgetLimit("maxNodeExecutions"or"deadline"),budgetNodeExecutionsandbudgetElapsedMsso the cause is readable without parsing the message string.VISIT_LIMIT_REACHED(added 2026-09-22) — the graph came back to a node that had already used up its ownmaxVisitsfor this run, so the engine refused to dispatch it again. LikeBUDGET_EXCEEDED, nothing went wrong with the node. Unlike it, this does not on its own mean the run failed — read the run’sstatus. The entry carriesvisitLimitandvisitCount. Three outcomes are possible: the node had a'loop_exhausted'edge and the run carried on down it (usuallyCOMPLETED); the node declaredmaxVisitsand had no escape edge, so the run isFAILED; or the node never declaredmaxVisits, in which case that branch simply stops and the rest of the graph continues — the long-standing behavior, which until now left no record at all. Full detail: Workflow Overview: Loops and re-entry and Workflows API: Loop re-entry andVISIT_LIMIT_REACHED.
A run’s ceilings are 250 node executions and 10 minutes of wall-clock time; a graph is
additionally capped at 200 nodes / 400 edges when it is saved or published, which surfaces as
an ordinary BadRequestException on the mutation rather than a run-time failure. Full detail:
Workflow Overview: Graph size and run limits
and Workflows API: Run limits and BUDGET_EXCEEDED.
A run that is waiting is not a run that failed
Section titled “A run that is waiting is not a run that failed”A run with status: "AWAITING_INPUT" paused at an
await_reply node and is waiting on the user’s next message.
It is neither complete nor broken, and it will never transition to COMPLETED — the reply is
executed as a separate run whose resumedFromRunId points back at it. Don’t build alerting
that treats a non-COMPLETED run as a failure without excluding this status; use
pendingAwaitReplies to see what is waiting
and until when.
Full details, event shapes, and query examples are already covered in Workflow API Reference and Troubleshooting — this page won’t duplicate them.
Practical guidance
Section titled “Practical guidance”- Always check
errors[].extensions.codefirst; fall back tomessageonly for logging/debugging. - Don’t assume a specific exception class always maps 1:1 to a specific business meaning across
every operation —
ForbiddenException, for instance, is reused for both “no tenant on this token” and genuine authorization failures. Read themessagefor the specific reason during development. - For a headless / no-UI integration (a bot, a backend
service), build retry/backoff logic around
InternalServerErrorException-class failures — not aroundBadRequestException/ForbiddenException/NotFoundException, which represent a request that will not succeed no matter how many times you retry it unchanged. - Rate-limited operations return their own error when a limit is exceeded — see Rate Limiting for exactly which operations are throttled today.