Skip to content

Commit 5aa941f

Browse files
committed
fix: separate entitlement errors from rate limits to prevent infinite retry loops
- usage_not_included now returns 403 instead of triggering rate limit handling - adds clear error message for subscription/entitlement issues - prevents account rotation for non-recoverable errors fixes #16
1 parent 0a813ea commit 5aa941f

3 files changed

Lines changed: 116 additions & 2 deletions

File tree

lib/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const PROVIDER_ID = "openai";
1818
/** HTTP Status Codes */
1919
export const HTTP_STATUS = {
2020
OK: 200,
21+
FORBIDDEN: 403,
2122
UNAUTHORIZED: 401,
2223
NOT_FOUND: 404,
2324
TOO_MANY_REQUESTS: 429,

lib/request/fetch-helpers.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,50 @@ export interface RateLimitInfo {
2525
code?: string;
2626
}
2727

28+
export interface EntitlementError {
29+
isEntitlement: true;
30+
code: string;
31+
message: string;
32+
}
33+
34+
/**
35+
* Checks if an error code indicates an entitlement/subscription issue
36+
* These errors should NOT be treated as rate limits because:
37+
* 1. They won't resolve by waiting
38+
* 2. They won't resolve by switching accounts (all accounts likely have same issue)
39+
* 3. User needs to upgrade their subscription
40+
*/
41+
export function isEntitlementError(code: string, bodyText: string): boolean {
42+
const haystack = `${code} ${bodyText}`.toLowerCase();
43+
// "usage_not_included" means the subscription doesn't include this feature
44+
// This is different from "usage_limit_reached" which is a temporary quota limit
45+
return /usage_not_included|not.included.in.your.plan|subscription.does.not.include/i.test(haystack);
46+
}
47+
48+
/**
49+
* Creates a user-friendly entitlement error response
50+
*/
51+
export function createEntitlementErrorResponse(_bodyText: string): Response {
52+
const message =
53+
"This model is not included in your ChatGPT subscription. " +
54+
"Please check that your account has access to Codex models (requires ChatGPT Plus/Pro). " +
55+
"If you recently subscribed, try logging out and back in with `opencode auth login`.";
56+
57+
const payload = {
58+
error: {
59+
message,
60+
type: "entitlement_error",
61+
code: "usage_not_included",
62+
},
63+
};
64+
65+
return new Response(JSON.stringify(payload), {
66+
status: 403, // Forbidden - not a rate limit
67+
statusText: "Forbidden",
68+
headers: { "content-type": "application/json; charset=utf-8" },
69+
});
70+
}
71+
2872
export interface ErrorHandlingResult {
2973
response: Response;
3074
rateLimit?: RateLimitInfo;
@@ -219,6 +263,12 @@ export async function handleErrorResponse(
219263
): Promise<ErrorHandlingResult> {
220264
const bodyText = await safeReadBody(response);
221265
const mapped = mapUsageLimit404WithBody(response, bodyText);
266+
267+
// Entitlement errors return a ready-to-use Response with 403 status
268+
if (mapped && mapped.status === HTTP_STATUS.FORBIDDEN) {
269+
return { response: mapped, rateLimit: undefined, errorBody: undefined };
270+
}
271+
222272
const finalResponse = mapped ?? response;
223273
const rateLimit = extractRateLimitInfoFromBody(finalResponse, bodyText);
224274

@@ -291,8 +341,13 @@ function mapUsageLimit404WithBody(response: Response, bodyText: string): Respons
291341
code = "";
292342
}
293343

344+
// Check for entitlement errors first - these should NOT be treated as rate limits
345+
if (isEntitlementError(code, bodyText)) {
346+
return createEntitlementErrorResponse(bodyText);
347+
}
348+
294349
const haystack = `${code} ${bodyText}`.toLowerCase();
295-
if (!/usage_limit_reached|usage_not_included|rate_limit_exceeded|usage limit/i.test(haystack)) {
350+
if (!/usage_limit_reached|rate_limit_exceeded|usage limit/i.test(haystack)) {
296351
return null;
297352
}
298353

@@ -313,9 +368,15 @@ function extractRateLimitInfoFromBody(
313368
const parsed = parseRateLimitBody(bodyText);
314369

315370
const haystack = `${parsed?.code ?? ""} ${bodyText}`.toLowerCase();
371+
372+
// Entitlement errors should not be treated as rate limits
373+
if (isEntitlementError(parsed?.code ?? "", bodyText)) {
374+
return undefined;
375+
}
376+
316377
const isRateLimit =
317378
isStatusRateLimit ||
318-
/usage_limit_reached|usage_not_included|rate_limit_exceeded|rate_limit|usage limit/i.test(
379+
/usage_limit_reached|rate_limit_exceeded|rate_limit|usage limit/i.test(
319380
haystack,
320381
);
321382
if (!isRateLimit) return undefined;

test/fetch-helpers.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
rewriteUrlForCodex,
88
createCodexHeaders,
99
handleErrorResponse,
10+
isEntitlementError,
11+
createEntitlementErrorResponse,
1012
} from '../lib/request/fetch-helpers.js';
1113
import type { Auth } from '../lib/types.js';
1214
import { URL_PATHS, OPENAI_HEADERS, OPENAI_HEADER_VALUES } from '../lib/constants.js';
@@ -200,5 +202,55 @@ describe('Fetch Helpers Module', () => {
200202
expect(headers.get(OPENAI_HEADERS.CONVERSATION_ID)).toBeNull();
201203
expect(headers.get(OPENAI_HEADERS.SESSION_ID)).toBeNull();
202204
});
205+
206+
it('maps usage_not_included 404 to 403 entitlement error, not rate limit', async () => {
207+
const body = {
208+
error: {
209+
code: 'usage_not_included',
210+
message: 'Usage not included in your plan',
211+
},
212+
};
213+
const resp = new Response(JSON.stringify(body), { status: 404 });
214+
const { response: result, rateLimit } = await handleErrorResponse(resp);
215+
expect(result.status).toBe(403);
216+
expect(rateLimit).toBeUndefined();
217+
const json = await result.json() as any;
218+
expect(json.error.type).toBe('entitlement_error');
219+
expect(json.error.message).toContain('not included in your ChatGPT subscription');
220+
});
203221
});
222+
223+
describe('isEntitlementError', () => {
224+
it('returns true for usage_not_included code', () => {
225+
expect(isEntitlementError('usage_not_included', '')).toBe(true);
226+
});
227+
228+
it('returns true when body contains "not included in your plan"', () => {
229+
expect(isEntitlementError('', 'Usage not included in your plan')).toBe(true);
230+
});
231+
232+
it('returns false for usage_limit_reached (rate limit)', () => {
233+
expect(isEntitlementError('usage_limit_reached', '')).toBe(false);
234+
});
235+
236+
it('returns false for rate_limit_exceeded', () => {
237+
expect(isEntitlementError('rate_limit_exceeded', '')).toBe(false);
238+
});
239+
240+
it('returns false for generic errors', () => {
241+
expect(isEntitlementError('not_found', 'Resource not found')).toBe(false);
242+
});
243+
});
244+
245+
describe('createEntitlementErrorResponse', () => {
246+
it('returns 403 status with user-friendly message', async () => {
247+
const resp = createEntitlementErrorResponse('original body');
248+
expect(resp.status).toBe(403);
249+
expect(resp.statusText).toBe('Forbidden');
250+
const json = await resp.json() as any;
251+
expect(json.error.type).toBe('entitlement_error');
252+
expect(json.error.code).toBe('usage_not_included');
253+
expect(json.error.message).toContain('ChatGPT subscription');
254+
});
255+
});
204256
});

0 commit comments

Comments
 (0)