Skip to content

Error Handling & Status Codes

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 does not run a custom GraphQL exception filter or a custom formatError mapping — it relies on Apollo Server’s and @nestjs/graphql’s default exception-to-extensions.code behavior. 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.
  • 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 — 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 — 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).

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 node 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.