Exceptions¶
Framework-agnostic exceptions raised by services. The view boundary maps them to DRF responses — see Errors & atomic for the mapping.
Each class is django-service-specs' own, re-exported at the drfs path shown, so
rest_framework_services.ServiceError is django_service_specs.ServiceError. An
except written against either package catches what the other raises, and the
documentation below is the kernel's.
ServiceValidationError takes a lazy translation (gettext_lazy("...")) as its
message, as well as a str; any other detail still answers with the default
message.
ServiceError ¶
Bases: Exception
Raised by services to signal a business-rule failure.
Carries a message and nothing else, so a transport can surface it
without the service depending on that transport. A structured payload
belongs to a member that declares one:
ServiceValidationError
carries a field-keyed detail.
ServiceValidationError ¶
Bases: ServiceError
Raised by services to signal invalid input or invalid state.
Distinct from
ServiceError so
a transport can tell "the arguments were fine but the business rule refused"
from "the input itself is invalid" and map the two differently. detail
may be a string, a dict (field → error(s)), or a list of errors.
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.
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 a permission refusal for a row the caller cannot see confirms that it exists, which is why the owner-scoped lookup above raises this either way.
A transport that has never heard of this type 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 — distinct from a plain
ServiceError,
which says "understood, and still not doing it" with no such implication.
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.")
A transport that has never heard of this type 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.
ActionUnavailable ¶
Bases: ServiceConflict
The operation is not possible right now, for a reason with a stable name.
Raised when one of a spec's
Affordance conditions
is not met, carrying that affordance's code and its reason as the
message. django-spec-html's error_response answers it over HTTP as a
409 whose body is {"detail": <reason>, "code": <code>}.
A ServiceConflict
subclass rather than a code= argument on its parent, for three reasons. The
409 answer needs no new status, because a subclass is matched by its
parent's arm. A transport that wants the code matches this class ahead of its
generic conflict handler, and one that has never heard of it still reports a
conflict. And a ServiceConflict a service raises by hand genuinely has no
code and never will, so a nullable code on the parent would be a field
that lies at every other call site.
code names the rule and is what a transport or client branches on; the
message is the affordance's reason, the sentence a person or a model is
shown when the call is refused. A transport serving an agent should pass on
both: the model can say the sentence, and the code is what stays stable when
the sentence is reworded.
Like every member, it must be matched before a generic ServiceConflict
or 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 parameter:
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 argument 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 an ordinary
argument 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. The
operation declares that argument like any other, so the closed argument set
admits it on the second call.
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.