opencode-anthropic-fix 0.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +31 -0
- package/.husky/pre-commit +2 -0
- package/.husky/pre-push +3 -0
- package/.planning/CLI-ENHANCEMENTS.md +221 -0
- package/.planning/codebase/ARCHITECTURE.md +201 -0
- package/.planning/codebase/CONCERNS.md +219 -0
- package/.planning/codebase/CONVENTIONS.md +335 -0
- package/.planning/codebase/INTEGRATIONS.md +215 -0
- package/.planning/codebase/STACK.md +160 -0
- package/.planning/codebase/STRUCTURE.md +244 -0
- package/.planning/codebase/TESTING.md +456 -0
- package/.prettierignore +3 -0
- package/.prettierrc +10 -0
- package/CONTRIBUTING.md +525 -0
- package/LICENSE +21 -0
- package/README.md +478 -0
- package/agents.md +22 -0
- package/bun.lock +43 -0
- package/cli.mjs +1773 -0
- package/cli.test.mjs +1797 -0
- package/docs/mimese-http-header-system-prompt.md +326 -0
- package/docs/plans/afcw-upstream-watcher.json +495 -0
- package/eslint.config.mjs +69 -0
- package/index.mjs +2209 -0
- package/index.test.mjs +2690 -0
- package/lib/accounts.mjs +670 -0
- package/lib/accounts.test.mjs +782 -0
- package/lib/backoff.mjs +217 -0
- package/lib/backoff.test.mjs +296 -0
- package/lib/config.mjs +366 -0
- package/lib/config.test.mjs +307 -0
- package/lib/oauth.mjs +103 -0
- package/lib/rotation.mjs +287 -0
- package/lib/rotation.test.mjs +346 -0
- package/lib/storage.mjs +265 -0
- package/lib/storage.test.mjs +470 -0
- package/nul +1 -0
- package/package.json +40 -0
- package/script/publish.ts +29 -0
- package/scripts/build.mjs +34 -0
- package/scripts/install.mjs +319 -0
package/lib/backoff.mjs
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {'AUTH_FAILED' | 'QUOTA_EXHAUSTED' | 'RATE_LIMIT_EXCEEDED'} RateLimitReason
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const QUOTA_EXHAUSTED_BACKOFFS = [60_000, 300_000, 1_800_000, 7_200_000];
|
|
6
|
+
const AUTH_FAILED_BACKOFF = 5_000;
|
|
7
|
+
const RATE_LIMIT_EXCEEDED_BACKOFF = 30_000;
|
|
8
|
+
const MIN_BACKOFF_MS = 2_000;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse the Retry-After header from a response.
|
|
12
|
+
* Supports both seconds (integer) and HTTP-date formats.
|
|
13
|
+
* @param {Response} response
|
|
14
|
+
* @returns {number | null} Retry-after duration in milliseconds, or null
|
|
15
|
+
*/
|
|
16
|
+
export function parseRetryAfterHeader(response) {
|
|
17
|
+
const header = response.headers.get("retry-after");
|
|
18
|
+
if (!header) return null;
|
|
19
|
+
|
|
20
|
+
// Try as integer (seconds)
|
|
21
|
+
const seconds = parseInt(header, 10);
|
|
22
|
+
if (!isNaN(seconds) && seconds > 0) {
|
|
23
|
+
return seconds * 1000;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Try as HTTP-date
|
|
27
|
+
const date = new Date(header);
|
|
28
|
+
if (!isNaN(date.getTime())) {
|
|
29
|
+
const ms = date.getTime() - Date.now();
|
|
30
|
+
return ms > 0 ? ms : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parse normalized error signals from a response body.
|
|
38
|
+
* Accepts raw strings, parsed objects, or null.
|
|
39
|
+
* @param {string | object | null | undefined} body
|
|
40
|
+
* @returns {{ errorType: string, message: string, text: string }}
|
|
41
|
+
*/
|
|
42
|
+
function extractErrorSignals(body) {
|
|
43
|
+
let errorType = "";
|
|
44
|
+
let message = "";
|
|
45
|
+
let text = "";
|
|
46
|
+
|
|
47
|
+
if (body == null) {
|
|
48
|
+
return { errorType, message, text };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (typeof body === "string") {
|
|
52
|
+
text = body.toLowerCase();
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(body);
|
|
55
|
+
errorType = String(parsed?.error?.type || "").toLowerCase();
|
|
56
|
+
message = String(parsed?.error?.message || "").toLowerCase();
|
|
57
|
+
} catch {
|
|
58
|
+
// Not JSON — use raw text only.
|
|
59
|
+
}
|
|
60
|
+
return { errorType, message, text };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (typeof body === "object") {
|
|
64
|
+
errorType = String(body?.error?.type || "").toLowerCase();
|
|
65
|
+
message = String(body?.error?.message || "").toLowerCase();
|
|
66
|
+
try {
|
|
67
|
+
text = JSON.stringify(body).toLowerCase();
|
|
68
|
+
} catch {
|
|
69
|
+
text = "";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { errorType, message, text };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Determine whether a response body contains account-specific error language
|
|
78
|
+
* (rate limit, quota, billing, auth, permission) as opposed to service-wide errors.
|
|
79
|
+
* @param {string | object | null | undefined} body
|
|
80
|
+
* @returns {boolean}
|
|
81
|
+
*/
|
|
82
|
+
function bodyHasAccountError(body) {
|
|
83
|
+
const { errorType, message, text } = extractErrorSignals(body);
|
|
84
|
+
|
|
85
|
+
const typeSignals = [
|
|
86
|
+
"rate_limit",
|
|
87
|
+
"quota",
|
|
88
|
+
"billing",
|
|
89
|
+
"permission",
|
|
90
|
+
"authentication",
|
|
91
|
+
"invalid_api_key",
|
|
92
|
+
"insufficient_permissions",
|
|
93
|
+
"invalid_grant",
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
const messageSignals = [
|
|
97
|
+
"rate limit",
|
|
98
|
+
"would exceed",
|
|
99
|
+
"quota",
|
|
100
|
+
"exhausted",
|
|
101
|
+
"credit balance",
|
|
102
|
+
"billing",
|
|
103
|
+
"permission",
|
|
104
|
+
"forbidden",
|
|
105
|
+
"unauthorized",
|
|
106
|
+
"authentication",
|
|
107
|
+
"not authorized",
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
typeSignals.some((signal) => errorType.includes(signal)) ||
|
|
112
|
+
messageSignals.some((signal) => message.includes(signal)) ||
|
|
113
|
+
messageSignals.some((signal) => text.includes(signal))
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check whether an HTTP response represents an account-specific error
|
|
119
|
+
* that would benefit from switching to a different account.
|
|
120
|
+
*
|
|
121
|
+
* Account-specific: rate limits, billing, auth, permissions.
|
|
122
|
+
* Service-wide (NOT account-specific): 529, 503, 500 — switching won't help.
|
|
123
|
+
*
|
|
124
|
+
* @param {number} status
|
|
125
|
+
* @param {string | object | null} [body]
|
|
126
|
+
* @returns {boolean}
|
|
127
|
+
*/
|
|
128
|
+
export function isAccountSpecificError(status, body) {
|
|
129
|
+
// 429 is always account-specific (per-account rate limits)
|
|
130
|
+
if (status === 429) return true;
|
|
131
|
+
|
|
132
|
+
// 401 is always account-specific (per-account auth)
|
|
133
|
+
if (status === 401) return true;
|
|
134
|
+
|
|
135
|
+
// 400/403 are account-specific only if the body contains relevant language
|
|
136
|
+
if ((status === 400 || status === 403) && body) {
|
|
137
|
+
return bodyHasAccountError(body);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Everything else (529, 503, 500, etc.) is service-wide
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Parse the rate limit reason from an HTTP status and response body.
|
|
146
|
+
* Only called for account-specific errors.
|
|
147
|
+
* @param {number} status
|
|
148
|
+
* @param {string | object | null} [body]
|
|
149
|
+
* @returns {RateLimitReason}
|
|
150
|
+
*/
|
|
151
|
+
export function parseRateLimitReason(status, body) {
|
|
152
|
+
const { errorType, message, text } = extractErrorSignals(body);
|
|
153
|
+
|
|
154
|
+
const authSignals = [
|
|
155
|
+
"authentication",
|
|
156
|
+
"invalid_api_key",
|
|
157
|
+
"invalid api key",
|
|
158
|
+
"invalid_grant",
|
|
159
|
+
"unauthorized",
|
|
160
|
+
"invalid access token",
|
|
161
|
+
"expired token",
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
const isAuthFailure =
|
|
165
|
+
status === 401 ||
|
|
166
|
+
authSignals.some((signal) => errorType.includes(signal)) ||
|
|
167
|
+
authSignals.some((signal) => message.includes(signal)) ||
|
|
168
|
+
authSignals.some((signal) => text.includes(signal));
|
|
169
|
+
|
|
170
|
+
if (isAuthFailure) {
|
|
171
|
+
return "AUTH_FAILED";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (
|
|
175
|
+
errorType.includes("quota") ||
|
|
176
|
+
errorType.includes("billing") ||
|
|
177
|
+
errorType.includes("permission") ||
|
|
178
|
+
errorType.includes("insufficient_permissions") ||
|
|
179
|
+
message.includes("quota") ||
|
|
180
|
+
message.includes("exhausted") ||
|
|
181
|
+
message.includes("credit balance") ||
|
|
182
|
+
message.includes("billing") ||
|
|
183
|
+
message.includes("permission") ||
|
|
184
|
+
message.includes("forbidden") ||
|
|
185
|
+
text.includes("permission")
|
|
186
|
+
) {
|
|
187
|
+
return "QUOTA_EXHAUSTED";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return "RATE_LIMIT_EXCEEDED";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Calculate backoff duration in milliseconds.
|
|
195
|
+
* @param {RateLimitReason} reason
|
|
196
|
+
* @param {number} consecutiveFailures - Zero-based count of consecutive failures
|
|
197
|
+
* @param {number | null} [retryAfterMs] - Value from Retry-After header
|
|
198
|
+
* @returns {number}
|
|
199
|
+
*/
|
|
200
|
+
export function calculateBackoffMs(reason, consecutiveFailures, retryAfterMs) {
|
|
201
|
+
// Retry-After header takes precedence
|
|
202
|
+
if (retryAfterMs && retryAfterMs > 0) {
|
|
203
|
+
return Math.max(retryAfterMs, MIN_BACKOFF_MS);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
switch (reason) {
|
|
207
|
+
case "AUTH_FAILED":
|
|
208
|
+
return AUTH_FAILED_BACKOFF;
|
|
209
|
+
case "QUOTA_EXHAUSTED": {
|
|
210
|
+
const index = Math.min(consecutiveFailures, QUOTA_EXHAUSTED_BACKOFFS.length - 1);
|
|
211
|
+
return QUOTA_EXHAUSTED_BACKOFFS[index];
|
|
212
|
+
}
|
|
213
|
+
case "RATE_LIMIT_EXCEEDED":
|
|
214
|
+
default:
|
|
215
|
+
return RATE_LIMIT_EXCEEDED_BACKOFF;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseRateLimitReason, calculateBackoffMs, parseRetryAfterHeader, isAccountSpecificError } from "./backoff.mjs";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// isAccountSpecificError
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
describe("isAccountSpecificError", () => {
|
|
9
|
+
it("returns true for 429 (always account-specific)", () => {
|
|
10
|
+
expect(isAccountSpecificError(429, null)).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns true for 429 even without body", () => {
|
|
14
|
+
expect(isAccountSpecificError(429)).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns true for 401 (always account-specific)", () => {
|
|
18
|
+
expect(isAccountSpecificError(401, null)).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("returns true for 400 with rate limit language in body", () => {
|
|
22
|
+
expect(isAccountSpecificError(400, "This request would exceed your account's rate limit")).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns true for 400 with quota language in body", () => {
|
|
26
|
+
expect(isAccountSpecificError(400, "Your quota has been exhausted")).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns true for 400 with credit balance language in body", () => {
|
|
30
|
+
expect(isAccountSpecificError(400, "Your credit balance is too low to complete this request")).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns true for 400 with billing language in body", () => {
|
|
34
|
+
expect(isAccountSpecificError(400, "Billing issue on your account")).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns true for 403 with permission language in body", () => {
|
|
38
|
+
expect(isAccountSpecificError(403, "You do not have permission to access this model")).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns true for 403 permission_error type in JSON body", () => {
|
|
42
|
+
expect(
|
|
43
|
+
isAccountSpecificError(403, JSON.stringify({ error: { type: "permission_error", message: "Forbidden" } })),
|
|
44
|
+
).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns true for 403 authentication_error type in JSON body", () => {
|
|
48
|
+
expect(
|
|
49
|
+
isAccountSpecificError(403, JSON.stringify({ error: { type: "authentication_error", message: "Unauthorized" } })),
|
|
50
|
+
).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns true for object body with account-specific error type", () => {
|
|
54
|
+
expect(
|
|
55
|
+
isAccountSpecificError(400, {
|
|
56
|
+
error: { type: "rate_limit_error", message: "too many requests" },
|
|
57
|
+
}),
|
|
58
|
+
).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("returns false for 400 without account-specific language", () => {
|
|
62
|
+
expect(isAccountSpecificError(400, "Invalid request body")).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns false for 400 with no body", () => {
|
|
66
|
+
expect(isAccountSpecificError(400, null)).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns false for 403 with no body", () => {
|
|
70
|
+
expect(isAccountSpecificError(403, null)).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns false for 500 (service-wide)", () => {
|
|
74
|
+
expect(isAccountSpecificError(500, null)).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("returns false for 503 (service-wide)", () => {
|
|
78
|
+
expect(isAccountSpecificError(503, null)).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("returns false for 529 (service-wide)", () => {
|
|
82
|
+
expect(isAccountSpecificError(529, null)).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns false for 200", () => {
|
|
86
|
+
expect(isAccountSpecificError(200, null)).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("is case-insensitive for body matching", () => {
|
|
90
|
+
expect(isAccountSpecificError(400, "RATE LIMIT exceeded")).toBe(true);
|
|
91
|
+
expect(isAccountSpecificError(400, "QUOTA Exhausted")).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// parseRateLimitReason
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
describe("parseRateLimitReason", () => {
|
|
100
|
+
it("returns QUOTA_EXHAUSTED for 429 with quota in error type", () => {
|
|
101
|
+
const body = JSON.stringify({
|
|
102
|
+
error: { type: "quota_exceeded", message: "You have exceeded your quota" },
|
|
103
|
+
});
|
|
104
|
+
expect(parseRateLimitReason(429, body)).toBe("QUOTA_EXHAUSTED");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("returns QUOTA_EXHAUSTED for 429 with exhausted in message", () => {
|
|
108
|
+
const body = JSON.stringify({
|
|
109
|
+
error: { type: "rate_error", message: "Token quota exhausted" },
|
|
110
|
+
});
|
|
111
|
+
expect(parseRateLimitReason(429, body)).toBe("QUOTA_EXHAUSTED");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns QUOTA_EXHAUSTED for credit balance message", () => {
|
|
115
|
+
const body = JSON.stringify({
|
|
116
|
+
error: { message: "Your credit balance is too low" },
|
|
117
|
+
});
|
|
118
|
+
expect(parseRateLimitReason(429, body)).toBe("QUOTA_EXHAUSTED");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns QUOTA_EXHAUSTED for billing message", () => {
|
|
122
|
+
const body = JSON.stringify({
|
|
123
|
+
error: { message: "Billing issue on account" },
|
|
124
|
+
});
|
|
125
|
+
expect(parseRateLimitReason(429, body)).toBe("QUOTA_EXHAUSTED");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("returns AUTH_FAILED for 401 status", () => {
|
|
129
|
+
expect(parseRateLimitReason(401, null)).toBe("AUTH_FAILED");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("returns QUOTA_EXHAUSTED for permission_error type", () => {
|
|
133
|
+
const body = JSON.stringify({
|
|
134
|
+
error: { type: "permission_error", message: "Forbidden" },
|
|
135
|
+
});
|
|
136
|
+
expect(parseRateLimitReason(403, body)).toBe("QUOTA_EXHAUSTED");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("returns AUTH_FAILED for authentication_error type", () => {
|
|
140
|
+
const body = JSON.stringify({
|
|
141
|
+
error: { type: "authentication_error", message: "Unauthorized" },
|
|
142
|
+
});
|
|
143
|
+
expect(parseRateLimitReason(403, body)).toBe("AUTH_FAILED");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("returns AUTH_FAILED for invalid_api_key message", () => {
|
|
147
|
+
const body = JSON.stringify({
|
|
148
|
+
error: { type: "invalid_request_error", message: "Invalid API key" },
|
|
149
|
+
});
|
|
150
|
+
expect(parseRateLimitReason(400, body)).toBe("AUTH_FAILED");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("returns RATE_LIMIT_EXCEEDED for 429 with rate limit message", () => {
|
|
154
|
+
const body = JSON.stringify({
|
|
155
|
+
error: { type: "rate_limit_error", message: "Rate limit exceeded" },
|
|
156
|
+
});
|
|
157
|
+
expect(parseRateLimitReason(429, body)).toBe("RATE_LIMIT_EXCEEDED");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("returns RATE_LIMIT_EXCEEDED for 429 with too many requests", () => {
|
|
161
|
+
const body = JSON.stringify({
|
|
162
|
+
error: { message: "Too many requests" },
|
|
163
|
+
});
|
|
164
|
+
expect(parseRateLimitReason(429, body)).toBe("RATE_LIMIT_EXCEEDED");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("returns RATE_LIMIT_EXCEEDED for 429 with per minute message", () => {
|
|
168
|
+
const body = JSON.stringify({
|
|
169
|
+
error: { message: "Exceeded 60 requests per minute" },
|
|
170
|
+
});
|
|
171
|
+
expect(parseRateLimitReason(429, body)).toBe("RATE_LIMIT_EXCEEDED");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("defaults to RATE_LIMIT_EXCEEDED for 429 with no body", () => {
|
|
175
|
+
expect(parseRateLimitReason(429, null)).toBe("RATE_LIMIT_EXCEEDED");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("defaults to RATE_LIMIT_EXCEEDED for 429 with unparseable body", () => {
|
|
179
|
+
expect(parseRateLimitReason(429, "not json")).toBe("RATE_LIMIT_EXCEEDED");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("defaults to RATE_LIMIT_EXCEEDED for unrecognized status", () => {
|
|
183
|
+
expect(parseRateLimitReason(418, null)).toBe("RATE_LIMIT_EXCEEDED");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("handles body as object (not string)", () => {
|
|
187
|
+
const body = { error: { type: "quota_exceeded", message: "quota" } };
|
|
188
|
+
expect(parseRateLimitReason(429, body)).toBe("QUOTA_EXHAUSTED");
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// calculateBackoffMs
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
describe("calculateBackoffMs", () => {
|
|
197
|
+
it("uses Retry-After header when provided", () => {
|
|
198
|
+
expect(calculateBackoffMs("RATE_LIMIT_EXCEEDED", 0, 5000)).toBe(5000);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("enforces minimum backoff for Retry-After", () => {
|
|
202
|
+
expect(calculateBackoffMs("RATE_LIMIT_EXCEEDED", 0, 500)).toBe(2000);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("ignores null Retry-After", () => {
|
|
206
|
+
const result = calculateBackoffMs("RATE_LIMIT_EXCEEDED", 0, null);
|
|
207
|
+
expect(result).toBe(30_000);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("ignores zero Retry-After", () => {
|
|
211
|
+
const result = calculateBackoffMs("RATE_LIMIT_EXCEEDED", 0, 0);
|
|
212
|
+
expect(result).toBe(30_000);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("escalates QUOTA_EXHAUSTED backoffs", () => {
|
|
216
|
+
expect(calculateBackoffMs("QUOTA_EXHAUSTED", 0)).toBe(60_000);
|
|
217
|
+
expect(calculateBackoffMs("QUOTA_EXHAUSTED", 1)).toBe(300_000);
|
|
218
|
+
expect(calculateBackoffMs("QUOTA_EXHAUSTED", 2)).toBe(1_800_000);
|
|
219
|
+
expect(calculateBackoffMs("QUOTA_EXHAUSTED", 3)).toBe(7_200_000);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("caps QUOTA_EXHAUSTED at max tier", () => {
|
|
223
|
+
expect(calculateBackoffMs("QUOTA_EXHAUSTED", 100)).toBe(7_200_000);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("returns fixed backoff for RATE_LIMIT_EXCEEDED", () => {
|
|
227
|
+
expect(calculateBackoffMs("RATE_LIMIT_EXCEEDED", 0)).toBe(30_000);
|
|
228
|
+
expect(calculateBackoffMs("RATE_LIMIT_EXCEEDED", 5)).toBe(30_000);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("returns short fixed backoff for AUTH_FAILED", () => {
|
|
232
|
+
expect(calculateBackoffMs("AUTH_FAILED", 0)).toBe(5_000);
|
|
233
|
+
expect(calculateBackoffMs("AUTH_FAILED", 5)).toBe(5_000);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("uses default (RATE_LIMIT_EXCEEDED) for unknown reason", () => {
|
|
237
|
+
expect(calculateBackoffMs("SOMETHING_ELSE", 0)).toBe(30_000);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// parseRetryAfterHeader
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
describe("parseRetryAfterHeader", () => {
|
|
246
|
+
it("returns null when no header present", () => {
|
|
247
|
+
const response = new Response(null, { headers: {} });
|
|
248
|
+
expect(parseRetryAfterHeader(response)).toBeNull();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("parses integer seconds", () => {
|
|
252
|
+
const response = new Response(null, {
|
|
253
|
+
headers: { "retry-after": "30" },
|
|
254
|
+
});
|
|
255
|
+
expect(parseRetryAfterHeader(response)).toBe(30_000);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("parses HTTP-date format", () => {
|
|
259
|
+
const futureDate = new Date(Date.now() + 60_000);
|
|
260
|
+
const response = new Response(null, {
|
|
261
|
+
headers: { "retry-after": futureDate.toUTCString() },
|
|
262
|
+
});
|
|
263
|
+
const result = parseRetryAfterHeader(response);
|
|
264
|
+
expect(result).toBeGreaterThan(50_000);
|
|
265
|
+
expect(result).toBeLessThanOrEqual(61_000);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("returns null for past HTTP-date", () => {
|
|
269
|
+
const pastDate = new Date(Date.now() - 60_000);
|
|
270
|
+
const response = new Response(null, {
|
|
271
|
+
headers: { "retry-after": pastDate.toUTCString() },
|
|
272
|
+
});
|
|
273
|
+
expect(parseRetryAfterHeader(response)).toBeNull();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("returns null for invalid header value", () => {
|
|
277
|
+
const response = new Response(null, {
|
|
278
|
+
headers: { "retry-after": "not-a-number-or-date" },
|
|
279
|
+
});
|
|
280
|
+
expect(parseRetryAfterHeader(response)).toBeNull();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("returns null for zero seconds", () => {
|
|
284
|
+
const response = new Response(null, {
|
|
285
|
+
headers: { "retry-after": "0" },
|
|
286
|
+
});
|
|
287
|
+
expect(parseRetryAfterHeader(response)).toBeNull();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("returns null for negative seconds", () => {
|
|
291
|
+
const response = new Response(null, {
|
|
292
|
+
headers: { "retry-after": "-5" },
|
|
293
|
+
});
|
|
294
|
+
expect(parseRetryAfterHeader(response)).toBeNull();
|
|
295
|
+
});
|
|
296
|
+
});
|