Cascade Manager
Failover logic across PSPs. Iterates candidates by priority until first success.
CASCADE_MAX_ATTEMPTS environment variable.Cascade Example
PSP #1 and PSP #2 are unavailable. Cascade automatically falls over to PSP #3, which processes the request successfully.
PSP #1 (priority 1)
Timeout after 30 s
PSP #2 (priority 2)
Soft rejection: limit
PSP #3 (priority 3)
Invoice created
Total latency = sum(timeout of each unresponsive PSP) + successful PSP time. In the example: ~60 s + ~1 s. This is why a short adapter-level timeout is critical.
Algorithm
Receive ordered candidate list from Orchestrator
List is already filtered by brand, geo, direction, method and sorted by priority ASC. Cascade iterates it left to right.
Pick first candidate (attempt #1)
Write psp_config_id to the transaction record. Log a cascade_attempt event to transaction_events.
Call adapter.initiateDeposit()
Synchronous call with configured timeout (default: 30 s). Cascade wraps the call in try/catch.
Success path
Adapter returns UnifiedResponse. State machine transitions to PROCESSING. Cascade returns result immediately -- no further candidates tried.
Failure path -- classify error
Determine if the error is retryable (network, soft rejection, adapter error) or not (hard rejection). Log to transaction_events with full error detail.
Hard rejection -- stop immediately
Do not try remaining candidates. Transition transaction to FAILED. Return error code CASCADE_HARD_REJECTION.
Retryable error -- advance to next candidate
Remove failed PSP from candidate list. If candidates remain and attempt count < MAX_ATTEMPTS (3) -- go back to step 2 with next candidate.
All candidates exhausted
No more PSPs to try or MAX_ATTEMPTS reached. Transition transaction to FAILED. Return error code CASCADE_ALL_FAILED.
Cascade Rules
On every failure the Cascade Manager answers two questions: will another PSP help? is it safe to try further? If both are "yes" -- continue. If either is "no" -- hard stop.
Continue cascade -- try next PSP
| Situation | Why continue |
|---|---|
| Network error / timeout | Infrastructure failure of this specific PSP -- another may be alive |
| PSP scheduled maintenance | Temporary unavailability -- next PSP unaffected |
| Our merchant rate limit at this PSP | Quota exhausted on our merchant account -- next PSP has its own independent quota |
| Method not supported by this PSP | PSP-level gap -- another PSP may support the same method |
| Amount outside this PSP's limits | PSP-specific limit -- next PSP may have different min/max |
| PSP temporarily does not support currency | Temporary or config issue on this PSP -- next PSP may accept |
| Unexpected / unparseable PSP response | Bug in adapter or non-standard response -- different adapter, different code path |
| User not registered in this PSP | Some PSPs require pre-registration -- next PSP may not have this requirement |
Hard stop -- stop cascade
| Situation | Why stop |
|---|---|
| Fraud block (confirmed) | Cascading past a fraud block bypasses fraud controls -- legal and chargeback risk. Hard stop always. |
| KYC required | User must complete identity verification before any PSP will accept them -- not a PSP issue |
| Duplicate transaction | Trying next PSP creates another duplicate -- makes the situation worse |
| User account blocked at platform level | Platform-level block -- not a PSP problem, cascading will not help |
| Invalid wallet address (withdrawal) | Malformed address -- every PSP will reject for the same reason |
| Regulatory geo-block | Country is legally restricted -- no licensed PSP can process it |
| Amount exceeds limits of ALL active PSPs | Can be detected in Router before cascade starts -- if amount > max of every candidate, no point trying |
Grey zones -- require explicit operator decision
| Situation | Problem | Decision needed |
|---|---|---|
| "Insufficient funds" from PSP | Could be PSP liquidity issue (continue) or player actually has no funds (stop) | Clarify with PassimPay which error code maps to which cause |
| "Fraud suspected" (soft signal) | PSPs vary: some return soft "suspicious" vs hard "confirmed fraud". Soft may be worth cascading. | Agree with PassimPay on exact error codes for soft vs hard fraud signals |
| User deposit limit exceeded | "Limit exceeded" can mean player's personal PSP limit (stop) OR our merchant quota at that PSP (continue) | PSP must return distinct error codes for player limit vs merchant limit |
| Method temporarily disabled by PSP | Temporary PSP config (continue to next PSP) vs regulatory suspension (stop for all) | Track known regulatory suspensions in psp_configs.is_active -- keep PSP disabled until resolved |
Implementation: decision at adapter level
Cascade Manager does not interpret PSP-specific error codes -- that is the adapter's responsibility. Each adapter returns a cascade field alongside the error. Cascade Manager only looks at that field.
type CascadeDecision = 'continue' | 'hard_stop'
interface AdapterError {
code: string // PSP-specific error code
message: string
cascade: CascadeDecision // adapter decides, not Cascade Manager
pspRaw?: unknown // raw PSP response for logs
}
// Example: PassimPay adapter maps its error codes
function mapPassimPayError(pspCode: string): CascadeDecision {
const HARD_STOPS = ['FRAUD_CONFIRMED', 'KYC_REQUIRED', 'DUPLICATE_TX', 'USER_BLOCKED']
return HARD_STOPS.includes(pspCode) ? 'hard_stop' : 'continue'
}Failure Types & Response
| Failure type | Examples | Retryable? | Cascade action | Log |
|---|---|---|---|---|
| Network / timeout | Connection refused, read timeout, DNS failure | Yes | Next candidate in list | warn |
| PSP soft rejection | Limit exceeded, method unavailable, currency mismatch | Yes | Next candidate in list | warn |
| PSP hard rejection | Fraud block, KYC required, account suspended | No | Stop cascade, return FAILED | error |
| Adapter internal error | Unexpected response format, missing required field in PSP response | Yes | Next candidate in list | error |
Events in transaction_events
Every cascade action is written to transaction_events. This provides a full audit trail for debugging and reconciliation.
| event_type | When | payload |
|---|---|---|
cascade_attempt | Before each adapter call | { psp_name, attempt_number, candidate_count } |
cascade_attempt_failed | After a retryable failure | { psp_name, error_type, error_message, duration_ms } |
cascade_hard_stop | On hard rejection | { psp_name, error_code, psp_raw_response } |
cascade_success | After successful adapter call | { psp_name, attempt_number, duration_ms } |
cascade_all_failed | When all candidates exhausted | { attempts: [{ psp_name, error }], total_duration_ms } |
Timeouts
Per-adapter timeout
ADAPTER_TIMEOUT_MSMax time to wait for a single PSP response. After this, the call is classified as a network timeout and cascade proceeds to the next candidate.
Total cascade budget
CASCADE_TOTAL_TIMEOUT_MSHard limit for the entire cascade (all attempts combined). If exceeded, remaining candidates are skipped and the transaction transitions to TIMED_OUT.
Max attempts
CASCADE_MAX_ATTEMPTSMaximum number of PSP candidates to try per request, regardless of how many are configured. Prevents tail latency from growing unbounded with many PSPs.
Health Score (Phase 6)
Not implemented in Phase 2
In Phase 6 the Cascade Manager will gain access to the health_score field from psp_configs. Instead of pure priority ordering, the candidate list will be re-ranked: priority * (health_score / 100). PSPs with sustained failures automatically sink lower in the cascade without manual ops intervention.