0008. Contract errors are typed and identical in both modes
- Status: Accepted
- Accepted: 2026-11-11
- Date: 2026-11-03
Context
Section titled “Context”A connector call such as connect("<contract>.v1").<action>(request) reads like a local call. Remote calls can time out, be refused, or fail halfway. Experience with RPC systems that make remote calls look local suggests callers then tend to under-handle failure. If local and remote calls raise different exceptions, callers can tell the modes apart, and their error handling is only correct in one of them.
Errors reach a caller from three places:
- The implementation: a domain failure, such as a missing entity, or a bug.
- The host and the transport: no provider could be reached, the deadline passed, or the reply could not be read.
- The caller’s own code: a call made with no call context, or to an action the contract does not have.
Only the first two are outcomes of a call. The third is a mistake in the calling code.
An exception that escapes an implementation carries its text, its type, its traceback and the exceptions chained to it. Sent to the caller, these expose the provider’s internals across an app boundary, and a caller can start to depend on them.
A deadline that passes does not say whether the action ran. gRPC’s DEADLINE_EXCEEDED is defined the same way: an operation that changes state may have completed.
Web frameworks render failures in their own formats. django-ninja 1.8 answers a request that fails validation with a 422 and a body of the form {"detail": [...]}, and in DEBUG mode answers an unhandled exception with a plain-text traceback and a 500 (ninja/errors.py, _default_validation_error and _default_exception). The draft spec 03 used 422 for declared kinds, and its error body named the detail record detail, which RFC 9457 defines as a human-readable explanation.
Options
Section titled “Options”- Let exceptions propagate as-is. Local calls raise the implementation’s exceptions, and remote calls raise HTTP-client errors. Nothing to build, but error handling differs by mode and internals cross the boundary.
- One
ContractErrorwith a kind, raised identically by both connectors. Standard kinds plus the kinds each action declares, each declared kind with a typed detail record. Callers branch on the kind, as with gRPC status codes. A caller cannot catch one kind with its ownexceptclause. - A class per kind. Declared errors are re-raised as their own exception types on the caller’s side. Callers catch each kind by class, but every class has to be importable from the contract package, and a caller on an older version of the contract has no class for a kind added later.
Decision
Section titled “Decision”Option 2.
- One error type. Every error that crosses a contract is a
ContractErrorwith akind, a message and, for a declared kind, its typed detail record. An implementation may raise any standard kind exceptunavailableandtimeout, or a kind its action declares. - Transport failures. Only hosts and transports produce
unavailableandtimeout. A transport failure surfaces only as one of those two. A reply or an error that arrives but does not validate against the contract surfaces asinternal, apart from an error of a kind the caller does not know (Decision 5). In-process, the deadline is checked before the call is dispatched and again when the action ends. Whatever the action returns, or raises as anException, after the deadline is discarded, and the call fails withtimeout, as it does over a network, where the caller stops waiting at the deadline. Following Decision 3, the provider’s host still logs a discardedException, and an exception that is not anExceptionpropagates. In every mode,timeoutmeans the outcome is unknown, as with gRPC’sDEADLINE_EXCEEDED. The test harness acts as a transport, so it can produceunavailableandtimeoutto test a caller in attached mode. - Unexpected failures. An
Exceptionthat escapes an implementation becomesinternal. So does aContractErrorof a kind the implementation may not raise:unavailable,timeout, or a kind outside the action’s error set. So does a failure of the provider’s host itself. An exception that is not anException, such as cancellation (asyncio.CancelledError) or interpreter exit (KeyboardInterrupt,SystemExit), is not converted: it is a signal to stop, not a failure of the action. Of an exception converted tointernal, nothing crosses, in any mode: not its text, type or traceback, nor the exceptions chained to it (__cause__,__context__), nor its notes. The message is fixed and carries the trace identifier, and the provider’s host logs the original with that identifier. Connect RPC likewise sendsunknownfor an error that is not one of its own, without the error’s text. The test harness may opt in to re-raising the original, so that a failing test shows it. That option is for tests only. - Nested calls. A
ContractErrorthat an implementation receives from one of its own outgoing calls, and lets escape unchanged, becomesinternalfor its caller. Passing it on takes a newContractError, raised by the implementation under the rules of Decision 1. The received error itself givesinternalhowever it is re-raised, bare, by name or after changes to its fields. Anot_foundfrom another contract says that the other contract’s entity is missing, which is not whatnot_foundfrom this action means. Connect RPC behaves the same way: a handler that returns a downstream error unchanged sends its client a bareinternal, and its documentation advises translating downstream failures into local errors. - Unknown kinds. A kind the caller’s version of the contract does not know arrives as a
ContractErrorwith that kind, and with its detail as an untyped value. Adding an error kind to an action is a compatible change (spec 01, CON-1), so an older caller must be able to receive one. - Usage errors are not contract errors. A mistake in the caller’s own code, such as a connector call with no active context or a call to an action the contract does not have, raises an ordinary exception, not a
ContractError. It reports a bug, not the outcome of a call, and code that handles contract errors must not swallow it. 0017 Decision 5 says the same. unauthenticated. A new standard kind: the caller cannot be identified, because its credentials are missing or could not be verified. Over HTTP it is 401. It is not retry safe.permission_deniedstays for a caller that is identified but may not perform the action. gRPC draws the same line:PERMISSION_DENIEDmust not be used when the caller cannot be identified, andUNAUTHENTICATEDis used instead.- HTTP. The provider’s host renders every failure on a contract path, meaning the path of an action of a contract version it provides, in the neolith error body of spec 03. That includes a request that fails validation and a failure of the host itself, so the framework’s own error responses never reach a caller. The body’s member for the detail record is renamed from
detailtodata, the name JSON-RPC 2.0 gives the same member. RFC 9457 usesdetailfor a human-readable explanation, and web frameworks use it in their own error bodies, as django-ninja does, so a record under that name is easy to misread.
Follows principles 5 (same semantics over same mechanics) and 6 (visible failure over transparent calls).
Consequences
Section titled “Consequences”- Callers write one set of error handling, which is correct in both modes. They branch on
kindinstead of catching a class per kind. - Retries rely on idempotent actions and idempotency keys, owned by the connector and its transport. Because
timeoutleaves the outcome unknown, a caller of a non-idempotent action has to learn the outcome another way before it tries again. - An in-process action that overruns its deadline still runs to the end in the caller’s thread (spec 02, TRN-6). The caller sees
timeoutonly when the action ends. The transport cannot interrupt it. - Debugging a provider failure starts from the trace identifier in the message and the provider host’s log, not from a traceback in the caller.
- The provider’s host has to tell a received error passed through unchanged (Decision 4) from one the implementation raised itself, for example by a mark the connector sets on every
ContractErrorit raises. - A 401 needs a challenge, which RFC 9110 requires. Spec 03 uses
Bearer. - CONTRIBUTING K7 binds from this record’s acceptance.
- The specification describes these rules in outline in the same change as this record, and TRN-4 is reworded to fit Decision 2. The rules become requirements, with IDs and the tests that cite them, together with the code: the error type in roadmap step 2, the in-process transport and the connector in step 3, the host in step 4 and the HTTP transport in step 5. The specification is written alongside the code, after the record that changes its meaning (spec README, 0018).
References
Section titled “References”- gRPC status codes:
DEADLINE_EXCEEDED,UNAUTHENTICATEDandPERMISSION_DENIED. - Connect RPC, errors: errors that are not Connect errors, and downstream errors returned unchanged.
- RFC 9457, Problem Details for HTTP APIs, section 3.1.4 (
detail). - JSON-RPC 2.0, the error object (
code,message,data). - django-ninja 1.8.0,
ninja/errors.py: the default 422 and DEBUG-mode 500 handlers. - RFC 9110, section 15.5.2, 401 Unauthorized; RFC 6750, section 3, the
Bearerchallenge.