Rust
Ownership and borrowing in practice, error handling, async, and the cargo commands that matter day to day.
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#
| Task | Command |
|---|---|
| Check without codegen (fast) | cargo check --all-targets |
| Build optimised | cargo build --release |
| Run tests, show output | cargo test -- --nocapture |
| One test | cargo test path::to::test_name |
| Lint hard | cargo clippy --all-targets -- -D warnings |
| Format | cargo fmt --all (check: --check) |
| Explain an error code | rustc --explain E0502 |
| Expand a macro | cargo expand |
| Dependency tree | cargo tree -d (duplicates) |
| Audit advisories | cargo audit |
| Update within semver | cargo update |
| Add a dependency | cargo add serde --features derive |
| Benchmark | cargo bench (or criterion) |
| Docs for this crate and deps | cargo doc --open |
| Binary size breakdown | cargo bloat --release |
| MSRV / toolchain pin | rust-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.
| Error | What it means | Usual fix |
|---|---|---|
E0382 use of moved value | You gave the value away and used it again | Borrow instead, or clone deliberately |
E0502 mutable borrow while borrowed | A shared and a mutable borrow overlap | Shorten the first borrow’s scope, or restructure |
E0499 two mutable borrows | Aliased mutation of the same data | Split the data (split_at_mut), or index instead |
E0597 does not live long enough | A reference outlives the value it points to | Own the data, or tie the lifetimes explicitly |
E0308 mismatched types | Often 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.
| Need | Type |
|---|---|
| Single owner, single thread | Plain value |
| Shared, single thread | Rc<T> / Rc<RefCell<T>> |
| Shared across threads, read-only | Arc<T> |
| Shared and mutable across threads | Arc<Mutex<T>> or Arc<RwLock<T>> |
| Counter | AtomicU64 |
| Message passing | std::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 binarycargo 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 versionCommit 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#
| Symptom | Cause | Check |
|---|---|---|
| Borrow-checker error you cannot see | The message names lines, not the real overlap | rustc --explain E05xx; shorten the borrow’s scope or clone |
cannot move out of ... borrowed content | Taking ownership through a reference | Borrow the field, clone, or restructure to own it |
| Rebuild recompiles the world | A build.rs, a proc-macro dep or a changed feature flag | cargo build --timings; check cargo tree -e features |
| Two versions of the same crate | Semver-incompatible requirements pull both | cargo tree -d; align versions or cargo update -p crate --precise |
| Async task never completes | A future not awaited, or a std mutex guard held across .await | Confirm the .await; use tokio::sync::Mutex and drop guards early |
| Deadlock under load | Inconsistent lock ordering, or a blocking std mutex in async code | Acquire locks in a fixed order; move blocking work to spawn_blocking |
SIGABRT with no unwind | panic = "abort" in the release profile | Expected; catch_unwind needs panic = "unwind" |
| Links against system OpenSSL unexpectedly | A transitive dependency on openssl | cargo tree -i openssl; switch to rustls or enable its vendored feature |
cargo audit reports an advisory | A vulnerable version in Cargo.lock | cargo update -p crate, then re-run cargo audit |
future cannot be sent between threads safely | A non-Send value (Rc, RefCell guard, std::sync::MutexGuard) lives across an .await | Scope the value so it drops before the .await; use Arc or Tokio’s Mutex |
already borrowed: BorrowMutError panic | Two live RefCell borrows | Shorten the first borrow; consider whether ownership can be restructured |
the trait ... cannot be made into an object | Generic method or Self return in the trait | Move the generic method to an extension trait, or add where Self: Sized |
| Feature-gated code compiles alone but fails in the workspace | Feature unification enabled a combination you never tested | cargo hack check --each-feature; guard code with the right cfg |
| Task output never appears | JoinHandle dropped or never awaited | Await or abort() the handle; use JoinSet |
| Runtime hangs on shutdown | A spawned task loops without a cancellation check | Pass a CancellationToken; tokio::time::timeout around join |
cargo clippy clean locally, fails in CI | Different toolchain version | Pin rust-toolchain.toml; match msrv in clippy.toml |
| Slow incremental compile after a small edit | Change in a crate everything depends on | Split 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-itemsSnippets#
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;