Skip to content

Commit 07620c9

Browse files
committed
fix(api): cover res.json() in 15s timeout to prevent refresh deadlock
1 parent ca7a1bc commit 07620c9

2 files changed

Lines changed: 103 additions & 73 deletions

File tree

src/api.js

Lines changed: 80 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -31,90 +31,97 @@ const PLAN_MAP = {
3131
*/
3232
async function fetchUsage(token) {
3333
const controller = new AbortController();
34+
// Keep the timer alive across both fetch() (headers) and res.json() (body)
35+
// so a stalled body can also be aborted. Cleared in finally below.
3436
const timeout = setTimeout(() => controller.abort(), 15_000);
35-
let res;
3637
try {
37-
res = await fetch("https://api.github.com/copilot_internal/user", {
38-
signal: controller.signal,
39-
headers: {
40-
Authorization: `Bearer ${token}`,
41-
Accept: "application/json",
42-
"User-Agent": `vscode-github-copilot-usage/${version}`,
43-
},
44-
});
45-
} catch (e) {
46-
clearTimeout(timeout);
47-
const isTimeout = e?.name === "AbortError";
48-
throw makeError(
49-
isTimeout ? "TIMEOUT" : "NETWORK_ERROR",
50-
isTimeout ? "Request timed out" : "Network error",
51-
);
52-
}
53-
clearTimeout(timeout);
38+
let res;
39+
try {
40+
res = await fetch("https://api.github.com/copilot_internal/user", {
41+
signal: controller.signal,
42+
headers: {
43+
Authorization: `Bearer ${token}`,
44+
Accept: "application/json",
45+
"User-Agent": `vscode-github-copilot-usage/${version}`,
46+
},
47+
});
48+
} catch (e) {
49+
const isTimeout = e?.name === "AbortError";
50+
throw makeError(
51+
isTimeout ? "TIMEOUT" : "NETWORK_ERROR",
52+
isTimeout ? "Request timed out" : "Network error",
53+
);
54+
}
5455

55-
if (res.status === 401) {
56-
throw makeError("AUTH", "Not signed in (401)");
57-
}
58-
if (res.status === 403) {
59-
throw makeError("FORBIDDEN", `Forbidden (403)`);
60-
}
56+
if (res.status === 401) {
57+
throw makeError("AUTH", "Not signed in (401)");
58+
}
59+
if (res.status === 403) {
60+
throw makeError("FORBIDDEN", `Forbidden (403)`);
61+
}
6162

62-
if (res.status === 429) {
63-
throw makeError("RATE_LIMIT", "Rate limited");
64-
}
63+
if (res.status === 429) {
64+
throw makeError("RATE_LIMIT", "Rate limited");
65+
}
6566

66-
if (!res.ok) {
67-
throw makeError(res.status >= 500 ? "SERVER_ERROR" : "API_ERROR", `API error: ${res.status}`);
68-
}
67+
if (!res.ok) {
68+
throw makeError(res.status >= 500 ? "SERVER_ERROR" : "API_ERROR", `API error: ${res.status}`);
69+
}
6970

70-
let data;
71-
try {
72-
data = await res.json();
73-
} catch {
74-
throw makeError("API_ERROR", "Invalid JSON from GitHub API");
75-
}
76-
const plan = PLAN_MAP[data.copilot_plan] ?? data.copilot_plan ?? "Unknown";
71+
let data;
72+
try {
73+
data = await res.json();
74+
} catch (e) {
75+
if (e?.name === "AbortError") {
76+
throw makeError("TIMEOUT", "Request timed out");
77+
}
78+
throw makeError("API_ERROR", "Invalid JSON from GitHub API");
79+
}
80+
const plan = PLAN_MAP[data.copilot_plan] ?? data.copilot_plan ?? "Unknown";
81+
82+
const pi = data?.quota_snapshots?.premium_interactions;
83+
if (!pi || pi.percent_remaining == null) {
84+
const unlimited = !!pi?.unlimited;
85+
return {
86+
used: 0,
87+
quota: pi?.entitlement ?? 0,
88+
usedPct: 0,
89+
unlimited,
90+
noData: !unlimited,
91+
overageEnabled: !!pi?.overage_permitted,
92+
overageUsed: pi?.overage_count ?? 0,
93+
plan,
94+
resetDate: getNextMonthReset(),
95+
};
96+
}
97+
98+
const entitlement = pi.entitlement ?? 0;
99+
const percentRemaining = Number(pi.percent_remaining);
100+
if (!Number.isFinite(percentRemaining)) {
101+
throw makeError("API_ERROR", "Invalid percent_remaining from GitHub API");
102+
}
103+
const usedPct = Math.max(0, Math.round((100 - percentRemaining) * 10) / 10);
104+
const used =
105+
entitlement > 0 ? Math.max(0, Math.round((entitlement * (100 - percentRemaining)) / 100)) : 0;
106+
107+
const rawResetDate = data.quota_reset_date ? new Date(data.quota_reset_date) : null;
108+
const resetDate =
109+
rawResetDate && !isNaN(rawResetDate.getTime()) ? rawResetDate : getNextMonthReset();
77110

78-
const pi = data?.quota_snapshots?.premium_interactions;
79-
if (!pi || pi.percent_remaining == null) {
80-
const unlimited = !!pi?.unlimited;
81111
return {
82-
used: 0,
83-
quota: pi?.entitlement ?? 0,
84-
usedPct: 0,
85-
unlimited,
86-
noData: !unlimited,
87-
overageEnabled: !!pi?.overage_permitted,
88-
overageUsed: pi?.overage_count ?? 0,
112+
used,
113+
quota: entitlement,
114+
usedPct,
115+
unlimited: !!pi.unlimited,
116+
noData: false,
117+
overageEnabled: !!pi.overage_permitted,
118+
overageUsed: pi.overage_count ?? 0,
89119
plan,
90-
resetDate: getNextMonthReset(),
120+
resetDate,
91121
};
122+
} finally {
123+
clearTimeout(timeout);
92124
}
93-
94-
const entitlement = pi.entitlement ?? 0;
95-
const percentRemaining = Number(pi.percent_remaining);
96-
if (!Number.isFinite(percentRemaining)) {
97-
throw makeError("API_ERROR", "Invalid percent_remaining from GitHub API");
98-
}
99-
const usedPct = Math.max(0, Math.round((100 - percentRemaining) * 10) / 10);
100-
const used =
101-
entitlement > 0 ? Math.max(0, Math.round((entitlement * (100 - percentRemaining)) / 100)) : 0;
102-
103-
const rawResetDate = data.quota_reset_date ? new Date(data.quota_reset_date) : null;
104-
const resetDate =
105-
rawResetDate && !isNaN(rawResetDate.getTime()) ? rawResetDate : getNextMonthReset();
106-
107-
return {
108-
used,
109-
quota: entitlement,
110-
usedPct,
111-
unlimited: !!pi.unlimited,
112-
noData: false,
113-
overageEnabled: !!pi.overage_permitted,
114-
overageUsed: pi.overage_count ?? 0,
115-
plan,
116-
resetDate,
117-
};
118125
}
119126

120127
function getNextMonthReset() {

tests/api.test.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,29 @@ describe("fetchUsage", () => {
186186
await assertion;
187187
vi.useRealTimers();
188188
});
189+
190+
it("times out when response body (res.json) hangs past the 15s deadline", async () => {
191+
vi.useFakeTimers();
192+
// Headers come back fine, but the body never resolves until abort fires.
193+
fetch.mockImplementation((_url, { signal }) => {
194+
return Promise.resolve({
195+
status: 200,
196+
ok: true,
197+
json: () =>
198+
new Promise((_resolve, reject) => {
199+
signal.addEventListener("abort", () => {
200+
const err = new Error("The operation was aborted");
201+
err.name = "AbortError";
202+
reject(err);
203+
});
204+
}),
205+
});
206+
});
207+
const assertion = expect(fetchUsage("token")).rejects.toMatchObject({ code: "TIMEOUT" });
208+
await vi.advanceTimersByTimeAsync(15_001);
209+
await assertion;
210+
vi.useRealTimers();
211+
});
189212
});
190213

191214
describe("malformed response handling", () => {

0 commit comments

Comments
 (0)