import Foundation
/// A best-effort retry queue in front of `MailboxClient.deposit`: rate limits,
/// storage pressure and network failures back off (`min(1000·2^n, 300000)` ms),
/// oversize payloads are dropped, auth failures are dropped after the client's
/// own single re-mint. The host app can snapshot and restore the queue.
public actor MailboxOutbox {
public struct Item: Sendable, Codable, Equatable {
public var room: String
public var payload: Data
public var attempts: Int = 0
public init(room: String, payload: Data, attempts: Int = 0) {
self.room = room
self.payload = payload
self.attempts = attempts
}
}
public enum Outcome: Sendable, Equatable {
case deposited(DepositResult)
case dropped(Item, reason: String)
case deferred(Item, retryAfter: Duration)
}
private let client: MailboxClient
private let clock: any P2PClock
private var queue: [Item] = []
private var flushing = false
private let onOutcome: (@Sendable (Outcome) -> Void)?
public init(client: MailboxClient, clock: any P2PClock = SystemClock(), onOutcome: (@Sendable (Outcome) -> Void)? = nil) {
self.client = client
self.clock = clock
self.onOutcome = onOutcome
}
public var pending: [Item] { queue }
public func enqueue(room: String, payload: Data) {
queue.append(Item(room: room, payload: payload))
}
/// Restores a previously snapshotted queue (appended to whatever is pending).
public func restore(_ items: [Item]) {
queue.append(contentsOf: items)
}
/// Delivers the queue in order. Returns when every item is deposited or
/// dropped; a retryable failure sleeps its backoff and continues.
public func flush() async {
guard !flushing else { return }
flushing = true
defer { flushing = false }
while var item = queue.first {
if Task.isCancelled { return }
do {
let result = try await client.deposit(room: item.room, item.payload)
queue.removeFirst()
onOutcome?(.deposited(result))
} catch let error as P2PError {
switch error {
case .http(let status, _, _) where status == 413:
queue.removeFirst()
onOutcome?(.dropped(item, reason: "message_too_large"))
case .http(let status, _, _) where status == 401 || status == 403:
queue.removeFirst()
onOutcome?(.dropped(item, reason: "unauthorized"))
case .config(let why):
queue.removeFirst()
onOutcome?(.dropped(item, reason: why))
default:
// 429, 507, 5xx, transport: back off and retry the same item
let delay = Backoff.mailbox(attempt: item.attempts)
item.attempts += 1
queue[0] = item
onOutcome?(.deferred(item, retryAfter: delay))
guard (try? await clock.sleep(for: delay)) != nil else { return }
}
} catch {
let delay = Backoff.mailbox(attempt: item.attempts)
item.attempts += 1
queue[0] = item
onOutcome?(.deferred(item, retryAfter: delay))
guard (try? await clock.sleep(for: delay)) != nil else { return }
}
}
}
}