Skip to content

Commit 738c78e

Browse files
feat: Phase 4 - EventPipeline integration with smart retry system (#300)
Smart retry system with rate-limiting and exponential backoff. Co-authored-by: Michael Grosse Huelsewiesche <mihuelsewiesche@twilio.com>
1 parent c952a70 commit 738c78e

19 files changed

Lines changed: 1294 additions & 68 deletions

core/build.gradle

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,19 @@ compileKotlin {
1919

2020
test {
2121
useJUnitPlatform()
22+
23+
if (System.getenv("CI") != null) {
24+
// Prevent indefinite hangs in CI and surface the exact timed-out test.
25+
systemProperty "junit.jupiter.execution.timeout.default", "2 m"
26+
}
27+
28+
testLogging {
29+
events "failed", "skipped"
30+
exceptionFormat "full"
31+
showExceptions true
32+
showCauses true
33+
showStackTraces true
34+
}
2235
}
2336

2437
dependencies {

core/src/main/java/com/segment/analytics/kotlin/core/Configuration.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.segment.analytics.kotlin.core
33
import com.segment.analytics.kotlin.core.Constants.DEFAULT_API_HOST
44
import com.segment.analytics.kotlin.core.Constants.DEFAULT_CDN_HOST
55
import com.segment.analytics.kotlin.core.platform.policies.FlushPolicy
6+
import com.segment.analytics.kotlin.core.retry.HttpConfig
67
import com.segment.analytics.kotlin.core.utilities.ConcreteStorageProvider
78
import kotlinx.coroutines.*
89
import sovran.kotlin.Store
@@ -21,6 +22,7 @@ import sovran.kotlin.Store
2122
* @property defaultSettings Settings object that will be used as fallback in case of network failure, defaults to empty
2223
* @property autoAddSegmentDestination automatically add SegmentDestination plugin, defaults to `true`
2324
* @property apiHost set a default apiHost to which Segment sends events, defaults to `api.segment.io/v1`
25+
* @property httpConfig HTTP retry configuration for rate limiting and exponential backoff, defaults to `null` (legacy mode)
2426
*/
2527
data class Configuration(
2628
val writeKey: String,
@@ -38,7 +40,8 @@ data class Configuration(
3840
var apiHost: String = DEFAULT_API_HOST,
3941
var cdnHost: String = DEFAULT_CDN_HOST,
4042
var requestFactory: RequestFactory = RequestFactory(),
41-
var errorHandler: ErrorHandler? = null
43+
var errorHandler: ErrorHandler? = null,
44+
var httpConfig: HttpConfig? = null
4245
) {
4346
fun isValid(): Boolean {
4447
return writeKey.isNotBlank() && application != null

core/src/main/java/com/segment/analytics/kotlin/core/Telemetry.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ object Telemetry: Subscriber {
276276
errorHandler?.invoke(e)
277277
if (e.responseCode == 429) {
278278
val headers = e.responseHeaders
279-
val rateLimit = headers["Retry-After"]?.firstOrNull()?.toLongOrNull()
279+
val rateLimit = headers["retry-after"]?.firstOrNull()?.toLongOrNull()
280280
if (rateLimit != null) {
281281
rateLimitEndTime = rateLimit + (System.currentTimeMillis() / 1000)
282282
}

core/src/main/java/com/segment/analytics/kotlin/core/platform/EventPipeline.kt

Lines changed: 128 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import com.segment.analytics.kotlin.core.platform.plugins.logger.LogKind
55
import com.segment.analytics.kotlin.core.platform.plugins.logger.log
66
import com.segment.analytics.kotlin.core.platform.plugins.logger.segmentLog
77
import com.segment.analytics.kotlin.core.platform.policies.FlushPolicy
8+
import com.segment.analytics.kotlin.core.retry.*
89
import com.segment.analytics.kotlin.core.utilities.EncodeDefaultsJson
910
import kotlinx.coroutines.channels.Channel
1011
import 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(

core/src/main/java/com/segment/analytics/kotlin/core/platform/plugins/SegmentDestination.kt

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import com.segment.analytics.kotlin.core.platform.policies.FrequencyFlushPolicy
1111
import com.segment.analytics.kotlin.core.retry.HttpConfig
1212
import kotlinx.coroutines.launch
1313
import kotlinx.serialization.Serializable
14+
import kotlinx.serialization.json.jsonObject
15+
import kotlinx.serialization.json.jsonPrimitive
16+
import kotlinx.serialization.json.booleanOrNull
1417
import sovran.kotlin.Subscriber
1518

1619
@Serializable
@@ -85,7 +88,8 @@ class SegmentDestination: DestinationPlugin(), VersionedPlugin, Subscriber {
8588
key,
8689
configuration.writeKey,
8790
flushPolicies,
88-
configuration.apiHost
91+
configuration.apiHost,
92+
configuration.httpConfig
8993
)
9094

9195
analyticsScope.launch(analyticsDispatcher) {
@@ -102,10 +106,33 @@ class SegmentDestination: DestinationPlugin(), VersionedPlugin, Subscriber {
102106
override fun update(settings: Settings, type: Plugin.UpdateType) {
103107
super.update(settings, type)
104108
if (settings.hasIntegrationSettings(this)) {
105-
// only populate the apiHost value if it exists
106-
settings.destinationSettings<SegmentSettings>(key)?.apiHost?.let {
109+
val segmentSettings = settings.destinationSettings<SegmentSettings>(key)
110+
111+
// Update apiHost if it exists
112+
segmentSettings?.apiHost?.let {
107113
pipeline?.apiHost = it
108114
}
115+
116+
// Read httpConfig from CDN settings and apply to pipeline
117+
segmentSettings?.httpConfig?.let { cdnConfig ->
118+
// CDN-sourced config defaults enabled to true (presence implies active).
119+
// Only honor explicit enabled: false from CDN.
120+
val rawJson = settings.integrations[key]?.jsonObject
121+
val httpConfigJson = rawJson?.get("httpConfig")?.jsonObject
122+
123+
val rlEnabled = httpConfigJson?.get("rateLimitConfig")?.jsonObject
124+
?.get("enabled")?.jsonPrimitive?.booleanOrNull
125+
val boEnabled = httpConfigJson?.get("backoffConfig")?.jsonObject
126+
?.get("enabled")?.jsonPrimitive?.booleanOrNull
127+
128+
val adjustedConfig = HttpConfig(
129+
rateLimitConfig = cdnConfig.rateLimitConfig.copy(enabled = rlEnabled ?: true),
130+
backoffConfig = cdnConfig.backoffConfig.copy(enabled = boEnabled ?: true)
131+
)
132+
133+
analytics.configuration.httpConfig = adjustedConfig
134+
pipeline?.updateHttpConfig(adjustedConfig)
135+
}
109136
}
110137
}
111138

core/src/main/java/com/segment/analytics/kotlin/core/platform/plugins/StartupQueue.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class StartupQueue : Plugin, Subscriber {
3232
subscriber = this@StartupQueue,
3333
stateClazz = System::class,
3434
initialState = true,
35+
queue = analyticsDispatcher,
3536
handler = this@StartupQueue::runningUpdate
3637
)
3738
}

core/src/main/java/com/segment/analytics/kotlin/core/retry/RetryConfig.kt

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,32 +6,42 @@ import kotlinx.serialization.encoding.Decoder
66
import kotlinx.serialization.encoding.Encoder
77
import kotlinx.serialization.json.*
88

9+
/**
10+
* Retry configuration for smart retry logic.
11+
* Both rate limiting and exponential backoff default to disabled (legacy mode).
12+
*/
913
@Serializable
1014
data class RetryConfig(
1115
val rateLimitConfig: RateLimitConfig = RateLimitConfig(),
16+
/**
17+
* Configuration for rate limiting (429 responses).
18+
* Default: disabled for backward compatibility.
19+
*/
1220
val backoffConfig: BackoffConfig = BackoffConfig()
1321
)
1422

1523
@Serializable
1624
data class RateLimitConfig(
17-
val enabled: Boolean = true,
25+
val enabled: Boolean = false,
1826
val maxRetryCount: Int = 100,
19-
val maxRetryInterval: Int = 300,
20-
val maxRateLimitDuration: Long = 43200
27+
val maxRetryInterval: Int = 300
2128
) {
2229
/**
2330
* Validate and clamp all numeric values to safe ranges.
2431
*/
2532
fun validated(): RateLimitConfig = copy(
2633
maxRetryCount = maxRetryCount.coerceIn(0, 1000),
27-
maxRetryInterval = maxRetryInterval.coerceIn(1, 3600),
28-
maxRateLimitDuration = maxRateLimitDuration.coerceIn(0, 604800)
34+
maxRetryInterval = maxRetryInterval.coerceIn(1, 3600)
2935
)
36+
/**
37+
* Configuration for exponential backoff on retryable errors.
38+
* Default: disabled for backward compatibility.
39+
*/
3040
}
3141

3242
@Serializable
3343
data class BackoffConfig(
34-
val enabled: Boolean = true,
44+
val enabled: Boolean = false,
3545
val maxRetryCount: Int = 100,
3646
val baseBackoffInterval: Double = 0.5,
3747
val maxBackoffInterval: Int = 300,

0 commit comments

Comments
 (0)