Exceptions¶
Framework-agnostic exceptions raised by services. The view boundary maps them to DRF responses — see Errors & atomic for the mapping.
ServiceError ¶
Bases: Exception
Raised by services to signal a business-rule failure.
Carries an optional structured detail payload so the view layer can
surface a meaningful response without the service depending on DRF.
ServiceValidationError ¶
Bases: ServiceError
Raised by services to signal invalid input or invalid state.
Distinct from
ServiceError so
the view boundary maps it to a DRF ValidationError (HTTP 400) where
ServiceError maps to 422. detail may be a string, a dict (field → error(s)),
or a list of errors, mirroring DRF's own ValidationError payload shapes.
ServiceNotFound ¶
Bases: ServiceError
The operation's target does not exist, or is not this caller's to see.
The distinction from a plain
ServiceError
is which thing is wrong: the resource is absent rather than in the wrong
state, so a client should stop asking rather than try again differently. Over
HTTP that is a 404; a plain ServiceError stays a 422.
def move_event(*, user, data):
event = Event.objects.filter(owner=user, pk=data.event_id).first()
if event is None:
raise ServiceNotFound(f"No event {data.event_id}.")
Say the same thing for "absent" and "not yours." Answering 403 to a row
the caller cannot see confirms that it exists, which is why the owner-scoped
lookup above raises this either way.
Off HTTP there is no status code to reach for, which is the point of the type:
a transport that has never heard of it still handles a ServiceError, and one
that wants to do better matches on the class. It must match before its
generic ServiceError handler, or the subclass check swallows it.
ServiceConflict ¶
Bases: ServiceError
The operation collides with the resource's current state.
A slot already taken, a row someone else moved first, a name already used. The
resource is there and the request is well-formed; the two are simply
incompatible right now, and a caller can often resolve it by re-reading and
trying again. Over HTTP that is a 409; a plain
ServiceError
stays a 422, which says "understood, and still not doing it".
def slot_is_free(*, user, data):
if Event.objects.filter(owner=user, day=data.day, hour=data.hour).exists():
raise ServiceConflict(f"{data.day} at {data.hour}:00 is taken.")
Reaching for this from a preconditions predicate is the common case, since
a state rule is usually exactly this kind of collision.
Off HTTP there is no status code to reach for, which is the point of the type:
a transport that has never heard of it still handles a ServiceError, and one
that wants to do better matches on the class. It must match before its
generic ServiceError handler, or the subclass check swallows it.
AdditionalInputRequired ¶
Bases: ServiceError
A service cannot proceed without a value it was not given.
Not "what you sent is wrong" — that is
ServiceValidationError
— but "I got far enough to discover I need something else", usually
conditional on what the service found, so it cannot be expressed as a
required input on the serializer:
def delete_rows(*, data, confirmed: bool = False):
doomed = rows_matching(data)
if len(doomed) > 100 and not confirmed:
raise AdditionalInputRequired(
f"{len(doomed)} rows match. Confirm to proceed.",
schema={"confirmed": {"type": "boolean"}},
)
...
schema describes what is missing, keyed by the input name the service
expects it back under: a transport that can ask renders it, one that cannot
still has a message worth showing. The answer comes back as ordinary
input on every transport — an HTTP client re-submits with confirmed in
the body, an MCP client is asked and its answer is merged into the tool
arguments before dispatch — so raising is the whole of the service's
involvement; there is no callback to hold and no session to resume.
A ServiceError
subclass deliberately, so a transport that has never heard of it still reports that
the operation could not be completed and why. One that wants to do better must catch
it before its generic ServiceError handler.