diff --git a/packages/desktop/src/context/global-sync.tsx b/packages/desktop/src/context/global-sync.tsx index fffef5b5f..27a89e7bc 100644 --- a/packages/desktop/src/context/global-sync.tsx +++ b/packages/desktop/src/context/global-sync.tsx @@ -18,6 +18,7 @@ import { } from "@opencode-ai/sdk/v2/client" import { createStore, produce, reconcile } from "solid-js/store" import { Binary } from "@opencode-ai/util/binary" +import { retry } from "@opencode-ai/util/retry" import { useGlobalSDK } from "./global-sdk" import { ErrorPage, type InitError } from "../pages/error" import { createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js" @@ -145,7 +146,7 @@ function createGlobalSync() { changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)), node: () => sdk.file.list({ path: "/" }).then((x) => setStore("node", x.data!)), } - await Promise.all(Object.values(load).map((p) => p().catch((e) => setGlobalStore("error", e)))) + await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e)))) .then(() => setStore("ready", true)) .catch((e) => setGlobalStore("error", e)) } @@ -295,21 +296,29 @@ function createGlobalSync() { async function bootstrap() { return Promise.all([ - globalSDK.client.path.get().then((x) => { - setGlobalStore("path", x.data!) - }), - globalSDK.client.project.list().then(async (x) => { - setGlobalStore( - "project", - x.data!.filter((p) => !p.worktree.includes("opencode-test")).sort((a, b) => a.id.localeCompare(b.id)), - ) - }), - globalSDK.client.provider.list().then((x) => { - setGlobalStore("provider", x.data ?? {}) - }), - globalSDK.client.provider.auth().then((x) => { - setGlobalStore("provider_auth", x.data ?? {}) - }), + retry(() => + globalSDK.client.path.get().then((x) => { + setGlobalStore("path", x.data!) + }), + ), + retry(() => + globalSDK.client.project.list().then(async (x) => { + setGlobalStore( + "project", + x.data!.filter((p) => !p.worktree.includes("opencode-test")).sort((a, b) => a.id.localeCompare(b.id)), + ) + }), + ), + retry(() => + globalSDK.client.provider.list().then((x) => { + setGlobalStore("provider", x.data ?? {}) + }), + ), + retry(() => + globalSDK.client.provider.auth().then((x) => { + setGlobalStore("provider_auth", x.data ?? {}) + }), + ), ]) .then(() => setGlobalStore("ready", true)) .catch((e) => setGlobalStore("error", e)) diff --git a/packages/desktop/src/context/sync.tsx b/packages/desktop/src/context/sync.tsx index ca25cae98..941b8b629 100644 --- a/packages/desktop/src/context/sync.tsx +++ b/packages/desktop/src/context/sync.tsx @@ -1,6 +1,7 @@ import { produce } from "solid-js/store" import { createMemo } from "solid-js" import { Binary } from "@opencode-ai/util/binary" +import { retry } from "@opencode-ai/util/retry" import { createSimpleContext } from "@opencode-ai/ui/context" import { useGlobalSync } from "./global-sync" import { useSDK } from "./sdk" @@ -61,10 +62,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }, async sync(sessionID: string, _isRetry = false) { const [session, messages, todo, diff] = await Promise.all([ - sdk.client.session.get({ sessionID }, { throwOnError: true }), - sdk.client.session.messages({ sessionID, limit: 100 }), - sdk.client.session.todo({ sessionID }), - sdk.client.session.diff({ sessionID }), + retry(() => sdk.client.session.get({ sessionID })), + retry(() => sdk.client.session.messages({ sessionID, limit: 100 })), + retry(() => sdk.client.session.todo({ sessionID })), + retry(() => sdk.client.session.diff({ sessionID })), ]) setStore( produce((draft) => { diff --git a/packages/util/src/retry.ts b/packages/util/src/retry.ts new file mode 100644 index 000000000..0014a604c --- /dev/null +++ b/packages/util/src/retry.ts @@ -0,0 +1,41 @@ +export interface RetryOptions { + attempts?: number + delay?: number + factor?: number + maxDelay?: number + retryIf?: (error: unknown) => boolean +} + +const TRANSIENT_MESSAGES = [ + "load failed", + "network connection was lost", + "network request failed", + "failed to fetch", + "econnreset", + "econnrefused", + "etimedout", + "socket hang up", +] + +function isTransientError(error: unknown): boolean { + if (!error) return false + const message = String(error instanceof Error ? error.message : error).toLowerCase() + return TRANSIENT_MESSAGES.some((m) => message.includes(m)) +} + +export async function retry(fn: () => Promise, options: RetryOptions = {}): Promise { + const { attempts = 3, delay = 500, factor = 2, maxDelay = 10000, retryIf = isTransientError } = options + + let lastError: unknown + for (let attempt = 0; attempt < attempts; attempt++) { + try { + return await fn() + } catch (error) { + lastError = error + if (attempt === attempts - 1 || !retryIf(error)) throw error + const wait = Math.min(delay * Math.pow(factor, attempt), maxDelay) + await new Promise((resolve) => setTimeout(resolve, wait)) + } + } + throw lastError +}