Skip to content

Commit 575953c

Browse files
Added support for retrying on known push service errors
1 parent 8c5cef4 commit 575953c

File tree

3 files changed

+136
-14
lines changed

3 files changed

+136
-14
lines changed

Sources/WebPush/WebPushManager.swift

+90-9
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ public actor WebPushManager: Sendable {
266266
case .httpClient(let httpClient, let privateKeyProvider):
267267
var logger = logger ?? backgroundActivityLogger
268268
logger[metadataKey: "message"] = ".data(\(message.base64URLEncodedString()))"
269-
try await execute(
269+
try await encryptPushMessage(
270270
httpClient: httpClient,
271271
privateKeyProvider: privateKeyProvider,
272272
data: message,
@@ -458,7 +458,7 @@ public actor WebPushManager: Sendable {
458458
logger[metadataKey: "message"] = "\(message)"
459459
switch executor {
460460
case .httpClient(let httpClient, let privateKeyProvider):
461-
try await execute(
461+
try await encryptPushMessage(
462462
httpClient: httpClient,
463463
privateKeyProvider: privateKeyProvider,
464464
data: message.data,
@@ -482,14 +482,14 @@ public actor WebPushManager: Sendable {
482482
/// Send a message via HTTP Client, mocked or otherwise, encrypting it on the way.
483483
/// - Parameters:
484484
/// - httpClient: The protocol implementing HTTP-like functionality.
485-
/// - applicationServerECDHPrivateKey: The private key to use for the key exchange. If nil, one will be generated.
485+
/// - privateKeyProvider: The private key to use for the key exchange. If nil, one will be generated.
486486
/// - message: The message to send as raw data.
487487
/// - subscriber: The subscriber to sign the message against.
488488
/// - deduplicationTopic: The topic to use when deduplicating messages stored on a Push Service.
489489
/// - expiration: The expiration of the message.
490490
/// - urgency: The urgency of the message.
491491
/// - logger: The logger to use for status updates.
492-
func execute(
492+
func encryptPushMessage(
493493
httpClient: some HTTPClientProtocol,
494494
privateKeyProvider: Executor.KeyProvider,
495495
data message: some DataProtocol,
@@ -499,6 +499,9 @@ public actor WebPushManager: Sendable {
499499
urgency: Urgency,
500500
logger: Logger
501501
) async throws {
502+
let clock = ContinuousClock()
503+
let startTime = clock.now
504+
502505
var logger = logger
503506
logger[metadataKey: "subscriber"] = [
504507
"vapidKeyID" : "\(subscriber.vapidKeyID)",
@@ -508,6 +511,11 @@ public actor WebPushManager: Sendable {
508511
logger[metadataKey: "urgency"] = "\(urgency)"
509512
logger[metadataKey: "origin"] = "\(subscriber.endpoint.origin)"
510513
logger[metadataKey: "messageSize"] = "\(message.count)"
514+
logger[metadataKey: "topic"] = "\(topic?.description ?? "nil")"
515+
516+
/// Force a random topic so any retries don't get duplicated.
517+
// let topic = topic ?? Topic()
518+
// logger[metadataKey: "resolvedTopic"] = "\(topic)"
511519
logger.trace("Sending notification")
512520

513521
guard let signingKey = vapidKeyLookup[subscriber.vapidKeyID] else {
@@ -589,8 +597,60 @@ public actor WebPushManager: Sendable {
589597
logger.warning("The message expiration should be less than \(Expiration.recommendedMaximum) seconds.")
590598
}
591599

600+
let expirationDeadline: ContinuousClock.Instant? = if expiration == .dropIfUndeliverable || expiration == .recommendedMaximum {
601+
nil
602+
} else {
603+
startTime.advanced(by: .seconds(max(expiration, .dropIfUndeliverable).seconds))
604+
}
605+
606+
let retryDurations: [Duration] = [.milliseconds(500), .seconds(2), .seconds(10)]
607+
608+
/// Build and send the request.
609+
try await executeRequest(
610+
httpClient: httpClient,
611+
endpointURLString: subscriber.endpoint.absoluteURL.absoluteString,
612+
authorization: authorization,
613+
expiration: expiration,
614+
urgency: urgency,
615+
topic: topic,
616+
requestContent: requestContent,
617+
clock: clock,
618+
expirationDeadline: expirationDeadline,
619+
retryDurations: retryDurations[...],
620+
logger: logger
621+
)
622+
}
623+
624+
func executeRequest(
625+
httpClient: some HTTPClientProtocol,
626+
endpointURLString: String,
627+
authorization: String,
628+
expiration: Expiration,
629+
urgency: Urgency,
630+
topic: Topic?,
631+
requestContent: [UInt8],
632+
clock: ContinuousClock,
633+
expirationDeadline: ContinuousClock.Instant?,
634+
retryDurations: ArraySlice<Duration>,
635+
logger: Logger
636+
) async throws {
637+
var logger = logger
638+
logger[metadataKey: "retryDurationsRemaining"] = .array(retryDurations.map { "\($0.components.seconds)seconds" })
639+
640+
var expiration = expiration
641+
var requestDeadline = NIODeadline.distantFuture
642+
if let expirationDeadline {
643+
let remainingDuration = clock.now.duration(to: expirationDeadline)
644+
expiration = Expiration(seconds: Int(remainingDuration.components.seconds))
645+
requestDeadline = .now() + TimeAmount(remainingDuration)
646+
logger[metadataKey: "resolvedExpiration"] = "\(expiration)"
647+
logger[metadataKey: "expirationDeadline"] = "\(expirationDeadline)"
648+
}
649+
650+
logger.trace("Preparing to send push message.")
651+
592652
/// Add the VAPID authorization and corrent content encoding and type.
593-
var request = HTTPClientRequest(url: subscriber.endpoint.absoluteURL.absoluteString)
653+
var request = HTTPClientRequest(url: endpointURLString)
594654
request.method = .POST
595655
request.headers.add(name: "Authorization", value: authorization)
596656
request.headers.add(name: "Content-Encoding", value: "aes128gcm")
@@ -603,10 +663,10 @@ public actor WebPushManager: Sendable {
603663
request.body = .bytes(ByteBuffer(bytes: requestContent))
604664

605665
/// Send the request to the push endpoint.
606-
let response = try await httpClient.execute(request, deadline: .distantFuture, logger: logger)
666+
let response = try await httpClient.execute(request, deadline: requestDeadline, logger: logger)
607667
logger[metadataKey: "response"] = "\(response)"
608668
logger[metadataKey: "statusCode"] = "\(response.status)"
609-
logger.trace("Sent notification")
669+
logger.trace("Sent push message.")
610670

611671
/// Check the response and determine if the subscription should be removed from our records, or if the notification should just be skipped.
612672
switch response.status {
@@ -615,10 +675,31 @@ public actor WebPushManager: Sendable {
615675
case .payloadTooLarge:
616676
logger.error("The encrypted payload was too large and was rejected by the push service.")
617677
throw MessageTooLargeError()
618-
// TODO: 429 too many requests, 500 internal server error, 503 server shutting down - check config and perform a retry after a delay?
678+
case .tooManyRequests, .internalServerError, .serviceUnavailable:
679+
/// 429 too many requests, 500 internal server error, 503 server shutting down are all opportunities to just retry if we can, otherwise throw the error
680+
guard let retryDuration = retryDurations.first else {
681+
logger.trace("Message was rejected, no retries remaining.")
682+
throw PushServiceError(response: response)
683+
}
684+
logger.trace("Message was rejected, but can be retried.")
685+
686+
try await Task.sleep(for: retryDuration)
687+
try await executeRequest(
688+
httpClient: httpClient,
689+
endpointURLString: endpointURLString,
690+
authorization: authorization,
691+
expiration: expiration,
692+
urgency: urgency,
693+
topic: topic,
694+
requestContent: requestContent,
695+
clock: clock,
696+
expirationDeadline: expirationDeadline,
697+
retryDurations: retryDurations.dropFirst(),
698+
logger: logger
699+
)
619700
default: throw PushServiceError(response: response)
620701
}
621-
logger.trace("Successfully sent notification")
702+
logger.trace("Successfully sent push message.")
622703
}
623704
}
624705

Tests/WebPushTests/Helpers/MockHTTPClient.swift

+8-4
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,22 @@ import NIOCore
1212
@testable import WebPush
1313

1414
actor MockHTTPClient: HTTPClientProtocol {
15-
var processRequest: (HTTPClientRequest) async throws -> HTTPClientResponse
15+
typealias Handler = (HTTPClientRequest) async throws -> HTTPClientResponse
16+
var handlers: [Handler]
17+
var index = 0
1618

17-
init(_ processRequest: @escaping (HTTPClientRequest) async throws -> HTTPClientResponse) {
18-
self.processRequest = processRequest
19+
init(_ requestHandler: Handler...) {
20+
self.handlers = requestHandler
1921
}
2022

2123
func execute(
2224
_ request: HTTPClientRequest,
2325
deadline: NIODeadline,
2426
logger: Logger?
2527
) async throws -> HTTPClientResponse {
26-
try await processRequest(request)
28+
let currentHandler = handlers[index]
29+
index = (index + 1) % handlers.count
30+
return try await currentHandler(request)
2731
}
2832

2933
nonisolated func syncShutdown() throws {}

Tests/WebPushTests/WebPushManagerTests.swift

+38-1
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,43 @@ struct WebPushManagerTests {
591591
}
592592
}
593593

594+
@Test func sendMessageSucceedsAfterRetries() async throws {
595+
try await confirmation(expectedCount: 1) { requestWasMade in
596+
let manager = WebPushManager(
597+
vapidConfiguration: .mockedConfiguration,
598+
backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }),
599+
executor: .httpClient(MockHTTPClient(
600+
{ _ in HTTPClientResponse(status: .tooManyRequests) },
601+
{ _ in HTTPClientResponse(status: .internalServerError) },
602+
{ _ in HTTPClientResponse(status: .serviceUnavailable) },
603+
{ _ in
604+
requestWasMade()
605+
return HTTPClientResponse(status: .created)
606+
}
607+
))
608+
)
609+
610+
try await manager.send(string: "hello", to: .mockedSubscriber())
611+
}
612+
}
613+
614+
@Test func sendMessageFailsDespiteRetries() async throws {
615+
await confirmation(expectedCount: 4) { requestWasMade in
616+
let manager = WebPushManager(
617+
vapidConfiguration: .mockedConfiguration,
618+
backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }),
619+
executor: .httpClient(MockHTTPClient({ request in
620+
requestWasMade()
621+
return HTTPClientResponse(status: .serviceUnavailable)
622+
}))
623+
)
624+
625+
await #expect(throws: PushServiceError(response: HTTPClientResponse(status: .serviceUnavailable))) {
626+
try await manager.send(string: "hello", to: .mockedSubscriber())
627+
}
628+
}
629+
}
630+
594631
@Test func sendMessageToSubscriberWithInvalidVAPIDKey() async throws {
595632
await confirmation(expectedCount: 0) { requestWasMade in
596633
var subscriber = Subscriber.mockedSubscriber
@@ -770,7 +807,7 @@ struct WebPushManagerTests {
770807
backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }),
771808
executor: .httpClient(MockHTTPClient({ request in
772809
requestWasMade()
773-
return HTTPClientResponse(status: .internalServerError)
810+
return HTTPClientResponse(status: .badRequest)
774811
}))
775812
)
776813

0 commit comments

Comments
 (0)