Skip to content

Commit 603c4f6

Browse files
claudekilldano
authored andcommitted
PR 1.1 fixup: jittered exponential backoff for resolver
Replaces the 2-attempt 300ms-fixed-delay retry with a 4-attempt exponential schedule (0, 2s, 4s, 6s) with ±200ms symmetric jitter. Motivation: field testing showed Google's short-URL service returning 404 stochastically — both attempts in the previous 2-attempt loop hit 404 within ~150ms of each other, falling open to PKJS's XHR fallback which then hits 403 (Google's anti-bot hits XHR harder than OkHttp). Spreading attempts across ~13s with jitter avoids the regular- interval polling pattern that contributes to the bot heuristic, while still bounded by a 16s overall timeout. Happy path stays snappy (attempt 1 immediate ±200ms jitter).
1 parent 0b1721c commit 603c4f6

1 file changed

Lines changed: 52 additions & 14 deletions

File tree

libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/shareintent/ShareUrlResolver.kt

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,43 @@ class ShareUrlResolver internal constructor(
7878
"Mozilla/5.0 (Linux; Android 13; SM-G991B) AppleWebKit/537.36 " +
7979
"(KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
8080

81-
private val RESOLVE_TIMEOUT = 6.seconds
82-
private const val MAX_ATTEMPTS = 2
83-
private const val RETRY_DELAY_MS = 300L
81+
private val RESOLVE_TIMEOUT = 16.seconds
82+
private const val MAX_ATTEMPTS = 4
83+
84+
/**
85+
* Backoff delays BEFORE each attempt, in milliseconds. Index 0 is
86+
* before attempt 1 (initial fire), index N is before attempt N+1.
87+
*
88+
* attempt 1: 0ms baseline (immediate, but with jitter)
89+
* attempt 2: 2000ms after failure of 1
90+
* attempt 3: 4000ms after failure of 2
91+
* attempt 4: 6000ms after failure of 3
92+
*
93+
* Each value gets ±[JITTER_MS] of symmetric noise added. The reason
94+
* we don't fire at exactly 0/2/4/6s: Google's anti-bot heuristics
95+
* appear to fingerprint request timing patterns, so perfectly
96+
* regular intervals are themselves a signal. Jitter adds 200ms of
97+
* naturalness — small enough to not hurt UX, large enough to
98+
* scramble the period.
99+
*
100+
* Even attempt 1 gets jittered (0..200ms) to avoid the "fire
101+
* immediately on share intent" pattern that's distinctive in
102+
* server logs.
103+
*/
104+
private val BACKOFF_MS = longArrayOf(0L, 2_000L, 4_000L, 6_000L)
105+
private const val JITTER_MS = 200L
106+
}
107+
108+
/**
109+
* Symmetric jitter around a base delay. Returns base + uniform[-J, +J].
110+
* For BACKOFF_MS[0] = 0L this returns 0..JITTER_MS (clamped non-negative).
111+
*/
112+
private fun jittered(baseMs: Long): Long {
113+
// kotlin.random.Random is fine here — we don't need crypto-grade
114+
// randomness, just unpredictable-enough timing.
115+
val noise = kotlin.random.Random.nextLong(-JITTER_MS, JITTER_MS + 1)
116+
val v = baseMs + noise
117+
return if (v < 0L) 0L else v
84118
}
85119

86120
/**
@@ -91,16 +125,26 @@ class ShareUrlResolver internal constructor(
91125
* Never throws — wraps failures into a fall-open return.
92126
*
93127
* Retry strategy: Google's Firebase Dynamic Links anti-bot heuristic is
94-
* stochastic — the same headers can yield 200 once and 403 the next
95-
* second. A single retry roughly doubles our success rate at minimal
96-
* cost. Total worst-case latency is bounded by RESOLVE_TIMEOUT and
97-
* runs concurrently with PKJS spinup so usually invisible.
128+
* stochastic — the same headers can yield 200 once and 404/403 the next
129+
* second. We use four attempts with exponentially-increasing backoff
130+
* (0, 2s, 4s, 6s) plus per-attempt jitter (±200ms) to spread requests
131+
* across the suspect rate-limit window without looking like a robotic
132+
* polling loop. Total worst-case latency is bounded by RESOLVE_TIMEOUT
133+
* and runs concurrently with PKJS spinup so partial latency is hidden.
98134
*/
99135
suspend fun resolveIfShortened(url: String): String {
100136
if (!isShortenedMapsUrl(url)) return url
101137

102138
val resolved = withTimeoutOrNull(RESOLVE_TIMEOUT) {
103139
for (attempt in 1..MAX_ATTEMPTS) {
140+
// Pre-attempt wait with jitter. Even the first attempt gets
141+
// 0..JITTER_MS of jitter so back-to-back shares don't all
142+
// fire at exactly the same offset from the share intent.
143+
val waitMs = jittered(BACKOFF_MS[attempt - 1])
144+
if (waitMs > 0L) {
145+
logger.v { "resolve attempt $attempt waiting ${waitMs}ms before fire" }
146+
kotlinx.coroutines.delay(waitMs)
147+
}
104148
val r = try {
105149
doResolve(url)
106150
} catch (e: Exception) {
@@ -111,13 +155,7 @@ class ShareUrlResolver internal constructor(
111155
if (attempt > 1) logger.i { "resolve succeeded on attempt $attempt" }
112156
return@withTimeoutOrNull r
113157
}
114-
if (attempt < MAX_ATTEMPTS) {
115-
// Brief backoff before retry. Doesn't need to be long —
116-
// Google's anti-bot decision seems request-local rather
117-
// than IP-rate-based, so even ~300ms is enough to land
118-
// a different decision tree.
119-
kotlinx.coroutines.delay(RETRY_DELAY_MS)
120-
}
158+
// Loop continues; next iteration's pre-attempt wait kicks in.
121159
}
122160
null
123161
}

0 commit comments

Comments
 (0)