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
This commit is contained in:
2026-08-29 19:49:49 +02:00
parent 3dff722535
commit bc1ebcaaaa
+48
View File
@@ -5,11 +5,15 @@
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
const highScoreApi = "/api/highscores"; const highScoreApi = "/api/highscores";
const highScoreRetryInitialDelayMs = 250;
const highScoreRetryMaxDelayMs = 30_000;
let lastRevision = 0; let lastRevision = 0;
let lastHighScoreRevision = 0; let lastHighScoreRevision = 0;
let highScoreRequestInFlight = false; let highScoreRequestInFlight = false;
let highScoreGeneration = 0; let highScoreGeneration = 0;
let highScoreSubmissionWarningShown = false; let highScoreSubmissionWarningShown = false;
let highScoreRetryDelayMs = highScoreRetryInitialDelayMs;
let highScoreRetryAt = 0;
function browserStorage() { function browserStorage() {
try { 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() { async function flushHighScoreSubmission() {
if (highScoreRequestInFlight) { if (highScoreRequestInFlight) {
return; return;
} }
if (Date.now() < highScoreRetryAt) {
return;
}
const revision = wasm_exports.tdkpin_browser_high_score_revision(); const revision = wasm_exports.tdkpin_browser_high_score_revision();
if (revision === lastHighScoreRevision) { if (revision === lastHighScoreRevision) {
return; return;
@@ -102,6 +146,7 @@
} }
highScoreRequestInFlight = true; highScoreRequestInFlight = true;
let retryAfterMs = null;
try { try {
const response = await fetch(highScoreApi, { const response = await fetch(highScoreApi, {
method: "POST", method: "POST",
@@ -110,6 +155,7 @@
keepalive: true, keepalive: true,
}); });
if (!response.ok) { if (!response.ok) {
retryAfterMs = retryAfterDelay(response);
throw new Error(`high-score submission failed (${response.status})`); throw new Error(`high-score submission failed (${response.status})`);
} }
const json = await response.text(); const json = await response.text();
@@ -118,7 +164,9 @@
wasm_exports.tdkpin_browser_high_score_ack(revision); wasm_exports.tdkpin_browser_high_score_ack(revision);
lastHighScoreRevision = revision; lastHighScoreRevision = revision;
highScoreSubmissionWarningShown = false; highScoreSubmissionWarningShown = false;
resetHighScoreRetry();
} catch (error) { } catch (error) {
scheduleHighScoreRetry(retryAfterMs);
if (!highScoreSubmissionWarningShown) { if (!highScoreSubmissionWarningShown) {
console.warn("shared high-score submission failed; will retry", error); console.warn("shared high-score submission failed; will retry", error);
highScoreSubmissionWarningShown = true; highScoreSubmissionWarningShown = true;