Xử lý lỗi, timeout và retry

4 câu hỏi~25 phútTừ khóa: onErrorResume, onErrorReturn, onErrorMap, doOnError, timeout, retryWhen, Retry.backoff, jitter, idempotency

Tiếp đi. Payment-service lúc lỗi lúc chậm thì xử lý trong chuỗi Mono thế nào? Không dùng try/catch được nhỉ?

Đúng, try/catch quanh code reactive gần như vô dụng: lỗi xảy ra sau này, khi dữ liệu chạy qua chuỗi, lúc đó method đã return từ lâu. Lỗi là một tín hiệu onError chạy dọc chuỗi, nên ta xử lý bằng operator.

1. Bài toán

Checkout gọi payment-service. Thực tế:

2. Vấn đề

try {
    return paymentClient.pay(req);   // chỉ trả về Mono, chưa gọi gì
} catch (Exception e) {              // không bao giờ bắt được lỗi HTTP
    ...
}

3. Giải pháp

OperatorÝ nghĩa
onErrorReturn(value)Lỗi thì trả giá trị mặc định
onErrorResume(e -> Mono)Lỗi thì chuyển sang Publisher khác (fallback)
onErrorMap(e -> e2)Đổi loại exception (sang lỗi nghiệp vụ)
doOnError(e -> log)Side effect (log, metric), lỗi vẫn đi tiếp
timeout(Duration)Không có tín hiệu trong khoảng thời gian thì phát TimeoutException
retryWhen(Retry.backoff(...))Subscribe lại khi lỗi, có chờ tăng dần

4. Code từng bước

Bước 1: Dependency: spring-boot-starter-webflux (đã có reactor.util.retry.Retry).

Bước 2: Config timeout ở tầng connection (bổ sung cho timeout() ở chuỗi):

@Bean
WebClient paymentWebClient(WebClient.Builder builder) {
    HttpClient http = HttpClient.create()
            .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2000)
            .responseTimeout(Duration.ofSeconds(3));
    return builder.baseUrl("http://payment-service")
            .clientConnector(new ReactorClientHttpConnector(http))
            .build();
}

Bước 3: Code Java

public Mono<Payment> pay(PaymentRequest req) {
    return paymentWebClient.post().uri("/payments")
            .header("Idempotency-Key", req.orderId().toString())   // retry an toàn
            .bodyValue(req)
            .retrieve()
            .bodyToMono(Payment.class)
            .timeout(Duration.ofSeconds(3))                         // mỗi lần thử tối đa 3s
            .retryWhen(Retry.backoff(3, Duration.ofMillis(200))     // 200ms, 400ms, 800ms (+jitter)
                    .filter(this::isTransient)                      // chỉ retry lỗi tạm thời
                    .onRetryExhaustedThrow((spec, signal) -> signal.failure()))
            .doOnError(e -> log.warn("Payment failed order={}: {}", req.orderId(), e.toString()))
            .onErrorMap(e -> new PaymentUnavailableException("Payment unavailable", e));
}

private boolean isTransient(Throwable e) {
    return e instanceof TimeoutException
        || e instanceof WebClientRequestException                       // lỗi kết nối
        || (e instanceof WebClientResponseException r && r.getStatusCode().is5xxServerError());
}

// Phần không quan trọng: lỗi thì bỏ qua
public Mono<List<Product>> recommendations(Long userId) {
    return recoClient.forUser(userId)
            .timeout(Duration.ofMillis(500))
            .onErrorReturn(List.of());
}

// Fallback sang nguồn khác
public Mono<Price> price(Long productId) {
    return priceClient.live(productId)
            .onErrorResume(WebClientResponseException.ServiceUnavailable.class,
                           e -> priceCache.get(productId));
}

Map lỗi nghiệp vụ ra HTTP status:

@RestControllerAdvice
public class ApiErrorHandler {
    @ExceptionHandler(PaymentUnavailableException.class)
    @ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
    public Map<String, String> handle(PaymentUnavailableException e) {
        return Map.of("error", e.getMessage());
    }
}

Bước 4: Test

@Test
void retriesThenSucceeds() {
    AtomicInteger attempts = new AtomicInteger();
    Mono<String> flaky = Mono.defer(() -> attempts.incrementAndGet() < 3
            ? Mono.error(new TimeoutException())
            : Mono.just("SUCCESS"));

    StepVerifier.create(flaky.retryWhen(Retry.backoff(3, Duration.ofMillis(10))))
            .expectNext("SUCCESS")
            .verifyComplete();
    assertEquals(3, attempts.get());
}
# tắt payment-service rồi:
curl -i -X POST localhost:8080/api/checkout -d '{"userId":1,"cartId":10}' \
  -H "Content-Type: application/json"     # sau ~vài giây: 503

5. Cách hoạt động

subscribe -> POST /payments --x 503
             chờ ~200ms
resubscribe -> POST /payments --x timeout 3s
             chờ ~400ms
resubscribe -> POST /payments --> 200 OK -> onNext(Payment)

Hết lượt retry:
  onError(503) -> doOnError(log) -> onErrorMap(PaymentUnavailable) -> 503 cho client

Retry hoạt động bằng cách subscribe lại từ đầu chuỗi phía trên nó. Vì WebClient là cold publisher nên mỗi lần subscribe là một HTTP request mới. Vị trí operator quan trọng: timeout đặt trước retryWhen là timeout cho từng lần thử; đặt sau là timeout tổng.

6. So sánh

onErrorReturnonErrorResumeonErrorMapdoOnError
Chuỗi tiếp tục vớiGiá trị cố địnhPublisher khácLỗi khácLỗi gốc
Nuốt lỗi?CóCó (trừ khi trả Mono.error)KhôngKhông
Dùng choDefault đơn giảnFallback, cacheĐổi sang lỗi nghiệp vụLog, metric
retry(n)retryWhen(Retry.backoff)
Chờ giữa các lầnKhôngExponential + jitter
Lọc loại lỗiKhôngCó filter
ProductionHiếmNên dùng

7. Khi nào nên dùng / không nên dùng

Vậy onErrorReturn chỉ là onErrorResume trả Mono.just thôi đúng không?

Đúng rồi, về kết quả thì onErrorReturn(x) tương đương onErrorResume(e -> Mono.just(x)). Khác biệt ở chỗ onErrorResume linh hoạt hơn:

.onErrorResume(e -> e instanceof WebClientResponseException.NotFound
        ? Mono.empty()
        : Mono.error(e))

Cẩn thận: nuốt lỗi ở chỗ quan trọng (như payment) làm đơn "thành công giả". Payment lỗi thì nên báo lỗi, không fallback bừa.

Tôi đặt doOnError log lỗi rồi mà client vẫn nhận 500, vậy doOnError không bắt lỗi à?

Đúng, doOnError chỉ "nhìn" lỗi đi qua (side effect), giống finally có log. Nó không thay đổi tín hiệu. Muốn đổi kết quả phải dùng onErrorResume/onErrorReturn/onErrorMap. Họ doOnXxx (doOnNext, doOnSuccess, doFinally) đều là side effect, dùng cho log và metric, không nên chứa logic chính hay gọi blocking.

Phỏng vấn hỏi xử lý lỗi, timeout, retry trong Reactor thì trả lời sao?

Q1. Xử lý lỗi trong Reactor thế nào?

🇻🇳 Tiếng Việt:

Lỗi là tín hiệu onError chạy dọc chuỗi nên try/catch không bắt được. Em dùng onErrorReturn cho giá trị mặc định, onErrorResume để fallback sang Publisher khác, onErrorMap để đổi sang exception nghiệp vụ, và doOnError chỉ để log. Sau đó @RestControllerAdvice map exception ra HTTP status.

🇬🇧 English:

An error is an onError signal travelling down the chain, so try/catch does not catch it. I use onErrorReturn for a default value, onErrorResume to fall back to another publisher, onErrorMap to translate into a business exception, and doOnError only for logging. A @RestControllerAdvice then maps exceptions to HTTP status codes.

Q2. Retry trong Reactor nên cấu hình ra sao?

🇻🇳 Tiếng Việt:

Dùng retryWhen với Retry.backoff: số lần giới hạn, chờ tăng dần có jitter, filter chỉ retry lỗi tạm thời như 5xx và timeout. Chỉ retry thao tác idempotent hoặc có idempotency key, và chỉ retry ở một tầng để tránh retry storm. Retry hoạt động bằng cách subscribe lại nguồn.

🇬🇧 English:

Use retryWhen with Retry.backoff: a bounded number of attempts, exponential delay with jitter, and a filter so only transient errors like 5xx and timeouts are retried. Only retry idempotent operations or ones with an idempotency key, and retry at a single layer to avoid retry storms. Retry works by resubscribing to the source.

Q3. timeout đặt trước hay sau retryWhen?

🇻🇳 Tiếng Việt:

Đặt trước retryWhen thì đó là timeout cho từng lần thử, vì retry subscribe lại cả đoạn phía trên. Đặt sau thì là timeout tổng cho mọi lần thử. Thường em đặt cả hai: per-attempt ngắn và một giới hạn tổng.

🇬🇧 English:

Placed before retryWhen it is a per-attempt timeout, because retry resubscribes everything upstream. Placed after, it is an overall timeout across all attempts. I usually use both: a short per-attempt timeout and an overall limit.

Cách nhớ ngắn gọn


Tự kiểm tra

Vì sao try/catch quanh paymentClient.pay(req) không bắt được lỗi HTTP?

Method chỉ trả về Mono; lỗi xảy ra sau khi subscribe, dưới dạng tín hiệu onError.

doOnError có làm client hết nhận lỗi không?

Không, nó chỉ là side effect. Lỗi vẫn đi tiếp.

Có nên retry lỗi 400 Bad Request?

Không. Lỗi do request sai, thử lại vẫn sai. Chỉ retry lỗi tạm thời.

Retry POST /payments có rủi ro gì?

Có thể trừ tiền hai lần nếu lần đầu thật ra đã thành công. Cần idempotency key.

.timeout(3s).retryWhen(backoff(3,...)): tối đa mất bao lâu?

Khoảng 4 lần thử x 3s cộng thời gian backoff, vì timeout áp cho từng lần thử.