-
Notifications
You must be signed in to change notification settings - Fork 0
feat(auth0): add retry service for refresh token #186
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
973d654
add retry service for auth0
mbeernut fa40544
rmeove uneccesssary translation
mbeernut 3123e65
decrease init delay
mbeernut e538964
move constants
mbeernut 757b63d
additional retry on refresh token
mbeernut d30847e
refactor
mbeernut 58986ff
Update CONVENTIONS.md
mbeernut File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| import { retryAuth0Operation, ErrorWithStatus } from '@/utils/retryAuth0'; | ||
|
|
||
| jest.mock('@/services/appInsightsService', () => ({ | ||
| appInsightsService: { | ||
| trackEvent: jest.fn(), | ||
| }, | ||
| })); | ||
|
|
||
| describe('retryAuth0Operation', () => { | ||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| }); | ||
|
|
||
| it('should return result on first attempt if successful', async () => { | ||
| const mockFn = jest.fn().mockResolvedValue('success'); | ||
|
|
||
| const result = await retryAuth0Operation(mockFn, 'testOperation'); | ||
|
|
||
| expect(result).toBe('success'); | ||
| expect(mockFn).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('should retry on retryable errors and succeed eventually', async () => { | ||
| const serviceError: ErrorWithStatus = new Error('Service Unavailable'); | ||
| serviceError.status = 503; | ||
|
|
||
| const mockFn = jest | ||
| .fn() | ||
| .mockRejectedValueOnce(serviceError) | ||
| .mockRejectedValueOnce(serviceError) | ||
| .mockResolvedValue('success'); | ||
|
|
||
| const result = await retryAuth0Operation(mockFn, 'testOperation', { | ||
| maxRetries: 3, | ||
| initialDelayMs: 10, | ||
| }); | ||
|
|
||
| expect(result).toBe('success'); | ||
| expect(mockFn).toHaveBeenCalledTimes(3); | ||
| }); | ||
|
|
||
| it('should not retry on non-retryable errors', async () => { | ||
| const authError: ErrorWithStatus = new Error('invalid_grant'); | ||
| authError.name = 'invalid_grant'; | ||
|
|
||
| const mockFn = jest.fn().mockRejectedValue(authError); | ||
|
|
||
| await expect( | ||
| retryAuth0Operation(mockFn, 'testOperation', { | ||
| maxRetries: 3, | ||
| initialDelayMs: 10, | ||
| }), | ||
| ).rejects.toThrow('invalid_grant'); | ||
|
|
||
| expect(mockFn).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('should throw after max retries exceeded', async () => { | ||
| const networkError: ErrorWithStatus = new Error('network'); | ||
|
|
||
| const mockFn = jest.fn().mockRejectedValue(networkError); | ||
|
|
||
| await expect( | ||
| retryAuth0Operation(mockFn, 'testOperation', { | ||
| maxRetries: 2, | ||
| initialDelayMs: 10, | ||
| }), | ||
| ).rejects.toThrow('network'); | ||
|
|
||
| expect(mockFn).toHaveBeenCalledTimes(3); // Initial + 2 retries | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,188 @@ | ||
| import { appInsightsService } from '@/services/appInsightsService'; | ||
|
|
||
| const DEFAULT_MAX_RETRIES = 3; | ||
mbeernut marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| const DEFAULT_INITIAL_DELAY_MS = 1000; | ||
| const DEFAULT_MAX_DELAY_MS = 10000; | ||
| const DEFAULT_BACKOFF_MULTIPLIER = 2; | ||
| const DEFAULT_RETRYABLE_STATUS_CODES = [408, 429, 500, 502, 503, 504]; | ||
|
|
||
| const FIRST_ATTEMPT = 1; | ||
|
|
||
| export interface RetryConfig { | ||
| maxRetries?: number; | ||
| initialDelayMs?: number; | ||
| maxDelayMs?: number; | ||
| backoffMultiplier?: number; | ||
| retryableStatusCodes?: number[]; | ||
| } | ||
|
|
||
| export interface ErrorWithStatus extends Error { | ||
| status?: number; | ||
| statusCode?: number; | ||
| code?: string; | ||
| } | ||
|
|
||
| const DEFAULT_RETRY_CONFIG: Required<RetryConfig> = { | ||
| maxRetries: DEFAULT_MAX_RETRIES, | ||
| initialDelayMs: DEFAULT_INITIAL_DELAY_MS, | ||
| maxDelayMs: DEFAULT_MAX_DELAY_MS, | ||
| backoffMultiplier: DEFAULT_BACKOFF_MULTIPLIER, | ||
| retryableStatusCodes: DEFAULT_RETRYABLE_STATUS_CODES, | ||
| }; | ||
|
|
||
| function isRetryableError(error: unknown, retryableStatusCodes: number[]): boolean { | ||
| if (!error) return false; | ||
|
|
||
| const err = error as ErrorWithStatus; | ||
|
|
||
| const statusCode = err.status || err.statusCode; | ||
| if (statusCode && retryableStatusCodes.includes(statusCode)) { | ||
| return true; | ||
| } | ||
|
|
||
| const errorText = [err.message, err.name, err.code].join(' ').toLowerCase(); | ||
| const patterns = ['network', 'timeout', 'connection', 'service', 'not found']; | ||
|
|
||
| return patterns.some((pattern) => errorText.includes(pattern)); | ||
| } | ||
|
|
||
| function calculateDelay( | ||
| attemptNumber: number, | ||
| initialDelayMs: number, | ||
| maxDelayMs: number, | ||
| backoffMultiplier: number, | ||
| ): number { | ||
| const exponentialDelay = | ||
| initialDelayMs * Math.pow(backoffMultiplier, attemptNumber - FIRST_ATTEMPT); | ||
| return Math.min(exponentialDelay, maxDelayMs); | ||
| } | ||
|
|
||
| function delay(ms: number): Promise<void> { | ||
| return new Promise((resolve) => setTimeout(resolve, ms)); | ||
| } | ||
|
|
||
| function shouldRetry( | ||
| attempt: number, | ||
| maxAttempts: number, | ||
| error: unknown, | ||
| retryableStatusCodes: number[], | ||
| ): boolean { | ||
| const hasRetriesLeft = attempt < maxAttempts; | ||
| const isRetryable = isRetryableError(error, retryableStatusCodes); | ||
| return hasRetriesLeft && isRetryable; | ||
| } | ||
|
|
||
| function logRetryAttempt(operationName: string, attempt: number, maxAttempts: number): void { | ||
| if (attempt > FIRST_ATTEMPT) { | ||
| console.info(`[Retry] Attempting ${operationName} (attempt ${attempt}/${maxAttempts})`); | ||
| } | ||
| } | ||
|
|
||
| function logRetrySuccess(operationName: string, attempt: number): void { | ||
| if (attempt > FIRST_ATTEMPT) { | ||
| console.info(`[Retry] ${operationName} succeeded on attempt ${attempt}`); | ||
| appInsightsService.trackEvent({ | ||
| name: 'Auth0RetrySuccess', | ||
| properties: { | ||
| operation: operationName, | ||
| attempt: attempt.toString(), | ||
| totalRetries: (attempt - FIRST_ATTEMPT).toString(), | ||
| }, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| function logAndTrackFailure( | ||
| operationName: string, | ||
| attempt: number, | ||
| error: unknown, | ||
| isRetryable: boolean, | ||
| ): void { | ||
| if (!isRetryable) { | ||
| console.error(`[Retry] ${operationName} failed with non-retryable error`, error); | ||
| } else { | ||
| console.error(`[Retry] ${operationName} failed after ${attempt} attempts`, error); | ||
| appInsightsService.trackEvent({ | ||
| name: 'Auth0RetryFailure', | ||
| properties: { | ||
| operation: operationName, | ||
| totalAttempts: attempt.toString(), | ||
| errorMessage: error instanceof Error ? error.message : 'Unknown error', | ||
| }, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| async function executeRetryDelay( | ||
| operationName: string, | ||
| attempt: number, | ||
| initialDelayMs: number, | ||
| maxDelayMs: number, | ||
| backoffMultiplier: number, | ||
| error: unknown, | ||
| ): Promise<void> { | ||
| const delayMs = calculateDelay(attempt, initialDelayMs, maxDelayMs, backoffMultiplier); | ||
| console.info( | ||
| `[Retry] ${operationName} failed on attempt ${attempt}, retrying in ${delayMs}ms...`, | ||
| ); | ||
|
|
||
| appInsightsService.trackEvent({ | ||
| name: 'Auth0RetryAttempt', | ||
| properties: { | ||
| operation: operationName, | ||
| attempt: attempt.toString(), | ||
| delayMs: delayMs.toString(), | ||
| errorMessage: error instanceof Error ? error.message : 'Unknown error', | ||
| }, | ||
| }); | ||
|
|
||
| await delay(delayMs); | ||
| } | ||
|
|
||
| export async function retryAuth0Operation<T>( | ||
| fn: () => Promise<T>, | ||
| operationName: string, | ||
| config?: RetryConfig, | ||
| ): Promise<T> { | ||
| const { maxRetries, initialDelayMs, maxDelayMs, backoffMultiplier, retryableStatusCodes } = { | ||
| ...DEFAULT_RETRY_CONFIG, | ||
| ...config, | ||
| }; | ||
|
|
||
| let attempt = FIRST_ATTEMPT; | ||
| const maxAttempts = maxRetries + FIRST_ATTEMPT; | ||
|
|
||
| while (attempt <= maxAttempts) { | ||
| try { | ||
| logRetryAttempt(operationName, attempt, maxAttempts); | ||
|
|
||
| const result = await fn(); | ||
|
|
||
| logRetrySuccess(operationName, attempt); | ||
|
|
||
| return result; | ||
| } catch (error) { | ||
| const canRetry = shouldRetry(attempt, maxAttempts, error, retryableStatusCodes); | ||
|
|
||
| if (!canRetry) { | ||
| const isRetryable = isRetryableError(error, retryableStatusCodes); | ||
| logAndTrackFailure(operationName, attempt, error, isRetryable); | ||
| throw error; | ||
| } | ||
|
|
||
| await executeRetryDelay( | ||
| operationName, | ||
| attempt, | ||
| initialDelayMs, | ||
| maxDelayMs, | ||
| backoffMultiplier, | ||
| error, | ||
| ); | ||
|
|
||
| attempt++; | ||
| } | ||
| } | ||
|
|
||
| // This should never be reached due to the logic above, but TypeScript needs it | ||
| throw new Error(`[Retry] ${operationName} exhausted all attempts without throwing`); | ||
| } | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.