# Exception mapping layer for the ingestion API

Every upstream failure currently surfaces as a 500. The ticket asks for a mapping layer so callers can tell a bad request from an outage, and it is blocked on three disagreements between the design and the code.

| | |
| --- | --- |
| Status | CHANGES REQUESTED |
| Source | jira:RIC-1249 |
| Workspace | Showcase |
| Tags | api-design, error-handling, python, observability |

## What it is

**A translation layer between internal exceptions and HTTP status codes.**

In progress, assigned, and blocked on review. The design document and the implementation disagree in three places, which is what the reviewer flagged.

The ingestion API raises domain exceptions and lets them propagate. The framework turns anything unhandled into a 500, so a malformed payload and a dead database are indistinguishable to a caller, and both page whoever is on call.

The proposal is a single mapping registry: each domain exception declares its status code, whether it is retryable, and what the caller is told. Everything unmapped stays a 500, deliberately, so adding an exception without a decision does not silently become a 400.

- **One registry, not per-handler try blocks.** Scattered handling is how the current inconsistency happened. A single table can be reviewed in one sitting.
- **Unmapped means 500.** The default has to be the pessimistic one. A new exception defaulting to 400 would tell callers not to retry something they should.
- **Retryability is part of the mapping.** Clients need to know whether to back off. That is a property of the failure, not of the status code alone.

## Why now

**On-call is paging for client mistakes.**

The cost is not theoretical. It shows up in the alert volume and in customer tickets that say the API is down when it is not.

- **Malformed payloads page a human** (high)
  - A 500 triggers the error-rate alert regardless of cause, so a customer sending bad JSON wakes someone up.
  - Mitigation: Mapping validation failures to 422 removes them from the alert entirely.
- **Clients cannot implement backoff** (medium)
  - With every failure identical, a well-behaved client either retries everything or nothing. Both are wrong.
  - Mitigation: Publish retryability alongside the status code.

## Scope

**The registry and its wiring. Not the exceptions themselves.**

Renaming or restructuring the domain exceptions is explicitly out of scope, and the ticket says so.

| | In scope | Out of scope |
| --- | --- | --- |
| Exception to status mapping | Yes, the registry and its tests |  |
| Domain exception hierarchy |  | Left alone; a separate ticket |
| Client SDK changes |  | Follows once the contract is stable |
| Alert rule updates | Yes, error-rate alert scoped to 5xx |  |

## Plan

**Land the registry unused, then switch the handler over.**

Splitting it this way keeps the risky step to one line and makes the revert obvious.

1. **Add the registry with tests, wired to nothing.** Reviewable in isolation, and cannot change behaviour.
2. **Switch the error handler to consult it.** One line. This is the change that alters what callers see, and the one to revert if anything moves.
3. **Narrow the error-rate alert to 5xx.** Only after the mapping is live, or the alert goes quiet while 4xx failures are still being misreported.
4. **Document the contract and notify integrators.** Some callers treat any non-200 as retryable and will need to change.

## Receipts

**What is quoted here, and who said it.**

Taken from the ticket body and the review thread, not summarized from memory.

- **Claim.** The design and the implementation disagree in three places.
  - Evidence: Reviewer listed status code for quota errors, retryability of timeouts, and whether unmapped defaults to 500 or 400.
  - Where: review comment, changes requested
- **Claim.** Exception restructuring is out of scope.
  - Evidence: Ticket description: this ticket does not rename or reparent the existing exception classes.
  - Where: ticket description
- **Claim.** The error-rate alert does not distinguish cause.
  - Evidence: Alert condition is on total 5xx rate over five minutes.
  - Where: linked runbook

## Open questions

**Three decisions are blocking, and none of them are technical.**

Each one is a contract choice that needs a person to make it, not code.

- **Quota exceeded: 429 or 403?.** 429 implies retry after waiting. 403 implies the caller needs a plan change. The answer depends on whether quotas reset.
- **Are upstream timeouts retryable?.** The design says yes. The implementation marks them non-retryable, and ingestion is not idempotent, which may be why.
- **Should unmapped be 500 or 400?.** The design says 500 and the implementation agrees, but the reviewer asked. Worth writing down the reasoning so it stops being reopened.

> **Idempotency is the real question under the second one**
>
> Whether a timeout is retryable depends on whether replaying the request can double-write. Nothing in the ticket or the review says whether ingestion is idempotent, and the answer changes the mapping.

## What this brief could not check

- This brief was written by hand as a design fixture. It is modelled on real work, but no model read a diff to produce it, and its claims should not be relied on.
- The linked design document was not read; the three disagreements are described from the review comment that lists them.
- Whether ingestion is idempotent could not be determined from the ticket, and it decides the timeout question.
