クラウド Mac で検証する iOS HTTP リトライの待機とキャンセル
API が 429 を返したのに、クライアントが即座にあと三回送信してしまう。ユーザーはすでにログアウトしているのに、古いリクエストが待機後に再送される。こうした不具合は、タイムアウトを長くするだけでは解決しません。クラウド Mac で iOS のネットワーク層を検証する際は、まずリトライを三つの境界に分けます。レスポンスをどう解釈するか、いつ再送を許可するか、キャンセル後の送信をどこで阻止するか、です。テストで実際に数十秒待つ必要はありません。
まずリトライを許可するリクエストを定義する
最初は読み取り専用の GET を対象にし、この例では 429 と 503 だけを扱います。それ以外のステータスやトランスポートエラーには別のポリシーを設け、あらゆる失敗を一つのループに押し込まないようにします。初回送信はリトライ回数に含めません。リトライが最大三回なら、送信は最大四回です。
書き込みリクエストの安全性は、メソッド名だけでは判断できません。サーバーが冪等性を保証する仕組みに対応し、同じ業務操作では同じ冪等性キーを再利用し、かつリクエストボディを再度読み取れる場合に限り、自動再送を検討します。ストリーミングアップロードでは、ストリームを再生成できるかも確認する必要があります。
タイムアウトが示すのは、クライアントが結果を受け取れなかったということだけです。サーバーで書き込みが完了していないとは限りません。ローカルのタスクをキャンセルしても、すでにサーバーに到達した操作は取り消せません。
リトライ回数の上限は、論理リクエストごとに独立して管理します。ネットワーク関数に入るたびにカウンターをリセットしてはいけません。外側と内側でそれぞれ三回リトライすると、実際のリクエスト数が膨らみます。
レスポンスヘッダーを明確な待機条件に変換する
Retry-After の値には、非負の整数で表す秒数と HTTP 日付の二種類があります。整数として受け付けるのは ASCII 数字だけとし、負数、小数、オーバーフローする値は拒否します。日付の解析では英語ロケールと GMT を固定し、ノードの現在の言語設定に依存させません。以下は、一般的な IMF-fixdate 形式を扱う例です。
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))
}
これは一般的な形式を厳密に解析するパーサーであり、過去の HTTP 日付形式すべてに対応する実装ではありません。API の仕様で旧形式の受け入れが求められる場合は、解析の分岐とテストフィクスチャを別途追加します。この例では、無効な値はローカルのバックオフにフォールバックし、解析に失敗した理由を記録します。
now を固定し、過去、現在、未来の日付をそれぞれ確認します。実行時の待機には継続クロックを使います。実時刻の時計は日付を残り秒数に変換するためだけに使い、システム時刻の補正によって開始済みの待機時間が変わらないようにします。
バックオフは純粋関数で計算し、関数内では待機しない
先ほどの解析関数に続けて以下の内容を記述し、retry-check.swift として保存します。retryIndex はゼロから始まります。基本の遅延は順に 1、2、4 秒で、比率ベースのジッターを適用します。乱数値は呼び出し側から渡します。
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)
Swift ツールチェーンを設定済みのノードで実行します。
swift retry-check.swift
正常終了すれば、アサーションはすべて通過しています。チェックを無効にする最適化オプションは付けないでください。ここでの 30 秒は、この例で自動的に待機する時間の上限であり、プロトコルの規定ではありません。サーバーがそれより長い待機を要求した場合は延期を示す状態を返します。30 秒に切り詰めて早く再送してはいけません。
遅延の数値だけでなく、キャンセルの境界を検証する
実行側は判断結果だけを受け取り、注入可能な待機関数を呼び出します。本番環境ではキャンセル可能な Task.sleep を使えます。テスト環境では制御可能なサスペンドポイントを用意し、「待機に入った」ことを通知させます。短いスリープを挟んでタスクの進行状況を推測する方法は避けてください。
少なくとも、次の三つの順序を検証します。
- 待機前にキャンセル:送信回数が増えないこと。
- 待機中にキャンセル:待機処理が投げたキャンセルエラーが上位へ伝播し、通常の失敗としてリトライされないこと。
- 待機が終わった直後にキャンセル:送信前にタスクのキャンセル状態とセッション世代を再確認すること。
セッション世代は、ログイン状態が切り替わるたびに増やす識別子です。古いリクエストは成功レスポンスを受け取っても、画面やキャッシュを更新する前に世代を比較しなければなりません。キャンセルの確認から実際のリクエスト送信までの間には、なお競合が残ります。そのため、キャンセルすればサーバーにリクエストが一切届かないと保証することはできません。
失敗の証拠をマージ前のチェック項目にする
パーサーのテストには、値なし、空白、負数、小数、極端に大きい整数、日付の境界値を追加します。スケジューラーでは、ゼロから三までのリトライインデックス、乱数の両端であるゼロと一、サーバー指定の待機時間が上限にちょうど達する場合と上限を超える場合を網羅します。
結合テストでは、あらかじめ指定した動作をするトランスポートのテストダブルを使い、503、429、200 を順に返して、各送信前の予定遅延を照合します。さらに、キャンセル、再送できないボディ、リトライ回数の上限到達もテストします。実在する公開 API にリクエストを送ってレート制限を発生させたり、テスト環境でレスポンスヘッダーが指定する時間を実際に待ったりしてはいけません。
ログに残すのは、論理リクエストの識別子、ステータスコード、リトライインデックス、予定遅延、終了理由だけとし、認証ヘッダーやボディは記録しません。最終的な受け入れでは、早すぎる再送がないこと、回数の上限を超えて送信しないこと、キャンセルを通常のエラーとして扱わないこと、再送できないリクエストがループに入らないことの四点を確認します。バックオフのパラメーター調整を検討するのは、これらを満たしてからです。
よくある質問
429 や 503 が返れば必ずリトライすべきですか?
いいえ。安全に再送できる要求か、回数の予算内か、サーバーの待機指定を守れるかも確認します。書き込み要求には、サーバーが保証する冪等性と再送可能な本文が必要です。
Retry-After が待機上限を超えたら短縮できますか?
短縮して再送してはいけません。延期を表す結果を上位層に返し、タスクの保存や利用者への案内に切り替えます。
実際に待たずにバックオフをテストできますか?
現在時刻、乱数、待機処理を注入します。純粋関数では予定待機時間を比較し、実行側では制御可能な停止点でキャンセルして送信回数が増えないことを確認します。
今回のビルドに専有Macが必要ですか?
用途に合わせてレンタル期間をお選びください。物理Macを1台まるごと専有でき、macOSのGUIとコマンドラインをすべてご利用いただけます。機種、設置場所、利用状況はコントロールパネルの最新情報をご確認ください。