Errors & atomic¶
Two ground rules:
- Services don't import from DRF. They raise framework-agnostic exceptions; the view boundary translates them.
- Every service call runs inside
transaction.atomic()by default. Opt out perServiceSpec(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
sourcecannot appear in an error about input, so it never shadows the writable field that can. source="*"and dottedsource="author.name"are skipped — neither is a key ofvalidated_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
intkeys are never mistaken for field names. - No serializer, no rename. A spec with no
input_serializerhas 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:
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.
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.