Skip to content

Commit 42b3f0c

Browse files
refactor(zoo-gateway): split error classification from UX, extract callback fan-out
Address review feedback on PR #347: - zoo-gateway.ts: split surfaceGatewayApiError into a pure classifyGatewayApiError (error -> action) plus a thin UX layer that switches on the action. The classifier is exported and covered by focused unit tests, so the status/code -> action mapping no longer needs the VS Code notification mocks to verify. - handleUri.ts: extract the per-instance token propagation loop into a propagateZooGatewayCallback helper, keeping the /auth-callback case focused on routing. Behaviour (sequential read-modify-write, per instance error isolation) is unchanged. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 4929e3c commit 42b3f0c

3 files changed

Lines changed: 120 additions & 51 deletions

File tree

src/activate/handleUri.ts

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,34 @@ import { getRouterUnavailableSignInMessage } from "../core/config/routerRemoval"
44
import { ClineProvider } from "../core/webview/ClineProvider"
55
import { handleAuthCallback as handleZooCodeAuthCallback, setZooCodeUserInfo } from "../services/zoo-code-auth"
66

7+
/**
8+
* Persist the Zoo Code session token to every active provider instance.
9+
*
10+
* The profile settings write (handleZooCodeCallback) must run on any active
11+
* instance — not just the visible one — so the zoo-gateway zooSessionToken is
12+
* persisted even when the sidebar/panel is hidden at callback time.
13+
*
14+
* Run sequentially (NOT Promise.all): each ClineProvider's handleZooCodeCallback
15+
* does a read-modify-write on the same backing provider settings store
16+
* (listConfig → getProfile → saveConfig / upsertProviderProfile). Fanning out
17+
* concurrently across N instances can interleave reads/writes and clobber
18+
* updates. Serialization is cheap (at most a handful of instances) and avoids
19+
* the race.
20+
*/
21+
async function propagateZooGatewayCallback(token: string): Promise<void> {
22+
const allInstances = ClineProvider.getAllInstances()
23+
for (const instance of allInstances) {
24+
try {
25+
await instance.handleZooCodeCallback(token)
26+
} catch (error) {
27+
console.error(
28+
"Failed to persist Zoo Gateway token for a provider instance:",
29+
error instanceof Error ? error.message : error,
30+
)
31+
}
32+
}
33+
}
34+
735
export const handleUri = async (uri: vscode.Uri) => {
836
const path = uri.path
937
const query = new URLSearchParams(uri.query.replace(/\+/g, "%2B"))
@@ -50,28 +78,7 @@ export const handleUri = async (uri: vscode.Uri) => {
5078
email,
5179
image,
5280
})
53-
// Write the token to all active provider instances regardless of visibility.
54-
// The profile settings write (handleZooCodeCallback) must run on any active
55-
// instance — not just the visible one — so the zoo-gateway zooSessionToken
56-
// is persisted even when the sidebar/panel is hidden at callback time.
57-
//
58-
// Run sequentially (NOT Promise.all): each ClineProvider's
59-
// handleZooCodeCallback does a read-modify-write on the same backing
60-
// provider settings store (listConfig → getProfile → saveConfig /
61-
// upsertProviderProfile). Fanning out concurrently across N instances
62-
// can interleave reads/writes and clobber updates. Serialization here
63-
// is cheap (at most a handful of instances) and avoids the race.
64-
const allInstances = ClineProvider.getAllInstances()
65-
for (const instance of allInstances) {
66-
try {
67-
await instance.handleZooCodeCallback(token)
68-
} catch (error) {
69-
console.error(
70-
"Failed to persist Zoo Gateway token for a provider instance:",
71-
error instanceof Error ? error.message : error,
72-
)
73-
}
74-
}
81+
await propagateZooGatewayCallback(token)
7582
}
7683
}
7784
break

src/api/providers/__tests__/zoo-gateway.spec.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import OpenAI from "openai"
1919

2020
import { zooGatewayDefaultModelId, ZOO_GATEWAY_DEFAULT_TEMPERATURE } from "@roo-code/types"
2121

22-
import { ZooGatewayHandler } from "../zoo-gateway"
22+
import { ZooGatewayHandler, classifyGatewayApiError } from "../zoo-gateway"
2323
import { ApiHandlerOptions } from "../../../shared/api"
2424
import { Package } from "../../../shared/package"
2525
import { clearZooCodeToken } from "../../../services/zoo-code-auth"
@@ -410,6 +410,39 @@ describe("ZooGatewayHandler", () => {
410410
})
411411
})
412412

413+
describe("classifyGatewayApiError", () => {
414+
it("returns sign_in on 401", () => {
415+
expect(classifyGatewayApiError(makeApiError(401))).toEqual({ kind: "sign_in" })
416+
})
417+
418+
it("returns add_credits (not budget) on 402", () => {
419+
expect(classifyGatewayApiError(makeApiError(402))).toEqual({ kind: "add_credits", budgetExceeded: false })
420+
})
421+
422+
it("returns add_credits with budgetExceeded on 429 budget codes", () => {
423+
expect(classifyGatewayApiError(makeApiError(429, { code: "monthly_budget_exceeded" }))).toEqual({
424+
kind: "add_credits",
425+
budgetExceeded: true,
426+
})
427+
expect(classifyGatewayApiError(makeApiError(429, { code: "daily_budget_exceeded" }))).toEqual({
428+
kind: "add_credits",
429+
budgetExceeded: true,
430+
})
431+
})
432+
433+
it("returns none on 429 without a budget code", () => {
434+
expect(classifyGatewayApiError(makeApiError(429, { code: "rate_limited" }))).toEqual({ kind: "none" })
435+
})
436+
437+
it("returns contact_support on 403", () => {
438+
expect(classifyGatewayApiError(makeApiError(403))).toEqual({ kind: "contact_support" })
439+
})
440+
441+
it("returns none for errors without an HTTP status", () => {
442+
expect(classifyGatewayApiError(new Error("network down"))).toEqual({ kind: "none" })
443+
})
444+
})
445+
413446
describe("surfaceGatewayApiError", () => {
414447
it("clears the cached token and offers re-sign-in on 401", async () => {
415448
const handler = new ZooGatewayHandler(mockOptions)

src/api/providers/zoo-gateway.ts

Lines changed: 57 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -46,46 +46,75 @@ function buildZooCodeSignInUrl(): string {
4646
return `${getZooCodeBaseUrl()}/dashboard/connect?device=${device}&editor=${editor}&version=${Package.version}&callback_uri=${callbackUri}`
4747
}
4848

49-
// Caller must always rethrow — this only surfaces UX, never swallows.
50-
async function surfaceGatewayApiError(error: unknown): Promise<void> {
49+
type ZooGatewayApiErrorAction =
50+
| { kind: "sign_in" }
51+
| { kind: "add_credits"; budgetExceeded: boolean }
52+
| { kind: "contact_support" }
53+
| { kind: "none" }
54+
55+
// Pure mapping from an API error to the UX action it warrants. No side effects,
56+
// so this is trivial to unit test independently of the VS Code notification flow.
57+
// Exported for unit tests.
58+
export function classifyGatewayApiError(error: unknown): ZooGatewayApiErrorAction {
5159
const status = getApiErrorStatus(error)
52-
if (status === undefined) return
60+
if (status === undefined) return { kind: "none" }
5361
const code = getApiErrorCode(error)
5462

5563
if (status === 401) {
56-
// Wipe before sign-in so the callback rebinds against an empty slot.
57-
await clearZooCodeToken()
58-
const action = await vscode.window.showErrorMessage(
59-
t("common:zooAuth.errors.session_expired"),
60-
t("common:zooAuth.buttons.sign_in"),
61-
)
62-
if (action) {
63-
void vscode.env.openExternal(vscode.Uri.parse(buildZooCodeSignInUrl()))
64-
}
65-
return
64+
return { kind: "sign_in" }
6665
}
6766

6867
const isBudgetExceeded = status === 429 && (code === "monthly_budget_exceeded" || code === "daily_budget_exceeded")
6968
if (status === 402 || isBudgetExceeded) {
70-
const message = isBudgetExceeded
71-
? t("common:zooAuth.errors.budget_exceeded")
72-
: t("common:zooAuth.errors.out_of_credits")
73-
const action = await vscode.window.showErrorMessage(message, t("common:zooAuth.buttons.add_credits"))
74-
if (action) {
75-
void vscode.env.openExternal(vscode.Uri.parse(`${getZooCodeBaseUrl()}/dashboard/credits`))
76-
}
77-
return
69+
return { kind: "add_credits", budgetExceeded: isBudgetExceeded }
7870
}
7971

8072
if (status === 403) {
81-
const action = await vscode.window.showErrorMessage(
82-
t("common:zooAuth.errors.account_unavailable"),
83-
t("common:zooAuth.buttons.contact_support"),
84-
)
85-
if (action) {
86-
void vscode.env.openExternal(vscode.Uri.parse(`${getZooCodeBaseUrl()}/support`))
73+
return { kind: "contact_support" }
74+
}
75+
76+
return { kind: "none" }
77+
}
78+
79+
// Caller must always rethrow — this only surfaces UX, never swallows.
80+
async function surfaceGatewayApiError(error: unknown): Promise<void> {
81+
const action = classifyGatewayApiError(error)
82+
83+
switch (action.kind) {
84+
case "sign_in": {
85+
// Wipe before sign-in so the callback rebinds against an empty slot.
86+
await clearZooCodeToken()
87+
const clicked = await vscode.window.showErrorMessage(
88+
t("common:zooAuth.errors.session_expired"),
89+
t("common:zooAuth.buttons.sign_in"),
90+
)
91+
if (clicked) {
92+
void vscode.env.openExternal(vscode.Uri.parse(buildZooCodeSignInUrl()))
93+
}
94+
return
95+
}
96+
case "add_credits": {
97+
const message = action.budgetExceeded
98+
? t("common:zooAuth.errors.budget_exceeded")
99+
: t("common:zooAuth.errors.out_of_credits")
100+
const clicked = await vscode.window.showErrorMessage(message, t("common:zooAuth.buttons.add_credits"))
101+
if (clicked) {
102+
void vscode.env.openExternal(vscode.Uri.parse(`${getZooCodeBaseUrl()}/dashboard/credits`))
103+
}
104+
return
105+
}
106+
case "contact_support": {
107+
const clicked = await vscode.window.showErrorMessage(
108+
t("common:zooAuth.errors.account_unavailable"),
109+
t("common:zooAuth.buttons.contact_support"),
110+
)
111+
if (clicked) {
112+
void vscode.env.openExternal(vscode.Uri.parse(`${getZooCodeBaseUrl()}/support`))
113+
}
114+
return
87115
}
88-
return
116+
default:
117+
return
89118
}
90119
}
91120

0 commit comments

Comments
 (0)