@vacbo/opencode-anthropic-fix 0.1.7 → 0.1.9
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/README.md +88 -88
- package/dist/opencode-anthropic-auth-cli.mjs +804 -507
- package/dist/opencode-anthropic-auth-plugin.js +4751 -4109
- package/package.json +67 -59
- package/src/__tests__/billing-edge-cases.test.ts +59 -59
- package/src/__tests__/bun-proxy.parallel.test.ts +388 -382
- package/src/__tests__/cc-comparison.test.ts +87 -87
- package/src/__tests__/cc-credentials.test.ts +254 -250
- package/src/__tests__/cch-drift-checker.test.ts +51 -51
- package/src/__tests__/cch-native-style.test.ts +56 -56
- package/src/__tests__/debug-gating.test.ts +42 -42
- package/src/__tests__/decomposition-smoke.test.ts +68 -68
- package/src/__tests__/fingerprint-regression.test.ts +575 -566
- package/src/__tests__/helpers/conversation-history.smoke.test.ts +271 -271
- package/src/__tests__/helpers/conversation-history.ts +119 -119
- package/src/__tests__/helpers/deferred.smoke.test.ts +103 -103
- package/src/__tests__/helpers/deferred.ts +69 -69
- package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +155 -155
- package/src/__tests__/helpers/in-memory-storage.ts +88 -88
- package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +68 -68
- package/src/__tests__/helpers/mock-bun-proxy.ts +189 -189
- package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +273 -273
- package/src/__tests__/helpers/plugin-fetch-harness.ts +288 -288
- package/src/__tests__/helpers/sse.smoke.test.ts +236 -236
- package/src/__tests__/helpers/sse.ts +209 -209
- package/src/__tests__/index.parallel.test.ts +605 -595
- package/src/__tests__/sanitization-regex.test.ts +112 -112
- package/src/__tests__/state-bounds.test.ts +90 -90
- package/src/account-identity.test.ts +197 -192
- package/src/account-identity.ts +69 -67
- package/src/account-state.test.ts +86 -86
- package/src/account-state.ts +25 -25
- package/src/accounts/matching.test.ts +335 -0
- package/src/accounts/matching.ts +167 -0
- package/src/accounts/persistence.test.ts +345 -0
- package/src/accounts/persistence.ts +432 -0
- package/src/accounts/repair.test.ts +276 -0
- package/src/accounts/repair.ts +407 -0
- package/src/accounts.dedup.test.ts +621 -621
- package/src/accounts.test.ts +933 -929
- package/src/accounts.ts +633 -989
- package/src/backoff.test.ts +345 -345
- package/src/backoff.ts +219 -219
- package/src/betas.ts +124 -124
- package/src/bun-fetch.test.ts +345 -342
- package/src/bun-fetch.ts +424 -424
- package/src/bun-proxy.test.ts +25 -25
- package/src/bun-proxy.ts +209 -209
- package/src/cc-credentials.ts +111 -111
- package/src/circuit-breaker.test.ts +184 -184
- package/src/circuit-breaker.ts +169 -169
- package/src/cli/commands/auth.ts +963 -0
- package/src/cli/commands/config.ts +547 -0
- package/src/cli/formatting.test.ts +406 -0
- package/src/cli/formatting.ts +219 -0
- package/src/cli.ts +255 -2022
- package/src/commands/handlers/betas.ts +100 -0
- package/src/commands/handlers/config.ts +99 -0
- package/src/commands/handlers/files.ts +375 -0
- package/src/commands/oauth-flow.ts +181 -166
- package/src/commands/prompts.ts +61 -61
- package/src/commands/router.test.ts +421 -0
- package/src/commands/router.ts +143 -635
- package/src/config.test.ts +482 -482
- package/src/config.ts +412 -404
- package/src/constants.ts +48 -48
- package/src/drift/cch-constants.ts +95 -95
- package/src/env.ts +111 -105
- package/src/headers/billing.ts +33 -33
- package/src/headers/builder.ts +130 -130
- package/src/headers/cch.ts +75 -75
- package/src/headers/stainless.ts +25 -25
- package/src/headers/user-agent.ts +23 -23
- package/src/index.ts +436 -828
- package/src/models.ts +27 -27
- package/src/oauth.test.ts +102 -102
- package/src/oauth.ts +178 -178
- package/src/parent-pid-watcher.test.ts +148 -148
- package/src/parent-pid-watcher.ts +69 -69
- package/src/plugin-helpers.ts +82 -82
- package/src/refresh-helpers.ts +145 -139
- package/src/refresh-lock.test.ts +94 -94
- package/src/refresh-lock.ts +93 -93
- package/src/request/body.history.test.ts +579 -571
- package/src/request/body.ts +255 -255
- package/src/request/metadata.ts +65 -65
- package/src/request/retry.test.ts +156 -156
- package/src/request/retry.ts +67 -67
- package/src/request/url.ts +21 -21
- package/src/request-orchestration-helpers.ts +648 -0
- package/src/response/index.ts +5 -5
- package/src/response/mcp.ts +58 -58
- package/src/response/streaming.test.ts +313 -311
- package/src/response/streaming.ts +412 -410
- package/src/rotation.test.ts +304 -301
- package/src/rotation.ts +205 -205
- package/src/storage.test.ts +547 -547
- package/src/storage.ts +315 -291
- package/src/system-prompt/builder.ts +38 -38
- package/src/system-prompt/index.ts +5 -5
- package/src/system-prompt/normalize.ts +60 -60
- package/src/system-prompt/sanitize.ts +30 -30
- package/src/thinking.ts +21 -20
- package/src/token-refresh.test.ts +265 -265
- package/src/token-refresh.ts +219 -214
- package/src/types.ts +30 -30
- package/dist/bun-proxy.mjs +0 -291
package/src/backoff.test.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
3
|
+
calculateBackoffMs,
|
|
4
|
+
isAccountSpecificError,
|
|
5
|
+
isRetriableNetworkError,
|
|
6
|
+
parseRateLimitReason,
|
|
7
|
+
parseRetryAfterHeader,
|
|
8
|
+
parseRetryAfterMsHeader,
|
|
9
|
+
parseShouldRetryHeader,
|
|
10
10
|
} from "./backoff.js";
|
|
11
11
|
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
@@ -14,119 +14,119 @@ import {
|
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
|
|
16
16
|
describe("isAccountSpecificError", () => {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
17
|
+
it("returns true for 429 (always account-specific)", () => {
|
|
18
|
+
expect(isAccountSpecificError(429, null)).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("returns true for 429 even without body", () => {
|
|
22
|
+
expect(isAccountSpecificError(429)).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns true for 401 (always account-specific)", () => {
|
|
26
|
+
expect(isAccountSpecificError(401, null)).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns true for 400 with rate limit language in body", () => {
|
|
30
|
+
expect(isAccountSpecificError(400, "This request would exceed your account's rate limit")).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns true for 400 with quota language in body", () => {
|
|
34
|
+
expect(isAccountSpecificError(400, "Your quota has been exhausted")).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns true for 400 with credit balance language in body", () => {
|
|
38
|
+
expect(isAccountSpecificError(400, "Your credit balance is too low to complete this request")).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns true for 400 with billing language in body", () => {
|
|
42
|
+
expect(isAccountSpecificError(400, "Billing issue on your account")).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns true for 403 with permission language in body", () => {
|
|
46
|
+
expect(isAccountSpecificError(403, "You do not have permission to access this model")).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns true for 403 permission_error type in JSON body", () => {
|
|
50
|
+
expect(
|
|
51
|
+
isAccountSpecificError(
|
|
52
|
+
403,
|
|
53
|
+
JSON.stringify({
|
|
54
|
+
error: { type: "permission_error", message: "Forbidden" },
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns true for 403 authentication_error type in JSON body", () => {
|
|
61
|
+
expect(
|
|
62
|
+
isAccountSpecificError(
|
|
63
|
+
403,
|
|
64
|
+
JSON.stringify({
|
|
65
|
+
error: { type: "authentication_error", message: "Unauthorized" },
|
|
66
|
+
}),
|
|
67
|
+
),
|
|
68
|
+
).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns true for object body with account-specific error type", () => {
|
|
72
|
+
expect(
|
|
73
|
+
isAccountSpecificError(400, {
|
|
74
|
+
error: { type: "rate_limit_error", message: "too many requests" },
|
|
75
|
+
}),
|
|
76
|
+
).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("returns false for 400 without account-specific language", () => {
|
|
80
|
+
expect(isAccountSpecificError(400, "Invalid request body")).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("returns false for 400 with no body", () => {
|
|
84
|
+
expect(isAccountSpecificError(400, null)).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("returns false for 403 with no body", () => {
|
|
88
|
+
expect(isAccountSpecificError(403, null)).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("returns false for 500 (service-wide)", () => {
|
|
92
|
+
expect(isAccountSpecificError(500, null)).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns false for 503 (service-wide)", () => {
|
|
96
|
+
expect(isAccountSpecificError(503, null)).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("returns false for 529 (service-wide)", () => {
|
|
100
|
+
expect(isAccountSpecificError(529, null)).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("returns false for 200", () => {
|
|
104
|
+
expect(isAccountSpecificError(200, null)).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("is case-insensitive for body matching", () => {
|
|
108
|
+
expect(isAccountSpecificError(400, "RATE LIMIT exceeded")).toBe(true);
|
|
109
|
+
expect(isAccountSpecificError(400, "QUOTA Exhausted")).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("returns true for 403 with 'membership benefits' message (JSON body)", () => {
|
|
113
|
+
const body = JSON.stringify({
|
|
114
|
+
error: { message: "We're unable to verify your membership benefits" },
|
|
115
|
+
});
|
|
116
|
+
expect(isAccountSpecificError(403, body)).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("returns true for 403 with 'membership benefits' message (string body)", () => {
|
|
120
|
+
expect(isAccountSpecificError(403, "We're unable to verify your membership benefits")).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("returns true for 403 with 'unable to verify' message", () => {
|
|
124
|
+
expect(isAccountSpecificError(403, "unable to verify your account")).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("returns false for 403 with unrelated error message (negative test)", () => {
|
|
128
|
+
expect(isAccountSpecificError(403, "Some other error occurred")).toBe(false);
|
|
129
|
+
});
|
|
130
130
|
});
|
|
131
131
|
|
|
132
132
|
// ---------------------------------------------------------------------------
|
|
@@ -134,106 +134,106 @@ describe("isAccountSpecificError", () => {
|
|
|
134
134
|
// ---------------------------------------------------------------------------
|
|
135
135
|
|
|
136
136
|
describe("parseRateLimitReason", () => {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
137
|
+
it("returns QUOTA_EXHAUSTED for 429 with quota in error type", () => {
|
|
138
|
+
const body = JSON.stringify({
|
|
139
|
+
error: {
|
|
140
|
+
type: "quota_exceeded",
|
|
141
|
+
message: "You have exceeded your quota",
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
expect(parseRateLimitReason(429, body)).toBe("QUOTA_EXHAUSTED");
|
|
143
145
|
});
|
|
144
|
-
expect(parseRateLimitReason(429, body)).toBe("QUOTA_EXHAUSTED");
|
|
145
|
-
});
|
|
146
146
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
147
|
+
it("returns QUOTA_EXHAUSTED for 429 with exhausted in message", () => {
|
|
148
|
+
const body = JSON.stringify({
|
|
149
|
+
error: { type: "rate_error", message: "Token quota exhausted" },
|
|
150
|
+
});
|
|
151
|
+
expect(parseRateLimitReason(429, body)).toBe("QUOTA_EXHAUSTED");
|
|
150
152
|
});
|
|
151
|
-
expect(parseRateLimitReason(429, body)).toBe("QUOTA_EXHAUSTED");
|
|
152
|
-
});
|
|
153
153
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
154
|
+
it("returns QUOTA_EXHAUSTED for credit balance message", () => {
|
|
155
|
+
const body = JSON.stringify({
|
|
156
|
+
error: { message: "Your credit balance is too low" },
|
|
157
|
+
});
|
|
158
|
+
expect(parseRateLimitReason(429, body)).toBe("QUOTA_EXHAUSTED");
|
|
157
159
|
});
|
|
158
|
-
expect(parseRateLimitReason(429, body)).toBe("QUOTA_EXHAUSTED");
|
|
159
|
-
});
|
|
160
160
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
161
|
+
it("returns QUOTA_EXHAUSTED for billing message", () => {
|
|
162
|
+
const body = JSON.stringify({
|
|
163
|
+
error: { message: "Billing issue on account" },
|
|
164
|
+
});
|
|
165
|
+
expect(parseRateLimitReason(429, body)).toBe("QUOTA_EXHAUSTED");
|
|
164
166
|
});
|
|
165
|
-
expect(parseRateLimitReason(429, body)).toBe("QUOTA_EXHAUSTED");
|
|
166
|
-
});
|
|
167
167
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
168
|
+
it("returns AUTH_FAILED for 401 status", () => {
|
|
169
|
+
expect(parseRateLimitReason(401, null)).toBe("AUTH_FAILED");
|
|
170
|
+
});
|
|
171
171
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
172
|
+
it("returns QUOTA_EXHAUSTED for permission_error type", () => {
|
|
173
|
+
const body = JSON.stringify({
|
|
174
|
+
error: { type: "permission_error", message: "Forbidden" },
|
|
175
|
+
});
|
|
176
|
+
expect(parseRateLimitReason(403, body)).toBe("QUOTA_EXHAUSTED");
|
|
175
177
|
});
|
|
176
|
-
expect(parseRateLimitReason(403, body)).toBe("QUOTA_EXHAUSTED");
|
|
177
|
-
});
|
|
178
178
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
179
|
+
it("returns AUTH_FAILED for authentication_error type", () => {
|
|
180
|
+
const body = JSON.stringify({
|
|
181
|
+
error: { type: "authentication_error", message: "Unauthorized" },
|
|
182
|
+
});
|
|
183
|
+
expect(parseRateLimitReason(403, body)).toBe("AUTH_FAILED");
|
|
182
184
|
});
|
|
183
|
-
expect(parseRateLimitReason(403, body)).toBe("AUTH_FAILED");
|
|
184
|
-
});
|
|
185
185
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
186
|
+
it("returns AUTH_FAILED for invalid_api_key message", () => {
|
|
187
|
+
const body = JSON.stringify({
|
|
188
|
+
error: { type: "invalid_request_error", message: "Invalid API key" },
|
|
189
|
+
});
|
|
190
|
+
expect(parseRateLimitReason(400, body)).toBe("AUTH_FAILED");
|
|
189
191
|
});
|
|
190
|
-
expect(parseRateLimitReason(400, body)).toBe("AUTH_FAILED");
|
|
191
|
-
});
|
|
192
192
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
193
|
+
it("returns RATE_LIMIT_EXCEEDED for 429 with rate limit message", () => {
|
|
194
|
+
const body = JSON.stringify({
|
|
195
|
+
error: { type: "rate_limit_error", message: "Rate limit exceeded" },
|
|
196
|
+
});
|
|
197
|
+
expect(parseRateLimitReason(429, body)).toBe("RATE_LIMIT_EXCEEDED");
|
|
196
198
|
});
|
|
197
|
-
expect(parseRateLimitReason(429, body)).toBe("RATE_LIMIT_EXCEEDED");
|
|
198
|
-
});
|
|
199
199
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
200
|
+
it("returns RATE_LIMIT_EXCEEDED for 429 with too many requests", () => {
|
|
201
|
+
const body = JSON.stringify({
|
|
202
|
+
error: { message: "Too many requests" },
|
|
203
|
+
});
|
|
204
|
+
expect(parseRateLimitReason(429, body)).toBe("RATE_LIMIT_EXCEEDED");
|
|
203
205
|
});
|
|
204
|
-
expect(parseRateLimitReason(429, body)).toBe("RATE_LIMIT_EXCEEDED");
|
|
205
|
-
});
|
|
206
206
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
207
|
+
it("returns RATE_LIMIT_EXCEEDED for 429 with per minute message", () => {
|
|
208
|
+
const body = JSON.stringify({
|
|
209
|
+
error: { message: "Exceeded 60 requests per minute" },
|
|
210
|
+
});
|
|
211
|
+
expect(parseRateLimitReason(429, body)).toBe("RATE_LIMIT_EXCEEDED");
|
|
210
212
|
});
|
|
211
|
-
expect(parseRateLimitReason(429, body)).toBe("RATE_LIMIT_EXCEEDED");
|
|
212
|
-
});
|
|
213
213
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
214
|
+
it("defaults to RATE_LIMIT_EXCEEDED for 429 with no body", () => {
|
|
215
|
+
expect(parseRateLimitReason(429, null)).toBe("RATE_LIMIT_EXCEEDED");
|
|
216
|
+
});
|
|
217
217
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
218
|
+
it("defaults to RATE_LIMIT_EXCEEDED for 429 with unparseable body", () => {
|
|
219
|
+
expect(parseRateLimitReason(429, "not json")).toBe("RATE_LIMIT_EXCEEDED");
|
|
220
|
+
});
|
|
221
221
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
222
|
+
it("defaults to RATE_LIMIT_EXCEEDED for unrecognized status", () => {
|
|
223
|
+
expect(parseRateLimitReason(418, null)).toBe("RATE_LIMIT_EXCEEDED");
|
|
224
|
+
});
|
|
225
225
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
226
|
+
it("handles body as object (not string)", () => {
|
|
227
|
+
const body = { error: { type: "quota_exceeded", message: "quota" } };
|
|
228
|
+
expect(parseRateLimitReason(429, body)).toBe("QUOTA_EXHAUSTED");
|
|
229
|
+
});
|
|
230
230
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
231
|
+
it("returns AUTH_FAILED for membership benefits message", () => {
|
|
232
|
+
const body = JSON.stringify({
|
|
233
|
+
error: { message: "We're unable to verify your membership benefits" },
|
|
234
|
+
});
|
|
235
|
+
expect(parseRateLimitReason(403, body)).toBe("AUTH_FAILED");
|
|
234
236
|
});
|
|
235
|
-
expect(parseRateLimitReason(403, body)).toBe("AUTH_FAILED");
|
|
236
|
-
});
|
|
237
237
|
});
|
|
238
238
|
|
|
239
239
|
// ---------------------------------------------------------------------------
|
|
@@ -241,23 +241,23 @@ describe("parseRateLimitReason", () => {
|
|
|
241
241
|
// ---------------------------------------------------------------------------
|
|
242
242
|
|
|
243
243
|
describe("isRetriableNetworkError", () => {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
244
|
+
it("returns true for retryable connection reset codes", () => {
|
|
245
|
+
const error = Object.assign(new Error("socket died"), {
|
|
246
|
+
code: "ECONNRESET",
|
|
247
|
+
});
|
|
248
248
|
|
|
249
|
-
|
|
250
|
-
|
|
249
|
+
expect(isRetriableNetworkError(error)).toBe(true);
|
|
250
|
+
});
|
|
251
251
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
252
|
+
it("returns true for Bun proxy upstream reset messages", () => {
|
|
253
|
+
expect(isRetriableNetworkError(new Error("Bun proxy upstream error: Connection reset by server"))).toBe(true);
|
|
254
|
+
});
|
|
255
255
|
|
|
256
|
-
|
|
257
|
-
|
|
256
|
+
it("returns false for user abort errors", () => {
|
|
257
|
+
const error = new DOMException("The operation was aborted", "AbortError");
|
|
258
258
|
|
|
259
|
-
|
|
260
|
-
|
|
259
|
+
expect(isRetriableNetworkError(error)).toBe(false);
|
|
260
|
+
});
|
|
261
261
|
});
|
|
262
262
|
|
|
263
263
|
// ---------------------------------------------------------------------------
|
|
@@ -265,48 +265,48 @@ describe("isRetriableNetworkError", () => {
|
|
|
265
265
|
// ---------------------------------------------------------------------------
|
|
266
266
|
|
|
267
267
|
describe("calculateBackoffMs", () => {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
268
|
+
it("uses Retry-After header when provided", () => {
|
|
269
|
+
expect(calculateBackoffMs("RATE_LIMIT_EXCEEDED", 0, 5000)).toBe(5000);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("enforces minimum backoff for Retry-After", () => {
|
|
273
|
+
expect(calculateBackoffMs("RATE_LIMIT_EXCEEDED", 0, 500)).toBe(2000);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("ignores null Retry-After", () => {
|
|
277
|
+
const result = calculateBackoffMs("RATE_LIMIT_EXCEEDED", 0, null);
|
|
278
|
+
expect(result).toBe(30_000);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("ignores zero Retry-After", () => {
|
|
282
|
+
const result = calculateBackoffMs("RATE_LIMIT_EXCEEDED", 0, 0);
|
|
283
|
+
expect(result).toBe(30_000);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("escalates QUOTA_EXHAUSTED backoffs", () => {
|
|
287
|
+
expect(calculateBackoffMs("QUOTA_EXHAUSTED", 0)).toBe(60_000);
|
|
288
|
+
expect(calculateBackoffMs("QUOTA_EXHAUSTED", 1)).toBe(300_000);
|
|
289
|
+
expect(calculateBackoffMs("QUOTA_EXHAUSTED", 2)).toBe(1_800_000);
|
|
290
|
+
expect(calculateBackoffMs("QUOTA_EXHAUSTED", 3)).toBe(7_200_000);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("caps QUOTA_EXHAUSTED at max tier", () => {
|
|
294
|
+
expect(calculateBackoffMs("QUOTA_EXHAUSTED", 100)).toBe(7_200_000);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("returns fixed backoff for RATE_LIMIT_EXCEEDED", () => {
|
|
298
|
+
expect(calculateBackoffMs("RATE_LIMIT_EXCEEDED", 0)).toBe(30_000);
|
|
299
|
+
expect(calculateBackoffMs("RATE_LIMIT_EXCEEDED", 5)).toBe(30_000);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("returns short fixed backoff for AUTH_FAILED", () => {
|
|
303
|
+
expect(calculateBackoffMs("AUTH_FAILED", 0)).toBe(5_000);
|
|
304
|
+
expect(calculateBackoffMs("AUTH_FAILED", 5)).toBe(5_000);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("uses default (RATE_LIMIT_EXCEEDED) for unknown reason", () => {
|
|
308
|
+
expect(calculateBackoffMs("SOMETHING_ELSE", 0)).toBe(30_000);
|
|
309
|
+
});
|
|
310
310
|
});
|
|
311
311
|
|
|
312
312
|
// ---------------------------------------------------------------------------
|
|
@@ -314,56 +314,56 @@ describe("calculateBackoffMs", () => {
|
|
|
314
314
|
// ---------------------------------------------------------------------------
|
|
315
315
|
|
|
316
316
|
describe("parseRetryAfterHeader", () => {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
317
|
+
it("returns null when no header present", () => {
|
|
318
|
+
const response = new Response(null, { headers: {} });
|
|
319
|
+
expect(parseRetryAfterHeader(response)).toBeNull();
|
|
320
|
+
});
|
|
321
321
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
322
|
+
it("parses integer seconds", () => {
|
|
323
|
+
const response = new Response(null, {
|
|
324
|
+
headers: { "retry-after": "30" },
|
|
325
|
+
});
|
|
326
|
+
expect(parseRetryAfterHeader(response)).toBe(30_000);
|
|
325
327
|
});
|
|
326
|
-
expect(parseRetryAfterHeader(response)).toBe(30_000);
|
|
327
|
-
});
|
|
328
328
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
329
|
+
it("parses HTTP-date format", () => {
|
|
330
|
+
const futureDate = new Date(Date.now() + 60_000);
|
|
331
|
+
const response = new Response(null, {
|
|
332
|
+
headers: { "retry-after": futureDate.toUTCString() },
|
|
333
|
+
});
|
|
334
|
+
const result = parseRetryAfterHeader(response);
|
|
335
|
+
expect(result).toBeGreaterThan(50_000);
|
|
336
|
+
expect(result).toBeLessThanOrEqual(61_000);
|
|
333
337
|
});
|
|
334
|
-
const result = parseRetryAfterHeader(response);
|
|
335
|
-
expect(result).toBeGreaterThan(50_000);
|
|
336
|
-
expect(result).toBeLessThanOrEqual(61_000);
|
|
337
|
-
});
|
|
338
338
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
339
|
+
it("returns null for past HTTP-date", () => {
|
|
340
|
+
const pastDate = new Date(Date.now() - 60_000);
|
|
341
|
+
const response = new Response(null, {
|
|
342
|
+
headers: { "retry-after": pastDate.toUTCString() },
|
|
343
|
+
});
|
|
344
|
+
expect(parseRetryAfterHeader(response)).toBeNull();
|
|
343
345
|
});
|
|
344
|
-
expect(parseRetryAfterHeader(response)).toBeNull();
|
|
345
|
-
});
|
|
346
346
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
347
|
+
it("returns null for invalid header value", () => {
|
|
348
|
+
const response = new Response(null, {
|
|
349
|
+
headers: { "retry-after": "not-a-number-or-date" },
|
|
350
|
+
});
|
|
351
|
+
expect(parseRetryAfterHeader(response)).toBeNull();
|
|
350
352
|
});
|
|
351
|
-
expect(parseRetryAfterHeader(response)).toBeNull();
|
|
352
|
-
});
|
|
353
353
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
354
|
+
it("returns null for zero seconds", () => {
|
|
355
|
+
const response = new Response(null, {
|
|
356
|
+
headers: { "retry-after": "0" },
|
|
357
|
+
});
|
|
358
|
+
expect(parseRetryAfterHeader(response)).toBeNull();
|
|
357
359
|
});
|
|
358
|
-
expect(parseRetryAfterHeader(response)).toBeNull();
|
|
359
|
-
});
|
|
360
360
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
361
|
+
it("returns null for negative seconds", () => {
|
|
362
|
+
const response = new Response(null, {
|
|
363
|
+
headers: { "retry-after": "-5" },
|
|
364
|
+
});
|
|
365
|
+
expect(parseRetryAfterHeader(response)).toBeNull();
|
|
364
366
|
});
|
|
365
|
-
expect(parseRetryAfterHeader(response)).toBeNull();
|
|
366
|
-
});
|
|
367
367
|
});
|
|
368
368
|
|
|
369
369
|
// ---------------------------------------------------------------------------
|
|
@@ -371,45 +371,45 @@ describe("parseRetryAfterHeader", () => {
|
|
|
371
371
|
// ---------------------------------------------------------------------------
|
|
372
372
|
|
|
373
373
|
describe("parseRetryAfterMsHeader", () => {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
374
|
+
it("returns null when header not present", () => {
|
|
375
|
+
const response = new Response(null, { headers: {} });
|
|
376
|
+
expect(parseRetryAfterMsHeader(response)).toBeNull();
|
|
377
|
+
});
|
|
378
378
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
379
|
+
it("parses integer milliseconds", () => {
|
|
380
|
+
const response = new Response(null, {
|
|
381
|
+
headers: { "retry-after-ms": "1500" },
|
|
382
|
+
});
|
|
383
|
+
expect(parseRetryAfterMsHeader(response)).toBe(1500);
|
|
382
384
|
});
|
|
383
|
-
expect(parseRetryAfterMsHeader(response)).toBe(1500);
|
|
384
|
-
});
|
|
385
385
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
386
|
+
it("parses fractional milliseconds and rounds", () => {
|
|
387
|
+
const response = new Response(null, {
|
|
388
|
+
headers: { "retry-after-ms": "1500.7" },
|
|
389
|
+
});
|
|
390
|
+
expect(parseRetryAfterMsHeader(response)).toBe(1501);
|
|
389
391
|
});
|
|
390
|
-
expect(parseRetryAfterMsHeader(response)).toBe(1501);
|
|
391
|
-
});
|
|
392
392
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
393
|
+
it("returns null for malformed value", () => {
|
|
394
|
+
const response = new Response(null, {
|
|
395
|
+
headers: { "retry-after-ms": "not-a-number" },
|
|
396
|
+
});
|
|
397
|
+
expect(parseRetryAfterMsHeader(response)).toBeNull();
|
|
396
398
|
});
|
|
397
|
-
expect(parseRetryAfterMsHeader(response)).toBeNull();
|
|
398
|
-
});
|
|
399
399
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
400
|
+
it("returns null for zero", () => {
|
|
401
|
+
const response = new Response(null, {
|
|
402
|
+
headers: { "retry-after-ms": "0" },
|
|
403
|
+
});
|
|
404
|
+
expect(parseRetryAfterMsHeader(response)).toBeNull();
|
|
403
405
|
});
|
|
404
|
-
expect(parseRetryAfterMsHeader(response)).toBeNull();
|
|
405
|
-
});
|
|
406
406
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
407
|
+
it("returns null for negative value", () => {
|
|
408
|
+
const response = new Response(null, {
|
|
409
|
+
headers: { "retry-after-ms": "-500" },
|
|
410
|
+
});
|
|
411
|
+
expect(parseRetryAfterMsHeader(response)).toBeNull();
|
|
410
412
|
});
|
|
411
|
-
expect(parseRetryAfterMsHeader(response)).toBeNull();
|
|
412
|
-
});
|
|
413
413
|
});
|
|
414
414
|
|
|
415
415
|
// ---------------------------------------------------------------------------
|
|
@@ -417,43 +417,43 @@ describe("parseRetryAfterMsHeader", () => {
|
|
|
417
417
|
// ---------------------------------------------------------------------------
|
|
418
418
|
|
|
419
419
|
describe("parseShouldRetryHeader", () => {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
420
|
+
it("returns null when header not present", () => {
|
|
421
|
+
const response = new Response(null, { headers: {} });
|
|
422
|
+
expect(parseShouldRetryHeader(response)).toBeNull();
|
|
423
|
+
});
|
|
424
424
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
425
|
+
it("returns true for 'true'", () => {
|
|
426
|
+
const response = new Response(null, {
|
|
427
|
+
headers: { "x-should-retry": "true" },
|
|
428
|
+
});
|
|
429
|
+
expect(parseShouldRetryHeader(response)).toBe(true);
|
|
428
430
|
});
|
|
429
|
-
expect(parseShouldRetryHeader(response)).toBe(true);
|
|
430
|
-
});
|
|
431
431
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
432
|
+
it("returns false for 'false'", () => {
|
|
433
|
+
const response = new Response(null, {
|
|
434
|
+
headers: { "x-should-retry": "false" },
|
|
435
|
+
});
|
|
436
|
+
expect(parseShouldRetryHeader(response)).toBe(false);
|
|
435
437
|
});
|
|
436
|
-
expect(parseShouldRetryHeader(response)).toBe(false);
|
|
437
|
-
});
|
|
438
438
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
439
|
+
it("returns null for unrecognized value", () => {
|
|
440
|
+
const response = new Response(null, {
|
|
441
|
+
headers: { "x-should-retry": "maybe" },
|
|
442
|
+
});
|
|
443
|
+
expect(parseShouldRetryHeader(response)).toBeNull();
|
|
442
444
|
});
|
|
443
|
-
expect(parseShouldRetryHeader(response)).toBeNull();
|
|
444
|
-
});
|
|
445
445
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
446
|
+
it("returns null for uppercase 'TRUE'", () => {
|
|
447
|
+
const response = new Response(null, {
|
|
448
|
+
headers: { "x-should-retry": "TRUE" },
|
|
449
|
+
});
|
|
450
|
+
expect(parseShouldRetryHeader(response)).toBeNull();
|
|
449
451
|
});
|
|
450
|
-
expect(parseShouldRetryHeader(response)).toBeNull();
|
|
451
|
-
});
|
|
452
452
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
453
|
+
it("returns null for '1'", () => {
|
|
454
|
+
const response = new Response(null, {
|
|
455
|
+
headers: { "x-should-retry": "1" },
|
|
456
|
+
});
|
|
457
|
+
expect(parseShouldRetryHeader(response)).toBeNull();
|
|
456
458
|
});
|
|
457
|
-
expect(parseShouldRetryHeader(response)).toBeNull();
|
|
458
|
-
});
|
|
459
459
|
});
|