Skip to content

Commit bf392eb

Browse files
committed
address comments
1 parent 91bfe36 commit bf392eb

1 file changed

Lines changed: 105 additions & 34 deletions

File tree

lib/connection/auth/tokenProvider/FederationProvider.ts

Lines changed: 105 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,21 @@ const DEFAULT_SCOPE = 'sql';
2828
*/
2929
const REQUEST_TIMEOUT_MS = 30000;
3030

31+
/**
32+
* Maximum number of retry attempts for transient errors.
33+
*/
34+
const MAX_RETRY_ATTEMPTS = 3;
35+
36+
/**
37+
* Base delay in milliseconds for exponential backoff.
38+
*/
39+
const RETRY_BASE_DELAY_MS = 1000;
40+
41+
/**
42+
* HTTP status codes that are considered retryable.
43+
*/
44+
const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]);
45+
3146
/**
3247
* A token provider that wraps another provider with automatic token federation.
3348
* When the base provider returns a token from a different issuer, this provider
@@ -111,6 +126,7 @@ export default class FederationProvider implements ITokenProvider {
111126

112127
/**
113128
* Exchanges the token for a Databricks-compatible token using RFC 8693.
129+
* Includes retry logic for transient errors with exponential backoff.
114130
* @param token - The token to exchange
115131
* @returns The exchanged token
116132
*/
@@ -128,47 +144,102 @@ export default class FederationProvider implements ITokenProvider {
128144
params.append('client_id', this.clientId);
129145
}
130146

131-
const controller = new AbortController();
132-
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
147+
let lastError: Error | undefined;
133148

134-
try {
135-
const response = await fetch(url, {
136-
method: 'POST',
137-
headers: {
138-
'Content-Type': 'application/x-www-form-urlencoded',
139-
},
140-
body: params.toString(),
141-
signal: controller.signal,
142-
});
143-
144-
if (!response.ok) {
145-
const errorText = await response.text();
146-
throw new Error(`Token exchange failed: ${response.status} ${response.statusText} - ${errorText}`);
149+
for (let attempt = 0; attempt <= MAX_RETRY_ATTEMPTS; attempt++) {
150+
if (attempt > 0) {
151+
// Exponential backoff: 1s, 2s, 4s
152+
const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
153+
await this.sleep(delay);
147154
}
148155

149-
const data = (await response.json()) as {
150-
access_token?: string;
151-
token_type?: string;
152-
expires_in?: number;
153-
};
154-
155-
if (!data.access_token) {
156-
throw new Error('Token exchange response missing access_token');
156+
const controller = new AbortController();
157+
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
158+
159+
try {
160+
const response = await fetch(url, {
161+
method: 'POST',
162+
headers: {
163+
'Content-Type': 'application/x-www-form-urlencoded',
164+
},
165+
body: params.toString(),
166+
signal: controller.signal,
167+
});
168+
169+
if (!response.ok) {
170+
const errorText = await response.text();
171+
const error = new Error(`Token exchange failed: ${response.status} ${response.statusText} - ${errorText}`);
172+
173+
// Check if this is a retryable status code
174+
if (RETRYABLE_STATUS_CODES.has(response.status) && attempt < MAX_RETRY_ATTEMPTS) {
175+
lastError = error;
176+
continue;
177+
}
178+
179+
throw error;
180+
}
181+
182+
const data = (await response.json()) as {
183+
access_token?: string;
184+
token_type?: string;
185+
expires_in?: number;
186+
};
187+
188+
if (!data.access_token) {
189+
throw new Error('Token exchange response missing access_token');
190+
}
191+
192+
// Calculate expiration from expires_in
193+
let expiresAt: Date | undefined;
194+
if (typeof data.expires_in === 'number') {
195+
expiresAt = new Date(Date.now() + data.expires_in * 1000);
196+
}
197+
198+
return new Token(data.access_token, {
199+
tokenType: data.token_type ?? 'Bearer',
200+
expiresAt,
201+
});
202+
} catch (error) {
203+
clearTimeout(timeoutId);
204+
205+
// Retry on network errors (timeout, connection issues)
206+
if (this.isRetryableError(error) && attempt < MAX_RETRY_ATTEMPTS) {
207+
lastError = error instanceof Error ? error : new Error(String(error));
208+
continue;
209+
}
210+
211+
throw error;
212+
} finally {
213+
clearTimeout(timeoutId);
157214
}
215+
}
158216

159-
// Calculate expiration from expires_in
160-
let expiresAt: Date | undefined;
161-
if (typeof data.expires_in === 'number') {
162-
expiresAt = new Date(Date.now() + data.expires_in * 1000);
163-
}
217+
// If we exhausted all retries, throw the last error
218+
throw lastError ?? new Error('Token exchange failed after retries');
219+
}
164220

165-
return new Token(data.access_token, {
166-
tokenType: data.token_type ?? 'Bearer',
167-
expiresAt,
168-
});
169-
} finally {
170-
clearTimeout(timeoutId);
221+
/**
222+
* Determines if an error is retryable (network errors, timeouts).
223+
*/
224+
private isRetryableError(error: unknown): boolean {
225+
if (error instanceof Error) {
226+
// AbortError from timeout
227+
if (error.name === 'AbortError') {
228+
return true;
229+
}
230+
// Network errors from node-fetch
231+
if (error.name === 'FetchError') {
232+
return true;
233+
}
171234
}
235+
return false;
236+
}
237+
238+
/**
239+
* Sleeps for the specified duration.
240+
*/
241+
private sleep(ms: number): Promise<void> {
242+
return new Promise((resolve) => setTimeout(resolve, ms));
172243
}
173244

174245
/**

0 commit comments

Comments
 (0)