-
Notifications
You must be signed in to change notification settings - Fork 386
fix(calling): update call keepalive retry logic #4557
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
Changes from all commits
84540e7
dfd63d1
c8a7837
072c1fc
ceae37c
e916248
177ac5c
5b99655
d004169
69c271e
b87b34b
05435f0
16df087
6816c90
ebe16e4
5fb140f
e044e2d
ff57334
9280ace
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -977,6 +977,34 @@ describe('State Machine handler tests', () => { | |
| ); | ||
| }); | ||
|
|
||
| it('session refresh 401 emits token error and ends the call', async () => { | ||
| expect.assertions(4); | ||
| const statusPayload = <WebexRequestPayload>(<unknown>{ | ||
| statusCode: 401, | ||
| }); | ||
|
|
||
| webex.request.mockReturnValue(statusPayload); | ||
| jest.spyOn(global, 'clearInterval'); | ||
|
|
||
| const emitSpy = jest.spyOn(call, 'emit'); | ||
|
|
||
| call.on(CALL_EVENT_KEYS.CALL_ERROR, (errObj) => { | ||
| expect(errObj.type).toStrictEqual(ERROR_TYPE.TOKEN_ERROR); | ||
| }); | ||
|
|
||
| const funcSpy = jest.spyOn(call, 'postStatus').mockRejectedValue(statusPayload); | ||
|
|
||
| call['handleCallEstablished']({} as CallEvent); | ||
|
|
||
| jest.advanceTimersByTime(DEFAULT_SESSION_TIMER); | ||
| await Promise.resolve(); | ||
| await Promise.resolve(); | ||
Kesari3008 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| expect(clearInterval).toHaveBeenCalledTimes(1); | ||
| expect(funcSpy).toBeCalledTimes(1); | ||
| expect(emitSpy).toHaveBeenCalledWith(CALL_EVENT_KEYS.DISCONNECT, call.getCorrelationId()); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we should add expect to check for end API being invoked, media connection getting closed and call getting cleared from callCollection
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wouldn't that make it an integration test? There would be a separate unit test for that already present |
||
| }); | ||
|
|
||
| it('session refresh failure', async () => { | ||
| expect.assertions(4); | ||
| const statusPayload = <WebexRequestPayload>(<unknown>{ | ||
|
|
@@ -1015,6 +1043,86 @@ describe('State Machine handler tests', () => { | |
| expect(funcSpy).toBeCalledTimes(1); | ||
| }); | ||
|
|
||
| it('session refresh 500 schedules retry via retry-after or default interval', async () => { | ||
| const errorPayload = <WebexRequestPayload>(<unknown>{ | ||
| statusCode: 500, | ||
| headers: { | ||
| 'retry-after': 1, | ||
| }, | ||
| }); | ||
|
|
||
| const okPayload = <WebexRequestPayload>(<unknown>{statusCode: 200, body: {}}); | ||
|
|
||
| const sendEvtSpy = jest.spyOn(call as any, 'sendCallStateMachineEvt'); | ||
| const postStatusSpy = jest | ||
| .spyOn(call as any, 'postStatus') | ||
| .mockRejectedValueOnce(errorPayload) | ||
| .mockResolvedValueOnce(okPayload); | ||
|
|
||
| if (call['sessionTimer'] === undefined) { | ||
| call['handleCallEstablished']({} as CallEvent); | ||
| } | ||
|
|
||
| jest.advanceTimersByTime(DEFAULT_SESSION_TIMER); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we should only advance here as per retyr after and not default session timer right
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. First time invocation of postStatus will only happen after the |
||
| await Promise.resolve(); | ||
| await Promise.resolve(); | ||
|
|
||
| expect(postStatusSpy).toHaveBeenCalledTimes(1); | ||
| expect(sendEvtSpy).toHaveBeenCalledWith({type: 'E_CALL_ESTABLISHED'}); | ||
| }); | ||
|
|
||
| it('keepalive ends after reaching max retry count', async () => { | ||
| const resolvePromise = async () => { | ||
| await Promise.resolve(); | ||
| await Promise.resolve(); | ||
| }; | ||
|
|
||
| const errorPayload = <WebexRequestPayload>(<unknown>{ | ||
| statusCode: 500, | ||
| headers: { | ||
| 'retry-after': 1, | ||
| }, | ||
| }); | ||
|
|
||
| jest.spyOn(global, 'clearInterval'); | ||
| const warnSpy = jest.spyOn(log, 'warn'); | ||
| const postStatusSpy = jest.spyOn(call, 'postStatus').mockRejectedValue(errorPayload); | ||
|
|
||
| // Put the call in the S_CALL_ESTABLISHED state and set it as connected | ||
| call['callStateMachine'].state.value = 'S_CALL_ESTABLISHED'; | ||
| call['connected'] = true; | ||
|
|
||
| // Call handleCallEstablished which will setup interval | ||
| call['handleCallEstablished']({} as CallEvent); | ||
|
|
||
| // Advance timer to trigger the first failure (uses DEFAULT_SESSION_TIMER) | ||
| jest.advanceTimersByTime(DEFAULT_SESSION_TIMER); | ||
| await resolvePromise(); | ||
|
|
||
| // Now advance by 1 second for each of the 3 more retry attempts (retry-after: 1 second each) | ||
| // Need to do this separately to allow state machine to process and create new intervals | ||
| jest.advanceTimersByTime(1000); | ||
| await resolvePromise(); | ||
|
|
||
| jest.advanceTimersByTime(1000); | ||
| await resolvePromise(); | ||
|
|
||
| jest.advanceTimersByTime(1000); | ||
| await resolvePromise(); | ||
|
|
||
| // The error handler should detect we're at max retry count and stop | ||
| expect(warnSpy).toHaveBeenCalledWith( | ||
| `Max call keepalive retry attempts reached for call: ${call.getCorrelationId()}`, | ||
| { | ||
| file: 'call', | ||
| method: 'handleCallEstablished', | ||
| } | ||
| ); | ||
| expect(postStatusSpy).toHaveBeenCalledTimes(4); | ||
| expect(call['callKeepaliveRetryCount']).toBe(0); | ||
| expect(call['sessionTimer']).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('state changes during successful incoming call', async () => { | ||
| const statusPayload = <WebexRequestPayload>(<unknown>{ | ||
| statusCode: 200, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -45,6 +45,7 @@ import { | |
| HOLD_ENDPOINT, | ||
| ICE_CANDIDATES_TIMEOUT, | ||
| INITIAL_SEQ_NUMBER, | ||
| MAX_CALL_KEEPALIVE_RETRY_COUNT, | ||
| MEDIA_ENDPOINT_RESOURCE, | ||
| METHODS, | ||
| NOISE_REDUCTION_EFFECT, | ||
|
|
@@ -169,6 +170,10 @@ export class Call extends Eventing<CallEventTypes> implements ICall { | |
|
|
||
| private rtcMetrics: RtcMetrics; | ||
|
|
||
| private callKeepaliveRetryCount = 0; | ||
|
|
||
| private callKeepaliveInterval?: number; | ||
|
|
||
| /** | ||
| * Getter to check if the call is muted or not. | ||
| * | ||
|
|
@@ -1501,10 +1506,12 @@ export class Call extends Eventing<CallEventTypes> implements ICall { | |
| */ | ||
| // eslint-disable-next-line @typescript-eslint/no-unused-vars | ||
| private handleCallEstablished(event: CallEvent) { | ||
| log.info(`${METHOD_START_MESSAGE} with: ${this.getCorrelationId()}`, { | ||
| const loggerContext = { | ||
| file: CALL_FILE, | ||
| method: METHODS.HANDLE_CALL_ESTABLISHED, | ||
| }); | ||
| }; | ||
|
|
||
| log.info(`${METHOD_START_MESSAGE} with: ${this.getCorrelationId()}`, loggerContext); | ||
|
|
||
| this.emit(CALL_EVENT_KEYS.ESTABLISHED, this.correlationId); | ||
|
|
||
|
|
@@ -1515,22 +1522,19 @@ export class Call extends Eventing<CallEventTypes> implements ICall { | |
|
|
||
| /* Session timers need to be reset at all offer/answer exchanges */ | ||
| if (this.sessionTimer) { | ||
| log.log('Resetting session timer', { | ||
| file: CALL_FILE, | ||
| method: METHODS.HANDLE_CALL_ESTABLISHED, | ||
| }); | ||
| log.log('Resetting session timer', loggerContext); | ||
|
|
||
| clearInterval(this.sessionTimer); | ||
| } | ||
|
|
||
| this.sessionTimer = setInterval(async () => { | ||
| try { | ||
| // eslint-disable-next-line @typescript-eslint/no-unused-vars | ||
| const res = await this.postStatus(); | ||
| this.callKeepaliveRetryCount = 0; | ||
| this.callKeepaliveInterval = undefined; | ||
|
|
||
| log.info(`Session refresh successful`, { | ||
| file: CALL_FILE, | ||
| method: METHODS.HANDLE_CALL_ESTABLISHED, | ||
| }); | ||
| log.info(`Session refresh successful`, loggerContext); | ||
| } catch (err: unknown) { | ||
| const error = <WebexRequestPayload>err; | ||
|
|
||
|
|
@@ -1544,34 +1548,54 @@ export class Call extends Eventing<CallEventTypes> implements ICall { | |
| clearInterval(this.sessionTimer); | ||
| } | ||
|
|
||
| handleCallErrors( | ||
| const abort = await handleCallErrors( | ||
| (callError: CallError) => { | ||
| this.emit(CALL_EVENT_KEYS.CALL_ERROR, callError); | ||
| this.submitCallErrorMetric(callError); | ||
| }, | ||
| ERROR_LAYER.CALL_CONTROL, | ||
| (interval: number) => { | ||
| setTimeout(() => { | ||
| /* We first post the status and then recursively call the handler which | ||
| * starts the timer again | ||
| */ | ||
| this.postStatus(); | ||
| this.sendCallStateMachineEvt({type: 'E_CALL_ESTABLISHED'}); | ||
| }, interval * 1000); | ||
|
Comment on lines
-1554
to
-1560
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This Now we are only calling |
||
| this.callKeepaliveRetryCount += 1; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We didn't need to remove the setTimeout. Retry logic was implemented in a way that if it fails, we were immediately trying one more time and schedule the timeout to try it again after specific interval. Even if we wanted to avoid the postStatus being sent twice, we should just remove postStatus and keep setting the state machine to established with a interval timeout to trigger the keepalive retry
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This logic is already there. I have removed the unnecessary setTimeout and postStatus, instead I am directly invoking the logic with the state machine. Please check the new logic. If required we can discuss over a call
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. With this logic with the first keepalive retry itself, count will start increasing
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is only being increased in case of error. |
||
| this.callKeepaliveInterval = interval * 1000; | ||
|
|
||
| // If we have reached the max retry count, do not attempt to refresh the session | ||
| if (this.callKeepaliveRetryCount === MAX_CALL_KEEPALIVE_RETRY_COUNT) { | ||
| this.callKeepaliveRetryCount = 0; | ||
| clearInterval(this.sessionTimer); | ||
| this.sessionTimer = undefined; | ||
| this.callKeepaliveInterval = undefined; | ||
|
|
||
| log.warn( | ||
| `Max call keepalive retry attempts reached for call: ${this.getCorrelationId()}`, | ||
| loggerContext | ||
| ); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| // Scheduling next keepalive attempt - calling handleCallEstablished | ||
| this.sendCallStateMachineEvt({type: 'E_CALL_ESTABLISHED'}); | ||
| }, | ||
| this.getCorrelationId(), | ||
| error, | ||
| this.handleCallEstablished.name, | ||
| 'handleCallEstablished', | ||
| CALL_FILE | ||
| ); | ||
|
|
||
| if (abort) { | ||
| this.sendCallStateMachineEvt({type: 'E_SEND_CALL_DISCONNECT'}); | ||
| this.emit(CALL_EVENT_KEYS.DISCONNECT, this.getCorrelationId()); | ||
| this.callKeepaliveRetryCount = 0; | ||
| this.callKeepaliveInterval = undefined; | ||
| } | ||
|
|
||
| await uploadLogs({ | ||
| correlationId: this.correlationId, | ||
| callId: this.callId, | ||
| broadworksCorrelationInfo: this.broadworksCorrelationInfo, | ||
| }); | ||
| } | ||
| }, DEFAULT_SESSION_TIMER); | ||
| }, this.callKeepaliveInterval || DEFAULT_SESSION_TIMER); | ||
| } | ||
|
|
||
| /** | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.