Skip to content

Commit 9049fc6

Browse files
schwaaampclaude
andcommitted
Resilient token refresh: retry transient errors, classify before flagging needs_reauth
Previously, any token refresh failure (including Fitbit/Whoop/Oura 500s) immediately set needs_reauth=true on connected_devices, permanently blocking the device from cron syncs. A transient provider outage could lock out users who had perfectly valid refresh tokens. Changes: - Add shared token-refresh.ts with TokenRefreshError (permanent flag), classifyTokenError (permanent vs transient), and refreshWithRetry (3 attempts, exponential backoff 1s/2s/4s) - Modify fitbit/oura/whoop refreshTokens() to use retry logic - Modify sync-fitbit/oura/whoop catch blocks: permanent errors set needs_reauth=true (401), transient errors skip needs_reauth (503) - Fix mockFetch spread ordering bug in test-helpers.ts (json/text methods were overwritten by ...r spread) - Fix mock cleanup bug in fitbit/whoop tests (globalThis.fetch = originalFetch after fetchStub.restore() poisoned subsequent tests) - Fix missing env vars in fitbit/whoop test setups Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b100ee3 commit 9049fc6

File tree

12 files changed

+1163
-266
lines changed

12 files changed

+1163
-266
lines changed

supabase/functions/_shared/fitbit-client.test.ts

Lines changed: 169 additions & 64 deletions
Large diffs are not rendered by default.

supabase/functions/_shared/fitbit-client.ts

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
*/
77

88
import { createClient } from './supabaseClient.ts';
9+
import { refreshWithRetry, fetchTokenEndpoint, TokenRefreshError } from './token-refresh.ts';
10+
export { TokenRefreshError } from './token-refresh.ts';
911

1012
// Types
1113
export interface FitbitTokens {
@@ -64,6 +66,9 @@ export async function getFreshTokens(userId: string): Promise<FitbitTokens> {
6466

6567
/**
6668
* Refresh Fitbit tokens using refresh token
69+
* Uses retry with exponential backoff for transient errors (5xx, network).
70+
* Throws TokenRefreshError with permanent flag for callers to distinguish
71+
* "user needs to re-auth" from "try again next hour".
6772
*/
6873
export async function refreshTokens(userId: string, refreshToken: string): Promise<FitbitTokens> {
6974
const clientId = Deno.env.get('FITBIT_CLIENT_ID');
@@ -73,25 +78,21 @@ export async function refreshTokens(userId: string, refreshToken: string): Promi
7378
throw new Error('Missing FITBIT_CLIENT_ID or FITBIT_CLIENT_SECRET');
7479
}
7580

76-
// Make refresh request
77-
const response = await fetch(FITBIT_TOKEN_URL, {
78-
method: 'POST',
79-
headers: {
80-
'Content-Type': 'application/x-www-form-urlencoded',
81-
'Authorization': `Basic ${btoa(`${clientId}:${clientSecret}`)}`,
82-
},
83-
body: new URLSearchParams({
84-
grant_type: 'refresh_token',
85-
refresh_token: refreshToken,
81+
// Retry transient errors up to 3 times with exponential backoff
82+
const tokenData = await refreshWithRetry(
83+
() => fetchTokenEndpoint(FITBIT_TOKEN_URL, {
84+
method: 'POST',
85+
headers: {
86+
'Content-Type': 'application/x-www-form-urlencoded',
87+
'Authorization': `Basic ${btoa(`${clientId}:${clientSecret}`)}`,
88+
},
89+
body: new URLSearchParams({
90+
grant_type: 'refresh_token',
91+
refresh_token: refreshToken,
92+
}),
8693
}),
87-
});
88-
89-
if (!response.ok) {
90-
const errorData = await response.json().catch(() => ({}));
91-
throw new Error(`Failed to refresh Fitbit token: ${JSON.stringify(errorData)}`);
92-
}
93-
94-
const tokenData: FitbitTokenResponse = await response.json();
94+
{ provider: 'fitbit' }
95+
) as unknown as FitbitTokenResponse;
9596

9697
// Calculate new expiry time
9798
const expiresAt = new Date(Date.now() + tokenData.expires_in * 1000).toISOString();

supabase/functions/_shared/oura-client.test.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,14 +119,83 @@ describe('oura-client', () => {
119119
);
120120
});
121121

122+
it('retries on 500 and succeeds on 2nd attempt', async () => {
123+
let callCount = 0;
124+
globalThis.fetch = vi.fn().mockImplementation(() => {
125+
callCount++;
126+
if (callCount === 1) {
127+
return Promise.resolve({
128+
ok: false,
129+
status: 500,
130+
json: () => Promise.resolve({ error: 'internal_server_error' }),
131+
});
132+
}
133+
return Promise.resolve({
134+
ok: true,
135+
status: 200,
136+
json: () => Promise.resolve({
137+
access_token: 'recovered-token',
138+
refresh_token: 'new-refresh',
139+
expires_in: 3600,
140+
token_type: 'Bearer',
141+
scope: 'personal daily',
142+
}),
143+
});
144+
});
145+
146+
const updateEqMock = vi.fn().mockResolvedValue({ error: null });
147+
const updateMock = vi.fn(() => ({ eq: updateEqMock }));
148+
mockSupabaseClient.from = vi.fn(() => ({ update: updateMock }));
149+
150+
const result = await refreshTokens('user-123', 'old-refresh-token');
151+
expect(result.access_token).toBe('recovered-token');
152+
expect(callCount).toBeGreaterThanOrEqual(2);
153+
});
154+
155+
it('throws permanent TokenRefreshError on 400 invalid_grant', async () => {
156+
globalThis.fetch = vi.fn().mockResolvedValue({
157+
ok: false,
158+
status: 400,
159+
json: () => Promise.resolve({ error: 'invalid_grant' }),
160+
});
161+
162+
try {
163+
await refreshTokens('user-123', 'invalid-token');
164+
throw new Error('Should have thrown');
165+
} catch (err: any) {
166+
expect(err.name).toBe('TokenRefreshError');
167+
expect(err.permanent).toBe(true);
168+
}
169+
170+
// Should NOT have retried
171+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
172+
});
173+
174+
it('throws transient TokenRefreshError after exhausting retries on 500', async () => {
175+
globalThis.fetch = vi.fn().mockResolvedValue({
176+
ok: false,
177+
status: 500,
178+
json: () => Promise.resolve({ error: 'server_error' }),
179+
});
180+
181+
try {
182+
await refreshTokens('user-123', 'refresh-token');
183+
throw new Error('Should have thrown');
184+
} catch (err: any) {
185+
expect(err.name).toBe('TokenRefreshError');
186+
expect(err.permanent).toBe(false);
187+
}
188+
});
189+
122190
it('throws error when refresh fails', async () => {
123191
globalThis.fetch = vi.fn().mockResolvedValue({
124192
ok: false,
193+
status: 400,
125194
json: () => Promise.resolve({ error: 'invalid_grant' }),
126195
});
127196

128197
await expect(refreshTokens('user-123', 'invalid-token')).rejects.toThrow(
129-
'Failed to refresh Oura token'
198+
'Token refresh failed'
130199
);
131200
});
132201

supabase/functions/_shared/oura-client.ts

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
*/
77

88
import { createClient } from './supabaseClient.ts';
9+
import { refreshWithRetry, fetchTokenEndpoint, TokenRefreshError } from './token-refresh.ts';
10+
export { TokenRefreshError } from './token-refresh.ts';
911

1012
// Types
1113
export interface OuraTokens {
@@ -63,6 +65,9 @@ export async function getFreshTokens(userId: string): Promise<OuraTokens> {
6365

6466
/**
6567
* Refresh Oura tokens using refresh token
68+
* Uses retry with exponential backoff for transient errors (5xx, network).
69+
* Throws TokenRefreshError with permanent flag for callers to distinguish
70+
* "user needs to re-auth" from "try again next hour".
6671
*/
6772
export async function refreshTokens(userId: string, refreshToken: string): Promise<OuraTokens> {
6873
const clientId = Deno.env.get('OURA_CLIENT_ID');
@@ -72,26 +77,22 @@ export async function refreshTokens(userId: string, refreshToken: string): Promi
7277
throw new Error('Missing OURA_CLIENT_ID or OURA_CLIENT_SECRET');
7378
}
7479

75-
// Make refresh request
76-
const response = await fetch(OURA_TOKEN_URL, {
77-
method: 'POST',
78-
headers: {
79-
'Content-Type': 'application/x-www-form-urlencoded',
80-
},
81-
body: new URLSearchParams({
82-
grant_type: 'refresh_token',
83-
refresh_token: refreshToken,
84-
client_id: clientId,
85-
client_secret: clientSecret,
80+
// Retry transient errors up to 3 times with exponential backoff
81+
const tokenData = await refreshWithRetry(
82+
() => fetchTokenEndpoint(OURA_TOKEN_URL, {
83+
method: 'POST',
84+
headers: {
85+
'Content-Type': 'application/x-www-form-urlencoded',
86+
},
87+
body: new URLSearchParams({
88+
grant_type: 'refresh_token',
89+
refresh_token: refreshToken,
90+
client_id: clientId,
91+
client_secret: clientSecret,
92+
}),
8693
}),
87-
});
88-
89-
if (!response.ok) {
90-
const errorData = await response.json().catch(() => ({}));
91-
throw new Error(`Failed to refresh Oura token: ${JSON.stringify(errorData)}`);
92-
}
93-
94-
const tokenData: OuraTokenResponse = await response.json();
94+
{ provider: 'oura' }
95+
) as unknown as OuraTokenResponse;
9596

9697
// Calculate new expiry time
9798
const expiresAt = new Date(Date.now() + tokenData.expires_in * 1000).toISOString();

supabase/functions/_shared/test-helpers.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,19 @@ export function mockFetch(...responses: Partial<Response>[]): Stub {
3434
activeFetchStub = null;
3535
}
3636

37-
const mockResponses = responses.map((r) => ({
38-
status: r.status || 200,
39-
ok: (r.status || 200) >= 200 && (r.status || 200) < 300,
40-
statusText: r.statusText || "OK",
41-
headers: new Headers(r.headers || {}),
42-
json: () => Promise.resolve((r as any).json || {}),
43-
text: () => Promise.resolve((r as any).text || ""),
44-
...r,
45-
}));
37+
const mockResponses = responses.map((r) => {
38+
const jsonData = (r as any).json;
39+
const textData = (r as any).text;
40+
return {
41+
...r,
42+
status: r.status || 200,
43+
ok: (r.status || 200) >= 200 && (r.status || 200) < 300,
44+
statusText: r.statusText || "OK",
45+
headers: new Headers(r.headers || {}),
46+
json: () => Promise.resolve(jsonData || {}),
47+
text: () => Promise.resolve(textData || ""),
48+
};
49+
});
4650

4751
let callIndex = 0;
4852

0 commit comments

Comments
 (0)