# RFC 032 — Error Response Contract

**Status.** Implemented  
**Tracks.** API  
**Touches.** `src/api/responses.rs`, `src/error.rs`, `docs/api.md`

## Summary

Define the stable JSON shape and code vocabulary for all error responses, ensuring every
non-2xx response body is a valid JSON object with a consistent structure that clients can
depend on.

## Motivation

External clients need to distinguish between "my API key is wrong" (403), "the SMTP server
is down" (502), and "I sent too many requests" (429) to respond appropriately — retry, alert,
or fix the request. An inconsistent or undocumented error shape forces clients to parse
error messages as prose, which is fragile (FR-071, FR-072).

## Scope

- `ErrorResponse` JSON shape (canonical form).
- All error `code` strings and their meanings.
- HTTP status code for each error code.
- Policy: error responses are always JSON, even for framework-level errors (413, 415).
- `request_id` inclusion in all error responses.

## Non-goals

- Internal error logging (RFC 080).
- Rate limit `Retry-After` header (RFC 073).
- Validation-specific error details (RFC 050).

## Design

### `ErrorResponse` shape

```json
{
  "status": "error",
  "code": "<error_code>",
  "message": "<human_readable_string>",
  "request_id": "<uuid>"
}
```

All fields are always present. `message` is a stable, human-readable string but not guaranteed
to be parseable by machines — use `code` for machine parsing.

```rust
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
    pub status: &'static str,    // always "error"
    pub code: &'static str,      // stable, machine-parseable
    pub message: String,         // human-readable; may vary
    pub request_id: String,
}
```

### Error code table

| HTTP | `code` | `message` (example) | Trigger |
|------|--------|---------------------|---------|
| 400 | `bad_request` | "Request body is not valid JSON" | Malformed JSON |
| 400 | `validation_failed` | "Invalid email address in 'to' field" | Validation failure |
| 401 | `unauthorized` | "Missing or invalid Authorization header" | No auth credentials |
| 403 | `forbidden` | "Authentication failed" | Bad or disabled API key |
| 413 | `payload_too_large` | "Request body exceeds maximum allowed size" | Body limit exceeded |
| 415 | `unsupported_media_type` | "Content-Type must be application/json" | Wrong Content-Type |
| 429 | `rate_limited` | "Rate limit exceeded" | Rate limit |
| 502 | `smtp_unavailable` | "SMTP server is unavailable" | SMTP failure |
| 500 | `internal_error` | "An internal error occurred" | Unexpected error |

### Code stability guarantee

The `code` string values in the table above are part of the public API contract and will not
change within the v1 API lifecycle. New `code` values may be added, but existing ones will
not be renamed or removed.

The `message` string is informational and may change between patch versions.

### Ensuring JSON error responses for framework-level rejections

By default, Axum's `RequestBodyLimitLayer` and `ContentTypeCheckLayer` return their own error
types that may not produce JSON. A custom rejection handler is needed:

```rust
// In app.rs, configure custom rejection handlers
// so that 413 and 415 produce ErrorResponse JSON.
```

This is implemented by handling `PayloadTooLargeRejection` and `ContentTypeMismatch` in a
custom extractor or a fallback service that wraps them in `AppError::PayloadTooLarge` and
`AppError::UnsupportedMediaType`.

### 405 and 404 responses

- `404 Not Found` — returned for unknown paths. Body: `{"status":"error","code":"not_found","message":"Not found","request_id":"..."}`.
- `405 Method Not Allowed` — returned for wrong HTTP method. Body: `{"status":"error","code":"method_not_allowed","message":"Method not allowed","request_id":"..."}`.

Both require `request_id`. The `request_id` at this point may be generated by a fallback
handler that creates a new UUID (the `RequestContextLayer` may not have run).

## Implementation Plan

1. Confirm `AppError::IntoResponse` produces the `ErrorResponse` shape (RFC 012).
2. Add custom rejection handlers for Axum framework rejections (413, 415).
3. Add custom 404 and 405 handlers with JSON bodies.
4. Write integration tests for every error code.
5. Document all error codes in `docs/api.md`.

## Test Plan

### Integration Tests

- Each error code from the table is reachable via a specific request pattern:
  - `bad_request`: body is `{not json`
  - `validation_failed`: body is `{"to":"not-an-email","subject":"s","body":"b"}`
  - `unauthorized`: no Authorization header
  - `forbidden`: wrong token
  - `payload_too_large`: body exceeds limit
  - `unsupported_media_type`: `Content-Type: text/plain`
  - `rate_limited`: send > rate_limit_per_minute requests
  - `smtp_unavailable`: SMTP is down
  - `internal_error`: injected via test double
- All error responses have `Content-Type: application/json`.
- All error responses have `request_id` present and non-empty.
- 404 returns `{"code":"not_found",...}`.
- 405 returns `{"code":"method_not_allowed",...}`.

### Security Tests

- `internal_error` response does not contain stack traces or internal state.
- `forbidden` response does not reveal whether the key exists or is disabled.

## Security Considerations

- The `message` for `forbidden` and `unauthorized` must not reveal whether the API key
  exists, is disabled, or was recognized. Use a generic message for both.
- The `message` for `validation_failed` may include the field name but must never echo
  the raw value, which could contain injected content.
- `internal_error` responses must have a fixed, generic message in the response body;
  the actual internal detail is logged server-side only.

## Operational Considerations

- Clients should implement retry logic for `502 smtp_unavailable` with exponential backoff.
- Clients should implement retry logic for `429 rate_limited` using the `Retry-After` header
  (RFC 073) when present.
- All other error codes indicate a client error that retrying will not fix.

## Documentation Changes

- Document the error response shape and full code table in `docs/api.md`.

## Acceptance Criteria

| ID | Criterion |
|----|-----------|
| AC-032-01 | Every non-2xx response has `Content-Type: application/json`. |
| AC-032-02 | Every non-2xx response body matches the `ErrorResponse` shape. |
| AC-032-03 | Every non-2xx response includes `request_id`. |
| AC-032-04 | All error codes in the table are reachable and tested. |
| AC-032-05 | `internal_error` response body does not expose internal details. |
| AC-032-06 | 404 and 405 responses are JSON, not Axum defaults. |

## Open Questions

None.
