Skip to content

Error Handling

Stoma provides a structured error system that ensures every error response follows a consistent JSON format. Errors are never leaked as raw stack traces or plain text — the gateway boundary enforces a uniform shape for both expected policy rejections and unexpected failures.

GatewayError is the structured error class used throughout the gateway. It extends Error with an HTTP status code, a machine-readable error code, and optional response headers.

class GatewayError extends Error {
readonly statusCode: number;
readonly code: string;
readonly headers?: Record<string, string>;
constructor(
statusCode: number,
code: string,
message: string,
headers?: Record<string, string>,
);
}

Throw a GatewayError from any policy or handler to produce a structured error response:

throw new GatewayError(429, "rate_limited", "Too many requests", {
"retry-after": "60",
"x-ratelimit-limit": "100",
"x-ratelimit-remaining": "0",
});

All gateway errors produce a consistent JSON response body:

{
"error": "rate_limited",
"message": "Too many requests",
"statusCode": 429,
"requestId": "550e8400-e29b-41d4-a716-446655440000"
}

The ErrorResponse interface:

interface ErrorResponse {
/** Machine-readable error code (e.g. "rate_limited", "unauthorized"). */
error: string;
/** Human-readable error description. */
message: string;
/** HTTP status code (e.g. 401, 429, 503). */
statusCode: number;
/** Request ID for tracing, when available. */
requestId?: string;
}

The requestId field is included whenever the error occurs after the context injector has run (i.e., on a matched route). Errors that occur before context injection (e.g., unmatched routes) omit this field.

Custom headers from the GatewayError (such as Retry-After or X-RateLimit-*) are merged into the HTTP response headers.

Each built-in policy throws GatewayError with specific status codes and error codes:

PolicyStatusError CodeScenarios
jwtAuth401unauthorizedMissing token, malformed JWT, expired token, invalid signature, issuer/audience mismatch, none algorithm
jwtAuth502jwks_errorJWKS endpoint unreachable or returns non-200
apiKeyAuth401unauthorizedMissing API key
apiKeyAuth403forbiddenInvalid API key (failed validation)
basicAuth401unauthorizedMissing or malformed credentials, invalid username/password
oauth2401unauthorizedMissing access token, token validation failed, token not active
oauth2403forbiddenInsufficient scope (token missing required scopes)
oauth2500config_errorNeither introspectionUrl nor localValidate configured
oauth2502introspection_errorIntrospection endpoint returned non-200
rbac403forbiddenUser role not in allowed roles, or user missing required permissions

Authentication errors from basicAuth include a WWW-Authenticate header to prompt browser credential dialogs:

{
"error": "unauthorized",
"message": "Invalid credentials",
"statusCode": 401
}

Response header: WWW-Authenticate: Basic realm="Restricted"

PolicyStatusError CodeHeaders
rateLimit429rate_limitedX-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After

The rate limit policy sets X-RateLimit-* headers on every response (not just rejections), so clients can monitor their remaining quota:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 45
Retry-After: 45
PolicyStatusError CodeScenarios
ipFilter403forbiddenClient IP not in allowlist, or client IP in denylist
geoIpFilter403geo_deniedCountry code not in allowlist (allow mode), or country code in denylist (deny mode)
sslEnforce301(redirect)HTTP request redirected to HTTPS (when redirect: true, the default)
sslEnforce403ssl_requiredHTTP request blocked (when redirect: false)
requestLimit413request_too_largeContent-Length header exceeds configured maxBytes
dynamicRouting404no_routeNo routing rule matched and fallthrough is false
interrupt(configurable)(n/a)Returns a static response with the configured statusCode (default 200) when the condition predicate returns true; does not throw GatewayError
PolicyStatusError CodeScenarios
jsonThreatProtection400json_threatJSON exceeds maximum depth, maximum key count, maximum string length, or maximum array size
jsonThreatProtection400invalid_jsonRequest body is not valid JSON
jsonThreatProtection413body_too_largeRequest body exceeds configured maxBodySize
regexThreatProtection400threat_detectedRequest path, query string, headers, or body matches a configured regex pattern
PolicyStatusError CodeScenarios
requestValidation400validation_failedRequest body fails user-provided validation function, or body is not valid JSON
PolicyStatusError CodeScenarios
overrideMethod400invalid_method_overrideOverride header specifies a method not in allowedMethods
PolicyStatusError CodeHeaders
circuitBreaker503circuit_openRetry-After
timeout504gateway_timeout
httpCallout502callout_failedExternal callout fetch failed or returned non-2xx (when no custom onError is provided)

The circuit breaker includes a Retry-After header indicating how many seconds until the circuit transitions to the half-open state.

SourceStatusError CodeScenarios
URL upstream502upstream_errorrewritePath changed the origin (SSRF protection)
Service Binding502upstream_errorBinding not available in Worker environment

The gateway processes errors in a specific order:

When a GatewayError is thrown (by a policy or handler), the global error handler converts it to a structured JSON response via errorToResponse(). The request ID is included if available from the PolicyContext.

If GatewayConfig.onError is provided, it receives the error and the Hono context. The custom handler has full control over the response:

const gateway = createGateway({
onError: (error, c) => {
// Log to external service
console.error("Gateway error:", error);
// Return custom response
return new Response(
JSON.stringify({
error: "service_error",
message: "Something went wrong",
statusCode: 500,
}),
{ status: 500, headers: { "content-type": "application/json" } },
);
},
routes: [/* ... */],
});

When onError is defined, it takes precedence over the default error handling for all errors, including GatewayError instances.

Errors that are not GatewayError instances (and when no custom onError is defined) produce a generic 500 response. Internal error details are never leaked to the client:

{
"error": "internal_error",
"message": "An unexpected error occurred",
"statusCode": 500,
"requestId": "550e8400-e29b-41d4-a716-446655440000"
}

The default message can be customized via GatewayConfig.defaultErrorMessage. The actual error is logged to console.error with the gateway name, HTTP method, and path for operator debugging.

Requests that do not match any registered route receive a structured 404 response (not Hono’s default plain-text 404):

{
"error": "not_found",
"message": "No route matches GET /unknown/path",
"statusCode": 404,
"gateway": "my-api"
}

Configuration errors are thrown at gateway construction time (inside createGateway()), before any requests are handled. These are programming errors, not runtime failures:

ConditionError CodeMessage
No routes providedconfig_error”Gateway requires at least one route”
Unknown upstream typeconfig_error"Unknown upstream type: {type}"
Invalid URL upstream targetJavaScript TypeErrorThrown by new URL(target)
jwtAuth missing secret and jwksUrlconfig_error”jwtAuth requires either ‘secret’ or ‘jwksUrl‘“
oauth2 missing introspectionUrl and localValidateconfig_error”oauth2 requires either introspectionUrl or localValidate”

Config errors are GatewayError instances (except for invalid URL targets, which throw a native TypeError). They should be caught during development and CI, not at runtime. The oauth2 config error is thrown at request time (status 500) rather than construction time because the validation happens inside the policy handler.