Skip to content

Error Handling & Status Codes

This content is not available in your language yet.

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.code is BAD_REQUEST; extensions.originalError.message is the array of constraint messages, and since 2026-10-07 the top-level message is those messages joined with ; (previously the generic Bad 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.code NOT_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.code CONFLICT) — 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).

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.

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.

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.

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 callAsks forRefuses
webhook and action node requests (so Custom Actions), and their file fetchesAccept-Encoding: identityany response with a Content-Encoding
MCP connector calls (tool node, listMcpTools), externalSyncUrl deliveries, knowledge-base URL ingestionAccept-Encoding: gzip, deflatea 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.

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 NodeFailedEvent subscription event, if you’re subscribed to it.
  • workflowRun.nodeTrace, which records what happened node-by-node (JWT-authenticated access only today).

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 — a tool, action, webhook, llm, sub_agent or set_variable node (the last four since 2026-10-15) threw, but the graph has a failure-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 as FAILED, and the entry carries budgetLimit ("maxNodeExecutions" or "deadline"), budgetNodeExecutions and budgetElapsedMs so 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 own maxVisits for this run, so the engine refused to dispatch it again. Like BUDGET_EXCEEDED, nothing went wrong with the node. Unlike it, this does not on its own mean the run failed — read the run’s status. The entry carries visitLimit and visitCount. Three outcomes are possible: the node had a 'loop_exhausted' edge and the run carried on down it (usually COMPLETED); the node declared maxVisits and had no escape edge, so the run is FAILED; or the node never declared maxVisits, 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 and VISIT_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.

  • Always check errors[].extensions.code first; fall back to message only 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 the message for 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 around BadRequestException/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.