@@ -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