Skip to content

Commit fb08163

Browse files
committed
fix(web): resilient retry for workspace-context writes
Extract a shared BackoffController primitive and use it so workspace-context-dependent writes survive transient outages instead of failing closed. - resolvedWorkspaceContextForWrite proceeds on a generation-matched last-good context instead of throwing on a transient `unavailable` (a weak-network blip no longer bricks project creation and every other workspace write); a cache from a retired generation (account switch) still fails closed. - context + billing refetch use exponential backoff + jitter, single-timer coordination, success-only reset, and SSE early-trigger without depth reset (replacing billing's fixed 5s and context's no-retry). - createProject auto-retries a 503 retryable WORKSPACE_AUTHORITY_UNAVAILABLE with the id minted once (idempotent). - unify the 3 duplicated SSE reconnect loops (project-events, Theater, useEventStream) onto BackoffController and add jitter.
1 parent e01efc7 commit fb08163

11 files changed

Lines changed: 819 additions & 85 deletions

File tree

apps/web/src/collab/useWorkspaceContext.ts

Lines changed: 127 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
buildWorkspaceSeatSummary,
1717
} from '@open-design/contracts';
1818
import { coalescedGet, forceCoalescedGet } from '../lib/coalesced-get';
19+
import { BackoffController, type BackoffOptions } from '../lib/backoff';
1920
import {
2021
markProjectDisplaySnapshotsDirty,
2122
patchProjectDisplaySnapshots,
@@ -231,6 +232,18 @@ function workspaceContextCoalesceKey(): string {
231232
return `workspace-context:${workspaceContextRequestToken}`;
232233
}
233234

235+
/**
236+
* The LIVE identity generation token. A cached workspace context is stamped
237+
* with the token it was resolved under (see `resourceReadIdentity.generation`);
238+
* a write path compares that stamp against this live value to decide whether a
239+
* retained (last-good) context still belongs to the current identity, rather
240+
* than trusting a possibly-stale `identityChangePending` snapshot. See
241+
* `resolvedWorkspaceContextForWrite` in `state/projects.ts`.
242+
*/
243+
export function currentWorkspaceContextRequestToken(): string {
244+
return workspaceContextRequestToken;
245+
}
246+
234247
async function fetchWorkspaceDirectory(): Promise<WorkspaceDirectoryResponse> {
235248
const response = await fetch('/api/workspace/directory', { cache: 'no-store' });
236249
if (!response.ok) {
@@ -476,6 +489,7 @@ export function resetWorkspaceContextCache(): void {
476489
workspaceContextIdentityChangePending = false;
477490
workspaceAccountGeneration = 0;
478491
workspaceAccountGenerationStamp = 'initial';
492+
resetWorkspaceContextRetrySchedules();
479493
writeWorkspaceSelection(null);
480494
}
481495

@@ -701,6 +715,9 @@ export function useWorkspaceContext(): WorkspaceContextState {
701715
cachedWorkspaceContext = nextContext;
702716
cachedWorkspaceContextGeneration = requestGeneration;
703717
workspaceContextIdentityChangePending = false;
718+
// A successful read is the only thing that rewinds the failure-retry
719+
// backoff for this generation.
720+
clearWorkspaceContextRetryFailures(requestGeneration);
704721
setState({
705722
context: cachedWorkspaceContext,
706723
resourceReadIdentity: cachedWorkspaceContext
@@ -719,6 +736,7 @@ export function useWorkspaceContext(): WorkspaceContextState {
719736
// last-known context instead of flashing the signed-out state. A never-
720737
// signed-in / personal user has a null cache, so this still shows the local
721738
// state for them.
739+
const unsupported = (error as { status?: unknown })?.status === 404;
722740
setState({
723741
context: cachedWorkspaceContext,
724742
resourceReadIdentity:
@@ -730,11 +748,13 @@ export function useWorkspaceContext(): WorkspaceContextState {
730748
: null,
731749
loading: false,
732750
identityChangePending: workspaceContextIdentityChangePending,
733-
failure:
734-
(error as { status?: unknown })?.status === 404
735-
? 'unsupported'
736-
: 'unavailable',
751+
failure: unsupported ? 'unsupported' : 'unavailable',
737752
});
753+
// An `unsupported` daemon has no workspace endpoint — retrying is
754+
// pointless. A transient `unavailable` outage arms the shared jittered
755+
// backoff so the shell recovers on its own without waiting for the 30s
756+
// poll or a focus event.
757+
if (!unsupported) scheduleWorkspaceContextRetry(requestGeneration);
738758
}
739759
}, []);
740760

@@ -815,15 +835,26 @@ export function useWorkspaceContext(): WorkspaceContextState {
815835
advanceWorkspaceAccountGeneration(event.newValue ?? 'storage');
816836
refreshAfterIdentityChange();
817837
};
838+
// A scheduled failure-retry (see `scheduleWorkspaceContextRetry`) fires this
839+
// event for a specific identity generation. Re-read only when it still names
840+
// the current generation — a retry armed for an identity the user has since
841+
// left must not spend a request.
842+
const onContextRetry = (event: Event) => {
843+
const detail = (event as CustomEvent<{ requestKey?: string }>).detail;
844+
if (detail?.requestKey !== workspaceContextRequestToken) return;
845+
void loadContext();
846+
};
818847
window.addEventListener('focus', refresh);
819848
window.addEventListener('pageshow', refresh);
820849
window.addEventListener(WORKSPACE_CONTEXT_REFRESH_EVENT, refreshAfterIdentityChange);
850+
window.addEventListener(WORKSPACE_CONTEXT_RETRY_EVENT, onContextRetry);
821851
window.addEventListener('storage', onStorage);
822852
document.addEventListener('visibilitychange', onVisibilityChange);
823853
return () => {
824854
window.removeEventListener('focus', refresh);
825855
window.removeEventListener('pageshow', refresh);
826856
window.removeEventListener(WORKSPACE_CONTEXT_REFRESH_EVENT, refreshAfterIdentityChange);
857+
window.removeEventListener(WORKSPACE_CONTEXT_RETRY_EVENT, onContextRetry);
827858
window.removeEventListener('storage', onStorage);
828859
document.removeEventListener('visibilitychange', onVisibilityChange);
829860
};
@@ -1607,7 +1638,7 @@ const WORKSPACE_BILLING_RETRY_EVENT = 'od:workspace-billing-retry';
16071638
* within the share window costs zero network requests).
16081639
*/
16091640
type WorkspaceBillingRetrySchedule = {
1610-
consecutiveFailures: number;
1641+
backoff: BackoffController;
16111642
timer: ReturnType<typeof setTimeout> | null;
16121643
};
16131644
const workspaceBillingRetrySchedules = new Map<string, WorkspaceBillingRetrySchedule>();
@@ -1616,15 +1647,23 @@ function scheduleWorkspaceBillingRetry(requestKey: string): void {
16161647
if (typeof window === 'undefined') return;
16171648
let schedule = workspaceBillingRetrySchedules.get(requestKey);
16181649
if (!schedule) {
1619-
schedule = { consecutiveFailures: 0, timer: null };
1650+
schedule = {
1651+
backoff: new BackoffController({
1652+
initialMs: WORKSPACE_BILLING_RETRY_BASE_MS,
1653+
maxMs: WORKSPACE_BILLING_RETRY_MAX_MS,
1654+
factor: 2,
1655+
// Jitter stays OFF for billing: its exponential schedule predates this
1656+
// change and is pinned by an exact-cadence regression test (the od://
1657+
// 502-storm). Only the arithmetic moves onto the shared controller;
1658+
// the observable 5s→10s→20s→40s→60s timing is unchanged.
1659+
jitter: false,
1660+
}),
1661+
timer: null,
1662+
};
16201663
workspaceBillingRetrySchedules.set(requestKey, schedule);
16211664
}
16221665
if (schedule.timer != null) return;
1623-
const delay = Math.min(
1624-
WORKSPACE_BILLING_RETRY_BASE_MS * 2 ** schedule.consecutiveFailures,
1625-
WORKSPACE_BILLING_RETRY_MAX_MS,
1626-
);
1627-
schedule.consecutiveFailures += 1;
1666+
const delay = schedule.backoff.nextDelay();
16281667
schedule.timer = setTimeout(() => {
16291668
schedule.timer = null;
16301669
// Dispatch unconditionally: listeners filter on their own active
@@ -1636,8 +1675,7 @@ function scheduleWorkspaceBillingRetry(requestKey: string): void {
16361675
}
16371676

16381677
function clearWorkspaceBillingRetryFailures(requestKey: string): void {
1639-
const schedule = workspaceBillingRetrySchedules.get(requestKey);
1640-
if (schedule) schedule.consecutiveFailures = 0;
1678+
workspaceBillingRetrySchedules.get(requestKey)?.backoff.reset();
16411679
}
16421680

16431681
function resetWorkspaceBillingRetrySchedules(): void {
@@ -1646,6 +1684,82 @@ function resetWorkspaceBillingRetrySchedules(): void {
16461684
}
16471685
workspaceBillingRetrySchedules.clear();
16481686
}
1687+
1688+
// ---------- workspace context failure retry ----------
1689+
//
1690+
// `GET /api/workspace/context` (and its directory prerequisite) previously had
1691+
// NO failure retry: a read that failed just sat on the last-good context until
1692+
// the next 30s poll or a focus event. During a multi-hour vela authority outage
1693+
// that left the shell stale far longer than necessary and, combined with the
1694+
// old fail-closed write gate, drove the create-retry storm this PR fixes.
1695+
//
1696+
// The failure path now arms a jittered exponential-backoff retry (1s → 30s),
1697+
// module-level and keyed by identity generation — one timer shared by every
1698+
// mounted consumer, exactly like the billing schedule above (a per-hook timer
1699+
// would arm a dozen for the dozen-plus mounted `useWorkspaceContext`s). Success
1700+
// resets the depth; an ambient trigger (SSE `workspace-context-changed`, focus,
1701+
// the poll floor) fetches immediately WITHOUT rewinding the depth, so unrelated
1702+
// foreground activity cannot keep kicking a flaky transport back to a 1s cadence.
1703+
const WORKSPACE_CONTEXT_RETRY_BASE_MS = 1_000;
1704+
const WORKSPACE_CONTEXT_RETRY_MAX_MS = 30_000;
1705+
const WORKSPACE_CONTEXT_RETRY_EVENT = 'od:workspace-context-retry';
1706+
1707+
function defaultWorkspaceContextRetryBackoff(): BackoffOptions {
1708+
return {
1709+
initialMs: WORKSPACE_CONTEXT_RETRY_BASE_MS,
1710+
maxMs: WORKSPACE_CONTEXT_RETRY_MAX_MS,
1711+
factor: 2,
1712+
jitter: true,
1713+
};
1714+
}
1715+
1716+
// Test seam: production uses jittered backoff; a test pins the schedule to a
1717+
// deterministic sequence (jitter off) to assert the exact 1s→2s→4s…→30s growth.
1718+
let workspaceContextRetryBackoffOptions: BackoffOptions = defaultWorkspaceContextRetryBackoff();
1719+
1720+
export function __setWorkspaceContextRetryBackoffForTests(
1721+
options: BackoffOptions | null,
1722+
): void {
1723+
workspaceContextRetryBackoffOptions = options ?? defaultWorkspaceContextRetryBackoff();
1724+
}
1725+
1726+
type WorkspaceContextRetrySchedule = {
1727+
backoff: BackoffController;
1728+
timer: ReturnType<typeof setTimeout> | null;
1729+
};
1730+
const workspaceContextRetrySchedules = new Map<string, WorkspaceContextRetrySchedule>();
1731+
1732+
function scheduleWorkspaceContextRetry(requestKey: string): void {
1733+
if (typeof window === 'undefined') return;
1734+
let schedule = workspaceContextRetrySchedules.get(requestKey);
1735+
if (!schedule) {
1736+
schedule = {
1737+
backoff: new BackoffController(workspaceContextRetryBackoffOptions),
1738+
timer: null,
1739+
};
1740+
workspaceContextRetrySchedules.set(requestKey, schedule);
1741+
}
1742+
if (schedule.timer != null) return;
1743+
const delay = schedule.backoff.nextDelay();
1744+
schedule.timer = setTimeout(() => {
1745+
schedule.timer = null;
1746+
window.dispatchEvent(
1747+
new CustomEvent(WORKSPACE_CONTEXT_RETRY_EVENT, { detail: { requestKey } }),
1748+
);
1749+
}, delay);
1750+
}
1751+
1752+
function clearWorkspaceContextRetryFailures(requestKey: string): void {
1753+
workspaceContextRetrySchedules.get(requestKey)?.backoff.reset();
1754+
}
1755+
1756+
function resetWorkspaceContextRetrySchedules(): void {
1757+
for (const schedule of workspaceContextRetrySchedules.values()) {
1758+
if (schedule.timer != null) clearTimeout(schedule.timer);
1759+
}
1760+
workspaceContextRetrySchedules.clear();
1761+
}
1762+
16491763
export const WORKSPACE_BILLING_REFRESH_EVENT = 'od:workspace-billing-refresh';
16501764
const WORKSPACE_BILLING_REFRESH_STORAGE_KEY = 'od.workspaceBilling.refreshAt';
16511765

apps/web/src/components/Theater/state/sse.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { WorkspaceCollabContext } from '@open-design/contracts';
99

1010
import type { CritiqueAction } from './reducer';
1111
import { workspaceResourceUrl } from '../../../collab/workspace-identity';
12+
import { BackoffController } from '../../../lib/backoff';
1213

1314
export interface CritiqueEventsConnection {
1415
close(): void;
@@ -24,6 +25,8 @@ export interface CritiqueEventsConnectionOptions {
2425
/** Test seam: setTimeout substitutes for fake timers. */
2526
setTimeoutFn?: typeof setTimeout;
2627
clearTimeoutFn?: typeof clearTimeout;
28+
/** Test seam: deterministic jitter source for the reconnect backoff. */
29+
randomFn?: () => number;
2730
/** Persisted project authority encoded into the EventSource URL. */
2831
workspaceContext?: WorkspaceCollabContext | null;
2932
}
@@ -114,13 +117,17 @@ export function createCritiqueEventsConnection(
114117
?? (typeof EventSource === 'undefined' ? null : EventSource);
115118
if (!Ctor) return { close() { /* noop */ } };
116119

117-
const initialBackoff = options.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF;
118-
const maxBackoff = options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF;
119120
const setT = options.setTimeoutFn ?? setTimeout;
120121
const clearT = options.clearTimeoutFn ?? clearTimeout;
122+
const backoff = new BackoffController({
123+
initialMs: options.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF,
124+
maxMs: options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF,
125+
factor: 2,
126+
jitter: true,
127+
random: options.randomFn,
128+
});
121129

122130
let cancelled = false;
123-
let backoff = initialBackoff;
124131
let source: EventSource | null = null;
125132
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
126133

@@ -145,7 +152,7 @@ export function createCritiqueEventsConnection(
145152
const es = new Ctor(critiqueEventsUrl(projectId, options.workspaceContext));
146153
source = es;
147154
es.addEventListener('ready', () => {
148-
backoff = initialBackoff;
155+
backoff.reset();
149156
});
150157
for (const name of CRITIQUE_SSE_EVENT_NAMES) {
151158
es.addEventListener(name, handleCritiqueFrame(name));
@@ -154,9 +161,7 @@ export function createCritiqueEventsConnection(
154161
if (cancelled) return;
155162
es.close();
156163
if (source === es) source = null;
157-
const delay = backoff;
158-
backoff = Math.min(backoff * 2, maxBackoff);
159-
reconnectTimer = setT(connect, delay) as ReturnType<typeof setTimeout>;
164+
reconnectTimer = setT(connect, backoff.nextDelay()) as ReturnType<typeof setTimeout>;
160165
});
161166
};
162167

apps/web/src/hooks/useEventStream.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useEffect, useRef, useState } from 'react';
2+
import { BackoffController } from '../lib/backoff';
23

34
// Collab realtime hop-2 — the reusable daemon→web SSE client.
45
//
@@ -77,7 +78,14 @@ class EventStreamManager {
7778
private readonly subscribers = new Set<InternalSubscriber>();
7879
private source: EventSource | null = null;
7980
private connected = false;
80-
private backoff = INITIAL_BACKOFF_MS;
81+
// Jittered exponential reconnect backoff (1s → 30s), shared with every other
82+
// SSE surface via the common controller.
83+
private readonly backoff = new BackoffController({
84+
initialMs: INITIAL_BACKOFF_MS,
85+
maxMs: MAX_BACKOFF_MS,
86+
factor: 2,
87+
jitter: true,
88+
});
8189
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
8290
private hiddenTimer: ReturnType<typeof setTimeout> | null = null;
8391
private readonly attachedNames = new Set<string>();
@@ -137,7 +145,7 @@ class EventStreamManager {
137145
this.source = es;
138146
this.attachedNames.clear();
139147
es.onopen = () => {
140-
this.backoff = INITIAL_BACKOFF_MS;
148+
this.backoff.reset();
141149
this.setConnected(true);
142150
// Snapshot catch-up on (re)connect — the thin-event model relies on this
143151
// instead of replaying buffered events.
@@ -154,7 +162,7 @@ class EventStreamManager {
154162
// too (covers transports where `onopen` is unreliable).
155163
this.listen('ready', () => {
156164
if (!this.connected) {
157-
this.backoff = INITIAL_BACKOFF_MS;
165+
this.backoff.reset();
158166
this.setConnected(true);
159167
for (const sub of Array.from(this.subscribers)) sub.onActive?.();
160168
}
@@ -192,13 +200,10 @@ class EventStreamManager {
192200

193201
private scheduleReconnect(): void {
194202
if (this.reconnectTimer || this.subscribers.size === 0) return;
195-
const jitter = Math.floor(Math.random() * 500);
196-
const delay = this.backoff + jitter;
197-
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
198203
this.reconnectTimer = setTimeout(() => {
199204
this.reconnectTimer = null;
200205
this.ensureOpen();
201-
}, delay);
206+
}, this.backoff.nextDelay());
202207
}
203208

204209
private closeSource(): void {

0 commit comments

Comments
 (0)