> ## Documentation Index
> Fetch the complete documentation index at: https://handbook.polar.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# ADR-0002: Business errors are status-coded PolarError subclasses

> Logical errors carry an explicit HTTP status; 422 is reserved for payload validation.

<Info>
  **Status**: Accepted

  **Area**: Backend

  **Date**: 2026-07-02
</Info>

## Context

`PolarError`'s default status code is 500, and Sentry reports every 500 as a hard crash. So a
normal business conflict (say, "you already have an active subscription") raised without an
explicit status pages on-call as if the service fell over, burying real incidents. Separately,
422 means one specific thing to clients: the request payload was malformed.

## Decision

For logical and conflict errors, raise `PolarError` subclasses with an explicit `status_code`
(404, 409, 410, 403, and so on) and declare them on the endpoint's `responses=` so the
generated client gets the error schema. Reserve 422 for request-payload validation: never
re-raise a business error as a validation error.

## Consequences

* Clients get correct HTTP status codes, and expected conflicts stay out of the crash reporter.
* The generated TypeScript client gets typed error shapes via `PolarError.schema()`.
* Don't add a content-less `422: {"description": ...}` override: it clobbers FastAPI's default
  `HTTPValidationError` and breaks the generated client.

## Alternatives considered

* **Let business errors fall through as bare 500s**: right response body, but pages on-call and
  pollutes Sentry.
* **Raise 422 for logical failures**: misleads clients into thinking they sent bad JSON.

## References

* `server/polar/exceptions.py` (base + subclasses), `server/polar/exception_handlers.py`
  (handlers, 422 separation).
* Real usage: `server/polar/checkout/service.py`, `server/polar/checkout/endpoints.py`.
* `server/AGENTS.md`, "Errors to status-coded `PolarError`".
