Trang chủ

Thử lại có độ lùi, và những request không được phép thử lại

Thử lại một request hỏng thì dễ. Thử lại đúng những request cần thử lại, ở đúng khoảng thời gian, mới là chỗ có bug — và sai lầm đắt nhất là thử lại một thứ vốn đã thành công.

Thuật toán

Độ lùi theo cấp số nhân kèm nhiễu ngẫu nhiên, trong khoảng hai mươi dòng:

func fetch(_ request: URLRequest, attempts: Int = 3) async throws -> Data {
    var lastError: Error?

    for attempt in 0..<attempts {
        do {
            let (data, response) = try await URLSession.shared.data(for: request)
            guard let http = response as? HTTPURLResponse else { return data }

            switch http.statusCode {
            case 200..<300:
                return data
            case 429, 500..<600:
                lastError = APIError.server(http.statusCode)     // thử lại những cái này
            default:
                throw APIError.client(http.statusCode)           // đừng thử lại
            }
        } catch let error as URLError where error.isRetryable {
            lastError = error
        }

        if attempt < attempts - 1 {
            let delay = pow(2.0, Double(attempt)) + Double.random(in: 0...0.5)
            try await Task.sleep(for: .seconds(delay))
        }
    }

    throw lastError ?? APIError.unknown
}

Các khoảng chờ đại khái 1s, 2s, 4s, cộng thêm tối đa nửa giây nhiễu ngẫu nhiên.

Phần nhiễu không phải trang trí. Không có nó, mọi client đã hỏng trong một sự cố sẽ thử lại vào đúng cùng một khoảnh khắc, và cái máy chủ đang hồi phục bị một đợt sóng đồng bộ đánh sập lần nữa. Đây là một kiểu hỏng có thật và có tên riêng — thundering herd — và nửa giây ngẫu nhiên là đủ để ngăn nó.

Thử lại cái gì

Đây là phần quan trọng hơn cả đường cong độ lùi.

Thử lại: hết giờ, mất kết nối, hỏng DNS, 429 Too Many Requests, và 5xx — những lỗi máy chủ có khả năng chỉ là nhất thời.

Đừng thử lại: 400, 401, 403, 404, 422. Request sai, và gửi lại nó cho ra đúng câu trả lời cũ trong khi phí pin của người dùng. Riêng 401 cần một lượt làm mới token chứ không phải một lượt thử lại — thử lại ba lần với cùng cái token hết hạn chỉ làm chậm màn hình đăng nhập.

extension URLError {
    var isRetryable: Bool {
        switch code {
        case .timedOut, .networkConnectionLost, .notConnectedToInternet,
             .dnsLookupFailed, .cannotConnectToHost, .cannotFindHost:
            return true
        default:
            return false
        }
    }
}

Cảnh báo

URLError.cancelled không bao giờ được thử lại. Nó nghĩa là người dùng đã rời đi hoặc một request mới hơn đã thay thế cái này, và thử lại nó là hồi sinh một công việc đã bị bỏ một cách có chủ đích. Đó cũng là thứ mà việc hủy Task sinh ra, nên thử lại nó là vô hiệu hóa hoàn toàn cơ chế hủy.

Tính bất biến khi lặp: sai lầm tốn tiền

Trường hợp nguy hiểm là một request đã thành công trên máy chủ nhưng phản hồi không bao giờ về tới. Client thấy hết giờ, thử lại, và thao tác đó xảy ra hai lần.

Với một GET thì vô hại. Với “trừ tiền thẻ này” hay “gửi tin nhắn này” thì không.

Mặc định chỉ thử lại những thao tác bất biến khi lặp. GET, PUT và DELETE là bất biến khi lặp theo định nghĩa. POST thì thường không.

Ở đâu bạn buộc phải thử lại một POST, hãy dùng một khóa chống lặp:

var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue(UUID().uuidString, forHTTPHeaderField: "Idempotency-Key")

Cái khóa phải được sinh ra một lần, bên ngoài vòng lặp thử lại, để mọi lượt thử đều mang cùng một cái. Máy chủ ghi lại nó và trả về đúng phản hồi ban đầu cho một bản trùng. Nếu backend không hỗ trợ điều này thì câu trả lời thành thật là đừng thử lại request đó — hãy phơi lỗi ra và để người dùng quyết định.

Tôn trọng Retry-After

Một 429 hay 503 thường mang theo một header nói khi nào thì quay lại. Lờ nó đi rồi dùng độ lùi của riêng bạn là cách bạn bị chặn hạn mức nặng hơn:

if let retryAfter = http.value(forHTTPHeaderField: "Retry-After"),
   let seconds = Double(retryAfter) {
    try await Task.sleep(for: .seconds(seconds))
} else {
    try await Task.sleep(for: .seconds(pow(2.0, Double(attempt))))
}

Cài đặt miễn phí và đó là máy chủ đang nói cho bạn câu trả lời đúng.

URLSession vốn đã làm gì

Đáng biết trước khi viết bất cứ thứ gì ở trên, vì một phần đã được lo sẵn:

  • waitsForConnectivity trên cấu hình session khiến một request chờ có kết nối thay vì hỏng ngay lập tức. Với một request do người dùng khởi xướng, cách đó thường tốt hơn là thử lại.
  • Background session thử lại xuyên qua các lần khởi chạy ứng dụng và cả lần khởi động lại máy. Với việc tải lên, đó là một lời bảo đảm mạnh hơn bất cứ thứ gì bạn viết được trong tiến trình.
  • Việc tái sử dụng kết nối HTTP/2 nghĩa là một lỗi “mất kết nối” thường được hồi phục trong suốt trước cả khi bạn thấy nó.

Bản tôi thật sự phát hành

Chỉ thử lại các request bất biến khi lặp, tối đa ba lần, có nhiễu ngẫu nhiên, tôn trọng Retry-After, không bao giờ thử lại 4xx trừ 429, không bao giờ thử lại khi bị hủy, và ghi log mọi lượt thử lại kèm lý do.

Phần cuối đó quan trọng hơn vẻ ngoài của nó. Một lượt thử lại lặng lẽ che đi một backend đang xuống cấp — ứng dụng trông vẫn ổn trong khi mọi request đều mất ba lượt, và chẳng ai biết cho tới khi ba lượt ấy không còn đủ.