@@ -15,6 +15,8 @@ public enum HTTPClientErrors: Error {
1515 case failedToOpenBatch
1616 case statusCode( code: Int )
1717 case unknown( error: Error )
18+ case rateLimited
19+ case badRequest
1820}
1921
2022public class HTTPClient {
@@ -28,14 +30,28 @@ public class HTTPClient {
2830
2931 private weak var analytics : Analytics ?
3032
31- init ( analytics: Analytics ) {
33+ private var retryStateMachine : RetryStateMachine ?
34+ private var retryState : RetryState
35+ private let timeProvider : TimeProvider
36+
37+ init ( analytics: Analytics , timeProvider: TimeProvider = SystemTimeProvider ( ) ) {
3238 self . analytics = analytics
3339
3440 self . apiKey = analytics. configuration. values. writeKey
3541 self . apiHost = analytics. configuration. values. apiHost
3642 self . cdnHost = analytics. configuration. values. cdnHost
37-
43+
3844 self . session = analytics. configuration. values. httpSession ( )
45+ self . timeProvider = timeProvider
46+
47+ // Initialize retry system if httpConfig provided
48+ if let httpConfig = analytics. configuration. values. httpConfig {
49+ self . retryStateMachine = RetryStateMachine ( config: httpConfig, timeProvider: timeProvider)
50+ self . retryState = analytics. storage. loadRetryState ( )
51+ } else {
52+ self . retryStateMachine = nil
53+ self . retryState = RetryState ( ) // Legacy mode
54+ }
3955 }
4056
4157 func segmentURL( for host: String , path: String ) -> URL ? {
@@ -59,11 +75,40 @@ public class HTTPClient {
5975 return nil
6076 }
6177
62- let urlRequest = configuredRequest ( for: uploadURL, method: " POST " )
78+ // Check if we should upload this batch
79+ if let stateMachine = retryStateMachine {
80+ let ( decision, updatedState) = stateMachine. shouldUploadBatch ( state: retryState, batchFile: batch. lastPathComponent)
81+ retryState = updatedState
82+ analytics? . storage. saveRetryState ( retryState)
83+
84+ switch decision {
85+ case . skipAllBatches, . skipThisBatch:
86+ completion ( . failure( HTTPClientErrors . rateLimited) )
87+ return nil
88+ case . dropBatch:
89+ analytics? . reportInternalError ( AnalyticsError . batchUploadFail ( AnalyticsError . networkServerRejected ( nil , 0 ) ) )
90+ completion ( . failure( HTTPClientErrors . badRequest) )
91+ return nil
92+ case . proceed:
93+ break // Continue with upload
94+ }
95+ }
96+
97+ var urlRequest = configuredRequest ( for: uploadURL, method: " POST " )
98+
99+ let batchFileName = batch. lastPathComponent
100+
101+ // Add X-Retry-Count header
102+ if let stateMachine = retryStateMachine {
103+ let retryCount = stateMachine. getRetryCount ( state: retryState, batchFile: batchFileName)
104+ if retryCount > 0 {
105+ urlRequest. addValue ( " \( retryCount) " , forHTTPHeaderField: " X-Retry-Count " )
106+ }
107+ }
63108
64109 let dataTask = session. uploadTask ( with: urlRequest, fromFile: batch) { [ weak self] ( data, response, error) in
65110 guard let self else { return }
66- handleResponse ( data: data, response: response, error: error, url: uploadURL, completion: completion)
111+ handleResponse ( data: data, response: response, error: error, url: uploadURL, batchFile : batchFileName , completion: completion)
67112 }
68113
69114 dataTask. resume ( )
@@ -77,30 +122,54 @@ public class HTTPClient {
77122 /// - batch: The array of the events, considered a batch of events.
78123 /// - completion: The closure executed when done. Passes if the task should be retried or not if failed.
79124 @discardableResult
80- func startBatchUpload( writeKey: String , data: Data , completion: @escaping ( _ result: Result < Bool , Error > ) -> Void ) -> ( any UploadTask ) ? {
125+ func startBatchUpload( writeKey: String , data: Data , batchId : String , completion: @escaping ( _ result: Result < Bool , Error > ) -> Void ) -> ( any UploadTask ) ? {
81126 guard let uploadURL = segmentURL ( for: apiHost, path: " /b " ) else {
82127 self . analytics? . reportInternalError ( HTTPClientErrors . failedToOpenBatch)
83128 completion ( . failure( HTTPClientErrors . failedToOpenBatch) )
84129 return nil
85130 }
86-
87- let urlRequest = configuredRequest ( for: uploadURL, method: " POST " )
131+
132+ var urlRequest = configuredRequest ( for: uploadURL, method: " POST " )
133+
134+ // Add X-Retry-Count header
135+ if let stateMachine = retryStateMachine {
136+ let retryCount = stateMachine. getRetryCount ( state: retryState, batchFile: batchId)
137+ if retryCount > 0 {
138+ urlRequest. addValue ( " \( retryCount) " , forHTTPHeaderField: " X-Retry-Count " )
139+ }
140+ }
88141
89142 let dataTask = session. uploadTask ( with: urlRequest, from: data) { [ weak self] ( data, response, error) in
90143 guard let self else { return }
91- handleResponse ( data: data, response: response, error: error, url: uploadURL, completion: completion)
144+ handleResponse ( data: data, response: response, error: error, url: uploadURL, batchFile : batchId , completion: completion)
92145 }
93-
146+
94147 dataTask. resume ( )
95148 return dataTask
96149 }
97150
98- private func handleResponse( data: Data ? , response: URLResponse ? , error: Error ? , url: URL ? , completion: @escaping ( _ result: Result < Bool , Error > ) -> Void ) {
151+ private func extractRetryAfter( from response: HTTPURLResponse ) -> Int ? {
152+ return response. value ( forHTTPHeaderField: " Retry-After " ) . flatMap { Int ( $0) }
153+ }
154+
155+ private func handleResponse( data: Data ? , response: URLResponse ? , error: Error ? , url: URL ? , batchFile: String , completion: @escaping ( _ result: Result < Bool , Error > ) -> Void ) {
99156 if let error = error {
100157 analytics? . log ( message: " Error uploading request \( error. localizedDescription) . " )
101158 analytics? . reportInternalError ( AnalyticsError . networkUnknown ( url, error) )
102159 completion ( . failure( HTTPClientErrors . unknown ( error: error) ) )
103160 } else if let httpResponse = response as? HTTPURLResponse {
161+ // Update retry state after response
162+ if let stateMachine = retryStateMachine {
163+ let responseInfo = ResponseInfo (
164+ statusCode: httpResponse. statusCode,
165+ retryAfterSeconds: extractRetryAfter ( from: httpResponse) ,
166+ batchFile: batchFile,
167+ currentTime: timeProvider. now ( )
168+ )
169+ retryState = stateMachine. handleResponse ( state: retryState, response: responseInfo)
170+ analytics? . storage. saveRetryState ( retryState)
171+ }
172+
104173 switch ( httpResponse. statusCode) {
105174 case 1 ..< 300 :
106175 completion ( . success( true ) )
@@ -161,6 +230,20 @@ public class HTTPClient {
161230 dataTask. resume ( )
162231 }
163232
233+ /// Returns true if the given status code should cause the batch to be dropped (not retried).
234+ func shouldDropBatch( forStatusCode code: Int ) -> Bool {
235+ return retryStateMachine? . shouldDropBatch ( statusCode: code) ?? ( code == 400 )
236+ }
237+
238+ /// Check if a batch should be uploaded, and update retry state accordingly.
239+ func checkBatchUpload( batchId: String ) -> UploadDecision {
240+ guard let stateMachine = retryStateMachine else { return . proceed }
241+ let ( decision, updatedState) = stateMachine. shouldUploadBatch ( state: retryState, batchFile: batchId)
242+ retryState = updatedState
243+ analytics? . storage. saveRetryState ( retryState)
244+ return decision
245+ }
246+
164247 deinit {
165248 // finish any tasks that may be processing
166249 session. finishTasksAndInvalidate ( )
0 commit comments