Errors
The narrative is in Errors & failures; this is the
exhaustive surface — every ExceptionKind, how to raise it, and the egress
policy each carries.
Raising¶
Every failure is a CoreException, raised through the exc factory — the kind is
the method:
from forze.base.exceptions import exc
raise exc.domain("A shipped order is final.")
raise exc.conflict("Email already registered.", code="email_taken")
exc.<kind>(summary, *, code=None, details=None, resource_type=None) — code
defaults to core.<kind>. A CoreException carries kind, summary, code, and
optional details and resource_type (see Non-disclosing denials).
The kinds¶
Each kind has an egress policy with two flags: expose_details (are details
safe to return to a caller?) and retryable (is the failure transient?). Only
the retryable kinds may appear in a resilience retry
policy.
| Kind | Meaning | Exposes details | Retryable | HTTP status | Default code |
|---|---|---|---|---|---|
validation |
malformed input | ✅ | — | 422 | core.validation |
domain |
a business rule was violated | ✅ | — | 400 | core.domain |
precondition |
a required state wasn't met (e.g. stale revision, a bad query field) | ✅ | — | 400 | core.precondition |
conflict |
the change collides with current state | ✅ | — | 409 | core.conflict |
concurrency |
transient contention | ✅ | ✅ | 409 | core.concurrency |
not_found |
the target doesn't exist | ✅ | — | 404 | core.not_found |
authentication |
who is calling | — | — | 401 | core.authentication |
authorization |
what they may do | — | — | 403 | core.authorization |
configuration |
the app is wired wrong | — | — | 500 | core.configuration |
infrastructure |
a backing system failed | — | ✅ | 500 | core.infrastructure |
throttled |
a rate limit rejected the call | — | ✅ | 429 | core.throttled |
timeout |
the invocation's time budget ran out | — | — | 504 | core.timeout |
unavailable |
it works, but is switched off right now (a feature flag, a maintenance window, an instance draining for shutdown) | — | ✅ | 503 | core.unavailable |
internal |
an unexpected bug | — | — | 500 | core.internal |
unavailable's summary and code reach clients unmasked, as a 4xx kind's do, so keep the
summary generic. Being retryable, it is redelivered like throttled: a
queue consumer or a
commit-stream consumer retries it with no
cap of its own on some paths, so pair a long-lived switch-off with pausing the consumer.
Outcomes¶
Handlers return a result or raise. Where code needs the outcome rather than a
raise — a finally_ stage hook — it receives an Outcome: Success(value) or
Failure(exc), from forze.application.contracts.execution.
At the edge¶
Core owns the canonical kind→status mapping (the column above, via
http_status_for_kind), but applies it at no transport itself. The
FastAPI exception handlers turn a CoreException
into a response — the status from the kind, the code on an error-code header,
and details exposed only when expose_details is set; kinds with no status of
their own (configuration, infrastructure, internal) map to 500. Every
transport masks a >= 500 error's summary and logs it as a server error, except
unavailable: its 503 reports a state an operator chose, not a fault, so its
summary reaches the client and nothing is logged as an error.
Non-disclosing denials¶
A 403 for a row you may not see and a 404 for a row that does not exist tell a caller which
ids exist. A DenialPosture closes that: for the resource types it names, a denial and a
not-found render as one response — 404, code core.not_found, detail "Not found", no
context — on every transport, because the collapse happens in the shared envelope.
from forze.base.exceptions import DenialPosture
runtime = build_runtime(
...,
denial_posture=DenialPosture(mode="non_disclosing", resource_types={"notes", "invoices"}),
)
The runtime sets the posture for the whole process while its scope is entered, and every runtime in one process must use the same one: a scope asking for a different posture than one already held is refused. What is covered:
- a denial from the authz before-hook when the hook has a
resource_factory— it carries the resource'sresource_type; - a not-found from a document port — it carries the spec name, so name your authz resource types after your document specs;
- anything you raise with
resource_type=yourself. A handler's ownexc.not_found("Note 7 not found")without it renders as written, and that difference is itself the leak.
A denial about no particular resource — the hook without a resource_factory, a policy scope
that denies a whole list — stays a 403: it says nothing about any row. The server-side
exception keeps its real kind and code, so logs and metrics still tell a denial from a miss.
For the row itself, read it with owned_by (see the document port):
a foreign row is then not found by the read, not by a check after it.
What this does and does not close
It closes the response-shape oracle. It does not close timing — refusing a row that exists can still take a different time than missing one that does not — and it does not stop enumeration by volume, which is throttling's job. It is off by default, and turning it on changes what clients see: any client that tells 403 from 404 will break. Adopt it per resource type, and list the types rather than the endpoints, so every route over a type is covered at once.
To check it holds across a workload, add denial_bodies_identical("notes.read", ...) from
forze_dst.invariants to a simulation: it fails when one operation refuses
with more than one rendered response.