Files
hapi/ios/Packages/HapiKit/Sources/HapiClient/SSE/ReconnectPolicy.swift
T
weishu 15f48c9c65 feat(ios): SSE client + reconnect state machine (A-M1c)
HapiClient/SSE per docs/api/client-contract/sse.md and the web reference
(web/src/hooks/useSSE.ts):

- SSELineParser: incremental frame parser (LF/CRLF, multi-line data,
  comments, retry ignored); per-block ids only — sticky-cursor semantics
  live in the caller so id-less heartbeats can never blank the cursor.
- ReconnectPolicy + SSETimings: 0s first retry, 1s x2 capped at 30s,
  300s slow ceiling after 8 attempts, 0-500ms injectable jitter; connect
  timeout 10s, staleness 90s, foreground-resume 45s, watchdog tick 10s.
- SSETransport protocol + URLSessionSSETransport (URLSession.bytes with
  dedicated config: 300s idle timeout, 7d resource, no waitsForConnectivity).
- actor SSEClient: idle/connecting/connected/backoff/suspended machine;
  handshake-gated connected state surfacing resume ok|gap; per-subscription
  cursor sent as ?lastEventId, advanced only after consumer yield
  (at-least-once); one free token-refresh retry per connect cycle on 401;
  suspend defers retries, resume applies the 45s staleness check;
  acceptEncodingIdentity escape hatch (gzip streaming verification TODO).
- NetworkPathObserving + NWPathObserver: path change while connected is
  treated as a transport error (immediate reconnect).
- swift-testing suite on a fake transport + manual clock: parser edge
  cases, exact backoff schedule + seeded jitter bounds, handshake gating,
  ok/gap verdicts, cursor replay + isolation, heartbeat vs watchdog,
  90s staleness, connect timeout, suspend/resume, event ordering,
  unknown-type passthrough, 401 bypass cap, path-change reconnect.
2026-08-17 15:28:14 +08:00

77 lines
3.3 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
/// Normative SSE timing constants, in milliseconds.
///
/// Values come from `docs/api/client-contract/sse.md` ("Reconnect policy")
/// and the web reference client (`web/src/hooks/useSSE.ts`).
public enum SSETimings {
/// The hub emits a heartbeat frame every 30 s (informational).
public static let heartbeatIntervalMs = 30_000
/// No frames of any kind for 90 s ⇒ tear down and reconnect.
public static let stalenessThresholdMs = 90_000
/// On app-foreground, reconnect immediately when the last frame is older
/// than this. One missed heartbeat interval after an OS suspend is
/// already enough to distrust the socket (no FIN/RST ever surfaces).
public static let foregroundResumeStalenessMs = 45_000
/// Staleness check interval. Checks are skipped while suspended.
public static let watchdogTickMs = 10_000
/// An attempt that has not reached OPEN within 10 s is likely hung on a
/// dead pooled socket — abandon it and retry on a fresh connection.
public static let connectTimeoutMs = 10_000
}
/// Pure backoff schedule for SSE reconnects.
///
/// Delay for attempt `n` (0-based, reset to 0 on every successful open):
///
/// - attempt 0 → 0 ms (the first retry is immediate; backoff is for
/// *repeated* failures, not the initial recovery),
/// - attempt n ≥ 1 → `min(cap, base × 2^(n-1))`,
/// - cap is 30 s, widening to 300 s once `n ≥ 8` (a hub that stays
/// unreachable is usually down for hours, and every retry through a relay
/// costs a TLS handshake),
/// - plus uniform jitter of 0–500 ms on every delay, immediate ones included.
///
/// Jitter is injectable: the generic `delayMs(forAttempt:using:)` draws from
/// any `RandomNumberGenerator` so tests can seed it, and callers that manage
/// randomness themselves can add `exponentialDelayMs(forAttempt:)` + their
/// own draw from `jitterRangeMs` (this is what `SSEClient` does).
public struct ReconnectPolicy: Equatable, Sendable {
public var baseDelayMs: Int
public var maxDelayMs: Int
public var slowMaxDelayMs: Int
public var slowAfterAttempts: Int
public var jitterRangeMs: ClosedRange<Int>
public init(
baseDelayMs: Int = 1_000,
maxDelayMs: Int = 30_000,
slowMaxDelayMs: Int = 300_000,
slowAfterAttempts: Int = 8,
jitterRangeMs: ClosedRange<Int> = 0...500
) {
self.baseDelayMs = baseDelayMs
self.maxDelayMs = maxDelayMs
self.slowMaxDelayMs = slowMaxDelayMs
self.slowAfterAttempts = slowAfterAttempts
self.jitterRangeMs = jitterRangeMs
}
/// The deterministic part of the schedule, without jitter.
public func exponentialDelayMs(forAttempt attempt: Int) -> Int {
guard attempt >= 1 else {
return 0
}
let cap = attempt >= slowAfterAttempts ? slowMaxDelayMs : maxDelayMs
// Clamp the exponent so the shift can never overflow; 2^30 × base is
// already far past every cap.
let exponent = min(attempt - 1, 30)
return min(cap, baseDelayMs << exponent)
}
/// Full delay including a jitter draw from the supplied generator.
public func delayMs<R: RandomNumberGenerator>(forAttempt attempt: Int, using rng: inout R) -> Int {
exponentialDelayMs(forAttempt: attempt) + Int.random(in: jitterRangeMs, using: &rng)
}
}