Swift中的HTTP(十二) 重试

5,875 阅读7分钟

HTTP简介

HTTP基础结构

HTTP请求体

HTTP 加载请求

HTTP 模拟测试

HTTP 链式加载器

HTTP 动态修改请求

HTTP 请求选项

HTTP 重置

HTTP 取消

HTTP 限流

HTTP 重试

HTTP 基础鉴权

HTTP 自动鉴权设置

HTTP 自动鉴权

HTTP 复合加载器

HTTP 头脑风暴

HTTP 总结

如果收到的响应与客户端所寻找的不完全一致,大多数网络库都能够自动再次发送请求。 让我们也把它添加到我们的库中。

配置

回忆一下我们的 HTTPLoader 的 API:

open class HTTPLoader {

    /// Load an HTTPTask that will eventually call its completion handler
    func load(task: HTTPTask)

    /// Reset the loader to its initial configuration
    func reset(with group: DispatchGroup)
}

请记住,这定义了一种加载任务的方式和一种“重新开始”的方式。 加载任务具有请求最终将返回响应的合同。

API 合同中没有任何内容表明请求只能加载一次。 您可以(并且我们将)拥有一个加载程序,多次执行单个请求,然后选择最佳响应传回。 这就是我们的装载机要做的。

重试请求与每个单独的请求密切相关,因此我们将首先创建每个请求的选项值来指定单个请求的行为方式。

重试选项

重试请求的决定归结为一个问题:“鉴于此响应,我是否应该重试该请求?如果是,我应该等待多长时间才能重试?” 我们可以将其表示为协议:

public protocol HTTPRetryStrategy {
    func retryDelay(for result: HTTPResult) -> TimeInterval?
}

我们的重试“策略”需要检查我们从上次请求调用中获得的 HTTPResult,然后返回一个 TimeInterval(以秒为单位的时间长度)等待再次发送请求。 返回 nil 表示“不重试”,返回 0 表示“立即重试”。

我们可以立即想象出几种不同的策略,例如立即重试、始终等待相同的时间或等待呈指数增长的时间长度:

public struct Backoff: HTTPRetryStrategy {
    public static func immediately(maximumNumberOfAttempts: Int) -> Backoff
    public static func constant(delay: TimeInterval, maximumNumberOfAttempts: Int) -> Backoff
    public static func exponential(delay: TimeInterval, maximumNumberOfAttempts: Int) -> Backoff
}

由于这是一个协议,我们也可以想象一个自定义实现来提供更动态的实现。 例如,Twitter API 说:

• 对于速率受限的查询(那些返回 HTTP 429 状态代码的查询),您必须检查 x-rate-limit-reset 标头并仅在指示的时间或之后重试。

• 对于导致 HTTP 503 服务不可用状态代码的查询,您必须检查 retry-after 标头并仅在指示的时间后重试。

许多 HTTP 服务提供类似的选项。 如果您发送太多请求太快或信息尚不可用,它们通常会在响应标头中指示您应该等待多长时间才能重试。 因此,您可以编写一个自定义重试策略来实现您所针对的 API 的特定行为:

struct TwitterRetryStrategy: HTTPRetryStrategy {
    func retryDelay(for result: HTTPResult) -> TimeInterval? {
        // TODO: are there other scenarios to consider?
        guard let response = result.response else { return nil }

        switch response.statusCode {

            case 429: 
                // look for the header that tells us when our limit resets
                guard let retryHeader = response.headers["x-rate-limit-reset"] else { return nil }
                guard let resetTime = TimeInterval(retryHeader) else { return nil }
                let resetDate = Date(timeIntervalSince1970: resetTime)
                let timeToWait = resetDate.timeIntervalSinceNow()
                guard timeToWait >= 0 else { return nil }
                return timeToWait

            case 503:
                // look for the header that tells us how long to wait
                guard let retryHeader = response.headers["retry-after"] else { return nil }
                return TimeInterval(retryHeader)

            default:
                return nil
        }
    }
}

定义了这些策略后,我们需要一个正式的 HTTPRequestOption 类型来声明它可以附加到请求中:

public enum RetryOption: HTTPRequestOption {
    // by default, HTTPRequests do not have a retry strategy, and therefore do not get retried
    public static var defaultOptionValue: HTTPRetryStrategy? { nil }
}

extension HTTPRequest {    
    public var retryStrategy: HTTPRetryStrategy? {
        get { self[option: RetryOption.self] }
        set { self[option: RetryOption.self] = newValue }
    }
}

加载器

我们创建的用于处理此问题的加载程序将是迄今为止我们最复杂的加载程序。 我个人的实现大约有 200 行代码,太长了,无法在本文中完整列出。 不过,我会突出显示它的关键部分。

  • 所有通过 load(task:) 方法接收的 HTTPTasks 在被传递到链中的下一个加载器之前被复制。 这是因为每个任务只应执行一次,因此请求的多次调用将需要多个任务。
  • 我们需要一种方法来记住哪个“重复”任务对应于原始任务。
  • 我们需要一种方法来保留所有等待重试的任务的列表,以及它们希望开始的时间。
  • 因此,我们需要某种类似计时器的机制来跟踪“下一个任务何时开始”。
  • 取消会有点棘手,因为原始任务将被取消,但我们需要一种方法来查看发生的情况并将取消命令转发给任何重复项。 -不要忘记重置 考虑到所有这些,我的实现大致如下所示:
// TODO: make all of this thread-safe
public class Retry: HTTPLoader {
    // the original tasks as received by the load(task:) method
    private var originalTasks = Dictionary<UUID, HTTPTask>()

    // the times at which specific tasks should be re-attempted
    private var pendingTasks = Dictionary<UUID, Date>()

    // the currently-executing duplicates
    private var executingAttempts = Dictionary<UUID, HTTPTask>()

    // the timer for notifying when it's time to try another attempt
    private var timer: Timer?
    
    public override func load(task: HTTPTask) {
        let taskID = task.id
        // we need to know when the original task is cancelled
        task.addCancelHandler { [weak self] in
            self?.cleanupFromCancel(taskID: taskID)
        }
        
        attempt(task)
    }
    
    /// Immediately attempt to load a duplicate of the task
    private func attempt(_ task: HTTPTask) {
        // overview: duplicate this task and 
        // 1. Create a new HTTPTask that invokes handleResult(_:for:) when done
        // 2. Save this information into the originalTasks and executingAttempts dictionaries

        let taskID = task.id        
        let thisAttempt = HTTPTask(request: task.request, completion: { [weak self] result in
            self?.handleResult(result, for: taskID)
        })
        
        originalTasks[taskID] = task
        executingAttempts[taskID] = thisAttempt
        
        super.load(task: thisAttempt)
    }
    
    private func cleanupFromCancel(taskID: UUID) {
        // when a task is cancelled:
        // - the original task is removed
        // - any executing attempt must be cancelled
        // - any pending task must be removed AND explicitly failed
        //   - this is a task that was stopped at this level, therefore
        //     this loader is responsible for completing it

        // TODO: implement this
    }
    
    private func handleResult(_ result: HTTPResult, for taskID: UUID) {
        // schedule the original task for retrying, if necessary
        // otherwise, manually complete the original task with the result

        executingAttempts.removeValue(forKey: taskID)
        guard let originalTask = originalTasks.removeValue(forKey: taskID) else { return }
            
        if let delay = retryDelay(for: originalTask, basedOn: result) {
            pendingTasks[taskID] = Date(timeIntervalSinceNow: delay)
            rescheduleTimer()
        } else {
            originalTask.complete(with: result)
        }
    }
    
    private func retryDelay(for task: HTTPTask, basedOn result: HTTPResult) -> TimeInterval? {
        // we do not retry tasks that were cancelled or stopped because we're resetting
        // TODO: return nil if the result indicates the task was cancelled
        // TODO: return nil if the result indicates the task failed because of `.resetInProgress`
        
        let strategy = task.request.retryStrategy
        guard let delay = strategy?.retryDelay(for: result) else { return nil }
        return max(delay, 0) // don't return a negative delay
    }
    
    private func rescheduleTimer() {
        // TODO: look through `pendingTasks` find the task that will be retried soonest
        // TODO: schedule the timer to fire at that time and call `fireTimer()`
    }
    
    private func fireTimer() {
        // TODO: get the tasks that should've started executing by now and attempt them
        // TODO: reschedule the timer
    }
    
    public override func reset(with group: DispatchGroup) {
        // This loader is done resetting when all its tasks are done executing

        for task in originalTasks.values {
            group.enter()
            task.addCompletionHandler { group.leave() }
        }
        
        super.reset(with: group)
    }
}

这个粗略的轮廓说明了“自动重试”加载程序的原理。 随着请求的到来,它们被保存到一边,重复项被转发到链下。 当复制完成时,加载器检查响应并弄清楚它应该如何处理它。 如果请求的重试策略表明它应该再试一次,那么它会将任务排队等待未来的日期。 如果不是,它会获取重复请求的结果并假装它一直是原始响应。

Retry 加载器是我们创建的第一个加载器,它在链中的位置会影响链的整体行为。 让我们考虑一个场景,我们有两个加载器:一个 Retry 加载器和一个 Throttle 加载器:

let throttle = Throttle()
throttle.maximumNumberOfRequests = 1

let retry = Retry()

现在假设我们要执行两个任务,taskA 和 taskB,我们还假设 taskA 将在最终失败之前最多重试 3 次,而 taskB 将成功。

let taskA: HTTPTask = ...
let taskB: HTTPTask = ...

let chain1 = throttle --> retry --> ...
let chain2 = retry --> throttle --> ... 

如果节流加载器放在重试加载器之前,那么“最大 1 个请求”的限制会在请求重试之前发生。 因此,如果 chain1 加载 taskA,然后加载 taskB,则执行顺序将始终为:A(尝试 1)、A(尝试 2)、A(尝试 3)、B。如果 taskA 的尝试之间存在较大延迟,则 taskB 可以 在尝试之前等待很长时间。

另一方面,如果 chain2 加载 taskA,然后加载 taskB,则执行顺序是不确定的。 可能是 A(尝试 1)、B、A(尝试 2)、A(尝试 3),B 有机会更快地执行。

“正确”的顺序完全取决于你想要的行为,但我建议节流可能是链中的最终加载器之一,这样链就不会无意中让传入的请求挨饿。

在下一篇文章中,我们将首先了解使用基本访问身份验证的身份验证。