Skip to content

Errors & atomic

Two ground rules:

  1. Services don't import from DRF. They raise framework-agnostic exceptions; the view boundary translates them.
  2. Every service call runs inside transaction.atomic() by default. Opt out per ServiceSpec (or per view) when the service manages its own transaction boundaries.

Framework-agnostic exceptions

from rest_framework_services import ServiceError, ServiceValidationError


def withdraw(*, instance, data):
    if data.amount > instance.balance:
        raise ServiceValidationError({"amount": ["insufficient funds"]})
    if instance.locked:
        raise ServiceError("account is locked")
    instance.balance -= data.amount
    instance.save(update_fields=["balance"])
    return instance

The view maps them to DRF responses:

Raised Becomes HTTP
ServiceValidationError rest_framework.exceptions.ValidationError 400
ServiceNotFound rest_framework.exceptions.NotFound 404
ServiceConflict rest_framework.exceptions.APIException 409
ServiceError rest_framework.exceptions.APIException 422
AdditionalInputRequired rest_framework.exceptions.APIException 422

Specific members first, the generic 422 last: they are all ServiceError subclasses, so the order is the mapping. A transport writing its own handler inherits that constraint — match the members before the generic one, or the subclass check swallows them.

ServiceValidationError

For business-rule violations that should look like input validation errors to the client. Accepts the same shapes DRF's ValidationError does:

raise ServiceValidationError("bad input")
raise ServiceValidationError(["error 1", "error 2"])
raise ServiceValidationError({"field": ["per-field error"]})
raise ServiceValidationError({"non_field_errors": ["whole-form error"]})

The translated DRF response is a normal 400 with the same body shape your serializers produce. From the client's point of view, a service violation and a serializer violation are indistinguishable — which is usually what you want.

Field names come back as the request spelled them

A service raises about the model, because the model is what it was handed. Where the two names differ, the dispatcher translates:

class PostSerializer(serializers.ModelSerializer):
    headline = serializers.CharField(source="title")
    writer = AuthorSerializer(source="author")  # some_name = CharField(source="name")


# The service says:
raise ServiceValidationError({"title": ["Too long."], "author": {"name": ["Too short."]}})

# The client is told:
{"headline": ["Too long."], "writer": {"some_name": ["Too short."]}}

This is not cosmetic. DRF resolves source= while building validated_data, at every depth, so a service — and everything it calls, including the nested-write helpers — only ever sees column names. Reporting those back names fields the request does not have, and an error renderer keyed on field name drops them.

The rule is narrow, and worth knowing exactly:

  • It renames what the serializer can name, and nothing else. A key with no matching field passes through untouched, so non_field_errors, __all__ and anything else a service invents survive as written.
  • Only writable fields are consulted. A read-only field's source cannot appear in an error about input, so it never shadows the writable field that can.
  • source="*" and dotted source="author.name" are skipped — neither is a key of validated_data, so neither can be a key of an error about it.
  • The detail's shape is untouched. A string stays a string, a list stays a list of the same length, and a collection keeps its rows where they were — in either of DRF's shapes for one, an aligned list or a mapping keyed by row index, whose int keys are never mistaken for field names.
  • No serializer, no rename. A spec with no input_serializer has only one vocabulary, and calling the mutation helpers directly is unaffected entirely.

It applies wherever a spec is dispatched — HTTP, off-HTTP, sync and async — because the translation happens in the shared dispatch core rather than in the view. A ModelSerializer whose fields are named after their columns is unaffected, which is most of them.

AdditionalInputRequired

For "I got far enough to discover I need something else" — which is not the same claim as ServiceValidationError's "what you sent is wrong". The difference that matters is that it is usually conditional on what the service found, so it cannot be expressed as a required field on the serializer:

def delete_rows(*, data):
    doomed = rows_matching(data)
    if len(doomed) > 100 and not data["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 put the question to a human renders it; one that cannot still has a message worth showing.

Over HTTP the 422 body carries both, the schema exactly as it was raised:

{"detail": "412 rows match. Confirm to proceed.", "schema": {"confirmed": {"type": "boolean"}}}

A "default": false stays a boolean and a "maximum": 3 a number. Without a schema, the body is the plain {"detail": ...} of any other 422.

Your EXCEPTION_HANDLER sees DRF's shape. The mapped exception's detail is built the way DRF builds every detail, so each leaf of the schema in it is an ErrorDetail string with a code, and a handler that walks detail for codes (drf-standardized-errors does) works unchanged. The schema as raised is kept on the exception's schema attribute, and drfs' views and viewsets put it back into the body after your handler has built the response: only when the response's data is a dict with a schema key of its own, so a handler that reshapes the body keeps the body it built. A handler that answers with a plain Django response, a JsonResponse say, has no data to restore into, and is served exactly as it built it.

Every route drfs ships restores it: ServiceCreateView, ServiceUpdateView, ServiceDeleteView, every viewset mixin, ServiceViewSet, SelectorViewSet, ActionSerializerResolver, and a @service_action on any viewset, DRF's own GenericViewSet and ViewSet included, whatever order its bases are listed in. Your handler runs once per request on every one of them, a handler that declines the error (returns None) included.

A @service_action restores it inside the exception handler: it hands DRF's handle_exception a handler that runs yours, or the one your viewset's get_exception_handler picks, and then puts the schema back. So a handle_exception of your own runs after the restore on any viewset, and what it writes into the body is what the client gets.

The viewset mixins and ActionSerializerResolver restore it in handle_exception instead, beneath an override of yours, and only where they are listed before GenericViewSet, as every drfs viewset lists them: class V(GenericViewSet, ServiceCreateMixin) resolves handle_exception to DRF's and serves create's schema stringified, while a @service_action on the same class still serves it as raised.

Anything else that holds the mapped exception reads the native schema off it, because there is no view to restore it:

try:
    call_service(purge, request=request, map_errors=True)
except APIException as exc:
    schema = getattr(exc, "schema", None)  # as raised; None for any other error

The same holds for a direct map_service_error(...) caller.

The answer comes back as ordinary input. An HTTP client re-submits with confirmed in the body. A transport that asks interactively — MCP, say — merges the answer into the parameters before dispatch. Either way the service reads it as a normal argument, which is why raising is the whole of the service's involvement: there is no callback to hold and no session to resume.

It subclasses ServiceError, so a transport that has never heard of it still behaves sensibly. A transport that wants to do better must catch it before its generic ServiceError handler, or the subclass check will swallow it.

ServiceError

For business-rule violations that aren't input errors — "the resource exists but is in the wrong state". The default mapping is 422 Unprocessable Entity, which is exactly what HTTP says about this case.

raise ServiceError("account is locked")
raise ServiceError("invoice already finalised")

ServiceNotFound and ServiceConflict

Two shapes of failure common enough to name, so a transport can tell them apart without a status code on the exception:

raise ServiceNotFound(f"No event {data.event_id}.")  # absent, or not yours to see
raise ServiceConflict("That slot is already taken.")  # the state collides

ServiceNotFound answers the same way for absent and for not yours on purpose: a 403 on a row the caller cannot see confirms that the row exists.

ServiceConflict is what a preconditions predicate usually wants, since a state rule is normally exactly this kind of collision — see State rules with preconditions.

Neither carries an HTTP status, and setting one does nothing. A status_code attribute on a ServiceError subclass is read by nobody, and could not be honoured off HTTP where there is no status to carry. The member is the portable form of the intent; each transport decides what it means. Over HTTP, this package decides: 404 and 409.

The generated OpenAPI document still declares only the 422 for spec-driven mutations. Advertising a 409 on every mutation, including those that cannot collide, would trade one wrong claim for another — a spec has no field saying which failures its service can raise, and inventing one to satisfy the schema is not a trade this package makes.

Why not just raise DRF exceptions?

Because the mutation can be reused outside a request — from a management command, a background job, a test. Coupling business rules to HTTP status codes makes those reuses awkward; coupling them to what kind of error happened keeps them portable.

The boundary translation costs you nothing inside a view; outside a view, you catch ServiceError / ServiceValidationError directly.

Atomic transactions

By default every service call is wrapped in transaction.atomic(). Opt out per spec — atomic lives on ServiceSpec:

class ImportView(ServiceCreateView):
    spec = ServiceSpec(
        service=run_import,
        input_serializer=ImportInput,
        atomic=False,  # the import service handles its own savepoints
    )

When to opt out:

  • the service drives a long-running import that needs per-row savepoints,
  • the service must commit partial work before raising,
  • the service spans systems and the database side is intentionally best-effort.

When not to opt out: the service makes more than one related write and you'd want them to roll back together on failure. That's the case the default exists for.

Async + atomic

Atomic wrapping works for async def services too. The dispatcher wraps the call in sync_to_async(thread_sensitive=True) so the ORM connection stays on a consistent thread. You don't need to do anything special in the service body.

See Async.