クラウド Mac の iOS シミュレータではメッセージ一覧が正常に見えても、実際のネットワークでは、一時的な切断後にステータス更新が 1 件抜けたり、同じ通知を 2 回処理したりすることがあります。SSE(Server-Sent Events、サーバーからクライアントへテキストイベントを継続的に送る仕組み)では、特に注意が必要です。HTTP リクエストが成功しても、クライアントが最後のイベントを処理し終えたとは限りません。ここでは、ローカルからのみ接続できるテストサーバーを使い、切断と再開を再現可能な手順で検証します。
再開時の取り決めを先に明確にする
SSE イベントは複数のフィールド行で構成され、空行で終わります。id はサーバーがイベントに付けるカーソルです。クライアントは再接続時、最後に処理を完了したイベントの ID を Last-Event-ID リクエストヘッダーに入れます。サーバーはそれを基に、後続のイベントを返します。
「ID を読み取った」ことと「イベントの処理を完了した」ことは別です。id: 2 と後続の data の間で接続が切れる場合もあります。この時点で ID 2 を保存してしまうと、再接続後は 3 から始まり、イベント 2 は失われます。空行まで受け取り、業務処理を完了してから、最後にカーソルを確定してください。処理でデータベースに書き込む場合は、イベント ID による重複排除も必要です。処理結果の確定とカーソルの保存の間にも、切断は起こり得ます。
テストの目的は、接続が絶対に切れないと証明することではありません。切断の前後で受け取るべきイベントの集合が一致し、各 ID が業務上の効果を高々 1 回しか生まないことを確認します。
制御できるローカルのイベントソースを起動する
クラウド Mac に sse_fixture.py を作成します。このスクリプトは 127.0.0.1 にのみバインドし、テスト用ポートを外部に公開しません。cut=1 を指定すると、イベントを 1 件送信したところでレスポンスを終了します。次のリクエストでは、サーバーがリクエストヘッダーを読み、続きのイベントから送信します。
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlsplit
EVENTS = [(1, "queued"), (2, "building"), (3, "finished")]
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
url = urlsplit(self.path)
if url.path != "/events":
self.send_error(404)
return
try:
last = int(self.headers.get("Last-Event-ID", "0"))
except ValueError:
self.send_error(400)
return
cut = parse_qs(url.query).get("cut") == ["1"]
self.send_response(200)
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.end_headers()
for event_id, value in EVENTS:
if event_id <= last:
continue
payload = f"id: {event_id}\ndata: {value}\n\n".encode()
try:
self.wfile.write(payload)
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError):
break
if cut:
break
HTTPServer(("127.0.0.1", 8765), Handler).serve_forever()
python3 sse_fixture.py を実行し、別のターミナルで 2 つのレスポンスを確認します。
curl -Ns 'http://127.0.0.1:8765/events?cut=1'
curl -Ns -H 'Last-Event-ID: 1' 'http://127.0.0.1:8765/events'
最初のレスポンスには ID 1 だけが含まれ、次のレスポンスには ID 2、3 が順に現れるはずです。ここでの正常終了は「ストリーム終了後に再リクエストする」動作のテストです。本番環境で起こる接続リセット、タイムアウト、途中までしか届かないイベントは、別途障害を注入して検証する必要があります。正常終了を 1 回確認しただけで、すべての切断ケースを検証したことにはなりません。
iOS 側ではイベントが完成してから確定する
テスト用クライアントでは、URLSession.bytes(for:) を使って行単位で読み取れます。以下のコードは、デプロイメントターゲットがこの API に対応していることを前提とします。扱うのは1 回の接続だけで、再試行のスケジュール管理は呼び出し側に任せます。空行が届いて初めてイベントを引き渡し、末尾に残った未完成のデータは破棄します。
import Foundation
struct StreamEvent {
let id: String
let data: String
}
func readEvents(after lastID: String?, url: URL) async throws -> [StreamEvent] {
var request = URLRequest(url: url)
if let lastID {
request.setValue(lastID, forHTTPHeaderField: "Last-Event-ID")
}
let (bytes, response) = try await URLSession.shared.bytes(for: request)
guard let http = response as? HTTPURLResponse,
http.statusCode == 200 else {
throw URLError(.badServerResponse)
}
var result: [StreamEvent] = []
var eventID: String?
var data: [String] = []
for try await line in bytes.lines {
if line.isEmpty {
if let eventID, !data.isEmpty {
result.append(StreamEvent(id: eventID, data: data.joined(separator: "\n")))
}
eventID = nil
data = []
} else if line.hasPrefix("id:") {
eventID = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)
} else if line.hasPrefix("data:") {
data.append(String(line.dropFirst(5)).dropFirst(
line.dropFirst(5).first == " " ? 1 : 0
).description)
}
}
return result
}
この最小限の例では複数行の data を保持し、ハートビート行やその他のフィールドは無視します。実運用のパーサーでは、ID のないイベント、空の ID、再試行の指示もプロトコルに従って扱う必要があります。ここではテスト結果を確認しやすいように配列を返していますが、無期限に動く本番用ストリームには向きません。長時間接続では受信しながら処理し、イベントをメモリにため続けないでください。
シミュレータで切断後の動作を 2 回に分けて確認する
まず Mac 上で curl を使い、サーバーに接続できることを確認します。次に iOS シミュレータから http://127.0.0.1:8765/events?cut=1 にリクエストします。シミュレータからは通常、動作中の Mac のローカルサービスにアクセスできます。一方、実機の 127.0.0.1 は実機自身を指すため、同じアドレスは使えません。アプリがデフォルトで平文 HTTP を禁止している場合は、デバッグビルドに限って範囲を絞ったローカルネットワーク用の例外を設定し、広範な例外をリリース構成に持ち込まないでください。
1 回目はイベント 1 を記録し、処理が成功してからカーソルを 1 に設定します。2 回目は cut=1 を外し、Last-Event-ID: 1 を付けて、受け取る ID がちょうど 2、3 であることを確認します。続いて、最初の業務処理を意図的に失敗させます。この場合、カーソルは以前の値のままでなければなりません。イベントを再受信した際は、ID で重複を排除するか、安全に再試行します。最後に読み取りタスクをキャンセルし、再接続タイマーもキャンセルされることを確認します。そうしないと、画面を閉じた後もバックグラウンドでリクエストが残る可能性があります。
| 確認項目 | 合格条件 |
|---|---|
| 完全なイベント | 終端の空行を受け取ってから業務処理に渡す |
| 切断後の再開 | 確定済み ID より後のイベントが順番に届く |
| 重複配信 | 同じ ID で業務上の効果が 2 回発生しない |
| 画面を閉じる | 読み取りタスクと実行待ちの再試行がともに停止する |
失敗の原因を適切な層で切り分ける
2 回目に最初からイベントが返る場合は、まず確定済みの ID がリクエストヘッダーに実際に入っているかを確認し、次にサーバーがカーソルによる絞り込みに対応しているかを調べます。すぐにシミュレータの問題と決めつけないでください。最後の 1 件だけが欠ける場合は、パーサーが「ストリームの終了」を誤ってイベントの区切りとして扱っていないかを重点的に確認します。再接続によって通知が重複する場合は、再試行間隔を延ばすだけでなく、業務処理側の重複排除を確認してください。
また、「接続に成功した」ことと「イベントの配信・処理に成功した」ことは区別する必要があります。サーバーがバイト列を送り終えて接続を閉じても、クライアントがローカルの状態に書き込めた証明にはなりません。クライアントがカーソルを保存できても、リモートの業務操作を安全に再試行できるとは限りません。イベント ID、処理結果、確定後のカーソルを同じテスト記録に残しておけば、次にパーサーや再試行ロジックを変更したとき、メッセージ一覧を目視するだけに頼らず、項目ごとに照合できます。
よくある質問
Last-Event-ID はいつ保存すべきですか?
イベント全体を受信し、処理に成功した後です。id 行を読んだ時点で保存すると、切断時に未処理のデータを飛ばすおそれがあります。
iOS シミュレータから 127.0.0.1 のサーバーに接続できますか?
通常は同じ Mac 上のローカルサービスに接続できます。先に curl でポートを確認してください。実機では同じループバックアドレスを使えません。
実機で試してみませんか?
専有 Mac mini をレンタルして、ビルドを実行
M4搭載の物理マシン2種類から選び、用途に合わせて利用期間とロケーションを指定できます。利用状況は管理画面に表示される最新情報をご確認ください。