Honor server backoff and section freshness in live polling #466

Merged
timmy merged 1 commits from timmy/465-adaptive-live-polling into main 2026-08-10 08:19:38 +00:00
13 changed files with 255 additions and 23 deletions

View File

@ -84,6 +84,11 @@ not changed by this session boundary. Installed-app
navigations are also deadline-bounded: after four seconds without a network response,
Stackchain aborts the request and opens the cached dashboard shell. If the shell has not
been installed yet, it returns explicit HTTP 504 reconnect guidance instead of hanging.
Live dashboard refreshes use the server's per-section freshness windows: healthy sections
refresh at their earliest deadline while a fully degraded snapshot waits for its reported
cooldown. HTTP `Retry-After` delays are honored up to five minutes, and transport failures
back off from eight seconds to a one-minute cap. Reconnect, foreground return, and explicit
refresh still run immediately; a successful response resets transport backoff.
Closed-app
delivery requests are deadline-bounded: a stalled identity, CSRF, or mutation request is
aborted after 15 seconds, its durable claim returns to the queue, and the next sync retries

View File

@ -10,6 +10,13 @@ function buildLiveRevisionQuery(revisions = {}) {
return params.toString();
}
function retryAfterMs(value) {
if (typeof value !== 'string' || !/^\d+$/.test(value)) return null;
const seconds = Number(value);
if (!Number.isFinite(seconds) || seconds <= 0) return null;
return Math.min(seconds * 1000, 300000);
}
function createContextPoller({
fetchContext,
onSnapshot,
@ -21,25 +28,51 @@ function createContextPoller({
clearDeadlineTimer = clearTimeout,
intervalMs = 8000,
timeoutMs = 12000,
maxBackoffMs = 60000,
}) {
let activeRequest = null;
let timer = null;
let stopped = false;
let revisions = {};
let retainedSnapshot = null;
let nextDelayMs = intervalMs;
let failureStreak = 0;
function snapshotDelay(snapshot) {
const freshness = snapshot && snapshot.freshness;
const sections = freshness && freshness.sections;
const sectionValues = sections && Object.values(sections);
if (sectionValues && sectionValues.length && sectionValues.every(section => section.degraded)) {
const retrySeconds = Number(freshness.retry_in_seconds);
if (Number.isFinite(retrySeconds) && retrySeconds > 0) return retrySeconds * 1000;
}
const freshForSeconds = Number(freshness && freshness.fresh_for_seconds);
const healthyDeadlines = (sectionValues || [])
.filter(section => !section.degraded)
.map(section => freshForSeconds - Number(section.age_seconds))
.filter(seconds => Number.isFinite(seconds));
if (Number.isFinite(freshForSeconds) && freshForSeconds > 0 && healthyDeadlines.length) {
const earliestDeadline = Math.min(...healthyDeadlines);
if (earliestDeadline <= 0 && sectionValues.some(section => !section.degraded && section.revalidating)) {
return intervalMs;
}
return Math.max(0, earliestDeadline) * 1000;
}
return intervalMs;
}
function cancelTimer() {
if (timer !== null) clearTimer(timer);
timer = null;
}
function schedule() {
function schedule(delayMs = intervalMs) {
cancelTimer();
if (stopped || isHidden()) return;
timer = setTimer(() => {
timer = null;
refresh();
}, intervalMs);
}, delayMs);
}
function abortError() {
@ -91,6 +124,8 @@ function createContextPoller({
requestState.promise = Promise.race([Promise.resolve(request), deadlinePromise])
.then((snapshot) => {
if (activeRequest !== requestState) return retainedSnapshot;
failureStreak = 0;
nextDelayMs = snapshotDelay(snapshot);
const changedSections = ['context', 'events', 'notifications'].filter(
(section) => Object.prototype.hasOwnProperty.call(snapshot, section)
);
@ -100,7 +135,14 @@ function createContextPoller({
return retainedSnapshot;
})
.catch((error) => {
if (activeRequest === requestState && !stopped) onError(error);
if (activeRequest === requestState && !stopped) {
const retryAfterMs = Number(error && error.retryAfterMs);
failureStreak += 1;
nextDelayMs = Number.isFinite(retryAfterMs) && retryAfterMs > 0
? retryAfterMs
: Math.min(intervalMs * (2 ** (failureStreak - 1)), maxBackoffMs);
onError(error);
}
return null;
})
.finally(() => {
@ -108,7 +150,7 @@ function createContextPoller({
clearDeadlineTimer(requestState.deadline);
if (activeRequest !== requestState) return;
activeRequest = null;
schedule();
schedule(nextDelayMs);
});
return requestState.promise;
}
@ -136,6 +178,7 @@ function createContextPoller({
}
createContextPoller.buildRevisionQuery = buildLiveRevisionQuery;
createContextPoller.retryAfterMs = retryAfterMs;
if (typeof module !== 'undefined' && module.exports) {
module.exports = createContextPoller;

View File

@ -376,7 +376,11 @@
headers: { Accept: 'application/json' },
signal,
});
if (!res.ok) throw new Error('HTTP ' + res.status);
if (!res.ok) {
const error = new Error('HTTP ' + res.status);
error.retryAfterMs = createContextPoller.retryAfterMs(res.headers.get('Retry-After'));
throw error;
}
return res.json();
}

View File

@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v79';
const CACHE = 'stackchain-dashboard-shell-v80';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;

View File

@ -281,4 +281,4 @@ async def test_current_today_update_offers_reply_and_next_without_marking_read()
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v79" in worker
assert "stackchain-dashboard-shell-v80" in worker

View File

@ -4,6 +4,7 @@ from pathlib import Path
POLLER = Path(__file__).parents[1] / "frontend" / "context-poller.js"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
def run_node(script: str) -> dict:
@ -116,6 +117,185 @@ const poller = createContextPoller({{
}
def test_context_poller_waits_for_server_cooldown_when_every_section_is_degraded():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const delays = [];
const poller = createContextPoller({{
fetchContext: () => Promise.resolve({{
context: {{}}, events: [], notifications: [],
freshness: {{
fresh_for_seconds: 8,
retry_in_seconds: 60,
sections: {{
context: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
events: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
notifications: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
}},
}},
}}),
onSnapshot: () => {{}},
onError: error => {{ throw error; }},
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
clearTimer: () => {{}},
intervalMs: 8000,
}});
(async () => {{
await poller.start();
process.stdout.write(JSON.stringify({{ delays }}));
}})();
"""
assert run_node(script) == {"delays": [60000]}
def test_context_poller_uses_earliest_healthy_section_freshness_deadline():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const delays = [];
const poller = createContextPoller({{
fetchContext: () => Promise.resolve({{
context: {{}}, events: [], notifications: [],
freshness: {{
fresh_for_seconds: 8,
retry_in_seconds: 60,
sections: {{
context: {{ degraded: false, age_seconds: 2 }},
events: {{ degraded: false, age_seconds: 5 }},
notifications: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
}},
}},
}}),
onSnapshot: () => {{}},
onError: error => {{ throw error; }},
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
clearTimer: () => {{}},
intervalMs: 8000,
}});
(async () => {{
await poller.start();
process.stdout.write(JSON.stringify({{ delays }}));
}})();
"""
assert run_node(script) == {"delays": [3000]}
def test_context_poller_does_not_spin_while_stale_sections_are_revalidating():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const delays = [];
const poller = createContextPoller({{
fetchContext: () => Promise.resolve({{
context: {{}}, events: [], notifications: [],
freshness: {{
fresh_for_seconds: 8,
sections: {{
context: {{ degraded: false, revalidating: true, age_seconds: 12 }},
events: {{ degraded: false, revalidating: true, age_seconds: 12 }},
notifications: {{ degraded: false, revalidating: true, age_seconds: 12 }},
}},
}},
}}),
onSnapshot: () => {{}},
onError: error => {{ throw error; }},
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
clearTimer: () => {{}},
intervalMs: 8000,
}});
(async () => {{
await poller.start();
process.stdout.write(JSON.stringify({{ delays }}));
}})();
"""
assert run_node(script) == {"delays": [8000]}
def test_context_poller_honors_http_retry_after_delay():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const delays = [];
const errors = [];
const unavailable = new Error('HTTP 503');
unavailable.retryAfterMs = 45000;
const poller = createContextPoller({{
fetchContext: () => Promise.reject(unavailable),
onSnapshot: () => {{}},
onError: error => errors.push(error.message),
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
clearTimer: () => {{}},
intervalMs: 8000,
}});
(async () => {{
await poller.start();
process.stdout.write(JSON.stringify({{ delays, errors }}));
}})();
"""
assert run_node(script) == {"delays": [45000], "errors": ["HTTP 503"]}
def test_retry_after_parser_accepts_bounded_delta_seconds():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
process.stdout.write(JSON.stringify({{
valid: createContextPoller.retryAfterMs('45'),
zero: createContextPoller.retryAfterMs('0'),
invalid: createContextPoller.retryAfterMs('later'),
excessive: createContextPoller.retryAfterMs('999999'),
}}));
"""
assert run_node(script) == {
"valid": 45000,
"zero": None,
"invalid": None,
"excessive": 300000,
}
def test_context_poller_backs_off_transport_failures_and_resets_after_success():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const delays = [];
let calls = 0;
const poller = createContextPoller({{
fetchContext: () => {{
calls += 1;
if (calls < 6) return Promise.reject(new TypeError('network unavailable'));
return Promise.resolve({{ context: {{}}, events: [], notifications: [] }});
}},
onSnapshot: () => {{}},
onError: () => {{}},
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
clearTimer: () => {{}},
intervalMs: 8000,
maxBackoffMs: 60000,
}});
(async () => {{
await poller.refresh();
await poller.refresh();
await poller.refresh();
await poller.refresh();
await poller.refresh();
await poller.refresh();
process.stdout.write(JSON.stringify({{ delays }}));
}})();
"""
assert run_node(script) == {
"delays": [8000, 16000, 32000, 60000, 60000, 8000]
}
def test_live_fetch_passes_retry_after_to_the_poller_error():
source = DASHBOARD.read_text()
assert "createContextPoller.retryAfterMs(res.headers.get('Retry-After'))" in source
assert "error.retryAfterMs" in source
def test_live_revision_query_preserves_bounded_opaque_tokens():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});

View File

@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v79" in source
assert "stackchain-dashboard-shell-v80" in source
assert "BASE + 'static/later-sync.js'" in source

View File

@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v79" in worker
assert "stackchain-dashboard-shell-v80" in worker

View File

@ -35,7 +35,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v79" in worker
assert "stackchain-dashboard-shell-v80" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():

View File

@ -292,6 +292,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v79" in source
assert "stackchain-dashboard-shell-v80" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source

View File

@ -122,7 +122,7 @@ async function dispatchNotificationClick(route) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v79" in source
assert "stackchain-dashboard-shell-v80" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@ -131,14 +131,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v79" in source
assert "stackchain-dashboard-shell-v80" in source
assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v79" in source
assert "stackchain-dashboard-shell-v80" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -146,7 +146,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v79" in source
assert "stackchain-dashboard-shell-v80" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -154,14 +154,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v79" in source
assert "stackchain-dashboard-shell-v80" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v79" in source
assert "stackchain-dashboard-shell-v80" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@ -170,21 +170,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v79" in source
assert "stackchain-dashboard-shell-v80" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v79" in source
assert "stackchain-dashboard-shell-v80" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v79" in source
assert "stackchain-dashboard-shell-v80" in source
assert "BASE + 'static/update-ownership.js'" in source
@ -385,7 +385,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v79" in source
assert "stackchain-dashboard-shell-v80" in source
assert "BASE + 'static/queue-today.js'" in source

View File

@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v79';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v80';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker

View File

@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v79" in source
assert "stackchain-dashboard-shell-v80" in source
assert "BASE + 'static/today-sync.js'" in source