fix(realtime): retry instead of refusing when the plan lookup fails

When the billing lookup failed, metering answered with a provisional
fallback plan, so the relay refused the session with
`subscription_required` or, against the fallback's allowance,
`insufficient_funds`, both terminal 1008 closes, and the SDK showed the
upgrade prompt to a paying account. A plan or funds refusal decided on a
provisional plan now sends `internal_error` and closes with 1011, at
connection and mid-session, so the client retries once the lookup
recovers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
404oopsandClaude Opus 5.5 authored and GitHub committed 2026-11-04 00:45:48 -07:00
1 parent af0d87b9e3
commit 41a9da9c72
3 files changed
+96 -9

No files matched your search

@@ -1074,6 +1074,72 @@ describe('OpenAIRealtimeRelayService relay', () => {
expect((await c.closed).code).toBe(1008);
});
describe('when the plan lookup fails', () => {
type Resolvers = { subscriptionResolvers: unknown[] };
// A resolver that throws makes the plan a provisional fallback.
const failing = () => {
throw new Error('billing unavailable');
};
const failLookups = (uuid: string) => {
const metering = server.services.metering;
(metering as unknown as Resolvers).subscriptionResolvers.unshift(
failing,
);
metering.invalidateActorSubscription(uuid);
};
afterEach(async () => {
const metering = server.services.metering;
const resolvers = (metering as unknown as Resolvers)
.subscriptionResolvers;
resolvers.splice(resolvers.indexOf(failing), 1);
// The fallback stays cached for its window otherwise.
const u = await server.stores.user.getByUsername(user.username);
metering.invalidateActorSubscription(u!.uuid);
});
it('asks the client to retry instead of reporting no subscription', async () => {
const u = await server.stores.user.getByUsername(user.username);
vi.mocked(
server.services.metering.getActorSubscription,
).mockRestore();
failLookups(u!.uuid);
const c = open(`${relayUrl}?auth_token=${user.token}`);
expect(await c.waitFor('error')).toMatchObject({
error: { code: 'internal_error' },
});
expect((await c.closed).code).toBe(1011);
expect(
c.events.some(
(e) =>
(e.error as { code?: string } | undefined)?.code ===
'subscription_required',
),
).toBe(false);
});
it('asks a session to retry instead of reporting no funds', async () => {
const u = await server.stores.user.getByUsername(user.username);
const c = open(
`${relayUrl}?model=gpt-realtime-mini&auth_token=${user.token}`,
);
await c.waitFor('session.created');
failLookups(u!.uuid);
vi.spyOn(
server.services.metering,
'getAllowedUsage',
).mockResolvedValue({
remaining: 0,
monthUsageAllowance: 0,
addons: {},
});
c.ws.send(JSON.stringify({ type: 'hold.response' }));
expect(await c.waitFor('error')).toMatchObject({
error: { code: 'internal_error' },
});
expect((await c.closed).code).toBe(1011);
});
});
it('forwards a normal stream without waiting for metering', async () => {
const c = open(`${relayUrl}?auth_token=${user.token}`);
await c.waitFor('session.created');
@@ -812,6 +812,24 @@ export class OpenAIRealtimeRelayService extends PuterService {
}
session.close(closeCode, code);
};
// A plan or balance refusal decided on a plan whose lookup failed may
// not apply to this account, so the client is told to retry instead.
const refuse = async (code: string, message: string) => {
let provisional = true;
try {
({ provisional } =
await this.services.metering.getActorSubscriptionStatus(
actor!,
));
} catch (err) {
console.warn('[realtime] plan lookup failed', err);
}
if (provisional) {
reject('internal_error', 'Could not verify subscription', 1011);
} else {
reject(code, message);
}
};
if (this.#draining) {
reject(
@@ -896,11 +914,14 @@ export class OpenAIRealtimeRelayService extends PuterService {
while (usageQueue.length) {
const { rows, holds } = usageQueue[0];
if (rows.length) {
await this.#record(actor!, rows, () =>
reject(
'insufficient_funds',
'No usage left for request',
),
await this.#record(
actor!,
rows,
() =>
void refuse(
'insufficient_funds',
'No usage left for request',
),
);
}
await Promise.all(holds.map((hold) => releaseHold(hold)));
@@ -938,7 +959,7 @@ export class OpenAIRealtimeRelayService extends PuterService {
responseHoldAmount(billingModel),
);
if (!taken) {
reject(
await refuse(
'insufficient_funds',
'No usage left for request',
);
@@ -1159,7 +1180,7 @@ export class OpenAIRealtimeRelayService extends PuterService {
} catch (err) {
const status = (err as { statusCode?: number }).statusCode;
if (status === 402 || status === 403) {
reject(
await refuse(
'subscription_required',
'Realtime conversations require a subscription',
);
@@ -1177,7 +1198,7 @@ export class OpenAIRealtimeRelayService extends PuterService {
responseHoldAmount(model),
);
if (!probe) {
reject('insufficient_funds', 'No usage left for request');
await refuse('insufficient_funds', 'No usage left for request');
return;
}
await probe.release();
+1 -1
View File
@@ -109,7 +109,7 @@ A new connection starts an empty conversation. To keep context, add earlier item
## Errors, billing, and limits
API errors arrive as `{ "type": "error", "error": { "code": "...", "message": "..." } }`. Handle socket errors and close frames too. Invalid, missing, or revoked credentials use `invalid_api_key`. If Puter can't check a credential, the error is `internal_error` and the session closes with 1011, so try again after a short, randomized delay. Other relay codes include `model_not_found`, `insufficient_funds`, `subscription_required`, `rate_limit_exceeded`, `invalid_event`, `connection_timeout`, `provider_unavailable`, `upstream_error`, and `server_restarting`.
API errors arrive as `{ "type": "error", "error": { "code": "...", "message": "..." } }`. Handle socket errors and close frames too. Invalid, missing, or revoked credentials use `invalid_api_key`. If Puter can't check a credential or the account's plan, the error is `internal_error` and the session closes with 1011, so try again after a short, randomized delay. Other relay codes include `model_not_found`, `insufficient_funds`, `subscription_required`, `rate_limit_exceeded`, `invalid_event`, `connection_timeout`, `provider_unavailable`, `upstream_error`, and `server_restarting`.
Policy failures generally close with 1008, oversized client messages with 1009, upstream or setup failures with 1011, server restarts with 1012, and temporary server overload with 1013. Reconnect after 1011, 1012, or 1013. Authentication and model failures can arrive after the WebSocket handshake succeeds.