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_EMPTYTemplated 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"),
)