One reading per request
The engine's generated current() carries one warning: call it once per
request and reuse the value — a reload landing between two calls would
otherwise let one request observe two configurations. With one section
that is easy advice. With two it is impossible advice, because "the same
generation" is a property of a pair of reads that no single call site
can see:
async fn handler() -> String {
let server = ServerConfig::current(); // generation 7
// a reload lands here
let features = FeaturesConfig::current(); // generation 8
// this response now mixes two documents
}
Both reads are correct. The response is not.
These five crates turn the advice into something the type system arranges: a layer takes one snapshot when the request begins, and every extractor in the handler reads out of that snapshot.
[dependencies]
dynamic-config-axum = "<version>"
async fn handler(
Config(server): Config<ServerConfig>,
Config(features): Config<FeaturesConfig>,
) -> String {
// One reading. These came from one snapshot.
format!("{} {}", server.port(), features.cache())
}
The five crates
| Crate | What it is | MSRV |
|---|---|---|
dynamic-config-web-core | the snapshot and the section list — no framework | 1.71 |
dynamic-config-tower | the layer/service pair over any tower stack | 1.71 |
dynamic-config-axum | the tower layer re-exported + a FromRequestParts extractor | 1.80 |
dynamic-config-actix | the same two pieces through Actix's Transform/FromRequest | 1.88 |
dynamic-config-loco | the Initializer Loco asks a library for, over the axum crate | 1.94 |
What they are not
They own no lifecycle. Loading, watching and the WatchHandle stay
in your main, exactly where the engine's book puts them. They ship no
routes: status(), check() and Exposition are public engine
surface, and Production Surface shows that a
handler over them is shorter than a route surface would be to adopt.
They add nothing to the engine — everything here is closures over
API that already exists.
The engine, the stores, and the Python and Node ecosystems each have
their own book — the family page
is the map. The Python web package makes the opposite choice to this
one (it ships routes and a lifecycle), for a reason that does not apply
here: a Python service has no tower to compose with.
Quick Start
[dependencies]
dynamic-config = { version = "<version>", features = ["toml", "watch"] }
dynamic-config-axum = "<version>"
use std::time::Duration;
use axum::{routing::get, Router};
use dynamic_config::dynamic_config;
use dynamic_config_axum::{sections, Config, SnapshotLayer};
use serde::Deserialize;
#[dynamic_config]
#[derive(Deserialize)]
struct Server {
host: String,
port: u16,
}
#[dynamic_config]
#[derive(Deserialize)]
struct Features {
cache: bool,
}
async fn index(
Config(server): Config<Server>,
Config(features): Config<Features>,
) -> String {
format!("{}:{} cache={}", server.host, server.port, features.cache)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// The lifecycle is yours, unchanged: load before serving, watch after.
Server::builder("server").file("config.toml").init()?;
Features::builder("features").file("config.toml").init()?;
let _watchers = [
Server::builder("server")
.file("config.toml")
.watch(Duration::from_millis(250))?,
Features::builder("features")
.file("config.toml")
.watch(Duration::from_millis(250))?,
];
let app = Router::new()
.route("/", get(index))
// After the routes it covers, like any tower layer.
.layer(SnapshotLayer::new(sections![Server, Features]));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
Edit config.toml while it serves. The next request answers with the
new document; no request ever mixes two. That is the whole product —
One Reading per Request is what it promises precisely,
and each framework chapter is the same wiring through different seams.
Two mistakes the crates catch for you, loudly:
- A handler asking for a section the layer was not given gets a 500 naming the type and the fix — a wiring bug, not a client error.
.layer()before.route()compiles and 500s on every request — the axum chapter says why, and the error text points here.
Runnable versions of exactly this: cargo run -p dynamic-config-axum --example axum_two_sections, and siblings for Actix and Loco.
One Reading per Request
What the snapshot promises, stated exactly — including the two places where the promise ends, on purpose.
The promise
SnapshotLayer calls Sections::take() once, before anything downstream
runs. take() reads every section's install counter, reads the sections,
and reads the counters again; if anything moved it starts over, up to
eight times. A snapshot that would have straddled a reload is therefore
refused and retaken — one request, one reading, however many sections
and however many extractors.
Within one request:
Config<T>twice answers the sameArc— not merely equal.- Two different sections came from one read pass that no install interrupted.
Where the promise ends, honestly
Two sections at different versions is not a tear. Each configuration
has its own atomic cell and the engine keeps no epoch across them. If
server.toml reloaded at 12:00:00 and features.toml at 12:00:02, a
request between those moments correctly sees the new server document
with the old features one — that is the true state of the world, not a
mixed read. What take() refuses is a snapshot whose reads straddled an
install; it cannot promise cross-file simultaneity that never existed.
A writer faster than the retry budget wins. Something reloading so
fast that eight consecutive read passes are all disturbed exhausts the
retry, and the last read is served unchecked — no worse than not
checking, which is what every caller had before these crates. Real
reloads are file events milliseconds apart; the stress test in
web-core pins exactly this boundary.
The errors are part of the design
NotInScope has two variants because the two mistakes have different
fixes: NotListed means add the section to sections![...];
NotLoaded means call init() before serving. The rejection a client
sees names neither a type path nor a value — the detail is in Display,
for whoever reads the logs.
Production Surface
The crates ship no routes, because the engine's pieces are public and a handler over them is shorter than a route surface is to adopt. These are the handlers, complete — copy, paste, adjust the types.
Liveness and readiness
Two questions, not one. /healthz says the process is alive and must
not fail on configuration — a process that cannot reload should be taken
out of rotation, not restarted into reading the same broken file.
/readyz is where nothing ever loaded and the reloads are failing
answer 503.
use axum::{http::StatusCode, Json};
use serde_json::{json, Value};
async fn healthz() -> StatusCode {
StatusCode::OK
}
async fn readyz() -> (StatusCode, Json<Value>) {
let status = ServerConfig::status();
let code = if status.generation == 0 || !status.is_healthy() {
StatusCode::SERVICE_UNAVAILABLE
} else {
StatusCode::OK
};
(code, Json(json!({
"generation": status.generation,
"healthy": status.is_healthy(),
})))
}
Prometheus metrics
Exposition renders the text format with no metrics dependency:
use dynamic_config::telemetry::Exposition;
async fn metrics() -> ([(&'static str, &'static str); 1], String) {
let mut exposition = Exposition::new();
exposition.add::<ServerConfig>("server");
exposition.add::<FeaturesConfig>("features");
(
[("content-type", "text/plain; version=0.0.4")],
exposition.render(),
)
}
Guarded diagnostics
explain renders every layer's answer for one dotted path, redacted by
default. Behind a token, constant-time compared:
use axum::extract::Path;
use axum::http::HeaderMap;
const TOKEN: &str = env!("CONFIG_TOKEN");
fn allowed(headers: &HeaderMap) -> bool {
headers
.get("x-config-token")
.and_then(|value| value.to_str().ok())
.is_some_and(|offered| {
use subtle::ConstantTimeEq;
offered.as_bytes().ct_eq(TOKEN.as_bytes()).into()
})
}
async fn explain(headers: HeaderMap, Path(path): Path<String>) -> (StatusCode, String) {
if !allowed(&headers) {
// 404, so a scanner learns nothing from the difference.
return (StatusCode::NOT_FOUND, String::new());
}
match ServerConfig::explain(&path) {
Ok(explanation) => (StatusCode::OK, explanation.redacted().to_string()),
Err(error) => (StatusCode::BAD_REQUEST, error.to_string()),
}
}
Graceful shutdown
The watchers are RAII: hold the handles in main, and dropping them on
the way out stops the threads. With axum's with_graceful_shutdown,
nothing else is needed — the engine holds no state that needs flushing,
because every install already happened atomically.
let shutdown = async {
tokio::signal::ctrl_c().await.ok();
};
axum::serve(listener, app).with_graceful_shutdown(shutdown).await?;
// _watchers drop here; the threads end.
The Python web package ships this whole page as code
(/healthz, /readyz, /metrics, guards, test doors). This book ships
it as recipes instead, on purpose: a Rust service composes these in
minutes from public engine surface, and a crate would freeze choices —
which metrics names, which token header — that are rightly yours.
axum
[dependencies]
dynamic-config-axum = "<version>"
Two pieces: SnapshotLayer (the tower layer, re-exported from
dynamic-config-tower) and Config<T> (a FromRequestParts extractor).
Request extensions are axum's request scope, so there is no task-local
and nothing to unwind.
let app = Router::new()
.route("/", get(handler))
.layer(SnapshotLayer::new(sections![Server, Features]));
Layer order is load-bearing
.layer() wraps only the routes present when it is called. This
compiles, and answers 500 on every request:
let app = Router::new()
.layer(SnapshotLayer::new(sections![Server])) // wraps nothing
.route("/", get(handler)); // added after
The 500's Display names this page. Put the layer after the routes.
Nesting merges
An outer Router and a nested one may each carry a layer. The outer
runs first; the inner one merges into what the outer took rather than
replacing it, so a handler under both sees the union — inner wins on a
type both list.
The escape hatch
snapshot(&Parts) answers the request's &Snapshot for code that is
not an extractor — another middleware, a guard, a handler taking
Request whole. SnapshotMissing::NoLayer is its error, and it means
the layer did not run for this route.
Dynamic<T> instances
sections![A, B] expands to try_current/generation closures on the
static slots. An instance-based Dynamic<T> registers by hand:
let sections = Sections::new()
.section_with_generation(
{ let handle = handle.clone(); move || Some(handle.current()) },
move || handle.generation(),
);
Tests: dynamic-config-axum/tests/scope.rs asks the eight questions
every adapter answers; axum_two_sections is the runnable example.
Actix Web
[dependencies]
dynamic-config-actix = "<version>"
The same two pieces through Actix's seams: DynamicConfig is a
Transform middleware, Config<T> a FromRequest extractor.
App::new()
.wrap(DynamicConfig::new(sections![Server, Features]))
.service(handler);
Where Actix differs from axum
wraporder reads outside-in — the lastwrapruns first. The snapshot middleware can sit anywhere; it only needs to run before the handler.- Extensions live behind a
RefCell, so the free functionsnapshot(&HttpRequest)answers a clone of the snapshot rather than a reference — cheap (it is a map ofArcs), and the reason its signature differs from axum's. - Scoped services nest exactly as axum's routers do: an outer
wrapand aweb::scope(...).wrap(...)merge, inner wins per type. - The rejection type implements
ResponseError; the body a client sees is generic on purpose, and the type path stays inDisplayfor logs.
Workers each run their own copy of the app factory; the sections list is
behind an Arc, so N workers share one list and each request anywhere
takes its own snapshot.
Tests: dynamic-config-actix/tests/scope.rs — kept parallel to the
axum file, case for case; actix_two_sections is the runnable example.
Loco
[dependencies]
dynamic-config-loco = "<version>"
Loco is axum underneath, so the layer and the extractor are the axum
crate's, re-exported unchanged. What Loco adds is a place to register
one — the Initializer trait — and that registration is the whole
crate:
use dynamic_config_loco::{sections, DynamicConfig};
impl Hooks for App {
async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> {
Ok(vec![DynamicConfig::boxed(sections![Server, Features])])
}
}
Loco calls after_routes once with the router it finished building, so
every route the application declares is covered — including the ones
Loco adds itself.
Loco's own configuration is a different thing
config/development.yaml is where the database URL, the worker mode and
the listen port live. None of it reloads, and none of it should: Loco
binds its listener and builds its pool from those values once. This
crate is for the other half — what an operator turns while the service
runs. Keep those settings in their own file with their own
#[dynamic_config] sections, and leave ctx.config to Loco.
Where loading goes
Hooks::boot, before the router exists — the same rule as everywhere
else: the crate owns no lifecycle.
Tests drive the real after_routes with a real AppContext (via Loco's
own tests_cfg); loco_two_sections is the runnable example, and its
main is a faithful copy of what loco_rs::boot runs.
Plain tower
[dependencies]
dynamic-config-tower = "<version>"
For every tower stack that is not axum: tonic, plain hyper, a framework
of your own. SnapshotLayer wraps any Service<http::Request<B>>; the
snapshot goes into the request's extensions, and reading it back out is
yours:
use dynamic_config_tower::{sections, Snapshot, SnapshotLayer};
use tower::{service_fn, Layer, Service, ServiceExt};
let service = service_fn(|request: http::Request<Body>| async move {
let snapshot = request.extensions().get::<Snapshot>().expect("the layer ran");
let server = snapshot.require::<ServerConfig>()?; // errors name the fix
// …
});
let mut wired = SnapshotLayer::new(sections![ServerConfig]).layer(service);
Snapshot::require is the read that distinguishes the two mistakes:
NotListed (add it to sections![...]) and NotLoaded (call init()
before serving). get is the Option form for code with its own
opinion about absence.
With tonic, attach the layer through Server::builder().layer(...); a
gRPC method reads the snapshot from the request extensions exactly as
above. The Long-lived Connections page applies to
streaming RPCs verbatim.
Long-lived Connections
A WebSocket upgrade, an SSE route and a streaming body all begin as an HTTP request, so the layer gives each one a snapshot — and for the handshake that is correct: whether to accept, and from which configuration, is a request-scoped question.
What the snapshot must not become is the connection's configuration for life.
async fn socket(upgrade: WebSocketUpgrade, Config(server): Config<ServerConfig>) -> Response {
// `server` is right, HERE: the handshake's decisions are one reading.
if !server.websockets_enabled() {
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
upgrade.on_upgrade(|mut socket| async move {
// Do NOT move `server` in here as "the config". The connection
// may live for an hour; read fresh state where you use it:
while let Some(message) = socket.recv().await {
let limits = ServerConfig::current(); // per message batch
// …
}
})
}
The rule, stated once: the snapshot is the handshake's; inside the
connection loop, T::current() per iteration or per message batch. If
the protocol wants a push on change, the engine's changes() stream is
the event source — subscribe in the connection task and forward.
The Python package documents the same boundary from the other side (ASGI
gives a websocket scope no request scope at all); the two books
describe one behaviour. The extra care here is because in axum the temptation
compiles: an Arc<T> moves into on_upgrade without complaint, and
nothing warns that it will be stale by lunchtime.
Stability & Versioning
Five crates, one version, published together: each names the one below
it exactly (=x.y.z), so they cannot drift apart. The engine is named
with a caret and releases on its own schedule; nothing here waits for
it.
Beta, like the rest of the organisation: the surface is small on purpose and has not needed to move, but pre-1.0 a breaking change bumps the minor version and the changelog says so in its first line.
- Raising a crate's MSRV is breaking. The floors differ — 1.71,
1.71, 1.80, 1.88, 1.94 — because each crate pays only for what it
pulls in, and each is measured against a lockfile resolved by stable,
which is what a user's
cargo addproduces. - Adding a framework crate is additive. Removing one is breaking.
- The engine floor moving is not by itself breaking here: what matters is whether these crates' surface moved.
What will not be added, so nobody waits for it: a Wiring lifecycle,
mounted routes, health endpoints. The reasoning is on the
Introduction, and it is a charter, not a backlog.