mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-05 05:24:13 +00:00
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:
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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user