Compare commits

..

2 Commits

Author SHA1 Message Date
Jack Jiang c4219eb9a7 fix(console): reuse workspace Stripe customers 2026-08-05 18:15:26 +00:00
Aiden Cline f929f8f100 refactor(opencode): simplify retry error matching (#40694) 2026-08-05 13:05:19 -05:00
4 changed files with 70 additions and 112 deletions
@@ -87,24 +87,7 @@ const createSetupIntent = async (input: { plan: string; workspaceID: string }) =
return { error: formError.alreadySubscribed }
}
let customerID = customer?.customerID
if (!customerID) {
const customer = await Billing.stripe().customers.create({
email,
metadata: {
workspaceID,
},
})
customerID = customer.id
await Database.use((tx) =>
tx
.update(BillingTable)
.set({
customerID,
})
.where(eq(BillingTable.workspaceID, workspaceID)),
)
}
const customerID = await Billing.ensureCustomer(email)
const intent = await Billing.stripe().setupIntents.create({
customer: customerID,
+49 -27
View File
@@ -42,6 +42,39 @@ export namespace Billing {
)
}
export const ensureCustomer = async (email?: string) => {
const billing = await get()
if (billing?.customerID) return billing.customerID
const workspaceID = Actor.workspace()
const stripe = Billing.stripe()
const created = await stripe.customers.create(
{
metadata: {
workspaceID,
},
},
{
idempotencyKey: `opencode-workspace-customer:${workspaceID}`,
},
)
await Database.use((tx) =>
tx
.update(BillingTable)
.set({
customerID: created.id,
})
.where(and(eq(BillingTable.workspaceID, workspaceID), isNull(BillingTable.customerID))),
)
const customerID = (await get())?.customerID
if (!customerID) throw new Error(`Workspace with ID ${workspaceID} not found`)
if (customerID === created.id && email) {
await stripe.customers.update(customerID, { email })
}
return customerID
}
export const payments = async () => {
return await Database.use((tx) =>
tx
@@ -231,8 +264,9 @@ export namespace Billing {
}
const email = await User.getAuthEmail(user.properties.userID)
const customer = await Billing.get()
const amountInCents = (amount ?? customer.reloadAmount ?? Billing.RELOAD_AMOUNT) * 100
const billing = await Billing.get()
const customerID = await Billing.ensureCustomer(email ?? undefined)
const amountInCents = (amount ?? billing.reloadAmount ?? Billing.RELOAD_AMOUNT) * 100
const session = await Billing.stripe().checkout.sessions.create({
mode: "payment",
billing_address_collection: "required",
@@ -254,18 +288,11 @@ export namespace Billing {
quantity: 1,
},
],
...(customer.customerID
? {
customer: customer.customerID,
customer_update: {
name: "auto",
address: "auto",
},
}
: {
customer_email: email!,
customer_creation: "always",
}),
customer: customerID,
customer_update: {
name: "auto",
address: "auto",
},
currency: "usd",
invoice_creation: {
enabled: true,
@@ -311,6 +338,7 @@ export namespace Billing {
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
const customerID = await Billing.ensureCustomer(email)
const coupons = await Database.use((tx) =>
tx
@@ -335,17 +363,11 @@ export namespace Billing {
Billing.stripe().checkout.sessions.create({
mode: "subscription",
discounts: coupon ? [{ coupon }] : undefined,
...(billing.customerID
? {
customer: billing.customerID,
customer_update: {
name: "auto",
address: "auto",
},
}
: {
customer_email: email,
}),
customer: customerID,
customer_update: {
name: "auto",
address: "auto",
},
...(() => {
if (method === "alipay") {
return {
@@ -411,14 +433,14 @@ export namespace Billing {
// get pending payment intent
const intents = await Billing.stripe().paymentIntents.search({
query: `-status:'canceled' AND -status:'processing' AND -status:'succeeded' AND customer:'${billing.customerID}'`,
query: `-status:'canceled' AND -status:'processing' AND -status:'succeeded' AND customer:'${customerID}'`,
})
if (intents.data.length === 0) throw e
for (const intent of intents.data) {
// get checkout session
const sessions = await Billing.stripe().checkout.sessions.list({
customer: billing.customerID!,
customer: customerID,
payment_intent: intent.id,
})
+13 -27
View File
@@ -28,12 +28,6 @@ export const RETRY_BACKOFF_FACTOR = 2
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout
const RETRYABLE_MESSAGE = [
/\b(?:server[_\s-]?error|internal[_\s-]?error|service[_\s-]?unavailable|overloaded|too many requests|rate increased too quickly|rate[_\s-]?limit)\b|\bprovider returned error\b/i,
/\b(?:fetch failed|network error|upstream connect|connection (?:error|refused|lost)|socket connection was closed|socket hang up|reset before headers|getaddrinfo|ENOTFOUND|EAI_AGAIN)\b|^timeout$|\b(?:request|response|connection|network|stream|read) (?:timeout|timed? out)\b/i,
/\b(?:resource[_\s-]?exhausted|please retry your request|you can retry your request|try your request again)\b/i,
]
function cap(ms: number) {
return Math.min(ms, RETRY_MAX_DELAY)
}
@@ -74,13 +68,11 @@ export function delay(attempt: number, error?: SessionV1.APIError) {
export function retryable(error: Err, provider: string) {
// context overflow errors should not be retried
if (SessionV1.ContextOverflowError.isInstance(error)) return undefined
const msg = isRecord(error.data) ? error.data.message : undefined
const retryableMessage = isRetryableMessage(msg)
if (SessionV1.APIError.isInstance(error)) {
const status = error.data.statusCode
// 5xx errors are transient server failures and should always be retried,
// even when the provider SDK doesn't explicitly mark them as retryable.
if (!error.data.isRetryable && !(status !== undefined && status >= 500) && !retryableMessage) return undefined
if (!error.data.isRetryable && !(status !== undefined && status >= 500)) return undefined
if (error.data.responseBody?.includes("FreeUsageLimitError")) {
return {
message: GO_UPSELL_MESSAGE,
@@ -130,28 +122,22 @@ export function retryable(error: Err, provider: string) {
return { message: error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message }
}
const json = parseJSON(msg)
if (json && typeof json === "object") {
const code = typeof json.code === "string" ? json.code : ""
if (json.type === "error" && json.error?.type === "too_many_requests") {
return { message: "Too Many Requests" }
}
if (code.includes("exhausted") || code.includes("unavailable")) {
return { message: "Provider is overloaded" }
}
if (json.type === "error" && typeof json.error?.code === "string" && json.error.code.includes("rate_limit")) {
return { message: "Rate Limited" }
}
const message = isRecord(error.data) ? error.data.message : undefined
if (typeof message !== "string") return undefined
const lower = message.toLowerCase()
if (
lower.includes("rate increased too quickly") ||
lower.includes("rate limit") ||
lower.includes("rate_limit") ||
lower.includes("too many requests")
) {
return { message }
}
if (retryableMessage && typeof msg === "string") return { message: msg }
if (lower.includes("too_many_requests")) return { message: "Too Many Requests" }
if (lower.includes("exhausted") || lower.includes("unavailable")) return { message: "Provider is overloaded" }
return undefined
}
function isRetryableMessage(input: unknown) {
if (typeof input !== "string") return false
return RETRYABLE_MESSAGE.some((pattern) => pattern.test(input))
}
function str(value: unknown) {
if (value === undefined || value === null) return ""
return String(value)
+7 -40
View File
@@ -118,16 +118,21 @@ describe("session.retry.delay", () => {
})
describe("session.retry.retryable", () => {
test("maps too_many_requests json messages", () => {
test("retries serialized too_many_requests messages", () => {
const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } }))
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Too Many Requests" })
})
test("maps overloaded provider codes", () => {
test("retries serialized overloaded provider codes", () => {
const error = wrap(JSON.stringify({ code: "resource_exhausted" }))
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Provider is overloaded" })
})
test("retries serialized rate_limit messages", () => {
const message = JSON.stringify({ type: "error", error: { code: "rate_limit_exceeded" } })
expect(SessionRetry.retryable(wrap(message), retryProvider)).toEqual({ message })
})
test("does not retry unknown json messages", () => {
const error = wrap(JSON.stringify({ error: { message: "no_kv_space" } }))
expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined()
@@ -163,44 +168,6 @@ describe("session.retry.retryable", () => {
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg })
})
test.each([
"server_error",
"Internal server error",
"Service Unavailable",
"Provider is overloaded",
"Provider returned error",
"fetch failed",
"connection timed out",
"socket hang up",
"getaddrinfo ENOTFOUND api.example.com",
"ResourceExhausted",
"You can retry your request",
])("retries transient plain text errors: %s", (msg) => {
expect(SessionRetry.retryable(wrap(msg), retryProvider)).toEqual({ message: msg })
})
test("retries transient messages nested in json", () => {
const msg = JSON.stringify({ type: "error", error: { code: "server_error", message: "xxx" } })
expect(SessionRetry.retryable(wrap(msg), retryProvider)).toEqual({ message: msg })
})
test("retries transient API errors even when the SDK does not", () => {
const error = new SessionV1.APIError({ message: "server_error", isRetryable: false }).toObject()
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "server_error" })
})
test.each(["Observer error", "Unterminated string in JSON", "Invalid timeout option"])(
"does not retry near-miss errors: %s",
(msg) => {
expect(SessionRetry.retryable(wrap(msg), retryProvider)).toBeUndefined()
},
)
test("retries transient messages in arbitrary json fields", () => {
const msg = JSON.stringify({ detail: "rate limit exceeded" })
expect(SessionRetry.retryable(wrap(msg), retryProvider)).toEqual({ message: msg })
})
test("retries transport timeout errors", () => {
const request = MessageV2.fromError(new ProviderError.HeaderTimeoutError(10000), { providerID })
expect(SessionV1.APIError.isInstance(request)).toBe(true)