From e7ac3d4f7389f770ad0edef4014b02063cdf4d1d Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Wed, 29 Jul 2026 08:05:13 -0500 Subject: [PATCH 1/3] fix(KNO-14505): handle guide engagement failures --- .changeset/handle-guide-engagement-errors.md | 8 ++ packages/client/src/clients/guide/client.ts | 41 +++--- packages/client/src/clients/users/index.ts | 3 +- .../client/test/clients/guide/guide.test.ts | 119 ++++++++++++++++++ .../client/test/clients/users/users.test.ts | 38 ++++++ 5 files changed, 190 insertions(+), 19 deletions(-) create mode 100644 .changeset/handle-guide-engagement-errors.md diff --git a/.changeset/handle-guide-engagement-errors.md b/.changeset/handle-guide-engagement-errors.md new file mode 100644 index 000000000..a80e11e5f --- /dev/null +++ b/.changeset/handle-guide-engagement-errors.md @@ -0,0 +1,8 @@ +--- +"@knocklabs/client": patch +"@knocklabs/react": patch +--- + +fix(KNO-14505): prevent guide engagement API failures from becoming unhandled promise rejections + +Guide seen, interacted, and archived events continue to update local state optimistically while failed background requests are caught and logged. API request errors now retain their original identity, HTTP status, and response data for diagnostics. diff --git a/packages/client/src/clients/guide/client.ts b/packages/client/src/clients/guide/client.ts index 5abd97a02..c798bf9c5 100644 --- a/packages/client/src/clients/guide/client.ts +++ b/packages/client/src/clients/guide/client.ts @@ -28,6 +28,7 @@ import { GroupStage, GuideAddedEvent, GuideData, + GuideEngagementEventBaseParams, GuideGroupAddedEvent, GuideGroupUpdatedEvent, GuideRemovedEvent, @@ -36,9 +37,6 @@ import { GuideUpdatedEvent, KnockGuide, KnockGuideStep, - MarkAsArchivedParams, - MarkAsInteractedParams, - MarkAsSeenParams, MarkGuideAsResponse, QueryFilterParams, QueryStatus, @@ -1022,10 +1020,7 @@ export class KnockGuideClient { data: this.targetParams.data, }; - this.knock.user.markGuideStepAs( - "seen", - params, - ); + this.sendEngagementEvent("seen", params); return updatedStep; } @@ -1065,10 +1060,7 @@ export class KnockGuideClient { metadata, }; - this.knock.user.markGuideStepAs< - MarkAsInteractedParams, - MarkGuideAsResponse - >("interacted", params); + this.sendEngagementEvent("interacted", params); return updatedStep; } @@ -1100,13 +1092,10 @@ export class KnockGuideClient { const params = this.buildEngagementEventBaseParams(guide, updatedStep); - this.knock.user.markGuideStepAs( - "archived", - { - ...params, - unthrottled: guide.bypass_global_group_limit, - }, - ); + this.sendEngagementEvent("archived", { + ...params, + unthrottled: guide.bypass_global_group_limit, + }); return updatedStep; } @@ -1135,6 +1124,22 @@ export class KnockGuideClient { return !!this.store.state.debug?.skipEngagementTracking; } + private sendEngagementEvent

( + status: "seen" | "interacted" | "archived", + params: P, + ) { + void this.knock.user + .markGuideStepAs(status, params) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + + this.knock.log( + `[Guide] Failed to mark guide step as ${status}: ${message}`, + true, + ); + }); + } + // // Helpers // diff --git a/packages/client/src/clients/users/index.ts b/packages/client/src/clients/users/index.ts index 0cdab95f2..93b0ef3e1 100644 --- a/packages/client/src/clients/users/index.ts +++ b/packages/client/src/clients/users/index.ts @@ -153,7 +153,8 @@ class UserClient { private handleResponse(response: ApiResponse) { if (response.statusCode === "error") { - throw new Error(response.error || response.body); + const error = response.error || response.body; + throw error instanceof Error ? error : new Error(error); } return response.body as T; diff --git a/packages/client/test/clients/guide/guide.test.ts b/packages/client/test/clients/guide/guide.test.ts index f099e3320..08213dba7 100644 --- a/packages/client/test/clients/guide/guide.test.ts +++ b/packages/client/test/clients/guide/guide.test.ts @@ -903,6 +903,125 @@ describe("KnockGuideClient", () => { }); }); + test.each([ + { + status: "seen", + timestamp: "seen_at", + engage: ( + client: KnockGuideClient, + guide: KnockGuide, + step: KnockGuideStep, + ) => client.markAsSeen(guide, step), + }, + { + status: "interacted", + timestamp: "interacted_at", + engage: ( + client: KnockGuideClient, + guide: KnockGuide, + step: KnockGuideStep, + ) => client.markAsInteracted(guide, step), + }, + { + status: "archived", + timestamp: "archived_at", + engage: ( + client: KnockGuideClient, + guide: KnockGuide, + step: KnockGuideStep, + ) => client.markAsArchived(guide, step), + }, + ] as const)( + "handles rejected $status requests after applying the optimistic update", + async ({ status, timestamp, engage }) => { + const error = new Error(`${status} request failed`); + vi.mocked(mockKnock.user.markGuideStepAs).mockRejectedValueOnce(error); + + const freshStep = { + ...mockStep, + message: { + ...mockStep.message, + seen_at: null, + read_at: null, + interacted_at: null, + archived_at: null, + }, + } as KnockGuideStep; + const freshGuide = { + ...mockGuide, + steps: [freshStep], + getStep: vi.fn().mockReturnValue(freshStep), + } as KnockGuide; + const stateWithGuides = { + guideGroups: [mockDefaultGroup], + guideGroupDisplayLogs: {}, + guides: { [freshGuide.key]: freshGuide }, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { forcedGuideKey: null, previewSessionId: null }, + }; + mockStore.state = stateWithGuides; + mockStore.getState.mockReturnValue(stateWithGuides); + + const result = await engage( + new KnockGuideClient(mockKnock, channelId), + freshGuide, + freshStep, + ); + await Promise.resolve(); + + expect(result?.message[timestamp]).toEqual(expect.any(String)); + expect(mockKnock.log).toHaveBeenCalledWith( + `[Guide] Failed to mark guide step as ${status}: ${error.message}`, + true, + ); + }, + ); + + test("does not wait for the engagement request before resolving", async () => { + const pendingRequest = new Promise(() => {}); + vi.mocked(mockKnock.user.markGuideStepAs).mockReturnValueOnce( + pendingRequest, + ); + + const freshStep = { + ...mockStep, + message: { + ...mockStep.message, + interacted_at: null, + read_at: null, + }, + } as KnockGuideStep; + const freshGuide = { + ...mockGuide, + steps: [freshStep], + getStep: vi.fn().mockReturnValue(freshStep), + } as KnockGuide; + const stateWithGuides = { + guideGroups: [mockDefaultGroup], + guideGroupDisplayLogs: {}, + guides: { [freshGuide.key]: freshGuide }, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { forcedGuideKey: null, previewSessionId: null }, + }; + mockStore.state = stateWithGuides; + mockStore.getState.mockReturnValue(stateWithGuides); + + const result = await new KnockGuideClient( + mockKnock, + channelId, + ).markAsInteracted(freshGuide, freshStep); + + expect(result?.message.interacted_at).toEqual(expect.any(String)); + }); + test("marks guide step as archived with bypass_global_group_limit true", async () => { // Create a fresh mock step for this test const freshMockStep = { diff --git a/packages/client/test/clients/users/users.test.ts b/packages/client/test/clients/users/users.test.ts index 5d84b1a02..5d2fb53df 100644 --- a/packages/client/test/clients/users/users.test.ts +++ b/packages/client/test/clients/users/users.test.ts @@ -993,5 +993,43 @@ describe("User Client", () => { }), ).rejects.toThrow("Guide step not found"); }); + + test("preserves the original API error and its response details", async () => { + const { knock, mockApiClient } = getTestSetup(); + const responseBody = { + error: "Invalid guide engagement", + field: "guide_step_ref", + }; + const apiError = Object.assign( + new Error("Request failed with status code 400"), + { + name: "ApiRequestError", + response: { + status: 400, + data: responseBody, + }, + }, + ); + + mockApiClient.makeRequest.mockResolvedValue({ + statusCode: "error", + status: 400, + error: apiError, + body: responseBody, + }); + + const request = knock.user.markGuideStepAs("interacted", { + guide_key: "onboarding_guide", + guide_id: "guide_456", + guide_step_ref: "step_1", + channel_id: "channel_456", + }); + + await expect(request).rejects.toBe(apiError); + expect(apiError.response).toEqual({ + status: 400, + data: responseBody, + }); + }); }); }); From 28f4339ab12ed8941a600598e71a56f2265b554a Mon Sep 17 00:00:00 2001 From: Kyle McDonald Date: Wed, 29 Jul 2026 08:18:45 -0500 Subject: [PATCH 2/3] refactor(KNO-14505): inline engagement error handling --- packages/client/src/clients/guide/client.ts | 62 +++++++++++++-------- 1 file changed, 39 insertions(+), 23 deletions(-) diff --git a/packages/client/src/clients/guide/client.ts b/packages/client/src/clients/guide/client.ts index c798bf9c5..a284c8199 100644 --- a/packages/client/src/clients/guide/client.ts +++ b/packages/client/src/clients/guide/client.ts @@ -28,7 +28,6 @@ import { GroupStage, GuideAddedEvent, GuideData, - GuideEngagementEventBaseParams, GuideGroupAddedEvent, GuideGroupUpdatedEvent, GuideRemovedEvent, @@ -37,6 +36,9 @@ import { GuideUpdatedEvent, KnockGuide, KnockGuideStep, + MarkAsArchivedParams, + MarkAsInteractedParams, + MarkAsSeenParams, MarkGuideAsResponse, QueryFilterParams, QueryStatus, @@ -1020,7 +1022,16 @@ export class KnockGuideClient { data: this.targetParams.data, }; - this.sendEngagementEvent("seen", params); + void this.knock.user + .markGuideStepAs("seen", params) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + + this.knock.log( + `[Guide] Failed to mark guide step as seen: ${message}`, + true, + ); + }); return updatedStep; } @@ -1060,7 +1071,19 @@ export class KnockGuideClient { metadata, }; - this.sendEngagementEvent("interacted", params); + void this.knock.user + .markGuideStepAs< + MarkAsInteractedParams, + MarkGuideAsResponse + >("interacted", params) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + + this.knock.log( + `[Guide] Failed to mark guide step as interacted: ${message}`, + true, + ); + }); return updatedStep; } @@ -1092,10 +1115,19 @@ export class KnockGuideClient { const params = this.buildEngagementEventBaseParams(guide, updatedStep); - this.sendEngagementEvent("archived", { - ...params, - unthrottled: guide.bypass_global_group_limit, - }); + void this.knock.user + .markGuideStepAs("archived", { + ...params, + unthrottled: guide.bypass_global_group_limit, + }) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + + this.knock.log( + `[Guide] Failed to mark guide step as archived: ${message}`, + true, + ); + }); return updatedStep; } @@ -1124,22 +1156,6 @@ export class KnockGuideClient { return !!this.store.state.debug?.skipEngagementTracking; } - private sendEngagementEvent

( - status: "seen" | "interacted" | "archived", - params: P, - ) { - void this.knock.user - .markGuideStepAs(status, params) - .catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - - this.knock.log( - `[Guide] Failed to mark guide step as ${status}: ${message}`, - true, - ); - }); - } - // // Helpers // From 5ec962330580783b21df6b7b6f218dc5f4379588 Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 3 Aug 2026 17:16:04 -0400 Subject: [PATCH 3/3] refactor(KNO-14505): narrow scope to guide engagement error handling Revert the UserClient.handleResponse change and its test. The guide engagement catch handlers only log error.message, and the underlying ApiRequestError already stringifies with its HTTP status, so preserving the original error object bought nothing at the call site. Dropping it also avoids incidentally changing the thrown error shape for the other UserClient methods (identify, setPreferences, getChannelData, and friends), keeping this patch limited to the unhandled rejection. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/handle-guide-engagement-errors.md | 2 +- packages/client/src/clients/users/index.ts | 3 +- .../client/test/clients/users/users.test.ts | 38 ------------------- 3 files changed, 2 insertions(+), 41 deletions(-) diff --git a/.changeset/handle-guide-engagement-errors.md b/.changeset/handle-guide-engagement-errors.md index a80e11e5f..aa535c0b3 100644 --- a/.changeset/handle-guide-engagement-errors.md +++ b/.changeset/handle-guide-engagement-errors.md @@ -5,4 +5,4 @@ fix(KNO-14505): prevent guide engagement API failures from becoming unhandled promise rejections -Guide seen, interacted, and archived events continue to update local state optimistically while failed background requests are caught and logged. API request errors now retain their original identity, HTTP status, and response data for diagnostics. +Guide seen, interacted, and archived events continue to update local state optimistically while failed background requests are caught and logged. diff --git a/packages/client/src/clients/users/index.ts b/packages/client/src/clients/users/index.ts index 93b0ef3e1..0cdab95f2 100644 --- a/packages/client/src/clients/users/index.ts +++ b/packages/client/src/clients/users/index.ts @@ -153,8 +153,7 @@ class UserClient { private handleResponse(response: ApiResponse) { if (response.statusCode === "error") { - const error = response.error || response.body; - throw error instanceof Error ? error : new Error(error); + throw new Error(response.error || response.body); } return response.body as T; diff --git a/packages/client/test/clients/users/users.test.ts b/packages/client/test/clients/users/users.test.ts index 5d2fb53df..5d84b1a02 100644 --- a/packages/client/test/clients/users/users.test.ts +++ b/packages/client/test/clients/users/users.test.ts @@ -993,43 +993,5 @@ describe("User Client", () => { }), ).rejects.toThrow("Guide step not found"); }); - - test("preserves the original API error and its response details", async () => { - const { knock, mockApiClient } = getTestSetup(); - const responseBody = { - error: "Invalid guide engagement", - field: "guide_step_ref", - }; - const apiError = Object.assign( - new Error("Request failed with status code 400"), - { - name: "ApiRequestError", - response: { - status: 400, - data: responseBody, - }, - }, - ); - - mockApiClient.makeRequest.mockResolvedValue({ - statusCode: "error", - status: 400, - error: apiError, - body: responseBody, - }); - - const request = knock.user.markGuideStepAs("interacted", { - guide_key: "onboarding_guide", - guide_id: "guide_456", - guide_step_ref: "step_1", - channel_id: "channel_456", - }); - - await expect(request).rejects.toBe(apiError); - expect(apiError.response).toEqual({ - status: 400, - data: responseBody, - }); - }); }); });