Errors
Stable minimums to depend on across Keel’s public runtime surfaces:
- HTTP status code
- machine-readable code
- human-readable message
- route-specific body shape
Do not assume a single universal error envelope across permit, execution, proxy, and request-level API failures.
If you need the associated permit id on execution routes, read the x-keel-permit-id response header. The execution body keeps governance focused on the decision outcome.
Error shapes by surface
| Surface | Typical result shape | Parse path | Notes |
|---|---|---|---|
POST /v1/permits deny decision | Permit object, usually not a top-level error object | decision, reason_code, reason_detail | Permit denials are decision results, not execution failures, and do not include an error object. |
POST /v1/executions denied response | Normalized execution envelope with status: "denied" and error | status, error.code, error.message, routing.reason_code | Denied executions stay inside the normalized execution envelope. |
POST /v1/executions failed response | Normalized execution envelope with status: "failed" and error | status, error.code, error.message, routing.reason_code | Provider or downstream execution failures still return an execution object. |
POST /v1/execute denied or failed response | Normalized execution envelope with error, plus resolved | status, error.code, error.message, routing.reason_code, resolved | Same parsing model as /v1/executions, with one extra resolved block. |
| Transport, framework, or top-level API error | Top-level { "error": { ... } } object | error.code, error.message, error.details | Use this shape for validation, auth, not-found, idempotency-conflict, and similar request-level failures before an execution object exists. |
POST /v1/proxy/* provider proxy error | Provider-native error object | Provider-native path such as error.message | Proxy routes do not return the normalized execution envelope. Parse them the same way you would parse the provider directly. |
For the execution routes:
- A denied
/v1/executionsresponse is a normalized execution envelope withstatus: "denied". - A failed
/v1/executionsresponse is a normalized execution envelope withstatus: "failed". - A transport, framework, or top-level API error is a standalone
{ "error": ... }object because no execution envelope was created. - A provider proxy error is provider-native, not normalized.
- Permit denials are decision results, not execution failures, and do not include an
errorobject.
Examples by surface
The permit decision examples below reflect the current documented contract for permit decisions.
/v1/permits deny
HTTP status: 200
{
"id": "permit_01jyf4p6c3m7n9q2t5v8w1x4y",
"decision": "deny",
"reason_code": "policy.model_not_allowed",
"reason_detail": {
"category": "policy",
"kind": "model_not_allowed",
"outcome": "deny"
},
"message": "The requested model is not allowed for this project.",
"actions": [
{
"type": "deny",
"message": "The requested model is not allowed for this project."
}
],
"metadata": {
"evaluated_at": "2026-03-26T18:43:02Z"
}
}Parsing note: Permit denials are decision results, not execution failures, and do not include an error object. Read decision and reason_code; do not expect error.code.
/v1/permits allow
HTTP status: 200
{
"id": "permit_01jyf4m3n8q2r6t9v1w5x7y0z",
"decision": "allow",
"actions": [
{
"type": "allow",
"message": "Allowed by base policy."
}
],
"metadata": {
"evaluated_at": "2026-03-26T18:42:11Z"
}
}Parsing note: Allow decisions are permit results too. Parse decision; reason_code is not set on allow decisions. Do not expect error.code.
/v1/executions denied response
HTTP status: 403
{
"id": "exec_01jv1jahqf3n9s2t4u6v8w0x1z",
"object": "execution",
"created_at": "2026-03-09T00:00:00Z",
"status": "denied",
"status_code": 403,
"output": null,
"routing": {
"requested_provider": "openai",
"requested_model": "gpt-4o-mini",
"selected_provider": "openai",
"selected_model": "gpt-4o-mini",
"reason_code": "explicit_request",
"fallback_occurred": false
},
"governance": {
"decision": "deny",
"reason": "...",
"actions": [
{
"type": "deny",
"message": "The request did not satisfy the configured project policy."
}
],
"constraints": null,
"budgets": null
},
"error": {
"code": "...",
"message": "The request did not satisfy the configured project policy."
}
}Parsing note: parse status, error.code, and routing.reason_code; denial is still an execution envelope.
/v1/executions execution failure
HTTP status: 502
{
"id": "exec_01jv1jahqf3n9s2t4u6v8w0x20",
"object": "execution",
"created_at": "2026-03-09T00:00:00Z",
"status": "failed",
"status_code": 502,
"output": null,
"routing": {
"requested_provider": "openai",
"requested_model": "gpt-4o-mini",
"selected_provider": "openai",
"selected_model": "gpt-4o-mini",
"reason_code": "explicit_request",
"fallback_occurred": false
},
"governance": {
"decision": "allow",
"reason": "ok",
"actions": [],
"constraints": null,
"budgets": null
},
"error": {
"code": "...",
"message": "The upstream provider request failed."
}
}Parsing note: treat this like a completed execution record with status: "failed" and read the failure from error.
/v1/execute denied response
HTTP status: 403
{
"id": "exec_01jv1jcm7r4p8t1u3v5w7x9y10",
"object": "execution",
"created_at": "2026-03-11T00:00:00Z",
"status": "denied",
"status_code": 403,
"output": null,
"routing": {
"requested_provider": "openai",
"requested_model": "gpt-4o-mini",
"selected_provider": "openai",
"selected_model": "gpt-4o-mini",
"reason_code": "explicit_request",
"fallback_occurred": false
},
"governance": {
"decision": "deny",
"reason": "...",
"actions": [
{
"type": "deny",
"message": "The request did not satisfy the configured project policy."
}
],
"constraints": null,
"budgets": null
},
"error": {
"code": "...",
"message": "The request did not satisfy the configured project policy."
},
"resolved": {
"provider": "openai",
"model": "gpt-4o-mini",
"alias": null
}
}Parsing note: parse /v1/execute denials exactly like /v1/executions, then read resolved if you need the final target metadata.
/v1/proxy/openai provider-style error
HTTP status: 400
{
"error": {
"message": "Unsupported parameter: 'max_completion_tokens'.",
"type": "invalid_request_error",
"param": "max_completion_tokens",
"code": "unsupported_parameter"
}
}Parsing note: parse this as an OpenAI-style provider error, not as a Keel execution envelope.
Generic top-level API error object
HTTP status: 409
{
"error": {
"code": "idempotency_conflict",
"message": "The same idempotency key was already used with a different semantic request.",
"details": {
"idempotency_key": "exec-sync-001"
}
}
}Parsing note: when the response is top-level { "error": ... }, no normalized execution object was created.
Status and retry matrix
Usual status means the documented status you should plan for in client logic, not an exhaustive guarantee for every deployment detail.
On /v1/permits, parse decision and reason_code; do not expect error.code. Allow decisions do not carry reason_code.
| HTTP status | Retryable? | Meaning |
|---|---|---|
200 | — | Success, or a permit decision (both allow and deny return 200 on permits). |
400 | No | The request body, headers, or field combination is invalid. |
401 | Conditionally | Missing or invalid credentials. Retry only after fixing auth. |
403 | No | Hard deny. The caller is not allowed to perform this action. Covers policy denials (policy.model_not_allowed, policy.rule_denied), cost cap denials (budget.daily_cap_exceeded, budget.monthly_cap_exceeded, budget.request_cap_exceeded), scope issues, and configuration problems. Does not cover throttle — see 429. |
404 | No | The requested resource does not exist for this project or route. |
409 | No | The request conflicts with an existing resource or replay contract. |
429 | Yes | Rate-limit throttle. Returned when a throttle_if_rate_exceeds policy rule trips (reason_code: budget.rate_limit_throttled). Includes a Retry-After header with the number of seconds to wait before retrying. Keel’s Python and JavaScript SDKs automatically parse this header and retry. |
500 | Yes | Keel failed internally. |
502 | Yes | The upstream provider request failed after governance approval. |
Read the error.code and error.message fields in the response body for machine-readable detail.
Permit reason codes
Every non-allow permit decision carries a reason_code from a locked vocabulary. These codes are stable API — they appear in permit responses, on the dashboard, in Timeline Replay, and in governance audit events. Filter on reason_code in your error handlers and audit queries; do not parse the human-readable message field.
| Code | Category | When it fires |
|---|---|---|
budget.request_cap_exceeded | Budget | Per-request cost cap exceeded |
budget.daily_cap_exceeded | Budget | Daily spend cap exceeded |
budget.weekly_cap_exceeded | Budget | Weekly spend cap exceeded |
budget.monthly_cap_exceeded | Budget | Monthly spend cap exceeded |
budget.quarterly_cap_exceeded | Budget | Quarterly spend cap exceeded |
budget.monthly_threshold_exceeded | Budget | Projected monthly spend exceeds configured threshold ratio |
budget.daily_spike_detected | Budget | Today’s projected spend exceeds baseline × multiplier |
budget.plan_quota_exceeded | Budget | Project has consumed its plan-tier request quota for the billing period |
budget.rate_limit_exceeded | Budget | Request rate exceeded — hard deny |
budget.rate_limit_throttled | Budget | Request rate exceeded — throttle with Retry-After |
budget.pricing_unavailable | Budget | Model has no pricing configured; request cannot be safely evaluated |
policy.model_not_allowed | Policy | Model not in the project’s or rule’s allowed list |
policy.rule_denied | Policy | A deny rule’s condition matched |
policy.review_required | Policy | A require_human_review rule’s condition matched |
budget.rate_limit_throttled is the only code that produces a 429 HTTP response. All other deny codes produce 403. Throttle responses include a Retry-After header; the reason_detail.outcome_detail field carries retry_after_seconds, window_seconds, limit, and observed for programmatic use.
Firewall denial reason codes (from the Prompt Firewall subsystem, e.g. prompt_firewall_blocked) are a separate system and are not part of this lexicon.