Skip to content

Commit dc84f25

Browse files
authored
chore: limit retries for guide client subscribe (#697)
1 parent 57bad0b commit dc84f25

File tree

3 files changed

+161
-2
lines changed

3 files changed

+161
-2
lines changed

.changeset/twenty-llamas-tickle.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@knocklabs/client": patch
3+
---
4+
5+
chore: limit retries for guide client real time subscription

packages/client/src/clients/guide/client.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ const DEFAULT_ORDER_RESOLUTION_DURATION = 50; // in milliseconds
4949
// trigger subscribed callbacks.
5050
const DEFAULT_COUNTER_INCREMENT_INTERVAL = 30 * 1000; // in milliseconds
5151

52+
// Maximum number of retry attempts for channel subscription
53+
const SUBSCRIBE_RETRY_LIMIT = 3;
54+
5255
// Return the global window object if defined, so to safely guard against SSR.
5356
const checkForWindow = () => {
5457
if (typeof window !== "undefined") {
@@ -154,6 +157,7 @@ export class KnockGuideClient {
154157
"guide_group.added",
155158
"guide_group.updated",
156159
];
160+
private subscribeRetryCount = 0;
157161

158162
// Original history methods to monkey patch, or restore in cleanups.
159163
private pushStateFn: History["pushState"] | undefined;
@@ -306,13 +310,44 @@ export class KnockGuideClient {
306310
}
307311

308312
if (["closed", "errored"].includes(newChannel.state)) {
309-
newChannel.join();
313+
// Reset retry count for new subscription attempt
314+
this.subscribeRetryCount = 0;
315+
316+
newChannel
317+
.join()
318+
.receive("ok", () => {
319+
this.knock.log("[Guide] Successfully joined channel");
320+
})
321+
.receive("error", (resp) => {
322+
this.knock.log(
323+
`[Guide] Failed to join channel: ${JSON.stringify(resp)}`,
324+
);
325+
this.handleChannelJoinError();
326+
})
327+
.receive("timeout", () => {
328+
this.knock.log("[Guide] Channel join timed out");
329+
this.handleChannelJoinError();
330+
});
310331
}
311332

312333
// Track the joined channel.
313334
this.socketChannel = newChannel;
314335
}
315336

337+
private handleChannelJoinError() {
338+
// Prevent phx channel from retrying forever in case of either network or
339+
// other errors (e.g. auth error, invalid channel etc)
340+
if (this.subscribeRetryCount >= SUBSCRIBE_RETRY_LIMIT) {
341+
this.knock.log(
342+
`[Guide] Channel join max retry limit reached: ${this.subscribeRetryCount}`,
343+
);
344+
this.unsubscribe();
345+
return;
346+
}
347+
348+
this.subscribeRetryCount++;
349+
}
350+
316351
unsubscribe() {
317352
if (!this.socketChannel) return;
318353
this.knock.log("[Guide] Unsubscribing from real time updates");

packages/client/test/clients/guide/guide.test.ts

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,11 @@ describe("KnockGuideClient", () => {
267267
test("subscribes to socket events when socket is available", () => {
268268
const mockChannel = {
269269
join: vi.fn().mockReturnValue({
270-
receive: vi.fn().mockReturnValue({ receive: vi.fn() }),
270+
receive: vi.fn().mockReturnValue({
271+
receive: vi.fn().mockReturnValue({
272+
receive: vi.fn()
273+
})
274+
}),
271275
}),
272276
on: vi.fn(),
273277
off: vi.fn(),
@@ -303,6 +307,121 @@ describe("KnockGuideClient", () => {
303307
expect(mockChannel.join).toHaveBeenCalled();
304308
});
305309

310+
test("handles successful channel join", () => {
311+
let okCallback: () => void;
312+
const mockChannel = {
313+
join: vi.fn().mockReturnValue({
314+
receive: vi.fn((event, callback) => {
315+
if (event === "ok") {
316+
okCallback = callback;
317+
}
318+
return {
319+
receive: vi.fn().mockReturnValue({ receive: vi.fn() })
320+
};
321+
}),
322+
}),
323+
on: vi.fn(),
324+
off: vi.fn(),
325+
leave: vi.fn(),
326+
state: "closed",
327+
};
328+
329+
const mockSocket = {
330+
channel: vi.fn().mockReturnValue(mockChannel),
331+
isConnected: vi.fn().mockReturnValue(true),
332+
connect: vi.fn(),
333+
};
334+
335+
mockApiClient.socket = mockSocket as unknown as Socket;
336+
vi.mocked(mockKnock.client).mockReturnValue(mockApiClient as ApiClient);
337+
338+
const client = new KnockGuideClient(
339+
mockKnock,
340+
channelId,
341+
defaultTargetParams,
342+
);
343+
client.subscribe();
344+
345+
// Trigger the ok callback
346+
okCallback!();
347+
348+
expect(mockKnock.log).toHaveBeenCalledWith("[Guide] Successfully joined channel");
349+
});
350+
351+
test("unsubscribes after reaching max retry limit", () => {
352+
let errorCallback: (resp: { reason: string }) => void;
353+
const mockChannel = {
354+
join: vi.fn().mockReturnValue({
355+
receive: vi.fn((event, callback) => {
356+
if (event === "error") {
357+
errorCallback = callback;
358+
}
359+
return {
360+
receive: vi.fn((event, callback) => {
361+
if (event === "error") {
362+
errorCallback = callback;
363+
}
364+
return {
365+
receive: vi.fn((event, callback) => {
366+
if (event === "error") {
367+
errorCallback = callback;
368+
}
369+
return { receive: vi.fn() };
370+
})
371+
};
372+
})
373+
};
374+
}),
375+
}),
376+
on: vi.fn(),
377+
off: vi.fn(),
378+
leave: vi.fn(),
379+
state: "closed",
380+
};
381+
382+
const mockSocket = {
383+
channel: vi.fn().mockReturnValue(mockChannel),
384+
isConnected: vi.fn().mockReturnValue(true),
385+
connect: vi.fn(),
386+
};
387+
388+
mockApiClient.socket = mockSocket as unknown as Socket;
389+
vi.mocked(mockKnock.client).mockReturnValue(mockApiClient as ApiClient);
390+
391+
const client = new KnockGuideClient(
392+
mockKnock,
393+
channelId,
394+
defaultTargetParams,
395+
);
396+
397+
const unsubscribeSpy = vi.spyOn(client, "unsubscribe");
398+
399+
client.subscribe();
400+
401+
// Initial fail. The retry count starts at 0 and is incremented on each
402+
// error to represent the next retry.
403+
errorCallback!({ reason: "auth_error" });
404+
405+
// First retry fail
406+
expect(client["subscribeRetryCount"]).toBe(1);
407+
errorCallback!({ reason: "auth_error" });
408+
409+
// Second retry fail
410+
expect(client["subscribeRetryCount"]).toBe(2);
411+
errorCallback!({ reason: "auth_error" });
412+
413+
// Third retry fail
414+
expect(client["subscribeRetryCount"]).toBe(3);
415+
errorCallback!({ reason: "auth_error" });
416+
417+
// Check that the max retry limit message was logged
418+
expect(mockKnock.log).toHaveBeenCalledWith(
419+
"[Guide] Channel join max retry limit reached: 3"
420+
);
421+
422+
expect(unsubscribeSpy).toHaveBeenCalled();
423+
});
424+
306425
test("unsubscribes from socket events", () => {
307426
const mockChannel = {
308427
join: vi.fn(),

0 commit comments

Comments
 (0)