Skip to content

Commit 0f35487

Browse files
laurentftechclaude
andcommitted
Improve reconnection with Retry-After header support
- Add Retry-After header parsing (seconds and HTTP date formats) - Increase max reconnect delay to 5 minutes - Add jitter (±10%) to prevent thundering herd - Use 30s conservative default for 429 without Retry-After Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 7fddfdb commit 0f35487

File tree

1 file changed

+62
-2
lines changed

1 file changed

+62
-2
lines changed

Sources/NtfyClient.swift

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,11 @@ final class NtfyClient: NSObject, @unchecked Sendable {
6868
private var reconnectAttempts = 0
6969
private let maxReconnectAttempts = 10
7070
private let baseReconnectDelay: TimeInterval = 2.0
71+
private let maxReconnectDelay: TimeInterval = 300.0 // 5 minutes max
7172
private var reconnectTimer: Timer?
7273
private var isConnecting = false
7374
private var shouldReconnect = true
75+
private var retryAfterDelay: TimeInterval? // From Retry-After header
7476

7577
init(serverURL: String, topics: [String], authToken: String? = nil) {
7678
self.serverURL = serverURL
@@ -159,11 +161,27 @@ final class NtfyClient: NSObject, @unchecked Sendable {
159161
dataTask = nil
160162
isConnecting = false
161163

162-
let delay = min(baseReconnectDelay * pow(2.0, Double(reconnectAttempts)), 60.0)
164+
// Calculate delay with exponential backoff
165+
var delay: TimeInterval
166+
167+
if let retryAfter = retryAfterDelay {
168+
// Server told us exactly when to retry (Retry-After header)
169+
delay = retryAfter
170+
retryAfterDelay = nil // Clear for next time
171+
Log.info("Server requested retry after \(Int(delay)) seconds")
172+
} else {
173+
// Exponential backoff: 2s, 4s, 8s, 16s, 32s, 64s, 128s, 256s, 300s (capped)
174+
delay = min(baseReconnectDelay * pow(2.0, Double(reconnectAttempts)), maxReconnectDelay)
175+
}
176+
177+
// Add jitter (±10%) to prevent thundering herd
178+
let jitter = delay * Double.random(in: -0.1...0.1)
179+
delay += jitter
180+
163181
reconnectAttempts += 1
164182

165183
if reconnectAttempts <= maxReconnectAttempts {
166-
Log.info("Reconnecting in \(delay) seconds (attempt \(reconnectAttempts)/\(maxReconnectAttempts))...")
184+
Log.info("Reconnecting in \(String(format: "%.1f", delay)) seconds (attempt \(reconnectAttempts)/\(maxReconnectAttempts))...")
167185

168186
// Schedule timer on main thread to ensure RunLoop is active
169187
DispatchQueue.main.async { [weak self] in
@@ -184,6 +202,37 @@ final class NtfyClient: NSObject, @unchecked Sendable {
184202
}
185203
}
186204

205+
/// Parse Retry-After header value (can be seconds or HTTP date)
206+
private func parseRetryAfter(_ value: String) -> TimeInterval {
207+
// Try parsing as seconds first
208+
if let seconds = TimeInterval(value) {
209+
return max(seconds, 1.0) // At least 1 second
210+
}
211+
212+
// Try parsing as HTTP date (e.g., "Sun, 18 Jan 2026 23:59:59 GMT")
213+
let httpDateFormatter = DateFormatter()
214+
httpDateFormatter.locale = Locale(identifier: "en_US_POSIX")
215+
httpDateFormatter.timeZone = TimeZone(identifier: "GMT")
216+
217+
// RFC 7231 formats
218+
let formats = [
219+
"EEE, dd MMM yyyy HH:mm:ss zzz", // IMF-fixdate
220+
"EEEE, dd-MMM-yy HH:mm:ss zzz", // RFC 850
221+
"EEE MMM d HH:mm:ss yyyy" // ANSI C asctime()
222+
]
223+
224+
for format in formats {
225+
httpDateFormatter.dateFormat = format
226+
if let date = httpDateFormatter.date(from: value) {
227+
let delay = date.timeIntervalSinceNow
228+
return max(delay, 1.0) // At least 1 second, even if date is in past
229+
}
230+
}
231+
232+
// Fallback if parsing fails
233+
return 30.0
234+
}
235+
187236
private func processLine(_ line: String) {
188237
guard !line.isEmpty else { return }
189238

@@ -256,6 +305,17 @@ extension NtfyClient: URLSessionDataDelegate {
256305
completionHandler(.allow)
257306
} else {
258307
Log.error("Server returned status code: \(httpResponse.statusCode)")
308+
309+
// Handle rate limiting (429) - extract Retry-After header
310+
if httpResponse.statusCode == 429 {
311+
if let retryAfterString = httpResponse.value(forHTTPHeaderField: "Retry-After") {
312+
retryAfterDelay = parseRetryAfter(retryAfterString)
313+
} else {
314+
// No Retry-After header, use a conservative default for 429
315+
retryAfterDelay = 30.0
316+
}
317+
}
318+
259319
completionHandler(.cancel)
260320
}
261321
}

0 commit comments

Comments
 (0)