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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user