Skip to content

Commit f94bc0e

Browse files
Added network configuration settings
1 parent 35104f4 commit f94bc0e

File tree

3 files changed

+165
-15
lines changed

3 files changed

+165
-15
lines changed

Sources/WebPush/WebPushManager.swift

+91-14
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ import ServiceLifecycle
2222
/// The manager should be installed as a service to wait for any in-flight messages to be sent before your application server shuts down.
2323
public actor WebPushManager: Sendable {
2424
/// The VAPID configuration used when configuring the manager.
25-
public let vapidConfiguration: VAPID.Configuration
25+
public nonisolated let vapidConfiguration: VAPID.Configuration
26+
27+
/// The network configuration used when configuring the manager.
28+
public nonisolated let networkConfiguration: NetworkConfiguration
2629

2730
/// The maximum encrypted payload size guaranteed by the spec.
2831
///
@@ -57,18 +60,26 @@ public actor WebPushManager: Sendable {
5760
/// - Note: On debug builds, this initializer will assert if VAPID authorization header expiration times are inconsistently set.
5861
/// - Parameters:
5962
/// - vapidConfiguration: The VAPID configuration to use when identifying the application server.
63+
/// - networkConfiguration: The network configuration used when configuring the manager.
6064
/// - backgroundActivityLogger: The logger to use for misconfiguration and background activity. By default, a print logger will be used, and if set to `nil`, a no-op logger will be used in release builds. When running in a server environment, your shared logger should be used instead giving you full control of logging and metadata.
6165
/// - eventLoopGroupProvider: The event loop to use for the internal HTTP client.
6266
public init(
6367
vapidConfiguration: VAPID.Configuration,
64-
// TODO: Add networkConfiguration for proxy, number of simultaneous pushes, etc…
68+
networkConfiguration: NetworkConfiguration = .default,
6569
backgroundActivityLogger: Logger? = .defaultWebPushPrintLogger,
6670
eventLoopGroupProvider: NIOEventLoopGroupProvider = .shared(.singletonMultiThreadedEventLoopGroup)
6771
) {
6872
let backgroundActivityLogger = backgroundActivityLogger ?? .defaultWebPushNoOpLogger
6973

7074
var httpClientConfiguration = HTTPClient.Configuration()
7175
httpClientConfiguration.httpVersion = .automatic
76+
httpClientConfiguration.timeout.connect = TimeAmount(networkConfiguration.connectionTimeout)
77+
httpClientConfiguration.timeout.read = networkConfiguration.confirmationTimeout.map { TimeAmount($0) }
78+
httpClientConfiguration.timeout.write = networkConfiguration.sendTimeout.map { TimeAmount($0) }
79+
httpClientConfiguration.proxy = networkConfiguration.httpProxy
80+
/// Apple's push service recomments leaving the connection open as long as possible. We are picking 12 hours here.
81+
/// - SeeAlso: [Sending notification requests to APNs: Follow best practices while sending push notifications with APNs](https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns#Follow-best-practices-while-sending-push-notifications-with-APNs)
82+
httpClientConfiguration.connectionPool.idleTimeout = .hours(12)
7283

7384
let executor: Executor = switch eventLoopGroupProvider {
7485
case .shared(let eventLoopGroup):
@@ -86,6 +97,7 @@ public actor WebPushManager: Sendable {
8697

8798
self.init(
8899
vapidConfiguration: vapidConfiguration,
100+
networkConfiguration: networkConfiguration,
89101
backgroundActivityLogger: backgroundActivityLogger,
90102
executor: executor
91103
)
@@ -96,11 +108,12 @@ public actor WebPushManager: Sendable {
96108
/// Note that this must be called before ``run()`` is called or the client's syncShutdown won't be called.
97109
/// - Parameters:
98110
/// - vapidConfiguration: The VAPID configuration to use when identifying the application server.
111+
/// - networkConfiguration: The network configuration used when configuring the manager.
99112
/// - backgroundActivityLogger: The logger to use for misconfiguration and background activity.
100113
/// - executor: The executor to use when sending push messages.
101114
package init(
102115
vapidConfiguration: VAPID.Configuration,
103-
// TODO: Add networkConfiguration for proxy, number of simultaneous pushes, etc…
116+
networkConfiguration: NetworkConfiguration = .default,
104117
backgroundActivityLogger: Logger,
105118
executor: Executor
106119
) {
@@ -125,6 +138,7 @@ public actor WebPushManager: Sendable {
125138
precondition(!vapidConfiguration.keys.isEmpty, "VAPID.Configuration must have keys specified. Please report this as a bug with reproduction steps if encountered: https://github.com/mochidev/swift-webpush/issues.")
126139

127140
self.vapidConfiguration = vapidConfiguration
141+
self.networkConfiguration = networkConfiguration
128142
let allKeys = vapidConfiguration.keys + Array(vapidConfiguration.deprecatedKeys ?? [])
129143
self.vapidKeyLookup = Dictionary(
130144
allKeys.map { ($0.id, $0) },
@@ -513,9 +527,13 @@ public actor WebPushManager: Sendable {
513527
logger[metadataKey: "messageSize"] = "\(message.count)"
514528
logger[metadataKey: "topic"] = "\(topic?.description ?? "nil")"
515529

516-
/// Force a random topic so any retries don't get duplicated.
517-
// let topic = topic ?? Topic()
518-
// logger[metadataKey: "resolvedTopic"] = "\(topic)"
530+
/// Force a random topic so any retries don't get duplicated when the option is set.
531+
var topic = topic
532+
if networkConfiguration.alwaysResolveTopics {
533+
let resolvedTopic = topic ?? Topic()
534+
logger[metadataKey: "resolvedTopic"] = "\(resolvedTopic)"
535+
topic = resolvedTopic
536+
}
519537
logger.trace("Sending notification")
520538

521539
guard let signingKey = vapidKeyLookup[subscriber.vapidKeyID] else {
@@ -603,8 +621,6 @@ public actor WebPushManager: Sendable {
603621
startTime.advanced(by: .seconds(max(expiration, .dropIfUndeliverable).seconds))
604622
}
605623

606-
let retryDurations: [Duration] = [.milliseconds(500), .seconds(2), .seconds(10)]
607-
608624
/// Build and send the request.
609625
try await executeRequest(
610626
httpClient: httpClient,
@@ -616,7 +632,7 @@ public actor WebPushManager: Sendable {
616632
requestContent: requestContent,
617633
clock: clock,
618634
expirationDeadline: expirationDeadline,
619-
retryDurations: retryDurations[...],
635+
retryIntervals: networkConfiguration.retryIntervals[...],
620636
logger: logger
621637
)
622638
}
@@ -631,11 +647,11 @@ public actor WebPushManager: Sendable {
631647
requestContent: [UInt8],
632648
clock: ContinuousClock,
633649
expirationDeadline: ContinuousClock.Instant?,
634-
retryDurations: ArraySlice<Duration>,
650+
retryIntervals: ArraySlice<Duration>,
635651
logger: Logger
636652
) async throws {
637653
var logger = logger
638-
logger[metadataKey: "retryDurationsRemaining"] = .array(retryDurations.map { "\($0.components.seconds)seconds" })
654+
logger[metadataKey: "retryDurationsRemaining"] = .array(retryIntervals.map { "\($0.components.seconds)seconds" })
639655

640656
var expiration = expiration
641657
var requestDeadline = NIODeadline.distantFuture
@@ -677,13 +693,13 @@ public actor WebPushManager: Sendable {
677693
throw MessageTooLargeError()
678694
case .tooManyRequests, .internalServerError, .serviceUnavailable:
679695
/// 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 {
696+
guard let retryInterval = retryIntervals.first else {
681697
logger.trace("Message was rejected, no retries remaining.")
682698
throw PushServiceError(response: response)
683699
}
684700
logger.trace("Message was rejected, but can be retried.")
685701

686-
try await Task.sleep(for: retryDuration)
702+
try await Task.sleep(for: retryInterval)
687703
try await executeRequest(
688704
httpClient: httpClient,
689705
endpointURLString: endpointURLString,
@@ -694,7 +710,7 @@ public actor WebPushManager: Sendable {
694710
requestContent: requestContent,
695711
clock: clock,
696712
expirationDeadline: expirationDeadline,
697-
retryDurations: retryDurations.dropFirst(),
713+
retryIntervals: retryIntervals.dropFirst(),
698714
logger: logger
699715
)
700716
default: throw PushServiceError(response: response)
@@ -876,6 +892,67 @@ extension WebPushManager.Urgency: Codable {
876892
}
877893
}
878894

895+
extension WebPushManager {
896+
/// The network configuration for a web push manager.
897+
public struct NetworkConfiguration: Hashable, Sendable {
898+
/// A list of intervals to wait between automatic retries.
899+
///
900+
/// Only some push service errors can safely be automatically retried. When one such error is encountered, this list is used to wait a set amount of time after a compatible failure, then perform a retry, adjusting expiration values as needed.
901+
///
902+
/// Specify `[]` to disable retries.
903+
public var retryIntervals: [Duration]
904+
905+
/// A flag to automatically generate a random `Topic` to prevent messages that are automatically retried from being delivered twice.
906+
///
907+
/// This is usually not necessary for a compliant push service, but can be turned on if you are experiencing the same message being delivered twice when a retry occurs.
908+
public var alwaysResolveTopics: Bool
909+
910+
/// A timeout before a connection is dropped.
911+
public var connectionTimeout: Duration
912+
913+
/// A timeout before we abandon the connection due to messages not being sent.
914+
///
915+
/// If `nil`, no timeout will be used.
916+
public var sendTimeout: Duration?
917+
918+
/// A timeout before we abondon the connection due to the push service not sending back acknowledgement a message was received.
919+
///
920+
/// If `nil`, no timeout will be used.
921+
public var confirmationTimeout: Duration?
922+
923+
/// An HTTP proxy to use when communicating to a push service.
924+
///
925+
/// If `nil`, no proxy will be used.
926+
public var httpProxy: HTTPClient.Configuration.Proxy?
927+
928+
/// Initialize a new network configuration.
929+
/// - Parameters:
930+
/// - retryIntervals: A list of intervals to wait between automatic retries before giving up. Defaults to a maximum of three retries.
931+
/// - alwaysResolveTopics: A flag to automatically generate a random `Topic` to prevent messages that are automatically retried from being delivered twice. Defaults to `false`.
932+
/// - connectionTimeout: A timeout before a connection is dropped. Defaults to 10 seconds
933+
/// - sendTimeout: A timeout before we abandon the connection due to messages not being sent. Defaults to no timeout.
934+
/// - confirmationTimeout: A timeout before we abondon the connection due to the push service not sending back acknowledgement a message was received. Defaults to no timeout.
935+
/// - httpProxy: An HTTP proxy to use when communicating to a push service. Defaults to no proxy.
936+
public init(
937+
retryIntervals: [Duration] = [.milliseconds(500), .seconds(2), .seconds(10)],
938+
alwaysResolveTopics: Bool = false,
939+
connectionTimeout: Duration? = nil,
940+
sendTimeout: Duration? = nil,
941+
confirmationTimeout: Duration? = nil,
942+
httpProxy: HTTPClient.Configuration.Proxy? = nil
943+
) {
944+
self.retryIntervals = retryIntervals
945+
self.alwaysResolveTopics = alwaysResolveTopics
946+
self.connectionTimeout = connectionTimeout ?? .seconds(10)
947+
self.sendTimeout = sendTimeout
948+
self.confirmationTimeout = confirmationTimeout
949+
self.httpProxy = httpProxy
950+
}
951+
952+
public static let `default` = NetworkConfiguration()
953+
}
954+
}
955+
879956
// MARK: - Package Types
880957

881958
extension WebPushManager {

Tests/WebPushTests/Helpers/MockHTTPClient.swift

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ actor MockHTTPClient: HTTPClientProtocol {
2727
) async throws -> HTTPClientResponse {
2828
let currentHandler = handlers[index]
2929
index = (index + 1) % handlers.count
30+
guard deadline >= .now() else { throw HTTPClientError.deadlineExceeded }
3031
return try await currentHandler(request)
3132
}
3233

Tests/WebPushTests/WebPushManagerTests.swift

+73-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
// Copyright © 2024 Mochi Development, Inc. All rights reserved.
77
//
88

9-
import AsyncHTTPClient
9+
@testable import AsyncHTTPClient
1010
@preconcurrency import Crypto
1111
import Foundation
1212
import Logging
@@ -109,6 +109,51 @@ struct WebPushManagerTests {
109109
let manager = WebPushManager(vapidConfiguration: configuration)
110110
#expect(await manager.vapidKeyLookup == [.mockedKeyID1 : .mockedKey1])
111111
}
112+
113+
@Test func defaultNetworkConfiguration() async throws {
114+
let manager = WebPushManager(vapidConfiguration: .mockedConfiguration)
115+
116+
#expect(manager.networkConfiguration.retryIntervals == [.milliseconds(500), .seconds(2), .seconds(10)])
117+
#expect(manager.networkConfiguration.alwaysResolveTopics == false)
118+
119+
if case .httpClient(let httpClient, _) = await manager.executor, let httpClient = httpClient as? HTTPClient {
120+
#expect(httpClient.configuration.httpVersion == .automatic)
121+
#expect(httpClient.configuration.timeout.connect == .seconds(10))
122+
#expect(httpClient.configuration.timeout.write == nil)
123+
#expect(httpClient.configuration.timeout.read == nil)
124+
#expect(httpClient.configuration.proxy == nil)
125+
} else {
126+
Issue.record("No HTTP client")
127+
}
128+
}
129+
130+
@Test func customNetworkConfiguration() async throws {
131+
var networkConfiguration = WebPushManager.NetworkConfiguration()
132+
networkConfiguration.retryIntervals = []
133+
networkConfiguration.alwaysResolveTopics = true
134+
networkConfiguration.connectionTimeout = .seconds(20)
135+
networkConfiguration.sendTimeout = .seconds(30)
136+
networkConfiguration.confirmationTimeout = .seconds(40)
137+
networkConfiguration.httpProxy = .server(host: "https://example.com", port: 8080)
138+
let manager = WebPushManager(
139+
vapidConfiguration: .mockedConfiguration,
140+
networkConfiguration: networkConfiguration
141+
)
142+
143+
#expect(manager.networkConfiguration.retryIntervals == [])
144+
#expect(manager.networkConfiguration.alwaysResolveTopics == true)
145+
146+
if case .httpClient(let httpClient, _) = await manager.executor, let httpClient = httpClient as? HTTPClient {
147+
#expect(httpClient.configuration.httpVersion == .automatic)
148+
#expect(httpClient.configuration.timeout.connect == .seconds(20))
149+
#expect(httpClient.configuration.timeout.write == .seconds(30))
150+
#expect(httpClient.configuration.timeout.read == .seconds(40))
151+
#expect(httpClient.configuration.proxy?.host == "https://example.com")
152+
#expect(httpClient.configuration.proxy?.port == 8080)
153+
} else {
154+
Issue.record("No HTTP client")
155+
}
156+
}
112157
}
113158

114159
@Suite("VAPID Key Retrieval") struct VAPIDKeyRetrieval {
@@ -403,6 +448,7 @@ struct WebPushManagerTests {
403448

404449
let manager = WebPushManager(
405450
vapidConfiguration: vapidConfiguration,
451+
networkConfiguration: .init(alwaysResolveTopics: true),
406452
backgroundActivityLogger: logger,
407453
executor: .httpClient(MockHTTPClient({ request in
408454
try validateAuthotizationHeader(
@@ -593,8 +639,11 @@ struct WebPushManagerTests {
593639

594640
@Test func sendMessageSucceedsAfterRetries() async throws {
595641
try await confirmation(expectedCount: 1) { requestWasMade in
642+
var networkConfiguration = WebPushManager.NetworkConfiguration()
643+
networkConfiguration.retryIntervals = [.seconds(0), .seconds(0), .seconds(0)]
596644
let manager = WebPushManager(
597645
vapidConfiguration: .mockedConfiguration,
646+
networkConfiguration: networkConfiguration,
598647
backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }),
599648
executor: .httpClient(MockHTTPClient(
600649
{ _ in HTTPClientResponse(status: .tooManyRequests) },
@@ -613,8 +662,11 @@ struct WebPushManagerTests {
613662

614663
@Test func sendMessageFailsDespiteRetries() async throws {
615664
await confirmation(expectedCount: 4) { requestWasMade in
665+
var networkConfiguration = WebPushManager.NetworkConfiguration()
666+
networkConfiguration.retryIntervals = [.seconds(0), .seconds(0), .seconds(0)]
616667
let manager = WebPushManager(
617668
vapidConfiguration: .mockedConfiguration,
669+
networkConfiguration: networkConfiguration,
618670
backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }),
619671
executor: .httpClient(MockHTTPClient({ request in
620672
requestWasMade()
@@ -628,6 +680,26 @@ struct WebPushManagerTests {
628680
}
629681
}
630682

683+
@Test func sendMessageFailsDespiteRetriesDueToExpiration() async throws {
684+
var networkConfiguration = WebPushManager.NetworkConfiguration()
685+
networkConfiguration.retryIntervals = [.seconds(2), .seconds(2), .seconds(2)]
686+
let manager = WebPushManager(
687+
vapidConfiguration: .mockedConfiguration,
688+
networkConfiguration: networkConfiguration,
689+
backgroundActivityLogger: Logger(label: "WebPushManagerTests", factory: { PrintLogHandler(label: $0, metadataProvider: $1) }),
690+
executor: .httpClient(MockHTTPClient(
691+
{ _ in HTTPClientResponse(status: .internalServerError) },
692+
{ _ in HTTPClientResponse(status: .internalServerError) },
693+
{ _ in HTTPClientResponse(status: .internalServerError) },
694+
{ _ in HTTPClientResponse(status: .created) }
695+
))
696+
)
697+
698+
await #expect(throws: HTTPClientError.deadlineExceeded) {
699+
try await manager.send(string: "hello", to: .mockedSubscriber(), expiration: .seconds(2))
700+
}
701+
}
702+
631703
@Test func sendMessageToSubscriberWithInvalidVAPIDKey() async throws {
632704
await confirmation(expectedCount: 0) { requestWasMade in
633705
var subscriber = Subscriber.mockedSubscriber

0 commit comments

Comments
 (0)