Danh sách bài viết

Bài 55: sqlx Error Mapping Sâu — SQLSTATE Và Domain Error

Bài 55 của series Rust RESTful API — đi sâu sqlx::Error 8+ variant (Database qua PgDatabaseError với SQLSTATE 5-character code Postgres + RowNotFound + PoolTimedOut + PoolClosed + Io + Tls + Configuration + Protocol + TypeNotFound + ColumnDecode + Decode + WorkerCrashed); refactor From<sqlx::Error> for AppError mở rộng từ B52 (3 case) lên full mapping qua helper map_db_error(db_err) tách logic; thêm 4 AppError variant mới — Conflict(String) 409, ForeignKeyViolation { constraint, table } 409 với detail object envelope, CheckViolation(String) 422, ServiceUnavailable 503, SerializationFailure (retry rồi 409); 7 SQLSTATE phổ biến Shop API cần handle — 23505 UNIQUE_VIOLATION (slug/email/payment_intent_id trùng) → 409 Conflict, 23503 FOREIGN_KEY_VIOLATION (order_items reference product không tồn tại) → 409 ForeignKeyViolation, 23514 CHECK_VIOLATION (stock < 0, price <= 0) → 422 CheckViolation, 23502 NOT_NULL_VIOLATION → 422, 40001 SERIALIZATION_FAILURE → retry rồi SerializationFailure 409, 40P01 DEADLOCK_DETECTED → retry hoặc 503, 53300 TOO_MANY_CONNECTIONS → 503 ServiceUnavailable; pattern domain error wrap sqlx continued từ B54 OrderError với 2 cách convert sang AppError (Cách 1 explicit match per-variant lock pattern Shop API cho control semantic clarity, Cách 2 thiserror #[from] cascade reject vì mất kiểm soát business mapping); retry strategy with_retry(f, max_attempts) helper exponential backoff 10/20/40ms cho transient error (40001, 40P01) max 3 attempts; fail-fast cho permanent error (23505, 23503, 23514) KHÔNG retry; logging chi tiết qua tracing với ?code ?constraint ?table debug dễ; envelope detail object pattern B48 continued cho FK + Check violation; verify test 4 case (unique slug trùng, FK product không tồn tại, check price âm, RowNotFound) qua curl + psql; file path lock — extend crates/shop-common/src/error.rs + NEW crates/shop-common/src/retry.rs; foundation cho B56 sqlx Pool Configuration Sâu.

15/06/2026
12 phút đọc
1 lượt xem
1

Mục Tiêu Bài Học

Sau bài học, bạn sẽ:

  • Hiểu sqlx::Error variants đầy đủ (8+ variant chính, role mỗi loại).
  • Hiểu PgDatabaseErrorSQLSTATE 5-character code chuẩn Postgres.
  • Map SQLSTATE phổ biến: 23505 unique, 23503 FK, 23514 check, 40001 serialization, 23502 not-null.
  • Refactor From<sqlx::Error> for AppError chi tiết per-SQLSTATE qua helper tách logic.
  • Thêm AppError variant mới: Conflict, ForeignKeyViolation, CheckViolation, ServiceUnavailable, SerializationFailure.
  • Pattern domain error wrap sqlx::Error (B54 OrderError continued).
  • Hiểu khi nào retry vs fail-fast: 40001 retry, 23505 fail-fast.
2

sqlx::Error 8+ Variants Tổng Quan

sqlx::Error là enum non-exhaustive chứa mọi tình huống lỗi có thể xảy ra khi giao tiếp với database. sqlx 0.8 (workspace lock B51) có hơn 12 variant chính, mỗi variant đại diện 1 nhóm nguyên nhân khác nhau.

// Tóm tắt variant chính của sqlx::Error (sqlx 0.8)
pub enum Error {
    Configuration(Box<dyn StdError + Send + Sync>),  // config parse fail
    Database(Box<dyn DatabaseError>),                // error từ Postgres
    Io(std::io::Error),                              // network/file IO
    Tls(Box<dyn StdError + Send + Sync>),            // TLS handshake fail
    Protocol(String),                                // sai protocol Postgres
    RowNotFound,                                     // fetch_one không có row
    TypeNotFound { type_name: String },              // Rust type không match
    ColumnDecode { index: String, source: Box<...> },// decode 1 column fail
    Decode(Box<dyn StdError + Send + Sync>),         // generic decode fail
    PoolTimedOut,                                    // acquire pool timeout
    PoolClosed,                                      // pool đã close
    WorkerCrashed,                                   // sqlx worker crash
    // ... non-exhaustive
}

Nhóm theo nguồn gốc giúp dễ map sang HTTP status:

  • Database-sideDatabase (chính, wrap PgDatabaseError), RowNotFound. Phía Postgres phản hồi cụ thể.
  • Connection-sideIo, Tls, Protocol, PoolTimedOut, PoolClosed, WorkerCrashed. Lỗi tầng kết nối hoặc pool quản lý.
  • Codec-sideTypeNotFound, ColumnDecode, Decode, Configuration. Lỗi parse / encode giữa Rust struct và Postgres type — thường là bug schema mismatch.

Bảng mapping sang status code Shop API:

sqlx::Error variant      | Status code | AppError variant
-------------------------+-------------+-----------------------------
RowNotFound              | 404         | NotFound
Database (SQLSTATE)      | varies      | Conflict / FK / Check / ...
Configuration / Tls      | 500         | Internal
Io / Protocol            | 500         | Internal
PoolTimedOut             | 503         | ServiceUnavailable
PoolClosed               | 500         | Internal
TypeNotFound / Decode    | 500         | Internal (server bug)
ColumnDecode             | 500         | Internal (schema mismatch)
WorkerCrashed            | 500         | Internal

Quyết định lock Shop API: tập trung mapping chi tiết cho RowNotFound + Database (SQLSTATE) + PoolTimedOut (3 trường hợp chiếm >95% error production); phần còn lại default về Internal 500 + log chi tiết qua tracing::error! để debug.

3

PgDatabaseError + SQLSTATE 5-Character Code

Khi Postgres trả lỗi, sqlx bọc qua sqlx::Error::Database(Box<dyn DatabaseError>). Inner type cụ thể là sqlx::postgres::PgDatabaseError exposed metadata phong phú: SQLSTATE code 5 ký tự + tên constraint + tên table/column + message.

use sqlx::error::DatabaseError;

match err {
    sqlx::Error::Database(db_err) => {
        let code = db_err.code().unwrap_or_default();       // vd "23505"
        let constraint = db_err.constraint().unwrap_or(""); // vd "products_slug_key"
        let table = db_err.table().unwrap_or("");           // vd "products"
        let message = db_err.message();                     // human-readable

        tracing::error!(
            ?code, ?constraint, ?table,
            "postgres database error: {}", message
        );
    }
    _ => {}
}

SQLSTATE là chuẩn quy ước trong SQL standard (lần đầu trong SQL-92), Postgres tuân theo: 5 ký tự code phân loại lỗi theo class + specific. Format XYZAB:

  • 2 ký tự đầu (XY) — class (general category). Vd 23 = Integrity Constraint Violation, 40 = Transaction Rollback, 42 = Syntax Error, 53 = Insufficient Resources.
  • 3 ký tự sau (ZAB) — specific code trong class. Vd 23505 = Unique Violation (class 23 + specific 505), 23503 = Foreign Key Violation, 23514 = Check Violation.

Class phổ biến cần biết khi build REST API:

Class | Tên                                  | Use case
------+--------------------------------------+----------------------------
00    | Successful Completion                | (không phải error)
23    | Integrity Constraint Violation       | UNIQUE/FK/CHECK/NOT NULL
40    | Transaction Rollback                 | serialization, deadlock
42    | Syntax Error or Access Rule          | SQL sai cú pháp, permission
53    | Insufficient Resources               | quá nhiều connection, disk full
57    | Operator Intervention                | admin shutdown DB
58    | System Error (external to PG)        | OS error
XX    | Internal Error                       | bug Postgres

Reference đầy đủ ở Postgres documentation appendix A. Mỗi version Postgres thêm code mới nhưng giữ backward — code đã định nghĩa không bao giờ đổi nghĩa.

4

SQLSTATE Phổ Biến Shop API Cần Xử Lý

7 SQLSTATE Shop API thường gặp trong production. Mỗi code map sang AppError + HTTP status theo nguyên tắc lock từ B3.

23505 UNIQUE_VIOLATION — insert/update vi phạm UNIQUE constraint. Use case Shop API: tạo product với slug trùng (constraint products_slug_key), register email trùng (users_email_key), webhook tạo lại payment với payment_intent_id trùng. Map: HTTP 409 Conflict. sqlx helper: db_err.is_unique_violation() trả true.

23503 FOREIGN_KEY_VIOLATION — insert FK trỏ tới row không tồn tại, hoặc delete row đang được FK reference. Use case Shop API: insert order_items với product_id không tồn tại (FK order_items_product_id_fkey với ON DELETE RESTRICT lock B54); xóa product đang trong order. Map: HTTP 409 Conflict (data integrity) hoặc 422 Unprocessable Entity (input không hợp lệ semantic) — lock Shop API chọn 409 vì semantic gần hơn với "không thể thực hiện vì conflict với state hiện tại". sqlx helper: db_err.is_foreign_key_violation().

23514 CHECK_VIOLATION — vi phạm CHECK constraint trên cột. Use case: insert products với stock < 0 hoặc price <= 0 (CHECK trong migration B51); insert orders với total <= 0 (CHECK lock B54). Map: HTTP 422 Unprocessable Entity — input parse được nhưng vi phạm business rule. Đây là defense in depth — validation tầng app (B41 ValidatedJson) thường bắt trước, nhưng CHECK ở DB là lưới an toàn cuối. sqlx helper: db_err.is_check_violation().

23502 NOT_NULL_VIOLATION — insert/update field NOT NULL với giá trị NULL. Use case: thiếu field bắt buộc. Map: HTTP 422. Đây gần như luôn là server bug — validation tầng DTO phải bắt trước qua #[validate(length(min = 1))]; nếu reach DB nghĩa là handler quên check.

40001 SERIALIZATION_FAILURE — 2 transaction concurrent ghi cùng row ở isolation SERIALIZABLE, Postgres detect conflict và abort 1 transaction. Use case: hiếm trong Shop API hiện tại vì lock B54 dùng READ COMMITTED default, nhưng có thể xảy ra khi nâng isolation cho report. Map: retry rồi mới fail (Bước 7) — đây là transient error.

40P01 DEADLOCK_DETECTED — 2 transaction lock chéo nhau (T1 giữ row A chờ row B, T2 giữ row B chờ row A). Postgres detect cycle và abort 1 trong 2. Use case: 2 order cùng update 2 product theo thứ tự khác nhau. Map: retry hoặc fail 503. Tránh bằng cách lock row theo thứ tự nhất quán (vd luôn lock theo product_id ASC).

53300 TOO_MANY_CONNECTIONS — Postgres đạt giới hạn max_connections (default 100). Use case: app scale quá nhanh, sự cố leak connection. Map: HTTP 503 Service Unavailable + Retry-After header.

5

Refactor From<sqlx::Error> for AppError Chi Tiết

B52 đã có impl From<sqlx::Error> for AppError với 3 case cơ bản (RowNotFound → NotFound, unique violation → Conflict, default → Internal). B55 mở rộng thành full mapping qua helper map_db_error tách logic cho Database variant.

Trước hết, mở rộng AppError với 4 variant mới:

// File: crates/shop-common/src/error.rs
use sqlx::error::DatabaseError;

#[derive(Debug, thiserror::Error)]
pub enum AppError {
    // ... 14 variant cũ (B10 + B41 + B48) giữ nguyên

    #[error("conflict: {0}")]
    Conflict(String),

    #[error("foreign key violation: {constraint} (table: {table})")]
    ForeignKeyViolation { constraint: String, table: String },

    #[error("check constraint violation: {0}")]
    CheckViolation(String),

    #[error("service temporarily unavailable")]
    ServiceUnavailable,

    #[error("transaction serialization failure (retry recommended)")]
    SerializationFailure,
}

Lưu ý: Conflict đã có sẵn ở B16 nhưng dùng cho generic case (vd optimistic lock). B55 reuse cùng variant cho unique violation — gộp về 1 variant cho consistency, format string riêng để client phân biệt qua field code.

Refactor impl From<sqlx::Error> for AppError đầy đủ:

// File: crates/shop-common/src/error.rs
impl From<sqlx::Error> for AppError {
    fn from(err: sqlx::Error) -> Self {
        match err {
            sqlx::Error::RowNotFound => {
                AppError::NotFound("row not found".into())
            }

            sqlx::Error::Database(db_err) => map_db_error(&*db_err),

            sqlx::Error::PoolTimedOut => AppError::ServiceUnavailable,

            sqlx::Error::PoolClosed => {
                tracing::error!("database pool closed");
                AppError::Internal(anyhow::anyhow!("database unavailable"))
            }

            sqlx::Error::Io(e) => {
                tracing::error!(?e, "sqlx IO error");
                AppError::Internal(anyhow::anyhow!("database io error"))
            }

            sqlx::Error::Tls(e) => {
                tracing::error!(?e, "sqlx TLS error");
                AppError::Internal(anyhow::anyhow!("database tls error"))
            }

            _ => {
                tracing::error!(?err, "unknown sqlx error");
                AppError::Internal(anyhow::anyhow!("database error"))
            }
        }
    }
}

Helper map_db_error tách logic SQLSTATE — match per-code, dễ thêm code mới sau này:

// File: crates/shop-common/src/error.rs
fn map_db_error(db_err: &dyn DatabaseError) -> AppError {
    let code = db_err.code().unwrap_or_default();
    let constraint = db_err.constraint().unwrap_or("");
    let table = db_err.table().unwrap_or("");

    match code.as_ref() {
        "23505" => AppError::Conflict(format!(
            "unique violation: {} on table {}",
            constraint, table
        )),

        "23503" => AppError::ForeignKeyViolation {
            constraint: constraint.to_string(),
            table: table.to_string(),
        },

        "23514" => AppError::CheckViolation(constraint.to_string()),

        "23502" => AppError::CheckViolation(format!(
            "not-null violation on {}",
            constraint
        )),

        "40001" | "40P01" => AppError::SerializationFailure,

        "53300" => AppError::ServiceUnavailable,

        _ => {
            tracing::error!(
                ?code, ?constraint, ?table,
                "unhandled postgres error: {}", db_err.message()
            );
            AppError::Internal(anyhow::anyhow!(
                "database error: {}", code
            ))
        }
    }
}

Update impl IntoResponse for AppError — thêm match arm cho 4 variant mới. Pattern envelope lock từ B16 + detail object cho FK/Check (B48 continued):

// File: crates/shop-common/src/error.rs
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        match self {
            // ... arm cũ giữ nguyên

            AppError::Conflict(msg) => {
                let body = json!({
                    "error": msg,
                    "code": "CONFLICT",
                    "request_id": null,
                });
                (StatusCode::CONFLICT, Json(body)).into_response()
            }

            AppError::ForeignKeyViolation { constraint, table } => {
                let body = json!({
                    "error": "foreign key violation",
                    "code": "FOREIGN_KEY_VIOLATION",
                    "request_id": null,
                    "detail": {
                        "constraint": constraint,
                        "table": table,
                    }
                });
                (StatusCode::CONFLICT, Json(body)).into_response()
            }

            AppError::CheckViolation(constraint) => {
                let body = json!({
                    "error": "check constraint violation",
                    "code": "CHECK_VIOLATION",
                    "request_id": null,
                    "detail": { "constraint": constraint }
                });
                (StatusCode::UNPROCESSABLE_ENTITY, Json(body)).into_response()
            }

            AppError::ServiceUnavailable => {
                let body = json!({
                    "error": "service temporarily unavailable",
                    "code": "SERVICE_UNAVAILABLE",
                    "request_id": null,
                });
                let mut response = (StatusCode::SERVICE_UNAVAILABLE, Json(body)).into_response();
                response.headers_mut().insert(
                    header::RETRY_AFTER,
                    HeaderValue::from_static("30"),
                );
                response
            }

            AppError::SerializationFailure => {
                let body = json!({
                    "error": "transaction conflict, please retry",
                    "code": "SERIALIZATION_FAILURE",
                    "request_id": null,
                });
                (StatusCode::CONFLICT, Json(body)).into_response()
            }
        }
    }
}

Helper internal status_code()code() cũng phải thêm 5 arm tương ứng (compiler enforce qua exhaustive match). Sau B55, AppError19 variant tổng cộng (14 cũ + 5 mới).

6

Domain Error Wrap sqlx::Error (B54 Continued)

B54 đã lock pattern domain error OrderError với #[from] sqlx::Error:

// File: crates/shop-db/src/orders.rs (lock B54)
#[derive(Debug, thiserror::Error)]
pub enum OrderError {
    #[error(transparent)]
    Sqlx(#[from] sqlx::Error),

    #[error("product {0} not found")]
    ProductNotFound(i64),

    #[error("insufficient stock for product {product_id}")]
    InsufficientStock {
        product_id: i64,
        requested: i32,
        available: i32,
    },
}

Câu hỏi: handler trong shop-api nhận Result<OrderRow, OrderError> — làm sao convert sang AppError để return response qua IntoResponse? Có 2 cách.

Cách 1 — impl From<OrderError> for AppError thủ công, match từng variant:

// File: crates/shop-api/src/responses/error_map.rs (B66 sẽ tạo)
impl From<OrderError> for AppError {
    fn from(err: OrderError) -> Self {
        match err {
            OrderError::Sqlx(e) => AppError::from(e),  // tận dụng From<sqlx::Error>

            OrderError::ProductNotFound(id) => {
                AppError::NotFound(format!("product {} not found", id))
            }

            OrderError::InsufficientStock { product_id, requested, available } => {
                AppError::Validation(format!(
                    "product {} stock {} < requested {}",
                    product_id, available, requested
                ))
            }
        }
    }
}

Cách 2 — dùng #[from] cascade tự convert mọi variant:

// KHÔNG dùng cho Shop API
#[derive(Debug, thiserror::Error)]
pub enum AppError {
    // ...
    #[error(transparent)]
    Order(#[from] OrderError),
}

So sánh:

  • Cách 1 control rõ ràng từng business error semantic. InsufficientStock map 422 vì user gửi quantity vượt stock — input không hợp lệ; ProductNotFound map 404 vì resource đúng nghĩa không tồn tại. Verbose hơn 5-10 dòng cho mỗi domain error enum.
  • Cách 2 ngắn gọn nhưng AppError có 1 variant generic Order không thể map semantic — phải custom IntoResponse trong AppError match nested enum (anti-pattern, vi phạm single responsibility). Mất luôn khả năng convert UserError, CartError độc lập.

Lock pattern Shop API: Cách 1 explicit match. Mọi service domain (B66 OrderError, B104 UserError, B106 CartError) đều có impl From<XxxError> for AppError riêng. Vị trí file: crates/shop-api/src/responses/error_map.rs (sẽ tạo ở B66 khi handler đầu tiên cần dùng).

Ưu điểm phụ: pattern này tách boundary rõ — shop-common::error::AppError không depend shop-db (giữ shop-common pure utility), impl From đặt ở shop-api binary là nơi có cả hai dependency.

7

Retry Strategy Cho 40001 Serialization Failure

SERIALIZATION_FAILURE (40001) và DEADLOCK_DETECTED (40P01) là transient error — retry thường thành công vì 2 transaction tranh chấp lần 2 thường không đụng nhau nữa. Pattern industry: retry với exponential backoff, max 3 attempts.

Tạo module mới crates/shop-common/src/retry.rs:

// File: crates/shop-common/src/retry.rs
use std::future::Future;
use std::time::Duration;
use tokio::time::sleep;

use crate::error::AppError;

pub async fn with_retry<F, Fut, T>(
    mut operation: F,
    max_attempts: u32,
) -> Result<T, AppError>
where
    F: FnMut() -> Fut,
    Fut: Future<Output = Result<T, AppError>>,
{
    let mut attempt = 0;

    loop {
        attempt += 1;

        match operation().await {
            Ok(result) => return Ok(result),

            Err(AppError::SerializationFailure) if attempt < max_attempts => {
                let backoff_ms = 10u64 * 2u64.pow(attempt - 1);  // 10, 20, 40
                tracing::warn!(
                    attempt, max_attempts, backoff_ms,
                    "serialization failure, retrying"
                );
                sleep(Duration::from_millis(backoff_ms)).await;
            }

            Err(e) => return Err(e),
        }
    }
}

Update crates/shop-common/src/lib.rs re-export:

// File: crates/shop-common/src/lib.rs
pub mod config;
pub mod dto;
pub mod error;
pub mod headers;
pub mod pagination;
pub mod retry;       // NEW B55
pub mod telemetry;

pub use retry::with_retry;

Use case ở B66 khi viết handler create order:

// Preview B66 — handler POST /api/v1/orders
pub async fn create_order_handler(
    State(state): State<AppState>,
    ValidatedJson(dto): ValidatedJson<CreateOrderDto>,
) -> AppResult<Created<OrderResponseDto>> {
    let pool = state.db.clone();

    let order = with_retry(
        || {
            let pool = pool.clone();
            let dto = dto.clone();
            async move {
                shop_db::orders::create_order_atomic(
                    &pool,
                    dto.user_id,
                    dto.items,
                    &dto.payment_type,
                    dto.payment_payload,
                )
                .await
                .map_err(AppError::from)
            }
        },
        3,  // max 3 attempts
    )
    .await?;

    Ok(Created {
        location: format!("/api/v1/orders/{}", order.id),
        data: order.into(),
    })
}

Quyết định lock Shop API:

  • Retry MAX 3 attempts cho transient error (40001, 40P01) — sau 3 lần fail thì trả SerializationFailure cho client.
  • Backoff exponential: 10ms → 20ms → 40ms. Tổng worst-case latency thêm 70ms — chấp nhận được cho UX.
  • Fail-fast cho permanent error (23505, 23503, 23514) — KHÔNG retry. Retry không giải quyết gì (slug vẫn trùng, FK vẫn sai) chỉ tốn DB cycle.
  • Helper with_retry reusable cho mọi service tương lai (B66 create_order, B98 admin bulk operation).
8

Verify Error Response Envelope

Build + run server:

cargo build -p shop-common
cargo run -p shop-api

Test 1 — Unique violation 23505 → 409 Conflict. Tạo product 2 lần với cùng slug:

curl -X POST http://localhost:3000/api/v1/products \
  -H 'Content-Type: application/json' \
  -d '{"name":"iPhone 15","slug":"iphone-15","price":"25000000.00","stock":10}'

# Lần thứ 2 (slug trùng) → 409 Conflict
# {
#   "error": "unique violation: products_slug_key on table products",
#   "code": "CONFLICT",
#   "request_id": "550e8400-e29b-41d4-a716-446655440000"
# }

Test 2 — Foreign key violation 23503 → 409 ForeignKeyViolation. Insert order_items với product_id không tồn tại (qua psql trực tiếp vì HTTP endpoint chưa có ở B55):

docker compose exec postgres psql -U shop -d shop_dev -c \
  "INSERT INTO orders (user_id, total) VALUES (1, 100) RETURNING id;"
# id = 1

docker compose exec postgres psql -U shop -d shop_dev -c \
  "INSERT INTO order_items (order_id, product_id, quantity, unit_price)
   VALUES (1, 999, 1, 1000);"
# ERROR:  insert or update on table "order_items" violates foreign key
# constraint "order_items_product_id_fkey"
# DETAIL:  Key (product_id)=(999) is not present in table "products".

Khi gọi qua handler (B66), error này sẽ map qua map_db_errorAppError::ForeignKeyViolation → response:

# {
#   "error": "foreign key violation",
#   "code": "FOREIGN_KEY_VIOLATION",
#   "request_id": "...",
#   "detail": {
#     "constraint": "order_items_product_id_fkey",
#     "table": "order_items"
#   }
# }

Test 3 — Check violation 23514 → 422 CheckViolation. Insert product với price âm:

docker compose exec postgres psql -U shop -d shop_dev -c \
  "INSERT INTO products (name, slug, price, stock)
   VALUES ('X', 'x-bad', -100, 1);"
# ERROR:  new row for relation "products" violates check constraint
# "products_price_check"

Response envelope dự kiến:

# 422 Unprocessable Entity
# {
#   "error": "check constraint violation",
#   "code": "CHECK_VIOLATION",
#   "request_id": "...",
#   "detail": { "constraint": "products_price_check" }
# }

Test 4 — RowNotFound → 404 NotFound. GET product không tồn tại:

curl http://localhost:3000/api/v1/products/non-existing-slug

# 404 Not Found
# {
#   "error": "product non-existing-slug not found",
#   "code": "NOT_FOUND",
#   "request_id": "..."
# }

Pattern lock Shop API: client thấy code rõ ràng → UI hiển thị message phù hợp + i18n key. Field detail structured cho debug (admin dashboard) hoặc client UI highlight constraint vi phạm.

Suggested commit: B55: sqlx error mapping SQLSTATE + 4 AppError variant + retry helper.

9

Tổng Kết

  • sqlx::Error 8+ variant chính: Database (qua PgDatabaseError), RowNotFound, PoolTimedOut, PoolClosed, Io, Tls, Configuration, Protocol, TypeNotFound, ColumnDecode, Decode, WorkerCrashed.
  • SQLSTATE 5-character code Postgres chuẩn ANSI — class 2 ký tự đầu, specific 3 ký tự sau.
  • 23505 UNIQUE → 409 Conflict.
  • 23503 FK → 409 ForeignKeyViolation (detail object constraint + table).
  • 23514 CHECK → 422 CheckViolation (detail object constraint).
  • 23502 NOT NULL → 422 (gộp dưới CheckViolation, validation tầng app phải bắt trước).
  • 40001 SERIALIZATION → retry rồi SerializationFailure 409.
  • 40P01 DEADLOCK → retry hoặc 503.
  • 53300 TOO_MANY_CONNECTIONS → 503 ServiceUnavailable.
  • 5 AppError variant mới: Conflict (reuse từ B16), ForeignKeyViolation, CheckViolation, ServiceUnavailable, SerializationFailure. Tổng AppError sau B55: 19 variant.
  • map_db_error helper function lock pattern — refactor From<sqlx::Error> impl, tách logic SQLSTATE.
  • Domain error wrap sqlx::Error với OrderError::Sqlx(#[from] sqlx::Error) + impl From<OrderError> for AppError explicit match (Cách 1) lock pattern Shop API.
  • Retry strategy cho transient (40001, 40P01): max 3 attempts, exponential backoff 10/20/40ms qua helper with_retry.
  • Fail-fast cho permanent (23505, 23503, 23514): KHÔNG retry — retry không giải quyết được.
  • Logging chi tiết qua tracing::error! với ?code ?constraint ?table debug dễ.
  • Envelope detail object pattern B48 continued cho FK + Check violation.
  • File path lock: extend crates/shop-common/src/error.rs + NEW crates/shop-common/src/retry.rs + update crates/shop-common/src/lib.rs re-export.
  • Foundation cho B56 (connection pool tuning sâu), B57 (pool benchmark), B66 (POST /orders với with_retry).
10

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. sqlx::Error 8+ variant nào? Mỗi variant map sang status code nào trong Shop API?
  2. SQLSTATE 23505 vs 23503 — use case khác nhau ra sao? Cho ví dụ cụ thể trong Shop API.
  3. Tại sao 23514 map 422 (Validation) thay 409 (Conflict)? Semantic difference?
  4. Retry strategy cho 40001 SERIALIZATION_FAILURE — backoff bao nhiêu? Tại sao KHÔNG retry 23505?
  5. Domain error wrap sqlx::Error — 2 cách convert sang AppError, pros/cons mỗi cách?
Đáp án
  1. sqlx::Error 8+ variant + mapping: sqlx 0.8 có hơn 12 variant chính. Database-side: Database(Box<dyn DatabaseError>) wrap PgDatabaseError → mapping per-SQLSTATE qua map_db_error (varies status); RowNotFoundAppError::NotFound 404 (handler fetch_one không có row). Connection-side: PoolTimedOutAppError::ServiceUnavailable 503 (pool exhausted); PoolClosedAppError::Internal 500 + log error (pool đã shut down); Io(std::io::Error)Internal 500 + log (network/file IO error); TlsInternal 500 (TLS handshake fail); Protocol(String)Internal 500 (sai protocol Postgres); WorkerCrashedInternal 500. Codec-side: TypeNotFound { type_name }Internal 500 (Rust type không match Postgres — server bug schema); ColumnDecode { index, source }Internal 500 (decode 1 column fail — schema mismatch); Decode(Box<dyn Error>)Internal 500 (generic decode fail); ConfigurationInternal 500 (config parse fail lúc startup). Lock Shop API: tập trung mapping chi tiết RowNotFound + Database (SQLSTATE) + PoolTimedOut (3 case chiếm >95% production), phần còn lại default Internal 500 + log tracing::error! chi tiết để debug; KHÔNG leak SQL/schema chi tiết ra client.
  2. 23505 vs 23503 use case: 23505 UNIQUE_VIOLATION — insert/update vi phạm UNIQUE constraint, key value trùng row đã có. Use case Shop API: (a) POST /api/v1/products với slug = "iphone-15" đã tồn tại → constraint products_slug_key fail; (b) POST /api/v1/auth/register với email trùng → constraint users_email_key; (c) Stripe webhook retry tạo lại payment với payment_intent_id trùng → constraint payments_payment_intent_id_key. Helper db_err.is_unique_violation() trả true. Map: 409 Conflict. 23503 FOREIGN_KEY_VIOLATION — insert FK trỏ tới row không tồn tại, hoặc delete row đang được FK reference (chỉ với ON DELETE RESTRICT hoặc NO ACTION). Use case Shop API: (a) create_order_atomic insert order_items với product_id = 999 không tồn tại → constraint order_items_product_id_fkey; (b) DELETE /api/v1/admin/products/:id mà product đang trong order_items còn → constraint cùng tên do ON DELETE RESTRICT (lock B54); (c) insert payments với order_id trỏ orders.id không tồn tại → constraint payments_order_id_fkey. Helper db_err.is_foreign_key_violation(). Map: 409 ForeignKeyViolation (lock Shop API) với detail object {constraint, table} envelope. Khác biệt: 23505 = "tài liệu này đã tồn tại" (duplicate key), 23503 = "reference tới resource không hợp lệ" (broken link); cả 2 đều 409 nhưng code khác để client phân biệt UI message.
  3. 23514 → 422 thay 409 vì semantic difference: HTTP 409 Conflict nghĩa là "request không thể hoàn thành vì conflict với state hiện tại của resource" (RFC 9110 mục 15.5.10) — typical use case duplicate key, optimistic lock fail, FK conflict. HTTP 422 Unprocessable Entity (RFC 9110 mục 15.5.21) nghĩa là "server hiểu Content-Type và parse được body nhưng nội dung vi phạm semantic/business rule". 23514 CHECK_VIOLATION là vi phạm CHECK constraint trên cột (vd products.stock CHECK (stock >= 0), orders.total CHECK (total > 0)) — input value sai semantic business rule (price âm, stock âm) chứ KHÔNG phải conflict với state hiện tại. Map 422 vì: (a) consistent với ValidationFailed validator crate B41 (cùng nguyên nhân — input không hợp lệ business rule), (b) client UI handle giống nhau (highlight field sai + hiển thị message), (c) semantic chính xác — không có resource conflict, chỉ input không pass DB constraint. Lưu ý: validation tầng app (ValidatedJson<CreateProductDto> B41 với #[validate(range(min = 1))]) phải bắt trước; nếu reach DB nghĩa là frontend gửi giá trị edge case mà DTO rule miss — CHECK ở DB là lưới an toàn cuối (defense in depth). Helper db_err.is_check_violation(). Lock Shop API: 23514 + 23502 NOT_NULL_VIOLATION đều gộp về CheckViolation 422 + detail object {constraint} envelope cho client biết constraint nào fail.
  4. Retry 40001 backoff + tại sao KHÔNG retry 23505: 40001 SERIALIZATION_FAILURE và 40P01 DEADLOCK_DETECTED là transient error — nguyên nhân tạm thời (2 transaction concurrent ghi cùng row ở SERIALIZABLE isolation hoặc deadlock cycle), retry lần 2 thường thành công vì 2 transaction không còn cùng lúc tranh chấp. Pattern industry: exponential backoff tránh thundering herd — wait time tăng gấp đôi mỗi attempt cho DB thời gian giải quyết contention. Lock Shop API: max 3 attempts, backoff 10ms → 20ms → 40ms qua công thức 10 * 2^(attempt-1) ms. Tổng worst-case latency thêm 70ms — chấp nhận cho UX (user thấy delay nhỏ vẫn tốt hơn fail). Sau 3 attempts trả SerializationFailure 409 cho client biết "transaction conflict, please retry". Helper with_retry module shop-common::retry reusable mọi service. KHÔNG retry 23505 vì đây là permanent error — slug trùng vẫn trùng kể cả retry 100 lần, FK sai vẫn sai, check fail vẫn fail. Retry không giải quyết được nguyên nhân — chỉ tốn DB cycle + tăng latency vô ích. Fail-fast trả 409 ngay cho client biết "data conflict, please change input". Cùng logic áp dụng cho 23503, 23514, 23502 — mọi class 23 integrity constraint violation đều fail-fast. Lock Shop API: helper with_retry chỉ retry khi matches!(err, AppError::SerializationFailure); mọi error khác bubble lên ngay.
  5. Domain error → AppError 2 cách pros/cons: Cách 1 — impl From<OrderError> for AppError thủ công match từng variant. Code: impl From<OrderError> for AppError { fn from(err: OrderError) -> Self { match err { OrderError::Sqlx(e) => AppError::from(e), OrderError::ProductNotFound(id) => AppError::NotFound(format!("product {} not found", id)), OrderError::InsufficientStock { product_id, requested, available } => AppError::Validation(format!("product {} stock {} < requested {}", product_id, available, requested)) } } }. Pros: (a) control rõ ràng từng business error semantic — InsufficientStock map 422 Validation vì user gửi quantity vượt stock (input không hợp lệ), ProductNotFound map 404 NotFound vì resource đúng nghĩa không tồn tại; (b) message format tùy chỉnh có context (product id, stock value) — client/log đọc rõ; (c) thay đổi business logic chỉ sửa 1 file mapping, không động đến enum domain hay AppError; (d) test dễ — match exhaustive compiler enforce thêm variant mới phải update mapping. Cons: verbose 5-10 dòng cho mỗi domain enum, repeat code khi nhiều service (Order/Cart/User/Payment). Cách 2 — thiserror #[from] cascade tự convert. Code: #[derive(Debug, thiserror::Error)] enum AppError { #[error(transparent)] Order(#[from] OrderError), #[error(transparent)] User(#[from] UserError), ... }. Pros: ngắn gọn, không viết match arm thủ công, ? operator tự convert. Cons: (a) AppError có nhiều variant generic Order/User/Cart mất khả năng map semantic — phải custom IntoResponse trong AppError match nested enum (anti-pattern, vi phạm single responsibility); (b) shop-common::error phải depend mọi crate domain (shop-db) — vi phạm dependency direction (common là leaf, không nên depend module cao tầng); (c) khó debug — error message nested khó đọc, stack trace không rõ; (d) test khó — phải xây dựng OrderError → kiểm tra outer match nested. Lock pattern Shop API: Cách 1 explicit match cho mọi service domain (B66 OrderError, B104 UserError, B106 CartError, B71 PaymentError). Vị trí file: crates/shop-api/src/responses/error_map.rs (B66 tạo) — nơi có cả AppError (shop-common) và domain error (shop-db) trong dependency.
11

Bài Tiếp Theo

— chi tiết PgPoolOptions: max_connections vs Postgres limit, acquire_timeout vs request timeout, idle_timeout life cycle, before_acquire/after_connect hook, statement cache, áp Shop API multi-env pool config (dev/staging/prod).