Skip to content

Commit 8e99f2d

Browse files
feat: add ability to retry-with-backoff for failed auth attempts (#4994)
1 parent a6c5c9b commit 8e99f2d

File tree

2 files changed

+324
-0
lines changed

2 files changed

+324
-0
lines changed

src/identity/apis/auth.test.ts

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
import {mocked} from 'ts-jest/utils'
2+
3+
import {isFlagEnabled} from 'src/shared/utils/featureFlag'
4+
5+
import {
6+
getIdentity,
7+
Error as UnityError,
8+
Identity,
9+
} from 'src/client/unityRoutes'
10+
11+
import {
12+
retryFetchIdentity,
13+
UnauthorizedError,
14+
ServerError,
15+
} from 'src/identity/apis/auth'
16+
17+
jest.mock('src/shared/constants', () => ({CLOUD: true}))
18+
19+
jest.mock('src/shared/utils/featureFlag.ts', () => ({
20+
isFlagEnabled: jest.fn(),
21+
}))
22+
23+
jest.mock(
24+
'src/client/unityRoutes',
25+
() => ({
26+
...jest.requireActual('src/client/unityRoutes'),
27+
getIdentity: jest.fn(),
28+
}),
29+
{virtual: true}
30+
)
31+
32+
const mockIdentity: Identity = {
33+
user: {
34+
id: '1234asdf',
35+
email: 'test@example.com',
36+
accountCount: 1,
37+
orgCount: 1,
38+
},
39+
account: {
40+
id: 12344,
41+
name: 'John Doe',
42+
type: 'free',
43+
accountCreatedAt: 'anytime',
44+
},
45+
org: {
46+
id: '7890uiop',
47+
name: 'John Doe org',
48+
clusterHost: 'aws-east-0-1',
49+
},
50+
}
51+
52+
const mockGoodResponse = () => {
53+
return Promise.resolve({
54+
status: 200,
55+
headers: {},
56+
data: mockIdentity,
57+
} as any)
58+
}
59+
60+
const mockError401: UnityError = {
61+
code: 'unauthorized',
62+
message: "You ain't allowed to see that, jack",
63+
}
64+
65+
const mockUnauthorizedError = () => {
66+
return Promise.resolve({
67+
status: 401,
68+
headers: {},
69+
data: mockError401,
70+
} as any)
71+
}
72+
73+
const mockUnityServerError: UnityError = {
74+
code: 'internal error',
75+
message: 'There was an error',
76+
}
77+
78+
const mockServerError = () => {
79+
return Promise.resolve({
80+
status: 500,
81+
headers: {},
82+
data: mockUnityServerError,
83+
} as any)
84+
}
85+
86+
const flushPromises = () => {
87+
return new Promise(jest.requireActual('timers').setImmediate)
88+
}
89+
90+
describe('retrying failed authentication attempts', () => {
91+
beforeEach(() => {
92+
// return true so that `uiUnificationFlag` and `quartzIdentity` both return true
93+
mocked(isFlagEnabled).mockImplementation(() => true)
94+
})
95+
96+
afterEach(() => {
97+
mocked(getIdentity).mockReset()
98+
})
99+
100+
it("doesn't retry when the first call is successful", async () => {
101+
mocked(getIdentity).mockImplementation(mockGoodResponse)
102+
103+
expect(await retryFetchIdentity()).toEqual(mockIdentity)
104+
})
105+
106+
it('throws the error in the event of a 401', async () => {
107+
mocked(getIdentity).mockImplementation(mockUnauthorizedError)
108+
109+
try {
110+
await retryFetchIdentity()
111+
} catch (error) {
112+
expect(error).toStrictEqual(new UnauthorizedError(mockError401.message))
113+
}
114+
})
115+
116+
it('retries once', async () => {
117+
mocked(getIdentity).mockImplementationOnce(mockServerError)
118+
119+
mocked(getIdentity).mockImplementationOnce(mockGoodResponse)
120+
121+
expect(await retryFetchIdentity(1, 1)).toEqual(mockIdentity)
122+
123+
const [firstCall, secondCall] = mocked(getIdentity).mock.results
124+
125+
expect(await (await firstCall.value).status).toBe(500)
126+
expect(await (await secondCall.value).status).toBe(200)
127+
})
128+
129+
it('retries multiple times', async () => {
130+
mocked(getIdentity).mockImplementationOnce(mockServerError)
131+
mocked(getIdentity).mockImplementationOnce(mockServerError)
132+
mocked(getIdentity).mockImplementationOnce(mockServerError)
133+
134+
mocked(getIdentity).mockImplementationOnce(mockGoodResponse)
135+
136+
expect(await retryFetchIdentity(1, 1)).toEqual(mockIdentity)
137+
138+
const [firstCall, secondCall, thirdCall, fourthCall] = mocked(
139+
getIdentity
140+
).mock.results
141+
142+
expect(await (await firstCall.value).status).toBe(500)
143+
expect(await (await secondCall.value).status).toBe(500)
144+
expect(await (await thirdCall.value).status).toBe(500)
145+
expect(await (await fourthCall.value).status).toBe(200)
146+
})
147+
148+
it('retries up to the retry limit', async () => {
149+
mocked(getIdentity).mockImplementationOnce(mockServerError)
150+
mocked(getIdentity).mockImplementationOnce(mockServerError)
151+
mocked(getIdentity).mockImplementationOnce(mockServerError)
152+
mocked(getIdentity).mockImplementationOnce(mockServerError)
153+
mocked(getIdentity).mockImplementationOnce(mockServerError)
154+
155+
// this call will never happen since the retry logic only allows five retries
156+
mocked(getIdentity).mockImplementationOnce(mockServerError)
157+
158+
try {
159+
await retryFetchIdentity(1, 1)
160+
} catch (error) {
161+
expect(error).toStrictEqual(new ServerError(mockUnityServerError.message))
162+
} finally {
163+
const [firstCall, secondCall, thirdCall, fourthCall, fifthCall] = mocked(
164+
getIdentity
165+
).mock.results
166+
167+
// only 5 getIdentity calls happened, even though the implementation was mocked 6 times
168+
expect(mocked(getIdentity).mock.calls.length).toBe(5)
169+
expect(await (await firstCall.value).status).toBe(500)
170+
expect(await (await secondCall.value).status).toBe(500)
171+
expect(await (await thirdCall.value).status).toBe(500)
172+
expect(await (await fourthCall.value).status).toBe(500)
173+
expect(await (await fifthCall.value).status).toBe(500)
174+
}
175+
})
176+
177+
it('retries until a non 500 error is encountered', async () => {
178+
mocked(getIdentity).mockImplementationOnce(mockServerError)
179+
mocked(getIdentity).mockImplementationOnce(mockServerError)
180+
mocked(getIdentity).mockImplementationOnce(mockUnauthorizedError)
181+
182+
try {
183+
await retryFetchIdentity(1, 1)
184+
} catch (error) {
185+
expect(error).toStrictEqual(new UnauthorizedError(mockError401.message))
186+
} finally {
187+
const [firstCall, secondCall, thirdCall] = mocked(
188+
getIdentity
189+
).mock.results
190+
191+
expect(await (await firstCall.value).status).toBe(500)
192+
expect(await (await secondCall.value).status).toBe(500)
193+
expect(await (await thirdCall.value).status).toBe(401)
194+
}
195+
})
196+
197+
describe('the timing of auth retrying', () => {
198+
beforeEach(() => {
199+
jest.useFakeTimers()
200+
jest.spyOn(global, 'setTimeout')
201+
})
202+
203+
afterEach(() => {
204+
jest.useRealTimers()
205+
})
206+
207+
it('retries once after the default of 30000 milliseconds', async () => {
208+
mocked(getIdentity).mockImplementationOnce(mockServerError)
209+
210+
mocked(getIdentity).mockImplementationOnce(mockGoodResponse)
211+
212+
const retryResponse = retryFetchIdentity()
213+
214+
await flushPromises()
215+
jest.runAllTimers()
216+
217+
expect(await retryResponse).toEqual(mockIdentity)
218+
219+
expect(setTimeout).toHaveBeenCalledTimes(1)
220+
expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 30000)
221+
})
222+
223+
it('retries multiple times, waiting longer each time', async () => {
224+
mocked(getIdentity).mockImplementationOnce(mockServerError)
225+
mocked(getIdentity).mockImplementationOnce(mockServerError)
226+
mocked(getIdentity).mockImplementationOnce(mockServerError)
227+
228+
mocked(getIdentity).mockImplementationOnce(mockGoodResponse)
229+
230+
const retryResponse = retryFetchIdentity()
231+
232+
await flushPromises()
233+
jest.runAllTimers()
234+
await flushPromises()
235+
jest.runAllTimers()
236+
await flushPromises()
237+
jest.runAllTimers()
238+
239+
expect(await retryResponse).toEqual(mockIdentity)
240+
241+
expect(setTimeout).toHaveBeenNthCalledWith(1, expect.any(Function), 30000)
242+
expect(setTimeout).toHaveBeenNthCalledWith(2, expect.any(Function), 60000)
243+
expect(setTimeout).toHaveBeenNthCalledWith(3, expect.any(Function), 90000)
244+
})
245+
246+
it('retries multiple times, waiting longer each time, until it fails', async () => {
247+
mocked(getIdentity).mockImplementationOnce(mockServerError)
248+
mocked(getIdentity).mockImplementationOnce(mockServerError)
249+
mocked(getIdentity).mockImplementationOnce(mockServerError)
250+
mocked(getIdentity).mockImplementationOnce(mockServerError)
251+
mocked(getIdentity).mockImplementationOnce(mockServerError)
252+
253+
try {
254+
const retryResponse = retryFetchIdentity()
255+
256+
await flushPromises()
257+
jest.runAllTimers()
258+
await flushPromises()
259+
jest.runAllTimers()
260+
await flushPromises()
261+
jest.runAllTimers()
262+
await flushPromises()
263+
jest.runAllTimers()
264+
265+
await retryResponse
266+
} catch (error) {
267+
expect(error).toStrictEqual(
268+
new ServerError(mockUnityServerError.message)
269+
)
270+
}
271+
expect(setTimeout).toHaveBeenNthCalledWith(1, expect.any(Function), 30000)
272+
expect(setTimeout).toHaveBeenNthCalledWith(2, expect.any(Function), 60000)
273+
expect(setTimeout).toHaveBeenNthCalledWith(3, expect.any(Function), 90000)
274+
expect(setTimeout).toHaveBeenNthCalledWith(
275+
4,
276+
expect.any(Function),
277+
120000
278+
)
279+
})
280+
})
281+
})

src/identity/apis/auth.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ export interface CurrentIdentity {
6565
status?: RemoteDataState
6666
}
6767

68+
export enum NetworkErrorTypes {
69+
UnauthorizedError = 'UnauthorizedError',
70+
NotFoundError = 'NotFoundError',
71+
ServerError = 'ServerError',
72+
GenericError = 'GenericError',
73+
}
74+
6875
// 401 error
6976
export class UnauthorizedError extends Error {
7077
constructor(message) {
@@ -109,6 +116,42 @@ export const fetchIdentity = async () => {
109116
return fetchQuartzMe()
110117
}
111118

119+
const identityRetryDelay = 30000 // 30 seconds
120+
const retryLimit = 5
121+
122+
export const retryFetchIdentity = async (
123+
retryAttempts = 1,
124+
retryDelay = identityRetryDelay
125+
) => {
126+
try {
127+
return await fetchIdentity()
128+
} catch (error) {
129+
if (
130+
error.name === NetworkErrorTypes.UnauthorizedError ||
131+
error.name === NetworkErrorTypes.GenericError
132+
) {
133+
throw error
134+
}
135+
136+
if (error.name === NetworkErrorTypes.ServerError) {
137+
if (retryAttempts >= retryLimit) {
138+
throw error
139+
}
140+
return new Promise((resolve, reject) => {
141+
setTimeout(() => {
142+
retryFetchIdentity(retryAttempts + 1, retryDelay)
143+
.then(user => {
144+
resolve(user)
145+
})
146+
.catch(error => {
147+
reject(error)
148+
})
149+
}, retryAttempts * retryDelay)
150+
})
151+
}
152+
}
153+
}
154+
112155
// fetch user identity from /quartz/identity.
113156
export const fetchQuartzIdentity = async (): Promise<Identity> => {
114157
const response = await getIdentity({})

0 commit comments

Comments
 (0)