Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e0e7fbe
fix: throttle retriable transport upload failures
denischilik May 14, 2026
42b6d59
fix: simplify transport retry throttling path
denischilik May 14, 2026
3c29a84
fix: avoid throttling on offline and timeout errors
denischilik May 14, 2026
e88d1a8
fix: route throttle logging through MPLog
denischilik May 18, 2026
2ec9e9b
test: add retry-after dictionary parser coverage
denischilik May 18, 2026
c691131
fix: simplify retry-after throttle parsing
denischilik May 18, 2026
710af0a
fix: centralize retry-after calculation in network helper
denischilik May 18, 2026
874935c
fix: throttle transport errors with internal backoff state
denischilik May 18, 2026
6d90bdb
refactor: use a single throttle method with computed retry time
denischilik May 18, 2026
689caa1
refactor: reuse shared mparticle instance in throttling
denischilik May 18, 2026
e2e21c7
fix method call
denischilik May 18, 2026
6ad7a85
fix target for tests
denischilik May 18, 2026
9258e61
fix: restore SDK timeout detection with shared connector constants
denischilik May 19, 2026
6621ba7
fix: treat offline and timeout as retriable transport errors
denischilik May 19, 2026
8564a52
fix: clamp transport retry schedule indexing
denischilik May 19, 2026
edd4b67
fix: reduce transport backoff schedule to short windows
denischilik May 19, 2026
03cfc99
deduplicate constants
denischilik May 19, 2026
1f84236
fix: tighten transport retries and align OneTrust selector
denischilik May 19, 2026
8ab2f91
fix: use OneTrust vendorID selector for consent details
denischilik May 19, 2026
9ef2c1c
fix: namespace retry-after NSDictionary extension methods
denischilik May 19, 2026
5b13232
Merge branch 'main' into fix/retry-transport-detection
jamesnrokt May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions UnitTests/SwiftTests/MPTransportErrorDetectorTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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_returnsFalse_whenNoConnectionCode() {
let error = NSError(domain: "any-domain", code: 1)
XCTAssertFalse(MPTransportErrorDetector.isRetriableTransportError(error))
}

func test_isRetriableTransportError_returnsTrue_whenNSURLErrorIsRetriable() {
let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotConnectToHost)
XCTAssertTrue(MPTransportErrorDetector.isRetriableTransportError(error))
}

func test_isRetriableTransportError_returnsFalse_whenNSURLErrorIsNotRetriable() {
let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled)
XCTAssertFalse(MPTransportErrorDetector.isRetriableTransportError(error))
}

func test_isRetriableTransportError_returnsTrue_whenNSURLErrorIsNotConnectedToInternet() {
let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet)
XCTAssertTrue(MPTransportErrorDetector.isRetriableTransportError(error))
}

func test_isRetriableTransportError_returnsTrue_whenNSURLErrorIsTimedOut() {
let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)
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,
code: MPTransportErrorDetector.semaphoreTimeoutErrorCode().intValue
)
XCTAssertTrue(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, 5)
}

func test_calculateRetryTimeForTransportError_reachesMaxAtFiveErrors() {
for _ in 0..<4 {
_ = MPTransportErrorDetector.calculateRetryTimeForTransportError()
}

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)
}

func test_calculateRetryTimeForTransportError_staysAtMaxAfterThreshold() {
for _ in 0..<5 {
_ = MPTransportErrorDetector.calculateRetryTimeForTransportError()
}

XCTAssertEqual(MPTransportErrorDetector.calculateRetryTimeForTransportError().doubleValue, 300)
}

func test_resetTransportErrorCounter_resetsBackoff() {
_ = MPTransportErrorDetector.calculateRetryTimeForTransportError()
_ = MPTransportErrorDetector.calculateRetryTimeForTransportError()
MPTransportErrorDetector.resetTransportErrorCounter()

XCTAssertEqual(MPTransportErrorDetector.calculateRetryTimeForTransportError().doubleValue, 5)
}
}
Original file line number Diff line number Diff line change
@@ -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.mp_retryDate()
let retryAfterSeconds = headers.mp_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))
}
Comment thread
denischilik marked this conversation as resolved.

return NSNumber(value: defaultRetryAfter)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import Foundation

@objc public class MPTransportErrorDetector: NSObject {
private static let maxRetryAfter: Double = 300
private static let maxErrorCountBeforeMaxRetry = 5
private static let retryAfterSchedule: [Double] = [5, 15, 60, 120, 300]
private static var consecutiveTransportErrorCount = 0
Comment thread
denischilik marked this conversation as resolved.
private static let backoffQueue = DispatchQueue(label: "com.mparticle.transport-error-backoff")
private static let semaphoreTimeoutErrorDomainValue = "com.mparticle"
private static let semaphoreTimeoutErrorCodeValue = 0
Comment thread
denischilik marked this conversation as resolved.

@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 {
guard let error else {
return false
}

if error.domain == NSURLErrorDomain {
switch error.code {
case NSURLErrorCannotFindHost,
NSURLErrorCannotConnectToHost,
NSURLErrorNotConnectedToInternet,
NSURLErrorNetworkConnectionLost,
NSURLErrorDNSLookupFailed,
NSURLErrorTimedOut,
NSURLErrorCannotLoadFromNetwork,
NSURLErrorSecureConnectionFailed,
Comment thread
denischilik marked this conversation as resolved.
NSURLErrorInternationalRoamingOff,
NSURLErrorDataNotAllowed,
NSURLErrorCallIsActive:
return true
default:
return false
}
}
Comment thread
denischilik marked this conversation as resolved.

return error.domain == semaphoreTimeoutErrorDomainValue
&& error.code == semaphoreTimeoutErrorCodeValue
}

@objc(calculateRetryTimeForTransportError)
public static func calculateRetryTimeForTransportError() -> NSNumber {
return backoffQueue.sync {
consecutiveTransportErrorCount += 1

if consecutiveTransportErrorCount >= maxErrorCountBeforeMaxRetry {
return NSNumber(value: maxRetryAfter)
}

guard !retryAfterSchedule.isEmpty else {
return NSNumber(value: maxRetryAfter)
}

let scheduleIndex = min(
max(0, consecutiveTransportErrorCount - 1),
retryAfterSchedule.count - 1
)
return NSNumber(value: retryAfterSchedule[scheduleIndex])
}
}

@objc(resetTransportErrorCounter)
public static func resetTransportErrorCounter() {
backoffQueue.sync {
consecutiveTransportErrorCount = 0
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Foundation

@objc public extension NSDictionary {
@objc(mp_retryDate)
func mp_retryDate() -> Date? {
guard let headerValue = self["Retry-After"] as? String else {
return nil
}

return MPDateFormatter.date(fromStringRFC1123: headerValue)
}

@objc(mp_retrySeconds)
func mp_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
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
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.mp_retryDate())
}

func testRetryDateReturnsNilForInvalidString() {
let headers: NSDictionary = ["Retry-After": "not-a-date"]

XCTAssertNil(headers.mp_retryDate())
}

func testRetrySecondsReturnsNumberValue() {
let headers: NSDictionary = ["Retry-After": NSNumber(value: 42)]

XCTAssertEqual(headers.mp_retrySeconds()?.doubleValue, 42)
}

func testRetrySecondsParsesStringValue() {
let headers: NSDictionary = ["Retry-After": "123.5"]

XCTAssertEqual(headers.mp_retrySeconds()?.doubleValue, 123.5)
}

func testRetrySecondsReturnsNilForInvalidString() {
let headers: NSDictionary = ["Retry-After": "not-a-number"]

XCTAssertNil(headers.mp_retrySeconds())
}

func testRetrySecondsReturnsNilWhenHeaderMissing() {
let headers: NSDictionary = ["Other-Header": "1"]

XCTAssertNil(headers.mp_retrySeconds())
}
}
8 changes: 8 additions & 0 deletions mParticle-Apple-SDK.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -25,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 */; };
Expand Down Expand Up @@ -346,6 +347,7 @@
359BAFFB2E56335300A8A704 /* SettingsProvider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SettingsProvider.m; sourceTree = "<group>"; };
359BAFFE2E575AF500A8A704 /* SettingsProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsProviderTests.swift; sourceTree = "<group>"; };
359BB0052E57A56300A8A704 /* MPUserDefaultsMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MPUserDefaultsMock.swift; sourceTree = "<group>"; };
35A1B2C22F50011100A1B2C3 /* MPTransportErrorDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MPTransportErrorDetectorTests.swift; sourceTree = "<group>"; };
35C3DE6E2F2919B40077C0FD /* MPCCPAConsent.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MPCCPAConsent.h; sourceTree = "<group>"; };
35C3DE6F2F2919B40077C0FD /* MPCCPAConsent.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MPCCPAConsent.m; sourceTree = "<group>"; };
35C3DE722F291DA40077C0FD /* MPGDPRConsent.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MPGDPRConsent.m; sourceTree = "<group>"; };
Expand Down Expand Up @@ -630,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",
);
Expand All @@ -649,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",
);
Expand Down Expand Up @@ -1178,6 +1184,7 @@
35E3FCC22E53B5C200DB5B18 /* MPAttributionResult+MParticlePrivateTests.swift */,
72FEBD162E86FE2D00B8341F /* MPIdentityTests.swift */,
72D356522E8460020012A0C2 /* MPEventTests.swift */,
35A1B2C22F50011100A1B2C3 /* MPTransportErrorDetectorTests.swift */,
);
path = SwiftTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -1562,6 +1569,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 */,
Expand Down
Loading
Loading