diff --git a/lib/__tests__/throwIfNotOk.test.ts b/lib/__tests__/throwIfNotOk.test.ts
new file mode 100644
index 00000000..bde9b08a
--- /dev/null
+++ b/lib/__tests__/throwIfNotOk.test.ts
@@ -0,0 +1,58 @@
+///
+
+import { throwIfNotOk } from '@/lib/api';
+
+/**
+ * Regression guard for SOLID-W3: ensureWebhookSubscription (and all other
+ * fetch calls in lib/api.ts) previously threw the raw Response object when the
+ * server returned a non-2xx status. TanStack Mutation's onError passed it
+ * directly to Sentry.captureException, which serialised the Response keys
+ * (_bodyBlob, _bodyInit, ok, status…) as the exception title rather than a
+ * human-readable message.
+ *
+ * throwIfNotOk ensures every failed fetch produces a proper Error instance.
+ */
+describe('throwIfNotOk', () => {
+ const makeResponse = (status: number, statusText: string, ok: boolean): Response =>
+ ({
+ ok,
+ status,
+ statusText,
+ url: 'https://api.example.com/test',
+ }) as unknown as Response;
+
+ it('does nothing when the response is OK', () => {
+ const response = makeResponse(200, 'OK', true);
+ expect(() => throwIfNotOk(response)).not.toThrow();
+ });
+
+ it('throws an Error instance (not the Response object) on a non-OK response', () => {
+ const response = makeResponse(401, 'Unauthorized', false);
+ expect(() => throwIfNotOk(response)).toThrow(Error);
+ });
+
+ it('includes the HTTP status code in the error message', () => {
+ const response = makeResponse(401, 'Unauthorized', false);
+ expect(() => throwIfNotOk(response)).toThrow('401');
+ });
+
+ it('includes the status text in the error message', () => {
+ const response = makeResponse(503, 'Service Unavailable', false);
+ expect(() => throwIfNotOk(response)).toThrow('Service Unavailable');
+ });
+
+ it('includes the URL in the error message', () => {
+ const response = makeResponse(500, 'Internal Server Error', false);
+ expect(() => throwIfNotOk(response)).toThrow('https://api.example.com/test');
+ });
+
+ it('throws for 4xx responses', () => {
+ const response = makeResponse(403, 'Forbidden', false);
+ expect(() => throwIfNotOk(response)).toThrow(Error);
+ });
+
+ it('throws for 5xx responses', () => {
+ const response = makeResponse(500, 'Internal Server Error', false);
+ expect(() => throwIfNotOk(response)).toThrow(Error);
+ });
+});
diff --git a/lib/api.ts b/lib/api.ts
index 32ac3257..6430f374 100644
--- a/lib/api.ts
+++ b/lib/api.ts
@@ -165,6 +165,14 @@ import { generateClientNonceData } from './utils/cardDetailsReveal';
import { decryptSecret, generateSessionId } from './utils/rainCardSecrets';
import { revealWirexCardWithSession } from './utils/wirexCardReveal';
+// Throws a proper Error when a fetch response is not OK, so callers always
+// receive an Error instance rather than a raw Response object.
+export function throwIfNotOk(response: Response): void {
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText} (${response.url})`);
+ }
+}
+
// Helper function to get platform-specific headers
export const getPlatformHeaders = () => {
const headers: Record = {};
@@ -290,7 +298,7 @@ export const refreshToken = async () => {
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response;
};
@@ -317,7 +325,7 @@ export const signUp = async (
credentials: 'include',
body: JSON.stringify(body),
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -336,7 +344,7 @@ export const updateSafeAddress = async (safeAddress: string) => {
body: JSON.stringify({ safeAddress }),
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -355,7 +363,7 @@ export const addReferrer = async (referralCode: string) => {
body: JSON.stringify({ referralCode }),
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -374,7 +382,7 @@ export const updateUserCredentialId = async (credentialId: string) => {
body: JSON.stringify({ credentialId }),
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -389,7 +397,7 @@ export const logout = async () => {
},
credentials: 'include',
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -408,7 +416,7 @@ export const updateExternalWalletAddress = async (externalWalletAddress: string)
body: JSON.stringify({ externalWalletAddress }),
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -544,7 +552,7 @@ export const createKycLink = async (
body: JSON.stringify(body),
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -563,7 +571,7 @@ export const getKycLink = async (kycLinkId: string): Promise => {
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -584,7 +592,7 @@ export const getKycLinkFromBridge = async (
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -606,7 +614,7 @@ export const submitPersonaKyc = async (
body: JSON.stringify({ personaInquiryId }),
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -635,7 +643,7 @@ export const personaSimulateAction = async (
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -686,7 +694,7 @@ export const submitRainKyc = async (formData: FormData): Promise => {
if (response.status === 404) return null;
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -1002,7 +1010,7 @@ export const getCustomerFromBridge = async (): Promise => {
if (response.status === 404) return null;
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -1219,7 +1227,7 @@ export const getCardDetails = async (): Promise =
// Response that appears as a raw LogBox error in development.
if (response.status === 404) return null;
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -1236,7 +1244,7 @@ export const getCardBalance = async (): Promise => {
},
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -1268,7 +1276,7 @@ export const getCardContracts = async (): Promise =>
},
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -1298,7 +1306,7 @@ export const getCardCollateralAvailable = async (
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -1319,7 +1327,7 @@ export const getOnrampAutomation = async (): Promise => {
headers: wirexBankHeaders(),
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -1399,7 +1407,7 @@ export const activateWirexBankAccount = async (
body: JSON.stringify({ accountType }),
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -1413,7 +1421,7 @@ export const initWirexWalletLink = async (): Promise
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -1664,7 +1672,7 @@ export const getMppCredentials = async (): Promise => {
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -1687,7 +1695,7 @@ export const getWebProvisioningToken = async (): Promise {
filterValue: username,
}),
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -1880,7 +1888,7 @@ export const fetchPoints = async (): Promise => {
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -1900,7 +1908,7 @@ export const fetchReferralSummary = async (): Promise => {
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -1927,7 +1935,7 @@ export const fetchLeaderboardUsers = async (params: {
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -1946,7 +1954,7 @@ export const fetchRewardsUserData = async (): Promise => {
},
credentials: 'include',
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -1961,7 +1969,7 @@ export const optInToRewards = async (): Promise => {
},
credentials: 'include',
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -1990,7 +1998,7 @@ export const activateTierTrial = async (): Promise => {
credentials: 'include',
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -2010,7 +2018,7 @@ export const fetchTierBenefits = async (): Promise => {
credentials: 'include',
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -2033,7 +2041,7 @@ export const fetchProductFeeRates = async (): Promise => {
},
credentials: 'include',
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -2053,7 +2061,7 @@ export const fetchProductFeeQuote = async (
credentials: 'include',
body: JSON.stringify({ product, baseAmountUsd }),
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -2078,7 +2086,7 @@ export const recordSwapFee = async (
credentials: 'include',
body: JSON.stringify(params),
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -2105,7 +2113,7 @@ export const recordStocksFee = async (
body: JSON.stringify(params),
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -2118,7 +2126,7 @@ export const fetchRewardsConfig = async (): Promise => {
},
credentials: 'include',
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -2165,7 +2173,7 @@ export const createMercuryoTransaction = async (
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
const data: { widgetUrl: string } = await response.json();
return data.widgetUrl;
@@ -2204,7 +2212,7 @@ export const fetchOnramperSession = async (): Promise => {
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
const data = await response.json();
@@ -2227,7 +2235,7 @@ export const bridgeDeposit = async (
body: JSON.stringify(bridge),
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -2248,7 +2256,7 @@ export const bridgeDepositTransactions = async (
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -2303,7 +2311,7 @@ export const createBridgeTransfer = async (params: {
body: JSON.stringify(params),
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -2347,7 +2355,7 @@ export const createDeposit = async (deposit: Deposit): Promise<{ transactionHash
body: JSON.stringify(deposit),
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -2366,7 +2374,7 @@ export const depositTransactions = async (safeAddress: string): Promise => {
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -2503,7 +2511,7 @@ export const freezeCard = async (): Promise<{ message: string }> => {
credentials: 'include',
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -2521,7 +2529,7 @@ export const unfreezeCard = async (): Promise<{ message: string }> => {
credentials: 'include',
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -2540,7 +2548,7 @@ export const withdrawFromCard = async (body: CardWithdrawal): Promise => {
credentials: 'include',
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -2711,7 +2719,7 @@ export const getCardTransaction = async (
// retrying, and the detail screen falls back to exactly that.
if (response.status === 404) return null;
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -2895,7 +2903,7 @@ export const setupTotp = async (): Promise<{
credentials: 'include',
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -2920,7 +2928,7 @@ export const verifyTotp = async (
body: JSON.stringify({ code, context }),
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -2941,7 +2949,7 @@ export const getTotpStatus = async (): Promise<{ verified: boolean }> => {
credentials: 'include',
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -2962,7 +2970,7 @@ export const createActivityEvent = async (
body: JSON.stringify(event),
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -2984,7 +2992,7 @@ export const fetchActivityEvents = async (
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -3009,7 +3017,7 @@ export const updateActivityEvent = async (
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -3030,7 +3038,7 @@ export const bulkUpsertActivityEvent = async (
body: JSON.stringify(events),
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -3075,7 +3083,7 @@ export const syncActivities = async (
credentials: 'include',
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -3106,7 +3114,7 @@ export const requestCardSecrets = async (
credentials: 'include',
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -3130,7 +3138,7 @@ export const updateCardPin = async (
body: JSON.stringify({ encryptedPin }),
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
const text = await response.text();
return text ? JSON.parse(text) : {};
@@ -3149,7 +3157,7 @@ export const getCardPin = async (sessionIdBase64: string): Promise => {
});
if (response.status === 404 || response.status === 204) return null;
- if (!response.ok) throw response;
+ throwIfNotOk(response);
const text = await response.text();
if (!text) return null;
@@ -3519,7 +3527,7 @@ export const fetchPromotionsBanner = async (): Promise
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -3538,7 +3546,7 @@ export const fetchActivityEvent = async (clientTxId: string): Promise(path: string, body?: unknown): Promise => {
credentials: 'include',
body: body === undefined ? undefined : JSON.stringify(body),
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -3846,7 +3854,7 @@ export const fetchAgent = async (): Promise => {
headers: agentJsonHeaders(),
credentials: 'include',
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -3864,7 +3872,7 @@ export const fetchAgentHasDeposited = async (): Promise => {
},
credentials: 'include',
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
const json = (await response.json()) as { totalDocs?: number; docs?: unknown[] };
return (json.totalDocs ?? json.docs?.length ?? 0) > 0;
};
@@ -3875,7 +3883,7 @@ export const fetchAgentApiKeys = async (): Promise => {
headers: agentJsonHeaders(),
credentials: 'include',
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -3886,7 +3894,7 @@ export const generateAgentApiKey = async (name?: string): Promise => {
headers: agentJsonHeaders(),
credentials: 'include',
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
};
export const fetchAddressBook = async (): Promise => {
@@ -3911,7 +3919,7 @@ export const fetchAddressBook = async (): Promise => {
credentials: 'include',
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -3928,7 +3936,7 @@ export const addToAddressBook = async (data: AddressBookRequest): Promise => {
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -3976,7 +3984,7 @@ export const getHoldingFundsPointsMultiplier =
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -4011,7 +4019,7 @@ export const getWebhookStatus = async (): Promise => {
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -4037,7 +4045,7 @@ export const ensureWebhookSubscription = async (): Promise {
credentials: 'include',
body: JSON.stringify({ token, platform }),
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -4072,7 +4080,7 @@ export const removePushToken = async (token: string) => {
credentials: 'include',
body: JSON.stringify({ token }),
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -4088,7 +4096,7 @@ export const trackUserPlatform = async (platform: typeof Platform.OS) => {
credentials: 'include',
body: JSON.stringify({ platform }),
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
};
/**
@@ -4116,7 +4124,7 @@ export const recordAppOpen = async (
deviceId,
}),
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -4138,7 +4146,7 @@ export const markStoreReviewPrompted = async (
body: JSON.stringify({ platform }),
},
);
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};
@@ -4158,7 +4166,7 @@ export const fetchSavingsSummary = async (
credentials: 'include',
});
- if (!response.ok) throw response;
+ throwIfNotOk(response);
return response.json();
};