Software Engineering WikiSE Wiki

Rust

Ownership and borrowing in practice, error handling, async, and the cargo commands that matter day to day.

Reviewed MarkdownEdit

On this page

Rust gives you memory safety and data-race freedom without a garbage collector by checking ownership and borrowing at compile time. The cost is that the compiler rejects programs a runtime-checked language would accept, so day-to-day work is largely about satisfying the borrow checker and choosing the right ownership shape. The sections below cover the rules that trip people up and the cargo commands that keep the loop fast. Check APIs against doc.rust-lang.org and tooling against the Cargo book.

Cheatsheet#

TaskCommand
Check without codegen (fast)cargo check --all-targets
Build optimisedcargo build --release
Run tests, show outputcargo test -- --nocapture
One testcargo test path::to::test_name
Lint hardcargo clippy --all-targets -- -D warnings
Formatcargo fmt --all (check: --check)
Explain an error coderustc --explain E0502
Expand a macrocargo expand
Dependency treecargo tree -d (duplicates)
Audit advisoriescargo audit
Update within semvercargo update
Add a dependencycargo add serde --features derive
Benchmarkcargo bench (or criterion)
Docs for this crate and depscargo doc --open
Binary size breakdowncargo bloat --release
MSRV / toolchain pinrust-toolchain.toml

Ownership in practice#

Every value has exactly one owner, and the value is dropped when that owner leaves scope. You may hold many shared references (&T) at once, or exactly one mutable reference (&mut T), but never both to the same data at the same time. This is what makes a data race and a use-after-free impossible to compile, and it is the rule behind most borrow-checker errors.

let s = String::from("hello");
let t = s;                  // move: ownership transfers, s is no longer usable
let u = t.clone();          // an explicit deep copy when you genuinely need two owners

fn read(v: &[u8]) {}        // borrow: the caller keeps ownership
fn consume(v: Vec<u8>) {}   // take ownership: the caller cannot use the value afterwards

Prefer borrowing in function signatures: take &str and &[T], return String and Vec<T>. That gives callers the most freedom and does not force an allocation just to call you.

ErrorWhat it meansUsual fix
E0382 use of moved valueYou gave the value away and used it againBorrow instead, or clone deliberately
E0502 mutable borrow while borrowedA shared and a mutable borrow overlapShorten the first borrow’s scope, or restructure
E0499 two mutable borrowsAliased mutation of the same dataSplit the data (split_at_mut), or index instead
E0597 does not live long enoughA reference outlives the value it points toOwn the data, or tie the lifetimes explicitly
E0308 mismatched typesOften String vs &str, or T vs &T&x, x.as_str(), or x.to_owned()

When a message is opaque, rustc --explain E05xx prints a worked example of the same error.

Errors#

Result<T, E> is how a fallible function reports failure, and ? propagates it up, converting the error type through a From impl on the way. Libraries expose a concrete, matchable error type so callers can branch; applications usually flatten everything into one contextual type. This is Rust’s answer to what Go does with wrapped error values.

use thiserror::Error;

#[derive(Debug, Error)]
pub enum StoreError {
    #[error("user {0} not found")]
    NotFound(i64),
    #[error("database: {0}")]
    Db(#[from] sqlx::Error),      // generates From<sqlx::Error>, so ? converts automatically
}

pub fn get(id: i64) -> Result<User, StoreError> {
    let row = query(id)?;          // sqlx::Error becomes StoreError::Db via #[from]
    row.ok_or(StoreError::NotFound(id))
}
use anyhow::{Context, Result};

fn main() -> Result<()> {
    let cfg = std::fs::read_to_string("config.toml")
        .context("reading config.toml")?;     // attaches a message, keeps the underlying source
    Ok(())
}

Use thiserror for libraries (typed, matchable) and anyhow for binaries (contextual, printable). unwrap() in production is a claim that the failure case is impossible; expect("reason") makes at least the reason visible in the panic message when the claim turns out to be wrong.

Options and iterators#

Option<T> encodes “value or nothing” in the type, so the compiler forces you to handle absence instead of dereferencing a null. Iterators are lazy adapters that compile down to the same machine code as a hand-written loop, so chaining map, filter and the rest costs nothing at runtime; nothing runs until a consumer like collect, sum or for drives it.

let name: Option<&str> = map.get("name").map(String::as_str);

let total: u64 = items.iter().filter(|i| i.active).map(|i| i.bytes).sum();
let first_err = results.iter().find_map(|r| r.as_ref().err());
let parsed: Result<Vec<i32>, _> = inputs.iter().map(|s| s.parse::<i32>()).collect();  // stops at the first Err
let (ok, bad): (Vec<_>, Vec<_>) = results.into_iter().partition(Result::is_ok);

value.unwrap_or_default();          // the type's default when None
value.unwrap_or_else(|| expensive());   // computed only when None
value.ok_or(Error::Missing)?;       // Option to Result, then propagate

Collecting into Result<Vec<_>, E> is the idiom for “all or nothing”: it returns the first error or the full vector.

Structs, traits and generics#

Traits define shared behaviour; generics let one function work over any type that implements a trait. Static dispatch (<S: Store>) monomorphises a copy per concrete type: fastest, largest binary. Dynamic dispatch (&dyn Store) keeps one copy behind a vtable: smaller code, one pointer indirection, and it lets you choose the type at runtime.

#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Config {
    pub name: String,
    #[serde(default = "default_port")]      // called when the field is absent from the input
    pub port: u16,
}

pub trait Store {
    fn get(&self, id: i64) -> Result<User, StoreError>;
}

fn handler<S: Store>(store: &S) {}          // static dispatch, monomorphised per type
fn handler_dyn(store: &dyn Store) {}        // dynamic dispatch, one copy of the code

Reach for generics by default and dyn Trait when the concrete type must be chosen at runtime or code size matters. impl Trait in argument position is shorthand for the generic form.

Shared state and concurrency#

The right wrapper depends on two questions: is the value shared across threads, and does it need mutation. Get this wrong and the compiler tells you, because Send and Sync are checked; the table below is the usual mapping.

NeedType
Single owner, single threadPlain value
Shared, single threadRc<T> / Rc<RefCell<T>>
Shared across threads, read-onlyArc<T>
Shared and mutable across threadsArc<Mutex<T>> or Arc<RwLock<T>>
CounterAtomicU64
Message passingstd::sync::mpsc or crossbeam / tokio::sync::mpsc
let state = Arc::new(Mutex::new(HashMap::new()));
let s = Arc::clone(&state);                // bump the refcount, not the data
std::thread::spawn(move || {
    s.lock().unwrap().insert("k", 1);      // lock() errors only if a previous holder panicked (poisoning)
});

A std::sync::MutexGuard held across an .await deadlocks an async runtime, because the task can be parked while still holding the lock. Use tokio::sync::Mutex there, or drop the guard before awaiting.

Async#

async functions return futures that do nothing until polled by a runtime such as Tokio. Concurrency comes from driving many futures on a few threads, so anything that blocks a thread (file I/O, std::thread::sleep, CPU-bound work) stalls every task on that worker. Move blocking work to tokio::task::spawn_blocking.

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let client = reqwest::Client::builder().timeout(Duration::from_secs(10)).build()?;

    let (a, b) = tokio::try_join!(fetch(&client, "/a"), fetch(&client, "/b"))?;   // both, fail on first error

    let results = futures::future::join_all(ids.iter().map(|id| fetch_one(&client, *id))).await;

    tokio::select! {                          // whichever branch completes first wins
        res = work() => res?,
        _ = tokio::time::sleep(Duration::from_secs(5)) => anyhow::bail!("timeout"),
    }
    Ok(())
}

Tests#

Unit tests live beside the code in a #[cfg(test)] mod tests and can reach private items; integration tests live in tests/ and see only the public API, which makes them the honest check of your interface. See testing for property-based and snapshot approaches.

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_valid_input() {
        assert_eq!(parse("a=1").unwrap(), Config { name: "a".into(), port: 1 });
    }

    #[test]
    fn rejects_empty() {
        assert!(matches!(parse(""), Err(ParseError::Empty)));   // match the variant, ignore its fields
    }

    #[tokio::test]
    async fn fetches() { /* an async test on the Tokio runtime */ }
}

Cargo and builds#

cargo drives the whole workflow: check type-checks without codegen for the fastest feedback, clippy adds lints beyond the compiler’s, and the release profile controls the size and speed of the shipped binary.

[profile.release]
lto = "thin"           # link-time optimisation across crates; "fat" is slower to build, marginally smaller
codegen-units = 1      # one unit gives the optimiser the whole crate, at the cost of parallel build time
panic = "abort"        # smaller and faster, but no stack unwinding and no catch_unwind
strip = "symbols"      # drop symbol tables from the binary
cargo build --release --target x86_64-unknown-linux-musl   # fully static binary for a scratch image
cargo test --workspace --all-features
cargo clippy --all-targets --all-features -- -D warnings   # treat every lint as an error, for CI
cargo tree -i openssl                                      # invert the tree: what pulls openssl in
cargo update -p serde --precise 1.0.203                    # pin one dependency to an exact version

Commit Cargo.lock for binaries so builds are reproducible; omit it for libraries so downstream crates resolve their own versions. Pin the toolchain with rust-toolchain.toml so CI and laptops compile with the same compiler.

Lifetimes and smart pointers#

A lifetime annotation does not change how long anything lives; it tells the compiler how the lifetimes of inputs and outputs relate so it can check that no reference outlives its data. Most signatures need none because of elision: one input reference means the output borrows from it. You write them when a function returns a reference and takes more than one, or when a struct holds a reference.

fn longest<'a>(a: &'a str, b: &'a str) -> &'a str { if a.len() > b.len() { a } else { b } }

struct Parser<'src> { input: &'src str, pos: usize }   // the struct cannot outlive the text it points into

fn first_word(s: &str) -> &str { s.split(' ').next().unwrap_or("") }   // elided: output borrows from s

const NAME: &'static str = "api";                      // lives for the whole program

A struct that borrows is cheap but infectious: everything holding it needs the lifetime too. When that spreads into async code or long-lived state, own the data (String, Vec<T>) or share it with Arc<str> instead. Box<T> puts a value on the heap with one owner, which is how recursive types (enum Expr { Add(Box<Expr>, Box<Expr>) }) and trait objects (Box<dyn Error>) get a known size. Cow<'a, str> holds either a borrow or an owned string, so a function that usually returns its input unchanged allocates only on the rare path that modifies it.

Interior mutability (Cell, RefCell, Mutex) moves the aliasing check from compile time to runtime. RefCell::borrow_mut panics if a borrow is already live; that panic is the same bug the borrow checker would have caught, surfacing later. Use it for caches and graph-like structures where the static rules are too coarse, not as a habit.

Iterator patterns#

An iterator adapter chain is a description of a computation; collect, for, sum, count and friends run it. Because adapters are lazy, iter().map(f).take(3) calls f three times, not once per element. The type annotation on collect chooses the container.

let names: Vec<&str> = users.iter().map(|u| u.name.as_str()).collect();
let by_id: HashMap<i64, &User> = users.iter().map(|u| (u.id, u)).collect();
let joined = names.join(",");                                           // Vec<&str> and Vec<String> both work

for (i, line) in text.lines().enumerate().skip(1) {}                    // header skipped, 1-based i
let pairs: Vec<(u8, char)> = bytes.iter().copied().zip(chars).collect();
let windows = readings.windows(2).filter(|w| w[1] > w[0]).count();      // consecutive overlapping pairs
let chunks: Vec<Vec<Job>> = jobs.chunks(100).map(<[Job]>::to_vec).collect();
let flat: Vec<Item> = pages.into_iter().flat_map(|p| p.items).collect();
let total = costs.iter().fold(0u64, |acc, c| acc.saturating_add(*c));   // no overflow panic
let max_by_len = words.iter().max_by_key(|w| w.len());
let grouped = items.iter().fold(HashMap::<_, Vec<_>>::new(), |mut m, i| { m.entry(i.kind).or_default().push(i); m });
let sorted = { let mut v = items.clone(); v.sort_by(|a, b| b.score.total_cmp(&a.score)); v };   // floats need total_cmp

iter() borrows, iter_mut() borrows mutably, into_iter() consumes. A for x in &v loop is v.iter(). When a closure needs to fail, collect into Result<Vec<_>, E> or use try_for_each; when it needs an index, enumerate rather than 0..v.len(). itertools adds chunk_by, sorted_by_key, unique and join on any iterator, worth the dependency in data-heavy code.

Trait design#

A trait’s methods can have default bodies, and a trait can declare associated types and constants that each implementation fixes. impl Trait for Type can live in either the trait’s crate or the type’s crate (the orphan rule), so wrapping a foreign type in a newtype (struct Bytes(Vec<u8>)) is how you implement a foreign trait for it. Derive standard traits liberally: Debug on everything, Clone when copying is reasonable, PartialEq/Eq/Hash for map keys, Default for config-like structs.

pub trait Backend {
    type Conn: Send;                                          // associated type, chosen by the implementor
    const NAME: &'static str;
    fn connect(&self) -> Result<Self::Conn, StoreError>;
    fn describe(&self) -> String { format!("backend {}", Self::NAME) }   // default method
}

impl std::fmt::Display for Host {                             // Display gives you to_string() for free
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}:{}", self.name, self.port) }
}

impl From<Row> for Host {                                     // From gives Into; ? uses From for errors
    fn from(r: Row) -> Self { Host { name: r.name, port: r.port } }
}

impl std::str::FromStr for Level {                            // "info".parse::<Level>()
    type Err = ParseError;
    fn from_str(s: &str) -> Result<Self, Self::Err> { match s { "info" => Ok(Level::Info), _ => Err(ParseError::Unknown(s.into())) } }
}

fn run(backends: &[Box<dyn Backend<Conn = PgConn>>]) {}       // trait objects need the associated type fixed

A trait is object-safe (usable as dyn Trait) only when none of its methods are generic or return Self by value. where clauses read better than inline bounds once there are two or more: fn send<T>(msg: T) where T: Serialize + Send + 'static. AsRef<str> and AsRef<Path> in argument position accept &str, String, &String and more without the caller converting. Since Rust 1.75 traits may have async fn directly; for a dyn-compatible async trait, the async-trait crate boxes the returned future.

Workspaces and features#

A workspace shares one Cargo.lock and target/ across several crates, so a service and its library split compile once and stay version-aligned. Feature flags compile optional code and dependencies; they are additive, meaning a crate must work with any combination enabled, and every dependency in the graph that enables a feature turns it on for everyone.

# Cargo.toml at the workspace root
[workspace]
members = ["crates/*"]
resolver = "3"                       # edition 2024 resolver; "2" for edition 2021

[workspace.package]
edition = "2024"
license = "MIT"

[workspace.dependencies]             # one version, inherited by members with `serde.workspace = true`
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] }

[workspace.lints.clippy]
unwrap_used = "warn"
# crates/api/Cargo.toml
[features]
default = ["json"]
json = ["dep:serde_json"]            # dep: prefix: the feature enables an optional dependency without exposing it
metrics = []                         # gates #[cfg(feature = "metrics")] code only

[dependencies]
serde_json = { version = "1", optional = true }
cargo build -p api                                 # one workspace member
cargo test --workspace --no-default-features       # prove the crate compiles with nothing optional
cargo build --features metrics --no-default-features
cargo hack check --each-feature --no-dev-deps      # cargo-hack: every feature alone, catches missing cfg guards
cargo tree -e features -p api                      # which features are active and who enabled them
cargo metadata --format-version 1 | jq '.workspace_members'

Edition 2024 (Rust 1.85+) changes unsafe extern, gen as a reserved keyword and the default lifetime capture rules in impl Trait return types; cargo fix --edition migrates a crate mechanically, and each crate in a workspace migrates independently.

Clippy#

Clippy groups lints by intent: correctness (deny by default), suspicious, style, complexity, perf (all warn), pedantic, nursery and restriction (allow, opt in). Configure at the workspace root with [workspace.lints] (Rust 1.74+) rather than per-file attributes, so every crate agrees, and treat warnings as errors in CI only.

[workspace.lints.clippy]
all = { level = "warn", priority = -1 }    # lower priority so the specific lints below override the group
pedantic = { level = "warn", priority = -1 }
module_name_repetitions = "allow"
missing_errors_doc = "allow"
unwrap_used = "warn"                        # restriction lint: forces expect() or ? in library code
dbg_macro = "warn"
todo = "warn"

[workspace.lints.rust]
unsafe_code = "forbid"
missing_debug_implementations = "warn"
cargo clippy --all-targets --all-features -- -D warnings   # CI: any warning fails
cargo clippy --fix --allow-dirty                            # apply machine-applicable suggestions in a dirty tree
cargo clippy -- -W clippy::pedantic 2>&1 | grep -c warning  # count before committing to a group

#[allow(clippy::too_many_arguments)] on one function with a comment is fine; #![allow(clippy::all)] at the crate root means the lint budget has been spent. clippy.toml beside Cargo.toml holds thresholds such as too-many-arguments-threshold = 8 and msrv = "1.80", which stops Clippy suggesting APIs newer than your minimum supported toolchain.

Tokio basics#

Tokio is a multi-threaded work-stealing runtime by default: #[tokio::main] starts one worker per core, and a task moves between workers at any .await, which is why a spawned future must be Send + 'static. tokio::spawn returns a JoinHandle that must be awaited or the task’s error is silently dropped; a JoinSet tracks many. Blocking the thread (std::fs, std::net, a heavy computation, a std::sync::Mutex under contention) stalls every task scheduled on that worker.

use tokio::sync::{mpsc, oneshot, watch, Semaphore};
use tokio::task::JoinSet;

let (tx, mut rx) = mpsc::channel::<Job>(256);              // bounded: send().await backpressures the producer
let (done_tx, done_rx) = oneshot::channel::<Result<(), Error>>();   // one reply
let (cfg_tx, cfg_rx) = watch::channel(Config::default());  // latest value, many readers: config reload

let mut set = JoinSet::new();
for id in ids {
    let client = client.clone();
    set.spawn(async move { fetch(&client, id).await });
}
while let Some(res) = set.join_next().await {
    match res {
        Ok(Ok(v)) => handle(v),
        Ok(Err(e)) => tracing::warn!(error = %e, "fetch failed"),   // the task's own error
        Err(join) if join.is_panic() => tracing::error!("task panicked"),
        Err(_) => {}                                                  // cancelled
    }
}

let sem = Arc::new(Semaphore::new(16));                    // bound concurrency across spawned tasks
let permit = sem.clone().acquire_owned().await?;           // held by the task; released on drop
tokio::spawn(async move { let _permit = permit; work().await });

let result = tokio::time::timeout(Duration::from_secs(5), call()).await;   // Err(Elapsed) on timeout
let handle = tokio::task::spawn_blocking(move || compress(&data));         // runs on the blocking pool

Cancellation in Tokio is dropping the future: select! drops the losing branches, timeout drops the inner future, and a dropped JoinHandle detaches rather than cancels (call abort() to cancel). A future that holds a resource and is dropped mid-await must be cancel-safe, which mpsc::Receiver::recv is and a half-read read_exact is not; the Tokio docs mark each method. tokio::signal::ctrl_c() and tokio::signal::unix::signal(SignalKind::terminate()) are the hooks for graceful shutdown, paired with tokio_util::sync::CancellationToken to tell every task to stop. tokio-console attaches to a runtime built with the tracing instrumentation and shows which tasks are busy, idle or never polled.

Troubleshooting#

SymptomCauseCheck
Borrow-checker error you cannot seeThe message names lines, not the real overlaprustc --explain E05xx; shorten the borrow’s scope or clone
cannot move out of ... borrowed contentTaking ownership through a referenceBorrow the field, clone, or restructure to own it
Rebuild recompiles the worldA build.rs, a proc-macro dep or a changed feature flagcargo build --timings; check cargo tree -e features
Two versions of the same crateSemver-incompatible requirements pull bothcargo tree -d; align versions or cargo update -p crate --precise
Async task never completesA future not awaited, or a std mutex guard held across .awaitConfirm the .await; use tokio::sync::Mutex and drop guards early
Deadlock under loadInconsistent lock ordering, or a blocking std mutex in async codeAcquire locks in a fixed order; move blocking work to spawn_blocking
SIGABRT with no unwindpanic = "abort" in the release profileExpected; catch_unwind needs panic = "unwind"
Links against system OpenSSL unexpectedlyA transitive dependency on opensslcargo tree -i openssl; switch to rustls or enable its vendored feature
cargo audit reports an advisoryA vulnerable version in Cargo.lockcargo update -p crate, then re-run cargo audit
future cannot be sent between threads safelyA non-Send value (Rc, RefCell guard, std::sync::MutexGuard) lives across an .awaitScope the value so it drops before the .await; use Arc or Tokio’s Mutex
already borrowed: BorrowMutError panicTwo live RefCell borrowsShorten the first borrow; consider whether ownership can be restructured
the trait ... cannot be made into an objectGeneric method or Self return in the traitMove the generic method to an extension trait, or add where Self: Sized
Feature-gated code compiles alone but fails in the workspaceFeature unification enabled a combination you never testedcargo hack check --each-feature; guard code with the right cfg
Task output never appearsJoinHandle dropped or never awaitedAwait or abort() the handle; use JoinSet
Runtime hangs on shutdownA spawned task loops without a cancellation checkPass a CancellationToken; tokio::time::timeout around join
cargo clippy clean locally, fails in CIDifferent toolchain versionPin rust-toolchain.toml; match msrv in clippy.toml
Slow incremental compile after a small editChange in a crate everything depends onSplit the leaf crate out; check cargo build --timings

Oneliners#

# Explain the error you just got
rustc --explain E0502

# Fastest feedback loop: recheck and retest on save
cargo watch -x check -x test

# Show where time goes in a slow build
cargo build --release --timings && open target/cargo-timings/cargo-timing.html

# Duplicate dependency versions bloating the build
cargo tree -d

# Unused dependencies (nightly tool)
cargo +nightly udeps

# Which features are enabled for a crate and why
cargo tree -e features -i tokio

# Expand a derive or macro to see the generated code
cargo expand --lib path::to::module

# Static musl binary, then confirm it has no dynamic links
cargo build --release --target x86_64-unknown-linux-musl && ldd target/x86_64-unknown-linux-musl/release/app

# Type-check the whole workspace without producing artefacts
cargo check --workspace --all-targets --all-features

# Fail CI on formatting
cargo fmt --all -- --check

# Security advisories against the lock file
cargo audit --deny warnings

# Run Miri to catch undefined behaviour in unsafe code
cargo +nightly miri test

# Print the exact toolchain in use, for a bug report or CI log
rustc -vV && cargo --version

# Run only tests whose name contains a substring, single-threaded, with logs
RUST_LOG=debug cargo test parse -- --test-threads=1 --nocapture

# Check the crate builds with the minimum supported Rust version declared in Cargo.toml
cargo +1.80 check --locked

# Symbolised backtrace on panic
RUST_BACKTRACE=1 cargo run

# Everything that would be published, to catch stray files
cargo package --list

# Build docs with private items and fail on broken intra-doc links
RUSTDOCFLAGS='-D warnings' cargo doc --no-deps --document-private-items

Snippets#

Retry with exponential backoff on a transient error class, cancellable by a token.

async fn retry<T, E, F, Fut>(attempts: u32, token: &CancellationToken, mut f: F) -> Result<T, E>
where
    F: FnMut() -> Fut,
    Fut: Future<Output = Result<T, E>>,
    E: std::fmt::Display,
{
    let mut delay = Duration::from_millis(200);
    for attempt in 1..=attempts {
        match f().await {
            Ok(v) => return Ok(v),
            Err(e) if attempt == attempts => return Err(e),
            Err(e) => tracing::warn!(attempt, error = %e, "retrying in {delay:?}"),
        }
        tokio::select! {
            _ = tokio::time::sleep(delay) => {}
            _ = token.cancelled() => break,
        }
        delay = (delay * 2).min(Duration::from_secs(10));
    }
    f().await                                                        // final attempt after a cancellation
}

Worker pool over a bounded channel: producers back-pressure, workers stop when the sender drops.

let (tx, rx) = tokio::sync::mpsc::channel::<Job>(64);
let rx = Arc::new(tokio::sync::Mutex::new(rx));
let mut workers = JoinSet::new();
for _ in 0..8 {
    let rx = Arc::clone(&rx);
    workers.spawn(async move {
        loop {
            let job = { rx.lock().await.recv().await };              // guard dropped before processing
            match job { Some(j) => process(j).await, None => break }  // None: every sender is gone
        }
    });
}
for j in jobs { tx.send(j).await?; }
drop(tx);                                                            // signals completion
while workers.join_next().await.is_some() {}

Per-call timeout with a typed error, so the caller can tell a timeout from a failure.

#[derive(Debug, thiserror::Error)]
enum CallError {
    #[error("timed out after {0:?}")] Timeout(Duration),
    #[error(transparent)] Upstream(#[from] reqwest::Error),
}

async fn call(client: &reqwest::Client, url: &str, budget: Duration) -> Result<String, CallError> {
    tokio::time::timeout(budget, async { client.get(url).send().await?.error_for_status()?.text().await })
        .await
        .map_err(|_| CallError::Timeout(budget))?
}

Configuration from a TOML file overlaid with environment variables, via serde and figment-free code.

#[derive(Debug, Clone, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
    pub listen: String,
    pub database_url: String,
    #[serde(with = "humantime_serde")] pub timeout: Duration,      // "5s" in the file
    pub workers: usize,
}
impl Default for Config {
    fn default() -> Self { Self { listen: "127.0.0.1:8080".into(), database_url: String::new(), timeout: Duration::from_secs(5), workers: 4 } }
}

pub fn load(path: &Path) -> anyhow::Result<Config> {
    let mut cfg: Config = match std::fs::read_to_string(path) {
        Ok(s) => toml::from_str(&s).with_context(|| format!("parsing {}", path.display()))?,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Config::default(),
        Err(e) => return Err(e).context("reading config"),
    };
    if let Ok(v) = std::env::var("APP_DATABASE_URL") { cfg.database_url = v; }
    anyhow::ensure!(!cfg.database_url.is_empty(), "database_url is required");
    Ok(cfg)
}

HTTP client with connect and total timeouts, connection pooling and a user agent, built once and cloned.

let client = reqwest::Client::builder()
    .connect_timeout(Duration::from_secs(3))
    .timeout(Duration::from_secs(30))                    // whole request, including the body
    .pool_idle_timeout(Duration::from_secs(90))
    .pool_max_idle_per_host(32)
    .user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")))
    .build()?;                                            // Client is an Arc internally; clone freely

Graceful shutdown on SIGTERM or Ctrl-C with a cancellation token and a drain deadline.

let token = CancellationToken::new();
let shutdown = {
    let token = token.clone();
    async move {
        let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
        tokio::select! { _ = tokio::signal::ctrl_c() => {}, _ = term.recv() => {} }
        tracing::info!("shutdown signal received");
        token.cancel();
        anyhow::Ok(())
    }
};
tokio::spawn(shutdown);

let server = axum::serve(listener, app).with_graceful_shutdown(token.clone().cancelled_owned());
tokio::time::timeout(Duration::from_secs(20), server).await??;   // give in-flight requests 20s

Structured logging with tracing: JSON to stderr, level from RUST_LOG, spans carrying request context.

use tracing_subscriber::{fmt, EnvFilter, prelude::*};

tracing_subscriber::registry()
    .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,hyper=warn")))
    .with(fmt::layer().json().with_current_span(true).with_writer(std::io::stderr))
    .init();

#[tracing::instrument(skip(db), fields(user_id = %id), err)]   // err: logs the Err at ERROR level automatically
async fn load_user(db: &Pool, id: i64) -> Result<User, StoreError> {
    tracing::debug!("querying");
    db.get(id).await
}

Newtype with validation at construction, so invalid values cannot exist downstream.

#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize)]
#[serde(transparent)]
pub struct Port(u16);

impl TryFrom<u16> for Port {
    type Error = ConfigError;
    fn try_from(v: u16) -> Result<Self, Self::Error> { if v == 0 { Err(ConfigError::ZeroPort) } else { Ok(Port(v)) } }
}
impl<'de> serde::Deserialize<'de> for Port {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        Port::try_from(u16::deserialize(d)?).map_err(serde::de::Error::custom)
    }
}

Read a large file line by line without loading it.

let file = std::fs::File::open(path)?;
for line in std::io::BufReader::new(file).lines() {
    let line = line?;                                     // io::Error on a bad read, not a panic
    if line.starts_with('#') { continue; }
    handle(&line);
}

Atomic write: temp file in the same directory, sync, rename.

fn write_atomic(path: &Path, data: &[u8]) -> std::io::Result<()> {
    let dir = path.parent().unwrap_or(Path::new("."));
    let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
    tmp.write_all(data)?;
    tmp.as_file().sync_all()?;
    tmp.persist(path).map(|_| ()).map_err(|e| e.error)   // rename; the temp file is removed on any earlier error
}

Run a subprocess with a timeout and capture output.

let out = tokio::time::timeout(
    Duration::from_secs(60),
    tokio::process::Command::new("pg_dump").arg("--format=custom").arg(&db_url).kill_on_drop(true).output(),
).await.context("pg_dump timed out")??;
anyhow::ensure!(out.status.success(), "pg_dump failed: {}", String::from_utf8_lossy(&out.stderr).trim());

Table-driven test with a data slice and a message that names the failing case.

#[test]
fn parses_durations() {
    let cases: &[(&str, Option<Duration>)] = &[
        ("5s", Some(Duration::from_secs(5))),
        ("2m", Some(Duration::from_secs(120))),
        ("", None),
        ("bad", None),
    ];
    for (input, want) in cases {
        assert_eq!(parse_duration(input).ok(), *want, "input {input:?}");
    }
}

Shared read-mostly state with RwLock and a reload path that swaps the whole value.

let rules: Arc<RwLock<Arc<Rules>>> = Arc::new(RwLock::new(Arc::new(load_rules()?)));

// hot path: clone the inner Arc, drop the read lock immediately
let current = Arc::clone(&*rules.read().unwrap());
current.matches(&event);

// reload: build the new value outside the lock, then swap
let fresh = Arc::new(load_rules()?);
*rules.write().unwrap() = fresh;