Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
84540e7
fix(calling): fix the ExtendedError usage in logs and UTs
rarajes2 Oct 17, 2025
dfd63d1
fix(calling): improve the error logs and fix the UTs
rarajes2 Oct 17, 2025
c8a7837
fix(calling): address review comments
rarajes2 Oct 23, 2025
072c1fc
fix(calling): warn log error messages in register file
rarajes2 Oct 23, 2025
ceae37c
fix(calling): update call keepalive retry logic
rarajes2 Oct 24, 2025
e916248
Merge branch 'next' into keepalive-retries-CAI-7258
rarajes2 Oct 31, 2025
177ac5c
fix(calling): retry callback param fix
rarajes2 Nov 4, 2025
5b99655
Merge branch 'next' into keepalive-retries-CAI-7258
rarajes2 Nov 4, 2025
d004169
fix(calling): add unit tests for call keepalive error handling scenarios
rarajes2 Nov 5, 2025
69c271e
fix(calling): add 30 sec of default timer for failing 5xx cases
rarajes2 Nov 5, 2025
b87b34b
fix(calling): address review comments
rarajes2 Nov 10, 2025
05435f0
fix(calling): fix failing unit test
rarajes2 Nov 12, 2025
16df087
Merge branch 'next' into keepalive-retries-CAI-7258
rarajes2 Nov 12, 2025
6816c90
fix(calling): add abort to the the handleCallError
rarajes2 Nov 12, 2025
ebe16e4
fix(calling): fix the keepalive retry counter logic
rarajes2 Nov 18, 2025
5fb140f
Merge branch 'next' into keepalive-retries-CAI-7258
rarajes2 Nov 18, 2025
e044e2d
fix(calling): reset the call keepalive counter after successful response
rarajes2 Nov 18, 2025
ff57334
fix(calling): improve unit tests. submit logs for final error too
rarajes2 Nov 18, 2025
9280ace
Merge branch 'next' into keepalive-retries-CAI-7258
rarajes2 Nov 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions packages/calling/src/CallingClient/calling/call.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

expect(clearInterval).toHaveBeenCalledTimes(1);
expect(funcSpy).toBeCalledTimes(1);
expect(emitSpy).toHaveBeenCalledWith(CALL_EVENT_KEYS.DISCONNECT, call.getCorrelationId());
Copy link
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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>{
Expand Down Expand Up @@ -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);
Copy link
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First time invocation of postStatus will only happen after the DEFAULT_SESSION_TIMER

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,
Expand Down
64 changes: 44 additions & 20 deletions packages/calling/src/CallingClient/calling/call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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);

Expand All @@ -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;

Expand All @@ -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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This setTimeout is not required as it was causing the this.postStatus() call to be made twice - once here and next on invoking the handleCallEstablished through state machine.

Now we are only calling handleCallEstablished through state machine immediately as the handleCallEstablished makes the keepalive call inside the setInterval which adds the required delay and keeps the counter consistent.

this.callKeepaliveRetryCount += 1;
Copy link
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

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

Copy link
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Contributor Author

@rarajes2 rarajes2 Nov 18, 2025

Choose a reason for hiding this comment

The 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);
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/calling/src/CallingClient/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const DEFAULT_LOCAL_CALL_ID = 'DefaultLocalId';
export const DEFAULT_REHOMING_INTERVAL_MAX = 120;
export const DEFAULT_REHOMING_INTERVAL_MIN = 60;
export const DEFAULT_SESSION_TIMER = 1000 * 60 * 10;
export const MAX_CALL_KEEPALIVE_RETRY_COUNT = 4;
export const DEVICES_ENDPOINT_RESOURCE = 'devices';
export const DISCOVERY_URL = 'https://ds.ciscospark.com/v1/region';
export const DUMMY_METRICS = {
Expand Down
Loading
Loading