Skip to content

Commit c65b585

Browse files
committed
feat: graceful degradation on network offline / rate limit
Offline handling: - Keep last known data on NETWORK_ERROR / TIMEOUT; clear isOffline on any server response (confirms connectivity) - Show $(alert) icon when offline with no cached data - Append $(warning) suffix to status bar text after >= 1 h offline (stale indicator: e.g. "25% warning") Rate-limit handling: - Keep last known data on RATE_LIMIT (show with tooltip notice) - Show $(alert) icon (not red error) when rate-limited with no cached data Tooltip polish: - Move offline / rate-limit notices to the last line of the tooltip - Unify wording: "Offline - data may be outdated" / "Rate limit - data may be outdated" (plain text, no italic parens) - Unlimited plan: add graph link in tooltip - noData: restructure tooltip to match buildTooltip layout Docs: - Update README status bar states table with new states and Unicode icons Tests: - Add tests for isOffline state, stale detection, and noData tooltip
1 parent 279c250 commit c65b585

3 files changed

Lines changed: 186 additions & 30 deletions

File tree

README.md

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,18 @@
2222

2323
## Status bar states
2424

25-
| Display | Meaning |
26-
| -------------- | ------------------------------------------------- |
27-
| `25%` | Normal usage |
28-
| `75%` (yellow) | Warning threshold reached |
29-
| `90%` (red) | Critical threshold reached |
30-
| `` | Unlimited plan |
31-
| `` | No premium quota data (plan has no tracked limit) |
32-
| `Sign in` | Not signed in — click to sign in |
33-
| _(spinner)_ | Loading |
34-
| _(error icon)_ | API / network error |
25+
| Display | Meaning |
26+
| -------------- | -------------------------------------------------- |
27+
| `25%` | Normal usage |
28+
| `75%` (yellow) | Warning threshold reached |
29+
| `90%` (red) | Critical threshold reached |
30+
| `` | Unlimited plan |
31+
| `` | No premium quota data (plan has no tracked limit) |
32+
| `25% ⚠` | Last known data — offline for > 1 hour |
33+
| `` | Offline or rate limited — no cached data available |
34+
| `Sign in` | Not signed in — click to sign in |
35+
| `` | Loading |
36+
| `` | Server error or access denied |
3537

3638
## License
3739

src/extension.js

Lines changed: 84 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ let refreshInFlight = false;
1818
let pendingSignIn = false;
1919
/** @type {boolean} */
2020
let deactivated = false;
21+
/** @type {boolean} */
22+
let isOffline = false;
23+
/** @type {Date | null} */
24+
let offlineSince = null;
2125

2226
/**
2327
* @param {vscode.ExtensionContext} context
@@ -50,6 +54,8 @@ async function activate(context) {
5054

5155
function deactivate() {
5256
deactivated = true;
57+
isOffline = false;
58+
offlineSince = null;
5359
statusBarItem?.hide();
5460
clearTimer();
5561
}
@@ -80,21 +86,34 @@ async function refresh(promptSignIn = false, isManual = false) {
8086
});
8187
} catch {
8288
// User cancelled the sign-in prompt (createIfNone: true throws on cancel)
89+
isOffline = false;
90+
offlineSince = null;
8391
showNoAuth();
8492
return;
8593
}
8694

8795
if (!session) {
96+
isOffline = false;
97+
offlineSince = null;
8898
showNoAuth();
8999
return;
90100
}
91101

92102
const data = await fetchUsage(session.accessToken);
93103
lastData = data;
94104
lastUpdatedAt = new Date();
105+
isOffline = false;
106+
offlineSince = null;
95107
updateStatusBar(data);
96108
} catch (err) {
97109
const code = err?.code;
110+
111+
// Any error that reached the server means we are online — clear offline state.
112+
if (code !== 'NETWORK_ERROR' && code !== 'TIMEOUT') {
113+
isOffline = false;
114+
offlineSince = null;
115+
}
116+
98117
if (code === 'AUTH') {
99118
showNoAuth();
100119
} else if (code === 'FORBIDDEN') {
@@ -105,15 +124,26 @@ async function refresh(promptSignIn = false, isManual = false) {
105124
try {
106125
updateStatusBar(lastData, true);
107126
} catch {
108-
showError('Rate limited');
127+
renderStatus({ text: '$(alert)', tooltip: 'Copilot Usage: Rate limited' });
109128
}
110129
} else {
111-
showError('Rate limited');
130+
renderStatus({ text: '$(alert)', tooltip: 'Copilot Usage: Rate limited' });
112131
}
113132
} else if (code === 'SERVER_ERROR') {
114133
showError('API error (5xx)');
115134
} else if (code === 'NETWORK_ERROR' || code === 'TIMEOUT') {
116-
showError('Network error');
135+
if (!isOffline) {
136+
isOffline = true;
137+
offlineSince = new Date();
138+
}
139+
if (lastData) {
140+
updateStatusBar(lastData);
141+
} else {
142+
renderStatus({
143+
text: '$(alert)',
144+
tooltip: 'Copilot Usage: Offline',
145+
});
146+
}
117147
} else {
118148
showError('Network / API error');
119149
}
@@ -138,21 +168,25 @@ const BILLING_URL = 'https://github.com/settings/billing/premium_requests_usage'
138168
*/
139169
function updateStatusBar(data, isRateLimited = false) {
140170
const cfg = getConfig();
171+
const isStale = computeIsStale(isOffline, offlineSince);
172+
const staleIcon = isStale ? ' $(warning)' : '';
141173

142174
if (data.noData) {
143175
const md = new vscode.MarkdownString('', true);
144176
md.isTrusted = { enabledCommands: ['githubCopilotUsage.refresh'] };
145-
md.appendText(`No premium quota · Plan: ${data.plan}`);
146-
if (isRateLimited) md.appendMarkdown('\n\n_(Rate limited — showing last known data)_');
147-
md.appendMarkdown('\n\n');
177+
md.appendMarkdown('**GitHub Copilot Usage**\n\nPlan: ');
178+
md.appendText(data.plan);
179+
md.appendMarkdown(`\n\nNo premium quota  [$(graph)](${BILLING_URL})\n\n`);
148180
if (lastUpdatedAt) md.appendMarkdown(`Updated at ${formatTimestamp(lastUpdatedAt)} `);
149-
md.appendMarkdown(`[$(refresh)](command:githubCopilotUsage.refresh)  [$(graph)](${BILLING_URL})`);
150-
renderStatus({ text: '—', tooltip: md });
181+
md.appendMarkdown(`[$(refresh)](command:githubCopilotUsage.refresh)`);
182+
if (isRateLimited) md.appendMarkdown('\n\nRate limit \u00b7 data may be outdated');
183+
if (isStale || isOffline) md.appendMarkdown('\n\nOffline \u00b7 data may be outdated');
184+
renderStatus({ text: `\u2014${staleIcon}`, tooltip: md });
151185
return;
152186
}
153187

154188
if (data.unlimited) {
155-
renderStatus({ text: '∞', tooltip: buildTooltip(data, isRateLimited) });
189+
renderStatus({ text: `\u221e${staleIcon}`, tooltip: buildTooltip(data, isRateLimited, isOffline, isStale) });
156190
return;
157191
}
158192

@@ -166,23 +200,29 @@ function updateStatusBar(data, isRateLimited = false) {
166200
}
167201
}
168202

169-
renderStatus({ text: `${pct}%`, tooltip: buildTooltip(data, isRateLimited), color });
203+
renderStatus({
204+
text: `${pct}%${staleIcon}`,
205+
tooltip: buildTooltip(data, isRateLimited, isOffline, isStale),
206+
color,
207+
});
170208
}
171209

172210
/**
173211
* @param {import('./api').UsageData} data
174212
* @param {boolean} isRateLimited
213+
* @param {boolean} [isOfflineState]
214+
* @param {boolean} [isStale]
175215
* @returns {vscode.MarkdownString}
176216
*/
177-
function buildTooltip(data, isRateLimited) {
217+
function buildTooltip(data, isRateLimited, isOfflineState = false, isStale = false) {
178218
const md = new vscode.MarkdownString('', true);
179219
md.isTrusted = { enabledCommands: ['githubCopilotUsage.refresh'] };
180220
md.appendMarkdown('**GitHub Copilot Usage**\n\nPlan: ');
181221
md.appendText(data.plan);
182222
md.appendMarkdown('\n\n');
183223

184224
if (data.unlimited) {
185-
md.appendMarkdown('Quota: Unlimited\n\n');
225+
md.appendMarkdown(`Quota: Unlimited  [$(graph)](${BILLING_URL})\n\n`);
186226
} else {
187227
md.appendMarkdown(`Used: ${data.used} / ${data.quota} (${data.usedPct}%)  [$(graph)](${BILLING_URL})\n\n`);
188228
if (data.overageEnabled && data.overageUsed > 0) {
@@ -200,12 +240,14 @@ function buildTooltip(data, isRateLimited) {
200240
md.appendMarkdown('\n\n');
201241
}
202242

203-
if (isRateLimited) {
204-
md.appendMarkdown('_(Rate limited — showing last known data)_\n\n');
205-
}
206-
207243
if (lastUpdatedAt) md.appendMarkdown(`Updated at ${formatTimestamp(lastUpdatedAt)} `);
208244
md.appendMarkdown('[$(refresh)](command:githubCopilotUsage.refresh)');
245+
if (isRateLimited) {
246+
md.appendMarkdown('\n\nRate limit \u00b7 data may be outdated');
247+
}
248+
if (isStale || isOfflineState) {
249+
md.appendMarkdown('\n\nOffline \u00b7 data may be outdated');
250+
}
209251
return md;
210252
}
211253

@@ -292,4 +334,29 @@ function getConfig() {
292334
};
293335
}
294336

295-
module.exports = { activate, deactivate, formatTimestamp, getConfig, buildTooltip };
337+
/**
338+
* Pure helper: returns true only when offline for > 1 h.
339+
* @param {boolean} offline
340+
* @param {Date | null} since
341+
* @returns {boolean}
342+
*/
343+
function computeIsStale(offline, since) {
344+
return offline && since !== null && Date.now() - since.getTime() > 60 * 60 * 1000;
345+
}
346+
347+
module.exports = {
348+
activate,
349+
deactivate,
350+
formatTimestamp,
351+
getConfig,
352+
buildTooltip,
353+
computeIsStale,
354+
// Test-only: inspect and mutate module-level state.
355+
_setState: (s) => {
356+
if ('isOffline' in s) isOffline = s.isOffline;
357+
if ('offlineSince' in s) offlineSince = s.offlineSince;
358+
if ('lastData' in s) lastData = s.lastData;
359+
if ('lastUpdatedAt' in s) lastUpdatedAt = s.lastUpdatedAt;
360+
},
361+
_getState: () => ({ isOffline, offlineSince }),
362+
};

tests/extension.test.js

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use strict';
22

3-
const { formatTimestamp, getConfig, buildTooltip } = require('../src/extension');
3+
const { formatTimestamp, getConfig, buildTooltip, computeIsStale, _setState, _getState } = require('../src/extension');
44

55
// ---------------------------------------------------------------------------
66
// formatTimestamp
@@ -112,12 +112,29 @@ describe('buildTooltip', () => {
112112

113113
it('includes rate-limit notice when isRateLimited is true', () => {
114114
const md = buildTooltip(BASE_DATA, true);
115-
expect(md.value).toContain('Rate limited');
115+
expect(md.value).toContain('Rate limit');
116116
});
117117

118118
it('does not include rate-limit notice when isRateLimited is false', () => {
119119
const md = buildTooltip(BASE_DATA, false);
120-
expect(md.value).not.toContain('Rate limited');
120+
expect(md.value).not.toContain('Rate limit');
121+
});
122+
123+
it('shows offline notice when isOfflineState is true but not stale', () => {
124+
const md = buildTooltip(BASE_DATA, false, true, false);
125+
expect(md.value).toContain('Offline');
126+
expect(md.value).not.toContain('1 h');
127+
});
128+
129+
it('shows offline/stale notice when isStale is true', () => {
130+
const md = buildTooltip(BASE_DATA, false, true, true);
131+
expect(md.value).toContain('Offline');
132+
expect(md.value).toContain('outdated');
133+
});
134+
135+
it('shows no offline notice when isOfflineState and isStale are both false', () => {
136+
const md = buildTooltip(BASE_DATA, false, false, false);
137+
expect(md.value).not.toContain('Offline');
121138
});
122139

123140
it('includes overage count when overageEnabled and overageUsed > 0', () => {
@@ -131,3 +148,73 @@ describe('buildTooltip', () => {
131148
expect(md.value).not.toContain('Overage');
132149
});
133150
});
151+
152+
// ---------------------------------------------------------------------------
153+
// computeIsStale — pure staleness helper
154+
// ---------------------------------------------------------------------------
155+
156+
describe('computeIsStale', () => {
157+
const HOUR_MS = 60 * 60 * 1000;
158+
159+
it('returns false when not offline', () => {
160+
const since = new Date(Date.now() - HOUR_MS - 1);
161+
expect(computeIsStale(false, since)).toBe(false);
162+
});
163+
164+
it('returns false when offline but offlineSince is null', () => {
165+
expect(computeIsStale(true, null)).toBe(false);
166+
});
167+
168+
it('returns false when offline for less than 1 hour', () => {
169+
const since = new Date(Date.now() - 30 * 60 * 1000); // 30 min ago
170+
expect(computeIsStale(true, since)).toBe(false);
171+
});
172+
173+
it('returns false when offline for exactly 1 hour (boundary)', () => {
174+
const since = new Date(Date.now() - HOUR_MS); // exactly 1h — not ">" yet
175+
expect(computeIsStale(true, since)).toBe(false);
176+
});
177+
178+
it('returns true when offline for more than 1 hour', () => {
179+
const since = new Date(Date.now() - HOUR_MS - 1); // 1h + 1ms
180+
expect(computeIsStale(true, since)).toBe(true);
181+
});
182+
});
183+
184+
// ---------------------------------------------------------------------------
185+
// isOffline / offlineSince state transitions (via _setState / _getState)
186+
// ---------------------------------------------------------------------------
187+
188+
describe('offline state transitions', () => {
189+
afterEach(() => {
190+
// Reset module state after each test so they don't bleed into each other.
191+
_setState({ isOffline: false, offlineSince: null, lastData: null, lastUpdatedAt: null });
192+
});
193+
194+
it('initial state: isOffline false, offlineSince null', () => {
195+
const state = _getState();
196+
expect(state.isOffline).toBe(false);
197+
expect(state.offlineSince).toBeNull();
198+
});
199+
200+
it('_setState sets isOffline and offlineSince independently', () => {
201+
const since = new Date();
202+
_setState({ isOffline: true, offlineSince: since });
203+
const state = _getState();
204+
expect(state.isOffline).toBe(true);
205+
expect(state.offlineSince).toBe(since);
206+
});
207+
208+
it('going offline is not stale immediately (offlineSince just set)', () => {
209+
_setState({ isOffline: true, offlineSince: new Date() });
210+
const { isOffline, offlineSince } = _getState();
211+
expect(computeIsStale(isOffline, offlineSince)).toBe(false);
212+
});
213+
214+
it('becomes stale only after offlineSince is >1 h in the past', () => {
215+
const since = new Date(Date.now() - 61 * 60 * 1000);
216+
_setState({ isOffline: true, offlineSince: since });
217+
const { isOffline, offlineSince } = _getState();
218+
expect(computeIsStale(isOffline, offlineSince)).toBe(true);
219+
});
220+
});

0 commit comments

Comments
 (0)