Danh sách bài viết

Bài 16: Error Response Pattern Với IntoResponse

Bài 16 của series Rust RESTful API — bài code thực hành thứ hai sau B10, implement axum::response::IntoResponse cho shop_common::error::AppError đã define 11 variants từ B10, map mỗi variant về HTTP status code đúng theo lock B3 (BadRequest → 400, Unauthenticated → 401, Forbidden → 403, NotFound → 404, MethodNotAllowed → 405, Conflict → 409, Validation → 422, RateLimited → 429, Internal → 500, Upstream → 502, Unavailable → 503), build error envelope JSON chuẩn { error, code, request_id } qua hai helper internal status_code() + code() (SCREAMING_SNAKE_CASE), gắn header phụ trợ tự động per variant (WWW-Authenticate: Bearer realm="shop-api" cho 401, Retry-After cho 429 + 503, Allow cho 405), tự log Internal/Upstream qua tracing::error! để không leak stack trace ra client, add axum + serde_json vào crates/shop-common/Cargo.toml để giải orphan rule cho phép impl IntoResponse trực tiếp trong shop-common; cập nhật crates/shop-api/src/main.rs thêm 3 demo handler GET /error/not-found + GET /error/unauthenticated + GET /error/rate-limited test full error path qua curl; sau bài này mọi handler Shop API return AppResult<Json<T>> dùng toán tử ? clean, error path tự động convert về response chuẩn không phải tự build response error per endpoint, field request_id hiện placeholder null sẽ inject từ Extension<RequestId> middleware ở B39.

12/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 pattern error response REST nhất quán — status code đúng category cộng body envelope cố định cross-endpoint, không sáng tạo riêng cho từng handler.
  • Implement axum::response::IntoResponse cho shop_common::error::AppError đã define 11 variants từ B10, không redefine enum.
  • Map mỗi variant về HTTP status code đúng theo lock B3 qua helper internal status_code() -> StatusCode.
  • Sinh field code SCREAMING_SNAKE_CASE từ variant name qua helper code() -> &'static str, dùng cho client filter/i18n/error tracking.
  • Gắn header phụ trợ tự động per error type — WWW-Authenticate cho 401, Retry-After cho 429 + 503, Allow cho 405 (lock B4).
  • Tự log Internal/Upstream qua tracing::error! để observability nắm error mà client chỉ thấy generic message, không leak stack trace.
  • Viết handler return AppResult<Json<T>> clean dùng toán tử ? — không phải tự build response error per endpoint.
  • Verify error response qua curl với 3 demo endpoint trả 404, 401, 429.
2

State Hiện Tại Recap

Trước khi vào code, recap state workspace tính đến B15:

  • shop_common::error::AppError đã define ở B10 với 11 variants dùng #[derive(thiserror::Error)]: BadRequest(String), Unauthenticated, Forbidden, NotFound(String), MethodNotAllowed(String), Conflict(String), Validation(String), RateLimited(u64), Internal(#[from] anyhow::Error), Upstream(String), Unavailable. Bài này KHÔNG redefine enum — chỉ extend impl.
  • shop_common::error::AppResult<T> alias = Result<T, AppError> đã có từ B10. Mọi handler sau B16 return AppResult<Json<T>>.
  • Mapping HTTP status đã lock B3BadRequest → 400, Unauthenticated → 401, Forbidden → 403, NotFound → 404, MethodNotAllowed → 405, Conflict → 409, Validation → 422, RateLimited → 429, Internal → 500, Upstream → 502, Unavailable → 503.
  • Error envelope đã lock B3 — body JSON shape { "error": "<message>", "code": "<SCREAMING_SNAKE_CASE>", "request_id": "<id>" }. Field request_id placeholder null ở bài này, sẽ inject từ Extension<RequestId> middleware ở B39.
  • Header phụ trợ đã lock B4WWW-Authenticate: Bearer realm="shop-api" cho 401, Retry-After cho 429 + 503, Allow cho 405.
  • Tracing đã init ở B10 qua shop_common::telemetry::init_tracing — handler chỉ cần gọi macro tracing::error! log với context fields đầy đủ.
  • Workspace deps đã lock B10axum, serde_json, thiserror 2, tracing đều ở [workspace.dependencies], bài này chỉ kéo về crates/shop-common/Cargo.toml qua .workspace = true.

Mục tiêu B16: impl IntoResponse cho AppError để mọi handler Shop API dùng toán tử ? clean, error path tự động convert response.

3

Tại Sao Cần Pattern Nhất Quán

Pattern error response nhất quán cross-endpoint không phải "nice to have" — đây là contract bắt buộc cho mọi API production. Năm lý do cụ thể:

  • Client parse một format duy nhất. Frontend/mobile có một hàm parseApiError(response) dùng cho mọi endpoint, không phải if (endpoint === "/products") { ... } else if (endpoint === "/cart") { ... }. Mỗi inconsistency là một bug khi dev frontend chuyển endpoint.
  • Filter logging và monitoring theo code field. Loki/Elasticsearch query code = "VALIDATION_FAILED" đếm số 422 cross-endpoint, alert khi tăng vọt. Nếu mỗi endpoint trả format khác nhau, dashboard phải parse riêng.
  • Error tracking (Sentry, Honeybadger) gom theo code. Sentry group fingerprint dựa trên codeRATE_LIMITED/cart/checkout gom cùng issue, không phải hai issue tách biệt.
  • i18n localization theo code. Field error là dev-friendly tiếng Anh ("not found: product 'phone-x' not found"); client dịch sang tiếng Việt/Nhật/Hàn dựa trên code + parameter trong message, không phải string match dễ vỡ.
  • Correlation log qua request_id. Khi user báo bug "error 500 lúc checkout", support hỏi request_id, search log toàn distributed trace — middleware X-Request-Id (lock B4) sẽ inject vào envelope ở B39.

Anti-pattern phổ biến cần tránh: (a) một số endpoint trả { "message": "..." } chỗ khác { "detail": "..." } chỗ khác nữa { "errors": [{ "field": "..." }] } — frontend phải union type parse phức tạp; (b) status code không match payload (vd trả 200 OK với body { "error": "..." } — đã lock B3 cấm tuyệt đối); (c) leak stack trace ra body (debug local OK, production là lỗ hổng security + làm dev lười sửa root cause).

Pattern Shop API: single source-of-truth cho mapping enum → response trong impl IntoResponse for AppError, mọi handler ? bubble error tự động.

4

Cấu Trúc File & Orphan Rule

Trước khi viết code, có một decision quan trọng cần giải: impl IntoResponse cho AppError đặt ở crate nào?

Rust có orphan rule (quy tắc mồ côi) cho impl Trait for Type: chỉ được impl khi hoặc Trait, hoặc Type là local trong crate bạn đang viết. Trường hợp B16:

  • axum::response::IntoResponse — trait external (định nghĩa trong crate axum).
  • shop_common::error::AppError — type local trong crate shop-common.

Hai lựa chọn:

  • Approach A — impl trong shop-api qua newtype wrapper: pub struct ApiError(pub AppError); rồi impl IntoResponse for ApiError. Handler return Result<T, ApiError>, mỗi ? phải convert qua From<AppError>. Trade-off: thêm một type cross-crate, signature handler verbose hơn, mọi crate gọi cần biết về ApiError.
  • Approach B — impl trực tiếp trong shop-common bằng cách thêm axum làm dependency của shop-common. Orphan rule OK (type local, trait external nhưng impl trong crate define type). Handler return AppResult<Json<T>> = Result<Json<T>, AppError> clean, ? không phải convert thêm bước.

Shop API chọn Approach B. Lý do: shop-common là crate "shared infrastructure" trong workspace, không phải pure domain logic (pure domain ở shop-core sẽ init G4). Việc share AppError + impl IntoResponse cùng nơi là natural — giữ single source-of-truth cho mapping enum → response. Phụ thuộc axum cho shop-common không phá tính chất "common" vì axum là HTTP framework cốt lõi dùng toàn workspace (shop-worker B247 sẽ gọi service trả AppResult, dù không phải HTTP, vẫn cần convert error path khi log).

File path lock đã ghi từ B14 là crates/shop-api/src/responses/error.rs. Với Approach B, file đó vẫn được tạo nhưng dùng cho custom error rejection từ axum extractor (vd JsonRejectionAppError::BadRequest, chi tiết B41) — không chứa impl IntoResponse for AppError. impl IntoResponse chuyển sang crates/shop-common/src/error.rs.

Folder structure sau B16:

crates/shop-common/src/
├── lib.rs              (existing B10)
├── config.rs           (existing B10)
├── error.rs            (B10 init + B16 extend impl IntoResponse)
├── headers.rs          (existing B10)
└── telemetry.rs        (existing B10)

crates/shop-api/src/
└── main.rs             (B12 + B16 extend 3 demo error routes)

File crates/shop-api/src/responses/error.rs chưa cần create ở B16 — sẽ create ở B41 khi cần wrap JsonRejection custom. Sub-agent sau B16 đọc note này không lăn tăn về file path.

5

Step 1: Add axum Dependency Cho shop-common

Update crates/shop-common/Cargo.toml thêm hai dependency:

# File: crates/shop-common/Cargo.toml
[package]
name = "shop-common"
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
license.workspace = true

[dependencies]
# Existing từ B10
serde = { workspace = true, features = ["derive"] }
thiserror = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
anyhow = { workspace = true }
dotenvy = { workspace = true }

# Thêm B16 — cho impl IntoResponse for AppError
axum = { workspace = true }
serde_json = { workspace = true }

Version inherit từ [workspace.dependencies] đã lock B10 (axum 0.8, serde_json 1) — không khai báo version riêng. Pattern này đã lock từ B10 ("workspace.dependencies single-source-of-truth").

Verify build sau khi update:

cd shop
cargo build -p shop-common
# Compiling shop-common v0.1.0
#     Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.2s

Bước này chỉ thêm dep, chưa import gì trong code shop-common — build pass nhưng chưa tận dụng. Step 2 + 3 mới impl thật.

6

Step 2: Helper status_code() & code()

Trước khi viết impl IntoResponse, tách hai helper method nhỏ — mapping enum variant về StatusCode và về string code. Pattern này giữ code đọc dễ hơn so với nhồi tất cả mapping vào into_response một block.

Mở file crates/shop-common/src/error.rs đã tồn tại từ B10 với enum 11 variants. Thêm impl AppError block với hai method:

// File: crates/shop-common/src/error.rs (extend từ B10)
use axum::http::StatusCode;

// Enum AppError giữ nguyên từ B10 — KHÔNG redefine ở đây.
// pub enum AppError { BadRequest(String), Unauthenticated, ... Unavailable }

impl AppError {
    /// Map variant → HTTP status code (lock B3).
    fn status_code(&self) -> StatusCode {
        match self {
            Self::BadRequest(_) => StatusCode::BAD_REQUEST,                    // 400
            Self::Unauthenticated => StatusCode::UNAUTHORIZED,                  // 401
            Self::Forbidden => StatusCode::FORBIDDEN,                           // 403
            Self::NotFound(_) => StatusCode::NOT_FOUND,                         // 404
            Self::MethodNotAllowed(_) => StatusCode::METHOD_NOT_ALLOWED,        // 405
            Self::Conflict(_) => StatusCode::CONFLICT,                          // 409
            Self::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,            // 422
            Self::RateLimited(_) => StatusCode::TOO_MANY_REQUESTS,              // 429
            Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,             // 500
            Self::Upstream(_) => StatusCode::BAD_GATEWAY,                       // 502
            Self::Unavailable => StatusCode::SERVICE_UNAVAILABLE,               // 503
        }
    }

    /// Sinh code SCREAMING_SNAKE_CASE từ variant name cho client filter/i18n.
    fn code(&self) -> &'static str {
        match self {
            Self::BadRequest(_) => "BAD_REQUEST",
            Self::Unauthenticated => "UNAUTHENTICATED",
            Self::Forbidden => "FORBIDDEN",
            Self::NotFound(_) => "NOT_FOUND",
            Self::MethodNotAllowed(_) => "METHOD_NOT_ALLOWED",
            Self::Conflict(_) => "CONFLICT",
            Self::Validation(_) => "VALIDATION_FAILED",
            Self::RateLimited(_) => "RATE_LIMITED",
            Self::Internal(_) => "INTERNAL_ERROR",
            Self::Upstream(_) => "UPSTREAM_ERROR",
            Self::Unavailable => "SERVICE_UNAVAILABLE",
        }
    }
}

Quan sát ba điểm:

  • Visibility fn (private) — hai method này là detail internal của IntoResponse impl, không expose ra ngoài. Caller chỉ tương tác qua err.into_response() hoặc gián tiếp qua handler ? operator.
  • Mapping table 1-1 đầy đủ 11 variants — pattern match exhaustive, compiler error khi sau này thêm variant mới mà quên update mapping (đặc tính an toàn của Rust enum). Đối với Upstream, lock B3 ghi "502/504" — Shop API ưu tiên 502 mặc định (invalid response), khi cần phân biệt timeout (504) có thể thêm variant UpstreamTimeout riêng ở G18 (chưa lock).
  • Code string lowercase variant thành SCREAMING_SNAKE_CASE — không phải copy-paste variant name. Validation"VALIDATION_FAILED" (thêm postfix FAILED cho rõ semantic), Internal"INTERNAL_ERROR" (thêm ERROR), Upstream"UPSTREAM_ERROR", Unavailable"SERVICE_UNAVAILABLE" (match status RFC tên). Convention này lock từ B16, mọi variant thêm mới sau follow pattern.

Tách helper đem lại lợi ích thứ ba ngoài đọc dễ: test unit từng helper riêng được. Bài này không viết unit test (tập trung code path chính), nhưng pattern cho phép G7 thêm test assert_eq!(AppError::NotFound("x".into()).status_code(), StatusCode::NOT_FOUND) khi cần.

7

Step 3: Implement IntoResponse Đầy Đủ

Đây là phần chính của bài. Thêm tiếp impl IntoResponse vào file crates/shop-common/src/error.rs:

// File: crates/shop-common/src/error.rs (tiếp theo Step 2)
use axum::{
    http::{header, HeaderValue, StatusCode},
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let status = self.status_code();
        let code = self.code();
        let message = self.to_string();         // qua Display impl từ thiserror

        // Log internal/upstream error chi tiết để dev debug từ trace log.
        // Client KHÔNG thấy stack trace — chỉ thấy generic message + code.
        if matches!(self, Self::Internal(_) | Self::Upstream(_)) {
            tracing::error!(
                error = %message,
                code = code,
                status = status.as_u16(),
                "internal error occurred"
            );
        }

        // Body envelope chuẩn (lock B3) — request_id sẽ inject từ middleware B39.
        let body = Json(json!({
            "error": message,
            "code": code,
            "request_id": serde_json::Value::Null,
        }));

        // Build base response (StatusCode + Json body).
        let mut response = (status, body).into_response();
        let headers = response.headers_mut();

        // Gắn header phụ trợ per variant (lock B4).
        match &self {
            Self::Unauthenticated => {
                headers.insert(
                    header::WWW_AUTHENTICATE,
                    HeaderValue::from_static("Bearer realm=\"shop-api\""),
                );
            }
            Self::RateLimited(retry_seconds) => {
                headers.insert(header::RETRY_AFTER, HeaderValue::from(*retry_seconds));
            }
            Self::Unavailable => {
                headers.insert(header::RETRY_AFTER, HeaderValue::from(60u64));
            }
            Self::MethodNotAllowed(allowed) => {
                if let Ok(value) = HeaderValue::from_str(allowed) {
                    headers.insert(header::ALLOW, value);
                }
            }
            _ => {}
        }

        response
    }
}

Phân tích từng phần:

  • self.to_string() — gọi Display impl mà thiserror::Error derive macro tự sinh từ #[error("...")] attribute trên enum variant (B10). Vd AppError::NotFound("product 'phone-x' not found".to_string()).to_string() trả "not found: product 'phone-x' not found". Đây là dev-friendly message, dùng cho log + field error envelope.
  • Internal/Upstream log qua tracing::error! — bắt buộc cho hai variant nhạy cảm này. Lý do: Internal là panic/uncaught error (vd database connection drop, JSON parse fail không expect), Upstream là third-party service hỏng — dev cần biết để debug root cause. Structured field (error, code, status) cho phép Loki/Elasticsearch query nhanh.
  • Body envelope { error, code, request_id } qua macro serde_json::json! — không phải define struct riêng vì envelope cố định, generic không cần. Field request_id hiện serde_json::Value::Null — sẽ inject thực tế khi middleware B39 set Extension<RequestId>, lúc đó into_response đọc Extension qua context request (yêu cầu refactor signature dùng FromRequestParts, lock chờ B39).
  • Build response 2 bước — bước 1 (status, body).into_response() tạo response cơ bản với status + JSON body (tuple impl IntoResponse đã lock B14). Bước 2 response.headers_mut() lấy &mut HeaderMap để chèn thêm header phụ trợ — cách clean hơn so với build tuple 3-element (StatusCode, HeaderMap, Json) phải build HeaderMap cho mọi nhánh kể cả khi không thêm gì.
  • Match header phụ trợ — chỉ 4 variant cần header thêm (lock B4):
    • UnauthenticatedWWW-Authenticate: Bearer realm="shop-api" (RFC 6750 yêu cầu 401 phải có header này để client biết auth scheme expected).
    • RateLimited(N)Retry-After: N (giây). Field u64 dùng HeaderValue::from trực tiếp — convert qua impl From<u64> for HeaderValue axum cung cấp.
    • UnavailableRetry-After: 60 (giây, hard-coded vì variant không carry value). Khi cần dynamic, sửa thành Unavailable(u64) tương lai.
    • MethodNotAllowed(allowed)Allow: <allowed>. String allowed là format RFC 9110 "GET, POST, PUT" — caller build sẵn trước khi tạo error. HeaderValue::from_str có thể fail nếu chuỗi có ký tự không valid header value (rất hiếm với verb HTTP), dùng if let Ok defense — không panic.
  • Arm _ => {} — bỏ qua các variant còn lại không cần header thêm. Compiler không complain non-exhaustive vì có catch-all.

Bonus — AppError đã có #[from] anyhow::Error trên variant Internal (lock B10), tức là impl From<anyhow::Error> for AppError tự generate bởi thiserror. Lợi ích thực tế: trong handler dùng crate gọi trả anyhow::Result<T> (vd std::fs::read wrap qua anyhow), ? operator tự convert thành AppError::Internal(err):

// Pattern handler đầy đủ (preview, sẽ dùng từ G7 onward):
async fn get_user(
    State(state): State<AppState>,
    Path(id): Path<i64>,
) -> AppResult<Json<UserDto>> {
    // sqlx error map qua From<sqlx::Error> for AppError (sẽ lock G6)
    let user = state.user_service.find(id).await?;        // ? bubble lỗi

    // anyhow error tự convert thành AppError::Internal qua #[from]
    let _config_value = read_external_config().context("read config fail")?;

    Ok(Json(UserDto::from(user)))
}

Mọi ? đều bubble error về AppError, axum tự gọi into_response() trả client response đúng status + envelope chuẩn. Code handler chỉ chứa business logic, không có match err.

8

Step 4: Demo Handler Test Error Path

Để verify impl IntoResponse chạy đúng, thêm 3 demo handler tạm thời vào crates/shop-api/src/main.rs. Pattern này tạm thời — khi G3+ có handler resource thật (Product, User, Cart), route demo sẽ remove.

Mở crates/shop-api/src/main.rs (state hiện tại B12 với 3 route / + /health + /version), extend như sau:

// File: crates/shop-api/src/main.rs (extend B12)
use axum::{
    routing::get,
    Json, Router,
};
use serde_json::json;
use shop_common::error::{AppError, AppResult};
// ... existing imports B10/B12 ...

fn build_router() -> Router {
    Router::new()
        // 3 route cũ B12 — giữ nguyên
        .route("/", get(root))
        .route("/health", get(health))
        .route("/version", get(version))
        // 3 demo route B16 — sẽ remove khi G3+ có handler thực tế
        .route("/error/not-found", get(demo_not_found))
        .route("/error/unauthenticated", get(demo_unauthenticated))
        .route("/error/rate-limited", get(demo_rate_limited))
}

// Demo error handlers — verify IntoResponse impl chạy đúng (B16).
// 3 handler dưới đều return AppResult<Json<serde_json::Value>> nhưng luôn Err
// để force error path. Khi handler thực tế ở G7, remove cả 3 route demo này.

async fn demo_not_found() -> AppResult<Json<serde_json::Value>> {
    Err(AppError::NotFound("product 'phone-x' not found".to_string()))
}

async fn demo_unauthenticated() -> AppResult<Json<serde_json::Value>> {
    Err(AppError::Unauthenticated)
}

async fn demo_rate_limited() -> AppResult<Json<serde_json::Value>> {
    Err(AppError::RateLimited(30))
}

Quan sát signature:

  • Return type AppResult<Json<serde_json::Value>> = Result<Json<Value>, AppError>. Cả 2 vế OkErr đều implement IntoResponse (lock B13): Json<T> impl từ axum, AppError impl mới B16 — handler compile được.
  • Trả Err(AppError::Xxx) không gói qua ? vì không có function nào trả Result để chain. Pattern ? chỉ áp dụng khi có function nhỏ trong handler body (vd service.find(id).await?) — bài này demo path đơn giản nhất.
  • 3 variant đại diện 3 nhánh code path khác nhau:
    • NotFound(String) — variant chứa data, message build từ format!("not found: {}", _) theo #[error("not found: {0}")] attribute B10.
    • Unauthenticated — unit variant, message cố định "unauthenticated", có header phụ trợ WWW-Authenticate.
    • RateLimited(30) — variant u64, message format "rate limited: retry after 30 seconds", header phụ trợ Retry-After: 30.

Workspace dep không phải thêm gì — serde_json đã có sẵn trong crates/shop-api/Cargo.toml từ B10 (handler /health + /version đã dùng json!), shop-common đã path dep cũng từ B10.

Sanity check imports: nếu IDE phát hiện AppError hoặc AppResult chưa import, đảm bảo dòng use shop_common::error::{AppError, AppResult}; được thêm. Nếu compiler không thấy IntoResponse impl, kiểm tra crates/shop-common/Cargo.toml đã có dòng axum = { workspace = true } (Step 1).

9

Step 5: Run & Verify Với curl

Build và chạy:

cd shop
cargo run -p shop-api
# Compiling shop-common v0.1.0 (.../crates/shop-common)
# Compiling shop-api v0.1.0 (.../crates/shop-api)
#     Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.8s
#      Running `target/debug/shop-api`
# 2026-06-12T10:00:00.123Z  INFO shop_api: shop-api listening addr=127.0.0.1:3000

Mở terminal khác, test 3 endpoint demo:

Test 1 — GET /error/not-found trả 404:

curl -i http://localhost:3000/error/not-found
# HTTP/1.1 404 Not Found
# content-type: application/json
# content-length: 99
# date: Thu, 12 Jun 2026 10:00:05 GMT
#
# {"code":"NOT_FOUND","error":"not found: product 'phone-x' not found","request_id":null}

Status 404 đúng mapping, body envelope đầy đủ 3 field, request_id hiện null (placeholder). Field error chứa dev-friendly message build từ #[error("not found: {0}")].

Test 2 — GET /error/unauthenticated trả 401 + header phụ trợ:

curl -i http://localhost:3000/error/unauthenticated
# HTTP/1.1 401 Unauthorized
# content-type: application/json
# www-authenticate: Bearer realm="shop-api"
# content-length: 67
# date: Thu, 12 Jun 2026 10:00:10 GMT
#
# {"code":"UNAUTHENTICATED","error":"unauthenticated","request_id":null}

Status 401 đúng, header www-authenticate: Bearer realm="shop-api" được set tự động — client SDK đọc header này biết cần gửi token Bearer ở header Authorization. Body envelope đầy đủ.

Test 3 — GET /error/rate-limited trả 429 + Retry-After:

curl -i http://localhost:3000/error/rate-limited
# HTTP/1.1 429 Too Many Requests
# content-type: application/json
# retry-after: 30
# content-length: 87
# date: Thu, 12 Jun 2026 10:00:15 GMT
#
# {"code":"RATE_LIMITED","error":"rate limited: retry after 30 seconds","request_id":null}

Status 429 đúng, header retry-after: 30 tự set theo value u64 trong variant — client biết chờ 30 giây trước khi retry. Body envelope đầy đủ.

Ba test confirm impl IntoResponse chạy đúng end-to-end:

  • Status code match mapping lock B3.
  • Body envelope { error, code, request_id } đúng shape lock B3 với request_id: null placeholder.
  • Content-Type application/json tự set bởi Json wrapper.
  • Header phụ trợ (www-authenticate, retry-after) set tự động per variant.

Test thêm cho MethodNotAllowedUnavailable không demo ở route public, nhưng pattern tương tự — header Allow hoặc Retry-After: 60 sẽ tự set khi handler trả variant tương ứng.

Suggested commit sau khi verify xong:

git add crates/shop-common/Cargo.toml \
        crates/shop-common/src/error.rs \
        crates/shop-api/src/main.rs
git commit -m "B16: implement AppError IntoResponse với 11 variants → HTTP status mapping + error envelope"

Sau commit, mọi handler tương lai từ G3 onward dùng AppResult<Json<T>> mặc định — pattern đã enable.

10

Tổng Kết

  • shop_common::error::AppError giờ impl axum::response::IntoResponse trực tiếp, không qua wrapper — single source-of-truth cho mapping enum → response cross-workspace.
  • Thêm hai dep cho crates/shop-common/Cargo.toml: axum = { workspace = true } + serde_json = { workspace = true }. Orphan rule OK vì AppError là type local trong cùng crate.
  • Hai helper internal status_code() -> StatusCodecode() -> &'static str mapping 11 variants — exhaustive match, compiler enforce khi thêm variant mới.
  • Mapping HTTP status cho 11 variants (lock B3): 400 BadRequest, 401 Unauthenticated, 403 Forbidden, 404 NotFound, 405 MethodNotAllowed, 409 Conflict, 422 Validation, 429 RateLimited, 500 Internal, 502 Upstream, 503 Unavailable.
  • Error envelope JSON: { "error": "<dev message>", "code": "<SCREAMING_SNAKE>", "request_id": null } — lock B3. error qua self.to_string() từ Display impl thiserror, code qua helper code().
  • 4 header phụ trợ tự động set per variant (lock B4):
    • UnauthenticatedWWW-Authenticate: Bearer realm="shop-api".
    • RateLimited(N)Retry-After: N.
    • UnavailableRetry-After: 60 (hard-coded).
    • MethodNotAllowed(allowed)Allow: <allowed>.
  • Internal/Upstream error tự log qua tracing::error! với structured field (error, code, status) — observability đầy đủ, client chỉ thấy generic message không leak stack trace.
  • #[from] anyhow::Error trên AppError::Internal (B10 đã có) cho phép ? operator tự convert anyhow::Result<T>AppResult<T> trong handler body — code business clean.
  • request_id hiện placeholder null, sẽ inject từ Extension<RequestId> middleware ở B39 (refactor sang FromRequestParts để đọc Extension context).
  • 3 demo handler /error/not-found, /error/unauthenticated, /error/rate-limited trong main.rs tạm thời để verify — sẽ remove khi G3+ có handler resource thật.
  • File crates/shop-api/src/responses/error.rs chưa tạo ở bài này (lock B14 ghi nhưng implement reschedule) — sẽ tạo ở B41 cho custom JsonRejectionAppError::BadRequest mapping.
  • Sau B16: mọi handler return AppResult<Json<T>> dùng ? operator clean, error path tự động map về response chuẩn — pattern lock cho mọi resource handler từ G3 onward.
11

Bài Tập Củng Cố

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

  1. Vì sao impl IntoResponse for AppError được đặt trong shop-common mà không phải shop-api? Orphan rule là gì? Hai approach khác (newtype wrapper, feature gate) có gì khác biệt?
  2. AppError::RateLimited(30) trả response status code gì? Body chứa field gì? Header nào được set tự động và giá trị bao nhiêu?
  3. Variant InternalUpstream có hai hành vi đặc biệt khác các variant còn lại. Liệt kê và giải thích ngắn gọn lý do.
  4. Field request_id trong body hiện luôn là null. Bài nào sẽ inject value thực tế? Mechanism dùng là gì (extractor nào, set ở đâu)?
  5. Attribute #[from] anyhow::Error trên variant Internal (lock B10) làm gì cụ thể? Khi handler dùng ? operator trên một anyhow::Result<T>, flow conversion diễn ra thế nào?
Đáp án
  1. impl IntoResponse for AppError đặt trong shop-commonorphan rule của Rust. Orphan rule: với impl Trait for Type, chỉ được implement khi hoặc Trait (định nghĩa trong crate đang viết) hoặc Type (định nghĩa trong crate đang viết) là local. Trường hợp B16: IntoResponse external (crate axum), AppError local trong shop-common — impl phải đặt trong crate define AppError (tức shop-common) hoặc crate define IntoResponse (tức axum, không thể vì không phải code của ta). Nếu cố impl trong shop-api, compiler báo error "only traits defined in the current crate can be implemented for types defined outside of the crate". Hai approach khác: (a) Newtype wrapper — define pub struct ApiError(pub AppError); trong shop-api, ApiError là type local nên impl IntoResponse for ApiError được. Handler return Result<T, ApiError>, mỗi ? phải convert qua From<AppError>. Trade-off: thêm type wrapper, signature handler verbose, mọi crate gọi cần biết về ApiError. (b) Feature gate — add axum làm optional dep trong shop-common với feature flag axum-integration, impl IntoResponse wrap trong #[cfg(feature = "axum")]. Crate dùng shop-common không cần web (vd shop-cli) bỏ qua axum compile. Shop API chọn Approach hiện tại (axum dep không gate) vì shop-common được shared cross-binary và mọi binary (shop-api, shop-worker, shop-cli) đều có path code có thể trả AppError; thêm conditional compilation gây phức tạp cho lợi ích nhỏ vì axum compile nhanh trong release.
  2. AppError::RateLimited(30) trả response status 429 Too Many Requests. Body JSON envelope chuẩn 3 field: { "error": "rate limited: retry after 30 seconds", "code": "RATE_LIMITED", "request_id": null }. Field error build từ #[error("rate limited: retry after {0} seconds")] attribute trên variant (B10), với {0} bind vào value 30. Field code"RATE_LIMITED" qua helper code(). Field request_id placeholder null. Header set tự động: Retry-After: 30 — value lấy trực tiếp từ u64 trong variant qua HeaderValue::from(*retry_seconds), convert qua impl From<u64> for HeaderValue axum cung cấp. Client SDK đọc Retry-After biết chờ 30 giây trước khi retry — standard RFC 9110 mục 10.2.3. Ngoài ra Content-Type application/json; charset=utf-8 tự set bởi Json wrapper.
  3. Hai hành vi đặc biệt của InternalUpstream: (a) Tự log qua tracing::error! trong body into_response với structured field (error, code, status). Lý do: hai variant này là error nghiêm trọng (Internal = panic/uncaught, Upstream = third-party service hỏng), dev phải biết để debug root cause; các variant còn lại như NotFound, Validation, Unauthenticated là expected behavior bình thường (resource không tồn tại, client gửi data sai, token expired) — không cần log error level mà chỉ DEBUG/INFO nếu cần (sẽ thêm middleware log access ở G15). (b) Variant Internal#[from] anyhow::Error (lock B10) cho phép ? operator tự convert anyhow::Result<T>AppResult<T> mà không phải .map_err tay. Lý do giữ Internal wrap anyhow::Error: anyhow::Error giữ chain context đầy đủ (vd parse fail.context("read config").context("startup init")) — dev đọc log thấy full stack trace; client chỉ thấy generic "internal server error" không leak detail. Hai variant này được tách riêng mapping (500 cho Internal panic-style, 502 cho Upstream third-party fail) để monitoring dashboard phân biệt — alert khác nhau, mức urgent khác nhau.
  4. Field request_id sẽ được inject value thực tế ở B39 — Extension Extractor — Request-Scoped Data. Mechanism: (a) Middleware RequestIdLayer chạy ở edge cho mọi request, sinh hoặc đọc UUID v4 từ header X-Request-Id (lock B4 — nếu client gửi sẵn server tôn trọng dùng lại, không có thì server tự sinh); (b) Middleware set value vào request extension qua req.extensions_mut().insert(RequestId(uuid_string)); — pattern Extension là request-scoped storage, mỗi request có copy riêng, axum extractor đọc qua Extension<RequestId>; (c) impl IntoResponse for AppError sẽ refactor sang impl IntoResponseParts hoặc nhận thêm Extension<RequestId> qua FromRequestParts để đọc value lúc build body — chi tiết refactor pattern ở B39. Trade-off design: into_response() hiện không có access vào request context (axum gọi sau khi handler return, context đã drop) — cách giải là (a) wrap response qua middleware đọc lại Extension và inject vào body JSON, hoặc (b) handler nhận extractor RequestId rồi convert vào AppError struct riêng. Approach (a) cleaner — middleware layer "enrich-error-body" chạy sau extract response, parse JSON, thêm field request_id từ Extension. Bài B39 sẽ chốt approach cụ thể.
  5. Attribute #[from] anyhow::Error trên variant Internal tự generate impl From<anyhow::Error> for AppError bởi macro thiserror::Error derive — không phải viết tay. Cụ thể macro expand thành: impl From<anyhow::Error> for AppError { fn from(err: anyhow::Error) -> Self { Self::Internal(err) } }. Flow conversion qua ? operator: (a) Handler gọi function trả anyhow::Result<T>, vd let config = read_config().context("read config fail")?;; (b) ? desugar thành: match read_config().context("read config fail") { Ok(v) => v, Err(e) => return Err(From::from(e)) }; (c) From::from(e) tìm impl From<anyhow::Error> for AppError đã generate, gọi → trả AppError::Internal(e); (d) return Err(...) bubble lên axum framework; (e) axum gọi err.into_response() → tạo response 500 với body envelope chuẩn + log tracing::error!. Lợi ích cụ thể: (i) Code handler không có match err { ... } chain dài — mọi error path đi qua một flow; (ii) Context chain của anyhow (.context("...")) được giữ nguyên trong AppError::Internal, log tracing::error! in ra full chain — dev debug nhanh; (iii) Mọi crate gọi từ shop-api (sqlx, redis, reqwest, ...) chỉ cần trả anyhow::Result hoặc convert qua .context — không cần define error type riêng cho mỗi crate. Pattern này lock cho mọi handler Shop API: function nội bộ không phải public API trả anyhow::Result<T>, handler convert qua ? tự động về AppResult.
12

Bài Tiếp Theo

— bàn cấu trúc folder shop-api trước khi mở rộng nhiều handler: monolithic vs modular, feature-folder vs layer-based, tách src/routes + src/handlers + src/services + src/dto, mod.rs aggregate pattern, chuẩn bị refactor main.rs từ B17 để mỗi resource có chỗ ở rõ ràng, không nhồi mọi handler vào một file.