mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-18 10:33:15 +00:00
fix: adjust tui retry dialog logic to be more provider specific and error case specific (#26366)
This commit is contained in:
@@ -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." })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user