Skip to content

Commit a2636de

Browse files
didiergarciaclaude
andauthored
feat: implement TAPI retry handling - Phase 2 (Settings Integration) (#295)
* feat: implement Phase 2 - Settings Integration Task 9: Add httpConfig field to SegmentSettings - Added httpConfig: JsonObject? field to SegmentSettings data class - Imported kotlinx.serialization.json.JsonObject Task 10: Create RetryConfigParser with defensive parsing - Parse httpConfig JSON from CDN into RetryConfig - Comprehensive defensive error handling: * Returns defaults on null/corrupt JSON * Clamps all numeric values to safe ranges * Validates retry behaviors and status codes * Never throws exceptions - 17 tests covering: * Default handling * Value clamping * Status code overrides * Error recovery * Partial configurations Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * docs: clarify statusCodeOverrides empty object behavior Document that an empty statusCodeOverrides JSON object {} intentionally clears all defaults, allowing the CDN to remove overrides when needed. This differs from other fields but is the desired behavior. Addresses low-confidence copilot review comment. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * refactor: rename maxTotalBackoffDuration to maxRateLimitDuration in RateLimitConfig Renamed for clarity since 'backoff duration' semantically belongs to exponential backoff (5xx), not rate limiting (429). - RateLimitConfig.maxTotalBackoffDuration → maxRateLimitDuration - Updated parser to use new field name - All tests pass Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * refactor: replace RetryConfigParser with HttpConfig data class and custom serializer Address PR review feedback to use typed data class instead of manual JSON parsing. Changes: - Created HttpConfig data class with custom serializer - Added validation methods to RateLimitConfig and BackoffConfig - Updated SegmentSettings to use HttpConfig instead of JsonObject - Deleted RetryConfigParser (manual parsing no longer needed) - Created HttpConfigTest with 16 tests (covers validation and error handling) - Deleted RetryConfigParserTest (replaced by HttpConfigTest) Benefits: - Type-safe access to config properties (httpConfig.rateLimitConfig.enabled) - Automatic deserialization via kotlinx.serialization - Still provides defensive error handling via custom serializer - Maintains value clamping and validation - Cleaner code following codebase patterns Custom serializer ensures: - Corrupt JSON → returns default HttpConfig() - Out-of-range values → automatically clamped - Never throws exceptions All tests pass (16 new validation tests + 24 existing state machine tests) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 6e14402 commit a2636de

3 files changed

Lines changed: 320 additions & 4 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@ import com.segment.analytics.kotlin.core.platform.VersionedPlugin
88
import com.segment.analytics.kotlin.core.platform.policies.CountBasedFlushPolicy
99
import com.segment.analytics.kotlin.core.platform.policies.FlushPolicy
1010
import com.segment.analytics.kotlin.core.platform.policies.FrequencyFlushPolicy
11+
import com.segment.analytics.kotlin.core.retry.HttpConfig
1112
import kotlinx.coroutines.launch
1213
import kotlinx.serialization.Serializable
1314
import sovran.kotlin.Subscriber
1415

1516
@Serializable
1617
data class SegmentSettings(
1718
var apiKey: String,
18-
var apiHost: String? = null
19+
var apiHost: String? = null,
20+
var httpConfig: HttpConfig? = null
1921
)
2022

2123
/**

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

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package com.segment.analytics.kotlin.core.retry
22

3-
import kotlinx.serialization.Serializable
3+
import kotlinx.serialization.*
4+
import kotlinx.serialization.descriptors.SerialDescriptor
5+
import kotlinx.serialization.encoding.Decoder
6+
import kotlinx.serialization.encoding.Encoder
7+
import kotlinx.serialization.json.*
48

59
@Serializable
610
data class RetryConfig(
@@ -13,8 +17,17 @@ data class RateLimitConfig(
1317
val enabled: Boolean = true,
1418
val maxRetryCount: Int = 100,
1519
val maxRetryInterval: Int = 300,
16-
val maxTotalBackoffDuration: Long = 43200
17-
)
20+
val maxRateLimitDuration: Long = 43200
21+
) {
22+
/**
23+
* Validate and clamp all numeric values to safe ranges.
24+
*/
25+
fun validated(): RateLimitConfig = copy(
26+
maxRetryCount = maxRetryCount.coerceIn(0, 1000),
27+
maxRetryInterval = maxRetryInterval.coerceIn(1, 3600),
28+
maxRateLimitDuration = maxRateLimitDuration.coerceIn(0, 604800)
29+
)
30+
}
1831

1932
@Serializable
2033
data class BackoffConfig(
@@ -35,4 +48,80 @@ data class BackoffConfig(
3548
501 to RetryBehavior.DROP,
3649
505 to RetryBehavior.DROP
3750
)
51+
) {
52+
/**
53+
* Validate and clamp all numeric values to safe ranges.
54+
* Filter status code overrides to valid range (100-599).
55+
*/
56+
fun validated(): BackoffConfig = copy(
57+
maxRetryCount = maxRetryCount.coerceIn(0, 1000),
58+
baseBackoffInterval = baseBackoffInterval.coerceIn(0.1, 60.0),
59+
maxBackoffInterval = maxBackoffInterval.coerceIn(1, 3600),
60+
maxTotalBackoffDuration = maxTotalBackoffDuration.coerceIn(0, 604800),
61+
jitterPercent = jitterPercent.coerceIn(0, 50),
62+
statusCodeOverrides = statusCodeOverrides.filterKeys { it in 100..599 }
63+
)
64+
}
65+
66+
/**
67+
* HTTP configuration for retry handling, parsed from CDN settings.
68+
*
69+
* Uses custom serializer to provide defensive error handling:
70+
* - Corrupt JSON gracefully falls back to defaults
71+
* - All numeric values are automatically clamped to safe ranges
72+
* - Invalid status codes are filtered out
73+
*/
74+
@Serializable(with = HttpConfigSerializer::class)
75+
data class HttpConfig(
76+
val rateLimitConfig: RateLimitConfig = RateLimitConfig(),
77+
val backoffConfig: BackoffConfig = BackoffConfig()
3878
)
79+
80+
/**
81+
* Custom serializer for HttpConfig that provides defensive error handling.
82+
*
83+
* On deserialization:
84+
* - Validates and clamps all numeric values to safe ranges
85+
* - Returns default HttpConfig on any SerializationException
86+
* - Never throws exceptions
87+
*/
88+
object HttpConfigSerializer : KSerializer<HttpConfig> {
89+
@Serializable
90+
@SerialName("HttpConfig")
91+
private data class HttpConfigSurrogate(
92+
val rateLimitConfig: RateLimitConfig = RateLimitConfig(),
93+
val backoffConfig: BackoffConfig = BackoffConfig()
94+
)
95+
96+
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
97+
98+
override val descriptor: SerialDescriptor = HttpConfigSurrogate.serializer().descriptor
99+
100+
override fun serialize(encoder: Encoder, value: HttpConfig) {
101+
val surrogate = HttpConfigSurrogate(
102+
rateLimitConfig = value.rateLimitConfig,
103+
backoffConfig = value.backoffConfig
104+
)
105+
encoder.encodeSerializableValue(HttpConfigSurrogate.serializer(), surrogate)
106+
}
107+
108+
override fun deserialize(decoder: Decoder): HttpConfig {
109+
return try {
110+
// Decode as JsonElement first for more defensive parsing
111+
val element = decoder.decodeSerializableValue(JsonElement.serializer())
112+
if (element !is JsonObject) {
113+
return HttpConfig()
114+
}
115+
116+
// Try to deserialize from the JsonElement
117+
val surrogate = json.decodeFromJsonElement(HttpConfigSurrogate.serializer(), element)
118+
HttpConfig(
119+
rateLimitConfig = surrogate.rateLimitConfig.validated(),
120+
backoffConfig = surrogate.backoffConfig.validated()
121+
)
122+
} catch (e: Exception) {
123+
// Any parse error → return defaults
124+
HttpConfig()
125+
}
126+
}
127+
}
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
package com.segment.analytics.kotlin.core.retry
2+
3+
import kotlinx.serialization.json.*
4+
import org.junit.jupiter.api.Assertions.*
5+
import org.junit.jupiter.api.Test
6+
7+
class HttpConfigTest {
8+
9+
@Test
10+
fun `HttpConfig deserializes from valid JSON`() {
11+
val json = """
12+
{
13+
"rateLimitConfig": {
14+
"enabled": true,
15+
"maxRetryCount": 50,
16+
"maxRetryInterval": 600,
17+
"maxRateLimitDuration": 7200
18+
},
19+
"backoffConfig": {
20+
"enabled": true,
21+
"maxRetryCount": 25,
22+
"baseBackoffInterval": 1.0,
23+
"maxBackoffInterval": 600,
24+
"maxTotalBackoffDuration": 3600,
25+
"jitterPercent": 20,
26+
"default4xxBehavior": "DROP",
27+
"default5xxBehavior": "RETRY",
28+
"unknownCodeBehavior": "DROP",
29+
"statusCodeOverrides": {
30+
"408": "RETRY",
31+
"501": "DROP"
32+
}
33+
}
34+
}
35+
""".trimIndent()
36+
37+
val config = Json.decodeFromString<HttpConfig>(json)
38+
39+
assertEquals(true, config.rateLimitConfig.enabled)
40+
assertEquals(50, config.rateLimitConfig.maxRetryCount)
41+
assertEquals(600, config.rateLimitConfig.maxRetryInterval)
42+
assertEquals(7200L, config.rateLimitConfig.maxRateLimitDuration)
43+
44+
assertEquals(true, config.backoffConfig.enabled)
45+
assertEquals(25, config.backoffConfig.maxRetryCount)
46+
assertEquals(1.0, config.backoffConfig.baseBackoffInterval)
47+
assertEquals(600, config.backoffConfig.maxBackoffInterval)
48+
assertEquals(3600L, config.backoffConfig.maxTotalBackoffDuration)
49+
assertEquals(20, config.backoffConfig.jitterPercent)
50+
assertEquals(RetryBehavior.RETRY, config.backoffConfig.statusCodeOverrides[408])
51+
assertEquals(RetryBehavior.DROP, config.backoffConfig.statusCodeOverrides[501])
52+
}
53+
54+
@Test
55+
fun `HttpConfig returns defaults on corrupt JSON`() {
56+
val json = """{"rateLimitConfig": "not an object"}"""
57+
58+
val config = Json.decodeFromString<HttpConfig>(json)
59+
60+
// Should return defaults without throwing
61+
assertEquals(true, config.rateLimitConfig.enabled)
62+
assertEquals(100, config.rateLimitConfig.maxRetryCount)
63+
assertEquals(true, config.backoffConfig.enabled)
64+
}
65+
66+
@Test
67+
fun `HttpConfig returns defaults on empty JSON`() {
68+
val json = "{}"
69+
70+
val config = Json.decodeFromString<HttpConfig>(json)
71+
72+
assertEquals(true, config.rateLimitConfig.enabled)
73+
assertEquals(100, config.rateLimitConfig.maxRetryCount)
74+
assertEquals(true, config.backoffConfig.enabled)
75+
}
76+
77+
@Test
78+
fun `HttpConfig handles partial configuration`() {
79+
val json = """
80+
{
81+
"rateLimitConfig": {
82+
"maxRetryCount": 75
83+
}
84+
}
85+
""".trimIndent()
86+
87+
val config = Json.decodeFromString<HttpConfig>(json)
88+
89+
assertEquals(75, config.rateLimitConfig.maxRetryCount)
90+
assertEquals(300, config.rateLimitConfig.maxRetryInterval) // Default
91+
assertEquals(true, config.backoffConfig.enabled) // Default
92+
}
93+
94+
@Test
95+
fun `RateLimitConfig validation clamps maxRetryCount`() {
96+
val config = RateLimitConfig(maxRetryCount = 2000)
97+
val validated = config.validated()
98+
99+
assertEquals(1000, validated.maxRetryCount) // Clamped to max
100+
}
101+
102+
@Test
103+
fun `RateLimitConfig validation clamps maxRetryInterval to max`() {
104+
val config = RateLimitConfig(maxRetryInterval = 5000)
105+
val validated = config.validated()
106+
107+
assertEquals(3600, validated.maxRetryInterval) // Clamped to 1 hour
108+
}
109+
110+
@Test
111+
fun `RateLimitConfig validation clamps maxRetryInterval to min`() {
112+
val config = RateLimitConfig(maxRetryInterval = 0)
113+
val validated = config.validated()
114+
115+
assertEquals(1, validated.maxRetryInterval) // Clamped to 1 second
116+
}
117+
118+
@Test
119+
fun `RateLimitConfig validation clamps maxRateLimitDuration`() {
120+
val config = RateLimitConfig(maxRateLimitDuration = 1000000)
121+
val validated = config.validated()
122+
123+
assertEquals(604800, validated.maxRateLimitDuration) // Clamped to 1 week
124+
}
125+
126+
@Test
127+
fun `BackoffConfig validation clamps maxRetryCount`() {
128+
val config = BackoffConfig(maxRetryCount = 2000)
129+
val validated = config.validated()
130+
131+
assertEquals(1000, validated.maxRetryCount) // Clamped to max
132+
}
133+
134+
@Test
135+
fun `BackoffConfig validation clamps baseBackoffInterval to max`() {
136+
val config = BackoffConfig(baseBackoffInterval = 100.0)
137+
val validated = config.validated()
138+
139+
assertEquals(60.0, validated.baseBackoffInterval) // Clamped to 60 seconds
140+
}
141+
142+
@Test
143+
fun `BackoffConfig validation clamps baseBackoffInterval to min`() {
144+
val config = BackoffConfig(baseBackoffInterval = 0.05)
145+
val validated = config.validated()
146+
147+
assertEquals(0.1, validated.baseBackoffInterval) // Clamped to 100ms
148+
}
149+
150+
@Test
151+
fun `BackoffConfig validation clamps jitterPercent`() {
152+
val config = BackoffConfig(jitterPercent = 100)
153+
val validated = config.validated()
154+
155+
assertEquals(50, validated.jitterPercent) // Clamped to 50%
156+
}
157+
158+
@Test
159+
fun `BackoffConfig validation filters invalid status codes`() {
160+
val config = BackoffConfig(
161+
statusCodeOverrides = mapOf(
162+
99 to RetryBehavior.RETRY, // Below valid range
163+
408 to RetryBehavior.RETRY, // Valid
164+
600 to RetryBehavior.RETRY // Above valid range
165+
)
166+
)
167+
val validated = config.validated()
168+
169+
assertNull(validated.statusCodeOverrides[99])
170+
assertNull(validated.statusCodeOverrides[600])
171+
assertEquals(RetryBehavior.RETRY, validated.statusCodeOverrides[408])
172+
}
173+
174+
@Test
175+
fun `BackoffConfig validation handles negative values`() {
176+
val config = BackoffConfig(
177+
maxRetryCount = -10,
178+
baseBackoffInterval = -1.0,
179+
jitterPercent = -5
180+
)
181+
val validated = config.validated()
182+
183+
assertEquals(0, validated.maxRetryCount) // Clamped to 0
184+
assertEquals(0.1, validated.baseBackoffInterval) // Clamped to min
185+
assertEquals(0, validated.jitterPercent) // Clamped to 0
186+
}
187+
188+
@Test
189+
fun `HttpConfig automatic validation clamps out-of-range values on deserialize`() {
190+
val json = """
191+
{
192+
"rateLimitConfig": {
193+
"maxRetryCount": 5000,
194+
"maxRetryInterval": 10000
195+
},
196+
"backoffConfig": {
197+
"jitterPercent": 200
198+
}
199+
}
200+
""".trimIndent()
201+
202+
val config = Json.decodeFromString<HttpConfig>(json)
203+
204+
// Values should be automatically clamped during deserialization
205+
assertEquals(1000, config.rateLimitConfig.maxRetryCount)
206+
assertEquals(3600, config.rateLimitConfig.maxRetryInterval)
207+
assertEquals(50, config.backoffConfig.jitterPercent)
208+
}
209+
210+
@Test
211+
fun `HttpConfig handles malformed JSON gracefully`() {
212+
val malformedJsons = listOf(
213+
"""{"rateLimitConfig": []}""", // Wrong type
214+
"""{"backoffConfig": 123}""", // Wrong type
215+
"""{"rateLimitConfig": {"maxRetryCount": "invalid"}}""" // Wrong value type
216+
)
217+
218+
malformedJsons.forEach { json ->
219+
val config = Json.decodeFromString<HttpConfig>(json)
220+
// Should return defaults without throwing
221+
assertNotNull(config)
222+
assertEquals(100, config.rateLimitConfig.maxRetryCount)
223+
}
224+
}
225+
}

0 commit comments

Comments
 (0)