opencode-gemini-auth 1.4.2 → 1.4.3
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/package.json +1 -1
- package/src/plugin/retry/index.ts +2 -4
- package/src/plugin/retry.test.ts +21 -0
- package/src/plugin/token.test.ts +57 -1
- package/src/plugin/token.ts +54 -15
package/package.json
CHANGED
|
@@ -57,11 +57,9 @@ export async function fetchWithRetry(
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
const delayMs = await resolveRetryDelayMs(response, attempt, quotaContext?.retryDelayMs);
|
|
60
|
-
if (delayMs
|
|
61
|
-
|
|
60
|
+
if (delayMs > 0) {
|
|
61
|
+
await wait(delayMs);
|
|
62
62
|
}
|
|
63
|
-
|
|
64
|
-
await wait(delayMs);
|
|
65
63
|
attempt += 1;
|
|
66
64
|
}
|
|
67
65
|
|
package/src/plugin/retry.test.ts
CHANGED
|
@@ -95,6 +95,27 @@ describe("fetchWithRetry", () => {
|
|
|
95
95
|
expect(response.status).toBe(429);
|
|
96
96
|
expect(fetchMock.mock.calls.length).toBe(1);
|
|
97
97
|
});
|
|
98
|
+
|
|
99
|
+
it("retries immediately when server returns Retry-After: 0", async () => {
|
|
100
|
+
const fetchMock = mock(async () => {
|
|
101
|
+
if (fetchMock.mock.calls.length === 1) {
|
|
102
|
+
return new Response("rate limited", {
|
|
103
|
+
status: 429,
|
|
104
|
+
headers: { "retry-after": "0" },
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return new Response("ok", { status: 200 });
|
|
108
|
+
});
|
|
109
|
+
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
|
|
110
|
+
|
|
111
|
+
const response = await fetchWithRetry("https://example.com", {
|
|
112
|
+
method: "POST",
|
|
113
|
+
body: JSON.stringify({ hello: "world" }),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(response.status).toBe(200);
|
|
117
|
+
expect(fetchMock.mock.calls.length).toBe(2);
|
|
118
|
+
});
|
|
98
119
|
});
|
|
99
120
|
|
|
100
121
|
describe("retryInternals", () => {
|
package/src/plugin/token.test.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
2
|
|
|
3
3
|
import { GEMINI_PROVIDER_ID } from "../constants";
|
|
4
4
|
import { refreshAccessToken } from "./token";
|
|
5
5
|
import type { OAuthAuthDetails, PluginClient } from "./types";
|
|
6
6
|
|
|
7
|
+
const originalSetTimeout = globalThis.setTimeout;
|
|
8
|
+
|
|
7
9
|
const baseAuth: OAuthAuthDetails = {
|
|
8
10
|
type: "oauth",
|
|
9
11
|
refresh: "refresh-token|project-123",
|
|
@@ -24,6 +26,14 @@ function createClient() {
|
|
|
24
26
|
describe("refreshAccessToken", () => {
|
|
25
27
|
beforeEach(() => {
|
|
26
28
|
mock.restore();
|
|
29
|
+
(globalThis as { setTimeout: typeof setTimeout }).setTimeout = ((fn: (...args: any[]) => void) => {
|
|
30
|
+
fn();
|
|
31
|
+
return 0 as unknown as ReturnType<typeof setTimeout>;
|
|
32
|
+
}) as typeof setTimeout;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
(globalThis as { setTimeout: typeof setTimeout }).setTimeout = originalSetTimeout;
|
|
27
37
|
});
|
|
28
38
|
|
|
29
39
|
it("updates the caller but skips persisting when refresh token is unchanged", async () => {
|
|
@@ -102,4 +112,50 @@ describe("refreshAccessToken", () => {
|
|
|
102
112
|
expect(firstResult?.access).toBe("deduped-access");
|
|
103
113
|
expect(secondResult?.access).toBe("deduped-access");
|
|
104
114
|
});
|
|
115
|
+
|
|
116
|
+
it("retries transient network errors during token refresh", async () => {
|
|
117
|
+
const client = createClient();
|
|
118
|
+
const fetchMock = mock(async () => {
|
|
119
|
+
if (fetchMock.mock.calls.length === 1) {
|
|
120
|
+
const err = new Error("socket reset") as Error & { code?: string };
|
|
121
|
+
err.code = "ECONNRESET";
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
return new Response(
|
|
125
|
+
JSON.stringify({
|
|
126
|
+
access_token: "recovered-access",
|
|
127
|
+
expires_in: 3600,
|
|
128
|
+
}),
|
|
129
|
+
{ status: 200 },
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
|
|
133
|
+
|
|
134
|
+
const result = await refreshAccessToken(baseAuth, client);
|
|
135
|
+
|
|
136
|
+
expect(result?.access).toBe("recovered-access");
|
|
137
|
+
expect(fetchMock.mock.calls.length).toBe(2);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("retries retryable HTTP errors from token endpoint", async () => {
|
|
141
|
+
const client = createClient();
|
|
142
|
+
const fetchMock = mock(async () => {
|
|
143
|
+
if (fetchMock.mock.calls.length === 1) {
|
|
144
|
+
return new Response("temporarily unavailable", { status: 503 });
|
|
145
|
+
}
|
|
146
|
+
return new Response(
|
|
147
|
+
JSON.stringify({
|
|
148
|
+
access_token: "recovered-after-503",
|
|
149
|
+
expires_in: 3600,
|
|
150
|
+
}),
|
|
151
|
+
{ status: 200 },
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
|
|
155
|
+
|
|
156
|
+
const result = await refreshAccessToken(baseAuth, client);
|
|
157
|
+
|
|
158
|
+
expect(result?.access).toBe("recovered-after-503");
|
|
159
|
+
expect(fetchMock.mock.calls.length).toBe(2);
|
|
160
|
+
});
|
|
105
161
|
});
|
package/src/plugin/token.ts
CHANGED
|
@@ -11,6 +11,14 @@ import {
|
|
|
11
11
|
logGeminiDebugMessage,
|
|
12
12
|
} from "./debug";
|
|
13
13
|
import { invalidateProjectContextCache } from "./project";
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_MAX_ATTEMPTS,
|
|
16
|
+
getExponentialDelayWithJitter,
|
|
17
|
+
isRetryableNetworkError,
|
|
18
|
+
isRetryableStatus,
|
|
19
|
+
resolveRetryDelayMs,
|
|
20
|
+
wait,
|
|
21
|
+
} from "./retry/helpers";
|
|
14
22
|
import type { OAuthAuthDetails, PluginClient, RefreshParts } from "./types";
|
|
15
23
|
|
|
16
24
|
interface OAuthErrorPayload {
|
|
@@ -98,21 +106,7 @@ async function refreshAccessTokenInternal(
|
|
|
98
106
|
parts: RefreshParts,
|
|
99
107
|
): Promise<OAuthAuthDetails | undefined> {
|
|
100
108
|
try {
|
|
101
|
-
|
|
102
|
-
logGeminiDebugMessage("OAuth refresh: POST https://oauth2.googleapis.com/token");
|
|
103
|
-
}
|
|
104
|
-
const response = await fetch("https://oauth2.googleapis.com/token", {
|
|
105
|
-
method: "POST",
|
|
106
|
-
headers: {
|
|
107
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
108
|
-
},
|
|
109
|
-
body: new URLSearchParams({
|
|
110
|
-
grant_type: "refresh_token",
|
|
111
|
-
refresh_token: parts.refreshToken,
|
|
112
|
-
client_id: GEMINI_CLIENT_ID,
|
|
113
|
-
client_secret: GEMINI_CLIENT_SECRET,
|
|
114
|
-
}),
|
|
115
|
-
});
|
|
109
|
+
const response = await fetchTokenRefresh(parts.refreshToken);
|
|
116
110
|
|
|
117
111
|
if (!response.ok) {
|
|
118
112
|
let errorText: string | undefined;
|
|
@@ -209,3 +203,48 @@ async function refreshAccessTokenInternal(
|
|
|
209
203
|
return undefined;
|
|
210
204
|
}
|
|
211
205
|
}
|
|
206
|
+
|
|
207
|
+
async function fetchTokenRefresh(refreshToken: string): Promise<Response> {
|
|
208
|
+
const tokenUrl = "https://oauth2.googleapis.com/token";
|
|
209
|
+
const init: RequestInit = {
|
|
210
|
+
method: "POST",
|
|
211
|
+
headers: {
|
|
212
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
213
|
+
},
|
|
214
|
+
body: new URLSearchParams({
|
|
215
|
+
grant_type: "refresh_token",
|
|
216
|
+
refresh_token: refreshToken,
|
|
217
|
+
client_id: GEMINI_CLIENT_ID,
|
|
218
|
+
client_secret: GEMINI_CLIENT_SECRET,
|
|
219
|
+
}),
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
let attempt = 1;
|
|
223
|
+
while (attempt <= DEFAULT_MAX_ATTEMPTS) {
|
|
224
|
+
if (isGeminiDebugEnabled()) {
|
|
225
|
+
logGeminiDebugMessage(`OAuth refresh attempt ${attempt}: POST ${tokenUrl}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
const response = await fetch(tokenUrl, init);
|
|
230
|
+
if (!isRetryableStatus(response.status) || attempt >= DEFAULT_MAX_ATTEMPTS) {
|
|
231
|
+
return response;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const delayMs = await resolveRetryDelayMs(response, attempt);
|
|
235
|
+
if (delayMs > 0) {
|
|
236
|
+
await wait(delayMs);
|
|
237
|
+
}
|
|
238
|
+
attempt += 1;
|
|
239
|
+
continue;
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (attempt >= DEFAULT_MAX_ATTEMPTS || !isRetryableNetworkError(error)) {
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
await wait(getExponentialDelayWithJitter(attempt));
|
|
245
|
+
attempt += 1;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return fetch(tokenUrl, init);
|
|
250
|
+
}
|