Skip to content
rusthacks.dev
Databases

Handling Database Timeouts and Retries in Rust Services

Set acquire, connect and statement timeouts, tell transient errors from permanent ones, retry only what is safe with backoff and jitter, and keep a retry from doubling a write. SQLx and PostgreSQL examples.

4 min read
Summarize in AI
Flat illustration for the post “Handling Database Timeouts and Retries in Rust Services”, composed around database.

A database call that never returns is worse than one that fails: it holds a connection, a request task and a user's patience. Timeouts convert hangs into errors, and retries convert some of those errors back into successes. Done carelessly, retries amplify an outage. This post sets the timeouts and a retry policy with SQLx. It compiles on Rust 1.90 with edition 2024, SQLx 0.8, Tokio 1.47 and PostgreSQL 17.

The timeouts

rust
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
use std::{str::FromStr, time::Duration};

pub async fn pool(url: &str) -> Result<sqlx::PgPool, sqlx::Error> {
    let opts = PgConnectOptions::from_str(url)?
        .options([("statement_timeout", "5000"), ("idle_in_transaction_session_timeout", "10000")]);

    PgPoolOptions::new()
        .max_connections(20)
        .acquire_timeout(Duration::from_secs(3))   // waiting for a free pooled connection
        .idle_timeout(Duration::from_secs(300))
        .max_lifetime(Duration::from_secs(1800))
        .connect_with(opts)
        .await
}
TimeoutWhat it boundsFailure you see
acquire_timeoutWaiting for a connection from the poolsqlx::Error::PoolTimedOut: the pool is exhausted or the database is slow
Connect timeoutOpening a new TCP and TLS connectionI/O error or PoolTimedOut
statement_timeoutOne query's execution, enforced by PostgreSQLSQLSTATE 57014 query canceled
idle_in_transaction_session_timeoutA transaction left open doing nothingConnection terminated by the server

A pool timeout is the signal to investigate pool size and slow queries rather than to raise the number (pool sizing). For a single slow endpoint that legitimately needs longer, set SET LOCAL statement_timeout inside its transaction.

Classifying errors

rust
use sqlx::Error;

pub fn is_transient(e: &Error) -> bool {
    match e {
        Error::PoolTimedOut | Error::Io(_) | Error::Tls(_) | Error::PoolClosed => true,
        Error::Database(db) => matches!(
            db.code().as_deref(),
            Some("40001")   // serialization_failure
                | Some("40P01") // deadlock_detected
                | Some("57P01") // admin_shutdown (failover)
                | Some("53300") // too_many_connections
        ),
        _ => false,
    }
}

Retryable: the operation may succeed if repeated unchanged. Not retryable: a unique violation (23505), a foreign key violation, a syntax error, a type mismatch, RowNotFound and 57014 query canceled by statement timeout, which will probably time out again and add load. Treat anything unknown as permanent; retrying a bug is wasted work.

Retry with backoff and jitter

rust
use std::{future::Future, time::Duration};

pub async fn with_retry<T, F, Fut>(mut op: F, max_attempts: u32) -> Result<T, sqlx::Error>
where
    F: FnMut() -> Fut,
    Fut: Future<Output = Result<T, sqlx::Error>>,
{
    let mut attempt = 0;
    loop {
        attempt += 1;
        match op().await {
            Ok(v) => return Ok(v),
            Err(e) if attempt < max_attempts && is_transient(&e) => {
                let base = 50u64 * 2u64.pow(attempt - 1);          // 50, 100, 200 ms
                let jitter = rand::random::<u64>() % base;       // 0..base
                tokio::time::sleep(Duration::from_millis(base + jitter)).await;
            }
            Err(e) => return Err(e),
        }
    }
}

Exponential backoff spreads retries over time; jitter keeps a thousand clients that failed together from retrying together. Cap attempts and also cap total elapsed time against the request's own deadline: a request with a 2-second budget should not retry for 10 seconds. A circuit breaker around the database, or load shedding, protects it when it is clearly down (circuit breakers).

Retry the whole transaction

rust
use sqlx::PgPool;

async fn transfer(pool: &PgPool, from: i64, to: i64, cents: i64) -> Result<(), sqlx::Error> {
    with_retry(
        || async {
            let mut tx = pool.begin().await?;
            sqlx::query("UPDATE accounts SET balance = balance - $1 WHERE id = $2")
                .bind(cents).bind(from).execute(&mut *tx).await?;
            sqlx::query("UPDATE accounts SET balance = balance + $1 WHERE id = $2")
                .bind(cents).bind(to).execute(&mut *tx).await?;
            tx.commit().await
        },
        3,
    )
    .await
}

After a serialization failure or deadlock the transaction is aborted; the correct retry re-runs everything from begin, including any reads that fed decisions. Retrying only the failed statement inside the same transaction does not work, since the transaction is already in an error state. Using a stricter isolation level makes 40001 more frequent and retry logic mandatory (SQLx transactions).

The duplicate-write trap

If a commit succeeds but the network drops before the acknowledgment reaches you, the error looks transient and the retry runs the write again. Make writes safe to repeat: a unique constraint on a client-supplied idempotency key, an INSERT ... ON CONFLICT DO NOTHING, or an update expressed as "set to X" rather than "add 1". See idempotency keys, and surface the timeout to users clearly when retries are exhausted: it is the error they will actually see.

Also inRust
Taggedtimeouts
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.