Errors & status codes
Domain error sentinels mapped to Connect/gRPC codes, HTTP status, and Problem JSON.
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
| 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)
REST clients receive an RFC 9457 application/problem+json document. The body is
the ProblemDetail struct in transport/exec/common/errors/problem_json.go:
{
"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) |
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 |
|---|---|
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
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
| 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
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
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
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
An error-masking interceptor runs just before the status interceptor. Any error
that resolves to Internal/Unknown has its message replaced with:
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.
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.