fix: adjust tui retry dialog logic to be more provider specific and error case specific (#26366)

This commit is contained in:
Aiden Cline
2026-05-08 10:48:19 -05:00
committed by GitHub
parent df75bfe07c
commit 799996db76
8 changed files with 98 additions and 64 deletions

View File

@@ -13,6 +13,7 @@ import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const providerID = ProviderID.make("test")
const retryProvider = "test"
const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer))
function apiError(headers?: Record<string, string>): MessageV2.APIError {
@@ -92,6 +93,7 @@ describe("session.retry.delay", () => {
const step = yield* Schedule.toStepWithMetadata(
SessionRetry.policy({
provider: "test",
parse: (err) => MessageV2.APIError.Schema.parse(err),
set: (info) =>
status.set(sessionID, {
@@ -118,47 +120,47 @@ describe("session.retry.delay", () => {
describe("session.retry.retryable", () => {
test("maps too_many_requests json messages", () => {
const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } }))
expect(SessionRetry.retryable(error)).toEqual({ message: "Too Many Requests" })
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Too Many Requests" })
})
test("maps overloaded provider codes", () => {
const error = wrap(JSON.stringify({ code: "resource_exhausted" }))
expect(SessionRetry.retryable(error)).toEqual({ message: "Provider is overloaded" })
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Provider is overloaded" })
})
test("does not retry unknown json messages", () => {
const error = wrap(JSON.stringify({ error: { message: "no_kv_space" } }))
expect(SessionRetry.retryable(error)).toBeUndefined()
expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined()
})
test("does not throw on numeric error codes", () => {
const error = wrap(JSON.stringify({ type: "error", error: { code: 123 } }))
const result = SessionRetry.retryable(error)
const result = SessionRetry.retryable(error, retryProvider)
expect(result).toBeUndefined()
})
test("returns undefined for non-json message", () => {
const error = wrap("not-json")
expect(SessionRetry.retryable(error)).toBeUndefined()
expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined()
})
test("retries plain text rate limit errors from Alibaba", () => {
const msg =
"Upstream error from Alibaba: Request rate increased too quickly. To ensure system stability, please adjust your client logic to scale requests more smoothly over time."
const error = wrap(msg)
expect(SessionRetry.retryable(error)).toEqual({ message: msg })
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg })
})
test("retries plain text rate limit errors", () => {
const msg = "Rate limit exceeded, please try again later"
const error = wrap(msg)
expect(SessionRetry.retryable(error)).toEqual({ message: msg })
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg })
})
test("retries too many requests in plain text", () => {
const msg = "Too many requests, please slow down"
const error = wrap(msg)
expect(SessionRetry.retryable(error)).toEqual({ message: msg })
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg })
})
test("does not retry context overflow errors", () => {
@@ -167,7 +169,7 @@ describe("session.retry.retryable", () => {
responseBody: '{"error":{"code":"context_length_exceeded"}}',
}).toObject()
expect(SessionRetry.retryable(error)).toBeUndefined()
expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined()
})
test("retries 500 errors even when isRetryable is false", () => {
@@ -180,7 +182,7 @@ describe("session.retry.retryable", () => {
}).toObject(),
)
expect(SessionRetry.retryable(error)).toEqual({ message: "Internal server error" })
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Internal server error" })
})
test("retries 502 bad gateway errors", () => {
@@ -192,7 +194,7 @@ describe("session.retry.retryable", () => {
}).toObject(),
)
expect(SessionRetry.retryable(error)).toEqual({ message: "Bad gateway" })
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Bad gateway" })
})
test("retries 503 service unavailable errors", () => {
@@ -204,7 +206,7 @@ describe("session.retry.retryable", () => {
}).toObject(),
)
expect(SessionRetry.retryable(error)).toEqual({ message: "Service unavailable" })
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Service unavailable" })
})
test("does not retry 4xx errors when isRetryable is false", () => {
@@ -216,7 +218,7 @@ describe("session.retry.retryable", () => {
}).toObject(),
)
expect(SessionRetry.retryable(error)).toBeUndefined()
expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined()
})
test("retries ZlibError decompression failures", () => {
@@ -228,7 +230,7 @@ describe("session.retry.retryable", () => {
}).toObject(),
)
const retryable = SessionRetry.retryable(error)
const retryable = SessionRetry.retryable(error, retryProvider)
expect(retryable).toBeDefined()
expect(retryable).toEqual({ message: "Response decompression failed" })
})
@@ -246,9 +248,11 @@ describe("session.retry.retryable", () => {
}).toObject(),
)
expect(SessionRetry.retryable(error)).toEqual({
expect(SessionRetry.retryable(error, "opencode")).toEqual({
message: SessionRetry.GO_UPSELL_MESSAGE,
action: {
reason: "free_tier_limit",
provider: "opencode",
title: "Free limit reached",
message: "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.",
label: "subscribe",
@@ -280,10 +284,12 @@ describe("session.retry.retryable", () => {
}).toObject(),
)
expect(SessionRetry.retryable(error)).toEqual({
expect(SessionRetry.retryable(error, "opencode-go")).toEqual({
message:
"5 hour usage limit reached. It will reset in 5 hours 23 minutes. To continue using this model now, enable usage from your available balance - https://opencode.ai/workspace/wrk_01K6XGM22R6FM8JVABE9XDQXGH/go",
action: {
reason: "account_rate_limit",
provider: "opencode-go",
title: "Go limit reached",
message:
"5 hour usage limit reached. It will reset in 5 hours 23 minutes. To continue using this model now, enable usage from your available balance",
@@ -292,6 +298,33 @@ describe("session.retry.retryable", () => {
},
})
})
test("maps Go subscription limits without limit metadata", () => {
const error = MessageV2.APIError.Schema.parse(
new MessageV2.APIError({
message: "Subscription quota exceeded. You can continue using free models.",
isRetryable: true,
statusCode: 429,
responseHeaders: {
"retry-after": "900",
},
responseBody: JSON.stringify({
type: "error",
error: {
type: "GoUsageLimitError",
message: "Subscription quota exceeded. You can continue using free models.",
},
metadata: {
workspace: "wrk_01K6XGM22R6FM8JVABE9XDQXGH",
},
}),
}).toObject(),
)
expect(SessionRetry.retryable(error, "opencode-go")?.action?.message).toBe(
"Usage limit reached. It will reset in 15 minutes. To continue using this model now, enable usage from your available balance",
)
})
})
describe("session.message-v2.fromError", () => {
@@ -341,7 +374,7 @@ describe("session.message-v2.fromError", () => {
}).toObject(),
)
const retryable = SessionRetry.retryable(error)
const retryable = SessionRetry.retryable(error, retryProvider)
expect(retryable).toBeDefined()
expect(retryable).toEqual({ message: "Connection reset by server" })
})
@@ -381,6 +414,6 @@ describe("session.message-v2.fromError", () => {
expect(MessageV2.APIError.isInstance(result)).toBe(true)
if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError")
expect(result.data.isRetryable).toBe(true)
expect(SessionRetry.retryable(result)).toEqual({ message: "An error occurred while processing your request." })
expect(SessionRetry.retryable(result, retryProvider)).toEqual({ message: "An error occurred while processing your request." })
})
})

View File

@@ -236,6 +236,8 @@ describe("SessionStatus.Info", () => {
attempt: 1,
message: "transient",
action: {
reason: "free_tier_limit",
provider: "opencode",
title: "Free limit reached",
message: "Subscribe to OpenCode Go.",
label: "subscribe",