Error Handling¶
Craft Easy uses a single, consistent error envelope for every failure — the same shape whether the error comes from a domain exception, a Pydantic validation failure, or an unhandled exception. Clients parse one structure and branch on the machine-readable code.
The envelope¶
{
"error": {
"code": "NOT_FOUND",
"message": "products 507f... not found",
"details": {
"resource": "products",
"item_id": "507f..."
}
},
"request_id": "a3f1-..."
}
| Field | Purpose |
|---|---|
error.code |
A stable, machine-readable identifier. Clients should branch on this, not on message. |
error.message |
A human-readable description. Safe to show to developers; not always safe to show to end-users verbatim. |
error.details |
Context specific to the error (resource, item id, validation errors, etc.). May be empty. |
request_id |
The request correlation id. Quote it when reporting bugs — it lets operators find the matching server log line. |
HTTP status codes are set from the exception class; the envelope is identical regardless of status.
Exception hierarchy¶
Every domain error is a subclass of CraftEasyError, which carries status_code, error_code, message, and details. Raising one from a route handler, hook, or service is enough — the global exception handler turns it into the envelope.
| Exception | HTTP | code |
Raised when |
|---|---|---|---|
CraftEasyError |
500 | INTERNAL_ERROR |
Base class. Do not raise directly — subclass instead. |
AuthenticationError |
401 | AUTH_REQUIRED |
Missing or invalid credentials. |
AuthorizationError |
403 | ACCESS_DENIED |
Feature or attribute guard rejected the call. details.feature is set when the feature name is known. |
NotFoundError |
404 | NOT_FOUND |
Lookup by id returned nothing. details carries resource and item_id. |
ConflictError |
409 | CONFLICT |
Generic conflict (duplicate key, state transition violation, etc.). |
ETagMismatchError |
412 | ETAG_MISMATCH |
If-Match header did not match the current revision. See ETag Concurrency. |
CascadeDenyError |
422 | CASCADE_DENY |
Delete blocked because another model's cascade rule denies it. details includes resource, item_id, referenced_by, count. |
ValidationError |
422 | VALIDATION_ERROR |
Domain-level validation failure raised from service code. details.errors mirrors the Pydantic error list shape. |
ETagRequiredError |
428 | ETAG_REQUIRED |
Mutation attempted without an If-Match header. |
Pydantic's own ValidationError is also mapped to 422 VALIDATION_ERROR by a dedicated handler, with details.errors carrying the full Pydantic error list.
Status codes in context¶
Craft Easy uses the standard HTTP status vocabulary:
| Status | When you see it |
|---|---|
400 Bad Request |
Malformed query parameters, invalid where JSON, unknown operator, invalid cursor. |
401 Unauthorized |
Missing or invalid Authorization header, expired JWT. |
403 Forbidden |
Authenticated, but access control denies the call (feature guard, attribute guard, tenant scope). |
404 Not Found |
Resource or id does not exist. |
409 Conflict |
Duplicate key on insert, state-transition conflict, bulk delete with all rows rejected. |
412 Precondition Failed |
If-Match mismatch on mutation. |
422 Unprocessable Entity |
Validation failed (Pydantic or domain), or cascade delete was denied. |
428 Precondition Required |
If-Match missing on mutation. |
429 Too Many Requests |
Rate limiter triggered. Includes Retry-After. |
5xx |
Unhandled exception on the server. Always includes request_id. |
Raising errors from your code¶
Prefer the typed exceptions over HTTPException so that every failure goes through the same handler and the same envelope:
from craft_easy.core.errors.exceptions import (
AuthorizationError,
ConflictError,
NotFoundError,
ValidationError,
)
async def cancel_order(order_id: str, user) -> Order:
order = await Order.get(order_id)
if not order:
raise NotFoundError("orders", order_id)
if order.status == "cancelled":
raise ConflictError("Order is already cancelled")
if order.created_by != user.id and not user.is_admin:
raise AuthorizationError("You cannot cancel someone else's order")
if order.total > 10_000:
raise ValidationError([{
"loc": ["total"],
"msg": "Orders above 10,000 require finance approval",
"type": "value_error.too_large",
}])
order.status = "cancelled"
await order.save()
return order
For ad-hoc errors that do not fit the hierarchy, raise CraftEasyError with explicit arguments:
from craft_easy.core.errors.exceptions import CraftEasyError
raise CraftEasyError(
"Payment provider unavailable",
status_code=503,
error_code="PROVIDER_UNAVAILABLE",
details={"provider": "stripe"},
)
Client-side handling¶
Branch on error.code, not on message or status alone:
import httpx
try:
response = client.patch(url, json=data, headers={"If-Match": etag})
response.raise_for_status()
except httpx.HTTPStatusError as exc:
envelope = exc.response.json()
code = envelope.get("error", {}).get("code")
if code == "ETAG_MISMATCH":
refetch_and_merge()
elif code == "CASCADE_DENY":
details = envelope["error"]["details"]
show_user(f"Cannot delete — referenced by {details['count']} {details['referenced_by']}.")
elif code == "VALIDATION_ERROR":
show_field_errors(envelope["error"]["details"]["errors"])
else:
raise
Request correlation¶
Every response, success or failure, includes a request_id — either echoed back from the caller's X-Request-Id header or generated by the server. The error envelope also includes it under the top-level request_id key:
curl -i -H 'X-Request-Id: smoke-test-1' https://api.example.com/products/does-not-exist
# HTTP/1.1 404 Not Found
# X-Request-Id: smoke-test-1
#
# {
# "error": {"code": "NOT_FOUND", "message": "...", "details": {...}},
# "request_id": "smoke-test-1"
# }
Operators can grep logs for that id to see every log line the request emitted on the server.
The unhandled case¶
Exceptions that are not CraftEasyError and not pydantic.ValidationError fall through to a catch-all handler that:
- Logs a full traceback with the request context.
- Returns
500 INTERNAL_ERRORwith a generic "An unexpected error occurred" message. - Never leaks the exception message or traceback to the client.
This is intentional: internal details stay in logs, clients get a stable, safe envelope.
Source¶
craft_easy/core/errors/exceptions.py—CraftEasyErrorand every subclass.craft_easy/core/errors/handlers.py—register_error_handlers, the three handler functions, and the JSON encoder that turnsObjectIdand other BSON types into strings.