@@ -5,6 +5,7 @@ import com.segment.analytics.kotlin.core.platform.plugins.logger.LogKind
55import com.segment.analytics.kotlin.core.platform.plugins.logger.log
66import com.segment.analytics.kotlin.core.platform.plugins.logger.segmentLog
77import com.segment.analytics.kotlin.core.platform.policies.FlushPolicy
8+ import com.segment.analytics.kotlin.core.retry.*
89import com.segment.analytics.kotlin.core.utilities.EncodeDefaultsJson
910import kotlinx.coroutines.channels.Channel
1011import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
@@ -22,7 +23,9 @@ open class EventPipeline(
2223 private val logTag : String ,
2324 apiKey : String ,
2425 private val flushPolicies : List <FlushPolicy >,
25- var apiHost : String = Constants .DEFAULT_API_HOST
26+ var apiHost : String = Constants .DEFAULT_API_HOST ,
27+ private val httpConfig : HttpConfig ? = null ,
28+ private val timeProvider : TimeProvider = SystemTimeProvider ()
2629) {
2730
2831 private var writeChannel: Channel <BaseEvent >
@@ -39,6 +42,12 @@ open class EventPipeline(
3942
4043 protected open val networkIODispatcher get() = analytics.networkIODispatcher
4144
45+ // Retry state machine for smart retry logic
46+ private var retryStateMachine: RetryStateMachine
47+ private var retryState: RetryState
48+
49+
50+
4251 var running: Boolean
4352 private set
4453
@@ -53,6 +62,23 @@ open class EventPipeline(
5362
5463 writeChannel = Channel (UNLIMITED )
5564 uploadChannel = Channel (UNLIMITED )
65+
66+ // Initialize retry state machine with config (or defaults if null)
67+ // Convert HttpConfig to RetryConfig (they have the same structure)
68+ val retryConfig = httpConfig?.let {
69+ RetryConfig (
70+ rateLimitConfig = it.rateLimitConfig,
71+ backoffConfig = it.backoffConfig
72+ )
73+ } ? : RetryConfig ()
74+
75+ retryStateMachine = RetryStateMachine (
76+ retryConfig,
77+ timeProvider
78+ )
79+
80+ // Load persisted retry state (or start with defaults)
81+ retryState = storage.loadRetryState()
5682 }
5783
5884 fun put (event : BaseEvent ) {
@@ -87,6 +113,18 @@ open class EventPipeline(
87113 unschedule()
88114 }
89115
116+ /* *
117+ * Update the retry configuration from CDN settings.
118+ * Recreates the RetryStateMachine with the new config while preserving retry state.
119+ */
120+ fun updateHttpConfig (newConfig : HttpConfig ) {
121+ val retryConfig = RetryConfig (
122+ rateLimitConfig = newConfig.rateLimitConfig,
123+ backoffConfig = newConfig.backoffConfig
124+ )
125+ retryStateMachine = RetryStateMachine (retryConfig, timeProvider)
126+ }
127+
90128 open fun stringifyBaseEvent (payload : BaseEvent ): String {
91129 val finalPayload = EncodeDefaultsJson .encodeToJsonElement(payload)
92130 .jsonObject.filterNot { (k, v) ->
@@ -129,13 +167,74 @@ open class EventPipeline(
129167 storage.rollover()
130168 }
131169
170+ // Upload Gate - Check if pipeline is rate-limited
171+ val currentTime = timeProvider.currentTimeMillis()
172+ if (retryState.isRateLimited(currentTime)) {
173+ analytics.log(" $logTag skipping uploads: pipeline is rate-limited until ${retryState.waitUntilTime} " )
174+ return @consumeEach // Skip all uploads for this flush
175+ }
176+
177+ // Clear RATE_LIMITED state if wait time has passed
178+ val waitTime = retryState.waitUntilTime
179+ if (retryState.pipelineState == PipelineState .RATE_LIMITED &&
180+ waitTime != null &&
181+ currentTime >= waitTime) {
182+ retryState = retryState.copy(
183+ pipelineState = PipelineState .READY ,
184+ waitUntilTime = null
185+ )
186+ }
132187 val fileUrlList = parseFilePaths(storage.read(Storage .Constants .Events ))
133188 for (url in fileUrlList) {
189+ // Load batch metadata and check if we should upload
190+ val (decision, updatedState) = retryStateMachine.shouldUploadBatch(
191+ retryState,
192+ url
193+ )
194+ retryState = updatedState
195+
196+ // Check if we should skip this batch
197+ when (decision) {
198+ UploadDecision .SkipAllBatches -> {
199+ // This shouldn't happen here (caught by upload gate), but handle it
200+ analytics.log(" $logTag skipping remaining uploads" )
201+ break // Stop processing remaining files
202+ }
203+ UploadDecision .SkipThisBatch -> {
204+ analytics.log(" $logTag skipping batch $url : not ready for retry" )
205+ continue // Skip this file, continue with next
206+ }
207+ is UploadDecision .DropBatch -> {
208+ // Batch exceeded retry limits - delete it
209+ val reason = decision.reason
210+ analytics.log(" $logTag dropping batch $url : $reason " )
211+ analytics.reportInternalError(
212+ Exception (" Batch dropped: $reason " )
213+ )
214+ storage.removeFile(url)
215+ continue
216+ }
217+ UploadDecision .Proceed -> {
218+ // Continue with upload
219+ }
220+ }
221+ // Get retry count for X-Retry-Count header
222+ val retryCount = retryStateMachine.getRetryCount(retryState, url)
223+
134224 // upload event file
135225 var shouldCleanup = true
226+ var statusCode = 0
227+ var retryAfterSeconds: Int? = null
228+
136229 storage.readAsStream(url)?.use { data ->
137230 try {
138231 val connection = httpClient.upload(apiHost)
232+
233+ // Add X-Retry-Count header (only on retries, not first attempt)
234+ if (retryCount > 0 ) {
235+ connection.connection.setRequestProperty(" X-Retry-Count" , retryCount.toString())
236+ }
237+
139238 connection.outputStream?.let {
140239 // Write the payloads into the OutputStream
141240 data.copyTo(connection.outputStream)
@@ -144,21 +243,43 @@ open class EventPipeline(
144243 // Upload the payloads.
145244 connection.close()
146245 }
147- // Cleanup uploaded payloads
246+
247+ // Success!
248+ statusCode = 200
148249 analytics.log(" $logTag uploaded $url " )
149250 } catch (e: Exception ) {
150251 analytics.reportInternalError(e)
252+
253+ // Extract status code and retry-after from exception
254+ if (e is HTTPException ) {
255+ statusCode = e.responseCode
256+ retryAfterSeconds = (e.responseHeaders[" retry-after" ])?.firstOrNull()?.toIntOrNull()
257+ }
258+
151259 shouldCleanup = handleUploadException(e, url)
152260 }
153261 }
154262
263+ // Update retry state based on response
264+ val responseInfo = ResponseInfo (
265+ statusCode = if (statusCode > 0 ) statusCode else 500 , // Default to 500 for unknown errors
266+ retryAfterSeconds = retryAfterSeconds,
267+ batchFile = url,
268+ currentTime = timeProvider.currentTimeMillis()
269+ )
270+ retryState = retryStateMachine.handleResponse(retryState, responseInfo)
271+
272+ // Persist updated retry state
273+ withContext(fileIODispatcher) {
274+ storage.saveRetryState(retryState)
275+ }
276+
155277 if (shouldCleanup) {
156278 storage.removeFile(url)
157279 }
158280 }
159281 }
160282 }
161-
162283 private fun schedule () {
163284 flushPolicies.forEach { it.schedule(analytics) }
164285 }
@@ -173,12 +294,15 @@ open class EventPipeline(
173294 var shouldCleanup = false
174295 if (e is HTTPException ) {
175296 analytics.log(" $logTag exception while uploading, ${e.message} " )
176- if (e.is4xx() && e.responseCode != 429 ) {
297+ if (retryStateMachine.shouldDeleteBatch( e.responseCode) ) {
177298 // Simply log and proceed to remove the rejected payloads from the queue.
178299 Analytics .segmentLog(
179300 message = " Payloads were rejected by server. Marked for removal." ,
180301 kind = LogKind .ERROR
181302 )
303+ analytics.reportInternalError(
304+ Exception (" Batch dropped due to non-retryable HTTP ${e.responseCode} " )
305+ )
182306 shouldCleanup = true
183307 } else {
184308 Analytics .segmentLog(
0 commit comments