Skip to content

Commit d9a4f36

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 57a52db commit d9a4f36

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
@@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- 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).
1313

14+
### Fixed
15+
16+
- 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).
17+
1418
## [7.76.3]
1519

1620
### Added

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
@@ -242,10 +242,20 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => {
242242
if (error instanceof LimitExceededError) {
243243
throw error;
244244
}
245+
if (headlessSessionId) {
246+
// Headless mode: route the swallowed infrastructure error through
247+
// the session so the consumer receives an onError callback, then
248+
// rethrow so the caller's outer catch unwinds the flow.
249+
failSession(headlessSessionId, error);
250+
throw error;
251+
}
252+
// Non-headless (UB2) preserves today's swallow — the existing test
253+
// 'logs error and returns when getUserLimits throws a non-limit
254+
// error' encodes that as intent.
245255
Logger.error(error as Error, 'Failed to check user limits');
246256
}
247257
},
248-
[getUserLimits, fiatCurrency, selectedPaymentMethod?.id],
258+
[getUserLimits, fiatCurrency, selectedPaymentMethod?.id, headlessSessionId],
249259
);
250260

251261
const navigateToVerifyIdentityCallback = useCallback(

0 commit comments

Comments
 (0)