# Errors & status codes (/docs/reference/errors)



Ductor speaks one error vocabulary across both protocols. Internally, code
returns **domain error sentinels**; the transport layer classifies each sentinel
to a Connect/gRPC code, an HTTP status, and a stable machine-readable error code,
then renders it as gRPC status details or RFC 9457 Problem JSON.

## The mapping, end to end [#the-mapping-end-to-end]

```mermaid
flowchart LR
  A["Domain sentinel"] --> B["Connect code"] --> C["HTTP status"] --> D["Error code"]
```

| Domain sentinel    | Connect code        | HTTP status | Error code       |
| ------------------ | ------------------- | ----------- | ---------------- |
| `ErrNotFound`      | `CodeNotFound`      | 404         | `NOT_FOUND`      |
| `ErrQuotaExceeded` | `ResourceExhausted` | 429         | `QUOTA_EXCEEDED` |
| `ErrConflict`      | `CodeAborted`       | 409         | `CONFLICT`       |

Anything not recognized as a domain error falls back to `CodeInternal` / HTTP
`500` / `INTERNAL` — infrastructure errors are never leaked verbatim to clients.

## Problem JSON (REST) [#problem-json-rest]

REST clients receive an RFC 9457 `application/problem+json` document. The body is
the `ProblemDetail` struct in `transport/exec/common/errors/problem_json.go`:

```json
{
  "type": "https://errors.ductor.io/QUOTA_EXCEEDED",
  "title": "Usage quota exceeded",
  "status": 429,
  "detail": "daily routing quota exceeded",
  "instance": "/api/v2/routing/route",
  "error_code": "QUOTA_EXCEEDED",
  "trace_id": "4f1c…",
  "request_id": "018f…",
  "retryable": false
}
```

| Field        | Always present | Meaning                                                                                       |
| ------------ | -------------- | --------------------------------------------------------------------------------------------- |
| `type`       | yes            | `https://errors.ductor.io/<ERROR_CODE>` — a stable, dereferenceable type URI                  |
| `title`      | yes            | The code's human description (from the error catalog)                                         |
| `status`     | yes            | HTTP status code                                                                              |
| `error_code` | yes            | Stable machine-readable code (see the catalog below)                                          |
| `retryable`  | yes            | Whether a retry can plausibly succeed                                                         |
| `detail`     | when domain    | Underlying message — only exposed for domain errors (see [Safe exposure](#what-gets-exposed)) |
| `instance`   | optional       | The request path/URI the error occurred on                                                    |
| `trace_id`   | optional       | Distributed-trace id; also sent as a response header                                          |
| `request_id` | optional       | Per-request id; also sent as a response header                                                |
| `errors[]`   | optional       | Field-level errors — each `{ field, description, value }`                                     |

Some errors add **domain enrichment** fields:

| Field             | Added for                                                     |
| ----------------- | ------------------------------------------------------------- |
| `cancellation_id` | Bulk-cancellation rejections — the matching cancellation row  |
| `reason`          | The operator-supplied reason on that cancellation row         |
| `run_id`          | In-flight idempotency duplicates — the original route attempt |

## Error code → HTTP status [#error-code--http-status]

| Error code                                                                | HTTP |
| ------------------------------------------------------------------------- | ---- |
| `VALIDATION_ERROR`, `INVALID_ARGUMENT`                                    | 400  |
| `UNAUTHENTICATED`                                                         | 401  |
| `PERMISSION_DENIED`                                                       | 403  |
| `NOT_FOUND`                                                               | 404  |
| `ALREADY_EXISTS`, `CONFLICT`, `DUPLICATE_DETECTED`, `DUPLICATE_IN_FLIGHT` | 409  |
| `RETURN_WINDOW_EXPIRED`                                                   | 410  |
| `PRECONDITION_FAILED`, `POOL_DISABLED`                                    | 412  |
| `PAYLOAD_TOO_LARGE`                                                       | 413  |
| `RATE_LIMITED`, `QUOTA_EXCEEDED`                                          | 429  |
| `CANCELED`                                                                | 499  |
| `INTERNAL`                                                                | 500  |
| `UNIMPLEMENTED`                                                           | 501  |
| `UNAVAILABLE`, `ROUTING_FAILED`, `NO_ELIGIBLE_RECIPIENTS`, `QUEUE_FULL`   | 503  |
| `TIMEOUT`                                                                 | 504  |

## The error catalog [#the-error-catalog]

Every wire code is registered in `transport/exec/common/errors` with a
`retryable` flag and an operational `severity` (used for alerting and logging).
The catalog is stable — codes and their semantics are part of the API contract.

| Error code               | Retryable | Severity |
| ------------------------ | --------- | -------- |
| `VALIDATION_ERROR`       | no        | warning  |
| `INVALID_ARGUMENT`       | no        | warning  |
| `NOT_FOUND`              | no        | warning  |
| `ALREADY_EXISTS`         | no        | warning  |
| `PERMISSION_DENIED`      | no        | warning  |
| `UNAUTHENTICATED`        | no        | warning  |
| `CANCELED`               | no        | warning  |
| `RATE_LIMITED`           | **yes**   | warning  |
| `QUOTA_EXCEEDED`         | no        | warning  |
| `CONFLICT`               | no        | warning  |
| `PRECONDITION_FAILED`    | no        | warning  |
| `PAYLOAD_TOO_LARGE`      | no        | warning  |
| `INTERNAL`               | no        | critical |
| `UNAVAILABLE`            | **yes**   | error    |
| `TIMEOUT`                | **yes**   | error    |
| `UNIMPLEMENTED`          | no        | warning  |
| `ROUTING_FAILED`         | **yes**   | error    |
| `NO_ELIGIBLE_RECIPIENTS` | **yes**   | warning  |
| `DUPLICATE_DETECTED`     | no        | warning  |
| `DUPLICATE_IN_FLIGHT`    | **yes**   | warning  |
| `RETURN_WINDOW_EXPIRED`  | no        | warning  |
| `QUEUE_FULL`             | **yes**   | warning  |
| `POOL_DISABLED`          | no        | warning  |

An unregistered code falls back to `critical` severity, non-retryable.

## Sentinel → Connect code → HTTP [#sentinel--connect-code--http]

| Connect code             | Error code               | HTTP | Representative sentinels                                                                                                |
| ------------------------ | ------------------------ | ---- | ----------------------------------------------------------------------------------------------------------------------- |
| `CodeNotFound`           | `NOT_FOUND`              | 404  | `ErrNotFound`, `ErrTenantNotFound`, `ErrClaimQueueNotFound`, `ErrStrategyNotFound`                                      |
| `CodeInvalidArgument`    | `INVALID_ARGUMENT`       | 400  | `ErrInvalidInput`, `ErrInvalidRequest`, `ErrClaimFilterInvalid`, `ErrTenantIDRequired`, `ErrConfigurationInvalid`       |
| `CodeAlreadyExists`      | `ALREADY_EXISTS`         | 409  | `ErrAlreadyExists`, `ErrIdempotencyConflict`, `ErrDuplicateDetected`                                                    |
| `CodeAborted`            | `CONFLICT`               | 409  | `ErrConflict`, `ErrItemAlreadyClaimed`, `ErrConcurrencyConflict`, `ErrIdempotencyInProgress`                            |
| `CodeUnauthenticated`    | `UNAUTHENTICATED`        | 401  | `ErrUnauthorized`, `ErrSAMLSessionInvalid`, `ErrAssertionReplayed`                                                      |
| `CodePermissionDenied`   | `PERMISSION_DENIED`      | 403  | `ErrForbidden`, `ErrNotClaimOwner`, `ErrTenantSuspended`, `ErrTenantDeleted`                                            |
| `CodeCanceled`           | `CANCELED`               | 499  | `ErrContextCanceled`                                                                                                    |
| `CodeDeadlineExceeded`   | `TIMEOUT`                | 504  | `ErrTimeout`, `ErrRoutingTimeout`, `ErrRenderTimeout`                                                                   |
| `CodeUnavailable`        | `UNAVAILABLE`            | 503  | `ErrServiceUnavailable`, `ErrLockAcquisitionFailed`                                                                     |
| `CodeUnavailable`        | `ROUTING_FAILED`         | 503  | `ErrRoutingFailed`                                                                                                      |
| `CodeUnavailable`        | `NO_ELIGIBLE_RECIPIENTS` | 503  | `ErrNoRecipientsAvailable`, `ErrNoEligibleCandidates`                                                                   |
| `CodeResourceExhausted`  | `QUOTA_EXCEEDED`         | 429  | `ErrQuotaExceeded`, `ErrDailyQuotaExceeded`, `ErrPoolsQuotaExceeded`, `ErrRulesQuotaExceeded`, `ErrClaimBudgetExceeded` |
| `CodeResourceExhausted`  | `RATE_LIMITED`           | 429  | `ErrExhausted`, `ErrCapacityExhausted`, `ErrMarketControlThrottled`                                                     |
| `CodeResourceExhausted`  | `PAYLOAD_TOO_LARGE`      | 413  | `ErrStepOutputExceedsCeiling`, `ErrRenderTooLarge`                                                                      |
| `CodeFailedPrecondition` | `PRECONDITION_FAILED`    | 412  | `ErrDisabled`, `ErrClaimQueueInactive`, `ErrFeatureNotEnabled`, `ErrTenantNotActive`, `ErrVersionIncompatible`          |
| `CodeFailedPrecondition` | `POOL_DISABLED`          | 412  | `ErrPoolDisabled`                                                                                                       |
| `CodeUnimplemented`      | `UNIMPLEMENTED`          | 501  | `ErrUnimplemented`, `ErrReplayNotSupported`                                                                             |
| `CodeInternal`           | `INTERNAL`               | 500  | `ErrInternal`, `ErrRuleEvaluationFailed`, `ErrQueueFailed`                                                              |

## Generic domain sentinels [#generic-domain-sentinels]

Declared in `domain/errors/errors.go`. Subdomain files add many more
(`strategy`, `claim`, `tenant`, `eventsourcing`, routing, connector, workflow…).

| Sentinel                | Message               |
| ----------------------- | --------------------- |
| `ErrNotFound`           | `not found`           |
| `ErrAlreadyExists`      | `already exists`      |
| `ErrInvalidInput`       | `invalid input`       |
| `ErrUnauthorized`       | `unauthorized`        |
| `ErrForbidden`          | `forbidden`           |
| `ErrConflict`           | `conflict`            |
| `ErrInternal`           | `internal error`      |
| `ErrServiceUnavailable` | `service unavailable` |
| `ErrTimeout`            | `timeout`             |
| `ErrQuotaExceeded`      | `quota exceeded`      |
| `ErrDisabled`           | `disabled`            |
| `ErrExhausted`          | `exhausted`           |
| `ErrUnimplemented`      | `unimplemented`       |
| `ErrRoutingFailed`      | `routing failed`      |

## Structured error types [#structured-error-types]

For rich errors, Ductor uses typed structs that wrap a sentinel via `Unwrap()`,
so `errors.Is(err, ErrNotFound)` still works while callers can also `errors.As`
the concrete type for its fields.

| Type                   | Wraps              | Fields                                      |
| ---------------------- | ------------------ | ------------------------------------------- |
| `NotFoundError`        | `ErrNotFound`      | Resource, ID                                |
| `ValidationError`      | `ErrInvalidInput`  | Field, Message, Value                       |
| `MultiValidationError` | `ErrInvalidInput`  | Errors\[]                                   |
| `ConflictError`        | `ErrConflict`      | Resource, ID, Reason                        |
| `StateError`           | `ErrConflict`      | Resource, ID, CurrentState, AttemptedAction |
| `ExhaustedError`       | `ErrExhausted`     | Resource, ID, Current, Limit                |
| `QuotaError`           | `ErrQuotaExceeded` | QuotaType, TenantID, Message                |

## What gets exposed [#what-gets-exposed]

The transport layer only exposes an error's message to clients when it is a
**domain error** — either a known structured type or a registered sentinel
(`IsDomainError`). Infrastructure errors (pgx, Redis, and other internals)
classify as non-domain, so their messages are never returned; clients get a
generic `INTERNAL` / 500 Problem JSON while the real cause is logged with a trace
ID. This is what keeps internal details from leaking through the API.

### Internal-error masking [#internal-error-masking]

An error-masking interceptor runs just before the status interceptor. Any error
that resolves to `Internal`/`Unknown` has its message replaced with:

```text
Internal error (ref: <8-hex>)
```

The `ref` is the first 8 hex characters of the SHA-256 of the original error
message. The full error is logged server-side keyed by that same ref, so an
operator can correlate a client's opaque reference to the real cause without ever
putting it on the wire. Give clients the `ref` when they report a `500` — it's
the join key back to the server log.

<Callout title="Retry guidance for clients">
  Use the `retryable` field (Problem JSON) or the Connect code, not the HTTP
  status alone. `429` (`RATE_LIMITED`/`QUOTA_EXCEEDED`), `503`
  (`UNAVAILABLE`/`ROUTING_FAILED`), and `504` (`TIMEOUT`) are the retryable
  families; `4xx` argument/permission errors are not.
</Callout>
