This page is the source-aligned reference for Callora backend error responses.
It documents the shared errorHandler response envelope, every error class in
src/errors/index.ts, the /v1/call gateway/proxy failure mapping, and the
billing/Soroban error mapping. It is documentation-only and does not describe
any runtime behavior that is not present in the current source.
The standard envelope applies to errors that reach the shared Express errorHandler. It does not wrap every response served by the backend.
For /v1/call proxy requests, an upstream HTTP response is streamed back to the
caller with the upstream status, upstream body, and safe upstream headers after
hop-by-hop headers are stripped. Those proxied upstream responses are not
converted into Callora's standard error envelope, even if the upstream status is
4xx or 5xx.
For generated Callora errors, the requestId field is read from req.id. If no
middleware or route has attached req.id, the error handler serializes
"unknown". The route-local proxy UUID used for upstream x-request-id
forwarding is separate from req.id unless application code explicitly wires
them together.
Some middleware can write responses directly instead of passing an AppError to
the shared handler. This page calls those cases out when they are adjacent to
the gateway or billing flows; direct middleware responses may not include requestId
or the exact standard envelope shape.
Errors handled by src/middleware/errorHandler.ts are returned as JSON:
{
"code": "BAD_GATEWAY",
"message": "Bad Gateway: upstream unreachable",
"requestId": "req_123"
}The HTTP status is carried by the HTTP response status line, not by a status
field in the JSON body. For AppError instances, the handler uses the error's
statusCode and explicit code; if an AppError has no code, the handler
derives one from the status. For non-AppError errors, it uses a numeric
err.status when present, otherwise 500, and derives the response code from
that status.
In production, unexpected non-AppError messages are masked to "Internal server error". AppError messages are not masked by the error handler.
details is optional. It is currently included for validation errors and any error-like object with an array details property:
{
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"requestId": "req_123",
"details": [
{
"field": "body.endpoints[0].path",
"message": "Required",
"code": "INVALID_TYPE"
}
]
}Every subclass accepts an optional custom code argument. The table lists the
default response behavior when the class is constructed without a code override.
AppError is the base class: it has a default status of 500, but it does not
set a default instance code; the shared handler derives the body code from the
status when code is omitted.
| Class | HTTP status | Default body code | Default message | Meaning |
|---|---|---|---|---|
AppError |
500 by constructor default |
INTERNAL_SERVER_ERROR when code is omitted and status is 500 |
caller-supplied | Base application error type. Prefer a specific subclass for public route errors. |
BadRequestError |
400 |
BAD_REQUEST |
Bad request |
The request is malformed, missing required input, or otherwise invalid. |
UnauthorizedError |
401 |
UNAUTHORIZED |
Unauthorized |
Authentication is missing, malformed, or invalid. |
ForbiddenError |
403 |
FORBIDDEN |
Forbidden |
The caller is authenticated but not allowed to perform the action. |
NotFoundError |
404 |
NOT_FOUND |
Not found |
The requested resource does not exist. |
PaymentRequiredError |
402 |
PAYMENT_REQUIRED |
Payment Required |
The caller has insufficient balance or payment is otherwise required. |
TooManyRequestsError |
429 |
TOO_MANY_REQUESTS |
Too Many Requests |
The caller exceeded a rate limit. |
ConflictError |
409 |
CONFLICT |
Conflict |
The request conflicts with existing state. |
InternalServerError |
500 |
INTERNAL_SERVER_ERROR |
Internal server error |
An internal service or invariant failed. |
BadGatewayError |
502 |
BAD_GATEWAY |
Bad Gateway |
The gateway could not obtain a valid upstream or dependency response. |
ServiceUnavailableError |
503 |
SERVICE_UNAVAILABLE |
Service unavailable |
A dependency or service is temporarily unavailable. |
GatewayTimeoutError |
504 |
GATEWAY_TIMEOUT |
Gateway Timeout |
A dependency or upstream service did not respond before its timeout. |
The examples below assume req.id === "req_123" when the error reaches the handler.
[
{
"class": "AppError",
"status": 500,
"body": {
"code": "INTERNAL_SERVER_ERROR",
"message": "Base application error",
"requestId": "req_123"
}
},
{
"class": "BadRequestError",
"status": 400,
"body": {
"code": "BAD_REQUEST",
"message": "Bad request",
"requestId": "req_123"
}
},
{
"class": "UnauthorizedError",
"status": 401,
"body": {
"code": "UNAUTHORIZED",
"message": "Unauthorized",
"requestId": "req_123"
}
},
{
"class": "ForbiddenError",
"status": 403,
"body": {
"code": "FORBIDDEN",
"message": "Forbidden",
"requestId": "req_123"
}
},
{
"class": "NotFoundError",
"status": 404,
"body": {
"code": "NOT_FOUND",
"message": "Not found",
"requestId": "req_123"
}
},
{
"class": "PaymentRequiredError",
"status": 402,
"body": {
"code": "PAYMENT_REQUIRED",
"message": "Payment Required",
"requestId": "req_123"
}
},
{
"class": "TooManyRequestsError",
"status": 429,
"body": {
"code": "TOO_MANY_REQUESTS",
"message": "Too Many Requests",
"requestId": "req_123"
}
},
{
"class": "ConflictError",
"status": 409,
"body": {
"code": "CONFLICT",
"message": "Conflict",
"requestId": "req_123"
}
},
{
"class": "InternalServerError",
"status": 500,
"body": {
"code": "INTERNAL_SERVER_ERROR",
"message": "Internal server error",
"requestId": "req_123"
}
},
{
"class": "BadGatewayError",
"status": 502,
"body": {
"code": "BAD_GATEWAY",
"message": "Bad Gateway",
"requestId": "req_123"
}
},
{
"class": "ServiceUnavailableError",
"status": 503,
"body": {
"code": "SERVICE_UNAVAILABLE",
"message": "Service unavailable",
"requestId": "req_123"
}
},
{
"class": "GatewayTimeoutError",
"status": 504,
"body": {
"code": "GATEWAY_TIMEOUT",
"message": "Gateway Timeout",
"requestId": "req_123"
}
}
]When a non-AppError error reaches the handler with a numeric status, or when an AppError reaches the handler with no explicit code, the handler derives the code from the status.
| Status | Derived code |
|---|---|
400 |
BAD_REQUEST |
401 |
UNAUTHORIZED |
402 |
PAYMENT_REQUIRED |
403 |
FORBIDDEN |
404 |
NOT_FOUND |
408 |
REQUEST_TIMEOUT |
409 |
CONFLICT |
413 |
REQUEST_BODY_TOO_LARGE |
415 |
UNSUPPORTED_MEDIA_TYPE |
422 |
UNPROCESSABLE_ENTITY |
429 |
TOO_MANY_REQUESTS |
500 |
INTERNAL_SERVER_ERROR |
502 |
BAD_GATEWAY |
503 |
SERVICE_UNAVAILABLE |
504 |
GATEWAY_TIMEOUT |
For statuses not listed above, the fallback is INTERNAL_SERVER_ERROR for 5xx statuses and BAD_REQUEST otherwise. Body-parser 413 errors receive the message "Request body too large".
src/middleware/validate.ts defines ValidationError, which extends BadRequestError, sets the status to 400, overrides the code to VALIDATION_ERROR, and adds field-level details.
{
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"requestId": "req_123",
"details": [
{
"field": "query.network",
"message": "Invalid option: expected one of \"testnet\"|\"mainnet\"",
"code": "INVALID_VALUE"
}
]
}The modern upstream proxy is implemented by createProxyRouter() in src/routes/proxyRoutes.ts. It registers ALL /v1/call/:apiSlugOrId/* and ALL /v1/call/:apiSlugOrId.
Gateway API-key authentication runs before the proxy handler. It can reject a
request before handleProxy() starts. The middleware reads X-Api-Key first;
if that header is absent, it parses Authorization: Bearer <api_key>. A
malformed Authorization header therefore causes 401 only when X-Api-Key is
not present.
| Condition | HTTP status | Code | Error class | Notes |
|---|---|---|---|---|
Missing API key, or malformed Authorization header when X-Api-Key is absent |
401 |
UNAUTHORIZED |
UnauthorizedError |
The exact message is Unauthorized: missing API key or Unauthorized: malformed Authorization header. |
| Unknown API slug or ID | 404 |
NOT_FOUND |
NotFoundError |
Message is Not Found: unknown API. |
| API key not found, invalid, incomplete, or not authorized for the resolved API | 401 |
UNAUTHORIZED |
UnauthorizedError |
The exact message describes the failed check. |
| Revoked API key | 403 |
FORBIDDEN |
ForbiddenError |
The current message text is Unauthorized: API key has been revoked, but the status and code are forbidden. |
| Condition | HTTP status | Code | Error class | Notes |
|---|---|---|---|---|
| Gateway authentication context is unexpectedly missing after auth middleware | 500 |
GATEWAY_AUTH_CONTEXT_MISSING |
InternalServerError |
Internal invariant failure before proxying. |
| Rate limiter rejects the API key | 429 |
TOO_MANY_REQUESTS |
TooManyRequestsError |
The route sets Retry-After to the retry delay rounded up to whole seconds. |
Pre-proxy balance check returns <= 0 |
402 |
PAYMENT_REQUIRED |
PaymentRequiredError |
Message is Payment Required: insufficient balance. |
| Resolved upstream target fails validation or allowlist checks | 502 |
UPSTREAM_TARGET_BLOCKED |
BadGatewayError |
The message is the validation error message when available, otherwise Configured upstream target is not allowed. |
The proxy maintains an internal upstreamStatus value for metrics and usage recording:
- Initialize
upstreamStatusto502before callingfetch(). - If
fetch()resolves with an HTTP response, setupstreamStatus = upstreamRes.status, stop the upstream timer with outcomesuccess, forward safe response headers, set the HTTP response status to the upstream status, and stream the upstream body. - If
fetch()throwsDOMExceptionwithname === "TimeoutError", setupstreamStatus = 504, stop the timer with outcometimeout, and throwGatewayTimeoutError('Upstream service timed out'). - If
fetch()throwsTypeErrorwith Undici codeUND_ERR_CONNECT_TIMEOUT, handle it the same way as a timeout:504andGATEWAY_TIMEOUT. - For any other fetch, DNS, connection, or transport failure, set
upstreamStatus = 502, stop the timer with outcomeerror, and throwBadGatewayError('Bad Gateway: upstream unreachable').
| Event | HTTP status returned by Callora | Code | Error class | Body behavior |
|---|---|---|---|---|
Upstream returns an HTTP response, including 4xx or 5xx |
upstream status | not generated by Callora | none | The proxy streams the upstream body and safe headers. |
fetch() throws DOMException with name === "TimeoutError" |
504 |
GATEWAY_TIMEOUT |
GatewayTimeoutError |
Standard error envelope. |
fetch() throws TypeError with code UND_ERR_CONNECT_TIMEOUT |
504 |
GATEWAY_TIMEOUT |
GatewayTimeoutError |
Standard error envelope. |
| Any other fetch/connect failure | 502 |
BAD_GATEWAY |
BadGatewayError |
Standard error envelope. |
For generated 502 and 504 proxy errors, the JSON body does not include upstreamStatus,
the raw upstream response body, raw upstream error payload, or a Soroban revert reason.
If the upstream actually returns an HTTP response, the proxy forwards that
response instead of generating the standard envelope.
Example proxy request:
curl -i \
-H 'X-Api-Key: <gateway_api_key>' \
'http://localhost:3000/v1/call/weather-api/forecast'Example generated timeout response when no request id middleware populated req.id:
HTTP/1.1 504 Gateway Timeout
Content-Type: application/json; charset=utf-8{
"code": "GATEWAY_TIMEOUT",
"message": "Upstream service timed out",
"requestId": "unknown"
}Example generated unreachable-upstream response when no request id middleware populated req.id:
HTTP/1.1 502 Bad Gateway
Content-Type: application/json; charset=utf-8{
"code": "BAD_GATEWAY",
"message": "Bad Gateway: upstream unreachable",
"requestId": "unknown"
}The legacy ALL /api/gateway/:apiId route also maps generated upstream timeouts
to 504 and other generated upstream failures to 502, but it performs API-key
lookup, credit deduction, and usage recording in the legacy route flow. The
/v1/call mapping above is the primary gateway/proxy reference.
Billing routes are implemented in src/routes/billing.ts. Soroban RPC failures
are represented by SorobanRpcError categories in
src/services/sorobanBilling.ts and then converted to AppError subclasses by
the billing route.
| Soroban category | HTTP status | Response code | Error class | Meaning |
|---|---|---|---|---|
INSUFFICIENT_BALANCE |
402 |
INSUFFICIENT_BALANCE |
PaymentRequiredError |
On-chain or pre-flight balance is too low. |
TIMEOUT |
504 |
SOROBAN_RPC_TIMEOUT |
GatewayTimeoutError |
The Soroban RPC request timed out, was aborted, or otherwise matched the timeout category. |
CONTRACT_ERROR |
502 |
SOROBAN_RPC_ERROR |
BadGatewayError |
The contract rejected the call, simulation failed, or the failure matched contract/wasm classification. |
NETWORK_ERROR |
502 |
SOROBAN_RPC_ERROR |
BadGatewayError |
Soroban transport, HTTP, or missing-result failures. |
POST /api/billing/deduct uses requireAuth before the route handler.
Authentication failures are passed through the shared handler as 401
responses. Depending on the auth failure, the response code can be the default
UNAUTHORIZED or one of the route-auth overrides: INVALID_AUTH_HEADER,
MISSING_TOKEN, INVALID_TOKEN, MISSING_CLAIMS, TOKEN_EXPIRED, or
TOKEN_NOT_ACTIVE.
The same route also uses idempotencyMiddleware. Two idempotency conflicts are
written directly by that middleware instead of being passed to errorHandler,
so their JSON body is { "error", "message", "code" } and does not include
requestId:
| Idempotency condition | HTTP status | Response code | Body shape |
|---|---|---|---|
| Existing idempotency key with different request hash | 409 |
IDEMPOTENCY_CONFLICT |
Direct middleware JSON response. |
Existing idempotency key is still marked started |
409 |
IDEMPOTENCY_IN_PROGRESS |
Direct middleware JSON response. |
POST /api/billing/deduct maps unsuccessful BillingService.deduct() result messages before falling back to a generic billing failure:
| Route condition | HTTP status | Response code | Error class | Notes |
|---|---|---|---|---|
| Missing authenticated user | 401 |
UNAUTHORIZED |
UnauthorizedError |
Auth middleware should normally prevent this. |
Invalid requestId, apiId, endpointId, apiKeyId, amountUsdc, or idempotencyKey |
400 |
BAD_REQUEST |
BadRequestError |
Each validation failure has a field-specific message. |
| Database pool is unavailable | 500 |
DATABASE_NOT_AVAILABLE |
InternalServerError |
Route-specific code override. |
Failure message contains insufficient balance or insufficient funds |
402 |
INSUFFICIENT_BALANCE |
PaymentRequiredError |
Message is preserved from the billing result. |
Failure message contains timeout or timed out |
504 |
SOROBAN_RPC_TIMEOUT |
GatewayTimeoutError |
Message is preserved from the billing result. |
Failure message contains balance check failed, contract, or network |
502 |
SOROBAN_RPC_ERROR |
BadGatewayError |
Message is preserved from the billing result. |
| Any other unsuccessful deduction result | 500 |
BILLING_DEDUCTION_FAILED |
InternalServerError |
Response message is Billing deduction failed. |
GET /api/billing/request/:requestId uses these route-specific errors:
| Route condition | HTTP status | Response code | Error class |
|---|---|---|---|
| Missing authenticated user | 401 |
UNAUTHORIZED |
UnauthorizedError |
Missing or empty requestId param |
400 |
BAD_REQUEST |
BadRequestError |
| Database pool is unavailable | 500 |
DATABASE_NOT_AVAILABLE |
InternalServerError |
| Billing request is not found | 404 |
BILLING_REQUEST_NOT_FOUND |
NotFoundError |
The billing error envelope does not add a structured raw Soroban category, raw
RPC payload, revert-reason field, or details array. It exposes the mapped HTTP
status, stable code, message, and requestId supplied by the shared error
handler. The message can contain the normalized Soroban or billing error
message, but consumers should branch on code and HTTP status rather than
parsing the message.
Example insufficient-balance request:
curl -i -X POST 'http://localhost:3000/api/billing/deduct' \
-H 'Authorization: Bearer <jwt>' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: bill_req_123' \
-d '{
"requestId": "bill_req_123",
"apiId": "api_001",
"endpointId": "forecast",
"apiKeyId": "key_001",
"amountUsdc": "0.10"
}'Example insufficient-balance response:
HTTP/1.1 402 Payment Required
Content-Type: application/json; charset=utf-8{
"code": "INSUFFICIENT_BALANCE",
"message": "Insufficient balance: required 1000000 units, available 0",
"requestId": "req_123"
}Example Soroban timeout response:
HTTP/1.1 504 Gateway Timeout
Content-Type: application/json; charset=utf-8{
"code": "SOROBAN_RPC_TIMEOUT",
"message": "Soroban RPC request timed out",
"requestId": "req_123"
}