Test iOS HTTP retry scheduling, backoff, and cancellation

Test iOS HTTP retry scheduling, backoff, and cancellation

Test iOS HTTP retry scheduling, backoff, and cancellation

An endpoint returns 429, but the client immediately sends three more requests. A user has signed out, yet an old request finishes waiting and sends again. Increasing the timeout will not fix these failures. When validating an iOS networking layer on a cloud Mac, start by separating retries into three boundaries: how to interpret the response, when another attempt is allowed, and who prevents a send after cancellation. The tests do not need to wait tens of seconds in real time.

Define which requests may be retried

Start with read-only GET requests. This example handles only 429 and 503. Give other status codes and transport errors their own policies rather than putting every failure into the same loop. The initial send does not count as a retry: at most three retries means at most four sends.

For write requests, the method name alone does not establish safety. Consider automatic retries only when the server supports an idempotency protocol, the same business operation reuses the same idempotency key, and the request body can be read again. For streaming uploads, also verify that the stream can be recreated.

A timeout means only that the client did not receive a result. It does not mean the server failed to complete the write. Cancelling a local task does not undo an operation that has already reached the server.

Keep a separate retry budget for each logical request. Do not reset the counter every time execution enters a networking function. If both an outer and an inner layer retry three times, the actual request count can multiply.

Turn response headers into explicit waiting constraints

Retry-After accepts two kinds of values: a nonnegative integer number of seconds or an HTTP date. For integers, accept only ASCII digits and reject negative numbers, fractions, and overflow. For dates, pin parsing to an English locale and GMT rather than relying on the node’s current language settings. The following example handles the common IMF-fixdate format:

import Foundation

func serverDelay(_ raw: String?, now: Date) -> TimeInterval? {
    guard let raw else { return nil }
    let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)

    if !text.isEmpty,
       text.utf8.allSatisfy({ $0 >= 48 && $0 <= 57 }) {
        guard let seconds = UInt64(text) else { return nil }
        return TimeInterval(seconds)
    }

    let parser = DateFormatter()
    parser.locale = Locale(identifier: "en_US_POSIX")
    parser.timeZone = TimeZone(secondsFromGMT: 0)
    parser.dateFormat = "EEE, dd MMM yyyy HH:mm:ss 'GMT'"
    parser.isLenient = false

    guard let date = parser.date(from: text),
          parser.string(from: date) == text else {
        return nil
    }
    return max(0, date.timeIntervalSince(now))
}

This is a strict parser for a common format, not a complete compatibility implementation for historical HTTP date formats. If the API contract requires older formats, add separate parsing branches and test fixtures. In this example, invalid values fall back to local backoff, and the reason for the parsing failure is logged.

Fix now, then check dates in the past, at the present instant, and in the future. Use a continuous clock for runtime waits. The wall clock should only convert a date into remaining seconds, so system clock corrections cannot change a wait that has already started.

Calculate backoff in a pure function, without sleeping

Save the following as retry-check.swift, after the parsing function above. retryIndex starts at zero. The base delays are 1, 2, and 4 seconds, with proportional jitter and a random input supplied by the caller.

enum Decision: Equatable {
    case stop
    case deferred
    case retry(TimeInterval)
}

func decide(
    status: Int,
    replayable: Bool,
    retryIndex: Int,
    server: TimeInterval?,
    unit: Double
) -> Decision {
    guard replayable,
          [429, 503].contains(status),
          (0..<3).contains(retryIndex),
          unit.isFinite,
          (0...1).contains(unit) else {
        return .stop
    }

    let base = pow(2.0, Double(retryIndex))
    let local = base * (0.5 + 0.5 * unit)

    if let server, !server.isFinite || server < 0 {
        return .stop
    }

    let delay = max(local, server ?? 0)
    guard delay <= 30 else { return .deferred }
    return .retry(delay)
}

precondition(decide(
    status: 503, replayable: true,
    retryIndex: 0, server: nil, unit: 0
) == .retry(0.5))

precondition(decide(
    status: 429, replayable: true,
    retryIndex: 1, server: 10, unit: 1
) == .retry(10))

precondition(decide(
    status: 429, replayable: true,
    retryIndex: 0, server: 60, unit: 0
) == .deferred)

precondition(decide(
    status: 503, replayable: true,
    retryIndex: 3, server: nil, unit: 1
) == .stop)

precondition(decide(
    status: 503, replayable: false,
    retryIndex: 0, server: nil, unit: 1
) == .stop)

Run it on a node with the Swift toolchain configured:

swift retry-check.swift

A normal exit means the assertions passed. Do not add optimization options that disable the checks. The 30-second limit here is an example cap on automatic waiting, not a protocol requirement. If the server asks for longer, return a deferred state. Do not truncate the delay to 30 seconds and retry early.

Test cancellation boundaries, not just delay values

The executor takes only the decision, then calls an injectable wait function. In production, that can be a cancellable Task.sleep. In tests, use a controllable suspension point with a notification that waiting has begun. Avoid brief sleeps that merely guess how far the task has progressed.

Verify at least these three orderings:

A session generation is an identifier that increments whenever the login state changes. Even if an old request receives a successful response, it must compare generations before updating the UI or cache. A race still exists between checking cancellation and actually sending the request, so cancellation cannot be claimed to guarantee that the server receives nothing.

Turn failure evidence into merge checks

Add parser coverage for missing values, whitespace, negative numbers, fractions, oversized integers, and date boundaries. For the scheduler, cover retry indices zero through three, random-input endpoints zero and one, and server delays exactly at and above the cap.

Use a scripted transport test double for integration tests. Have it return 503, 429, and 200 in sequence, and verify the planned delay before each send. Add scenarios for cancellation, a non-replayable body, and an exhausted retry budget. Do not trigger rate limiting against real public APIs, and do not make the test environment actually wait for the duration specified in a response header.

Log only the logical request identifier, status code, retry index, planned delay, and termination reason. Do not log authentication headers or bodies. Final acceptance comes down to four checks: no early retries, no sends beyond the budget, no cancellation treated as an ordinary error, and no non-replayable requests entering the loop. Meet those conditions before discussing whether the backoff parameters need tuning.

Frequently asked questions

Should every 429 or 503 response trigger a retry?

No. Check replay safety, the remaining retry budget, and the server's delay requirement first. Writes need a server-supported idempotency contract and a replayable request body.

Can I shorten Retry-After to fit my waiting limit?

No. That would retry earlier than requested. Return a deferred outcome so the caller can persist the job or inform the user instead.

How can backoff tests avoid real waiting?

Inject the clock, random input, and sleeper. Compare planned delays in pure-function tests, then cancel at a controlled suspension point and verify that the send count does not increase.

Need a dedicated Mac for your next build?

Choose a rental term that fits your needs. Get exclusive use of a physical Mac with full access to the macOS GUI and command line. Models, locations, and availability are shown live in the console.

Put these steps to work on your next archive.

Check the rental term and configuration before starting your build.