Files
buzz/desktop/src/shared/api/relayGateBoundary.ts
Will Pfleger 6f6093243e fix(desktop): harden profile batch and thread-reply fetches against relay slowness (#7188)
Three client gaps turn transient relay failures into permanent UI
degradation. Under a slow or rate-limited relay:

1. A cold channel's profile batch exhausts its single retry and leaves
raw npubs + broken mention chips until the user manually kicks the
channel.
2. A thread opened from a notification trusts a successful-but-empty
reply read as authoritative and never retries.
3. A rate-limited `CLOSED` on a history subscription immediately rejects
the caller rather than retrying after the rate-limit window.

All three are addressed without changing global query defaults or the
happy-path behavior.

## Changes

**Fix 1 — cold profile batch resilience** (`useUsersBatchQuery`,
`desktop/src/features/profile/hooks.ts`)

Override `retry: 3` with exponential backoff and error-gated
`refetchOnWindowFocus: (query) => query.state.status === "error"`,
scoped to this query only. The global defaults (`retry: 1`,
`refetchOnWindowFocus: false`) are intentional for other queries and are
unchanged. After the retry budget exhausts, a window-focus event (e.g.
channel-switch) recovers the query automatically — but only when it is
already in an error state, preventing unnecessary refetches for
successful batches.

**Fix 2 — stale-empty thread reads** (`useThreadReplies.ts`,
`ChannelScreen.tsx`)

Add optional `expectedEventId` parameter. When a completed paged fetch
does not contain the expected event, throw
`ThreadExpectedEventMissingError` so React Query's built-in retry
machinery handles it rather than caching an authoritative empty.
`ChannelScreen` passes `threadScrollTargetId` (the notification-linked
reply ID) as `expectedEventId`.

When notification routing changes `expectedEventId` while the same
thread root is already mounted (same query key), an explicit
`invalidateQueries` in a `useEffect` triggers a fresh validation pass.
The `useEffect` is declared after `useQuery` so TanStack's internal
options-update effect installs the new `queryFn` closure first; the
refetch therefore uses the current `expectedEventId` rather than the
previous null closure. For the cold-start race (target arrives before
the first page returns), the effect detects `fetchStatus === "fetching"
&& status === "pending"` and calls
`cancelQueries().then(invalidateQueries)` so the obsolete in-flight
response cannot settle as authoritative before the new target's
validation closure is active.

The query-fn tracks consecutive fetch attempts per target. On attempt 3,
it adds the target to `exhaustedTargetsRef` before calling
`loadThreadReplies`. `loadThreadReplies` sees the target in the
exhausted set and returns the fetched replies directly rather than
throwing — the terminal attempt always resolves to success. No
re-entrant scheduling: the resolution is synchronous inside the query
function itself. Deleted/moderated targets never lock the thread in a
terminal error surface.

**Fix 3 — CLOSED recovery for history subscriptions**
(`relayClosedRecovery.ts`, `relayClientSession.ts`,
`relayClientShared.ts`, `relayGateBoundary.ts`)

On a rate-limited `CLOSED` the subscription previously rejected the
caller immediately. Store `filter` and `timeoutMs` on
`HistorySubscription`, then on rate-limited `CLOSED` re-register under a
fresh `subId` and defer `sendReq` until the rate-limit window clears —
matching the live-sub recovery design already present in
`relayClosedRecovery.ts`. Bounded to 3 attempts; exhausted retries
reject immediately so callers are never left waiting indefinitely. A new
op-timeout guards the retry REQ against a non-responding relay; when the
op-timeout fires it sends `CLOSE` for the rotated `subId` (matching the
behavior of the original timeout path) so the relay releases the slot
rather than counting it against the per-connection cap.

## Tests

- `relayClosedRecovery.test.mjs`: behavioral fake-clock tests for
history-sub retry, 3-attempt exhaustion, op-timeout CLOSE send +
late-EOSE non-regression, rejecting-`closeSubscription` swallowed
without unhandled rejection, wiring source assertion (fails if
`relayClientSession.ts` drops the `closeSubscription` callback) — 18
tests
- `useThreadReplies.test.mjs`: `loadThreadReplies` unit tests
(throw/exhaustion-guard); behavioral hook tests via real
`QueryClientProvider` + `renderHook`: exhaustion-resolves-to-data,
settled null→target change retries on missing-target page and lands
target data (fails if `invalidateQueries` is removed OR if `useQuery` is
moved after the `useEffect`), cold-fetch cancel-then-invalidate (gated
fetcher — released after rerender, stale empty discarded, replacement
fetch settles with target); ChannelScreen wiring source assertion — 9
tests
- `profileBatchResilience.test.mjs`: source assertions for `retry: 3`,
`retryDelay`, error-gated `refetchOnWindowFocus`, and unchanged global
defaults — 2 tests

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-09-02 17:42:45 -04:00

135 lines
4.2 KiB
TypeScript

/**
* Gate-aware relay operation boundaries.
*
* History REQ operations are issued here rather than inline in RelayClient so
* the gate-await + op-timeout pattern lives in one place and the op timeout
* budget starts only after the rate-limit window has cleared.
*/
import { collectWithConcurrency } from "@/shared/api/concurrency";
import { AUX_BACKFILL_CHUNK_SIZE } from "@/shared/api/relayChannelFilters";
import { waitForRateLimit } from "@/shared/api/relayRateLimitGate";
import type {
RelaySubscription,
RelaySubscriptionFilter,
} from "@/shared/api/relayClientShared";
import type { RelayEvent } from "@/shared/api/types";
const AUX_BACKFILL_CONCURRENCY = 4;
/**
* Issue a history REQ on `filter`, waiting for any active rate-limit gate
* before starting the subscription so the op timeout begins only after
* back-pressure has cleared.
*/
export async function requestHistoryGated(
subscriptions: Map<string, RelaySubscription>,
sendRaw: (payload: unknown[]) => Promise<void>,
closeSubscription: (subId: string) => Promise<void>,
filter: RelaySubscriptionFilter,
historyTimeoutMs: number,
): Promise<RelayEvent[]> {
// Await the gate before issuing REQ; op timeout starts after the wait.
await waitForRateLimit();
return new Promise<RelayEvent[]>((resolve, reject) => {
const subId = `history-${crypto.randomUUID()}`;
const timeout = window.setTimeout(() => {
subscriptions.delete(subId);
void closeSubscription(subId);
reject(new Error("Timed out while loading channel history."));
}, historyTimeoutMs);
subscriptions.set(subId, {
mode: "history",
filter,
events: [],
resolve,
reject,
timeout,
timeoutMs: historyTimeoutMs,
});
void sendRaw(["REQ", subId, filter]).catch((error) => {
window.clearTimeout(timeout);
subscriptions.delete(subId);
reject(
error instanceof Error
? error
: new Error("Failed to request channel history."),
);
});
});
}
/**
* Issue a REQ that resolves as soon as its first matching event arrives.
*
* This keeps single-event lookups from waiting for EOSE while still resolving
* `null` when the relay completes an empty result set.
*/
export async function requestFirstEventGated(
subscriptions: Map<string, RelaySubscription>,
sendRaw: (payload: unknown[]) => Promise<void>,
closeSubscription: (subId: string) => Promise<void>,
filter: RelaySubscriptionFilter,
historyTimeoutMs: number,
): Promise<RelayEvent | null> {
await waitForRateLimit();
return new Promise<RelayEvent | null>((resolve, reject) => {
const subId = `first-${crypto.randomUUID()}`;
const timeout = window.setTimeout(() => {
subscriptions.delete(subId);
void closeSubscription(subId);
reject(new Error("Timed out while loading relay event."));
}, historyTimeoutMs);
subscriptions.set(subId, {
mode: "first",
onEvent: (event) => {
window.clearTimeout(timeout);
subscriptions.delete(subId);
void closeSubscription(subId);
resolve(event);
},
resolve,
reject,
timeout,
});
void sendRaw(["REQ", subId, filter]).catch((error) => {
window.clearTimeout(timeout);
subscriptions.delete(subId);
reject(
error instanceof Error
? error
: new Error("Failed to request relay event."),
);
});
});
}
/**
* Fetch relay history for event IDs in bounded, concurrent chunks and flatten
* the responses into one list. Returns an empty list without issuing a request
* when `eventIds` is empty.
*/
export async function fetchChunkedHistory(
eventIds: string[],
buildFilter: (eventIds: string[]) => RelaySubscriptionFilter,
fetchHistory: (filter: RelaySubscriptionFilter) => Promise<RelayEvent[]>,
): Promise<RelayEvent[]> {
if (eventIds.length === 0) return [];
const chunks: string[][] = [];
for (let i = 0; i < eventIds.length; i += AUX_BACKFILL_CHUNK_SIZE) {
chunks.push(eventIds.slice(i, i + AUX_BACKFILL_CHUNK_SIZE));
}
const batches = await collectWithConcurrency(
chunks,
AUX_BACKFILL_CONCURRENCY,
(ids) => fetchHistory(buildFilter(ids)),
);
return batches.flat();
}