From bc1ebcaaaaf709e250904a503ce7b0152a34a18a Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sat, 29 Aug 2026 19:49:49 +0200 Subject: [PATCH] fix(web): back off high-score retries The browser previously retried every failed shared high-score submission on the 50 ms polling interval. With server-side rate limiting and database load shedding, immediate 429 and 503 responses could create a tight retry loop. Add a bounded exponential delay with jitter, honor Retry-After on transient responses, and reset the delay after a successful submission. Keep the pending revision retryable without allowing overlapping requests. Test Plan: - `node --check tdkpin-rs/web/storage.js` -- passed - `prettier --check tdkpin-rs/web/storage.js` -- passed - Node VM retry timing harness covering Retry-After, backoff, and reset -- passed - `git diff --cached --check` -- passed --- tdkpin-rs/web/storage.js | 48 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tdkpin-rs/web/storage.js b/tdkpin-rs/web/storage.js index f832098..016dfc7 100644 --- a/tdkpin-rs/web/storage.js +++ b/tdkpin-rs/web/storage.js @@ -5,11 +5,15 @@ const encoder = new TextEncoder(); const decoder = new TextDecoder(); const highScoreApi = "/api/highscores"; + const highScoreRetryInitialDelayMs = 250; + const highScoreRetryMaxDelayMs = 30_000; let lastRevision = 0; let lastHighScoreRevision = 0; let highScoreRequestInFlight = false; let highScoreGeneration = 0; let highScoreSubmissionWarningShown = false; + let highScoreRetryDelayMs = highScoreRetryInitialDelayMs; + let highScoreRetryAt = 0; function browserStorage() { try { @@ -86,10 +90,50 @@ } } + function retryAfterDelay(response) { + if (response.status !== 429 && response.status !== 503) { + return null; + } + const value = response.headers.get("Retry-After")?.trim(); + if (!value) { + return null; + } + + const seconds = Number(value); + if (Number.isFinite(seconds) && seconds >= 0) { + const delay = seconds * 1000; + return Number.isFinite(delay) ? delay : null; + } + + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) + ? Math.max(0, timestamp - Date.now()) + : null; + } + + function scheduleHighScoreRetry(retryAfterMs) { + const jitteredDelay = highScoreRetryDelayMs * (0.5 + Math.random()); + const delay = + retryAfterMs === null ? jitteredDelay : retryAfterMs + jitteredDelay; + highScoreRetryAt = Date.now() + delay; + highScoreRetryDelayMs = Math.min( + highScoreRetryDelayMs * 2, + highScoreRetryMaxDelayMs, + ); + } + + function resetHighScoreRetry() { + highScoreRetryDelayMs = highScoreRetryInitialDelayMs; + highScoreRetryAt = 0; + } + async function flushHighScoreSubmission() { if (highScoreRequestInFlight) { return; } + if (Date.now() < highScoreRetryAt) { + return; + } const revision = wasm_exports.tdkpin_browser_high_score_revision(); if (revision === lastHighScoreRevision) { return; @@ -102,6 +146,7 @@ } highScoreRequestInFlight = true; + let retryAfterMs = null; try { const response = await fetch(highScoreApi, { method: "POST", @@ -110,6 +155,7 @@ keepalive: true, }); if (!response.ok) { + retryAfterMs = retryAfterDelay(response); throw new Error(`high-score submission failed (${response.status})`); } const json = await response.text(); @@ -118,7 +164,9 @@ wasm_exports.tdkpin_browser_high_score_ack(revision); lastHighScoreRevision = revision; highScoreSubmissionWarningShown = false; + resetHighScoreRetry(); } catch (error) { + scheduleHighScoreRetry(retryAfterMs); if (!highScoreSubmissionWarningShown) { console.warn("shared high-score submission failed; will retry", error); highScoreSubmissionWarningShown = true;