go / retrytx

I use retryable transactions to handle transient database errors like lock timeouts and serialization failures.

The problem

Database operations can fail for transient reasons:

tx, err := db.BeginTx(ctx, nil)
if err != nil {
    return err
}
// ... do work ...
err = tx.Commit()
// ERROR: could not serialize access (40001)
// ERROR: lock timeout (55P03)

These errors are recoverable — retrying often succeeds. But naive retry logic is error-prone: you must rollback the failed transaction before starting a new one.

The pattern

Wrap transaction logic in a retry loop:

var retryDelays = []time.Duration{
	100 * time.Millisecond,
	200 * time.Millisecond,
	400 * time.Millisecond,
	800 * time.Millisecond,
	1600 * time.Millisecond,
}

func WithRetryableTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error {
	var lastErr error

	for attempt := range len(retryDelays) + 1 {
		tx, err := db.BeginTx(ctx, nil)
		if err != nil {
			return err
		}

		err = fn(tx)
		if err == nil {
			return tx.Commit()
		}
		lastErr = err

		// Always rollback on error
		if rbErr := tx.Rollback(); rbErr != nil {
			return rbErr
		}

		// Check if error is retryable
		if !isRetryable(err) {
			return err
		}

		// Wait before retry, respecting context
		if attempt < len(retryDelays) {
			if err := sleepCtx(ctx, retryDelays[attempt]); err != nil {
				return err
			}
		}
	}
	return lastErr
}

Retryable errors

For Postgres, retry on lock timeouts and serialization failures:

import "github.com/lib/pq"

func isRetryable(err error) bool {
	var pqErr *pq.Error
	if errors.As(err, &pqErr) {
		switch pqErr.Code {
		case "40001": // serialization_failure
			return true
		case "40P01": // deadlock_detected
			return true
		case "55P03": // lock_not_available
			return true
		}
	}
	return false
}

For SQLite, retry on busy errors:

func isRetryable(err error) bool {
	// modernc.org/sqlite returns error strings
	return strings.Contains(err.Error(), "SQLITE_BUSY")
}

See backoff for why lookup tables are preferred, and jitter to avoid thundering herd.

Usage

err := WithRetryableTx(ctx, db, func(tx *sql.Tx) error {
    _, err := tx.Exec("UPDATE accounts SET balance = balance - $1 WHERE id = $2", amount, from)
    if err != nil {
        return err
    }
    _, err = tx.Exec("UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, to)
    return err
})

The function is called repeatedly until it succeeds, returns a non-retryable error, or the context is canceled.

Context-aware sleep

func sleepCtx(ctx context.Context, d time.Duration) error {
	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-time.After(d):
		return nil
	}
}

See sleepctx for more details.

When to use

When not to use

← All articles