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