Skip to content

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.