A handler returning the right status code is not enough to make an API operable. You need to follow one request across logs without copying credentials into those logs. Large bodies and slow handlers need explicit ceilings, and invalid configuration should stop startup rather than fail after traffic arrives.
This fixture isolates the middleware boundary so it can be added to the application from Article 30 and exercised by the integration setup in Article 32. It uses Rust 2024 with pinned Axum 0.8.6, tower-http 0.6.8, tracing 0.1.41, and Tokio 1.53.1. Six deterministic tests run without an external network or wall-clock sleeps.
1. Validate configuration before building the router
AppConfig::try_from_pairs accepts a key-value map rather than reading process environment variables itself. Production startup can read the environment once and pass the values through this boundary. Tests avoid mutating process-global state.
pub struct AppConfig {
api_secret: String,
pub max_body_bytes: usize,
pub request_timeout: Duration,
}
pub enum ConfigError {
Missing(&'static str),
NotUnsigned(&'static str),
Zero(&'static str),
}
API_SECRET must be nonempty. MAX_BODY_BYTES and REQUEST_TIMEOUT_MS must parse as positive usize values. Parse failures and zero values have different typed errors, which lets deployment tooling report the faulty setting without printing the secret.
Timing matters here. Validation finishes before the router constructs either limit. An invalid configuration never creates a service that can briefly accept requests.
2. Assign a request ID before opening the span
The request span records request_id, http.method, and http.uri as fields instead of embedding them in a formatted sentence. A JSON subscriber or collector can index those stable names without parsing log prose.
TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
let request_id = request
.headers()
.get("x-request-id")
.and_then(|value| value.to_str().ok())
.unwrap_or("missing");
tracing::info_span!(
"http.request",
request_id,
http.method = %request.method(),
http.uri = %request.uri(),
)
})
Layer order changes behavior. The setter must run before tracing so the span can read the ID. Propagation runs around the inner service and copies that ID onto the response. The fixture's atomic counter emits values such as req-0000000000000001 for reproducible tests. A multi-process deployment that needs globally unique IDs should replace it with a UUID or an upstream identity policy.
Tower HTTP preserves an existing ID instead of replacing it. One test checks generated IDs; another sends caller-42 and expects the same response header. Whether an external caller is allowed to choose a trusted correlation ID belongs at the proxy boundary.
3. Use secrets without turning them into telemetry
The /echo route checks Authorization: Bearer ..., but it never returns the configured secret. The stronger rule is to avoid recording complete headers or payloads in the first place. The span uses a small allowlist: method, URI, and request ID.
ServiceBuilder::new()
.layer(RequestBodyLimitLayer::new(max_body_bytes))
.layer(SetSensitiveRequestHeadersLayer::new([AUTHORIZATION]))
.layer(SetRequestIdLayer::x_request_id(SequentialRequestId::default()))
.layer(TraceLayer::new_for_http())
.layer(PropagateRequestIdLayer::x_request_id())
.layer(TimeoutLayer::with_status_code(
StatusCode::GATEWAY_TIMEOUT,
request_timeout,
))
SetSensitiveRequestHeadersLayer marks Authorization before the trace layer observes the request, allowing header-aware formatters to redact it. That marker is defense in depth, not permission to log every header. Application events should also exclude tokens, passwords, cookies, and arbitrary JSON bodies.
Missing credentials return 401; valid credentials echo the input JSON. The tests confirm that neither path returns the secret. Constant-time credential comparison and a complete authentication scheme are outside this fixture. A deployed service should use a reviewed authentication layer and a secret manager.
4. Give body size and handler time separate budgets
RequestBodyLimitLayer converts a request above the configured byte ceiling into 413 Payload Too Large before the handler runs. Axum extractors have a default limit, but direct Body::poll_frame consumers bypass it. A service-wide layer keeps the outer boundary explicit.
TimeoutLayer::with_status_code returns an empty 504 Gateway Timeout when the handler future exceeds its budget. This does not replace a body-transfer idle timeout or an absolute upload deadline. Tower HTTP provides separate body timeout layers when those policies are required.
#[tokio::test(start_paused = true)]
async fn service_timeout_returns_504_without_wall_clock_waiting() {
let task = tokio::spawn(async move { app.oneshot(slow_request()).await.unwrap() });
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_millis(50)).await;
assert_eq!(task.await.unwrap().status(), StatusCode::GATEWAY_TIMEOUT);
}
The test pauses Tokio time and advances exactly 50ms. It remains fast and deterministic. At the boundary, the timeout response wins and the handler future is dropped. Dropping a future does not undo an external side effect that already started; database and remote-call code still needs a cancellation contract.
5. Keep liveness apart from readiness
/health/live returns 200 while the process can handle HTTP. /health/ready reads a shared AtomicBool, returning 503 before dependencies are ready and 200 afterward.
async fn ready(State(state): State<AppState>) -> StatusCode {
if state.runtime.ready.load(Ordering::Acquire) {
StatusCode::OK
} else {
StatusCode::SERVICE_UNAVAILABLE
}
}
Combining the probes can turn a temporary dependency failure into a process restart loop. A readiness endpoint that always returns 200 has the opposite problem: traffic reaches an instance that cannot serve it. Production readiness should include only conditions required to handle requests, such as migrations, a connection pool, or worker startup. Making every remote dependency mandatory can amplify an outage.
6. Run the fixture and its gates
Run the following commands from the repository root. They cover formatting, all-target compilation, warning-free Clippy, debug and release tests, and exact program output.
cd examples/article-31-api-observability-security
cargo fmt --all -- --check
cargo check --offline --all-targets --all-features
cargo clippy --offline --all-targets --all-features -- -D warnings
cargo test --offline --all-features
cargo test --offline --release --all-features
cargo run --offline --quiet
The binary prints:
ready_status=200
request_id=req-0000000000000001
max_body_bytes=1024
request_timeout_ms=100
The six tests cover invalid configuration, separate liveness and readiness, generated and preserved request IDs, the 32-byte request limit, a paused-time 504, and secret non-disclosure. The health contract does not confuse process survival with the ability to accept traffic.
Before integrating this fixture into Article 30, decide which request IDs to trust, what makes the application ready, and what a timed-out side effect means. Article 32 can then test proxy headers, a real listener, and graceful shutdown. Middleware supplies mechanisms; the service still owns the operating policy.
Full source code
The complete runnable source for this article is available in the Chapter 31 project on GitHub.
Leave a Reply