Skip to content

0008. Contract errors are typed and identical in both modes

  • Status: Accepted
  • Accepted: 2026-11-11
  • Date: 2026-11-03

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.

  1. 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.
  2. One ContractError with 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 own except clause.
  3. 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.

Option 2.

  1. One error type. Every error that crosses a contract is a ContractError with a kind, a message and, for a declared kind, its typed detail record. An implementation may raise any standard kind except unavailable and timeout, or a kind its action declares.
  2. Transport failures. Only hosts and transports produce unavailable and timeout. 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 as internal, 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 an Exception, after the deadline is discarded, and the call fails with timeout, as it does over a network, where the caller stops waiting at the deadline. Following Decision 3, the provider’s host still logs a discarded Exception, and an exception that is not an Exception propagates. In every mode, timeout means the outcome is unknown, as with gRPC’s DEADLINE_EXCEEDED. The test harness acts as a transport, so it can produce unavailable and timeout to test a caller in attached mode.
  3. Unexpected failures. An Exception that escapes an implementation becomes internal. So does a ContractError of 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 an Exception, 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 to internal, 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 sends unknown for 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.
  4. Nested calls. A ContractError that an implementation receives from one of its own outgoing calls, and lets escape unchanged, becomes internal for its caller. Passing it on takes a new ContractError, raised by the implementation under the rules of Decision 1. The received error itself gives internal however it is re-raised, bare, by name or after changes to its fields. A not_found from another contract says that the other contract’s entity is missing, which is not what not_found from this action means. Connect RPC behaves the same way: a handler that returns a downstream error unchanged sends its client a bare internal, and its documentation advises translating downstream failures into local errors.
  5. Unknown kinds. A kind the caller’s version of the contract does not know arrives as a ContractError with 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.
  6. 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.
  7. 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_denied stays for a caller that is identified but may not perform the action. gRPC draws the same line: PERMISSION_DENIED must not be used when the caller cannot be identified, and UNAUTHENTICATED is used instead.
  8. 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 detail to data, the name JSON-RPC 2.0 gives the same member. RFC 9457 uses detail for 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).

  • Callers write one set of error handling, which is correct in both modes. They branch on kind instead of catching a class per kind.
  • Retries rely on idempotent actions and idempotency keys, owned by the connector and its transport. Because timeout leaves 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 timeout only 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 ContractError it 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).