Skip to content

Commit 3fff472

Browse files
fix(ramp): route swallowed getUserLimits errors through failSession (Phase 9.5 Fix A)
useTransakRouting.checkUserLimits previously caught errors from getUserLimits, rethrew LimitExceededError, and swallowed every other error (Logger.error). In headless mode the consumer got no onError callback when this fired for an infrastructure failure (network blip, malformed response), leaving the session hung. Resolution: in the catch, gate the new behavior on headlessSessionId: if (error instanceof LimitExceededError) throw error; if (headlessSessionId) { failSession(headlessSessionId, error); throw error; } Logger.error(error as Error, 'Failed to check user limits'); UB2's existing swallow is preserved — the existing test 'logs error and returns when getUserLimits throws a non-limit error' encodes the swallow as intent and continues to pass. Two new tests cover (a) failSession invoked + rethrow propagates in headless mode and (b) LimitExceededError short-circuits before failSession. The parallel getUserLimits swallow in useDepositRouting.ts:137-180 is deliberately left untouched (Deposit is not a headless route, and the "ramps work is UB2-only" rule keeps us off the Deposit tree).
1 parent 89c4209 commit 3fff472

4 files changed

Lines changed: 127 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Aligned previously base-enabled custom network logos (Stable, Flow, XDC, Fraxtal, Hemi, Plasma, Lukso, Rootstock, MSU, Lens, Plume) to a square format consistent with Popular networks (#29943)
1313
- Made the Ramp Headless Host invisible so consumer-rendered loading UI is visible during a headless buy; added a transparent `RAMP.HEADLESS_ENTRY` outer route so the headless flow no longer renders an opaque V2 stack card on top of the consumer screen (Phase 9.5).
1414

15+
### Fixed
16+
17+
- Route swallowed `getUserLimits` infrastructure errors through `failSession` when a headless Ramp session is active, so headless consumers receive an `onError` callback instead of a silent hang (Phase 9.5 Fix A).
18+
1519
## [7.75.1]
1620

1721
### Fixed

app/components/UI/Ramp/headless/PLAN.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,29 @@ This is the same pattern MainNavigator already uses for `BridgeModals`, `EarnMod
648648
649649
Safety net for the unverified `beforeRemove` + `transparentModal` interaction: Phase 8's `useHeadlessSessionDismissal` fires `closeSession({ reason: 'user_dismissed' })` on unmount when `HEADLESS_HOST` is no longer in the navigator tree. `closeSession` idempotency means both paths can fire without producing duplicate `onClose` callbacks.
650650
651+
#### Fix A — `getUserLimits` swallow → `failSession` (conditional rethrow)
652+
653+
[useTransakRouting.ts:241-256](../hooks/useTransakRouting.ts) (`checkUserLimits`) catches errors from `getUserLimits`, rethrows `LimitExceededError`, and **swallows everything else** (network blips, malformed responses, etc.). In headless mode the consumer got no callback when this fired.
654+
655+
Resolution: gate the new behavior on `headlessSessionId`:
656+
657+
```ts
658+
} catch (error) {
659+
if (error instanceof LimitExceededError) {
660+
throw error;
661+
}
662+
if (headlessSessionId) {
663+
failSession(headlessSessionId, error);
664+
throw error;
665+
}
666+
Logger.error(error as Error, 'Failed to check user limits');
667+
}
668+
```
669+
670+
UB2's existing swallow stays in place — the existing test at [useTransakRouting.test.ts:957-982](../hooks/useTransakRouting.test.ts) (`'logs error and returns when getUserLimits throws a non-limit error'`) encodes the swallow as intent and continues to pass. Two new tests cover the headless paths (`failSession` invoked + rethrow propagates; `LimitExceededError` short-circuits before `failSession`).
671+
672+
The parallel swallow in [useDepositRouting.ts:137-180](../hooks/useDepositRouting.ts) is deliberately **not** fixed here — Deposit isn't a headless route, and the "ramps work is UB2-only" rule keeps us off the Deposit tree.
673+
651674
---
652675
653676
## Phase 10 — Implement deferred Phase 5b + playground polish

app/components/UI/Ramp/hooks/useTransakRouting.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -980,6 +980,95 @@ describe('useTransakRouting', () => {
980980

981981
expect(mockRequestOtt).toHaveBeenCalled();
982982
});
983+
984+
it('routes getUserLimits infrastructure errors through failSession when headlessSessionId is set', async () => {
985+
const mockFailSession = jest.requireMock('../headless/sessionRegistry')
986+
.failSession as jest.Mock;
987+
mockGetUserDetails.mockResolvedValue({
988+
firstName: 'John',
989+
address: {},
990+
});
991+
mockGetKycRequirement.mockResolvedValue({
992+
status: 'APPROVED',
993+
kycType: 'SIMPLE',
994+
});
995+
const networkError = new Error('Network failure');
996+
mockGetUserLimits.mockRejectedValue(networkError);
997+
mockRequestOtt.mockResolvedValue({ ott: 'test-ott' });
998+
mockGeneratePaymentWidgetUrl.mockReturnValue(
999+
'https://payment.example.com',
1000+
);
1001+
1002+
const { result } = renderHook(() =>
1003+
useTransakRouting({
1004+
baseRoute: 'RampHeadlessHost',
1005+
baseRouteParams: { headlessSessionId: 'headless-buy-fixa' },
1006+
}),
1007+
);
1008+
1009+
// The rethrow propagates through the outer `case 'APPROVED'` catch,
1010+
// which re-wraps as `parseUserFacingError`. We catch the rejection
1011+
// inside act so all microtasks settle before the assertion.
1012+
let caught: unknown;
1013+
await act(async () => {
1014+
try {
1015+
await result.current.routeAfterAuthentication(
1016+
mockQuote as never,
1017+
mockQuote.fiatAmount,
1018+
);
1019+
} catch (e) {
1020+
caught = e;
1021+
}
1022+
});
1023+
expect(caught).toBeInstanceOf(Error);
1024+
1025+
expect(mockFailSession).toHaveBeenCalledWith(
1026+
'headless-buy-fixa',
1027+
networkError,
1028+
);
1029+
// The rethrow unwinds the flow before reaching OTT generation.
1030+
expect(mockRequestOtt).not.toHaveBeenCalled();
1031+
});
1032+
1033+
it('does not call failSession when LimitExceededError fires in headless mode (early rethrow wins)', async () => {
1034+
const mockFailSession = jest.requireMock('../headless/sessionRegistry')
1035+
.failSession as jest.Mock;
1036+
mockFailSession.mockReset();
1037+
mockGetUserDetails.mockResolvedValue({
1038+
firstName: 'John',
1039+
address: {},
1040+
});
1041+
mockGetKycRequirement.mockResolvedValue({
1042+
status: 'APPROVED',
1043+
kycType: 'SIMPLE',
1044+
});
1045+
// Returning daily remaining=0 forces the inner code path to throw
1046+
// LimitExceededError before the generic catch sees it.
1047+
mockGetUserLimits.mockResolvedValue({
1048+
remaining: { '1': 0, '30': 50000, '365': 200000 },
1049+
});
1050+
1051+
const { result } = renderHook(() =>
1052+
useTransakRouting({
1053+
baseRoute: 'RampHeadlessHost',
1054+
baseRouteParams: { headlessSessionId: 'headless-buy-fixa-limit' },
1055+
}),
1056+
);
1057+
1058+
await expect(
1059+
act(async () => {
1060+
await result.current.routeAfterAuthentication(
1061+
mockQuote as never,
1062+
mockQuote.fiatAmount,
1063+
);
1064+
}),
1065+
).rejects.toThrow();
1066+
1067+
// LimitExceededError takes the early rethrow path — failSession isn't
1068+
// invoked because the limit error is a user-actionable condition that
1069+
// the outer flow handles directly.
1070+
expect(mockFailSession).not.toHaveBeenCalled();
1071+
});
9831072
});
9841073

9851074
describe('navigateToVerifyIdentity', () => {

app/components/UI/Ramp/hooks/useTransakRouting.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,10 +244,20 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => {
244244
if (error instanceof LimitExceededError) {
245245
throw error;
246246
}
247+
if (headlessSessionId) {
248+
// Headless mode: route the swallowed infrastructure error through
249+
// the session so the consumer receives an onError callback, then
250+
// rethrow so the caller's outer catch unwinds the flow.
251+
failSession(headlessSessionId, error);
252+
throw error;
253+
}
254+
// Non-headless (UB2) preserves today's swallow — the existing test
255+
// 'logs error and returns when getUserLimits throws a non-limit
256+
// error' encodes that as intent.
247257
Logger.error(error as Error, 'Failed to check user limits');
248258
}
249259
},
250-
[getUserLimits, fiatCurrency, selectedPaymentMethod?.id],
260+
[getUserLimits, fiatCurrency, selectedPaymentMethod?.id, headlessSessionId],
251261
);
252262

253263
const navigateToVerifyIdentityCallback = useCallback(

0 commit comments

Comments
 (0)