Skip to content

feat: API client network-behavior RCA items (MAGE-694, MAGE-496, MAGE-500)#519

Open
evan-masseau wants to merge 18 commits into
rel/4.5.0from
feat/api-client-rca-items
Open

feat: API client network-behavior RCA items (MAGE-694, MAGE-496, MAGE-500)#519
evan-masseau wants to merge 18 commits into
rel/4.5.0from
feat/api-client-rca-items

Conversation

@evan-masseau

@evan-masseau evan-masseau commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Description

Note: All this code was previously reviewed in individual PRs.

Consolidated API-client network-behavior fixes from the post-incident RCA: widen 5xx retry coverage, cap the request queue at 200 with oldest-first eviction, and wait max(Retry-After, backoff). Merged down from the stacked chain into one feature branch targeting rel/4.5.0.

Due Diligence

  • I have tested this on an emulator and/or a physical device.
  • I have added sufficient unit/integration tests of my changes.
  • I have adjusted or added new test cases to team test docs, if applicable.
  • I am confident these changes are compatible with all Android versions the SDK currently supports.

Release/Versioning Considerations

  • Patch Contains internal changes or backwards-compatible bug fixes.
  • Minor Contains changes to the public API.
  • Major Contains breaking changes.
  • Contains readme or migration guide changes.
  • This is planned work for an upcoming release.

Public surface changes only via a @Deprecated no-op on Config.Builder.networkFlushDepth(Int) (source/binary compatible); all other changes are backwards-compatible behavior. Targeting rel/4.5.0.

Changelog / Code Overview

  • Retry the entire 5xx range (MAGE-694). Retry 429 + all 500–599 with no exclusions, so CDN/edge failures (e.g. Cloudflare 520–527) like those seen during the cannot-access-klaviyo.com incident are retried instead of dropped. 501/505 are deliberately included — for the SDK's fixed request shapes a genuine origin 501/505 is unreachable, so any 5xx we see is edge noise worth retrying (data-retention > a wasted retry).
  • Align queue-depth behavior with iOS (MAGE-496). Cap the request queue at 200 (MAX_QUEUE_SIZE); at capacity, evict the oldest request by enqueue timestamp (queuedTime) rather than the front of the deque, so freshly-inserted head-of-line/priority events are protected. Eviction drains fully to the cap even if the queue was already oversized (init-merge / in-flight reinsertion). Retire the depth-triggered flush — flushing is now interval + forced only, matching iOS. Config.Builder.networkFlushDepth(Int) is kept as a @Deprecated no-op (no source/binary break, no forced major bump).
  • Greater-of Retry-After vs exponential backoff (MAGE-500). Wait max(Retry-After, cappedExponentialBackoff) instead of Retry-After alone, and raise the backoff cap 180s → 300s. Damps sustained fleet-wide retry volume during rate-limit storms (esp. Retry-After: 0 bursts) while never retrying sooner than the server asked.

Test Plan

:sdk:core + :sdk:analytics unit suites green; eviction covered by mocked-clock tests (FIFO order, head-of-line protection, true 200-cap). ktlint clean. CI green on the merged branch.

On-device UAT — automated, Proxyman MITM fault-injection + mobile-mcp on the emulator (in-repo sample app, SDK 4.4.1 / Android 36). Full test plan + per-case evidence are posted in the MAGE-957 issue comments.

Case Validates Result
TC-001 Cloudflare 522 retried → delivered on recovery ✅ Pass
TC-002 403 load-shed stays terminal (no retry) ✅ Pass
TC-003 5xx bounds — 500/599 retried, 499/600 terminal ✅ Pass
TC-004 Transient network/IO failure retried ✅ Pass
TC-005 Retry-After honored as a floor ✅ Pass
TC-006 Exponential backoff on deep retries ✅ Pass
TC-007 300s retry-interval ceiling ⏭️ Skipped — unit-covered
TC-008 Queue caps at 200, evicts oldest ✅ Pass
TC-009 Depth-flush trigger removed (no-op) ✅ Pass

No regressions observed; safe to land pending standard review/CI.

Related Issues/Tickets

  • Test + release tracking: MAGE-957 — full on-device UAT summary + per-case evidence are posted in the issue comments.
  • RCA items implemented here: MAGE-694 (widen 5xx retry range), MAGE-496 (queue cap + evict-oldest), MAGE-500 (max(Retry-After, backoff) + 300s cap).

Summary by CodeRabbit

  • Improvements
    • Enforced a maximum queued analytics request limit; when exceeded, oldest requests are evicted (with a warning), while newer priority requests are protected.
    • Queue flushing now relies on the timer interval or explicit forcing (no depth-based gating).
    • Expanded retry logic to treat HTTP 429 and all HTTP 5xx (500–599) responses as retryable.
    • Retry delays now use the longer of exponential backoff and Retry-After (case-insensitive), with a higher max retry cap (5 minutes).
    • Debug sample builds now trust user-added CA certificates to enable HTTPS interception during testing.
  • Deprecations
    • Depth-based flushing configuration (networkFlushDepth) is deprecated and removed from runtime behavior; the setting no longer affects behavior and is scheduled for future removal.

evan-masseau and others added 15 commits July 9, 2026 11:20
* 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>
Add MAX_QUEUE_SIZE = 200 constant to KlaviyoApiClient. When a new request
is enqueued and the queue is at capacity, the oldest (front-of-deque) entry
is evicted and its persisted payload cleared from the datastore. This matches
the iOS SDK cap and prevents unbounded queue growth during request storms
(e.g. a push-token registration loop that fills the queue and blocks
legitimate new requests).

Also removes the depth-triggered flush in NetworkRunnable (flushDepth field
and corresponding guard in NetworkRunnable.run) — the timer-based flush via
networkFlushIntervals is the sole remaining flush trigger, which aligns with
iOS behaviour.

Unit test added: Queue cap evicts oldest request on overflow and emits
warning log.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
BREAKING CHANGE: Config.networkFlushDepth property and
Config.Builder.networkFlushDepth(Int) method are removed from the public API.
Callers who configure a custom flush depth will get a compile error and must
remove that call — the queue now flushes only on the timer interval
(networkFlushIntervals). Queue depth is bounded by the new MAX_QUEUE_SIZE cap
(200) in KlaviyoApiClient rather than used as a flush trigger.

Files changed:
- Config.kt: remove networkFlushDepth property and Builder method
- KlaviyoConfig.kt: remove NETWORK_FLUSH_DEPTH_DEFAULT constant, singleton
  field, builder field, builder override, and build() assignment
- KlaviyoConfigTest.kt: remove all networkFlushDepth assertions and builder
  calls; adjust error-log count from 9 to 8 (one fewer bad-value call)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…(MAGE-496)

The queue cap evicted via apiQueue.pollFirst(), which removes the FRONT of
the deque. Head-of-line requests are inserted at the front via offerFirst(),
so a freshly-enqueued head-of-line request (the newest) could be evicted
instead of the actual oldest request.

Evict the entry with the smallest queuedTime instead. queuedTime is the
persisted wall-clock enqueue timestamp, so this always targets the true
oldest request and naturally protects freshly-enqueued head-of-line requests
(newest queuedTime is never the min). Datastore-clear and warning log are
unchanged.

Tests: control queuedTime via the mocked Registry.clock to assert the
oldest-by-queuedTime request is evicted, that a newer head-of-line request at
the front of the deque is retained, and that the queue stays bounded at 200.

-Claude
…ing it

Restore Config.Builder.networkFlushDepth(Int) as a @deprecated no-op rather
than a hard removal, so existing caller code keeps compiling (no source/binary
break, no forced major version bump). Depth-triggered flushing is gone — the
setter now does nothing and is slated for removal in a future major release.
The internal depth-flush machinery (constant, field, trigger) stays removed;
the queue is bounded by the MAX_QUEUE_SIZE cap.

Also harden queue eviction: gate the clear/log side effects on the boolean
return of ConcurrentLinkedDeque.remove(). The minByOrNull + remove pair is not
atomic, so a concurrent enqueue could target the same victim; if remove()
returns false the while-loop re-scans, making eviction a best-effort,
self-healing soft bound rather than double-clearing an already-evicted request.

-Claude
…flushes

The deprecation copy said the queue "flushes only on the timer interval", but a
forced flush (NetworkRunnable with force=true) still bypasses the interval.
Reword both the Config.Builder interface and KlaviyoConfig.Builder impl messages
to "flushes on the timer interval or when explicitly forced" (CodeRabbit). No
behavior change.

-Claude
…off (MAGE-500)

computeRetryInterval() previously honored the Retry-After response header exactly
whenever present, falling back to exponential backoff only when the header was
absent. This meant a device deep in a rate-limit storm could retry too soon just
because the server's rate-limit window reset to a short Retry-After.

Now always compute the (network-floored, config-capped) exponential backoff and,
when a usable Retry-After header is present, wait the GREATER of the two. Jitter
behavior and the existing non-capping of a large Retry-After value are unchanged.

🤖 Generated with Reca
…500)

180s was more aggressive than comparable analytics SDKs (Segment caps at 300s).
Align the exponential-backoff ceiling with 300s per MAGE-500 proposal #1. The cap
is already enforced by computeRetryInterval(); this only changes the default.

🤖 Generated with Reca
HTTP header names are case-insensitive and an HTTP/2 stack may deliver Retry-After
lowercased, which a case-sensitive map lookup would miss (falling back to backoff
only). Look it up case-insensitively, comparing from the constant so a null header
key (the status line) can't NPE. Adds a lowercase-header test.

🤖 Generated with Reca
…ardcoding

Replace the two hardcoded 300_000L literals in the rate-limit backoff test with
Registry.config.networkMaxRetryInterval so the assertion tracks the configured
cap instead of duplicating the magic number (CodeRabbit DRY nit).

-Claude
Replace the hardcoded 300_000L in "Caps exponential backoff interval at
configured maximum" with mockConfig.networkMaxRetryInterval, so the assertion
tracks the configured cap and can't silently stale if it changes — same
treatment CodeRabbit prompted in KlaviyoApiClientTest (glenn-klaviyo review).

-Claude
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The SDK removes queue-depth-triggered flushing, adds bounded queue eviction, broadens retryable HTTP status handling, refines Retry-After backoff selection, raises the default maximum retry interval to five minutes, and adds debug CA trust configuration.

Changes

Networking queue and retry behavior

Layer / File(s) Summary
Flush and retry configuration contracts
sdk/core/src/main/java/com/klaviyo/core/config/*, sdk/core/src/test/java/com/klaviyo/core/config/*, sdk/fixtures/src/main/java/com/klaviyo/fixtures/BaseTest.kt
networkFlushDepth is deprecated and ignored, while the default maximum retry interval increases from three to five minutes.
Queue capacity and timer-based flushing
sdk/analytics/src/main/java/com/klaviyo/analytics/networking/KlaviyoApiClient.kt, sdk/analytics/src/test/java/com/klaviyo/analytics/networking/KlaviyoApiClientTest.kt
The API queue evicts the oldest request at capacity, and flushing depends on elapsed time or force rather than queue depth.
Retry classification and backoff selection
sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/KlaviyoApiRequest.kt, sdk/analytics/src/test/java/com/klaviyo/analytics/networking/requests/KlaviyoApiRequestTest.kt
HTTP 429 and all 5xx responses are retryable; retry delays use the greater of exponential backoff and a case-insensitive Retry-After value.
Validation updates
sdk/analytics/src/test/java/com/klaviyo/analytics/*
Tests cover queue eviction, flush timing, retry boundaries, and backoff capping and scheduling.

Debug network security configuration

Layer / File(s) Summary
Debug CA trust wiring
sample/src/debug/AndroidManifest.xml, sample/src/debug/res/xml/network_security_config.xml
Debug builds reference a network security configuration that permits cleartext traffic and trusts system and user-added CA certificates.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant KlaviyoApiClient
  participant KlaviyoApiRequest
  participant API
  KlaviyoApiClient->>KlaviyoApiRequest: send request
  KlaviyoApiRequest->>API: submit request
  API-->>KlaviyoApiRequest: return 429 or 5xx response
  KlaviyoApiRequest->>KlaviyoApiRequest: select max(Retry-After, exponential backoff)
  KlaviyoApiRequest-->>KlaviyoApiClient: schedule retry
Loading

Possibly related PRs

Suggested labels: networking improvement

Suggested reviewers: ndurell, atharva8168

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly matches the main changes: API-client network behavior fixes for the cited RCA items.
Description check ✅ Passed The description follows the template well, with completed sections for due diligence, release notes, code overview, test plan, and related tickets.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/api-client-rca-items

Comment @coderabbitai help to get the list of available commands.

@evan-masseau
evan-masseau marked this pull request as ready for review July 23, 2026 16:02
@evan-masseau
evan-masseau requested a review from a team as a code owner July 23, 2026 16:02
@klaviyoit
klaviyoit requested a review from ndurell July 23, 2026 16:02
evan-masseau and others added 2 commits July 23, 2026 13:38
Adds a debug-only source set (sample/src/debug) with a network security config trusting system + user-added CAs, plus a manifest overlay wiring it in. Debug builds only; release artifacts are unaffected. Lets an intercepting proxy (e.g. Proxyman) decrypt the sample app's HTTPS for SDK network UAT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Attaching any network-security-config makes Android ignore android:usesCleartextTraffic; on targetSdk cleartext defaults to disallowed, so the trust-anchor-only debug NSC would silently block plain-HTTP in debug builds. Set cleartextTrafficPermitted=true on the debug base-config (debug only; release unaffected).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@evan-masseau
evan-masseau requested a review from Atharva8168 July 23, 2026 20:44
Comment thread sample/src/debug/res/xml/network_security_config.xml
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant