Reference

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
Domain sentinelConnect codeHTTP statusError code
ErrNotFoundCodeNotFound404NOT_FOUND
ErrQuotaExceededResourceExhausted429QUOTA_EXCEEDED
ErrConflictCodeAborted409CONFLICT

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
}
FieldAlways presentMeaning
typeyeshttps://errors.ductor.io/<ERROR_CODE> — a stable, dereferenceable type URI
titleyesThe code's human description (from the error catalog)
statusyesHTTP status code
error_codeyesStable machine-readable code (see the catalog below)
retryableyesWhether a retry can plausibly succeed
detailwhen domainUnderlying message — only exposed for domain errors (see Safe exposure)
instanceoptionalThe request path/URI the error occurred on
trace_idoptionalDistributed-trace id; also sent as a response header
request_idoptionalPer-request id; also sent as a response header
errors[]optionalField-level errors — each { field, description, value }

Some errors add domain enrichment fields:

FieldAdded for
cancellation_idBulk-cancellation rejections — the matching cancellation row
reasonThe operator-supplied reason on that cancellation row
run_idIn-flight idempotency duplicates — the original route attempt

Error code → HTTP status

Error codeHTTP
VALIDATION_ERROR, INVALID_ARGUMENT400
UNAUTHENTICATED401
PERMISSION_DENIED403
NOT_FOUND404
ALREADY_EXISTS, CONFLICT, DUPLICATE_DETECTED, DUPLICATE_IN_FLIGHT409
RETURN_WINDOW_EXPIRED410
PRECONDITION_FAILED, POOL_DISABLED412
PAYLOAD_TOO_LARGE413
RATE_LIMITED, QUOTA_EXCEEDED429
CANCELED499
INTERNAL500
UNIMPLEMENTED501
UNAVAILABLE, ROUTING_FAILED, NO_ELIGIBLE_RECIPIENTS, QUEUE_FULL503
TIMEOUT504

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 codeRetryableSeverity
VALIDATION_ERRORnowarning
INVALID_ARGUMENTnowarning
NOT_FOUNDnowarning
ALREADY_EXISTSnowarning
PERMISSION_DENIEDnowarning
UNAUTHENTICATEDnowarning
CANCELEDnowarning
RATE_LIMITEDyeswarning
QUOTA_EXCEEDEDnowarning
CONFLICTnowarning
PRECONDITION_FAILEDnowarning
PAYLOAD_TOO_LARGEnowarning
INTERNALnocritical
UNAVAILABLEyeserror
TIMEOUTyeserror
UNIMPLEMENTEDnowarning
ROUTING_FAILEDyeserror
NO_ELIGIBLE_RECIPIENTSyeswarning
DUPLICATE_DETECTEDnowarning
DUPLICATE_IN_FLIGHTyeswarning
RETURN_WINDOW_EXPIREDnowarning
QUEUE_FULLyeswarning
POOL_DISABLEDnowarning

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

Sentinel → Connect code → HTTP

Connect codeError codeHTTPRepresentative sentinels
CodeNotFoundNOT_FOUND404ErrNotFound, ErrTenantNotFound, ErrClaimQueueNotFound, ErrStrategyNotFound
CodeInvalidArgumentINVALID_ARGUMENT400ErrInvalidInput, ErrInvalidRequest, ErrClaimFilterInvalid, ErrTenantIDRequired, ErrConfigurationInvalid
CodeAlreadyExistsALREADY_EXISTS409ErrAlreadyExists, ErrIdempotencyConflict, ErrDuplicateDetected
CodeAbortedCONFLICT409ErrConflict, ErrItemAlreadyClaimed, ErrConcurrencyConflict, ErrIdempotencyInProgress
CodeUnauthenticatedUNAUTHENTICATED401ErrUnauthorized, ErrSAMLSessionInvalid, ErrAssertionReplayed
CodePermissionDeniedPERMISSION_DENIED403ErrForbidden, ErrNotClaimOwner, ErrTenantSuspended, ErrTenantDeleted
CodeCanceledCANCELED499ErrContextCanceled
CodeDeadlineExceededTIMEOUT504ErrTimeout, ErrRoutingTimeout, ErrRenderTimeout
CodeUnavailableUNAVAILABLE503ErrServiceUnavailable, ErrLockAcquisitionFailed
CodeUnavailableROUTING_FAILED503ErrRoutingFailed
CodeUnavailableNO_ELIGIBLE_RECIPIENTS503ErrNoRecipientsAvailable, ErrNoEligibleCandidates
CodeResourceExhaustedQUOTA_EXCEEDED429ErrQuotaExceeded, ErrDailyQuotaExceeded, ErrPoolsQuotaExceeded, ErrRulesQuotaExceeded, ErrClaimBudgetExceeded
CodeResourceExhaustedRATE_LIMITED429ErrExhausted, ErrCapacityExhausted, ErrMarketControlThrottled
CodeResourceExhaustedPAYLOAD_TOO_LARGE413ErrStepOutputExceedsCeiling, ErrRenderTooLarge
CodeFailedPreconditionPRECONDITION_FAILED412ErrDisabled, ErrClaimQueueInactive, ErrFeatureNotEnabled, ErrTenantNotActive, ErrVersionIncompatible
CodeFailedPreconditionPOOL_DISABLED412ErrPoolDisabled
CodeUnimplementedUNIMPLEMENTED501ErrUnimplemented, ErrReplayNotSupported
CodeInternalINTERNAL500ErrInternal, ErrRuleEvaluationFailed, ErrQueueFailed

Generic domain sentinels

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

SentinelMessage
ErrNotFoundnot found
ErrAlreadyExistsalready exists
ErrInvalidInputinvalid input
ErrUnauthorizedunauthorized
ErrForbiddenforbidden
ErrConflictconflict
ErrInternalinternal error
ErrServiceUnavailableservice unavailable
ErrTimeouttimeout
ErrQuotaExceededquota exceeded
ErrDisableddisabled
ErrExhaustedexhausted
ErrUnimplementedunimplemented
ErrRoutingFailedrouting 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.

TypeWrapsFields
NotFoundErrorErrNotFoundResource, ID
ValidationErrorErrInvalidInputField, Message, Value
MultiValidationErrorErrInvalidInputErrors[]
ConflictErrorErrConflictResource, ID, Reason
StateErrorErrConflictResource, ID, CurrentState, AttemptedAction
ExhaustedErrorErrExhaustedResource, ID, Current, Limit
QuotaErrorErrQuotaExceededQuotaType, 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.