From 4184d0f094a123e0c6fb1a66c248a540d415d83f Mon Sep 17 00:00:00 2001 From: JD Date: Tue, 2 Jun 2026 11:52:20 +0000 Subject: [PATCH] fix(ui): add retry logic to SSE pong to improve connection resilience When the client receives a ping from the server, it responds with a pong via HTTP POST. On unstable networks (mobile, WiFi with poor signal, network switches), this POST can fail due to transient errors: - Network timeouts - Brief disconnections - Server temporarily unavailable Previously, a single pong failure would cause the server to close the SSE connection after 45s timeout, leaving message responses stuck in queue until the next message triggered a reconnection. This fix wraps the pong POST in retryWithBackoff with exponential backoff: - 3 attempts maximum - 100ms initial delay, doubling each retry - 2000ms maximum delay between retries This handles transient network issues gracefully without requiring reconnection, improving message delivery reliability for all users on imperfect networks (not just mobile). Retry logic is extracted to a reusable retry-utils.ts module that can be used for other fragile HTTP operations in the future. --- packages/ui/src/lib/retry-utils.ts | 38 ++++++++++++++++++++++++++++ packages/ui/src/lib/server-events.ts | 22 ++++++++++------ 2 files changed, 52 insertions(+), 8 deletions(-) create mode 100644 packages/ui/src/lib/retry-utils.ts diff --git a/packages/ui/src/lib/retry-utils.ts b/packages/ui/src/lib/retry-utils.ts new file mode 100644 index 000000000..ac5783c21 --- /dev/null +++ b/packages/ui/src/lib/retry-utils.ts @@ -0,0 +1,38 @@ +interface RetryOptions { + maxAttempts?: number + initialDelayMs?: number + maxDelayMs?: number + backoffMultiplier?: number +} + +export async function retryWithBackoff( + fn: () => Promise, + options: RetryOptions = {}, +): Promise { + const { + maxAttempts = 3, + initialDelayMs = 100, + maxDelayMs = 5000, + backoffMultiplier = 2, + } = options + + let lastError: Error | null = null + let delayMs = initialDelayMs + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn() + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)) + + if (attempt < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, delayMs)) + delayMs = Math.min(delayMs * backoffMultiplier, maxDelayMs) + } + } + } + + throw new Error( + `Failed after ${maxAttempts} attempts: ${lastError?.message || "Unknown error"}`, + ) +} diff --git a/packages/ui/src/lib/server-events.ts b/packages/ui/src/lib/server-events.ts index 833e6c2aa..2ecfeb6ee 100644 --- a/packages/ui/src/lib/server-events.ts +++ b/packages/ui/src/lib/server-events.ts @@ -2,6 +2,7 @@ import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../server/ import { serverApi } from "./api-client" import { getClientIdentity } from "./client-identity" import { getLogger } from "./logger" +import { retryWithBackoff } from "./retry-utils" const RETRY_BASE_DELAY = 1000 const RETRY_MAX_DELAY = 10000 @@ -39,14 +40,19 @@ class ServerEvents { (event) => this.dispatch(event), () => this.scheduleReconnect(), (payload) => { - void serverApi - .sendClientConnectionPong({ - ...getClientIdentity(), - pingTs: payload.ts, - }) - .catch((error) => { - log.error("Failed to send client connection pong", error) - }) + const identity = getClientIdentity() + const pongPayload = { ...identity, pingTs: payload.ts } + + void retryWithBackoff( + () => serverApi.sendClientConnectionPong(pongPayload), + { + maxAttempts: 3, + initialDelayMs: 100, + maxDelayMs: 2000, + }, + ).catch((error) => { + log.error("Failed to send client connection pong after retries", error) + }) }, ) this.source.onopen = () => {