App-facing error envelope
Contract 2 codes and the HTTP refusal body for crouter cloud apps.
crouter cloud's app-facing HTTP endpoints use contract 2's error body. An app's own endpoints can use the same shape with origin: 'app'. The SDK publishes the fixed code tuples:
import { errorCodes, type ErrorEnvelope } from '@crouter/sdk/errors';
const row = errorCodes.invalid_request;
// { type: 'invalid_request_error', status: 400, retryable: false }
const body: ErrorEnvelope = {
error: {
code: 'invalid_request',
message: 'Missing prompt',
type: row.type,
origin: 'app',
retryable: row.retryable,
request_id: 'req-123',
param: 'prompt',
},
};
// Send body with HTTP status row.status.The body is { error: { code, message, details?, type, param?, origin, retryable, retry_after_s?, reset_at?, request_id, run_id?, question_id?, user_action?, scope? } }. origin is directory, router, runtime, provider, or app. type uses OpenAI's invalid_request_error, authentication_error, permission_error, rate_limit_error, or server_error. Status belongs only in the HTTP status line, not in the body. Send Retry-After when retry_after_s is set. Keep request_id in the body and echo it as a request-id response header. Do not expose stack traces in a 5xx message.
errorCodes is keyed by code. Each row gives that code's fixed contract-2 { type, status, retryable } tuple; the daemon renderer uses this module for type via @crouter/api. An unsatisfiable byte range is range_not_satisfiable (416), an upstream model failure is upstream_model_unavailable (503, retryable), and an upstream vendor sign-in refusal is upstream_refused (502). The renderer preserves the thrown HTTP status and retry decision; throw sites must choose a code whose tuple matches those values. If no existing code fits an app-specific failure, declare an app code with a consistent type/status and document it in the app's OpenAPI spec.
Not every error has a fixed tuple. provider_error carries the provider's varying 4xx status and is not in errorCodes; usage is a legacy socket/loopback code that becomes invalid_request on the app-facing listener. stream_gap, stream_dropped, and stream_error are stream protocol codes with beta-defined behavior, not ordinary static HTTP refusal rows. SDK-generated connection failures (daemon_unavailable, transport_error, daemon_request_interrupted, request_timeout, daemon_health_unavailable) are not daemon refusals. runtime_unreachable applies to a hosted router, runtime_provisioning, grant_removed, grant_suspended, refresh_token_expired, and refresh_token_reused are raised by the SDK for directory token responses (see When a refresh is refused), and wake_timeout is reserved for the later hosted sleep/wake stage; their presence in the table does not mean the local daemon emits them today.
ErrorEnvelope models the full app-facing response, not the SDK's permissive ErrorBody used to parse older and socket responses. A socket or v0 loopback response can have only code, message, and optional details (with run-layer fields in some cases); never assume type or request_id exists on those transports. APIError.status is separate from the parsed JSON body. See Errors for error classes, transport failures, and retry rules.