Danh sách bài viết

Bài 82: Timeout Per-Route — tower-http TimeoutLayer + 504 Response

Bài 82 của series Rust RESTful API — bài thứ 7 Group 8 Middleware Sâu (B82/B85), áp dụng tower-http::TimeoutLayer per-route cho Shop API với 4 timeout class lock: default 5s (95% endpoint JSON, fail-fast UX) + import 30s (/products/import.ndjson bulk processing B49 lock continued) + upload 60s (/products/{slug}/upload multipart large file B79) + webhook 10s (/webhooks/stripe align Stripe retry policy) + health/metrics 1s (fail-fast load balancer detect); phân biệt 3 loại timeout HTTP API (request timeout server chờ N giây hoàn thành handler — focus B82 + response timeout proxy chờ N giây nhận response cross NGINX/HAProxy proxy_read_timeout + idle/keep-alive timeout hyper preview G18); quy tắc relationship server_request_timeout < proxy_read_timeout < load_balancer_timeout (5s < 30s < 60s AWS ALB default); AppError::Timeout(Duration) variant 22 bump 21 → 22 với 504 Gateway Timeout response chuẩn RFC 7231 body envelope { error, code: "REQUEST_TIMEOUT", detail: { timeout_seconds } }; HandleErrorLayer + TimeoutLayer ServiceBuilder chain lock pattern (HandleError outer) convert tower::timeout::error::Elapsed BoxError → AppError::Timeout; pitfall background task cancel khi tokio future drop — PostgreSQL atomic transaction rollback hoặc commit hoàn toàn KHÔNG partial nhưng side-effect external (Stripe API call, file write, queue publish) có thể partial; mitigate qua Idempotency-Key B66 + Saga pattern G18 + queue worker G21; drop guard pattern explicit cleanup khi future drop emit metric transaction_rolled_back_total{reason} qua Grafana monitor; verify end-to-end curl 4 scenario (endpoint nhanh OK + slow handler 5s → 504 + import 25s OK / 31s → 504 + upload 50s OK / 70s → 504); middleware stack giờ 10 layer (9 cũ B81 + timeout_per_route mới B82); file path lock NEW middleware/timeout_layer.rs 4 helper factory + extend error_map.rs handle_timeout helper + error.rs Timeout variant; foundation cho B83 request validation + G18 proxy timeout config + G21 queue worker saga.

16/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 3 loại timeout HTTP API: request, response, idle.
  • Áp dụng tower-http::TimeoutLayer per-route thay global.
  • Implement timeout config 3 class: default 5s + import 30s + upload 60s + webhook 10s.
  • Response 504 Gateway Timeout với AppError::Timeout variant mới (variant 22, bump 21 → 22).
  • Hiểu pitfall background task — tokio future cancel khi timeout, partial state risk side-effect external.
  • Pattern drop guard cleanup cho transaction rollback khi cancel, emit metric monitor.
  • Multi-env timeout strategy (Local 30s debug, Production strict 5s).
  • Quy tắc relationship server < proxy < load balancer chain timeout đúng thứ tự.
2

3 Loại Timeout HTTP API

Trước khi viết code, phân biệt 3 loại timeout vận hành ở 3 tầng khác nhau trong stack HTTP. Nhầm tầng đặt timeout = bug khó debug (timeout trên proxy 60s nhưng server timeout 5s sẽ trả 504 trong khi proxy vẫn chờ — client thấy lỗi kỳ lạ).

Request timeout — server chờ tối đa N giây hoàn thành handler:

  • Use case: chống slow handler block tài nguyên (1 task ngốn thread 10 phút = pool DB / tokio executor cạn).
  • Vị trí: ngay trong process axum, áp dụng qua tower-http::TimeoutLayer.
  • Default Shop API: 5s — phù hợp 95% endpoint JSON CRUD (P95 healthy < 100ms, 5s gấp 50 lần đủ buffer outlier).
  • Khi timeout: server cắt connection + response 504, tokio future bị drop (xem step 6 pitfall).

Response timeout — client/proxy chờ tối đa N giây nhận response:

  • Use case: cấu hình ở NGINX proxy_read_timeout, HAProxy timeout server, AWS ALB idle_timeout.
  • Vị trí: tầng proxy/load balancer phía trước axum.
  • Quy tắc cross: server request timeout MUST nhỏ hơn proxy response timeout — nếu proxy timeout trước server, proxy trả 504 còn server tiếp tục xử lý lãng phí tài nguyên.
  • Shop API focus B82: request timeout phía server; proxy/lb timeout config sẽ deploy ở G18.

Idle/Keep-alive timeout — TCP connection idle bao lâu trước close:

  • Use case: cấu hình ở hyper backend qua http2_keep_alive_interval / http2_keep_alive_timeout.
  • Vị trí: tầng HTTP server (hyper) hoặc client (reqwest pool).
  • Tách biệt hoàn toàn request timeout — idle timeout đo thời gian KHÔNG có data flow trên connection, request timeout đo thời gian processing 1 request cụ thể.
  • Shop API B82 skip — preview G18 deploy.

Lock decision Shop API B82: focus request timeout per-route; response timeout + idle timeout deploy phase G18 khi setup NGINX reverse proxy + ALB. Quy tắc relationship lock vĩnh viễn:

server_request_timeout  <  proxy_read_timeout  <  load_balancer_timeout
5s                      <  30s                 <  60s (AWS ALB default)

Margin gấp 6x giữa mỗi tầng đủ buffer cho retry + network jitter + cold start container. Nếu đảo thứ tự (server 30s, proxy 5s) → proxy luôn timeout trước, server xử lý xong vô ích → tài nguyên lãng phí + client thấy 504 sai nguyên nhân (proxy báo timeout chứ không phải server).

Một số endpoint Shop API cần timeout dài hơn default 5s do tính chất workload — đó là lý do bài này cấu hình per-route thay vì global một con số duy nhất.

3

Cài + Wire tower-http TimeoutLayer

Feature timeout đã có sẵn trong tower-http workspace dep từ B10 (default feature set). Không cần thêm dep mới — chỉ tạo helper factory function để mỗi sub-router B79 wire 1 timeout layer riêng.

Tạo file mới crates/shop-api/src/middleware/timeout_layer.rs:

// File: crates/shop-api/src/middleware/timeout_layer.rs
use std::time::Duration;
use tower_http::timeout::TimeoutLayer;

/// Default timeout cho 95% endpoint JSON CRUD —
/// P95 healthy < 100ms, 5s gấp 50 lần đủ buffer outlier.
pub fn default_timeout() -> TimeoutLayer {
    TimeoutLayer::new(Duration::from_secs(5))
}

/// Import NDJSON bulk processing — B49 lock continued.
/// 30s đủ xử lý 10K row insert + validate + audit log.
pub fn import_timeout() -> TimeoutLayer {
    TimeoutLayer::new(Duration::from_secs(30))
}

/// Upload multipart large file — B79 lock continued.
/// 60s đủ upload 100MB qua kết nối 4G chậm (~2 MB/s).
pub fn upload_timeout() -> TimeoutLayer {
    TimeoutLayer::new(Duration::from_secs(60))
}

/// Webhook Stripe — align Stripe retry policy 15s connect timeout
/// + 8s read timeout. Shop API 10s margin nhỏ để Stripe nhận 200 OK
/// trước khi Stripe đánh dấu webhook failed (retry exponential).
pub fn webhook_timeout() -> TimeoutLayer {
    TimeoutLayer::new(Duration::from_secs(10))
}

/// Health + metrics endpoint — fail-fast load balancer detect.
/// 1s đủ cho query pool sqlx + format Prometheus text;
/// nếu vượt 1s = service unhealthy, LB tự cắt traffic.
pub fn health_timeout() -> TimeoutLayer {
    TimeoutLayer::new(Duration::from_secs(1))
}

5 helper factory function trả TimeoutLayer với 5 mức khác nhau. Mỗi helper là factory pattern (tạo instance mới mỗi lần gọi) thay vì biến static — TimeoutLayer không phải Copy, không tái dùng được giữa nhiều .layer() call.

Lock decision Shop API per-route timeout:

  • Default routes (95%): 5s — fail-fast UX, user thấy lỗi nhanh thay vì chờ loading vô vọng.
  • Import NDJSON: 30s — bulk processing 10K row hợp lý dưới 1 phút.
  • Upload multipart: 60s — large file qua kết nối chậm.
  • Webhook: 10s — Stripe retry policy mặc định 8s read timeout, Shop API 10s margin nhỏ ưu tiên ACK Stripe trước khi Stripe đánh dấu failed.
  • Health/metrics: 1s — fail-fast load balancer detect, nếu service degrade load balancer cắt traffic ngay.

Update crates/shop-api/src/middleware/mod.rs re-export:

// File: crates/shop-api/src/middleware/mod.rs
pub mod trace_layer;       // B80
pub mod metrics_layer;     // B81
pub mod timeout_layer;     // B82 NEW

pub use trace_layer::custom_trace_layer;
pub use metrics_layer::metrics_middleware;
pub use timeout_layer::{
    default_timeout, import_timeout, upload_timeout,
    webhook_timeout, health_timeout,
};

Helper riêng cho từng class thay vì 1 hàm tham số fn timeout(d: Duration) — đảm bảo team mới đọc code thấy ngay timeout đang áp dụng cho route nào, không phải đào số 5/30/60 rải rác. Defensive pattern: muốn đổi timeout cho 1 class chỉ sửa 1 chỗ duy nhất.

4

AppError::Timeout Variant + 504 Response

Vấn đề: TimeoutLayer của tower-http trả BoxError mặc định khi timeout — không tự map sang HTTP status code. Cần convert tower::timeout::error::ElapsedAppError::Timeout qua HandleErrorLayer wrap.

Bước 1 — extend crates/shop-common/src/error.rs thêm variant Timeout (bump 21 → 22):

// File: crates/shop-common/src/error.rs
use std::time::Duration;

#[derive(Debug, thiserror::Error)]
pub enum AppError {
    // ... 21 variant cũ B78 (BadRequest, Validation, Unauthenticated,
    // Forbidden, NotFound, MethodNotAllowed, Conflict, RateLimited,
    // Internal, Upstream, Unavailable, ... TooManyRequests B78)

    /// Variant 22 (B82 NEW) — request vượt deadline TimeoutLayer.
    /// Map 504 Gateway Timeout RFC 7231 §6.6.5.
    #[error("request timeout after {0:?}")]
    Timeout(Duration),
}

Bước 2 — update impl IntoResponse for AppError handle variant mới (lock B16 envelope chuẩn):

// File: crates/shop-common/src/error.rs (impl IntoResponse)
use axum::{http::StatusCode, response::IntoResponse, Json};
use serde_json::json;

impl IntoResponse for AppError {
    fn into_response(self) -> axum::response::Response {
        match self {
            // ... 21 nhánh cũ B16 + B78

            AppError::Timeout(duration) => {
                let body = json!({
                    "error": "request timeout",
                    "code": "REQUEST_TIMEOUT",
                    "request_id": null,
                    "detail": {
                        "timeout_seconds": duration.as_secs_f64(),
                    }
                });
                (StatusCode::GATEWAY_TIMEOUT, Json(body)).into_response()
            }
        }
    }
}

Body envelope giữ format chuẩn B16 + thêm field detail.timeout_seconds để client biết deadline cụ thể vi phạm — hữu ích cho client retry strategy (client tự điều chỉnh request size nếu hay vượt deadline).

Bước 3 — tạo helper convert BoxErrorAppError::Timeout trong crates/shop-api/src/error_map.rs:

// File: crates/shop-api/src/error_map.rs
use std::time::Duration;
use axum::{response::Response, BoxError};
use tower::timeout::error::Elapsed;
use shop_common::error::AppError;

/// Convert BoxError từ TimeoutLayer → AppError::Timeout (504),
/// fallback Internal cho mọi error khác (KHÔNG bao giờ panic).
pub async fn handle_timeout(err: BoxError) -> Response {
    if err.is::<Elapsed> () {
        // tower::timeout::error::Elapsed KHÔNG carry Duration value
        // → đọc default 5s; mỗi sub-router gắn layer khác nhau,
        // muốn phân biệt thì truyền duration qua closure capture
        // (xem step 5 — wire per-route truyền timeout đúng).
        AppError::Timeout(Duration::from_secs(5)).into_response()
    } else {
        // Fallback cho mọi error khác từ stack tower —
        // không leak detail ra client, log internal đầy đủ.
        tracing::error!(error = %err, "unexpected service error");
        AppError::Internal(err.to_string()).into_response()
    }
}

Alternative pattern: closure capture duration cho từng sub-router để 504 response phản ánh đúng timeout class (default 5s vs import 30s vs upload 60s):

// File: crates/shop-api/src/error_map.rs (improved version)
use std::time::Duration;
use axum::{response::Response, BoxError};

pub fn make_timeout_handler(
    timeout: Duration,
) -> impl Fn(BoxError) -> futures::future::Ready<Response> + Clone {
    move |err: BoxError| {
        let resp = if err.is::<tower::timeout::error::Elapsed> () {
            AppError::Timeout(timeout).into_response()
        } else {
            tracing::error!(error = %err, "unexpected service error");
            AppError::Internal(err.to_string()).into_response()
        };
        futures::future::ready(resp)
    }
}

Lock decision Shop API: dùng HandleErrorLayer::new(make_timeout_handler(duration)) wrap TimeoutLayer trong ServiceBuilder chain để mỗi sub-router truyền đúng Duration tương ứng vào response. Order matter: HandleErrorLayer phải đặt outer hơn TimeoutLayer để bắt error trồi lên từ layer trong.

5

Wire Per-Route Timeout

Cập nhật crates/shop-api/src/router.rs wire HandleErrorLayer + TimeoutLayer cho từng sub-router theo pattern lock B79 (default + import + upload sub-router):

// File: crates/shop-api/src/router.rs
use std::time::Duration;
use axum::{error_handling::HandleErrorLayer, Router, routing::post};
use tower::ServiceBuilder;
use tower_http::limit::RequestBodyLimitLayer;
use axum::extract::DefaultBodyLimit;

use crate::middleware::{
    custom_trace_layer, metrics_middleware,
    default_timeout, import_timeout, upload_timeout,
    webhook_timeout, health_timeout,
};
use crate::error_map::make_timeout_handler;
use crate::state::AppState;

pub fn build_router(state: AppState) -> Router {
    // Default routes: 5s timeout — 95% endpoint JSON CRUD.
    let default_routes = Router::new()
        .merge(routes::products::default_routes())
        .merge(routes::categories::routes())
        .merge(routes::brands::routes())
        .merge(routes::cart::routes())
        .merge(routes::users::routes())
        .layer(DefaultBodyLimit::max(2 * 1024 * 1024))
        .layer(
            ServiceBuilder::new()
                // OUTER: HandleErrorLayer convert BoxError → AppError.
                .layer(HandleErrorLayer::new(
                    make_timeout_handler(Duration::from_secs(5)),
                ))
                // INNER: TimeoutLayer cắt future quá deadline.
                .layer(default_timeout()),
        );

    // Import routes: 30s timeout — bulk NDJSON processing B49.
    let import_routes = Router::new()
        .route("/products/import.ndjson", post(import_products_ndjson))
        .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
        .layer(
            ServiceBuilder::new()
                .layer(HandleErrorLayer::new(
                    make_timeout_handler(Duration::from_secs(30)),
                ))
                .layer(import_timeout()),
        );

    // Upload routes: 60s timeout — multipart large file B79.
    let upload_routes = Router::new()
        .route("/products/{slug}/upload", post(upload_product_image))
        .layer(DefaultBodyLimit::max(100 * 1024 * 1024))
        .layer(
            ServiceBuilder::new()
                .layer(HandleErrorLayer::new(
                    make_timeout_handler(Duration::from_secs(60)),
                ))
                .layer(upload_timeout()),
        );

    // Webhook: 10s timeout — Stripe retry policy align.
    let webhook_routes = Router::new()
        .route("/webhooks/stripe", post(stripe_webhook))
        .layer(DefaultBodyLimit::max(1024 * 1024))
        .layer(
            ServiceBuilder::new()
                .layer(HandleErrorLayer::new(
                    make_timeout_handler(Duration::from_secs(10)),
                ))
                .layer(webhook_timeout()),
        );

    // Health/metrics: 1s timeout — fail-fast LB detect.
    let infra_routes = Router::new()
        .merge(routes::health::routes())
        .route("/metrics", axum::routing::get(routes::metrics::metrics))
        .layer(
            ServiceBuilder::new()
                .layer(HandleErrorLayer::new(
                    make_timeout_handler(Duration::from_secs(1)),
                ))
                .layer(health_timeout()),
        );

    Router::new()
        .merge(infra_routes)
        .nest("/api/v1", default_routes
            .merge(import_routes)
            .merge(upload_routes)
            .merge(webhook_routes))
        // INNER N — metrics_layer (B81)
        .layer(axum::middleware::from_fn(metrics_middleware))
        // OUTER N — trace_layer (B80)
        .layer(custom_trace_layer())
        // OUTERMOST — global hard cap raw bytes (B79)
        .layer(RequestBodyLimitLayer::new(100 * 1024 * 1024))
        .with_state(state)
}

Pattern lock B82: ServiceBuilder chain HandleErrorLayer + TimeoutLayerorder matters. HandleErrorLayer phải đặt outer (gọi .layer() trước trong builder chain) để bắt được error trồi lên từ TimeoutLayer inner.

Stack giờ 10 layer (9 cũ B81 + per-route timeout). Lưu ý timeout layer KHÔNG nằm trong global stack mà nằm trong từng sub-router — đó là lý do middleware stack count tăng "logical" 10 lớp dù physically có 5 timeout layer khác nhau cho 5 sub-router.

6

Pitfall Background Task — Tokio Cancel Behavior

Khi handler timeout, tokio cancel future tương ứng — nghĩa là drop future ngay tại điểm await đang chờ. Hệ quả: nếu handler đang ở giữa chuỗi await nhiều bước, các bước sau KHÔNG bao giờ chạy.

Example scenario tạo đơn hàng:

pub async fn create_order(
    State(state): State<AppState>,
    Json(dto): Json<CreateOrderDto>,
) -> Result<Json<OrderResponseDto>, AppError> {
    let mut tx = state.db.begin().await?;

    insert_order(&mut tx, &dto).await?;
    decrement_stock(&mut tx, &dto).await?;  // ← timeout giữa đây

    tx.commit().await?;  // ← KHÔNG bao giờ chạy
    Ok(Json(...))
}

Khi timeout xảy ra tại decrement_stock:

  • tx bị drop → sqlx tự ROLLBACK best-effort thông qua async drop (B54 lock continued).
  • Nếu tx.commit() chưa kịp chạy → DB ở trạng thái trước transaction (an toàn).
  • Nếu tx.commit() đã chạy xong và return Ok → tokio cancel sau commit thì transaction đã COMMIT thành công, response 504 trả về client nhưng DB đã thay đổi.

Tin tốt: PostgreSQL transaction atomic — rollback hoặc commit hoàn toàn, KHÔNG bao giờ partial. Dù tokio cancel giữa chừng, DB state nhất quán (ACID guarantee).

Tin xấu: side-effect bên ngoài DB KHÔNG có atomic guarantee. Scenario rủi ro:

  • Stripe API call đã thành công nhưng tokio cancel trước khi save payment_id vào DB → mất tiền không có record.
  • File write đã ghi xong nhưng timeout trước khi commit row metadata → file rác không có entry tham chiếu.
  • Queue publish đã enqueue message background worker nhưng cancel trước khi mark queued_at → message duplicate publish lần sau.

Pattern phòng vệ Shop API:

  • Idempotency-Key (B66 lock) cho mọi POST mutation external side-effect — client retry safe, server detect duplicate qua key trả response cũ.
  • Background task tách qua queue (G21 worker preview) — handler chỉ enqueue rồi return 202 Accepted nhanh, không chờ side-effect external; worker idempotent xử lý có retry.
  • Saga pattern cho multi-service workflow (G18 deploy) — mỗi bước có compensation transaction rollback nếu bước sau fail.
  • Outbox pattern (G21 preview) — write event vào table outbox cùng business transaction, worker poll table publish queue → atomic guarantee.

Quy tắc lock B82: handler critical mutation (create_order, checkout, payment_capture) KHÔNG gọi side-effect external trực tiếp trong handler — luôn enqueue qua outbox + worker xử lý async với idempotent guarantee.

7

Drop Guard Pattern — Transaction Cleanup

Drop guard là pattern Rust idiomatic — struct với impl Drop tự động chạy cleanup code khi struct ra khỏi scope (kể cả khi future bị cancel). Áp dụng cho transaction: emit metric mỗi khi transaction bị drop chưa commit để monitor phát hiện anomaly.

// File: crates/shop-core/src/orders.rs (preview)
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};

/// Drop guard cho transaction lifecycle — emit metric +
/// log warn khi transaction bị drop chưa commit (timeout / panic / early return).
struct TransactionGuard {
    committed: Arc<AtomicBool>,
    operation: &'static str,
}

impl TransactionGuard {
    fn new(operation: &'static str) -> (Self, Arc<AtomicBool>) {
        let committed = Arc::new(AtomicBool::new(false));
        let guard = TransactionGuard {
            committed: committed.clone(),
            operation,
        };
        (guard, committed)
    }
}

impl Drop for TransactionGuard {
    fn drop(&mut self) {
        let committed = self.committed.load(Ordering::Acquire);
        if !committed {
            tracing::warn!(
                operation = self.operation,
                "transaction dropped without commit — rolled back"
            );
            metrics::counter!(
                "transaction_rolled_back_total",
                "operation" => self.operation,
                "reason" => "drop",
            )
            .increment(1);
        }
    }
}

Áp dụng vào handler create_order:

pub async fn create_order(
    State(state): State<AppState>,
    Json(dto): Json<CreateOrderDto>,
) -> Result<Json<OrderResponseDto>, AppError> {
    let (_guard, committed_flag) = TransactionGuard::new("create_order");

    let mut tx = state.db.begin().await?;
    insert_order(&mut tx, &dto).await?;
    decrement_stock(&mut tx, &dto).await?;
    tx.commit().await?;

    // Mark committed TRƯỚC khi _guard ra khỏi scope —
    // drop chạy sau cùng tự đọc committed=true bỏ qua warn.
    committed_flag.store(true, Ordering::Release);

    Ok(Json(...))
}

Lock pattern Shop API:

  • Mọi mutation transaction critical (create_order, checkout, payment_capture) wrap TransactionGuard.
  • Emit metric transaction_rolled_back_total{operation, reason} cho mỗi rollback unexpected.
  • Logging WARN nếu drop chưa commit — alert engineer review log Loki theo request_id (B80 lock continued).
  • Monitor qua Grafana panel với PromQL rate(transaction_rolled_back_total{reason="drop"}[5m]) > 0.01 alert Slack non-paging — bình thường rate = 0, vượt = anomaly đáng debug.

Note quan trọng: B54 đã lock tx.commit() / tx.rollback() explicit match pattern — drop guard chỉ thêm tầng phòng vệ quan sát (observability), không thay thế explicit commit/rollback. Drop guard giúp phát hiện sớm code path nào quên explicit (B54 best practice), không phải workaround cho code sai.

Atomic flag committed tách khỏi struct guard để compiler không complain ownership move khi struct chứa generic Transaction không Clone — pattern Arc<AtomicBool> lock vĩnh viễn cho mọi drop guard tương lai Shop API.

8

Verify End-To-End + Test Timeout

Verify pipeline end-to-end qua 4 scenario kiểm tra mọi class timeout đã wire đúng + 504 response đúng format envelope.

Test 1 — endpoint nhanh dưới deadline default 5s:

time curl -s http://localhost:3000/api/v1/products | head -c 100
# real    0m0.052s
# user    0m0.005s
# sys     0m0.005s
# {"items":[{"id":1,"slug":"iphone-15",...
# → OK, response trong 52ms << 5s

Test 2 — simulate slow handler vượt deadline 5s:

// File: crates/shop-api/src/routes/test.rs (chỉ enable feature "test-handlers")
#[cfg(feature = "test-handlers")]
pub async fn slow_handler() -> Result<&'static str, AppError> {
    tokio::time::sleep(Duration::from_secs(10)).await;
    Ok("done")
}
time curl -i http://localhost:3000/api/v1/test/slow
# real    0m5.012s   # ← cắt đúng tại 5s deadline
# HTTP/1.1 504 Gateway Timeout
# content-type: application/json
# content-length: 113
#
# {"error":"request timeout","code":"REQUEST_TIMEOUT",
#  "request_id":null,"detail":{"timeout_seconds":5.0}}

Test 3 — import endpoint 30s timeout:

# File 5K row — process ~25s under 30s limit:
time curl -s -X POST http://localhost:3000/api/v1/products/import.ndjson \
  -H "Idempotency-Key: $(uuidgen)" \
  --data-binary @small-5k.ndjson
# real    0m25.4s
# {"imported":5000,"failed":0,...}
# → OK

# File 50K row — process ~31s vượt 30s limit:
time curl -i -X POST http://localhost:3000/api/v1/products/import.ndjson \
  -H "Idempotency-Key: $(uuidgen)" \
  --data-binary @large-50k.ndjson
# real    0m30.018s
# HTTP/1.1 504 Gateway Timeout
# {"error":"request timeout","code":"REQUEST_TIMEOUT",
#  "request_id":null,"detail":{"timeout_seconds":30.0}}

Test 4 — upload endpoint 60s timeout:

# Upload 20MB qua kết nối 1MB/s — ~20s under 60s:
time curl -s -X POST http://localhost:3000/api/v1/products/iphone-15/upload \
  -F "[email protected]" \
  --limit-rate 1M
# real    0m20.3s
# {"image_url":"https://cdn.shop.vn/products/iphone-15/abc.jpg"}

# Upload 100MB qua kết nối 1MB/s — ~100s vượt 60s:
time curl -i -X POST http://localhost:3000/api/v1/products/iphone-15/upload \
  -F "[email protected]" \
  --limit-rate 1M
# real    1m0.024s
# HTTP/1.1 504 Gateway Timeout
# {"error":"request timeout","code":"REQUEST_TIMEOUT",
#  "request_id":null,"detail":{"timeout_seconds":60.0}}

Verify metric Prometheus:

curl -s http://localhost:3000/metrics | grep -E "timeout|transaction_rolled"
# transaction_rolled_back_total{operation="create_order",reason="drop"} 0
# http_request_duration_seconds_bucket{method="GET",path="/api/v1/test/slow",le="5.0"} 0
# http_request_duration_seconds_bucket{method="GET",path="/api/v1/test/slow",le="10.0"} 1
# http_request_duration_seconds_count{method="GET",path="/api/v1/test/slow"} 1
# http_request_duration_seconds_sum{method="GET",path="/api/v1/test/slow"} 5.012

# Lưu ý: request /test/slow KHÔNG record full 10s duration —
# timeout cắt tại 5s, sum = 5.012s (deadline + margin nhỏ overhead);
# bucket le="5.0" = 0 (chưa đạt mốc bị cắt), le="10.0" = 1 (rơi vào bucket cao hơn).
# Anomaly detect: sum >> sum_normal cho cùng endpoint = signal timeout đang xảy ra.

Pattern verify lock B82: chạy 4 scenario sau mỗi deploy production để confirm timeout config đúng + 504 response format giữ envelope chuẩn cross-service. Tự động hóa qua suite e2e test (B70 lock testcontainers continued) — mock slow handler + assert 504 + check metric counter.

9

Tổng Kết

  • 3 loại timeout: request (B82 focus), response (G18 preview), idle (G18 preview).
  • tower-http::TimeoutLayer lock per-route — KHÔNG global.
  • 4 timeout class lock: default 5s, import 30s, upload 60s, webhook 10s.
  • Health/metrics 1s fail-fast cho load balancer detect service degrade.
  • Relationship: server_timeout < proxy_timeout < lb_timeout (5s < 30s < 60s).
  • AppError::Timeout(Duration) variant 22 — bump 21 → 22.
  • 504 Gateway Timeout response chuẩn HTTP RFC 7231 §6.6.5.
  • HandleErrorLayer + TimeoutLayer ServiceBuilder chain lock pattern — HandleError outer.
  • Pitfall background task cancel: tokio future drop → partial state risk.
  • PostgreSQL atomic — DB rollback hoặc commit hoàn toàn, KHÔNG partial.
  • Side-effect external (Stripe / file / queue) có thể partial — mitigate qua Idempotency-Key + outbox.
  • Drop guard pattern + metric transaction_rolled_back_total{operation,reason} cho monitor Grafana.
  • Saga + queue cho multi-service workflow (G18 + G21 deploy).
  • File path lock: NEW middleware/timeout_layer.rs 5 helper factory + extend error_map.rs handle_timeout/make_timeout_handler + extend error.rs Timeout variant.
  • Stack giờ 10 layer (9 cũ B81 + timeout_per_route B82).
  • Foundation cho B83 (request validation), G18 (proxy timeout config), G21 (queue worker + saga + outbox).
10

Bài Tập Củng Cố

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

  1. 3 loại timeout HTTP — phân biệt request vs response vs idle timeout. Quy tắc relationship server < proxy < lb tại sao bắt buộc theo thứ tự này? Cho scenario cụ thể nếu đảo thứ tự gì xảy ra.
  2. 4 timeout class Shop API — phân tích pros/cons mỗi config (default 5s / import 30s / upload 60s / webhook 10s). Tại sao webhook 10s thay 5s default? Stripe retry policy ảnh hưởng quyết định ra sao?
  3. HandleErrorLayer + TimeoutLayer ServiceBuilder chain — tại sao HandleErrorLayer phải đặt outer (gọi .layer() trước trong builder)? Cho scenario order matters: nếu đặt sai thứ tự (TimeoutLayer outer + HandleErrorLayer inner) gì xảy ra?
  4. Pitfall background task cancel — Tokio future drop behavior khi timeout. PostgreSQL atomic vs side-effect external scenario: cho ví dụ cụ thể Shop API (Stripe payment + DB save) — gì có thể partial, gì atomic. Mitigate strategy 3 tầng (Idempotency-Key + outbox + saga) áp dụng ra sao?
  5. Drop guard pattern — explicit cleanup khi future drop. Cho ví dụ scenario uncommitted transaction + monitor metric. Tại sao tách AtomicBool ra khỏi struct guard (pattern Arc<AtomicBool>)? Drop guard có thay thế explicit commit/rollback B54 không?
Đáp án
  1. 3 loại timeout HTTP phân biệt: (a) Request timeout — server (axum) chờ tối đa N giây cho handler hoàn thành 1 request cụ thể; vị trí trong process axum áp dụng qua tower-http::TimeoutLayer; default Shop API 5s; khi vượt server cắt future + response 504. (b) Response timeout — proxy/load balancer (NGINX proxy_read_timeout, HAProxy timeout server, AWS ALB idle_timeout) chờ tối đa N giây nhận response từ upstream; vị trí ngoài process axum; cấu hình ở tầng infrastructure G18; thường lớn hơn server request timeout để tránh proxy timeout trước. (c) Idle/Keep-alive timeout — đo thời gian TCP connection KHÔNG có data flow trước close (hyper http2_keep_alive_interval); tách biệt request timeout vì đo connection state chứ không phải request processing time; phục vụ tối ưu connection pool client (reqwest pool) + server (hyper). Quy tắc relationship: server_timeout < proxy_timeout < lb_timeout (5s < 30s < 60s AWS ALB default). Tại sao bắt buộc thứ tự này: (i) margin gấp 6x giữa mỗi tầng đủ buffer cho retry network jitter + cold start container + GC pause; (ii) tầng dưới timeout trước tầng trên → root cause rõ ràng (server log 504 = handler thật sự chậm, không phải proxy/lb nhầm); (iii) client retry không bị nhân đôi attempt khi proxy timeout trước server (proxy đã trả 504 nhưng server vẫn xử lý → client retry lần 2 gây duplicate request). Scenario đảo thứ tự: nếu cấu hình server 30s + proxy 5s + lb 10s, request handler chạy 15s thực tế — proxy timeout tại 5s trả 504, nhưng server vẫn tiếp tục xử lý đến hết 15s; tài nguyên server lãng phí (DB connection + thread tokio); client thấy lỗi 504 từ proxy nhưng nguyên nhân thực là proxy_read_timeout sai cấu hình chứ KHÔNG phải server chậm; client retry lần 2 đến server đang còn chạy lần 1 → double-charge nếu là payment endpoint; debug khó vì log server không có error (handler chạy thành công 15s sau nhưng client đã disconnect). Lock Shop API: review timeout chain 3 tầng mỗi quý deploy G18 với checklist 4 mức (server < proxy < lb < client_browser default 60s).
  2. 4 timeout class pros/cons: Default 5s — pros: fail-fast UX user thấy lỗi nhanh thay vì chờ vô vọng, tài nguyên server giải phóng sớm, alert nhanh khi service degrade; cons: outlier query phức tạp (báo cáo aggregate join nhiều bảng) có thể chạm deadline → cần optimize query hoặc tách endpoint riêng class 30s; áp dụng 95% endpoint Shop API CRUD JSON (P95 healthy < 100ms gấp 50 lần deadline đủ buffer outlier). Import 30s — pros: đủ xử lý 10K row NDJSON bulk insert + validate + audit log; cons: nếu user import file 100K row sẽ luôn timeout → cần chunk client-side trước hoặc chuyển sang async job queue 202 Accepted (G16 deploy); use case: /products/import.ndjson B49 lock. Upload 60s — pros: cover large file 100MB qua kết nối 4G chậm 2 MB/s; cons: 60s là threshold tối đa user kiên nhẫn chờ progress bar → khuyến cáo client chia chunked upload (multipart resumable) cho file > 100MB; use case: /products/{slug}/upload B79 lock. Webhook 10s — pros: align Stripe retry policy (Stripe gửi POST webhook với 15s connect timeout + 8s read timeout, gấp đôi 5s default an toàn margin); cons: webhook handler MUST trả 200 OK trong 10s nếu không Stripe đánh dấu failed + retry exponential (5s/30s/1h/6h/...) 3 ngày; pattern lock: webhook handler chỉ verify signature + enqueue background job → return 200 OK ngay trong < 100ms, worker xử lý async (B71 lock outbox preview). Tại sao webhook 10s thay 5s default: Stripe retry policy mặc định 8s read timeout — nếu Shop API 5s default sẽ ép Shop API cắt response sớm hơn Stripe expect; với 10s margin, webhook có thời gian (a) HMAC verify signature (1ms), (b) parse JSON event (1ms), (c) enqueue background worker (10ms), (d) return 200 OK; tổng << 1s nhưng để 10s deadline buffer cho cold start container + DB connection pool lazy init lần đầu (B56 acquire_timeout 5s + 5s margin xử lý). Stripe retry policy ảnh hưởng: nếu webhook timeout Stripe sẽ retry tối đa 3 ngày exponential — risk duplicate event nếu Shop API xử lý partial (Idempotency-Key bằng event.id Stripe mitigate B66 lock). Health/metrics 1s — pros: fail-fast LB detect service unhealthy ngay trong 1 scrape interval; cons: cold start lần đầu container mất 2-3s init pool sqlx → health check fail trong 2-3 health check đầu (cấu hình LB healthy_threshold 2 health check sai → pod bị kill khởi động lại loop, cần healthy_threshold 5+ và start_period 30s grace).
  3. HandleErrorLayer + TimeoutLayer ServiceBuilder chain order matters: ServiceBuilder builder pattern tower — .layer(L1).layer(L2).layer(L3) tạo stack L1(L2(L3(service))), layer thêm trước là outer bọc ngoài layer sau. Tại sao HandleErrorLayer outer: TimeoutLayer wrap inner service → khi inner service timeout, TimeoutLayer trả Err(BoxError) trồi lên outer; HandleErrorLayer ở outer nhận Err(BoxError) + chạy closure handler convert sang Response (axum yêu cầu service Final phải Service<Request, Response = Response, Error = Infallible> để mount vào router); nếu KHÔNG có HandleErrorLayer outer, BoxError trồi lên axum router → axum panic vì error type không match (router yêu cầu Infallible). Pattern lock B82: ServiceBuilder::new().layer(HandleErrorLayer::new(closure)).layer(TimeoutLayer::new(duration)) đúng order — HandleErrorLayer outer, TimeoutLayer inner. Scenario đặt sai thứ tự: nếu viết ServiceBuilder::new().layer(TimeoutLayer::new(duration)).layer(HandleErrorLayer::new(closure)) — TimeoutLayer outer, HandleErrorLayer inner; khi inner service trả Ok(Response) bình thường, HandleErrorLayer pass-through; nhưng khi inner service trả Err(BoxError), HandleErrorLayer ở inner convert sang Response; sau đó TimeoutLayer outer wrap response — nhưng TimeoutLayer KHÔNG biết phân biệt "response từ handler thành công" vs "response từ HandleError convert error", nên timeout có thể cắt response stream khi đang ghi error 504 ra wire → client nhận response cụt + connection reset thay vì 504 đầy đủ. Hơn nữa, nếu TimeoutLayer outer mà timeout trước khi inner service kịp trả error, BoxError sẽ trồi lên router (vì HandleErrorLayer inner đã chạy xong stage convert) → router panic Infallible mismatch. Tóm lại: HandleErrorLayer MUST outer hơn mọi layer có thể sinh BoxError. Lock Shop API: comment // OUTER: HandleErrorLayer + // INNER: TimeoutLayer MANDATORY mỗi sub-router build.
  4. Pitfall background task cancel + PostgreSQL atomic: khi tokio cancel future tại điểm await đang chờ, mọi statement sau điểm đó KHÔNG chạy + struct local destruct theo Drop. PostgreSQL atomic guarantee: transaction BEGIN ... COMMIT hoặc BEGIN ... ROLLBACK hoàn toàn — không có trạng thái partial; nếu tx bị drop chưa commit → sqlx tự ROLLBACK best-effort qua async drop (B54 lock); nếu tx.commit() đã chạy xong return Ok rồi tokio cancel sau → COMMIT đã succeed DB nhất quán. Scenario Shop API: create_order handler 3 step: (a) insert_order tx, (b) decrement_stock tx, (c) tx.commit() — nếu timeout giữa (a) và (b): tx drop, ROLLBACK, DB không có order + stock không đổi (atomic OK); nếu timeout giữa (b) và (c): tx drop, ROLLBACK, DB không có order + stock không đổi (atomic OK); nếu timeout SAU (c) commit xong: DB có order + stock đã decrement (atomic OK), nhưng client nhận 504 không biết order đã tạo → risk duplicate khi retry. Side-effect external scenario: create_order + Stripe API call + DB save payment_id: (a) insert_order tx, (b) stripe.charge(...) API call (external, không trong tx), (c) insert_payment(payment_id from stripe) tx, (d) tx.commit() — nếu timeout giữa (b) và (c): Stripe đã charge tiền user thành công nhưng Shop API tx rollback không lưu payment_id → mất tiền không có record DB; user thấy 504 retry → Stripe charge lần 2 (nếu không có Idempotency-Key Stripe-side); duplicate charge thật. Mitigate 3 tầng: (i) Idempotency-Key B66 — client gửi UUID per request, server cache response 24h, retry với cùng key trả response cũ; mitigate duplicate request từ client side. (ii) Outbox pattern G21 — handler chỉ write event vào table outbox trong cùng transaction business; worker poll outbox publish queue + gọi Stripe API; atomic guarantee giữa business state + outbox event (cùng transaction); worker idempotent với Idempotency-Key Stripe-side; mitigate side-effect external partial. (iii) Saga pattern G18 — multi-step workflow có compensation transaction rollback ngược nếu step sau fail (vd: reserve_stock → charge_payment → confirm_order, nếu charge_payment fail thì compensation = release_stock); cho complex flow cross-service. Lock Shop API: critical mutation (create_order/checkout/payment_capture/refund) KHÔNG gọi Stripe trực tiếp handler — luôn enqueue outbox + worker xử lý async (G21 deploy).
  5. Drop guard pattern + Arc<AtomicBool> design rationale: drop guard là struct với impl Drop tự động chạy cleanup code khi struct ra khỏi scope (kể cả khi future bị cancel giữa chừng) — guarantee bởi Rust language spec (drop chạy cho mọi local variable khi stack unwind hoặc scope exit). Scenario uncommitted transaction: create_order handler timeout giữa decrement_stocktx.commit() — tokio cancel future, local variable tx + _guard ra khỏi scope: (a) _guard Drop chạy → đọc committed.load(Acquire) == false → emit metric transaction_rolled_back_total{operation="create_order",reason="drop"} + log WARN "transaction dropped without commit"; (b) tx Drop chạy sau → sqlx ROLLBACK best-effort. Monitor Grafana panel: PromQL rate(transaction_rolled_back_total[5m]) normal = 0, anomaly > 0.01 = signal có timeout liên tục cho operation cụ thể → engineer debug log Loki query {operation="create_order"} | level="warn" trace nguyên nhân. Tại sao tách AtomicBool ra khỏi struct guard (Arc<AtomicBool> pattern): alternative naive design embed flag trực tiếp struct guard struct Guard { committed: bool, ... } — vấn đề: Rust ownership rule, sau khi tạo guard nếu muốn set guard.committed = true ở cuối handler thì cần &mut guard, nhưng _guard với prefix underscore convention = unused intentionally + lifetime hết scope handler không lấy được &mut; phải redesign signature let mut guard = ...; ... guard.commit_marked(); drop(guard); verbose + dễ quên explicit drop. Pattern Arc<AtomicBool> tách flag ra Arc share: guard giữ committed: Arc<AtomicBool> clone, handler giữ committed_flag: Arc<AtomicBool> clone — set committed_flag.store(true, Release) không cần mutable borrow guard; Drop của guard đọc qua immutable self.committed.load(Acquire); memory ordering Acquire/Release đảm bảo happen-before nếu set xảy ra trước Drop chạy. Drop guard KHÔNG thay thế explicit commit/rollback B54: B54 lock pattern explicit match tx.commit().await { Ok(_) => ..., Err(e) => ... } là MANDATORY cho mọi transaction — đảm bảo handle commit failure rõ ràng (transient network error, serialization failure, deadlock victim). Drop guard chỉ là tầng observability bổ sung — phát hiện code path nào quên explicit commit/rollback (anti-pattern); KHÔNG là cleanup logic thay thế. Lock Shop API: critical mutation handler wrap drop guard + giữ B54 explicit commit/rollback đầy đủ; pattern combine 2 best practice không loại trừ.
11

Bài Tiếp Theo

— middleware validate query string + path param + header trước khi reach handler, sanitize input (HTML escape, SQL injection prevention recap), pattern strict-mode validation với validator crate global rule.