Skip to content

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:

  1. Logs a full traceback with the request context.
  2. Returns 500 INTERNAL_ERROR with a generic "An unexpected error occurred" message.
  3. 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.pyCraftEasyError and every subclass.
  • craft_easy/core/errors/handlers.pyregister_error_handlers, the three handler functions, and the JSON encoder that turns ObjectId and other BSON types into strings.