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.
Errors say the same thing every time
ApiError produces the { "error": { "code", "message", "request_id", "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
PageParams, Page, and PageKey implement keyset pagination with opaque cursors that are bound to
the request filters:
# use Query;
# 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.
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.
retry_after_from_headers parses both the delay-seconds and HTTP-date forms.
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.