Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

CrateWhat it isMSRV
dynamic-config-web-corethe snapshot and the section list — no framework1.71
dynamic-config-towerthe layer/service pair over any tower stack1.71
dynamic-config-axumthe tower layer re-exported + a FromRequestParts extractor1.80
dynamic-config-actixthe same two pieces through Actix's Transform/FromRequest1.88
dynamic-config-locothe Initializer Loco asks a library for, over the axum crate1.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 same Arc — 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

  • wrap order reads outside-in — the last wrap runs first. The snapshot middleware can sit anywhere; it only needs to run before the handler.
  • Extensions live behind a RefCell, so the free function snapshot(&HttpRequest) answers a clone of the snapshot rather than a reference — cheap (it is a map of Arcs), and the reason its signature differs from axum's.
  • Scoped services nest exactly as axum's routers do: an outer wrap and a web::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 in Display for 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 add produces.
  • 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.