feat(ringcentral): add WebSocket auto-reconnect with exponential backoff

- Add automatic reconnection on subscription errors
- Use exponential backoff (1s to 60s) with max 10 attempts
- Handle subscribeError, renewError, and automaticRenewError events
- Reset reconnect attempts on successful connection
- Clean up properly on shutdown
This commit is contained in:
John Lin 2026-01-28 23:07:18 +08:00
parent eab688e366
commit c84cd60b6d
No known key found for this signature in database

View File

@ -30,6 +30,11 @@ export type RingCentralRuntimeEnv = {
const recentlySentMessageIds = new Set<string>(); const recentlySentMessageIds = new Set<string>();
const MESSAGE_ID_TTL = 60000; // 60 seconds const MESSAGE_ID_TTL = 60000; // 60 seconds
// Reconnection settings
const RECONNECT_INITIAL_DELAY = 1000; // 1 second
const RECONNECT_MAX_DELAY = 60000; // 60 seconds
const RECONNECT_MAX_ATTEMPTS = 10;
function trackSentMessageId(messageId: string): void { function trackSentMessageId(messageId: string): void {
recentlySentMessageIds.add(messageId); recentlySentMessageIds.add(messageId);
setTimeout(() => recentlySentMessageIds.delete(messageId), MESSAGE_ID_TTL); setTimeout(() => recentlySentMessageIds.delete(messageId), MESSAGE_ID_TTL);
@ -651,17 +656,37 @@ export async function startRingCentralMonitor(
const { account, config, runtime, abortSignal, statusSink } = options; const { account, config, runtime, abortSignal, statusSink } = options;
const core = getRingCentralRuntime(); const core = getRingCentralRuntime();
let subscription: ReturnType<InstanceType<typeof Subscriptions>["createSubscription"]> | null = null;
let reconnectAttempts = 0;
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
let isShuttingDown = false;
let ownerId: string | undefined;
// Calculate delay with exponential backoff
const getReconnectDelay = () => {
const delay = Math.min(
RECONNECT_INITIAL_DELAY * Math.pow(2, reconnectAttempts),
RECONNECT_MAX_DELAY
);
return delay;
};
// Create and setup subscription
const createSubscription = async (): Promise<void> => {
if (isShuttingDown || abortSignal.aborted) return;
runtime.log?.(`[${account.accountId}] Starting RingCentral WebSocket subscription...`); runtime.log?.(`[${account.accountId}] Starting RingCentral WebSocket subscription...`);
try {
// Get SDK instance // Get SDK instance
const sdk = await getRingCentralSDK(account); const sdk = await getRingCentralSDK(account);
// Create subscriptions manager // Create subscriptions manager
const subscriptions = new Subscriptions({ sdk }); const subscriptions = new Subscriptions({ sdk });
const subscription = subscriptions.createSubscription(); subscription = subscriptions.createSubscription();
// Track current user ID to filter out self messages // Track current user ID to filter out self messages
let ownerId: string | undefined; if (!ownerId) {
try { try {
const platform = sdk.platform(); const platform = sdk.platform();
const response = await platform.get("/restapi/v1.0/account/~/extension/~"); const response = await platform.get("/restapi/v1.0/account/~/extension/~");
@ -671,6 +696,7 @@ export async function startRingCentralMonitor(
} catch (err) { } catch (err) {
runtime.error?.(`[${account.accountId}] Failed to get current user: ${String(err)}`); runtime.error?.(`[${account.accountId}] Failed to get current user: ${String(err)}`);
} }
}
// Handle notifications // Handle notifications
subscription.on(subscription.events.notification, (event: unknown) => { subscription.on(subscription.events.notification, (event: unknown) => {
@ -692,10 +718,12 @@ export async function startRingCentralMonitor(
// Handle subscription status changes // Handle subscription status changes
subscription.on(subscription.events.subscribeSuccess, () => { subscription.on(subscription.events.subscribeSuccess, () => {
runtime.log?.(`[${account.accountId}] WebSocket subscription active`); runtime.log?.(`[${account.accountId}] WebSocket subscription active`);
reconnectAttempts = 0; // Reset attempts on successful connection
}); });
subscription.on(subscription.events.subscribeError, (err: unknown) => { subscription.on(subscription.events.subscribeError, (err: unknown) => {
runtime.error?.(`[${account.accountId}] WebSocket subscription error: ${String(err)}`); runtime.error?.(`[${account.accountId}] WebSocket subscription error: ${String(err)}`);
scheduleReconnect();
}); });
subscription.on(subscription.events.renewSuccess, () => { subscription.on(subscription.events.renewSuccess, () => {
@ -704,12 +732,16 @@ export async function startRingCentralMonitor(
subscription.on(subscription.events.renewError, (err: unknown) => { subscription.on(subscription.events.renewError, (err: unknown) => {
runtime.error?.(`[${account.accountId}] WebSocket subscription renew error: ${String(err)}`); runtime.error?.(`[${account.accountId}] WebSocket subscription renew error: ${String(err)}`);
scheduleReconnect();
});
// Handle automatic renew errors (connection lost)
subscription.on(subscription.events.automaticRenewError, (err: unknown) => {
runtime.error?.(`[${account.accountId}] WebSocket automatic renew failed: ${String(err)}`);
scheduleReconnect();
}); });
// Subscribe to Team Messaging events // Subscribe to Team Messaging events
// - /restapi/v1.0/glip/posts: New posts in chats
// - /restapi/v1.0/glip/groups: Chat/group changes
try {
await subscription await subscription
.setEventFilters([ .setEventFilters([
"/restapi/v1.0/glip/posts", "/restapi/v1.0/glip/posts",
@ -718,17 +750,59 @@ export async function startRingCentralMonitor(
.register(); .register();
runtime.log?.(`[${account.accountId}] RingCentral WebSocket subscription established`); runtime.log?.(`[${account.accountId}] RingCentral WebSocket subscription established`);
reconnectAttempts = 0; // Reset on success
} catch (err) { } catch (err) {
runtime.error?.(`[${account.accountId}] Failed to create WebSocket subscription: ${String(err)}`); runtime.error?.(`[${account.accountId}] Failed to create WebSocket subscription: ${String(err)}`);
throw err; scheduleReconnect();
} }
};
// Schedule reconnection with exponential backoff
const scheduleReconnect = () => {
if (isShuttingDown || abortSignal.aborted) return;
if (reconnectAttempts >= RECONNECT_MAX_ATTEMPTS) {
runtime.error?.(`[${account.accountId}] Max reconnection attempts (${RECONNECT_MAX_ATTEMPTS}) reached. Giving up.`);
return;
}
const delay = getReconnectDelay();
reconnectAttempts++;
runtime.log?.(`[${account.accountId}] Scheduling reconnection attempt ${reconnectAttempts}/${RECONNECT_MAX_ATTEMPTS} in ${delay}ms...`);
// Clean up existing subscription
if (subscription) {
subscription.reset().catch(() => {});
subscription = null;
}
reconnectTimeout = setTimeout(() => {
reconnectTimeout = null;
createSubscription().catch((err) => {
runtime.error?.(`[${account.accountId}] Reconnection failed: ${String(err)}`);
});
}, delay);
};
// Initial connection
await createSubscription();
// Handle abort signal // Handle abort signal
const cleanup = () => { const cleanup = () => {
isShuttingDown = true;
runtime.log?.(`[${account.accountId}] Stopping RingCentral WebSocket subscription...`); runtime.log?.(`[${account.accountId}] Stopping RingCentral WebSocket subscription...`);
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
if (subscription) {
subscription.reset().catch((err) => { subscription.reset().catch((err) => {
runtime.error?.(`[${account.accountId}] Failed to reset subscription: ${String(err)}`); runtime.error?.(`[${account.accountId}] Failed to reset subscription: ${String(err)}`);
}); });
subscription = null;
}
}; };
if (abortSignal.aborted) { if (abortSignal.aborted) {