From e0e7fbe726235e2105c86ecd47b788d7bebce20f Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Thu, 14 May 2026 11:31:45 -0400 Subject: [PATCH 01/20] fix: throttle retriable transport upload failures Detect retriable transport errors via a shared Swift detector and apply throttling for message/alias retry paths while preserving existing HTTP retry ordering. --- .../MPTransportErrorDetectorTests.swift | 33 +++++++++++++++ .../Network/MPTransportErrorDetector.swift | 38 +++++++++++++++++ mParticle-Apple-SDK.xcodeproj/project.pbxproj | 4 ++ .../Network/MPNetworkCommunication.m | 42 ++++++++++++++++--- 4 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift create mode 100644 mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift diff --git a/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift b/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift new file mode 100644 index 000000000..59b1b4d94 --- /dev/null +++ b/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift @@ -0,0 +1,33 @@ +import XCTest +internal import mParticle_Apple_SDK_Swift + +final class MPTransportErrorDetectorTests: XCTestCase { + func test_isRetriableTransportError_returnsFalse_whenErrorIsNil() { + XCTAssertFalse(MPTransportErrorDetector.isRetriableTransportError(nil)) + } + + func test_isRetriableTransportError_returnsTrue_whenNoConnectionCode() { + let error = NSError(domain: "any-domain", code: 1) + XCTAssertTrue(MPTransportErrorDetector.isRetriableTransportError(error)) + } + + func test_isRetriableTransportError_returnsTrue_whenNSURLErrorIsRetriable() { + let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) + XCTAssertTrue(MPTransportErrorDetector.isRetriableTransportError(error)) + } + + func test_isRetriableTransportError_returnsFalse_whenNSURLErrorIsNotRetriable() { + let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled) + XCTAssertFalse(MPTransportErrorDetector.isRetriableTransportError(error)) + } + + func test_isRetriableTransportError_returnsTrue_forMParticleTimeoutError() { + let error = NSError(domain: "com.mparticle", code: 0) + XCTAssertTrue(MPTransportErrorDetector.isRetriableTransportError(error)) + } + + func test_isRetriableTransportError_returnsFalse_forUnknownError() { + let error = NSError(domain: "custom-domain", code: 42) + XCTAssertFalse(MPTransportErrorDetector.isRetriableTransportError(error)) + } +} diff --git a/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift b/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift new file mode 100644 index 000000000..25a04be88 --- /dev/null +++ b/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift @@ -0,0 +1,38 @@ +import Foundation + +@objc public class MPTransportErrorDetector: NSObject { + private static let noConnectionErrorCode = 1 + + @objc(isRetriableTransportError:) + public static func isRetriableTransportError(_ error: NSError?) -> Bool { + guard let error else { + return false + } + + if error.code == noConnectionErrorCode { + return true + } + + if error.domain == NSURLErrorDomain { + switch error.code { + case NSURLErrorNotConnectedToInternet, + NSURLErrorTimedOut, + NSURLErrorCannotFindHost, + NSURLErrorCannotConnectToHost, + NSURLErrorNetworkConnectionLost, + NSURLErrorDNSLookupFailed, + NSURLErrorCannotLoadFromNetwork, + NSURLErrorSecureConnectionFailed, + NSURLErrorInternationalRoamingOff, + NSURLErrorDataNotAllowed, + NSURLErrorCallIsActive, + NSURLErrorAppTransportSecurityRequiresSecureConnection: + return true + default: + return false + } + } + + return error.domain == "com.mparticle" && error.code == 0 + } +} diff --git a/mParticle-Apple-SDK.xcodeproj/project.pbxproj b/mParticle-Apple-SDK.xcodeproj/project.pbxproj index 053fc4088..d05e1aadf 100644 --- a/mParticle-Apple-SDK.xcodeproj/project.pbxproj +++ b/mParticle-Apple-SDK.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 351779502E706BF8004BF05E /* ExecutorMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3517794C2E706BE4004BF05E /* ExecutorMock.swift */; }; 35329FE92E54C38C009AC4FD /* MPNetworkOptions+MParticlePrivate.m in Sources */ = {isa = PBXBuildFile; fileRef = 35329FE82E54C38C009AC4FD /* MPNetworkOptions+MParticlePrivate.m */; }; 35329FEC2E54C483009AC4FD /* MPNetworkOptions+MParticlePrivateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35329FEB2E54C480009AC4FD /* MPNetworkOptions+MParticlePrivateTests.swift */; }; + 35A1B2C32F50011100A1B2C3 /* MPTransportErrorDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A1B2C22F50011100A1B2C3 /* MPTransportErrorDetectorTests.swift */; }; 35329FF02E54CA49009AC4FD /* MParticleOptions+MParticlePrivate.h in Headers */ = {isa = PBXBuildFile; fileRef = 35329FEE2E54CA49009AC4FD /* MParticleOptions+MParticlePrivate.h */; }; 35329FF32E54CA78009AC4FD /* MParticleOptions+MParticlePrivate.m in Sources */ = {isa = PBXBuildFile; fileRef = 35329FF12E54CA78009AC4FD /* MParticleOptions+MParticlePrivate.m */; }; 35329FF52E54CB8C009AC4FD /* MParticleOptions+MParticlePrivateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35329FF42E54CB84009AC4FD /* MParticleOptions+MParticlePrivateTests.swift */; }; @@ -334,6 +335,7 @@ 3517794C2E706BE4004BF05E /* ExecutorMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutorMock.swift; sourceTree = ""; }; 35329FE82E54C38C009AC4FD /* MPNetworkOptions+MParticlePrivate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "MPNetworkOptions+MParticlePrivate.m"; sourceTree = ""; }; 35329FEB2E54C480009AC4FD /* MPNetworkOptions+MParticlePrivateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MPNetworkOptions+MParticlePrivateTests.swift"; sourceTree = ""; }; + 35A1B2C22F50011100A1B2C3 /* MPTransportErrorDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MPTransportErrorDetectorTests.swift; sourceTree = ""; }; 35329FEE2E54CA49009AC4FD /* MParticleOptions+MParticlePrivate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "MParticleOptions+MParticlePrivate.h"; sourceTree = ""; }; 35329FF12E54CA78009AC4FD /* MParticleOptions+MParticlePrivate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "MParticleOptions+MParticlePrivate.m"; sourceTree = ""; }; 35329FF42E54CB84009AC4FD /* MParticleOptions+MParticlePrivateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MParticleOptions+MParticlePrivateTests.swift"; sourceTree = ""; }; @@ -1178,6 +1180,7 @@ 35E3FCC22E53B5C200DB5B18 /* MPAttributionResult+MParticlePrivateTests.swift */, 72FEBD162E86FE2D00B8341F /* MPIdentityTests.swift */, 72D356522E8460020012A0C2 /* MPEventTests.swift */, + 35A1B2C22F50011100A1B2C3 /* MPTransportErrorDetectorTests.swift */, ); path = SwiftTests; sourceTree = ""; @@ -1562,6 +1565,7 @@ 534CD28D29CE2CE1008452B3 /* MPUploadBuilderTests.m in Sources */, 534CD28E29CE2CE1008452B3 /* MPBaseTestCase.m in Sources */, 35329FEC2E54C483009AC4FD /* MPNetworkOptions+MParticlePrivateTests.swift in Sources */, + 35A1B2C32F50011100A1B2C3 /* MPTransportErrorDetectorTests.swift in Sources */, 7231B8352EB95F9F001565E5 /* MParticleBreadcrumbTests.swift in Sources */, 534CD28F29CE2CE1008452B3 /* MPIdentityApiRequestTests.m in Sources */, 534CD29029CE2CE1008452B3 /* MPKitAppsFlyerTest.m in Sources */, diff --git a/mParticle-Apple-SDK/Network/MPNetworkCommunication.m b/mParticle-Apple-SDK/Network/MPNetworkCommunication.m index c0ecbc97a..00c124b75 100644 --- a/mParticle-Apple-SDK/Network/MPNetworkCommunication.m +++ b/mParticle-Apple-SDK/Network/MPNetworkCommunication.m @@ -435,6 +435,24 @@ - (void)setIdentifying:(BOOL)identifying { } #pragma mark Private methods +- (BOOL)isRetriableTransportError:(NSError *)error { + return [MPTransportErrorDetector isRetriableTransportError:error]; +} + +- (void)throttleForTransportError:(NSError *)error uploadType:(MPUploadType)uploadType httpResponse:(NSHTTPURLResponse *)httpResponse { + if (!error) { + return; + } + + NSString *uploadLabel = uploadType == MPUploadTypeAlias ? @"alias requests" : @"uploads"; + MPILogWarning(@"Throttling %@ after transport error: %@ (domain: %@, code: %ld)", + uploadLabel, + error.localizedDescription, + error.domain, + (long)error.code); + [self throttleWithHTTPResponse:httpResponse uploadType:uploadType]; +} + - (void)throttleWithHTTPResponse:(NSHTTPURLResponse *)httpResponse uploadType:(MPUploadType)uploadType { NSDate *now = [NSDate date]; NSDictionary *httpHeaders = [httpResponse allHeaderFields]; @@ -681,7 +699,11 @@ - (void)requestAudiencesWithCompletionHandler:(MPAudienceResponseHandler)complet } - (BOOL)performMessageUpload:(MPUpload *)upload { - NSDate *minUploadDate = [MParticle.sharedInstance.stateMachine minUploadDateForUploadType:MPUploadTypeMessage]; + MParticle *mParticle = MParticle.sharedInstance; + MPStateMachine_PRIVATE *stateMachine = mParticle.stateMachine; + MPPersistenceController_PRIVATE *persistenceController = mParticle.persistenceController; + + NSDate *minUploadDate = [stateMachine minUploadDateForUploadType:MPUploadTypeMessage]; if ([minUploadDate compare:[NSDate date]] == NSOrderedDescending) { return YES; //stop upload loop } @@ -694,8 +716,8 @@ - (BOOL)performMessageUpload:(MPUpload *)upload { MPILogVerbose(@"Beginning upload for upload ID: %@", upload.uuid); NSData *zipUploadData; - NSNumber *authTimestamp = [MParticle sharedInstance].stateMachine.attAuthorizationTimestamp; - NSNumber *authStatus = [MParticle sharedInstance].stateMachine.attAuthorizationStatus; + NSNumber *authTimestamp = stateMachine.attAuthorizationTimestamp; + NSNumber *authStatus = stateMachine.attAuthorizationStatus; if (authStatus != nil && authTimestamp != nil) { NSDictionary *uploadDictionary = [NSJSONSerialization JSONObjectWithData:upload.uploadData options:0 error:nil]; @@ -736,7 +758,7 @@ - (BOOL)performMessageUpload:(MPUpload *)upload { } if (zipUploadData == nil || zipUploadData.length <= 0) { - [[MParticle sharedInstance].persistenceController deleteUpload:upload]; + [persistenceController deleteUpload:upload]; return NO; } NSTimeInterval start = [[NSDate date] timeIntervalSince1970]; @@ -746,6 +768,7 @@ - (BOOL)performMessageUpload:(MPUpload *)upload { serializedParams:zipUploadData secret:upload.uploadSettings.secret]; NSData *data = response.data; + NSError *error = response.error; NSHTTPURLResponse *httpResponse = response.httpResponse; NSInteger responseCode = [httpResponse statusCode]; @@ -753,9 +776,9 @@ - (BOOL)performMessageUpload:(MPUpload *)upload { BOOL isSuccessCode = responseCode >= 200 && responseCode < 300; BOOL isInvalidCode = responseCode != 429 && responseCode >= 400 && responseCode < 500; if (isSuccessCode || isInvalidCode) { - [[MParticle sharedInstance].persistenceController deleteUpload:upload]; + [persistenceController deleteUpload:upload]; if (isSuccessCode && uploadString.length) { - [[MParticle sharedInstance] logKitBatch:uploadString]; + [mParticle logKitBatch:uploadString]; } } @@ -786,6 +809,9 @@ - (BOOL)performMessageUpload:(MPUpload *)upload { //5xx, 0, 999, -1, etc if (!isSuccessCode && !isInvalidCode) { + if ([self isRetriableTransportError:error]) { + [self throttleForTransportError:error uploadType:MPUploadTypeMessage httpResponse:httpResponse]; + } return YES; } @@ -818,6 +844,7 @@ - (BOOL)performAliasUpload:(MPUpload *)upload { serializedParams:upload.uploadData secret:upload.uploadSettings.secret]; NSData *data = response.data; + NSError *error = response.error; NSHTTPURLResponse *httpResponse = response.httpResponse; NSInteger responseCode = [httpResponse statusCode]; @@ -874,6 +901,9 @@ - (BOOL)performAliasUpload:(MPUpload *)upload { //5xx, 0, 999, -1, etc if (!isSuccessCode && !isInvalidCode) { + if ([self isRetriableTransportError:error]) { + [self throttleForTransportError:error uploadType:upload.uploadType httpResponse:httpResponse]; + } return YES; } From 42b6d597d02410e8d4bfeef97aa5d549c15df94b Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Thu, 14 May 2026 13:23:04 -0400 Subject: [PATCH 02/20] fix: simplify transport retry throttling path Remove the dedicated transport-throttle helper and handle transport retry throttling inline for message and alias uploads to keep the retry flow straightforward. --- .../Network/MPNetworkCommunication.m | 20 ++++--------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/mParticle-Apple-SDK/Network/MPNetworkCommunication.m b/mParticle-Apple-SDK/Network/MPNetworkCommunication.m index 00c124b75..4bea66363 100644 --- a/mParticle-Apple-SDK/Network/MPNetworkCommunication.m +++ b/mParticle-Apple-SDK/Network/MPNetworkCommunication.m @@ -439,20 +439,6 @@ - (BOOL)isRetriableTransportError:(NSError *)error { return [MPTransportErrorDetector isRetriableTransportError:error]; } -- (void)throttleForTransportError:(NSError *)error uploadType:(MPUploadType)uploadType httpResponse:(NSHTTPURLResponse *)httpResponse { - if (!error) { - return; - } - - NSString *uploadLabel = uploadType == MPUploadTypeAlias ? @"alias requests" : @"uploads"; - MPILogWarning(@"Throttling %@ after transport error: %@ (domain: %@, code: %ld)", - uploadLabel, - error.localizedDescription, - error.domain, - (long)error.code); - [self throttleWithHTTPResponse:httpResponse uploadType:uploadType]; -} - - (void)throttleWithHTTPResponse:(NSHTTPURLResponse *)httpResponse uploadType:(MPUploadType)uploadType { NSDate *now = [NSDate date]; NSDictionary *httpHeaders = [httpResponse allHeaderFields]; @@ -810,7 +796,8 @@ - (BOOL)performMessageUpload:(MPUpload *)upload { //5xx, 0, 999, -1, etc if (!isSuccessCode && !isInvalidCode) { if ([self isRetriableTransportError:error]) { - [self throttleForTransportError:error uploadType:MPUploadTypeMessage httpResponse:httpResponse]; + MPILogWarning(@"Throttling uploads after transport error."); + [self throttleWithHTTPResponse:httpResponse uploadType:MPUploadTypeMessage]; } return YES; } @@ -902,7 +889,8 @@ - (BOOL)performAliasUpload:(MPUpload *)upload { //5xx, 0, 999, -1, etc if (!isSuccessCode && !isInvalidCode) { if ([self isRetriableTransportError:error]) { - [self throttleForTransportError:error uploadType:upload.uploadType httpResponse:httpResponse]; + MPILogWarning(@"Throttling alias requests after transport error."); + [self throttleWithHTTPResponse:httpResponse uploadType:MPUploadTypeAlias]; } return YES; } From 3c29a843f66a2d62012e0532239063c223be0880 Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Thu, 14 May 2026 14:01:09 -0400 Subject: [PATCH 03/20] fix: avoid throttling on offline and timeout errors Keep transport throttling focused on host/connectivity failures by excluding not-connected and timed-out NSURL errors, and update tests to lock in the new retry classification behavior. --- .../SwiftTests/MPTransportErrorDetectorTests.swift | 12 +++++++++++- .../Sources/Network/MPTransportErrorDetector.swift | 4 +--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift b/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift index 59b1b4d94..ec71ca58d 100644 --- a/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift +++ b/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift @@ -12,7 +12,7 @@ final class MPTransportErrorDetectorTests: XCTestCase { } func test_isRetriableTransportError_returnsTrue_whenNSURLErrorIsRetriable() { - let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) + let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotConnectToHost) XCTAssertTrue(MPTransportErrorDetector.isRetriableTransportError(error)) } @@ -21,6 +21,16 @@ final class MPTransportErrorDetectorTests: XCTestCase { XCTAssertFalse(MPTransportErrorDetector.isRetriableTransportError(error)) } + func test_isRetriableTransportError_returnsFalse_whenNSURLErrorIsNotConnectedToInternet() { + let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet) + XCTAssertFalse(MPTransportErrorDetector.isRetriableTransportError(error)) + } + + func test_isRetriableTransportError_returnsFalse_whenNSURLErrorIsTimedOut() { + let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) + XCTAssertFalse(MPTransportErrorDetector.isRetriableTransportError(error)) + } + func test_isRetriableTransportError_returnsTrue_forMParticleTimeoutError() { let error = NSError(domain: "com.mparticle", code: 0) XCTAssertTrue(MPTransportErrorDetector.isRetriableTransportError(error)) diff --git a/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift b/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift index 25a04be88..91b92e392 100644 --- a/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift +++ b/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift @@ -15,9 +15,7 @@ import Foundation if error.domain == NSURLErrorDomain { switch error.code { - case NSURLErrorNotConnectedToInternet, - NSURLErrorTimedOut, - NSURLErrorCannotFindHost, + case NSURLErrorCannotFindHost, NSURLErrorCannotConnectToHost, NSURLErrorNetworkConnectionLost, NSURLErrorDNSLookupFailed, From e88d1a8efea1d5a7542e0586e818f467b3fb496a Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Mon, 18 May 2026 10:15:30 -0400 Subject: [PATCH 04/20] fix: route throttle logging through MPLog Use the SDK logger in throttleWithHTTPResponse to align with the new logging path and ensure custom logger handling is applied consistently. --- .../Network/MPNetworkCommunication.m | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/mParticle-Apple-SDK/Network/MPNetworkCommunication.m b/mParticle-Apple-SDK/Network/MPNetworkCommunication.m index 4bea66363..8e545576e 100644 --- a/mParticle-Apple-SDK/Network/MPNetworkCommunication.m +++ b/mParticle-Apple-SDK/Network/MPNetworkCommunication.m @@ -67,6 +67,7 @@ @interface MParticle () @property (nonatomic, strong, readonly) MPStateMachine_PRIVATE *stateMachine; @property (nonatomic, strong, readonly) MPBackendController_PRIVATE *backendController; +- (MPLog *)getLogger; - (void)logKitBatch:(NSString *)batch; + (void)executeOnMain:(void(^)(void))block; + (void)executeOnMainSync:(void(^)(void))block; @@ -440,6 +441,7 @@ - (BOOL)isRetriableTransportError:(NSError *)error { } - (void)throttleWithHTTPResponse:(NSHTTPURLResponse *)httpResponse uploadType:(MPUploadType)uploadType { + MPLog *logger = MParticle.sharedInstance.getLogger; NSDate *now = [NSDate date]; NSDictionary *httpHeaders = [httpResponse allHeaderFields]; NSTimeInterval retryAfter = 7200; // Default of 2 hours @@ -454,14 +456,16 @@ - (void)throttleWithHTTPResponse:(NSHTTPURLResponse *)httpResponse uploadType:(M retryAfter = MIN(([retryAfterDate timeIntervalSince1970] - [now timeIntervalSince1970]), maxRetryAfter); retryAfter = retryAfter > 0 ? retryAfter : 7200; } else { - MPILogError(@"Invalid 'Retry-After' date: %@ - using default retry interval", suggestedRetryAfter); + NSString *invalidRetryAfterDateMessage = [NSString stringWithFormat:@"Invalid 'Retry-After' date: %@ - using default retry interval", suggestedRetryAfter]; + [logger error:invalidRetryAfterDateMessage]; } } else { // Number of seconds @try { retryAfter = MIN([(NSString *)suggestedRetryAfter doubleValue], maxRetryAfter); } @catch (NSException *exception) { retryAfter = 7200; - MPILogError(@"Invalid 'Retry-After' value: %@ - using default retry interval", suggestedRetryAfter); + NSString *invalidRetryAfterValueMessage = [NSString stringWithFormat:@"Invalid 'Retry-After' value: %@ - using default retry interval", suggestedRetryAfter]; + [logger error:invalidRetryAfterValueMessage]; } } } else if ([suggestedRetryAfter isKindOfClass:[NSNumber class]]) { @@ -473,9 +477,11 @@ - (void)throttleWithHTTPResponse:(NSHTTPURLResponse *)httpResponse uploadType:(M if ([minUploadDate compare:now] == NSOrderedAscending) { [MParticle.sharedInstance.stateMachine setMinUploadDate:[now dateByAddingTimeInterval:retryAfter] uploadType:uploadType]; if (uploadType == MPUploadTypeMessage) { - MPILogDebug(@"Throttling uploads for %.0f seconds", retryAfter); + NSString *messageThrottleLog = [NSString stringWithFormat:@"Throttling uploads for %.0f seconds", retryAfter]; + [logger debug:messageThrottleLog]; } else if (uploadType == MPUploadTypeAlias) { - MPILogDebug(@"Throttling alias requests for %.0f seconds", retryAfter); + NSString *aliasThrottleLog = [NSString stringWithFormat:@"Throttling alias requests for %.0f seconds", retryAfter]; + [logger debug:aliasThrottleLog]; } } } From 2ec9e9b6f4803a2d9e323cd1a04e58fb9ebea659 Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Mon, 18 May 2026 10:38:14 -0400 Subject: [PATCH 05/20] test: add retry-after dictionary parser coverage Add a Swift NSDictionary extension for parsing Retry-After seconds and cover NSNumber, String, invalid, and missing header scenarios with unit tests. --- .../Network/NSDictionary+MPRetryAfter.swift | 16 +++++++++++ .../NSDictionary+MPRetryAfterTests.swift | 28 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 mParticle-Apple-SDK-Swift/Sources/Network/NSDictionary+MPRetryAfter.swift create mode 100644 mParticle-Apple-SDK-Swift/Test/Utils/NSDictionary+MPRetryAfterTests.swift diff --git a/mParticle-Apple-SDK-Swift/Sources/Network/NSDictionary+MPRetryAfter.swift b/mParticle-Apple-SDK-Swift/Sources/Network/NSDictionary+MPRetryAfter.swift new file mode 100644 index 000000000..d93dd6df1 --- /dev/null +++ b/mParticle-Apple-SDK-Swift/Sources/Network/NSDictionary+MPRetryAfter.swift @@ -0,0 +1,16 @@ +import Foundation + +@objc public extension NSDictionary { + func retrySeconds() -> NSNumber? { + let headerValue = self["Retry-After"] + + if let number = headerValue as? NSNumber { + return NSNumber(value: number.doubleValue) + } + if let string = headerValue as? String { + return Double(string).map { NSNumber(value: $0) } + } + + return nil + } +} diff --git a/mParticle-Apple-SDK-Swift/Test/Utils/NSDictionary+MPRetryAfterTests.swift b/mParticle-Apple-SDK-Swift/Test/Utils/NSDictionary+MPRetryAfterTests.swift new file mode 100644 index 000000000..988baf6e5 --- /dev/null +++ b/mParticle-Apple-SDK-Swift/Test/Utils/NSDictionary+MPRetryAfterTests.swift @@ -0,0 +1,28 @@ +import XCTest +@testable import mParticle_Apple_SDK_Swift + +final class NSDictionaryMPRetryAfterTests: XCTestCase { + func testRetrySecondsReturnsNumberValue() { + let headers: NSDictionary = ["Retry-After": NSNumber(value: 42)] + + XCTAssertEqual(headers.retrySeconds()?.doubleValue, 42) + } + + func testRetrySecondsParsesStringValue() { + let headers: NSDictionary = ["Retry-After": "123.5"] + + XCTAssertEqual(headers.retrySeconds()?.doubleValue, 123.5) + } + + func testRetrySecondsReturnsNilForInvalidString() { + let headers: NSDictionary = ["Retry-After": "not-a-number"] + + XCTAssertNil(headers.retrySeconds()) + } + + func testRetrySecondsReturnsNilWhenHeaderMissing() { + let headers: NSDictionary = ["Other-Header": "1"] + + XCTAssertNil(headers.retrySeconds()) + } +} From c691131278a99e529c6aa7b9d03554f1655ab9a4 Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Mon, 18 May 2026 10:44:36 -0400 Subject: [PATCH 06/20] fix: simplify retry-after throttle parsing Use dictionary retry-after helpers for date and seconds parsing in throttling logic and add Swift tests for both parsing paths. --- .../Network/NSDictionary+MPRetryAfter.swift | 8 +++++ .../NSDictionary+MPRetryAfterTests.swift | 12 +++++++ .../Network/MPNetworkCommunication.m | 32 ++++--------------- 3 files changed, 27 insertions(+), 25 deletions(-) diff --git a/mParticle-Apple-SDK-Swift/Sources/Network/NSDictionary+MPRetryAfter.swift b/mParticle-Apple-SDK-Swift/Sources/Network/NSDictionary+MPRetryAfter.swift index d93dd6df1..e96a95e85 100644 --- a/mParticle-Apple-SDK-Swift/Sources/Network/NSDictionary+MPRetryAfter.swift +++ b/mParticle-Apple-SDK-Swift/Sources/Network/NSDictionary+MPRetryAfter.swift @@ -1,6 +1,14 @@ import Foundation @objc public extension NSDictionary { + func retryDate() -> Date? { + guard let headerValue = self["Retry-After"] as? String else { + return nil + } + + return MPDateFormatter.dateFromStringRFC1123(headerValue) + } + func retrySeconds() -> NSNumber? { let headerValue = self["Retry-After"] diff --git a/mParticle-Apple-SDK-Swift/Test/Utils/NSDictionary+MPRetryAfterTests.swift b/mParticle-Apple-SDK-Swift/Test/Utils/NSDictionary+MPRetryAfterTests.swift index 988baf6e5..d2b7eafad 100644 --- a/mParticle-Apple-SDK-Swift/Test/Utils/NSDictionary+MPRetryAfterTests.swift +++ b/mParticle-Apple-SDK-Swift/Test/Utils/NSDictionary+MPRetryAfterTests.swift @@ -2,6 +2,18 @@ import XCTest @testable import mParticle_Apple_SDK_Swift final class NSDictionaryMPRetryAfterTests: XCTestCase { + func testRetryDateParsesRFC1123String() { + let headers: NSDictionary = ["Retry-After": "Wed, 21 Oct 2015 07:28:00 GMT"] + + XCTAssertNotNil(headers.retryDate()) + } + + func testRetryDateReturnsNilForInvalidString() { + let headers: NSDictionary = ["Retry-After": "not-a-date"] + + XCTAssertNil(headers.retryDate()) + } + func testRetrySecondsReturnsNumberValue() { let headers: NSDictionary = ["Retry-After": NSNumber(value: 42)] diff --git a/mParticle-Apple-SDK/Network/MPNetworkCommunication.m b/mParticle-Apple-SDK/Network/MPNetworkCommunication.m index 8e545576e..9438d37c5 100644 --- a/mParticle-Apple-SDK/Network/MPNetworkCommunication.m +++ b/mParticle-Apple-SDK/Network/MPNetworkCommunication.m @@ -444,33 +444,15 @@ - (void)throttleWithHTTPResponse:(NSHTTPURLResponse *)httpResponse uploadType:(M MPLog *logger = MParticle.sharedInstance.getLogger; NSDate *now = [NSDate date]; NSDictionary *httpHeaders = [httpResponse allHeaderFields]; + NSDate *retryAfterDate = [httpHeaders retryDate]; NSTimeInterval retryAfter = 7200; // Default of 2 hours NSTimeInterval maxRetryAfter = 86400; // Maximum of 24 hours - id suggestedRetryAfter = httpHeaders[@"Retry-After"]; - - if (!MPIsNull(suggestedRetryAfter)) { - if ([suggestedRetryAfter isKindOfClass:[NSString class]]) { - if ([suggestedRetryAfter containsString:@":"]) { // Date - NSDate *retryAfterDate = [MPDateFormatter dateFromStringRFC1123:(NSString *)suggestedRetryAfter]; - if (retryAfterDate) { - retryAfter = MIN(([retryAfterDate timeIntervalSince1970] - [now timeIntervalSince1970]), maxRetryAfter); - retryAfter = retryAfter > 0 ? retryAfter : 7200; - } else { - NSString *invalidRetryAfterDateMessage = [NSString stringWithFormat:@"Invalid 'Retry-After' date: %@ - using default retry interval", suggestedRetryAfter]; - [logger error:invalidRetryAfterDateMessage]; - } - } else { // Number of seconds - @try { - retryAfter = MIN([(NSString *)suggestedRetryAfter doubleValue], maxRetryAfter); - } @catch (NSException *exception) { - retryAfter = 7200; - NSString *invalidRetryAfterValueMessage = [NSString stringWithFormat:@"Invalid 'Retry-After' value: %@ - using default retry interval", suggestedRetryAfter]; - [logger error:invalidRetryAfterValueMessage]; - } - } - } else if ([suggestedRetryAfter isKindOfClass:[NSNumber class]]) { - retryAfter = MIN([(NSNumber *)suggestedRetryAfter doubleValue], maxRetryAfter); - } + NSNumber *retryAfterSeconds = [httpHeaders retrySeconds]; + if (retryAfterDate) { + retryAfter = MIN(([retryAfterDate timeIntervalSince1970] - [now timeIntervalSince1970]), maxRetryAfter); + retryAfter = retryAfter > 0 ? retryAfter : 7200; + } else if (retryAfterSeconds) { + retryAfter = MIN(retryAfterSeconds.doubleValue, maxRetryAfter); } NSDate *minUploadDate = [MParticle.sharedInstance.stateMachine minUploadDateForUploadType:uploadType]; From 710af0ad06d11e7162258cce613d25c559e6ca78 Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Mon, 18 May 2026 10:59:18 -0400 Subject: [PATCH 07/20] fix: centralize retry-after calculation in network helper Move retry-after interval calculation into a dedicated Swift network helper and cover the helper behavior with unit tests for seconds and date-based headers. --- .../MPNetworkCommunicationHelper.swift | 23 +++++++++ .../MPNetworkCommunicationHelperTests.swift | 50 +++++++++++++++++++ .../Network/MPNetworkCommunication.m | 11 +--- 3 files changed, 74 insertions(+), 10 deletions(-) create mode 100644 mParticle-Apple-SDK-Swift/Sources/Network/MPNetworkCommunicationHelper.swift create mode 100644 mParticle-Apple-SDK-Swift/Test/Utils/MPNetworkCommunicationHelperTests.swift diff --git a/mParticle-Apple-SDK-Swift/Sources/Network/MPNetworkCommunicationHelper.swift b/mParticle-Apple-SDK-Swift/Sources/Network/MPNetworkCommunicationHelper.swift new file mode 100644 index 000000000..79743dfb4 --- /dev/null +++ b/mParticle-Apple-SDK-Swift/Sources/Network/MPNetworkCommunicationHelper.swift @@ -0,0 +1,23 @@ +import Foundation + +@objc public final class MPNetworkCommunicationHelper: NSObject { + @objc(calculateRetryTimeForHeaders:) + public static func calculateRetryTime(for headers: NSDictionary) -> NSNumber { + let retryAfterDate = headers.retryDate() + let retryAfterSeconds = headers.retrySeconds() + let defaultRetryAfter: Double = 7200 + let maxRetryAfter: Double = 86400 + + if let retryAfterDate { + let now = Date() + let retryAfter = min(retryAfterDate.timeIntervalSince1970 - now.timeIntervalSince1970, maxRetryAfter) + return NSNumber(value: retryAfter > 0 ? retryAfter : defaultRetryAfter) + } + + if let retryAfterSeconds { + return NSNumber(value: min(retryAfterSeconds.doubleValue, maxRetryAfter)) + } + + return NSNumber(value: defaultRetryAfter) + } +} diff --git a/mParticle-Apple-SDK-Swift/Test/Utils/MPNetworkCommunicationHelperTests.swift b/mParticle-Apple-SDK-Swift/Test/Utils/MPNetworkCommunicationHelperTests.swift new file mode 100644 index 000000000..5dfabdf47 --- /dev/null +++ b/mParticle-Apple-SDK-Swift/Test/Utils/MPNetworkCommunicationHelperTests.swift @@ -0,0 +1,50 @@ +import XCTest +@testable import mParticle_Apple_SDK_Swift + +final class MPNetworkCommunicationHelperTests: XCTestCase { + func testCalculateRetryTimeUsesNumberHeader() { + let headers: NSDictionary = ["Retry-After": NSNumber(value: 42)] + + XCTAssertEqual(MPNetworkCommunicationHelper.calculateRetryTime(for: headers).doubleValue, 42) + } + + func testCalculateRetryTimeUsesStringSecondsHeader() { + let headers: NSDictionary = ["Retry-After": "123.5"] + + XCTAssertEqual(MPNetworkCommunicationHelper.calculateRetryTime(for: headers).doubleValue, 123.5) + } + + func testCalculateRetryTimeFallsBackToDefaultForInvalidString() { + let headers: NSDictionary = ["Retry-After": "not-a-number"] + + XCTAssertEqual(MPNetworkCommunicationHelper.calculateRetryTime(for: headers).doubleValue, 7200) + } + + func testCalculateRetryTimeFallsBackToDefaultWhenHeaderMissing() { + let headers: NSDictionary = ["Other-Header": "1"] + + XCTAssertEqual(MPNetworkCommunicationHelper.calculateRetryTime(for: headers).doubleValue, 7200) + } + + func testCalculateRetryTimeClampsToMax() { + let headers: NSDictionary = ["Retry-After": NSNumber(value: 999999)] + + XCTAssertEqual(MPNetworkCommunicationHelper.calculateRetryTime(for: headers).doubleValue, 86400) + } + + func testCalculateRetryTimeParsesDateHeader() { + let futureDate = Date().addingTimeInterval(300) + let retryAfterHeader = MPDateFormatter.string(fromDateRFC1123: futureDate) + let headers: NSDictionary = ["Retry-After": retryAfterHeader as Any] + + XCTAssertEqual(MPNetworkCommunicationHelper.calculateRetryTime(for: headers).doubleValue, 300, accuracy: 2.0) + } + + func testCalculateRetryTimeUsesDefaultForPastDateHeader() { + let pastDate = Date().addingTimeInterval(-300) + let retryAfterHeader = MPDateFormatter.string(fromDateRFC1123: pastDate) + let headers: NSDictionary = ["Retry-After": retryAfterHeader as Any] + + XCTAssertEqual(MPNetworkCommunicationHelper.calculateRetryTime(for: headers).doubleValue, 7200) + } +} diff --git a/mParticle-Apple-SDK/Network/MPNetworkCommunication.m b/mParticle-Apple-SDK/Network/MPNetworkCommunication.m index 9438d37c5..5a0207b2d 100644 --- a/mParticle-Apple-SDK/Network/MPNetworkCommunication.m +++ b/mParticle-Apple-SDK/Network/MPNetworkCommunication.m @@ -444,16 +444,7 @@ - (void)throttleWithHTTPResponse:(NSHTTPURLResponse *)httpResponse uploadType:(M MPLog *logger = MParticle.sharedInstance.getLogger; NSDate *now = [NSDate date]; NSDictionary *httpHeaders = [httpResponse allHeaderFields]; - NSDate *retryAfterDate = [httpHeaders retryDate]; - NSTimeInterval retryAfter = 7200; // Default of 2 hours - NSTimeInterval maxRetryAfter = 86400; // Maximum of 24 hours - NSNumber *retryAfterSeconds = [httpHeaders retrySeconds]; - if (retryAfterDate) { - retryAfter = MIN(([retryAfterDate timeIntervalSince1970] - [now timeIntervalSince1970]), maxRetryAfter); - retryAfter = retryAfter > 0 ? retryAfter : 7200; - } else if (retryAfterSeconds) { - retryAfter = MIN(retryAfterSeconds.doubleValue, maxRetryAfter); - } + NSTimeInterval retryAfter = [[MPNetworkCommunicationHelper calculateRetryTimeForHeaders:httpHeaders] doubleValue]; NSDate *minUploadDate = [MParticle.sharedInstance.stateMachine minUploadDateForUploadType:uploadType]; if ([minUploadDate compare:now] == NSOrderedAscending) { From 874935c76cd2cdfcfcb0dc9584e8fa05c6deb852 Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Mon, 18 May 2026 11:55:57 -0400 Subject: [PATCH 08/20] fix: throttle transport errors with internal backoff state Add transport-error retry backoff state to the detector, route retriable transport failures through a dedicated throttling path, and reset the counter after successful uploads. --- .../MPTransportErrorDetectorTests.swift | 49 +++++++++++++++++-- .../Network/MPTransportErrorDetector.swift | 33 ++++++++++--- .../Network/MPNetworkCommunication.m | 28 ++++++++++- 3 files changed, 98 insertions(+), 12 deletions(-) diff --git a/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift b/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift index ec71ca58d..fe37b1db7 100644 --- a/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift +++ b/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift @@ -2,13 +2,18 @@ import XCTest internal import mParticle_Apple_SDK_Swift final class MPTransportErrorDetectorTests: XCTestCase { + override func setUp() { + super.setUp() + MPTransportErrorDetector.resetTransportErrorCounter() + } + func test_isRetriableTransportError_returnsFalse_whenErrorIsNil() { XCTAssertFalse(MPTransportErrorDetector.isRetriableTransportError(nil)) } - func test_isRetriableTransportError_returnsTrue_whenNoConnectionCode() { + func test_isRetriableTransportError_returnsFalse_whenNoConnectionCode() { let error = NSError(domain: "any-domain", code: 1) - XCTAssertTrue(MPTransportErrorDetector.isRetriableTransportError(error)) + XCTAssertFalse(MPTransportErrorDetector.isRetriableTransportError(error)) } func test_isRetriableTransportError_returnsTrue_whenNSURLErrorIsRetriable() { @@ -31,13 +36,49 @@ final class MPTransportErrorDetectorTests: XCTestCase { XCTAssertFalse(MPTransportErrorDetector.isRetriableTransportError(error)) } - func test_isRetriableTransportError_returnsTrue_forMParticleTimeoutError() { + func test_isRetriableTransportError_returnsFalse_forMParticleTimeoutError() { let error = NSError(domain: "com.mparticle", code: 0) - XCTAssertTrue(MPTransportErrorDetector.isRetriableTransportError(error)) + XCTAssertFalse(MPTransportErrorDetector.isRetriableTransportError(error)) } func test_isRetriableTransportError_returnsFalse_forUnknownError() { let error = NSError(domain: "custom-domain", code: 42) XCTAssertFalse(MPTransportErrorDetector.isRetriableTransportError(error)) } + + func test_calculateRetryTimeForTransportError_usesSmallValueForFirstError() { + XCTAssertEqual(MPTransportErrorDetector.calculateRetryTimeForTransportError().doubleValue, 60) + } + + func test_calculateRetryTimeForTransportError_reachesMaxAtFiveErrors() { + for _ in 0..<4 { + _ = MPTransportErrorDetector.calculateRetryTimeForTransportError() + } + + XCTAssertEqual(MPTransportErrorDetector.calculateRetryTimeForTransportError().doubleValue, 86400) + } + + func test_calculateRetryTimeForTransportError_usesExpectedBackoffSchedule() { + XCTAssertEqual(MPTransportErrorDetector.calculateRetryTimeForTransportError().doubleValue, 60) + XCTAssertEqual(MPTransportErrorDetector.calculateRetryTimeForTransportError().doubleValue, 300) + XCTAssertEqual(MPTransportErrorDetector.calculateRetryTimeForTransportError().doubleValue, 1800) + XCTAssertEqual(MPTransportErrorDetector.calculateRetryTimeForTransportError().doubleValue, 21600) + XCTAssertEqual(MPTransportErrorDetector.calculateRetryTimeForTransportError().doubleValue, 86400) + } + + func test_calculateRetryTimeForTransportError_staysAtMaxAfterThreshold() { + for _ in 0..<5 { + _ = MPTransportErrorDetector.calculateRetryTimeForTransportError() + } + + XCTAssertEqual(MPTransportErrorDetector.calculateRetryTimeForTransportError().doubleValue, 86400) + } + + func test_resetTransportErrorCounter_resetsBackoff() { + _ = MPTransportErrorDetector.calculateRetryTimeForTransportError() + _ = MPTransportErrorDetector.calculateRetryTimeForTransportError() + MPTransportErrorDetector.resetTransportErrorCounter() + + XCTAssertEqual(MPTransportErrorDetector.calculateRetryTimeForTransportError().doubleValue, 60) + } } diff --git a/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift b/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift index 91b92e392..b945206b1 100644 --- a/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift +++ b/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift @@ -1,7 +1,11 @@ import Foundation @objc public class MPTransportErrorDetector: NSObject { - private static let noConnectionErrorCode = 1 + private static let maxRetryAfter: Double = 86400 + private static let maxErrorCountBeforeMaxRetry = 5 + private static let retryAfterSchedule: [Double] = [60, 300, 1800, 21600] + private static var consecutiveTransportErrorCount = 0 + private static let backoffQueue = DispatchQueue(label: "com.mparticle.transport-error-backoff") @objc(isRetriableTransportError:) public static func isRetriableTransportError(_ error: NSError?) -> Bool { @@ -9,10 +13,6 @@ import Foundation return false } - if error.code == noConnectionErrorCode { - return true - } - if error.domain == NSURLErrorDomain { switch error.code { case NSURLErrorCannotFindHost, @@ -31,6 +31,27 @@ import Foundation } } - return error.domain == "com.mparticle" && error.code == 0 + return false + } + + @objc(calculateRetryTimeForTransportError) + public static func calculateRetryTimeForTransportError() -> NSNumber { + return backoffQueue.sync { + consecutiveTransportErrorCount += 1 + + if consecutiveTransportErrorCount >= maxErrorCountBeforeMaxRetry { + return NSNumber(value: maxRetryAfter) + } + + let scheduleIndex = max(0, consecutiveTransportErrorCount - 1) + return NSNumber(value: retryAfterSchedule[scheduleIndex]) + } + } + + @objc(resetTransportErrorCounter) + public static func resetTransportErrorCounter() { + backoffQueue.sync { + consecutiveTransportErrorCount = 0 + } } } diff --git a/mParticle-Apple-SDK/Network/MPNetworkCommunication.m b/mParticle-Apple-SDK/Network/MPNetworkCommunication.m index 5a0207b2d..2ca772aec 100644 --- a/mParticle-Apple-SDK/Network/MPNetworkCommunication.m +++ b/mParticle-Apple-SDK/Network/MPNetworkCommunication.m @@ -459,6 +459,24 @@ - (void)throttleWithHTTPResponse:(NSHTTPURLResponse *)httpResponse uploadType:(M } } +- (void)throttleWithHTTPResponseForErrors:(NSHTTPURLResponse *)httpResponse uploadType:(MPUploadType)uploadType { + MPLog *logger = MParticle.sharedInstance.getLogger; + NSDate *now = [NSDate date]; + NSTimeInterval retryAfter = [[MPTransportErrorDetector calculateRetryTimeForTransportError] doubleValue]; + + NSDate *minUploadDate = [MParticle.sharedInstance.stateMachine minUploadDateForUploadType:uploadType]; + if ([minUploadDate compare:now] == NSOrderedAscending) { + [MParticle.sharedInstance.stateMachine setMinUploadDate:[now dateByAddingTimeInterval:retryAfter] uploadType:uploadType]; + if (uploadType == MPUploadTypeMessage) { + NSString *messageThrottleLog = [NSString stringWithFormat:@"Throttling uploads for %.0f seconds", retryAfter]; + [logger debug:messageThrottleLog]; + } else if (uploadType == MPUploadTypeAlias) { + NSString *aliasThrottleLog = [NSString stringWithFormat:@"Throttling alias requests for %.0f seconds", retryAfter]; + [logger debug:aliasThrottleLog]; + } + } +} + - (NSNumber *)maxAgeForCache:(nonnull NSString *)cache { NSNumber *maxAge; cache = cache.lowercaseString; @@ -740,6 +758,9 @@ - (BOOL)performMessageUpload:(MPUpload *)upload { MPILogVerbose(@"Upload response code: %ld", (long)responseCode); BOOL isSuccessCode = responseCode >= 200 && responseCode < 300; BOOL isInvalidCode = responseCode != 429 && responseCode >= 400 && responseCode < 500; + if (isSuccessCode) { + [MPTransportErrorDetector resetTransportErrorCounter]; + } if (isSuccessCode || isInvalidCode) { [persistenceController deleteUpload:upload]; if (isSuccessCode && uploadString.length) { @@ -776,7 +797,7 @@ - (BOOL)performMessageUpload:(MPUpload *)upload { if (!isSuccessCode && !isInvalidCode) { if ([self isRetriableTransportError:error]) { MPILogWarning(@"Throttling uploads after transport error."); - [self throttleWithHTTPResponse:httpResponse uploadType:MPUploadTypeMessage]; + [self throttleWithHTTPResponseForErrors:httpResponse uploadType:MPUploadTypeMessage]; } return YES; } @@ -818,6 +839,9 @@ - (BOOL)performAliasUpload:(MPUpload *)upload { BOOL isSuccessCode = responseCode >= 200 && responseCode < 300; BOOL isInvalidCode = responseCode != 429 && responseCode >= 400 && responseCode < 500; + if (isSuccessCode) { + [MPTransportErrorDetector resetTransportErrorCounter]; + } if (isSuccessCode || isInvalidCode) { [[MParticle sharedInstance].persistenceController deleteUpload:upload]; } @@ -869,7 +893,7 @@ - (BOOL)performAliasUpload:(MPUpload *)upload { if (!isSuccessCode && !isInvalidCode) { if ([self isRetriableTransportError:error]) { MPILogWarning(@"Throttling alias requests after transport error."); - [self throttleWithHTTPResponse:httpResponse uploadType:MPUploadTypeAlias]; + [self throttleWithHTTPResponseForErrors:httpResponse uploadType:MPUploadTypeAlias]; } return YES; } From 6d90bdb9c6d7327781f8fae7edf132f892daf6d0 Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Mon, 18 May 2026 13:06:04 -0400 Subject: [PATCH 09/20] refactor: use a single throttle method with computed retry time Unify throttling into one method that accepts retryAfter and compute retry intervals at each call site for HTTP response and transport error paths. --- .../Network/MPNetworkCommunication.m | 36 ++++++------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/mParticle-Apple-SDK/Network/MPNetworkCommunication.m b/mParticle-Apple-SDK/Network/MPNetworkCommunication.m index 2ca772aec..6ce35349e 100644 --- a/mParticle-Apple-SDK/Network/MPNetworkCommunication.m +++ b/mParticle-Apple-SDK/Network/MPNetworkCommunication.m @@ -440,29 +440,9 @@ - (BOOL)isRetriableTransportError:(NSError *)error { return [MPTransportErrorDetector isRetriableTransportError:error]; } -- (void)throttleWithHTTPResponse:(NSHTTPURLResponse *)httpResponse uploadType:(MPUploadType)uploadType { +- (void)throttleWithRetryAfter:(NSTimeInterval)retryAfter uploadType:(MPUploadType)uploadType { MPLog *logger = MParticle.sharedInstance.getLogger; NSDate *now = [NSDate date]; - NSDictionary *httpHeaders = [httpResponse allHeaderFields]; - NSTimeInterval retryAfter = [[MPNetworkCommunicationHelper calculateRetryTimeForHeaders:httpHeaders] doubleValue]; - - NSDate *minUploadDate = [MParticle.sharedInstance.stateMachine minUploadDateForUploadType:uploadType]; - if ([minUploadDate compare:now] == NSOrderedAscending) { - [MParticle.sharedInstance.stateMachine setMinUploadDate:[now dateByAddingTimeInterval:retryAfter] uploadType:uploadType]; - if (uploadType == MPUploadTypeMessage) { - NSString *messageThrottleLog = [NSString stringWithFormat:@"Throttling uploads for %.0f seconds", retryAfter]; - [logger debug:messageThrottleLog]; - } else if (uploadType == MPUploadTypeAlias) { - NSString *aliasThrottleLog = [NSString stringWithFormat:@"Throttling alias requests for %.0f seconds", retryAfter]; - [logger debug:aliasThrottleLog]; - } - } -} - -- (void)throttleWithHTTPResponseForErrors:(NSHTTPURLResponse *)httpResponse uploadType:(MPUploadType)uploadType { - MPLog *logger = MParticle.sharedInstance.getLogger; - NSDate *now = [NSDate date]; - NSTimeInterval retryAfter = [[MPTransportErrorDetector calculateRetryTimeForTransportError] doubleValue]; NSDate *minUploadDate = [MParticle.sharedInstance.stateMachine minUploadDateForUploadType:uploadType]; if ([minUploadDate compare:now] == NSOrderedAscending) { @@ -789,7 +769,9 @@ - (BOOL)performMessageUpload:(MPUpload *)upload { // 429, 503 if (responseCode == HTTPStatusCodeServiceUnavailable || responseCode == HTTPStatusCodeTooManyRequests) { - [self throttleWithHTTPResponse:httpResponse uploadType:MPUploadTypeMessage]; + NSDictionary *httpHeaders = [httpResponse allHeaderFields]; + NSTimeInterval retryAfter = [[MPNetworkCommunicationHelper calculateRetryTimeForHeaders:httpHeaders] doubleValue]; + [self throttleWithRetryAfter:retryAfter uploadType:MPUploadTypeMessage]; return YES; } @@ -797,7 +779,8 @@ - (BOOL)performMessageUpload:(MPUpload *)upload { if (!isSuccessCode && !isInvalidCode) { if ([self isRetriableTransportError:error]) { MPILogWarning(@"Throttling uploads after transport error."); - [self throttleWithHTTPResponseForErrors:httpResponse uploadType:MPUploadTypeMessage]; + NSTimeInterval retryAfter = [[MPTransportErrorDetector calculateRetryTimeForTransportError] doubleValue]; + [self throttleWithRetryAfter:retryAfter uploadType:MPUploadTypeMessage]; } return YES; } @@ -885,7 +868,9 @@ - (BOOL)performAliasUpload:(MPUpload *)upload { // 429, 503 if (responseCode == HTTPStatusCodeServiceUnavailable || responseCode == HTTPStatusCodeTooManyRequests) { aliasResponse.willRetry = YES; - [self throttleWithHTTPResponse:httpResponse uploadType:upload.uploadType]; + NSDictionary *httpHeaders = [httpResponse allHeaderFields]; + NSTimeInterval retryAfter = [[MPNetworkCommunicationHelper calculateRetryTimeForHeaders:httpHeaders] doubleValue]; + [self throttleWithRetryAfter:retryAfter uploadType:upload.uploadType]; return YES; } @@ -893,7 +878,8 @@ - (BOOL)performAliasUpload:(MPUpload *)upload { if (!isSuccessCode && !isInvalidCode) { if ([self isRetriableTransportError:error]) { MPILogWarning(@"Throttling alias requests after transport error."); - [self throttleWithHTTPResponseForErrors:httpResponse uploadType:MPUploadTypeAlias]; + NSTimeInterval retryAfter = [[MPTransportErrorDetector calculateRetryTimeForTransportError] doubleValue]; + [self throttleWithRetryAfter:retryAfter uploadType:MPUploadTypeAlias]; } return YES; } From 689caa1d3872b2e0a62944b0170c33e55cfcb532 Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Mon, 18 May 2026 13:07:16 -0400 Subject: [PATCH 10/20] refactor: reuse shared mparticle instance in throttling Use a local shared instance reference in throttleWithRetryAfter to keep logger and state machine access consistent and avoid repeated singleton lookups. --- mParticle-Apple-SDK/Network/MPNetworkCommunication.m | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mParticle-Apple-SDK/Network/MPNetworkCommunication.m b/mParticle-Apple-SDK/Network/MPNetworkCommunication.m index 6ce35349e..e89fe76f5 100644 --- a/mParticle-Apple-SDK/Network/MPNetworkCommunication.m +++ b/mParticle-Apple-SDK/Network/MPNetworkCommunication.m @@ -441,12 +441,13 @@ - (BOOL)isRetriableTransportError:(NSError *)error { } - (void)throttleWithRetryAfter:(NSTimeInterval)retryAfter uploadType:(MPUploadType)uploadType { - MPLog *logger = MParticle.sharedInstance.getLogger; + MParticle* mparticle = MParticle.sharedInstance; + MPLog *logger = mparticle.getLogger; NSDate *now = [NSDate date]; NSDate *minUploadDate = [MParticle.sharedInstance.stateMachine minUploadDateForUploadType:uploadType]; if ([minUploadDate compare:now] == NSOrderedAscending) { - [MParticle.sharedInstance.stateMachine setMinUploadDate:[now dateByAddingTimeInterval:retryAfter] uploadType:uploadType]; + [mparticle.stateMachine setMinUploadDate:[now dateByAddingTimeInterval:retryAfter] uploadType:uploadType]; if (uploadType == MPUploadTypeMessage) { NSString *messageThrottleLog = [NSString stringWithFormat:@"Throttling uploads for %.0f seconds", retryAfter]; [logger debug:messageThrottleLog]; From e2e21c7e283344923518867d0fac65da03e7e055 Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Mon, 18 May 2026 14:28:29 -0400 Subject: [PATCH 11/20] fix method call --- .../Sources/Network/NSDictionary+MPRetryAfter.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mParticle-Apple-SDK-Swift/Sources/Network/NSDictionary+MPRetryAfter.swift b/mParticle-Apple-SDK-Swift/Sources/Network/NSDictionary+MPRetryAfter.swift index e96a95e85..45598c08d 100644 --- a/mParticle-Apple-SDK-Swift/Sources/Network/NSDictionary+MPRetryAfter.swift +++ b/mParticle-Apple-SDK-Swift/Sources/Network/NSDictionary+MPRetryAfter.swift @@ -6,7 +6,7 @@ import Foundation return nil } - return MPDateFormatter.dateFromStringRFC1123(headerValue) + return MPDateFormatter.date(fromStringRFC1123: headerValue) } func retrySeconds() -> NSNumber? { From 6ad7a854c26d06c7df34ce2a1bca5e966889caa5 Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Mon, 18 May 2026 14:56:10 -0400 Subject: [PATCH 12/20] fix target for tests --- mParticle-Apple-SDK.xcodeproj/project.pbxproj | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/mParticle-Apple-SDK.xcodeproj/project.pbxproj b/mParticle-Apple-SDK.xcodeproj/project.pbxproj index d05e1aadf..e742395d9 100644 --- a/mParticle-Apple-SDK.xcodeproj/project.pbxproj +++ b/mParticle-Apple-SDK.xcodeproj/project.pbxproj @@ -14,7 +14,6 @@ 351779502E706BF8004BF05E /* ExecutorMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3517794C2E706BE4004BF05E /* ExecutorMock.swift */; }; 35329FE92E54C38C009AC4FD /* MPNetworkOptions+MParticlePrivate.m in Sources */ = {isa = PBXBuildFile; fileRef = 35329FE82E54C38C009AC4FD /* MPNetworkOptions+MParticlePrivate.m */; }; 35329FEC2E54C483009AC4FD /* MPNetworkOptions+MParticlePrivateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35329FEB2E54C480009AC4FD /* MPNetworkOptions+MParticlePrivateTests.swift */; }; - 35A1B2C32F50011100A1B2C3 /* MPTransportErrorDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A1B2C22F50011100A1B2C3 /* MPTransportErrorDetectorTests.swift */; }; 35329FF02E54CA49009AC4FD /* MParticleOptions+MParticlePrivate.h in Headers */ = {isa = PBXBuildFile; fileRef = 35329FEE2E54CA49009AC4FD /* MParticleOptions+MParticlePrivate.h */; }; 35329FF32E54CA78009AC4FD /* MParticleOptions+MParticlePrivate.m in Sources */ = {isa = PBXBuildFile; fileRef = 35329FF12E54CA78009AC4FD /* MParticleOptions+MParticlePrivate.m */; }; 35329FF52E54CB8C009AC4FD /* MParticleOptions+MParticlePrivateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35329FF42E54CB84009AC4FD /* MParticleOptions+MParticlePrivateTests.swift */; }; @@ -26,6 +25,7 @@ 359BAFFC2E56335300A8A704 /* SettingsProvider.m in Sources */ = {isa = PBXBuildFile; fileRef = 359BAFFB2E56335300A8A704 /* SettingsProvider.m */; }; 359BAFFF2E575B0300A8A704 /* SettingsProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 359BAFFE2E575AF500A8A704 /* SettingsProviderTests.swift */; }; 359BB0062E57A56800A8A704 /* MPUserDefaultsMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 359BB0052E57A56300A8A704 /* MPUserDefaultsMock.swift */; }; + 35A1B2C32F50011100A1B2C3 /* MPTransportErrorDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A1B2C22F50011100A1B2C3 /* MPTransportErrorDetectorTests.swift */; }; 35C3DE702F2919B40077C0FD /* MPCCPAConsent.m in Sources */ = {isa = PBXBuildFile; fileRef = 35C3DE6F2F2919B40077C0FD /* MPCCPAConsent.m */; }; 35C3DE712F2919B40077C0FD /* MPCCPAConsent.h in Headers */ = {isa = PBXBuildFile; fileRef = 35C3DE6E2F2919B40077C0FD /* MPCCPAConsent.h */; settings = {ATTRIBUTES = (Public, ); }; }; 35C3DE732F291DA40077C0FD /* MPGDPRConsent.m in Sources */ = {isa = PBXBuildFile; fileRef = 35C3DE722F291DA40077C0FD /* MPGDPRConsent.m */; }; @@ -335,7 +335,6 @@ 3517794C2E706BE4004BF05E /* ExecutorMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutorMock.swift; sourceTree = ""; }; 35329FE82E54C38C009AC4FD /* MPNetworkOptions+MParticlePrivate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "MPNetworkOptions+MParticlePrivate.m"; sourceTree = ""; }; 35329FEB2E54C480009AC4FD /* MPNetworkOptions+MParticlePrivateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MPNetworkOptions+MParticlePrivateTests.swift"; sourceTree = ""; }; - 35A1B2C22F50011100A1B2C3 /* MPTransportErrorDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MPTransportErrorDetectorTests.swift; sourceTree = ""; }; 35329FEE2E54CA49009AC4FD /* MParticleOptions+MParticlePrivate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "MParticleOptions+MParticlePrivate.h"; sourceTree = ""; }; 35329FF12E54CA78009AC4FD /* MParticleOptions+MParticlePrivate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "MParticleOptions+MParticlePrivate.m"; sourceTree = ""; }; 35329FF42E54CB84009AC4FD /* MParticleOptions+MParticlePrivateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MParticleOptions+MParticlePrivateTests.swift"; sourceTree = ""; }; @@ -348,6 +347,7 @@ 359BAFFB2E56335300A8A704 /* SettingsProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SettingsProvider.m; sourceTree = ""; }; 359BAFFE2E575AF500A8A704 /* SettingsProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsProviderTests.swift; sourceTree = ""; }; 359BB0052E57A56300A8A704 /* MPUserDefaultsMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MPUserDefaultsMock.swift; sourceTree = ""; }; + 35A1B2C22F50011100A1B2C3 /* MPTransportErrorDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MPTransportErrorDetectorTests.swift; sourceTree = ""; }; 35C3DE6E2F2919B40077C0FD /* MPCCPAConsent.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MPCCPAConsent.h; sourceTree = ""; }; 35C3DE6F2F2919B40077C0FD /* MPCCPAConsent.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MPCCPAConsent.m; sourceTree = ""; }; 35C3DE722F291DA40077C0FD /* MPGDPRConsent.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MPGDPRConsent.m; sourceTree = ""; }; @@ -632,11 +632,13 @@ Test/Utils/MPDateFormatterTests.swift, Test/Utils/MPDeviceTests.swift, Test/Utils/MPLaunchInfoTests.m, + Test/Utils/MPNetworkCommunicationHelperTests.swift, Test/Utils/MPResponseConfigTests.swift, Test/Utils/MPUserAttributeChangeTests.m, Test/Utils/MPUserDefaultsTests.swift, Test/Utils/MPZipTests.m, "Test/Utils/NSArray+MPCaseInsensitiveTests.swift", + "Test/Utils/NSDictionary+MPRetryAfterTests.swift", "Test/Utils/NSNumber+MPFormatterTests.m", "Test/Utils/NSString+MPPercentEscapeTests.swift", ); @@ -651,11 +653,13 @@ Test/Utils/MPDateFormatterTests.swift, Test/Utils/MPDeviceTests.swift, Test/Utils/MPLaunchInfoTests.m, + Test/Utils/MPNetworkCommunicationHelperTests.swift, Test/Utils/MPResponseConfigTests.swift, Test/Utils/MPUserAttributeChangeTests.m, Test/Utils/MPUserDefaultsTests.swift, Test/Utils/MPZipTests.m, "Test/Utils/NSArray+MPCaseInsensitiveTests.swift", + "Test/Utils/NSDictionary+MPRetryAfterTests.swift", "Test/Utils/NSNumber+MPFormatterTests.m", "Test/Utils/NSString+MPPercentEscapeTests.swift", ); From 9258e61d8ea5077a5ff3d3cc3c88613f15f125a9 Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Tue, 19 May 2026 10:50:40 -0400 Subject: [PATCH 13/20] fix: restore SDK timeout detection with shared connector constants Promote the connector semaphore timeout domain/code to shared constants and reuse them in connector timeout errors. Restore transport detector handling for the SDK timeout signal and align unit expectations with the updated behavior. --- .../SwiftTests/MPTransportErrorDetectorTests.swift | 4 ++-- .../Sources/Network/MPTransportErrorDetector.swift | 5 ++++- mParticle-Apple-SDK/Network/MPConnector.h | 3 +++ mParticle-Apple-SDK/Network/MPConnector.m | 10 ++++++++-- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift b/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift index fe37b1db7..7cc39bdda 100644 --- a/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift +++ b/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift @@ -36,9 +36,9 @@ final class MPTransportErrorDetectorTests: XCTestCase { XCTAssertFalse(MPTransportErrorDetector.isRetriableTransportError(error)) } - func test_isRetriableTransportError_returnsFalse_forMParticleTimeoutError() { + func test_isRetriableTransportError_returnsTrue_forMParticleTimeoutError() { let error = NSError(domain: "com.mparticle", code: 0) - XCTAssertFalse(MPTransportErrorDetector.isRetriableTransportError(error)) + XCTAssertTrue(MPTransportErrorDetector.isRetriableTransportError(error)) } func test_isRetriableTransportError_returnsFalse_forUnknownError() { diff --git a/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift b/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift index b945206b1..ba6cf127f 100644 --- a/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift +++ b/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift @@ -6,6 +6,8 @@ import Foundation private static let retryAfterSchedule: [Double] = [60, 300, 1800, 21600] private static var consecutiveTransportErrorCount = 0 private static let backoffQueue = DispatchQueue(label: "com.mparticle.transport-error-backoff") + private static let semaphoreTimeoutErrorDomain = "com.mparticle" + private static let semaphoreTimeoutErrorCode = 0 @objc(isRetriableTransportError:) public static func isRetriableTransportError(_ error: NSError?) -> Bool { @@ -31,7 +33,8 @@ import Foundation } } - return false + return error.domain == semaphoreTimeoutErrorDomain + && error.code == semaphoreTimeoutErrorCode } @objc(calculateRetryTimeForTransportError) diff --git a/mParticle-Apple-SDK/Network/MPConnector.h b/mParticle-Apple-SDK/Network/MPConnector.h index 1473bbcdb..16e4a447f 100644 --- a/mParticle-Apple-SDK/Network/MPConnector.h +++ b/mParticle-Apple-SDK/Network/MPConnector.h @@ -4,6 +4,9 @@ @class MPURL; +FOUNDATION_EXPORT NSErrorDomain const MPConnectorSemaphoreTimeoutErrorDomain; +FOUNDATION_EXPORT NSInteger const MPConnectorSemaphoreTimeoutErrorCode; + typedef NS_ENUM(NSInteger, HTTPStatusCode) { HTTPStatusCodeSuccess = 200, HTTPStatusCodeCreated = 201, diff --git a/mParticle-Apple-SDK/Network/MPConnector.m b/mParticle-Apple-SDK/Network/MPConnector.m index 07f54054c..13f8559c9 100644 --- a/mParticle-Apple-SDK/Network/MPConnector.m +++ b/mParticle-Apple-SDK/Network/MPConnector.m @@ -8,6 +8,8 @@ #import "MPURL.h" static NSArray *mpStoredCertificates = nil; +NSErrorDomain const MPConnectorSemaphoreTimeoutErrorDomain = @"com.mparticle"; +NSInteger const MPConnectorSemaphoreTimeoutErrorCode = 0; @implementation MPConnectorResponse @@ -273,7 +275,9 @@ - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didComp (long)completionHttpResponse.statusCode, (unsigned long)completionData.length); } else { MPILogError(@"GET request timed out after %ld seconds - host: %@", (long)(NETWORK_REQUEST_MAX_WAIT_SECONDS + 1), url.url.host); - response.error = [NSError errorWithDomain:@"com.mparticle" code:0 userInfo:@{@"mParticle Error":@"Semaphore wait timed out"}]; + response.error = [NSError errorWithDomain:MPConnectorSemaphoreTimeoutErrorDomain + code:MPConnectorSemaphoreTimeoutErrorCode + userInfo:@{@"mParticle Error":@"Semaphore wait timed out"}]; [_urlSession invalidateAndCancel]; } @@ -318,7 +322,9 @@ - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didComp (long)completionHttpResponse.statusCode, (unsigned long)completionData.length); } else { MPILogError(@"POST request timed out after %ld seconds - host: %@", (long)(NETWORK_REQUEST_MAX_WAIT_SECONDS + 1), url.url.host); - response.error = [NSError errorWithDomain:@"com.mparticle" code:0 userInfo:@{@"mParticle Error":@"Semaphore wait timed out"}]; + response.error = [NSError errorWithDomain:MPConnectorSemaphoreTimeoutErrorDomain + code:MPConnectorSemaphoreTimeoutErrorCode + userInfo:@{@"mParticle Error":@"Semaphore wait timed out"}]; [_urlSession invalidateAndCancel]; } } else { From 6621ba72f908e4981a0fca078499cd9795d5138c Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Tue, 19 May 2026 10:55:30 -0400 Subject: [PATCH 14/20] fix: treat offline and timeout as retriable transport errors Classify NSURLErrorNotConnectedToInternet and NSURLErrorTimedOut as retriable transport failures and update detector tests to match the restored behavior. --- UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift | 8 ++++---- .../Sources/Network/MPTransportErrorDetector.swift | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift b/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift index 7cc39bdda..4061375b4 100644 --- a/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift +++ b/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift @@ -26,14 +26,14 @@ final class MPTransportErrorDetectorTests: XCTestCase { XCTAssertFalse(MPTransportErrorDetector.isRetriableTransportError(error)) } - func test_isRetriableTransportError_returnsFalse_whenNSURLErrorIsNotConnectedToInternet() { + func test_isRetriableTransportError_returnsTrue_whenNSURLErrorIsNotConnectedToInternet() { let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet) - XCTAssertFalse(MPTransportErrorDetector.isRetriableTransportError(error)) + XCTAssertTrue(MPTransportErrorDetector.isRetriableTransportError(error)) } - func test_isRetriableTransportError_returnsFalse_whenNSURLErrorIsTimedOut() { + func test_isRetriableTransportError_returnsTrue_whenNSURLErrorIsTimedOut() { let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) - XCTAssertFalse(MPTransportErrorDetector.isRetriableTransportError(error)) + XCTAssertTrue(MPTransportErrorDetector.isRetriableTransportError(error)) } func test_isRetriableTransportError_returnsTrue_forMParticleTimeoutError() { diff --git a/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift b/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift index ba6cf127f..ffeef9e74 100644 --- a/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift +++ b/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift @@ -19,8 +19,10 @@ import Foundation switch error.code { case NSURLErrorCannotFindHost, NSURLErrorCannotConnectToHost, + NSURLErrorNotConnectedToInternet, NSURLErrorNetworkConnectionLost, NSURLErrorDNSLookupFailed, + NSURLErrorTimedOut, NSURLErrorCannotLoadFromNetwork, NSURLErrorSecureConnectionFailed, NSURLErrorInternationalRoamingOff, From 8564a5270d0d63e20325fe1894a7c2f119f65566 Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Tue, 19 May 2026 10:56:50 -0400 Subject: [PATCH 15/20] fix: clamp transport retry schedule indexing Guard against empty retry schedules and clamp the calculated backoff index to the last available entry to avoid out-of-bounds access if constants drift. --- .../Sources/Network/MPTransportErrorDetector.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift b/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift index ffeef9e74..8a822565c 100644 --- a/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift +++ b/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift @@ -48,7 +48,14 @@ import Foundation return NSNumber(value: maxRetryAfter) } - let scheduleIndex = max(0, consecutiveTransportErrorCount - 1) + guard !retryAfterSchedule.isEmpty else { + return NSNumber(value: maxRetryAfter) + } + + let scheduleIndex = min( + max(0, consecutiveTransportErrorCount - 1), + retryAfterSchedule.count - 1 + ) return NSNumber(value: retryAfterSchedule[scheduleIndex]) } } From edd4b6721b3bc1586d9e9c73f5f8a27093882dde Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Tue, 19 May 2026 11:16:54 -0400 Subject: [PATCH 16/20] fix: reduce transport backoff schedule to short windows Lower transport retry intervals to a 5s/15s/60s/120s/300s progression and cap at 5 minutes to avoid long upload freezes after transient connectivity failures. --- .../SwiftTests/MPTransportErrorDetectorTests.swift | 14 +++++++------- .../Sources/Network/MPTransportErrorDetector.swift | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift b/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift index 4061375b4..ffa55358a 100644 --- a/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift +++ b/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift @@ -47,7 +47,7 @@ final class MPTransportErrorDetectorTests: XCTestCase { } func test_calculateRetryTimeForTransportError_usesSmallValueForFirstError() { - XCTAssertEqual(MPTransportErrorDetector.calculateRetryTimeForTransportError().doubleValue, 60) + XCTAssertEqual(MPTransportErrorDetector.calculateRetryTimeForTransportError().doubleValue, 5) } func test_calculateRetryTimeForTransportError_reachesMaxAtFiveErrors() { @@ -55,15 +55,15 @@ final class MPTransportErrorDetectorTests: XCTestCase { _ = MPTransportErrorDetector.calculateRetryTimeForTransportError() } - XCTAssertEqual(MPTransportErrorDetector.calculateRetryTimeForTransportError().doubleValue, 86400) + XCTAssertEqual(MPTransportErrorDetector.calculateRetryTimeForTransportError().doubleValue, 300) } func test_calculateRetryTimeForTransportError_usesExpectedBackoffSchedule() { + XCTAssertEqual(MPTransportErrorDetector.calculateRetryTimeForTransportError().doubleValue, 5) + XCTAssertEqual(MPTransportErrorDetector.calculateRetryTimeForTransportError().doubleValue, 15) XCTAssertEqual(MPTransportErrorDetector.calculateRetryTimeForTransportError().doubleValue, 60) + XCTAssertEqual(MPTransportErrorDetector.calculateRetryTimeForTransportError().doubleValue, 120) XCTAssertEqual(MPTransportErrorDetector.calculateRetryTimeForTransportError().doubleValue, 300) - XCTAssertEqual(MPTransportErrorDetector.calculateRetryTimeForTransportError().doubleValue, 1800) - XCTAssertEqual(MPTransportErrorDetector.calculateRetryTimeForTransportError().doubleValue, 21600) - XCTAssertEqual(MPTransportErrorDetector.calculateRetryTimeForTransportError().doubleValue, 86400) } func test_calculateRetryTimeForTransportError_staysAtMaxAfterThreshold() { @@ -71,7 +71,7 @@ final class MPTransportErrorDetectorTests: XCTestCase { _ = MPTransportErrorDetector.calculateRetryTimeForTransportError() } - XCTAssertEqual(MPTransportErrorDetector.calculateRetryTimeForTransportError().doubleValue, 86400) + XCTAssertEqual(MPTransportErrorDetector.calculateRetryTimeForTransportError().doubleValue, 300) } func test_resetTransportErrorCounter_resetsBackoff() { @@ -79,6 +79,6 @@ final class MPTransportErrorDetectorTests: XCTestCase { _ = MPTransportErrorDetector.calculateRetryTimeForTransportError() MPTransportErrorDetector.resetTransportErrorCounter() - XCTAssertEqual(MPTransportErrorDetector.calculateRetryTimeForTransportError().doubleValue, 60) + XCTAssertEqual(MPTransportErrorDetector.calculateRetryTimeForTransportError().doubleValue, 5) } } diff --git a/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift b/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift index 8a822565c..2ad0c47f2 100644 --- a/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift +++ b/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift @@ -1,9 +1,9 @@ import Foundation @objc public class MPTransportErrorDetector: NSObject { - private static let maxRetryAfter: Double = 86400 + private static let maxRetryAfter: Double = 300 private static let maxErrorCountBeforeMaxRetry = 5 - private static let retryAfterSchedule: [Double] = [60, 300, 1800, 21600] + private static let retryAfterSchedule: [Double] = [5, 15, 60, 120, 300] private static var consecutiveTransportErrorCount = 0 private static let backoffQueue = DispatchQueue(label: "com.mparticle.transport-error-backoff") private static let semaphoreTimeoutErrorDomain = "com.mparticle" From 03cfc9970a03ff1321a027815aabe6d511833b8a Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Tue, 19 May 2026 12:52:33 -0400 Subject: [PATCH 17/20] deduplicate constants --- .../MPTransportErrorDetectorTests.swift | 5 ++++- .../Network/MPTransportErrorDetector.swift | 18 ++++++++++++++---- mParticle-Apple-SDK/Network/MPConnector.h | 3 --- mParticle-Apple-SDK/Network/MPConnector.m | 16 ++++++++++------ 4 files changed, 28 insertions(+), 14 deletions(-) diff --git a/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift b/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift index ffa55358a..35166d370 100644 --- a/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift +++ b/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift @@ -37,7 +37,10 @@ final class MPTransportErrorDetectorTests: XCTestCase { } func test_isRetriableTransportError_returnsTrue_forMParticleTimeoutError() { - let error = NSError(domain: "com.mparticle", code: 0) + let error = NSError( + domain: MPTransportErrorDetector.semaphoreTimeoutErrorDomain() as String, + code: MPTransportErrorDetector.semaphoreTimeoutErrorCode().intValue + ) XCTAssertTrue(MPTransportErrorDetector.isRetriableTransportError(error)) } diff --git a/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift b/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift index 2ad0c47f2..b5d209868 100644 --- a/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift +++ b/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift @@ -6,8 +6,18 @@ import Foundation private static let retryAfterSchedule: [Double] = [5, 15, 60, 120, 300] private static var consecutiveTransportErrorCount = 0 private static let backoffQueue = DispatchQueue(label: "com.mparticle.transport-error-backoff") - private static let semaphoreTimeoutErrorDomain = "com.mparticle" - private static let semaphoreTimeoutErrorCode = 0 + private static let semaphoreTimeoutErrorDomainValue = "com.mparticle" + private static let semaphoreTimeoutErrorCodeValue = 0 + + @objc(semaphoreTimeoutErrorDomain) + public static func semaphoreTimeoutErrorDomain() -> NSString { + semaphoreTimeoutErrorDomainValue as NSString + } + + @objc(semaphoreTimeoutErrorCode) + public static func semaphoreTimeoutErrorCode() -> NSNumber { + NSNumber(value: semaphoreTimeoutErrorCodeValue) + } @objc(isRetriableTransportError:) public static func isRetriableTransportError(_ error: NSError?) -> Bool { @@ -35,8 +45,8 @@ import Foundation } } - return error.domain == semaphoreTimeoutErrorDomain - && error.code == semaphoreTimeoutErrorCode + return error.domain == semaphoreTimeoutErrorDomainValue + && error.code == semaphoreTimeoutErrorCodeValue } @objc(calculateRetryTimeForTransportError) diff --git a/mParticle-Apple-SDK/Network/MPConnector.h b/mParticle-Apple-SDK/Network/MPConnector.h index 16e4a447f..1473bbcdb 100644 --- a/mParticle-Apple-SDK/Network/MPConnector.h +++ b/mParticle-Apple-SDK/Network/MPConnector.h @@ -4,9 +4,6 @@ @class MPURL; -FOUNDATION_EXPORT NSErrorDomain const MPConnectorSemaphoreTimeoutErrorDomain; -FOUNDATION_EXPORT NSInteger const MPConnectorSemaphoreTimeoutErrorCode; - typedef NS_ENUM(NSInteger, HTTPStatusCode) { HTTPStatusCodeSuccess = 200, HTTPStatusCodeCreated = 201, diff --git a/mParticle-Apple-SDK/Network/MPConnector.m b/mParticle-Apple-SDK/Network/MPConnector.m index 13f8559c9..d6e168b11 100644 --- a/mParticle-Apple-SDK/Network/MPConnector.m +++ b/mParticle-Apple-SDK/Network/MPConnector.m @@ -6,10 +6,14 @@ #import "MPILogger.h" #import "mParticle.h" #import "MPURL.h" +@import mParticle_Apple_SDK_Swift; + +@interface MPTransportErrorDetector (MPConnectorTimeout) ++ (NSString *)semaphoreTimeoutErrorDomain; ++ (NSNumber *)semaphoreTimeoutErrorCode; +@end static NSArray *mpStoredCertificates = nil; -NSErrorDomain const MPConnectorSemaphoreTimeoutErrorDomain = @"com.mparticle"; -NSInteger const MPConnectorSemaphoreTimeoutErrorCode = 0; @implementation MPConnectorResponse @@ -275,8 +279,8 @@ - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didComp (long)completionHttpResponse.statusCode, (unsigned long)completionData.length); } else { MPILogError(@"GET request timed out after %ld seconds - host: %@", (long)(NETWORK_REQUEST_MAX_WAIT_SECONDS + 1), url.url.host); - response.error = [NSError errorWithDomain:MPConnectorSemaphoreTimeoutErrorDomain - code:MPConnectorSemaphoreTimeoutErrorCode + response.error = [NSError errorWithDomain:[MPTransportErrorDetector semaphoreTimeoutErrorDomain] + code:[MPTransportErrorDetector semaphoreTimeoutErrorCode].integerValue userInfo:@{@"mParticle Error":@"Semaphore wait timed out"}]; [_urlSession invalidateAndCancel]; } @@ -322,8 +326,8 @@ - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didComp (long)completionHttpResponse.statusCode, (unsigned long)completionData.length); } else { MPILogError(@"POST request timed out after %ld seconds - host: %@", (long)(NETWORK_REQUEST_MAX_WAIT_SECONDS + 1), url.url.host); - response.error = [NSError errorWithDomain:MPConnectorSemaphoreTimeoutErrorDomain - code:MPConnectorSemaphoreTimeoutErrorCode + response.error = [NSError errorWithDomain:[MPTransportErrorDetector semaphoreTimeoutErrorDomain] + code:[MPTransportErrorDetector semaphoreTimeoutErrorCode].integerValue userInfo:@{@"mParticle Error":@"Semaphore wait timed out"}]; [_urlSession invalidateAndCancel]; } From 1f84236f5e02b392dca20e26cbf77aeef10bb9ac Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Tue, 19 May 2026 13:54:32 -0400 Subject: [PATCH 18/20] fix: tighten transport retries and align OneTrust selector Exclude ATS secure-connection failures from retriable transport errors with matching test coverage, and update OneTrust vendor details call to the current vendorId selector. --- .../onetrust/Sources/mParticle-OneTrust/MPKitOneTrust.m | 2 +- UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift | 8 ++++++++ .../Sources/Network/MPTransportErrorDetector.swift | 3 +-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Kits/onetrust/onetrust/Sources/mParticle-OneTrust/MPKitOneTrust.m b/Kits/onetrust/onetrust/Sources/mParticle-OneTrust/MPKitOneTrust.m index 42d458223..95689ec4e 100644 --- a/Kits/onetrust/onetrust/Sources/mParticle-OneTrust/MPKitOneTrust.m +++ b/Kits/onetrust/onetrust/Sources/mParticle-OneTrust/MPKitOneTrust.m @@ -196,7 +196,7 @@ -(MPConsentState *)updateVendorConsents:(NSDictionary*)consentMapping :(MPConsentState *)consentState { for(NSString *consentKey in [consentMapping allKeys]) { // Fetch consent keys from one trust and pre-populate - NSNumber *status = [OTPublishersHeadlessSDK.shared getVendorDetailsWithVendorID:consentKey for:mode][@"consent"]; + NSNumber *status = [OTPublishersHeadlessSDK.shared getVendorDetailsWithVendorId:consentKey for:mode][@"consent"]; consentState = [self createConsentEvent:consentKey :consentMapping :status :consentState]; } return consentState; diff --git a/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift b/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift index 35166d370..126dafa36 100644 --- a/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift +++ b/UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift @@ -36,6 +36,14 @@ final class MPTransportErrorDetectorTests: XCTestCase { XCTAssertTrue(MPTransportErrorDetector.isRetriableTransportError(error)) } + func test_isRetriableTransportError_returnsFalse_whenNSURLErrorIsAppTransportSecurityRequiresSecureConnection() { + let error = NSError( + domain: NSURLErrorDomain, + code: NSURLErrorAppTransportSecurityRequiresSecureConnection + ) + XCTAssertFalse(MPTransportErrorDetector.isRetriableTransportError(error)) + } + func test_isRetriableTransportError_returnsTrue_forMParticleTimeoutError() { let error = NSError( domain: MPTransportErrorDetector.semaphoreTimeoutErrorDomain() as String, diff --git a/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift b/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift index b5d209868..59945b83c 100644 --- a/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift +++ b/mParticle-Apple-SDK-Swift/Sources/Network/MPTransportErrorDetector.swift @@ -37,8 +37,7 @@ import Foundation NSURLErrorSecureConnectionFailed, NSURLErrorInternationalRoamingOff, NSURLErrorDataNotAllowed, - NSURLErrorCallIsActive, - NSURLErrorAppTransportSecurityRequiresSecureConnection: + NSURLErrorCallIsActive: return true default: return false From 8ab2f91e3a511d683c4cc8db0e6dd094bb2d5ce4 Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Tue, 19 May 2026 14:20:08 -0400 Subject: [PATCH 19/20] fix: use OneTrust vendorID selector for consent details Switch the OneTrust vendor consent lookup to the exported getVendorDetailsWithVendorID:for: selector so kit builds succeed with the current SDK headers. --- .../onetrust/Sources/mParticle-OneTrust/MPKitOneTrust.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Kits/onetrust/onetrust/Sources/mParticle-OneTrust/MPKitOneTrust.m b/Kits/onetrust/onetrust/Sources/mParticle-OneTrust/MPKitOneTrust.m index 95689ec4e..42d458223 100644 --- a/Kits/onetrust/onetrust/Sources/mParticle-OneTrust/MPKitOneTrust.m +++ b/Kits/onetrust/onetrust/Sources/mParticle-OneTrust/MPKitOneTrust.m @@ -196,7 +196,7 @@ -(MPConsentState *)updateVendorConsents:(NSDictionary*)consentMapping :(MPConsentState *)consentState { for(NSString *consentKey in [consentMapping allKeys]) { // Fetch consent keys from one trust and pre-populate - NSNumber *status = [OTPublishersHeadlessSDK.shared getVendorDetailsWithVendorId:consentKey for:mode][@"consent"]; + NSNumber *status = [OTPublishersHeadlessSDK.shared getVendorDetailsWithVendorID:consentKey for:mode][@"consent"]; consentState = [self createConsentEvent:consentKey :consentMapping :status :consentState]; } return consentState; From 9ef2c1cd4f087fd5b1d8dacfb6747b8fabf12aeb Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Tue, 19 May 2026 14:25:47 -0400 Subject: [PATCH 20/20] fix: namespace retry-after NSDictionary extension methods Prefix retry-after category methods with mp_ to avoid Objective-C selector collisions in host apps, and update helper and tests to use the namespaced API. --- .../Network/MPNetworkCommunicationHelper.swift | 4 ++-- .../Sources/Network/NSDictionary+MPRetryAfter.swift | 6 ++++-- .../Test/Utils/NSDictionary+MPRetryAfterTests.swift | 12 ++++++------ 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/mParticle-Apple-SDK-Swift/Sources/Network/MPNetworkCommunicationHelper.swift b/mParticle-Apple-SDK-Swift/Sources/Network/MPNetworkCommunicationHelper.swift index 79743dfb4..71ecafd7c 100644 --- a/mParticle-Apple-SDK-Swift/Sources/Network/MPNetworkCommunicationHelper.swift +++ b/mParticle-Apple-SDK-Swift/Sources/Network/MPNetworkCommunicationHelper.swift @@ -3,8 +3,8 @@ import Foundation @objc public final class MPNetworkCommunicationHelper: NSObject { @objc(calculateRetryTimeForHeaders:) public static func calculateRetryTime(for headers: NSDictionary) -> NSNumber { - let retryAfterDate = headers.retryDate() - let retryAfterSeconds = headers.retrySeconds() + let retryAfterDate = headers.mp_retryDate() + let retryAfterSeconds = headers.mp_retrySeconds() let defaultRetryAfter: Double = 7200 let maxRetryAfter: Double = 86400 diff --git a/mParticle-Apple-SDK-Swift/Sources/Network/NSDictionary+MPRetryAfter.swift b/mParticle-Apple-SDK-Swift/Sources/Network/NSDictionary+MPRetryAfter.swift index 45598c08d..4156ac091 100644 --- a/mParticle-Apple-SDK-Swift/Sources/Network/NSDictionary+MPRetryAfter.swift +++ b/mParticle-Apple-SDK-Swift/Sources/Network/NSDictionary+MPRetryAfter.swift @@ -1,7 +1,8 @@ import Foundation @objc public extension NSDictionary { - func retryDate() -> Date? { + @objc(mp_retryDate) + func mp_retryDate() -> Date? { guard let headerValue = self["Retry-After"] as? String else { return nil } @@ -9,7 +10,8 @@ import Foundation return MPDateFormatter.date(fromStringRFC1123: headerValue) } - func retrySeconds() -> NSNumber? { + @objc(mp_retrySeconds) + func mp_retrySeconds() -> NSNumber? { let headerValue = self["Retry-After"] if let number = headerValue as? NSNumber { diff --git a/mParticle-Apple-SDK-Swift/Test/Utils/NSDictionary+MPRetryAfterTests.swift b/mParticle-Apple-SDK-Swift/Test/Utils/NSDictionary+MPRetryAfterTests.swift index d2b7eafad..31b663a02 100644 --- a/mParticle-Apple-SDK-Swift/Test/Utils/NSDictionary+MPRetryAfterTests.swift +++ b/mParticle-Apple-SDK-Swift/Test/Utils/NSDictionary+MPRetryAfterTests.swift @@ -5,36 +5,36 @@ final class NSDictionaryMPRetryAfterTests: XCTestCase { func testRetryDateParsesRFC1123String() { let headers: NSDictionary = ["Retry-After": "Wed, 21 Oct 2015 07:28:00 GMT"] - XCTAssertNotNil(headers.retryDate()) + XCTAssertNotNil(headers.mp_retryDate()) } func testRetryDateReturnsNilForInvalidString() { let headers: NSDictionary = ["Retry-After": "not-a-date"] - XCTAssertNil(headers.retryDate()) + XCTAssertNil(headers.mp_retryDate()) } func testRetrySecondsReturnsNumberValue() { let headers: NSDictionary = ["Retry-After": NSNumber(value: 42)] - XCTAssertEqual(headers.retrySeconds()?.doubleValue, 42) + XCTAssertEqual(headers.mp_retrySeconds()?.doubleValue, 42) } func testRetrySecondsParsesStringValue() { let headers: NSDictionary = ["Retry-After": "123.5"] - XCTAssertEqual(headers.retrySeconds()?.doubleValue, 123.5) + XCTAssertEqual(headers.mp_retrySeconds()?.doubleValue, 123.5) } func testRetrySecondsReturnsNilForInvalidString() { let headers: NSDictionary = ["Retry-After": "not-a-number"] - XCTAssertNil(headers.retrySeconds()) + XCTAssertNil(headers.mp_retrySeconds()) } func testRetrySecondsReturnsNilWhenHeaderMissing() { let headers: NSDictionary = ["Other-Header": "1"] - XCTAssertNil(headers.retrySeconds()) + XCTAssertNil(headers.mp_retrySeconds()) } }