From 3c78214896ab1e3d205673f3957e47e2162159fb Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 4 Mar 2026 12:57:02 -0400 Subject: [PATCH 1/7] wip Signed-off-by: Simo --- config/sentryProbes.ts | 37 +++++++++++++++++++++++++++++++++++++ sentry.edge.config.ts | 14 +++++++++++--- sentry.server.config.ts | 14 +++++++++++--- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/config/sentryProbes.ts b/config/sentryProbes.ts index 3f25b6ba93..fb1f95e7cc 100644 --- a/config/sentryProbes.ts +++ b/config/sentryProbes.ts @@ -18,6 +18,8 @@ const probeTags = { probe_type: "generic-exploit-scan", }; +const SERVER_ACTION_NOT_FOUND = "Failed to find Server Action"; + const CONNECTION_ERROR_PATTERNS = ["aborted", "ECONNRESET", "socket hang up"]; const HTTP_SERVER_STACK_PATTERNS = [ @@ -122,6 +124,41 @@ export function filterTunnelRouteErrors( return event; } +function isServerActionNotFoundError(event: Event): boolean { + const value = event.exception?.values?.[0]?.value; + const message = typeof value === "string" ? value : event.message; + return ( + typeof message === "string" && message.includes(SERVER_ACTION_NOT_FOUND) + ); +} + +function isProbeLikeServerActionRequest(event: Event): boolean { + const url = (event.request?.url || "").toLowerCase(); + + if (event.tags?.["security_probe"] === "true") { + return true; + } + + return ( + PROBE_PATTERNS.some((pattern) => url.includes(pattern)) || + url.includes("jquery-file-upload") + ); +} + +export function filterServerActionProbeErrors( + event: T +): T | null { + if (!isServerActionNotFoundError(event)) { + return event; + } + + if (isProbeLikeServerActionRequest(event)) { + return null; + } + + return event; +} + export function tagSecurityProbes(event: T): T { try { const url = (event?.request?.url || "").toLowerCase(); diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts index a5a5f7cd18..95ae5576d3 100644 --- a/sentry.edge.config.ts +++ b/sentry.edge.config.ts @@ -6,6 +6,7 @@ import { publicEnv } from "@/config/env"; import { + filterServerActionProbeErrors, filterTunnelRouteErrors, tagSecurityProbes, } from "@/config/sentryProbes"; @@ -39,11 +40,18 @@ Sentry.init({ // Handle obvious bot / exploit probes more gently (edge) // ------------------------------------------------------------ beforeSend(event: Sentry.ErrorEvent, hint: Sentry.EventHint) { - const filtered = filterTunnelRouteErrors(event, hint); - if (filtered === null) { + const tunnelFiltered = filterTunnelRouteErrors(event, hint); + if (tunnelFiltered === null) { return null; } - return sanitizeSentryEvent(tagSecurityProbes(filtered)); + + const tagged = tagSecurityProbes(tunnelFiltered); + const actionFiltered = filterServerActionProbeErrors(tagged); + if (actionFiltered === null) { + return null; + } + + return sanitizeSentryEvent(actionFiltered); }, beforeSendTransaction(event) { diff --git a/sentry.server.config.ts b/sentry.server.config.ts index 12b475ad7e..8e33391dfe 100644 --- a/sentry.server.config.ts +++ b/sentry.server.config.ts @@ -4,6 +4,7 @@ import { publicEnv } from "@/config/env"; import { + filterServerActionProbeErrors, filterTunnelRouteErrors, tagSecurityProbes, } from "@/config/sentryProbes"; @@ -39,11 +40,18 @@ Sentry.init({ // Handle obvious bot / exploit probes more gently // ------------------------------------------------------------ beforeSend(event, hint) { - const filtered = filterTunnelRouteErrors(event, hint); - if (filtered === null) { + const tunnelFiltered = filterTunnelRouteErrors(event, hint); + if (tunnelFiltered === null) { return null; } - return sanitizeSentryEvent(tagSecurityProbes(filtered)); + + const tagged = tagSecurityProbes(tunnelFiltered); + const actionFiltered = filterServerActionProbeErrors(tagged); + if (actionFiltered === null) { + return null; + } + + return sanitizeSentryEvent(actionFiltered); }, beforeSendTransaction(event) { From 5b04bd9ce10cf7749a8813c720f8a91da691f73d Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 4 Mar 2026 13:04:42 -0400 Subject: [PATCH 2/7] wip Signed-off-by: Simo --- config/sentryProbes.ts | 50 +++++++++++++++++++++++++++++++++++++---- sentry.edge.config.ts | 8 ++++++- sentry.server.config.ts | 8 ++++++- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/config/sentryProbes.ts b/config/sentryProbes.ts index fb1f95e7cc..bf7c92373b 100644 --- a/config/sentryProbes.ts +++ b/config/sentryProbes.ts @@ -19,6 +19,8 @@ const probeTags = { }; const SERVER_ACTION_NOT_FOUND = "Failed to find Server Action"; +const WEBSTREAMS_TRANSFORM_ALGORITHM_ERROR = + "controller[kState].transformAlgorithm is not a function"; const CONNECTION_ERROR_PATTERNS = ["aborted", "ECONNRESET", "socket hang up"]; @@ -132,10 +134,11 @@ function isServerActionNotFoundError(event: Event): boolean { ); } -function isProbeLikeServerActionRequest(event: Event): boolean { - const url = (event.request?.url || "").toLowerCase(); - - if (event.tags?.["security_probe"] === "true") { +function isProbeLikeRequest( + url: string, + securityProbeTag: string | undefined +): boolean { + if (securityProbeTag === "true") { return true; } @@ -145,6 +148,31 @@ function isProbeLikeServerActionRequest(event: Event): boolean { ); } +function isProbeLikeServerActionRequest(event: Event): boolean { + const url = (event.request?.url || "").toLowerCase(); + return isProbeLikeRequest(url, event.tags?.["security_probe"]); +} + +function isWebStreamsTransformAlgorithmError(event: Event): boolean { + const value = event.exception?.values?.[0]; + const message = + typeof value?.value === "string" ? value.value : event.message || ""; + + if (value?.type && value.type !== "TypeError") { + return false; + } + + return ( + typeof message === "string" && + message.includes(WEBSTREAMS_TRANSFORM_ALGORITHM_ERROR) + ); +} + +function isProbeLikeWebStreamsRequest(event: Event): boolean { + const url = (event.request?.url || "").toLowerCase(); + return isProbeLikeRequest(url, event.tags?.["security_probe"]); +} + export function filterServerActionProbeErrors( event: T ): T | null { @@ -159,6 +187,20 @@ export function filterServerActionProbeErrors( return event; } +export function filterWebStreamsProbeErrors( + event: T +): T | null { + if (!isWebStreamsTransformAlgorithmError(event)) { + return event; + } + + if (isProbeLikeWebStreamsRequest(event)) { + return null; + } + + return event; +} + export function tagSecurityProbes(event: T): T { try { const url = (event?.request?.url || "").toLowerCase(); diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts index 95ae5576d3..9d762595eb 100644 --- a/sentry.edge.config.ts +++ b/sentry.edge.config.ts @@ -8,6 +8,7 @@ import { publicEnv } from "@/config/env"; import { filterServerActionProbeErrors, filterTunnelRouteErrors, + filterWebStreamsProbeErrors, tagSecurityProbes, } from "@/config/sentryProbes"; import { @@ -51,7 +52,12 @@ Sentry.init({ return null; } - return sanitizeSentryEvent(actionFiltered); + const webStreamsFiltered = filterWebStreamsProbeErrors(actionFiltered); + if (webStreamsFiltered === null) { + return null; + } + + return sanitizeSentryEvent(webStreamsFiltered); }, beforeSendTransaction(event) { diff --git a/sentry.server.config.ts b/sentry.server.config.ts index 8e33391dfe..37b77aba53 100644 --- a/sentry.server.config.ts +++ b/sentry.server.config.ts @@ -6,6 +6,7 @@ import { publicEnv } from "@/config/env"; import { filterServerActionProbeErrors, filterTunnelRouteErrors, + filterWebStreamsProbeErrors, tagSecurityProbes, } from "@/config/sentryProbes"; import { @@ -51,7 +52,12 @@ Sentry.init({ return null; } - return sanitizeSentryEvent(actionFiltered); + const webStreamsFiltered = filterWebStreamsProbeErrors(actionFiltered); + if (webStreamsFiltered === null) { + return null; + } + + return sanitizeSentryEvent(webStreamsFiltered); }, beforeSendTransaction(event) { From c2478b883698bcf2494efa5aa9b38d1b4bcd665a Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 4 Mar 2026 13:08:47 -0400 Subject: [PATCH 3/7] wip Signed-off-by: Simo --- config/sentryProbes.ts | 141 +++++++++++++++++++++++++++++++++++++++- sentry.edge.config.ts | 11 +++- sentry.server.config.ts | 11 +++- 3 files changed, 160 insertions(+), 3 deletions(-) diff --git a/config/sentryProbes.ts b/config/sentryProbes.ts index bf7c92373b..14955a8b77 100644 --- a/config/sentryProbes.ts +++ b/config/sentryProbes.ts @@ -21,6 +21,16 @@ const probeTags = { const SERVER_ACTION_NOT_FOUND = "Failed to find Server Action"; const WEBSTREAMS_TRANSFORM_ALGORITHM_ERROR = "controller[kState].transformAlgorithm is not a function"; +const NEXTJS_RSC_TEXT_PLAIN_INVARIANT = "Expected RSC response, got text/plain"; +const MALFORMED_NEXT_ACTION_PATTERNS = [ + "mozilla", + "webkit", + "chrome", + "safari", + "edge", + "boundary", +] as const; +const MIN_NEXT_ACTION_LENGTH = 12; const CONNECTION_ERROR_PATTERNS = ["aborted", "ECONNRESET", "socket hang up"]; @@ -148,8 +158,120 @@ function isProbeLikeRequest( ); } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function getHeaderValue( + headers: unknown, + headerName: string +): string | undefined { + const normalizedTarget = headerName.toLowerCase(); + + if (Array.isArray(headers)) { + for (const entry of headers) { + if (!Array.isArray(entry) || entry.length < 2) { + continue; + } + const key = entry[0]; + const value = entry[1]; + if ( + typeof key === "string" && + key.toLowerCase() === normalizedTarget && + typeof value === "string" + ) { + return value; + } + } + return undefined; + } + + if (!isRecord(headers)) { + return undefined; + } + + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() !== normalizedTarget) { + continue; + } + return typeof value === "string" ? value : undefined; + } + + return undefined; +} + +function getRequestContentType(event: Event): string { + const headers = (event.request as { headers?: unknown } | undefined)?.headers; + const fromHeader = getHeaderValue(headers, "content-type"); + if (typeof fromHeader === "string") { + return fromHeader.toLowerCase(); + } + + const req = event.request as + | ({ inferred_content_type?: unknown } & Record) + | undefined; + const inferred = req?.["inferred_content_type"]; + return typeof inferred === "string" ? inferred.toLowerCase() : ""; +} + +function getNextActionHeader(event: Event): string | undefined { + const headers = (event.request as { headers?: unknown } | undefined)?.headers; + const value = getHeaderValue(headers, "next-action"); + return typeof value === "string" ? value.trim() : undefined; +} + +function isMalformedNextActionId(nextAction: string): boolean { + const normalized = nextAction.trim().toLowerCase(); + if (!normalized) { + return true; + } + + if (normalized.length < MIN_NEXT_ACTION_LENGTH) { + return true; + } + + if (normalized.includes(" ") || normalized.includes("----")) { + return true; + } + + return MALFORMED_NEXT_ACTION_PATTERNS.some((pattern) => + normalized.includes(pattern) + ); +} + +function isMalformedNextActionProbe(event: Event): boolean { + const nextAction = getNextActionHeader(event); + if (!nextAction || !isMalformedNextActionId(nextAction)) { + return false; + } + + const method = (event.request?.method || "").toUpperCase(); + const contentType = getRequestContentType(event); + + return method === "POST" && contentType.includes("text/plain"); +} + +function hasServerActionProbeSignature(event: Event): boolean { + const value = event.exception?.values?.[0]; + const message = + (typeof value?.value === "string" && value.value) || + (typeof event.message === "string" ? event.message : ""); + + if (!message) { + return false; + } + + return ( + message.includes(SERVER_ACTION_NOT_FOUND) || + message.includes(NEXTJS_RSC_TEXT_PLAIN_INVARIANT) + ); +} + function isProbeLikeServerActionRequest(event: Event): boolean { const url = (event.request?.url || "").toLowerCase(); + if (isMalformedNextActionProbe(event)) { + return true; + } return isProbeLikeRequest(url, event.tags?.["security_probe"]); } @@ -187,6 +309,20 @@ export function filterServerActionProbeErrors( return event; } +export function filterMalformedNextActionProbeErrors( + event: T +): T | null { + if (!hasServerActionProbeSignature(event)) { + return event; + } + + if (isMalformedNextActionProbe(event)) { + return null; + } + + return event; +} + export function filterWebStreamsProbeErrors( event: T ): T | null { @@ -205,7 +341,10 @@ export function tagSecurityProbes(event: T): T { try { const url = (event?.request?.url || "").toLowerCase(); - if (PROBE_PATTERNS.some((p) => url.includes(p))) { + if ( + PROBE_PATTERNS.some((p) => url.includes(p)) || + isMalformedNextActionProbe(event) + ) { event.level = "info"; event.tags = event.tags ? { ...event.tags, ...probeTags } diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts index 9d762595eb..df66245099 100644 --- a/sentry.edge.config.ts +++ b/sentry.edge.config.ts @@ -6,6 +6,7 @@ import { publicEnv } from "@/config/env"; import { + filterMalformedNextActionProbeErrors, filterServerActionProbeErrors, filterTunnelRouteErrors, filterWebStreamsProbeErrors, @@ -52,7 +53,15 @@ Sentry.init({ return null; } - const webStreamsFiltered = filterWebStreamsProbeErrors(actionFiltered); + const malformedNextActionFiltered = + filterMalformedNextActionProbeErrors(actionFiltered); + if (malformedNextActionFiltered === null) { + return null; + } + + const webStreamsFiltered = filterWebStreamsProbeErrors( + malformedNextActionFiltered + ); if (webStreamsFiltered === null) { return null; } diff --git a/sentry.server.config.ts b/sentry.server.config.ts index 37b77aba53..9f93ca1bfe 100644 --- a/sentry.server.config.ts +++ b/sentry.server.config.ts @@ -4,6 +4,7 @@ import { publicEnv } from "@/config/env"; import { + filterMalformedNextActionProbeErrors, filterServerActionProbeErrors, filterTunnelRouteErrors, filterWebStreamsProbeErrors, @@ -52,7 +53,15 @@ Sentry.init({ return null; } - const webStreamsFiltered = filterWebStreamsProbeErrors(actionFiltered); + const malformedNextActionFiltered = + filterMalformedNextActionProbeErrors(actionFiltered); + if (malformedNextActionFiltered === null) { + return null; + } + + const webStreamsFiltered = filterWebStreamsProbeErrors( + malformedNextActionFiltered + ); if (webStreamsFiltered === null) { return null; } From afd8c439f12c78b5b7801512a8ad1fd467ea1eb2 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 4 Mar 2026 13:24:27 -0400 Subject: [PATCH 4/7] wip Signed-off-by: Simo --- .../drops/WaveDropsReverseContainer.test.tsx | 112 ++++++++++++++---- .../waves/drops/WaveDropsReverseContainer.tsx | 2 +- config/sentryProbes.ts | 9 +- 3 files changed, 97 insertions(+), 26 deletions(-) diff --git a/__tests__/components/waves/drops/WaveDropsReverseContainer.test.tsx b/__tests__/components/waves/drops/WaveDropsReverseContainer.test.tsx index d92ea17bbb..fc23406719 100644 --- a/__tests__/components/waves/drops/WaveDropsReverseContainer.test.tsx +++ b/__tests__/components/waves/drops/WaveDropsReverseContainer.test.tsx @@ -1,26 +1,27 @@ -import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; -import { Provider } from 'react-redux'; -import { configureStore } from '@reduxjs/toolkit'; -import { WaveDropsReverseContainer } from '@/components/waves/drops/WaveDropsReverseContainer'; -import { useIntersectionObserver } from '@/hooks/scroll/useIntersectionObserver'; -import { editSlice } from '@/store/editSlice'; +import React from "react"; +import { render, fireEvent } from "@testing-library/react"; +import { Provider } from "react-redux"; +import { configureStore } from "@reduxjs/toolkit"; +import { WaveDropsReverseContainer } from "@/components/waves/drops/WaveDropsReverseContainer"; +import { useIntersectionObserver } from "@/hooks/scroll/useIntersectionObserver"; +import { editSlice } from "@/store/editSlice"; -jest.mock('@/hooks/scroll/useIntersectionObserver'); +jest.mock("@/hooks/scroll/useIntersectionObserver"); const mockUseIntersectionObserver = useIntersectionObserver as jest.Mock; -const createTestStore = () => configureStore({ - reducer: { - edit: editSlice.reducer, - }, -}); +const createTestStore = () => + configureStore({ + reducer: { + edit: editSlice.reducer, + }, + }); function setup(props?: any) { const onTopIntersection = jest.fn(); const onUserScroll = jest.fn(); const store = createTestStore(); - + const utils = render( { +describe("WaveDropsReverseContainer", () => { beforeEach(() => { mockUseIntersectionObserver.mockImplementation((ref, opts, cb) => { cb({ isIntersecting: true } as any); }); - jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: any) => { - cb(); - return 1 as any; - }); + jest + .spyOn(window, "requestAnimationFrame") + .mockImplementation((cb: any) => { + cb(); + return 1 as any; + }); }); - it('calls onTopIntersection when sentinel visible', () => { + it("calls onTopIntersection when sentinel visible", () => { const { onTopIntersection } = setup(); expect(onTopIntersection).toHaveBeenCalled(); }); - it('invokes onUserScroll on scroll', () => { + it("invokes onUserScroll on scroll", () => { const { container, onUserScroll } = setup(); const scrollDiv = container.firstChild as HTMLElement; - Object.defineProperty(scrollDiv, 'scrollTop', { value: -10, writable: true }); + Object.defineProperty(scrollDiv, "scrollTop", { + value: -10, + writable: true, + }); fireEvent.scroll(scrollDiv); - expect(onUserScroll).toHaveBeenCalledWith('up', false); + expect(onUserScroll).toHaveBeenCalledWith("up", false); + }); + + it("keeps callback ref stable across rerenders and only detaches on unmount", () => { + const store = createTestStore(); + const onTopIntersection = jest.fn(); + const callbackRef = jest.fn(); + + const { rerender, unmount } = render( + + +
child
+
+
+ ); + + const attachedCalls = () => + callbackRef.mock.calls.filter(([instance]) => instance !== null); + const detachedCalls = () => + callbackRef.mock.calls.filter(([instance]) => instance === null); + + expect(attachedCalls()).toHaveLength(1); + expect(detachedCalls()).toHaveLength(0); + + rerender( + + +
child
+
+
+ ); + + rerender( + + +
child
+
+
+ ); + + expect(attachedCalls()).toHaveLength(1); + expect(detachedCalls()).toHaveLength(0); + + unmount(); + + expect(detachedCalls()).toHaveLength(1); }); }); diff --git a/components/waves/drops/WaveDropsReverseContainer.tsx b/components/waves/drops/WaveDropsReverseContainer.tsx index 62b17534c0..1f48cad4af 100644 --- a/components/waves/drops/WaveDropsReverseContainer.tsx +++ b/components/waves/drops/WaveDropsReverseContainer.tsx @@ -88,7 +88,7 @@ export const WaveDropsReverseContainer = forwardRef< }; }, []); - React.useImperativeHandle(ref, () => scrollContainerRef.current!); + React.useImperativeHandle(ref, () => scrollContainerRef.current!, []); return (
{ return typeof value === "object" && value !== null; } @@ -272,7 +277,7 @@ function isProbeLikeServerActionRequest(event: Event): boolean { if (isMalformedNextActionProbe(event)) { return true; } - return isProbeLikeRequest(url, event.tags?.["security_probe"]); + return isProbeLikeRequest(url, getStringTagValue(event, "security_probe")); } function isWebStreamsTransformAlgorithmError(event: Event): boolean { @@ -292,7 +297,7 @@ function isWebStreamsTransformAlgorithmError(event: Event): boolean { function isProbeLikeWebStreamsRequest(event: Event): boolean { const url = (event.request?.url || "").toLowerCase(); - return isProbeLikeRequest(url, event.tags?.["security_probe"]); + return isProbeLikeRequest(url, getStringTagValue(event, "security_probe")); } export function filterServerActionProbeErrors( From e770a83af98762b8e892f679a8710e262ef7c460 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 4 Mar 2026 13:46:07 -0400 Subject: [PATCH 5/7] wip Signed-off-by: Simo --- .../NotificationsContext.test.tsx | 127 +++++ .../notifications/NotificationsContext.tsx | 462 ++++++++++++++++-- 2 files changed, 560 insertions(+), 29 deletions(-) diff --git a/__tests__/components/notifications/NotificationsContext.test.tsx b/__tests__/components/notifications/NotificationsContext.test.tsx index 952ee380e8..7d77c35061 100644 --- a/__tests__/components/notifications/NotificationsContext.test.tsx +++ b/__tests__/components/notifications/NotificationsContext.test.tsx @@ -116,6 +116,7 @@ describe("NotificationsContext initialization", () => { PushNotifications.addListener.mockClear(); PushNotifications.register.mockClear(); sentry.captureException.mockClear(); + sentry.addBreadcrumb.mockClear(); }); it("does not initialize when isActive is false", async () => { @@ -185,6 +186,132 @@ describe("NotificationsContext initialization", () => { }); }); +describe("push registration behavior", () => { + const setupRegistrationCallback = async () => { + const { PushNotifications } = require("@capacitor/push-notifications"); + + let registrationCallback: + | ((token: { value: string }) => Promise) + | null = null; + + PushNotifications.addListener.mockImplementation( + (event: string, callback: (arg: unknown) => Promise) => { + if (event === "registration") { + registrationCallback = callback as (token: { + value: string; + }) => Promise; + } + return Promise.resolve(); + } + ); + + renderHook(() => useNotificationsContext(), { wrapper }); + + await waitFor(() => { + expect(PushNotifications.addListener).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(registrationCallback).not.toBeNull(); + }); + + return { + registrationCallback: registrationCallback as (token: { + value: string; + }) => Promise, + }; + }; + + beforeEach(() => { + const { PushNotifications } = require("@capacitor/push-notifications"); + const { commonApiPost } = require("@/services/api/common-api"); + const sentry = require("@sentry/nextjs"); + + jest.clearAllMocks(); + PushNotifications.addListener.mockClear(); + commonApiPost.mockReset(); + commonApiPost.mockResolvedValue({}); + sentry.captureException.mockClear(); + sentry.addBreadcrumb.mockClear(); + }); + + it("retries on rate limit and does not capture exception", async () => { + const { commonApiPost } = require("@/services/api/common-api"); + const sentry = require("@sentry/nextjs"); + const rateLimitError = new Error("Rate limit exceeded. Try again in 1 sec"); + + commonApiPost.mockRejectedValue(rateLimitError); + const { registrationCallback } = await setupRegistrationCallback(); + + const setTimeoutSpy = jest.spyOn(global, "setTimeout").mockImplementation((( + handler: TimerHandler + ) => { + if (typeof handler === "function") { + handler(); + } + return 0 as unknown as NodeJS.Timeout; + }) as typeof global.setTimeout); + + try { + await act(async () => { + await registrationCallback({ value: "test-token" }); + }); + } finally { + setTimeoutSpy.mockRestore(); + } + + expect(commonApiPost).toHaveBeenCalledTimes(3); + expect(sentry.captureException).not.toHaveBeenCalled(); + expect(sentry.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Push registration rate limited.", + }) + ); + }); + + it("skips duplicate registration for identical fingerprint", async () => { + const { commonApiPost } = require("@/services/api/common-api"); + const sentry = require("@sentry/nextjs"); + const { registrationCallback } = await setupRegistrationCallback(); + + await act(async () => { + await registrationCallback({ value: "test-token" }); + await registrationCallback({ value: "test-token" }); + }); + + expect(commonApiPost).toHaveBeenCalledTimes(1); + expect(sentry.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Push registration skipped (already registered in session).", + }) + ); + }); + + it("captures non-rate-limit push registration errors", async () => { + const { commonApiPost } = require("@/services/api/common-api"); + const sentry = require("@sentry/nextjs"); + const fatalError = new Error("fatal push registration failure"); + commonApiPost.mockRejectedValue(fatalError); + + const { registrationCallback } = await setupRegistrationCallback(); + + await act(async () => { + await registrationCallback({ value: "test-token" }); + }); + + expect(commonApiPost).toHaveBeenCalledTimes(1); + expect(sentry.captureException).toHaveBeenCalledWith( + fatalError, + expect.objectContaining({ + tags: expect.objectContaining({ + component: "NotificationsProvider", + operation: "registerPushNotification", + }), + }) + ); + }); +}); + it("removes notifications when functions called", async () => { const { PushNotifications } = require("@capacitor/push-notifications"); diff --git a/components/notifications/NotificationsContext.tsx b/components/notifications/NotificationsContext.tsx index be68286446..1729e6d475 100644 --- a/components/notifications/NotificationsContext.tsx +++ b/components/notifications/NotificationsContext.tsx @@ -63,12 +63,243 @@ const MAX_RETRY_DELAY_MS = 5000; const IOS_INITIALIZATION_DELAY_MS = 500; const PROFILE_SWITCH_SETTLE_TIMEOUT_MS = 3000; const PROFILE_SWITCH_POLL_INTERVAL_MS = 50; +const PUSH_REGISTRATION_TOTAL_ATTEMPTS = 3; +const PUSH_REGISTRATION_BASE_DELAY_MS = 500; +const PUSH_REGISTRATION_MAX_DELAY_MS = 4000; +const PUSH_REGISTRATION_JITTER_FACTOR = 0.2; +const PUSH_REGISTRATION_MAX_RETRY_AFTER_MS = 10000; const DELEGATE_ERROR_PATTERNS = [ "capacitorDidRegisterForRemoteNotifications", "didRegisterForRemoteNotifications", ]; +const PUSH_REGISTRATION_RETRY_MESSAGE_PATTERN = + /(?:retry[-\s]?after|try again in)\s*(\d+)\s*(millisecond|milliseconds|ms|second|seconds|sec|s|minute|minutes|min|m)?/i; +const RATE_LIMIT_ERROR_PATTERNS = ["rate limit", "too many requests", "429"]; +const TRANSIENT_ERROR_PATTERNS = [ + "failed to fetch", + "load failed", + "network request failed", + "network error", + "timeout", +]; + +type PushRegistrationFingerprint = { + deviceId: string; + token: string; + profileId: string | null; +}; + +const toErrorMessage = (error: unknown): string => { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "string") { + return error; + } + if (typeof error === "object" && error) { + const typedError = error as { + message?: unknown; + error?: unknown; + }; + if (typeof typedError.message === "string") { + return typedError.message; + } + if (typeof typedError.error === "string") { + return typedError.error; + } + } + return String(error); +}; + +const parseStatusCode = (status: unknown): number | null => { + if (typeof status === "number" && Number.isFinite(status)) { + return status; + } + if (typeof status === "string") { + const parsed = Number.parseInt(status, 10); + return Number.isNaN(parsed) ? null : parsed; + } + return null; +}; + +const extractErrorStatusCode = (error: unknown): number | null => { + if (!error || typeof error !== "object") { + return null; + } + const typedError = error as { + status?: unknown; + code?: unknown; + response?: { + status?: unknown; + }; + }; + + return ( + parseStatusCode(typedError.status) ?? + parseStatusCode(typedError.response?.status) ?? + parseStatusCode(typedError.code) + ); +}; + +const parseRetryAfterHeaderValue = (value: string): number | null => { + const seconds = Number.parseFloat(value); + if (Number.isFinite(seconds) && seconds >= 0) { + return Math.round(seconds * 1000); + } + + const retryAt = Date.parse(value); + if (!Number.isNaN(retryAt)) { + return Math.max(0, retryAt - Date.now()); + } + + return null; +}; + +const toRecord = (value: unknown): Record | null => { + if (!value || typeof value !== "object") { + return null; + } + return value as Record; +}; + +const extractRetryAfterFromHeaders = (headers: unknown): number | null => { + if (headers instanceof Headers) { + const retryAfter = headers.get("retry-after"); + return retryAfter ? parseRetryAfterHeaderValue(retryAfter) : null; + } + + const typedHeaders = toRecord(headers); + if (!typedHeaders) { + return null; + } + + const retryAfter = + typedHeaders["retry-after"] ?? typedHeaders["Retry-After"] ?? null; + if (typeof retryAfter === "string") { + return parseRetryAfterHeaderValue(retryAfter); + } + + return null; +}; + +const extractRetryAfterMs = (error: unknown): number | null => { + if (error && typeof error === "object") { + const typedError = error as { + headers?: unknown; + response?: { + headers?: unknown; + }; + }; + + const retryAfterFromHeaders = + extractRetryAfterFromHeaders(typedError.response?.headers) ?? + extractRetryAfterFromHeaders(typedError.headers); + if (retryAfterFromHeaders !== null) { + return Math.min( + retryAfterFromHeaders, + PUSH_REGISTRATION_MAX_RETRY_AFTER_MS + ); + } + } + + const message = toErrorMessage(error); + const match = PUSH_REGISTRATION_RETRY_MESSAGE_PATTERN.exec(message); + if (!match) { + return null; + } + + const value = Number.parseInt(match[1] ?? "", 10); + if (!Number.isFinite(value) || value < 0) { + return null; + } + + const unit = (match[2] ?? "seconds").toLowerCase(); + let multiplier = 1000; + if (unit.startsWith("m") && unit !== "ms" && unit !== "millisecond") { + multiplier = 60_000; + } else if (unit.startsWith("ms")) { + multiplier = 1; + } else if (unit.startsWith("millisecond")) { + multiplier = 1; + } + + return Math.min(value * multiplier, PUSH_REGISTRATION_MAX_RETRY_AFTER_MS); +}; + +const isRateLimitError = (error: unknown): boolean => { + if (extractErrorStatusCode(error) === 429) { + return true; + } + const normalizedMessage = toErrorMessage(error).toLowerCase(); + return RATE_LIMIT_ERROR_PATTERNS.some((pattern) => + normalizedMessage.includes(pattern) + ); +}; + +const isTransientPushRegistrationError = (error: unknown): boolean => { + if (isRateLimitError(error)) { + return true; + } + + const statusCode = extractErrorStatusCode(error); + if (statusCode !== null) { + return statusCode === 408 || (statusCode >= 500 && statusCode < 600); + } + + const normalizedMessage = toErrorMessage(error).toLowerCase(); + return TRANSIENT_ERROR_PATTERNS.some((pattern) => + normalizedMessage.includes(pattern) + ); +}; + +const computePushRegistrationRetryDelayMs = ( + attempt: number, + retryAfterMs: number | null +): number => { + if (retryAfterMs !== null) { + return Math.max(0, retryAfterMs); + } + + const baseDelay = Math.min( + PUSH_REGISTRATION_BASE_DELAY_MS * Math.pow(2, attempt), + PUSH_REGISTRATION_MAX_DELAY_MS + ); + const jitterMultiplier = + 1 + (Math.random() * 2 - 1) * PUSH_REGISTRATION_JITTER_FACTOR; + return Math.max(0, Math.round(baseDelay * jitterMultiplier)); +}; + +const toCaptureExceptionInput = (error: unknown): Error => { + if (error instanceof Error) { + return error; + } + return new Error(toErrorMessage(error)); +}; + +const createPushRegistrationFingerprint = ({ + deviceId, + token, + profile, +}: { + deviceId: string; + token: string; + profile?: ApiIdentity; +}): PushRegistrationFingerprint => ({ + deviceId, + token, + profileId: profile?.id ?? null, +}); + +const isSamePushRegistrationFingerprint = ( + left: PushRegistrationFingerprint, + right: PushRegistrationFingerprint +): boolean => + left.deviceId === right.deviceId && + left.token === right.token && + left.profileId === right.profileId; + const isDelegateError = (error: unknown): boolean => { const errorMessage = error instanceof Error ? error.message : String(error); return DELEGATE_ERROR_PATTERNS.some((pattern) => @@ -147,6 +378,9 @@ export const NotificationsProvider: React.FC<{ children: React.ReactNode }> = ({ const router = useRouter(); const initializationRef = useRef(null); const isRegisteredRef = useRef(false); + const lastSuccessfulRegistrationRef = + useRef(null); + const inFlightRegistrationRef = useRef | null>(null); const connectedProfileRef = useRef(connectedProfile); const connectedAccountsRef = useRef(connectedAccounts); const activeAddressRef = useRef(address); @@ -356,6 +590,203 @@ export const NotificationsProvider: React.FC<{ children: React.ReactNode }> = ({ ] ); + const registerPushNotificationWithRetry = useCallback( + async ( + deviceId: string, + deviceInfo: DeviceInfo, + token: string, + profile?: ApiIdentity + ): Promise => { + const profileId = profile?.id ?? null; + + for ( + let attempt = 0; + attempt < PUSH_REGISTRATION_TOTAL_ATTEMPTS; + attempt++ + ) { + try { + await commonApiPost({ + endpoint: `push-notifications/register`, + body: { + device_id: deviceId, + token, + platform: deviceInfo.platform, + profile_id: profile?.id, + }, + }); + + return true; + } catch (error) { + const attemptNumber = attempt + 1; + const statusCode = extractErrorStatusCode(error); + const rateLimited = isRateLimitError(error); + const retryAfterMs = extractRetryAfterMs(error); + const hasRetriesLeft = + attemptNumber < PUSH_REGISTRATION_TOTAL_ATTEMPTS; + const shouldRetry = + hasRetriesLeft && isTransientPushRegistrationError(error); + + if (shouldRetry) { + const delayMs = computePushRegistrationRetryDelayMs( + attempt, + retryAfterMs + ); + + console.warn("Push registration attempt failed. Retrying...", { + attempt: attemptNumber, + maxAttempts: PUSH_REGISTRATION_TOTAL_ATTEMPTS, + delayMs, + rateLimited, + statusCode, + profileId, + }); + Sentry.addBreadcrumb({ + category: "notifications", + level: "warning", + message: "Push registration attempt failed. Retrying.", + data: { + component: "NotificationsProvider", + operation: "registerPushNotification", + attempt: attemptNumber, + max_attempts: PUSH_REGISTRATION_TOTAL_ATTEMPTS, + delay_ms: delayMs, + rate_limited: rateLimited, + status_code: statusCode ?? undefined, + profile_id: profileId ?? undefined, + platform: deviceInfo.platform, + }, + }); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + continue; + } + + if (rateLimited) { + console.warn("Push registration rate limited", { + attempt: attemptNumber, + maxAttempts: PUSH_REGISTRATION_TOTAL_ATTEMPTS, + statusCode, + profileId, + }); + Sentry.addBreadcrumb({ + category: "notifications", + level: "warning", + message: "Push registration rate limited.", + data: { + component: "NotificationsProvider", + operation: "registerPushNotification", + attempt: attemptNumber, + max_attempts: PUSH_REGISTRATION_TOTAL_ATTEMPTS, + delay_ms: retryAfterMs ?? undefined, + status_code: statusCode ?? undefined, + profile_id: profileId ?? undefined, + platform: deviceInfo.platform, + }, + }); + return false; + } + + console.error("Push registration error", error); + Sentry.captureException(toCaptureExceptionInput(error), { + tags: { + component: "NotificationsProvider", + operation: "registerPushNotification", + }, + extra: { + attempt: attemptNumber, + max_attempts: PUSH_REGISTRATION_TOTAL_ATTEMPTS, + status_code: statusCode ?? undefined, + profile_id: profileId ?? undefined, + platform: deviceInfo.platform, + }, + }); + return false; + } + } + + return false; + }, + [] + ); + + const handlePushRegistration = useCallback( + async ( + deviceId: string, + deviceInfo: DeviceInfo, + token: string, + profile?: ApiIdentity + ): Promise => { + const fingerprint = createPushRegistrationFingerprint({ + deviceId, + token, + profile, + }); + const previousSuccess = lastSuccessfulRegistrationRef.current; + + if ( + previousSuccess && + isSamePushRegistrationFingerprint(previousSuccess, fingerprint) + ) { + Sentry.addBreadcrumb({ + category: "notifications", + level: "info", + message: "Push registration skipped (already registered in session).", + data: { + component: "NotificationsProvider", + operation: "registerPushNotification", + profile_id: fingerprint.profileId ?? undefined, + platform: deviceInfo.platform, + }, + }); + return; + } + + if (inFlightRegistrationRef.current) { + await inFlightRegistrationRef.current; + const latestSuccess = lastSuccessfulRegistrationRef.current; + if ( + latestSuccess && + isSamePushRegistrationFingerprint(latestSuccess, fingerprint) + ) { + Sentry.addBreadcrumb({ + category: "notifications", + level: "info", + message: + "Push registration skipped (already handled by in-flight registration).", + data: { + component: "NotificationsProvider", + operation: "registerPushNotification", + profile_id: fingerprint.profileId ?? undefined, + platform: deviceInfo.platform, + }, + }); + return; + } + } + + const registrationTask = (async () => { + const didRegister = await registerPushNotificationWithRetry( + deviceId, + deviceInfo, + token, + profile + ); + if (didRegister) { + lastSuccessfulRegistrationRef.current = fingerprint; + } + })(); + + inFlightRegistrationRef.current = registrationTask; + try { + await registrationTask; + } finally { + if (inFlightRegistrationRef.current === registrationTask) { + inFlightRegistrationRef.current = null; + } + } + }, + [registerPushNotificationWithRetry] + ); + const initializePushNotifications = useCallback( async (profile?: ApiIdentity) => { try { @@ -368,7 +799,7 @@ export const NotificationsProvider: React.FC<{ children: React.ReactNode }> = ({ await PushNotifications.addListener("registration", async (token) => { isRegisteredRef.current = true; - await registerPushNotification( + await handlePushRegistration( stableDeviceId, deviceInfo, token.value, @@ -418,7 +849,7 @@ export const NotificationsProvider: React.FC<{ children: React.ReactNode }> = ({ throw error; } }, - [isIos, router, handlePushNotificationAction] + [isIos, router, handlePushNotificationAction, handlePushRegistration] ); const initializeNotifications = useCallback( @@ -502,33 +933,6 @@ export const NotificationsProvider: React.FC<{ children: React.ReactNode }> = ({ ); }; -const registerPushNotification = async ( - deviceId: string, - deviceInfo: DeviceInfo, - token: string, - profile?: ApiIdentity -) => { - try { - await commonApiPost({ - endpoint: `push-notifications/register`, - body: { - device_id: deviceId, - token, - platform: deviceInfo.platform, - profile_id: profile?.id, - }, - }); - } catch (error) { - console.error("Push registration error", error); - Sentry.captureException(error, { - tags: { - component: "NotificationsProvider", - operation: "registerPushNotification", - }, - }); - } -}; - const resolveRedirectUrl = (notificationData: DevicePushData) => { const { redirect, ...params } = notificationData; From 8a443393681a4845330116a188785cad48017e2b Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 4 Mar 2026 14:05:41 -0400 Subject: [PATCH 6/7] wip Signed-off-by: Simo --- .../NotificationsContext.test.tsx | 75 +++++++++++++ __tests__/services/common-api.test.ts | 42 +++++++- .../notifications/NotificationsContext.tsx | 36 +++++-- services/api/common-api.ts | 101 ++++++++++++++++-- 4 files changed, 234 insertions(+), 20 deletions(-) diff --git a/__tests__/components/notifications/NotificationsContext.test.tsx b/__tests__/components/notifications/NotificationsContext.test.tsx index 7d77c35061..5014468b97 100644 --- a/__tests__/components/notifications/NotificationsContext.test.tsx +++ b/__tests__/components/notifications/NotificationsContext.test.tsx @@ -269,6 +269,81 @@ describe("push registration behavior", () => { ); }); + it("parses milliseconds retry hints as milliseconds", async () => { + const { PushNotifications } = require("@capacitor/push-notifications"); + const { commonApiPost } = require("@/services/api/common-api"); + const sentry = require("@sentry/nextjs"); + const rateLimitError = new Error( + "Rate limit exceeded. Try again in 500 milliseconds" + ); + + PushNotifications.requestPermissions.mockResolvedValueOnce({ + receive: "denied", + }); + commonApiPost + .mockRejectedValueOnce(rateLimitError) + .mockResolvedValueOnce({}); + + const { registrationCallback } = await setupRegistrationCallback(); + + await act(async () => { + await registrationCallback({ value: "test-token" }); + }); + + expect(commonApiPost).toHaveBeenCalledTimes(2); + expect(sentry.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Push registration attempt failed. Retrying.", + data: expect.objectContaining({ + delay_ms: 500, + rate_limited: true, + }), + }) + ); + expect(sentry.captureException).not.toHaveBeenCalled(); + }); + + it("uses retry-after header metadata from structured API errors", async () => { + const { commonApiPost } = require("@/services/api/common-api"); + const sentry = require("@sentry/nextjs"); + const rateLimitHeaders = new Headers({ "Retry-After": "2" }); + const rateLimitError = Object.assign(new Error("Too Many Requests"), { + status: 429, + headers: rateLimitHeaders, + response: { + status: 429, + headers: rateLimitHeaders, + }, + }); + + commonApiPost + .mockRejectedValueOnce(rateLimitError) + .mockResolvedValueOnce({}); + + const { registrationCallback } = await setupRegistrationCallback(); + + await act(async () => { + await registrationCallback({ value: "test-token" }); + }); + + expect(commonApiPost).toHaveBeenCalledTimes(2); + expect(commonApiPost).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ errorMode: "structured" }) + ); + expect(sentry.addBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Push registration attempt failed. Retrying.", + data: expect.objectContaining({ + delay_ms: 2000, + status_code: 429, + rate_limited: true, + }), + }) + ); + expect(sentry.captureException).not.toHaveBeenCalled(); + }); + it("skips duplicate registration for identical fingerprint", async () => { const { commonApiPost } = require("@/services/api/common-api"); const sentry = require("@sentry/nextjs"); diff --git a/__tests__/services/common-api.test.ts b/__tests__/services/common-api.test.ts index 3556bbd860..19da4edbd9 100644 --- a/__tests__/services/common-api.test.ts +++ b/__tests__/services/common-api.test.ts @@ -34,7 +34,6 @@ describe("commonApiFetch", () => { "x-6529-auth": "s", Authorization: "Bearer jwt", }), - signal: undefined, }) ); expect(result).toEqual({ result: 1 }); @@ -55,7 +54,6 @@ describe("commonApiFetch", () => { "https://api.test.6529.io/api/bad", expect.objectContaining({ headers: {}, - signal: undefined, }) ); }); @@ -117,4 +115,44 @@ describe("commonApiPost", () => { }) ); }); + + it("rejects with structured metadata when requested", async () => { + (getStagingAuth as jest.Mock).mockReturnValue(null); + (getAuthJwt as jest.Mock).mockReturnValue(null); + const responseHeaders = new Headers({ "retry-after": "2" }); + fetchMock.mockResolvedValue({ + ok: false, + status: 429, + statusText: "Too Many Requests", + headers: responseHeaders, + json: async () => ({ error: "rate limited" }), + }); + + const error = await commonApiPost({ + endpoint: "e", + body: {}, + errorMode: "structured", + }).catch( + (error) => + error as { + message: string; + status: number; + headers: Headers; + response: { + status: number; + headers: Headers; + body?: unknown; + }; + } + ); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe("rate limited"); + expect(error.status).toBe(429); + expect(error.headers).toBe(responseHeaders); + expect(error.headers.get("retry-after")).toBe("2"); + expect(error.response.status).toBe(429); + expect(error.response.headers).toBe(responseHeaders); + expect(error.response.body).toEqual({ error: "rate limited" }); + }); }); diff --git a/components/notifications/NotificationsContext.tsx b/components/notifications/NotificationsContext.tsx index 1729e6d475..dc7cb6da3d 100644 --- a/components/notifications/NotificationsContext.tsx +++ b/components/notifications/NotificationsContext.tsx @@ -134,12 +134,22 @@ const extractErrorStatusCode = (error: unknown): number | null => { response?: { status?: unknown; }; + cause?: { + status?: unknown; + code?: unknown; + response?: { + status?: unknown; + }; + }; }; return ( parseStatusCode(typedError.status) ?? parseStatusCode(typedError.response?.status) ?? - parseStatusCode(typedError.code) + parseStatusCode(typedError.code) ?? + parseStatusCode(typedError.cause?.status) ?? + parseStatusCode(typedError.cause?.response?.status) ?? + parseStatusCode(typedError.cause?.code) ); }; @@ -191,11 +201,19 @@ const extractRetryAfterMs = (error: unknown): number | null => { response?: { headers?: unknown; }; + cause?: { + headers?: unknown; + response?: { + headers?: unknown; + }; + }; }; const retryAfterFromHeaders = extractRetryAfterFromHeaders(typedError.response?.headers) ?? - extractRetryAfterFromHeaders(typedError.headers); + extractRetryAfterFromHeaders(typedError.headers) ?? + extractRetryAfterFromHeaders(typedError.cause?.response?.headers) ?? + extractRetryAfterFromHeaders(typedError.cause?.headers); if (retryAfterFromHeaders !== null) { return Math.min( retryAfterFromHeaders, @@ -217,12 +235,15 @@ const extractRetryAfterMs = (error: unknown): number | null => { const unit = (match[2] ?? "seconds").toLowerCase(); let multiplier = 1000; - if (unit.startsWith("m") && unit !== "ms" && unit !== "millisecond") { - multiplier = 60_000; - } else if (unit.startsWith("ms")) { - multiplier = 1; - } else if (unit.startsWith("millisecond")) { + if (unit === "ms" || unit === "millisecond" || unit === "milliseconds") { multiplier = 1; + } else if ( + unit === "m" || + unit === "min" || + unit === "minute" || + unit === "minutes" + ) { + multiplier = 60_000; } return Math.min(value * multiplier, PUSH_REGISTRATION_MAX_RETRY_AFTER_MS); @@ -613,6 +634,7 @@ export const NotificationsProvider: React.FC<{ children: React.ReactNode }> = ({ platform: deviceInfo.platform, profile_id: profile?.id, }, + errorMode: "structured", }); return true; diff --git a/services/api/common-api.ts b/services/api/common-api.ts index 119bbe85ae..7eadf57bb5 100644 --- a/services/api/common-api.ts +++ b/services/api/common-api.ts @@ -1,6 +1,18 @@ import { publicEnv } from "@/config/env"; import { getAuthJwt, getStagingAuth } from "../auth/auth.utils"; +type ApiErrorMode = "legacy-string" | "structured"; + +type StructuredApiError = Error & { + status: number; + headers: Headers; + response: { + status: number; + headers: Headers; + body?: unknown; + }; +}; + const getHeaders = ( headers?: Record, contentType: boolean = true @@ -36,22 +48,86 @@ const buildUrl = ( return url; }; -const handleApiError = async (res: Response): Promise => { +const normalizeHeaders = (value: unknown): Headers => { + if (value instanceof Headers) { + return value; + } + try { + return new Headers(value as HeadersInit); + } catch { + return new Headers(); + } +}; + +const createStructuredApiError = ({ + message, + status, + headers, + body, +}: { + message: string; + status: number; + headers: Headers; + body?: unknown; +}): StructuredApiError => { + const error = new Error(message) as StructuredApiError; + error.name = "ApiError"; + error.status = status; + error.headers = headers; + error.response = { + status, + headers, + ...(body !== undefined ? { body } : {}), + }; + return error; +}; + +const handleApiError = async ( + res: Response, + errorMode: ApiErrorMode +): Promise => { let errorMessage: string; - let rawContent: string = ""; + let errorBody: unknown = undefined; try { - const body: any = await res.json(); - errorMessage = body?.error ?? res.statusText ?? "Something went wrong"; + const body: unknown = await res.json(); + errorBody = body; + const bodyRecord = + body && typeof body === "object" + ? (body as Record) + : null; + const bodyError = bodyRecord?.error; + const bodyMessage = bodyRecord?.message; + if (typeof bodyError === "string") { + errorMessage = bodyError; + } else if (typeof bodyMessage === "string") { + errorMessage = bodyMessage; + } else if (typeof body === "string") { + errorMessage = body; + } else { + errorMessage = res.statusText || "Something went wrong"; + } } catch { try { - rawContent = await res.text(); + const rawContent = await res.text(); + if (rawContent) { + errorBody = rawContent; + } errorMessage = rawContent || res.statusText || "Something went wrong"; } catch { errorMessage = res.statusText || "Something went wrong"; } } + if (errorMode === "structured") { + throw createStructuredApiError({ + message: errorMessage, + status: res.status, + headers: normalizeHeaders((res as { headers?: unknown }).headers), + body: errorBody, + }); + } + return Promise.reject(errorMessage); }; @@ -61,7 +137,8 @@ const executeApiRequest = async ( headers: Record, body?: BodyInit, signal?: AbortSignal, - parseJson: boolean = true + parseJson: boolean = true, + errorMode: ApiErrorMode = "legacy-string" ): Promise => { try { const res = await fetch(url, { @@ -72,7 +149,7 @@ const executeApiRequest = async ( }); if (!res.ok) { - return handleApiError(res); + return handleApiError(res, errorMode); } if (!parseJson) { @@ -170,7 +247,7 @@ interface RetryOptions { */ export const commonApiFetchWithRetry = async < T, - U = Record + U = Record, >(param: { readonly endpoint: string; readonly headers?: Record | undefined; @@ -187,7 +264,6 @@ export const commonApiFetchWithRetry = async < let attempts = 0; let currentDelayMs = initialDelayMs; - while (true) { try { if (fetchParams.signal?.aborted) { @@ -264,6 +340,7 @@ export const commonApiPost = async >(param: { headers?: Record | undefined; params?: Z | undefined; signal?: AbortSignal | undefined; + errorMode?: ApiErrorMode | undefined; }): Promise => { const url = buildUrl( param.endpoint, @@ -275,7 +352,9 @@ export const commonApiPost = async >(param: { "POST", getHeaders(param.headers, true), JSON.stringify(param.body), - param.signal + param.signal, + true, + param.errorMode ?? "legacy-string" ); }; @@ -314,7 +393,7 @@ export const commonApiDelete = async (param: { export const commonApiDeleteWithBody = async < T, U, - Z = Record + Z = Record, >(param: { endpoint: string; body: T; From 5443dfbcf401f09e783970f5599786dd310bd976 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 4 Mar 2026 14:24:23 -0400 Subject: [PATCH 7/7] wip Signed-off-by: Simo --- __tests__/services/common-api.test.ts | 101 +++++++++++++----- .../notifications/NotificationsContext.tsx | 14 ++- services/api/common-api.ts | 52 ++++----- 3 files changed, 113 insertions(+), 54 deletions(-) diff --git a/__tests__/services/common-api.test.ts b/__tests__/services/common-api.test.ts index 19da4edbd9..971f7d91e6 100644 --- a/__tests__/services/common-api.test.ts +++ b/__tests__/services/common-api.test.ts @@ -46,7 +46,7 @@ describe("commonApiFetch", () => { ok: false, status: 400, statusText: "Bad", - json: async () => ({ error: "err" }), + text: async () => JSON.stringify({ error: "err" }), }); await expect(commonApiFetch({ endpoint: "bad" })).rejects.toBe("err"); @@ -98,7 +98,7 @@ describe("commonApiPost", () => { ok: false, status: 400, statusText: "B", - json: async () => ({ error: "err" }), + text: async () => JSON.stringify({ error: "err" }), }); await expect(commonApiPost({ endpoint: "e", body: {} })).rejects.toBe( @@ -125,34 +125,85 @@ describe("commonApiPost", () => { status: 429, statusText: "Too Many Requests", headers: responseHeaders, - json: async () => ({ error: "rate limited" }), + text: async () => JSON.stringify({ error: "rate limited" }), }); - const error = await commonApiPost({ - endpoint: "e", - body: {}, - errorMode: "structured", - }).catch( - (error) => - error as { - message: string; + let error: { + message: string; + status: number; + headers: Headers; + response: { + status: number; + headers: Headers; + body?: unknown; + }; + } | null = null; + + try { + await commonApiPost({ + endpoint: "e", + body: {}, + errorMode: "structured", + }); + } catch (caught) { + error = caught as { + message: string; + status: number; + headers: Headers; + response: { status: number; headers: Headers; - response: { - status: number; - headers: Headers; - body?: unknown; - }; - } - ); + body?: unknown; + }; + }; + } expect(error).toBeInstanceOf(Error); - expect(error.message).toBe("rate limited"); - expect(error.status).toBe(429); - expect(error.headers).toBe(responseHeaders); - expect(error.headers.get("retry-after")).toBe("2"); - expect(error.response.status).toBe(429); - expect(error.response.headers).toBe(responseHeaders); - expect(error.response.body).toEqual({ error: "rate limited" }); + expect(error?.message).toBe("rate limited"); + expect(error?.status).toBe(429); + expect(error?.headers).toBe(responseHeaders); + expect(error?.headers.get("retry-after")).toBe("2"); + expect(error?.response.status).toBe(429); + expect(error?.response.headers).toBe(responseHeaders); + expect(error?.response.body).toBe('{"error":"rate limited"}'); + }); + + it("prefers message when error key is missing", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 400, + statusText: "Bad Request", + text: async () => JSON.stringify({ message: "from message field" }), + }); + + await expect(commonApiPost({ endpoint: "e", body: {} })).rejects.toBe( + "from message field" + ); + }); + + it("falls back to raw text when body is non-json", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + text: async () => "plain error text", + }); + + await expect(commonApiPost({ endpoint: "e", body: {} })).rejects.toBe( + "plain error text" + ); + }); + + it("falls back to statusText when response body is empty", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 503, + statusText: "Service Unavailable", + text: async () => "", + }); + + await expect(commonApiPost({ endpoint: "e", body: {} })).rejects.toBe( + "Service Unavailable" + ); }); }); diff --git a/components/notifications/NotificationsContext.tsx b/components/notifications/NotificationsContext.tsx index dc7cb6da3d..869712e3a7 100644 --- a/components/notifications/NotificationsContext.tsx +++ b/components/notifications/NotificationsContext.tsx @@ -737,11 +737,19 @@ export const NotificationsProvider: React.FC<{ children: React.ReactNode }> = ({ token: string, profile?: ApiIdentity ): Promise => { - const fingerprint = createPushRegistrationFingerprint({ + const fingerprintInput: { + deviceId: string; + token: string; + profile?: ApiIdentity; + } = { deviceId, token, - profile, - }); + }; + if (profile !== undefined) { + fingerprintInput.profile = profile; + } + + const fingerprint = createPushRegistrationFingerprint(fingerprintInput); const previousSuccess = lastSuccessfulRegistrationRef.current; if ( diff --git a/services/api/common-api.ts b/services/api/common-api.ts index 7eadf57bb5..6fc9084ac5 100644 --- a/services/api/common-api.ts +++ b/services/api/common-api.ts @@ -86,37 +86,37 @@ const handleApiError = async ( res: Response, errorMode: ApiErrorMode ): Promise => { - let errorMessage: string; + const fallbackErrorMessage = res.statusText || "Something went wrong"; + let errorMessage = fallbackErrorMessage; let errorBody: unknown = undefined; try { - const body: unknown = await res.json(); - errorBody = body; - const bodyRecord = - body && typeof body === "object" - ? (body as Record) - : null; - const bodyError = bodyRecord?.error; - const bodyMessage = bodyRecord?.message; - if (typeof bodyError === "string") { - errorMessage = bodyError; - } else if (typeof bodyMessage === "string") { - errorMessage = bodyMessage; - } else if (typeof body === "string") { - errorMessage = body; - } else { - errorMessage = res.statusText || "Something went wrong"; - } - } catch { - try { - const rawContent = await res.text(); - if (rawContent) { - errorBody = rawContent; + const rawContent = await res.text(); + + if (rawContent) { + errorBody = rawContent; + try { + const parsedBody: unknown = JSON.parse(rawContent); + const bodyRecord = + parsedBody && typeof parsedBody === "object" + ? (parsedBody as Record) + : null; + const bodyError = bodyRecord?.["error"]; + const bodyMessage = bodyRecord?.["message"]; + + if (typeof bodyError === "string") { + errorMessage = bodyError; + } else if (typeof bodyMessage === "string") { + errorMessage = bodyMessage; + } else { + errorMessage = rawContent; + } + } catch { + errorMessage = rawContent; } - errorMessage = rawContent || res.statusText || "Something went wrong"; - } catch { - errorMessage = res.statusText || "Something went wrong"; } + } catch { + errorMessage = fallbackErrorMessage; } if (errorMode === "structured") {