Mục lục
- Mục Tiêu Bài Học
- State Hiện Tại Recap
- Tại Sao Cần Pattern Nhất Quán
- Cấu Trúc File & Orphan Rule
- Step 1: Add axum Dependency Cho shop-common
- Step 2: Helper status_code() & code()
- Step 3: Implement IntoResponse Đầy Đủ
- Step 4: Demo Handler Test Error Path
- Step 5: Run & Verify Với curl
- Tổng Kết
- Bài Tập Củng Cố
- Bài Tiếp Theo
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::IntoResponsechoshop_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
codeSCREAMING_SNAKE_CASE từ variant name qua helpercode() -> &'static str, dùng cho client filter/i18n/error tracking. - Gắn header phụ trợ tự động per error type —
WWW-Authenticatecho 401,Retry-Aftercho 429 + 503,Allowcho 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.
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 returnAppResult<Json<T>>.- Mapping HTTP status đã lock B3 —
BadRequest→ 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>" }. Fieldrequest_idplaceholdernullở bài này, sẽ inject từExtension<RequestId>middleware ở B39. - Header phụ trợ đã lock B4 —
WWW-Authenticate: Bearer realm="shop-api"cho 401,Retry-Aftercho 429 + 503,Allowcho 405. - Tracing đã init ở B10 qua
shop_common::telemetry::init_tracing— handler chỉ cần gọi macrotracing::error!log với context fields đầy đủ. - Workspace deps đã lock B10 —
axum,serde_json,thiserror 2,tracingđều ở[workspace.dependencies], bài này chỉ kéo vềcrates/shop-common/Cargo.tomlqua.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.
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ảiif (endpoint === "/products") { ... } else if (endpoint === "/cart") { ... }. Mỗi inconsistency là một bug khi dev frontend chuyển endpoint. - Filter logging và monitoring theo
codefield. Loki/Elasticsearch querycode = "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êncode—RATE_LIMITEDở/cartvà/checkoutgom cùng issue, không phải hai issue tách biệt. - i18n localization theo
code. Fielderrorlà 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êncode+ 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ỏirequest_id, search log toàn distributed trace — middlewareX-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.
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 crateaxum).shop_common::error::AppError— type local trong crateshop-common.
Hai lựa chọn:
- Approach A — impl trong
shop-apiqua newtype wrapper:pub struct ApiError(pub AppError);rồiimpl IntoResponse for ApiError. Handler returnResult<T, ApiError>, mỗi?phải convert quaFrom<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-commonbằng cách thêmaxumlàm dependency củashop-common. Orphan rule OK (type local, trait external nhưng impl trong crate define type). Handler returnAppResult<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 JsonRejection → AppError::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.
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.
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ủaIntoResponseimpl, không expose ra ngoài. Caller chỉ tương tác quaerr.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 variantUpstreamTimeoutriê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 postfixFAILEDcho rõ semantic),Internal→"INTERNAL_ERROR"(thêmERROR),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.
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ọiDisplayimpl màthiserror::Errorderive macro tự sinh từ#[error("...")]attribute trên enum variant (B10). VdAppError::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 + fielderrorenvelope.- 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 macroserde_json::json!— không phải define struct riêng vì envelope cố định, generic không cần. Fieldrequest_idhiệnserde_json::Value::Null— sẽ inject thực tế khi middleware B39 setExtension<RequestId>, lúc đóinto_responseđọcExtensionqua context request (yêu cầu refactor signature dùngFromRequestParts, 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 implIntoResponseđã lock B14). Bước 2response.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 buildHeaderMapcho 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):
Unauthenticated→WWW-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). Fieldu64dùngHeaderValue::fromtrực tiếp — convert qua implFrom<u64> for HeaderValueaxum cung cấp.Unavailable→Retry-After: 60(giây, hard-coded vì variant không carry value). Khi cần dynamic, sửa thànhUnavailable(u64)tương lai.MethodNotAllowed(allowed)→Allow: <allowed>. Stringallowedlà format RFC 9110"GET, POST, PUT"— caller build sẵn trước khi tạo error.HeaderValue::from_strcó thể fail nếu chuỗi có ký tự không valid header value (rất hiếm với verb HTTP), dùngif let Okdefense — 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.
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ếOkvàErrđều implementIntoResponse(lock B13):Json<T>impl từ axum,AppErrorimpl 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 (vdservice.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)— variantu64, 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).
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ớirequest_id: nullplaceholder. - Content-Type
application/jsontự set bởiJsonwrapper. - Header phụ trợ (
www-authenticate,retry-after) set tự động per variant.
Test thêm cho MethodNotAllowed và Unavailable 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.
Tổng Kết
shop_common::error::AppErrorgiờ implaxum::response::IntoResponsetrự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ìAppErrorlà type local trong cùng crate. - Hai helper internal
status_code() -> StatusCodevàcode() -> &'static strmapping 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.errorquaself.to_string()từ Display impl thiserror,codequa helpercode(). - 4 header phụ trợ tự động set per variant (lock B4):
Unauthenticated→WWW-Authenticate: Bearer realm="shop-api".RateLimited(N)→Retry-After: N.Unavailable→Retry-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::ErrortrênAppError::Internal(B10 đã có) cho phép?operator tự convertanyhow::Result<T>→AppResult<T>trong handler body — code business clean.request_idhiện placeholdernull, sẽ inject từExtension<RequestId>middleware ở B39 (refactor sangFromRequestPartsđể đọc Extension context).- 3 demo handler
/error/not-found,/error/unauthenticated,/error/rate-limitedtrongmain.rstạm thời để verify — sẽ remove khi G3+ có handler resource thật. - File
crates/shop-api/src/responses/error.rschưa tạo ở bài này (lock B14 ghi nhưng implement reschedule) — sẽ tạo ở B41 cho customJsonRejection→AppError::BadRequestmapping. - 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.
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- Vì sao
impl IntoResponse for AppErrorđược đặt trongshop-commonmà không phảishop-api? Orphan rule là gì? Hai approach khác (newtype wrapper, feature gate) có gì khác biệt? 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?- Variant
InternalvàUpstreamcó 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. - Field
request_idtrong 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)? - Attribute
#[from] anyhow::Errortrên variantInternal(lock B10) làm gì cụ thể? Khi handler dùng?operator trên mộtanyhow::Result<T>, flow conversion diễn ra thế nào?
Đáp án
impl IntoResponse for AppErrorđặt trongshop-commonvì orphan rule của Rust. Orphan rule: vớiimpl 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:IntoResponseexternal (crateaxum),AppErrorlocal trongshop-common— impl phải đặt trong crate defineAppError(tứcshop-common) hoặc crate defineIntoResponse(tứcaxum, không thể vì không phải code của ta). Nếu cố impl trongshop-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 — definepub struct ApiError(pub AppError);trongshop-api,ApiErrorlà type local nên implIntoResponse for ApiErrorđược. Handler returnResult<T, ApiError>, mỗi?phải convert quaFrom<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 trongshop-commonvới feature flagaxum-integration, implIntoResponsewrap trong#[cfg(feature = "axum")]. Crate dùng shop-common không cần web (vdshop-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.AppError::RateLimited(30)trả response status429 Too Many Requests. Body JSON envelope chuẩn 3 field:{ "error": "rate limited: retry after 30 seconds", "code": "RATE_LIMITED", "request_id": null }. Fielderrorbuild từ#[error("rate limited: retry after {0} seconds")]attribute trên variant (B10), với{0}bind vào value30. Fieldcodelà"RATE_LIMITED"qua helpercode(). Fieldrequest_idplaceholdernull. Header set tự động:Retry-After: 30— value lấy trực tiếp từu64trong variant quaHeaderValue::from(*retry_seconds), convert qua implFrom<u64> for HeaderValueaxum cung cấp. Client SDK đọcRetry-Afterbiết chờ 30 giây trước khi retry — standard RFC 9110 mục 10.2.3. Ngoài ra Content-Typeapplication/json; charset=utf-8tự set bởiJsonwrapper.- Hai hành vi đặc biệt của
InternalvàUpstream: (a) Tự log quatracing::error!trong bodyinto_responsevớ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,Unauthenticatedlà 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) VariantInternalcó#[from] anyhow::Error(lock B10) cho phép?operator tự convertanyhow::Result<T>→AppResult<T>mà không phải.map_errtay. Lý do giữInternalwrapanyhow::Error:anyhow::Errorgiữ chain context đầy đủ (vdparse 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. - Field
request_idsẽ được inject value thực tế ở B39 — Extension Extractor — Request-Scoped Data. Mechanism: (a) MiddlewareRequestIdLayerchạy ở edge cho mọi request, sinh hoặc đọc UUID v4 từ headerX-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 quareq.extensions_mut().insert(RequestId(uuid_string));— pattern Extension là request-scoped storage, mỗi request có copy riêng, axum extractor đọc quaExtension<RequestId>; (c)impl IntoResponse for AppErrorsẽ refactor sangimpl IntoResponsePartshoặc nhận thêmExtension<RequestId>quaFromRequestPartsđể đọ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 extractorRequestIdrồ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 fieldrequest_idtừ Extension. Bài B39 sẽ chốt approach cụ thể. - Attribute
#[from] anyhow::Errortrên variantInternaltự generateimpl From<anyhow::Error> for AppErrorbởi macrothiserror::Errorderive — 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>, vdlet 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 implFrom<anyhow::Error> for AppErrorđã generate, gọi → trảAppError::Internal(e); (d)return Err(...)bubble lên axum framework; (e) axum gọierr.into_response()→ tạo response 500 với body envelope chuẩn + logtracing::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ủaanyhow(.context("...")) được giữ nguyên trongAppError::Internal, logtracing::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::Resulthoặ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.
Bài Tiếp Theo
Bài 17: Project Structure Cho Axum App — 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.
