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.
use ;
use ;
async
let options = default
.with_allowed_origins?
.with_additional_allowed_headers?
.with_json_rejection_codes;
let app = finalize;
# let _: Router = app;
# Ok::
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:
use HttpOptions;
let options = default
.with_allowed_origins?
.with_additional_exposed_headers?;
assert_eq!;
# Ok::
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:
use ;
let options = default.with_response_cache_policy;
assert_eq!;
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:
use ApiError;
#
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:
use ;
use ApiError;
#
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:
use ;
let codes = new?;
let options = default.with_json_rejection_codes;
# let _ = options;
# Ok::
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.
use ;
use ;
async
let locale_config = new?;
let app = new
.route
.with_state;
# let _: Router = app;
# Ok::
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:
# use Query;
# use ;
# use ;
# use ;
# use Uuid;
#
#
#
#
#
#
#
#
#
async
# 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.
use ;
use ;
const PLAN_ETAG: = new;
async
# 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 is the protocol they
follow.
use ;
use ;
const CREATE_NOTE_KEY: IdempotencyKeyRule = new;
#
#
async
# 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:
use Duration;
use ;
use ;
let mut headers = new;
headers.insert;
let options = default.with_max_retry_after;
assert_eq!;
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.