opencode-gemini-auth 1.4.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-gemini-auth",
3
3
  "module": "index.ts",
4
- "version": "1.4.1",
4
+ "version": "1.4.3",
5
5
  "author": "jenslys",
6
6
  "repository": "https://github.com/jenslys/opencode-gemini-auth",
7
7
  "files": [
@@ -14,13 +14,13 @@
14
14
  "update:gemini-cli": "git -C .local/gemini-cli pull --ff-only"
15
15
  },
16
16
  "devDependencies": {
17
- "@opencode-ai/plugin": "^1.2.10",
18
17
  "@types/bun": "latest"
19
18
  },
20
19
  "peerDependencies": {
21
20
  "typescript": "^5.9.3"
22
21
  },
23
22
  "dependencies": {
23
+ "@opencode-ai/plugin": "^1.2.10",
24
24
  "@openauthjs/openauth": "^0.4.3"
25
25
  }
26
26
  }
@@ -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 <= 0) {
61
- return response;
60
+ if (delayMs > 0) {
61
+ await wait(delayMs);
62
62
  }
63
-
64
- await wait(delayMs);
65
63
  attempt += 1;
66
64
  }
67
65
 
@@ -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", () => {
@@ -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
  });
@@ -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
- if (isGeminiDebugEnabled()) {
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
+ }