Skip to content

Commit 74dfe94

Browse files
evan-masseauReca
andauthored
Retry all 5xx server errors (MAGE-694) (#485)
* Retry all 5xx server errors (MAGE-694) Widen the retryable classification from {429,500,502,503,504} to 429 plus the full 5xx range (500-599). This covers transient CDN/edge failures such as Cloudflare's 520-527 codes, which were observed during the cannot-access-klaviyo-com incident and had zero overlap with the old allowlist, causing data loss for the duration of the outage. 4xx codes (including 403 load-shed) remain non-retryable so the backend can still shed load. Adds tests for Cloudflare 522/525 and the 599 upper bound. 🤖 Generated with Reca * refine(analytics): exclude 501/505 from retryable 5xx + add bounds tests (MAGE-694) 501 (Not Implemented) and 505 (HTTP Version Not Supported) are permanent, deterministic server errors — retrying the identical request always fails the same way — so classify them as non-retryable (Failed) like a 4xx, via a 501, 505 -> branch placed before the in 500..599 retryable branch. The rest of 5xx (429 + 500..599 except 501/505) stays retryable. Adds tests: 501/505 non-retryable, plus 499 (lower-bound) and 600 (upper-bound) sanity checks that just-outside-5xx codes remain non-retryable. -Claude * refactor(analytics): name 5xx status constants + dedup boundary tests (CodeRabbit) Extract named constants for the previously-inlined HTTP status literals in parseResponse's when(responseCode): HTTP_NOT_IMPLEMENTED (501), HTTP_VERSION_NOT_SUPPORTED (505), and the HTTP_5XX_RETRYABLE_RANGE (500..599) range, placed alongside the existing HTTP_RETRY const. Behavior is identical. De-dup the single-send status-code tests behind a shared assertStatusForResponseCode(responseCode, expected) helper, removing the repeated mock/send/assert boilerplate while keeping the distinct, readable per-code test methods and their explanatory comments. Coverage is unchanged. -Claude * revert(analytics): retry entire 5xx range incl 501/505 (drop permanent-error exclusion) (MAGE-694) An earlier commit excluded 501 (Not Implemented) and 505 (HTTP Version Not Supported) as "permanent" server errors. Reverting that: for the SDK's fixed request shapes over the HTTP versions Klaviyo supports, a genuine origin 501/505 is effectively unreachable, so any 5xx we actually observe is almost certainly edge/CDN/proxy noise during an incident — exactly what MAGE-694 wants to retry. The incident data-retention benefit outweighs the negligible wasted-retry cost on codes we will never legitimately see. - Drop the 501/505 -> Status.Failed when-branch so they fall through to the retryable HTTP_5XX_RETRYABLE_RANGE branch like the rest of 500–599. - Remove the now-unused HTTP_NOT_IMPLEMENTED / HTTP_VERSION_NOT_SUPPORTED constants; keep HTTP_5XX_RETRYABLE_RANGE and HTTP_RETRY. - Flip the 501/505 tests to assert PendingRetry and document the deliberate retry. 499/600 bounds tests remain non-retryable. 4xx (incl. 403) unchanged. -Claude --------- Co-authored-by: Reca <reca@klaviyo.com>
1 parent 1cb6733 commit 74dfe94

2 files changed

Lines changed: 78 additions & 49 deletions

File tree

sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/KlaviyoApiRequest.kt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ internal open class KlaviyoApiRequest(
6969
const val HTTP_MULT_CHOICE = HttpURLConnection.HTTP_MULT_CHOICE
7070
const val HTTP_RETRY = 429 // oddly not a const in HttpURLConnection
7171
const val HTTP_BAD_REQUEST = HttpURLConnection.HTTP_BAD_REQUEST
72+
val HTTP_5XX_RETRYABLE_RANGE = 500..599
7273

7374
// JSON keys for persistence
7475
const val TYPE_JSON_KEY = "request_type"
@@ -432,8 +433,16 @@ internal open class KlaviyoApiRequest(
432433

433434
status = when (responseCode) {
434435
in successCodes -> Status.Complete
435-
// 429 rate limit, 500, 502, 503 and 504 are all treated as retryable
436-
HTTP_RETRY, 500, in 502..504 -> {
436+
// 429 rate limit plus the entire 5xx range (500–599) are treated as retryable.
437+
// Widening from the legacy {500,502,503,504} allowlist to the full range covers
438+
// transient CDN/edge failures such as Cloudflare's 520–527 codes, which were the
439+
// codes observed during the cannot-access-klaviyo-com incident. We deliberately
440+
// retry even 501 (Not Implemented) and 505 (HTTP Version Not Supported): for the
441+
// SDK's fixed request shapes a genuine origin 501/505 is effectively unreachable,
442+
// so any 5xx we actually observe is almost certainly edge/proxy noise during an
443+
// incident — exactly what we want to retry. 4xx codes (including 403 load-shed)
444+
// remain non-retryable so the backend can shed load.
445+
HTTP_RETRY, in HTTP_5XX_RETRYABLE_RANGE -> {
437446
if (attempts < maxAttempts) {
438447
Status.PendingRetry
439448
} else {

sdk/analytics/src/test/java/com/klaviyo/analytics/networking/requests/KlaviyoApiRequestTest.kt

Lines changed: 67 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,20 @@ internal class KlaviyoApiRequestTest : BaseApiRequestTest<KlaviyoApiRequest>() {
7575
return connectionSpy
7676
}
7777

78+
/**
79+
* Sends a single request against a connection stubbed to return [responseCode] and asserts the
80+
* resulting [KlaviyoApiRequest.Status] and that exactly one attempt was made. Shared by the
81+
* single-send status-code tests to keep their boilerplate DRY.
82+
*/
83+
private fun assertStatusForResponseCode(responseCode: Int, expected: KlaviyoApiRequest.Status) {
84+
val connectionMock = withConnectionMock(URL(expectedFullUrl))
85+
every { connectionMock.responseCode } returns responseCode
86+
87+
val request = makeTestRequest()
88+
assertEquals(expected, request.send())
89+
assertEquals(1, request.attempts)
90+
}
91+
7892
@Before
7993
override fun setup() {
8094
super.setup()
@@ -722,42 +736,68 @@ internal class KlaviyoApiRequestTest : BaseApiRequestTest<KlaviyoApiRequest>() {
722736

723737
@Test
724738
fun `500 response code returns PendingRetry when under max attempts`() {
725-
val connectionMock = withConnectionMock(URL(expectedFullUrl))
726-
every { connectionMock.responseCode } returns 500
727-
728-
val request = makeTestRequest()
729-
assertEquals(KlaviyoApiRequest.Status.PendingRetry, request.send())
730-
assertEquals(1, request.attempts)
739+
assertStatusForResponseCode(500, KlaviyoApiRequest.Status.PendingRetry)
731740
}
732741

733742
@Test
734743
fun `502 response code returns PendingRetry when under max attempts`() {
735-
val connectionMock = withConnectionMock(URL(expectedFullUrl))
736-
every { connectionMock.responseCode } returns 502
737-
738-
val request = makeTestRequest()
739-
assertEquals(KlaviyoApiRequest.Status.PendingRetry, request.send())
740-
assertEquals(1, request.attempts)
744+
assertStatusForResponseCode(502, KlaviyoApiRequest.Status.PendingRetry)
741745
}
742746

743747
@Test
744748
fun `503 response code returns PendingRetry when under max attempts`() {
745-
val connectionMock = withConnectionMock(URL(expectedFullUrl))
746-
every { connectionMock.responseCode } returns 503
747-
748-
val request = makeTestRequest()
749-
assertEquals(KlaviyoApiRequest.Status.PendingRetry, request.send())
750-
assertEquals(1, request.attempts)
749+
assertStatusForResponseCode(503, KlaviyoApiRequest.Status.PendingRetry)
751750
}
752751

753752
@Test
754753
fun `504 response code returns PendingRetry when under max attempts`() {
755-
val connectionMock = withConnectionMock(URL(expectedFullUrl))
756-
every { connectionMock.responseCode } returns 504
754+
assertStatusForResponseCode(504, KlaviyoApiRequest.Status.PendingRetry)
755+
}
757756

758-
val request = makeTestRequest()
759-
assertEquals(KlaviyoApiRequest.Status.PendingRetry, request.send())
760-
assertEquals(1, request.attempts)
757+
@Test
758+
fun `522 Cloudflare response code returns PendingRetry when under max attempts`() {
759+
// 522 (connection timed out) is a Cloudflare edge code outside the legacy allowlist
760+
// and was one of the codes observed during the cannot-access-klaviyo-com incident.
761+
assertStatusForResponseCode(522, KlaviyoApiRequest.Status.PendingRetry)
762+
}
763+
764+
@Test
765+
fun `525 Cloudflare response code returns PendingRetry when under max attempts`() {
766+
assertStatusForResponseCode(525, KlaviyoApiRequest.Status.PendingRetry)
767+
}
768+
769+
@Test
770+
fun `599 upper-bound 5xx response code returns PendingRetry when under max attempts`() {
771+
assertStatusForResponseCode(599, KlaviyoApiRequest.Status.PendingRetry)
772+
}
773+
774+
@Test
775+
fun `501 response code returns PendingRetry when under max attempts`() {
776+
// 501 (Not Implemented) is inside the full 5xx retryable range and we deliberately
777+
// retry it: for the SDK's fixed request shapes a genuine origin 501 is effectively
778+
// unreachable, so an observed 501 is almost certainly edge/proxy noise worth retrying.
779+
assertStatusForResponseCode(501, KlaviyoApiRequest.Status.PendingRetry)
780+
}
781+
782+
@Test
783+
fun `505 response code returns PendingRetry when under max attempts`() {
784+
// 505 (HTTP Version Not Supported) is inside the full 5xx retryable range and we
785+
// deliberately retry it: for the SDK's fixed request shapes a genuine origin 505 is
786+
// effectively unreachable, so an observed 505 is almost certainly edge/proxy noise
787+
// worth retrying.
788+
assertStatusForResponseCode(505, KlaviyoApiRequest.Status.PendingRetry)
789+
}
790+
791+
@Test
792+
fun `499 response code returns Failed immediately`() {
793+
// Lower-bound sanity: 499 sits just below the 5xx range and must stay non-retryable.
794+
assertStatusForResponseCode(499, KlaviyoApiRequest.Status.Failed)
795+
}
796+
797+
@Test
798+
fun `600 response code returns Failed immediately`() {
799+
// Upper-bound sanity: 600 sits just above the 5xx range and must stay non-retryable.
800+
assertStatusForResponseCode(600, KlaviyoApiRequest.Status.Failed)
761801
}
762802

763803
@Test
@@ -778,41 +818,21 @@ internal class KlaviyoApiRequestTest : BaseApiRequestTest<KlaviyoApiRequest>() {
778818

779819
@Test
780820
fun `400 response code returns Failed immediately`() {
781-
val connectionMock = withConnectionMock(URL(expectedFullUrl))
782-
every { connectionMock.responseCode } returns 400
783-
784-
val request = makeTestRequest()
785-
assertEquals(KlaviyoApiRequest.Status.Failed, request.send())
786-
assertEquals(1, request.attempts)
821+
assertStatusForResponseCode(400, KlaviyoApiRequest.Status.Failed)
787822
}
788823

789824
@Test
790825
fun `401 response code returns Failed immediately`() {
791-
val connectionMock = withConnectionMock(URL(expectedFullUrl))
792-
every { connectionMock.responseCode } returns 401
793-
794-
val request = makeTestRequest()
795-
assertEquals(KlaviyoApiRequest.Status.Failed, request.send())
796-
assertEquals(1, request.attempts)
826+
assertStatusForResponseCode(401, KlaviyoApiRequest.Status.Failed)
797827
}
798828

799829
@Test
800830
fun `403 response code returns Failed immediately`() {
801-
val connectionMock = withConnectionMock(URL(expectedFullUrl))
802-
every { connectionMock.responseCode } returns 403
803-
804-
val request = makeTestRequest()
805-
assertEquals(KlaviyoApiRequest.Status.Failed, request.send())
806-
assertEquals(1, request.attempts)
831+
assertStatusForResponseCode(403, KlaviyoApiRequest.Status.Failed)
807832
}
808833

809834
@Test
810835
fun `404 response code returns Failed immediately`() {
811-
val connectionMock = withConnectionMock(URL(expectedFullUrl))
812-
every { connectionMock.responseCode } returns 404
813-
814-
val request = makeTestRequest()
815-
assertEquals(KlaviyoApiRequest.Status.Failed, request.send())
816-
assertEquals(1, request.attempts)
836+
assertStatusForResponseCode(404, KlaviyoApiRequest.Status.Failed)
817837
}
818838
}

0 commit comments

Comments
 (0)