Skip to content

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.