Skip to content

Transactions

A mutating request runs inside a transaction with no boilerplate. The DbContext interceptor — auto-installed by DatabaseModule — opens it on the way in, hands the handler the transactional executor, and decides commit or rollback based on the response. The hard cases — serialization conflicts, deadlocks — get their own primitive, with a defined boundary between what the interceptor handles and what the service owns.

The conflict classifier matches against sqlx’s typed DatabaseError::code() (SQLSTATE) — never a formatted error string — so a digit substring in a message can’t trigger a false retry. SeaORM provides the TransactionTrait the programmatic boundary builds on.

DbContext classifies the HTTP method and branches:

GET / HEAD / OPTIONS / TRACE → Executor::Pool, no transaction
POST / PUT / PATCH / DELETE → Executor::Txn, commit on 2xx/3xx
rollback otherwise

Inside the handler, Repo::<E>::conn() returns whichever variant the interceptor installed — same code, different mode. The decision matrix:

OutcomeDecision
Ok(2xx) or Ok(3xx)commit
Ok(4xx) or Ok(5xx)rollback
Err(_) from the handlerrollback
Commit errorresponse becomes 500, rollback is implicit

A failed mutation never half-persists, even when the failure surfaces as a typed 4xx body from a thiserror-derived error. See Repo and executor for the full sequence, including the escape-on-spawn case.

Why retrying belongs to the service, not the interceptor

Section titled “Why retrying belongs to the service, not the interceptor”

A poem Request is consumed by next.run — the body bytes are gone past the handler call. The interceptor cannot replay the same handler on a fresh transaction; the DatabaseTransaction handle is already aborted by the time a conflict surfaces. Two consequences:

  1. The interceptor observes the conflict, it does not retry it. The failed commit has already consumed its handle, and the handler’s non-transactional side effects — a queued job, an emitted event, an object written to storage — would replay with it.
  2. Retrying is the service’s job. Only the service knows which boundary is replayable, so the replayable closure owns that boundary itself.

The framework exposes two pieces, deliberately split:

Terminal window
NESTRS_DATABASE__OBSERVE_SERIALIZATION_CONFLICTS=true
DatabaseModule::for_root(DatabaseConfig {
observe_serialization_conflicts: true,
..Default::default()
})

With the flag on, the DbContext interceptor matches a commit-time conflict against the SQLSTATE markers below and logs it at warn with the conflict tag (target nest_rs::orm) so ops sees the failure mode distinctly from a generic commit error.

For an actually-retried operation, wrap a replayable closure in retry_on_conflict inside a service method that owns its programmatic transaction boundary:

crates/features/src/posts/service.rs
use std::sync::Arc;
use std::time::Duration;
use nest_rs_seaorm::retry::{
retry_on_conflict, DEFAULT_RETRY_ATTEMPTS, DEFAULT_INITIAL_BACKOFF,
};
use sea_orm::{DatabaseConnection, TransactionTrait};
impl PostsService {
pub async fn publish(&self, id: Uuid) -> Result<post::Model, ServiceError> {
let db = self.db.clone();
retry_on_conflict(DEFAULT_RETRY_ATTEMPTS, DEFAULT_INITIAL_BACKOFF, || {
let db = db.clone();
async move {
let txn = db.begin().await?;
// read + update + insert against `txn`
txn.commit().await?;
Ok::<_, sea_orm::DbErr>(/* the published row */)
}
})
.await
.map_err(ServiceError::from)
}
}

retry_on_conflict runs the closure up to attempts times, sleeping initial_backoff << attempt between tries (5 ms → 10 ms → 20 ms with the defaults). A non-retryable error returns immediately; a retryable one is logged at warn and the closure re-runs from scratch — that’s what makes the retry honest, the body re-opens a fresh transaction against a fresh snapshot.

The defaults are conservative on purpose:

ConstantValue
DEFAULT_RETRY_ATTEMPTS3
DEFAULT_INITIAL_BACKOFF5 ms
MAX_RETRY_ATTEMPTS32 (hard ceiling)
Per-sleep cap30 s

The hard ceiling and per-sleep cap exist because 1u32 << attempt overflows at attempt >= 32 (UB in debug, wraps to 0 in release — which would hot-spin). Pass usize::MAX and the function still terminates in bounded time.

SQLSTATEBackend(s)What it means
40001Postgres, MySQLSerialization failure
40P01PostgresDeadlock detected
1213MySQLDeadlock found
1205SQL ServerTransaction was deadlock victim

Anything else — 23505 (unique violation), RecordNotFound, a generic Internal error whose message contains “40001” — returns immediately. A unique-violation retry would loop forever; a substring match on the message would falsely retry a connection log mentioning port 40001. The classifier reads the typed code only, never the display string.

The failure modes a retry cannot fix:

  • A unique constraint violation. Retrying inserts the same row again, same failure. The classifier rejects these.
  • A row that doesn’t exist. RecordNotFound is terminal — no amount of retries materialize the row.
  • Application-side validation. ServiceError::Validation is not a conflict; the closure re-runs from scratch but the input is the same.
  • A handler-time conflict on the auto-commit transaction. The interceptor cannot retry the body; the conflict surfaces as a DbErr from the service, which gets mapped to the appropriate HTTP status. Use retry_on_conflict at the service level instead.

The split is deliberate. The interceptor handles the common case transparently (open / commit / rollback); the service handles the hard case explicitly (replay the work against a fresh snapshot). One way to do each.

  • Database — the slice that imports DatabaseModule.
  • Repo and executor — what the interceptor installs, the escape-on-spawn fail-closed.
  • Pagination — the other knob the entity carries.
  • Configuration — the full NESTRS_DATABASE__* scheme.

Built by YV17labs