baukit-http 0.10.29

Shared Axum middleware, error, and HTTP telemetry conventions.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
# baukit-http

`baukit-http` gives every Baukit service one request lifecycle. `finalize` wraps a router with
extractor and routing errors, request identity, W3C trace extraction and propagation, route-template
spans, HTTP RED metrics, panic and timeout envelopes, body and concurrency limits, and explicit CORS.

```rust
use axum::{Router, routing::get};
use baukit_http::{HttpOptions, JsonRejectionCodes, RequestId, finalize};

async fn hello(request_id: RequestId) -> String {
    format!("hello from request {}", request_id.as_str())
}

let options = HttpOptions::default()
    .with_allowed_origins(["https://app.example.com"])?
    .with_additional_allowed_headers(["accept", "x-webhook-secret"])?
    .with_json_rejection_codes(JsonRejectionCodes::default());
let app = finalize(Router::new().route("/hello", get(hello)), options);
# let _: Router = app;
# Ok::<(), baukit_http::HttpOptionsError>(())
```

Defaults are a 2 MiB body limit, 1,024 concurrent requests, and a 30 second timeout. CORS origins
start empty and have to be named; there is no permissive default to forget to tighten.

## Browser-visible headers

Browsers hide response headers from cross-origin scripts unless `Access-Control-Expose-Headers`
names them. The default exposed set is `x-request-id`, `traceparent`, `tracestate`,
`Retry-After`, `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`, `ETag`, and `Location`.
That covers the request identity, every header `baukit-ratelimit` emits, the revision validator,
and the address of a created resource. The default allowed request headers are `Authorization`,
`Content-Type`, `If-Match`, `Idempotency-Key`, `x-request-id`, `traceparent`, and `tracestate`, so
a browser can send a conditional or keyed write. Add product headers with `with_additional_exposed_headers`:

```rust
use baukit_http::HttpOptions;

let options = HttpOptions::default()
    .with_allowed_origins(["https://app.example.com"])?
    .with_additional_exposed_headers(["x-next-cursor"])?;
assert_eq!(options.additional_exposed_headers().len(), 1);
# Ok::<(), baukit_http::HttpOptionsError>(())
```

CORS headers only reach responses produced inside `finalize`. Add authentication and rate-limit
layers to the router before calling `finalize`, or their 401 and 429 responses reach the browser
without CORS headers and look like network errors.

## Cache policy

API responses usually carry per-user data, so `finalize` adds `Cache-Control: private, no-store` to
every response that has no `Cache-Control` header yet. A handler that sets its own value keeps it,
which is how a public document such as an OpenAPI file opts into caching. Products that own the
header everywhere turn the default off:

```rust
use baukit_http::{HttpOptions, ResponseCachePolicy};

let options = HttpOptions::default().with_response_cache_policy(ResponseCachePolicy::HandlerOwned);
assert_eq!(options.response_cache_policy(), ResponseCachePolicy::HandlerOwned);
```

The `baukit-ops` health, readiness, build-info, and metrics routes run on the separate operations
listener without these layers, so the policy does not touch them.

## Errors say the same thing every time

`ApiError` produces the `{ "error": { "code", "message", "requestId", "details" } }` envelope from
`baukit-openapi`, so the documented schema and the actual response body come from one type.

Constructors cover the usual cases: `bad_request`, `validation_field`, `unauthenticated`,
`permission_denied`, `not_found`, `conflict`, `rate_limited`. The interesting one is `internal`:

```rust
use baukit_http::ApiError;

# fn example(error: std::io::Error) -> ApiError {
ApiError::internal(error)
# }
```

It takes ownership of the cause, keeps it for logging, and returns a flat "An internal error
occurred" to the client. Leaking a driver error to a caller is how connection strings, table names,
and internal hostnames end up in someone's browser console. The type makes doing it right the shorter
path.

Use `with_header` to add response headers without wrapping `ApiError`. `with_retry_after` writes a
`Retry-After` value in delta seconds, which covers the common quota response:

```rust
use axum::http::{HeaderValue, header};
use baukit_http::ApiError;

# fn quota_error() -> ApiError {
ApiError::rate_limited()
    .with_retry_after(30)
    .with_header(header::CACHE_CONTROL, HeaderValue::from_static("no-store"))
# }
```

If code adds `X-Request-Id` through `with_header`, the request middleware replaces it with the
request's actual ID.

Extractor and routing failures produce the same envelope. A malformed JSON body should not return
Axum's default plain-text rejection while every other error on the service returns structured JSON;
clients then need two parsers for one API.

### JSON rejection classes

`ApiJson<T>` can retain the reason Axum rejected a JSON body. Enable this behavior with
`HttpOptions::with_json_rejection_codes`. The default class codes are:

| Rejection | Status | Default code | Safe detail |
| --- | ---: | --- | --- |
| Body exceeds the configured limit | 413 | `payload_too_large` | None |
| Missing or invalid JSON content type | 415 | `unsupported_media_type` | None |
| Malformed JSON | 400 | `invalid_json` | `body: must contain valid JSON` |
| JSON does not match the target type | 422 | `validation_failed` | `body: must match the request schema` |

Use `JsonRejectionCodes::new` when a product already has different public codes:

```rust
use baukit_http::{HttpOptions, JsonRejectionCodes};

let codes = JsonRejectionCodes::new(
    "payload_too_large",
    "invalid_content_type",
    "malformed_payload",
    "invalid_payload",
)?;
let options = HttpOptions::default().with_json_rejection_codes(codes);
# let _ = options;
# Ok::<(), baukit_http::HttpOptionsError>(())
```

Baukit never copies the submitted body, Axum rejection text, or serde parser details into the
response or request logs. Products map the stable codes to their own user-facing text. Products also
set route-specific body limits, for example with Axum's `DefaultBodyLimit`.

### Migration from one JSON rejection code

No change is required for current consumers. `HttpOptions::default()` and
`with_json_rejection_code("invalid_json")` keep the previous behavior: every `ApiJson<T>` rejection
returns status 400 with the one configured code. This compatibility mode remains available for this
release cycle.

To distinguish oversized bodies and content-type failures, replace `with_json_rejection_code` with
`with_json_rejection_codes`. Review clients for the new 413, 415, and 422 statuses before making the
switch. The global `HttpOptions::body_size_limit` uses the configured body-too-large code in
class-specific mode.

## Request locale extraction

`RequestLocale` selects only from product-configured locales. Put `RequestLocaleConfig` in Axum
state directly, or derive `FromRef` for a field in the application state.

```rust
use axum::{Router, routing::get};
use baukit_http::{LocaleQueryOverride, RequestLocale, RequestLocaleConfig};

async fn greeting(locale: RequestLocale) -> String {
    format!("locale={}", locale.as_str())
}

let locale_config = RequestLocaleConfig::new(
    ["en", "de", "es-MX"],
    "en",
    LocaleQueryOverride::parameter("locale")?,
)?;
let app = Router::new()
    .route("/greeting", get(greeting))
    .with_state(locale_config);
# let _: Router = app;
# Ok::<(), baukit_http::RequestLocaleConfigError>(())
```

An enabled, percent-decoded query override wins over `Accept-Language`. An unsupported explicit
query locale is a 400 validation error. Header choices use the highest quality value, then header
order for equal values. Locale lookup checks an exact configured tag, a configured regional tag for
a bare language, then progressively shorter requested tags. Configuration order resolves multiple
matches for one range. A wildcard selects the first configured locale. Missing or unmatched headers
use the configured fallback.

Malformed percent escapes, duplicate override parameters, malformed language ranges or quality
values, and oversized inputs return a 400 `validation_failed` envelope. The raw query limit is 2,048
bytes and the combined `Accept-Language` limit is 1,024 bytes. The extractor does not log or return
submitted values.

This API is additive. Existing handlers keep their current locale behavior until they add
`RequestLocaleConfig` to state and accept `RequestLocale`. Product locale lists and translated copy
remain outside `baukit-http`.

## Request identity and tracing

Every request carries a `RequestId`, echoed in `X-Request-Id` and available as an extractor. It goes
into the error envelope too, so a user reporting a failure hands you the exact ID to grep for.

`extract_trace_context` reads inbound W3C trace headers and `inject_trace_context` puts the current
context on an outbound request, which is what keeps one trace intact across service hops. Spans are
named by route template rather than by concrete path, so `/widgets/{id}` is one span name instead of
one per widget.

## Metrics

The crate records `http_requests_total`, `http_request_duration_seconds`, and
`http_requests_in_flight` through the `metrics` facade and never installs a recorder. The recorder
owner, normally `baukit-telemetry`, configures the duration histogram with `DURATION_BUCKETS`.

One recorder per process is the reason for that split. Two crates each installing their own is a
runtime conflict, and buckets configured in two places drift apart.

## Keyset pagination

`baukit_core::pagination` owns `PageParams`, `Page`, `PageKey`, and `Cursor`, which implement keyset
pagination with opaque cursors bound to the request filters. They live in `baukit-core` behind its
`pagination` feature, so domain and service crates can build pages without depending on Axum.
`baukit-http` enables that feature and converts `PaginationError` into a field-level `ApiError`:

```rust
# use axum::extract::Query;
# use baukit_core::pagination::{Page, PageKey, PageParams};
# use baukit_http::{ApiError, ResponseEnvelope};
# use serde::{Deserialize, Serialize};
# use uuid::Uuid;
# #[derive(Deserialize)]
# struct ListQuery { limit: Option<i64>, cursor: Option<String>, category: Option<String> }
# #[derive(Serialize)]
# struct Filters { category: Option<String> }
# #[derive(Clone, Serialize)]
# struct Item { id: Uuid, name: String }
# #[derive(Serialize)]
# #[serde(rename_all = "camelCase")]
# struct PageMeta { next_cursor: Option<String> }
async fn list(
    Query(query): Query<ListQuery>,
) -> Result<ResponseEnvelope<Vec<Item>, PageMeta>, ApiError> {
    let params = PageParams::new(query.limit, query.cursor)?;
    let filters = Filters { category: query.category };
    let after = params.decode_cursor(&filters)?;

    // Fetch `params.fetch_limit()?` rows ordered by (name, id), starting after
    // `after.page_key::<String>()?` when it is present.
    # let _ = after;
    let rows: Vec<Item> = Vec::new();

    let page = Page::from_rows(rows, &params, &filters, |item| {
        PageKey::new(item.name.clone(), item.id)
    })?;
    Ok(ResponseEnvelope::new(page.items, PageMeta { next_cursor: page.next_cursor }))
}
# let _ = list;
```

Binding the cursor to the filters is what makes it safe. A cursor from a `category=books` query
replayed against `category=tools` is rejected instead of paging through the wrong result set from a
meaningless offset. Keyset beats `OFFSET` for the usual reason: page 500 costs the same as page 1,
and rows inserted mid-scroll do not shift everything down by one.

`Cursor::decode` rejects input longer than `MAX_CURSOR_BYTES` (4096) with
`PaginationError::InvalidCursor` before it decodes anything, and `Cursor::encode` refuses to issue a
cursor above that bound.

## Revision preconditions

A write that must not overwrite someone else's change carries the ETag from the last read in
`If-Match`. `RevisionEtag` formats and parses strong ETags of the form `"<prefix><revision>"`. The
prefix is the product's, such as `rev-` or `settings-`, and may be empty. It is compared byte for
byte, so `REV-4` does not match `rev-`. The revision is a `Revision`, a non-negative integer up to
`i64::MAX` in canonical decimal: `0` is valid, `01`, `+1`, and `-0` are not.

```rust
use axum::http::{HeaderMap, StatusCode, header};
use baukit_http::{ApiError, Revision, RevisionEtag, ensure_current_revision};

const PLAN_ETAG: RevisionEtag<'static> = RevisionEtag::new("rev-");

async fn update_plan(headers: HeaderMap) -> Result<(StatusCode, HeaderMap), ApiError> {
    let expected = PLAN_ETAG.required_if_match(&headers)?;
    let stored = Revision::try_from(7_i64)?;
    ensure_current_revision(expected, stored)?;
    let mut response = HeaderMap::new();
    response.insert(header::ETAG, PLAN_ETAG.header_value(Revision::try_from(8_i64)?));
    Ok((StatusCode::OK, response))
}
# let _ = update_plan;
```

Each route picks `required_if_match` or `optional_if_match`. The parser accepts exactly one strong
ETag, with surrounding spaces and tabs trimmed. Every failure is a `PreconditionError` that
converts into `ApiError`:

| Case | Status | `code` | `details` |
| --- | --- | --- | --- |
| Required header missing | 428 | `precondition_required` | none |
| `*`, weak ETag, list, repeated header, non-ASCII bytes, wrong prefix, bad revision | 400 | `invalid_if_match` | `reason`, one of the `InvalidIfMatch::reason` values |
| Revision is not the stored one | 412 | `precondition_failed` | `currentRevision` when known |

The comparison against storage stays in the product, because it usually happens inside the
update statement. `ensure_current_revision` turns a mismatch into the 412. When the store only
reports that nothing matched, return `PreconditionError::Stale { current: None }`. A stored value
outside the `Revision` range converts into a 500 through `RevisionOutOfRange`.

`RevisionEtag::new` panics on an invalid prefix, which is a compile error in a `const`. Use
`RevisionEtag::try_new` for a prefix built at runtime, such as one that embeds a resource ID.
`baukit-openapi` documents the header parameter and the `ETag` response header with
`document_if_match` and `document_etag`. The shared vectors live in
`fixtures/etag-preconditions/vectors-v1.json`.

## Idempotency keys

A client that loses the response to a create resends it with the same `Idempotency-Key`, and the
server replays the stored result instead of creating a second row. `IdempotencyKeyRule` parses the
header with bounds the route chooses. Storage, the fingerprint, and the replay stay in the product;
[replay-safe mutations](../../../docs/platform/replay-safe-mutations.md) is the protocol they
follow.

```rust
use axum::http::{HeaderMap, StatusCode};
use baukit_http::{ApiError, IdempotencyError, IdempotencyKeyRule};

const CREATE_NOTE_KEY: IdempotencyKeyRule = IdempotencyKeyRule::new(1, 128);

# enum Claim { New, Replay(StatusCode), Reused }
# fn claim_in_transaction(_key: &str) -> Claim { Claim::New }
async fn create_note(headers: HeaderMap) -> Result<StatusCode, ApiError> {
    let key = CREATE_NOTE_KEY.required(&headers)?;
    match claim_in_transaction(key.as_str()) {
        Claim::Replay(stored_status) => Ok(stored_status),
        Claim::Reused => Err(IdempotencyError::Reused.into()),
        Claim::New => Ok(StatusCode::CREATED),
    }
}
# let _ = create_note;
```

Each route picks `required` or `optional`. An optional route with no header returns `Ok(None)` and
runs without a replay record. A key is one header of `min..=max` bytes of visible ASCII, and `max`
is at most `MAX_IDEMPOTENCY_KEY_BYTES` (255). The value is opaque. Nothing is trimmed, and quotes
are part of it. `IdempotencyKey`'s `Debug` output hides the value. `IdempotencyKeyRule::new`
panics on bad bounds, which is a compile error in a `const`; `try_new` returns
`InvalidIdempotencyKeyRule` instead.

| Case | Status | `code` | `details` |
| --- | --- | --- | --- |
| Required header missing | 400 | `idempotency_key_required` | none |
| Repeated header, empty, too short, too long, or a byte outside visible ASCII | 400 | `invalid_idempotency_key` | `reason`, one of the `InvalidIdempotencyKey::reason` values |
| Same key and scope with a different fingerprint | 409 | `idempotency_key_reused` | none |
| The first request with the key is still running | 409 | `idempotency_key_in_progress` | none |

The product returns `IdempotencyError::Reused` or `InProgress` from its replay lookup. The
`baukit-test` replay-safe mutation check proves that lookup against real PostgreSQL.

## Outbound retries

`classify_http_status` turns an upstream response into a `RetryClass` so every outbound client in the
process shares one policy: `RetryAfter(duration)` when the upstream named a delay, `RateLimited` when
it did not, `Unavailable`, `Timeout`, `Revoked` for a rejected credential, and `Permanent`.

`Revoked` is separate from `Permanent` because the recovery differs. A revoked credential needs
re-authorization, and retrying it burns quota against an endpoint that will keep saying no.
`425 Too Early` is `Unavailable`: RFC 8470 section 5.2 lets a client retry once the request is no
longer sent as early data. `retry_after_from_headers` parses both the delay-seconds and HTTP-date
forms.

Every `5xx`, including `500`, `502`, `503`, and `504`, becomes `RetryAfter` when a retry
header supplies a valid delay. Without one, `504` stays `Timeout` and other `5xx` stay
`Unavailable`. `429` uses `RetryAfter` with a usable delay and `RateLimited` otherwise.

`401` and `403` are `Revoked` by default. For APIs such as GitHub, pass
`RetryHeaderOptions::default().with_forbidden_rate_limit()` to `classify_http_status_with_options`.
A `403` with a usable `Retry-After` or configured vendor retry header becomes `RetryAfter`.
Otherwise, `x-ratelimit-remaining: 0` uses `x-ratelimit-reset` as a Unix epoch in seconds,
or gives `RateLimited` without a usable reset. Past resets give a zero delay. Other `403`
responses and all `401` responses stay `Revoked`. Use `classify_http_status_with_options_at`
to supply the reference time in tests.

The delay is uncapped by default, so the classifier reports what the upstream sent. A worker that
honors `Retry-After: 86400` sleeps for a day, so a client that schedules its own retries sets a
cap. Delays above it are clamped to it:

```rust
use std::time::Duration;

use axum::http::{HeaderMap, HeaderValue, StatusCode, header::RETRY_AFTER};
use baukit_http::{RetryClass, RetryHeaderOptions, classify_http_status_with_options};

let mut headers = HeaderMap::new();
headers.insert(RETRY_AFTER, HeaderValue::from_static("86400"));
let options = RetryHeaderOptions::default().with_max_retry_after(Duration::from_secs(300));
assert_eq!(
    classify_http_status_with_options(StatusCode::SERVICE_UNAVAILABLE, &headers, options),
    RetryClass::RetryAfter(Duration::from_secs(300))
);
```

`baukit_egress::GuardedClient` applies a 300 second cap by default.

## Scope

The crate owns the lifecycle around your handlers, not the handlers. No business logic, no
persistence, no authentication; `baukit-auth` layers that on top.