Skip to content

Errors

Errors that carry a status, a why, and a fix, declared once in a catalog instead of at every raise site.

Updated View as Markdown

WidelogError carries an HTTP status and a machine-readable code, plus two fields written for whoever reads the log later: why says what went wrong, fix says what to do about it.

from widelog import WidelogError

raise WidelogError(
    "Payment failed",
    code="CARD_DECLINED",
    status=402,
    why="Issuer declined the charge",
    fix="Try a different payment method or contact your bank",
    internal={"processor_ref": "ch_live_x9"},
)

to_dict() returns every field except internal, so you can put the error straight on the wire. internal never leaves the process: not in the response, not in the event.

Wiring it into FastAPI

FastAPI converts the exception into a response before the middleware sees it, so the middleware only observes the status. Record the error in an exception handler:

@app.exception_handler(WidelogError)
async def on_widelog_error(request, exc):
    use_logger().error(exc)
    return JSONResponse(status_code=exc.status, content=exc.to_dict())

Declare errors once

Writing why and fix at every raise means they drift. A catalog declares each error once, and derives its code from the attribute name so the two cannot fall out of step.

from widelog import ErrorCatalog, ErrorSpec


class BillingErrors(ErrorCatalog, prefix="billing"):
    CART_EMPTY = ErrorSpec(status=400, message="Cart is empty")
    PAYMENT_DECLINED = ErrorSpec(
        status=402,
        message="Card declined",
        why="Issuer declined the charge",
        fix="Try a different payment method",
        link="https://docs.example.com/errors/payment-declined",
    )
    INSUFFICIENT_FUNDS = ErrorSpec(
        status=402,
        message=lambda available, required: f"Insufficient funds: ${available} of ${required}",
        fix="Add funds and retry",
    )

Raising one reads as the name of the thing that went wrong:

if not cart.items:
    raise BillingErrors.CART_EMPTY()          # code: billing.CART_EMPTY

Templated messages

A message that is a function turns its parameters into required keyword arguments, so a templated error cannot be raised with a value missing:

raise BillingErrors.INSUFFICIENT_FUNDS(available=balance, required=cart.total, cause=exc)

Misspell one and you get a TypeError at the call site rather than a message with a hole in it.

Overrides

Any spec field can be overridden per call. internal merges, with the call site winning:

raise BillingErrors.PAYMENT_DECLINED(
    link="/support/payment-issues",
    internal={"processor_ref": "ch_x"},
    cause=stripe_error,
)

Branching without repeating the string

except WidelogError as exc:
    if exc.code == BillingErrors.PAYMENT_DECLINED.code:
        ...

BillingErrors.CART_EMPTY.status   # 400
BillingErrors.codes()             # ('billing.CART_EMPTY', 'billing.PAYMENT_DECLINED', ...)

Rename the attribute and every code moves with it, because the code was never written out by hand.

A single error

For one error with no group to join, bind a spec to a code directly:

from widelog import ErrorFactory

fraud_detected = ErrorFactory(
    "billing.FRAUD_DETECTED",
    ErrorSpec(status=403, message="Transaction flagged for review"),
)
Navigation

Type to search…

↑↓ navigate↵ selectEsc close