| 206 | } |
| 207 | |
| 208 | export async function refreshAccessToken( |
| 209 | config: OAuthFlowConfig, |
| 210 | refreshToken: string, |
| 211 | options: RefreshOptions, |
| 212 | ): Promise<TokenInfo> { |
| 213 | const maxRetries = options.maxRetries ?? 3; |
| 214 | const backoff = options.backoffMs ?? ((attempt) => 2 ** attempt * 1000); |
| 215 | const sleep = |
| 216 | options.sleep ?? |
| 217 | ((ms: number) => |
| 218 | new Promise<void>((resolve) => { |
| 219 | setTimeout(resolve, ms); |
| 220 | })); |
| 221 | const url = `${config.oauthHost.replace(/\/$/, '')}/api/oauth/token`; |
| 222 | |
| 223 | let lastError: Error | undefined; |
| 224 | for (let attempt = 0; attempt < maxRetries; attempt += 1) { |
| 225 | let status: number; |
| 226 | let data: Record<string, unknown>; |
| 227 | try { |
| 228 | ({ status, data } = await postForm( |
| 229 | url, |
| 230 | { |
| 231 | client_id: config.clientId, |
| 232 | grant_type: 'refresh_token', |
| 233 | refresh_token: refreshToken, |
| 234 | }, |
| 235 | options.deviceHeaders, |
| 236 | )); |
| 237 | } catch (error) { |
| 238 | // Transport-level failure (DNS, connection refused, timeout). Treat |
| 239 | // as retryable to match Python's `aiohttp.ClientError` handling. |
| 240 | lastError = error instanceof Error ? error : new OAuthError(String(error)); |
| 241 | if (attempt < maxRetries - 1) { |
| 242 | await sleep(backoff(attempt)); |
| 243 | continue; |
| 244 | } |
| 245 | throw lastError instanceof Error ? lastError : new OAuthError(String(lastError)); |
| 246 | } |
| 247 | |
| 248 | if (status === 200 && typeof data['access_token'] === 'string') { |
| 249 | return tokenFromResponse(data); |
| 250 | } |
| 251 | |
| 252 | const errorCode = typeof data['error'] === 'string' ? data['error'] : ''; |
| 253 | const detail = extractApiErrorMessage(data); |
| 254 | if (status === 401 || status === 403 || errorCode === 'invalid_grant') { |
| 255 | throw new OAuthUnauthorizedError(detail ?? 'Token refresh unauthorized.'); |
| 256 | } |
| 257 | |
| 258 | const desc = detail ?? `Token refresh failed (HTTP ${status}).`; |
| 259 | if (RETRYABLE_STATUSES.has(status)) { |
| 260 | lastError = new RetryableRefreshError(desc); |
| 261 | if (attempt < maxRetries - 1) { |
| 262 | await sleep(backoff(attempt)); |
| 263 | continue; |
| 264 | } |
| 265 | // fall through: out of retries, surface the retryable error |