Routing & Smart Retries
Routing & Smart Retries
MVS Pay decides which connector authorizes a payment and what happens when
a connector declines. Both decisions run inside Hyperswitch, not in MVS
code. The orchestration service (apps/mvs-pay-extensions) and the SDK
(@mvs/mvs-pay-sdk) only configure the rules and flip the toggles; the actual
selection and retry logic execute in the Hyperswitch router on every payment.
This is a direct application of ADR-007: Maximum native Hyperswitch bias — surface every Hyperswitch feature through the SDK or UI rather than rebuilding a parallel engine MVS-side. Routing, the decision-engine MAB, and Smart Retries are listed in that ADR as the canonical “native features are first-class consumers” cases.
Three independent layers, all delegated to Hyperswitch. Static rules pick a connector deterministically; dynamic routing (the MAB) overrides the static choice for a configurable share of traffic; Smart Retries recovers a declined attempt by retrying on an alternate connector. They compose — a payment can be MAB-routed and then Smart-Retried — but each is toggled separately on the merchant’s Hyperswitch business profile.
How the pieces fit together
The three configuration surfaces map cleanly onto the SDK:
Source of truth:
apps/mvs-pay-extensions/src/routes/routing.tsapps/mvs-pay-extensions/src/routes/routing-dynamic.tsapps/mvs-pay-extensions/src/routes/smart-retries.tspackages/mvs-pay-sdk/src/namespaces/routing.ts,routing-dynamic.ts,smart-retries.tspackages/mvs-pay-sdk/src/data/gsm-seed.ts
Static routing rules
A static routing rule is a Hyperswitch routing algorithm scoped to a
business profile. MVS Pay supports the four upstream variants, validated at the
service edge in routing.ts against a local Zod mirror of the SDK’s
CreateRoutingInput discriminated union (the SDK exports types only, so the
runtime schema is re-declared inline — keep both in lockstep when the union
grows).
The Zod routingAlgorithmSchema in routing.ts accepts all five variants
including volume_split_priority. The rule-DSL editor UI
(apps/mvs-pay/app/(dashboard)/routing/page.tsx) only exposes four —
single, priority, volume_split, and advanced. volume_split_priority is
reachable via the SDK / API but has no first-class control in the operator UI;
use the Control Center deep-link for it.
The advanced rule-DSL
The advanced variant carries a list of rules, each with a name, a
routing_type (priority or volume_split), an output (connector list or
weighted split), and statements. Each statement is an array of conditions:
A full advanced algorithm posted through the SDK:
Lifecycle: create, activate, deactivate
Creating a rule does not activate it. Activation is a separate step, profile-scoped. The service exposes:
On activate/deactivate the service also writes a mirror of the active
algorithm id onto the tenant’s MerchantExtension row. setActiveRoutingId()
finds the MerchantExtension whose hyperswitchProfileId matches the rule’s
profile_id and overwrites routingAlgorithmId; if no profile matches it falls
back to the tenant’s isDefault row. This lets the operator UI surface the
active rule without an extra Hyperswitch round-trip.
The mirror is best-effort and authoritative-on-Hyperswitch-only. If the
MerchantExtension write misses (no profile match and no default row),
setActiveRoutingId() is a no-op — the active rule is still correct on
Hyperswitch’s side, but the UI mirror will be stale. The live rule is always the
one Hyperswitch reports, never the mirror. The same caveat applies to the Smart
Retries mirror below.
Simulating a rule before activation
The operator UI calls POST /api/routing/simulate (a Next.js route handler in
apps/mvs-pay, not a service route). The page comment states the simulator runs
the same evaluator the rule-DSL editor targets, so the “Simulate” button is
faithful to the real engine. The simulate response carries chosen,
candidates[], matchedRule, and notes[].
/api/routing/simulate is the safe way to validate an advanced rule against a
sample payment payload before you activate it. There is no separate SDK method
for simulation — it is a UI-only convenience endpoint.
Control Center deep-link (the escape hatch)
The rule-DSL editor is deliberately a thin shell over Hyperswitch’s algorithm
JSON. When a rule is too complex for the editor (or you need
volume_split_priority, or any field the editor doesn’t expose), the footer
deep-links the Hyperswitch Control Center routing screen:
This URL is hardcoded in apps/mvs-pay/app/(dashboard)/routing/page.tsx
(HYPERSWITCH_CONTROL_CENTER_ROUTING_URL) because the Control Center lives
outside the per-org surface and is not threaded through env. It is an internal
hostname; it resolves only from inside the cluster / VPN, not from the public
internet.
Dynamic routing — the decision-engine MAB
Dynamic routing lets Hyperswitch override the static choice using runtime signals. The connector recommendation is computed by the decision-engine, a Rust service from Juspay that runs a Multi-Armed Bandit (MAB) over per-connector success rates. The Hyperswitch router queries it over an in-cluster ClusterIP on every payment where the profile has dynamic routing enabled. See the Decision-engine runbook for deploy and operation.
routing-dynamic.ts exposes six pass-through routes. There is no MVS-side
logic beyond tenant-scoping and Zod validation — every route forwards to
Hyperswitch’s per-profile dynamic-routing toggles.
The three modes are orthogonal and may be enabled together:
- success-rate-based — the MAB exploits the highest-scoring connector, exploring others a fixed fraction of the time.
- elimination — temporarily drops a connector that flaps (fails repeatedly in a sliding window) so the next payment skips it.
- contract — routes a fixed percentage to a connector to satisfy a signed volume SLA.
All six dynamic routes require both merchantId and profileId. The GET
routes return 400 missing_query if either query param is absent; all routes
return 400 tenant_required if the org context header is missing (construct the
MvsPayClient with orgId / orgSlug).
MAB tuning parameters
These live in the decision-engine chart values
(deploy/decision-engine/values.yaml), not in MVS application code. They are
documented here because they explain the routing behavior an engineer will
observe:
Fallback is silent and frequent during warm-up. Until a connector
accumulates 200 samples, or if the engine times out (100ms) or its url is
empty, the router uses the static rule with no error. If
/routing/dynamic-stats shows all connectors with identical scores, the most
likely cause is traffic below min_aggregates_size or the engine being in its
exploration phase — not a bug. An empty score-window (entries: []) means the
MAB has not yet hit 200 samples for any connector.
Reading scores
State lives in Redis, shared across decision-engine replicas, so the score
window is global to the merchant, not per-pod. The operator UI surfaces this
read-only at /routing/dynamic-stats. The
successRateScoreReconcileWorkflow (Temporal, Track D) also consumes
getScoreWindow to emit Datadog metrics — see the
Temporal worker surface.
To disable dynamic routing globally without uninstalling the engine, set
decisionEngine.url: "" in deploy/hyperswitch/values-prod.yaml (the router
then falls back to static rules); Argo CD reconciles in ~60s. Full rollback in
the Decision-engine runbook.
Smart Retries — native decline recovery
Smart Retries is Hyperswitch’s native auto-retry engine. When a connector
declines, Hyperswitch consults the gateway_status_map (GSM) to decide
whether to retry the attempt on an alternate connector, escalate to step-up 3DS,
or surface the decline. The retry engine runs inside Hyperswitch — the
/v1/smart-retries routes only configure the rules and the on/off switch.
Smart Retries is two cooperating halves:
Profile flag
POST /v1/smart-retries/enable flips is_auto_retries_enabled: true and sets
max_auto_retries_enabled on the merchant’s Hyperswitch business_profile
(defaults to 3 when maxRetries is omitted; capped at 10 by the Zod schema).
/disable sets it back to false. Both mirror the state onto
MerchantExtension.smartRetriesEnabled / maxAutoRetries via
syncSmartRetriesFlag(), keyed by hyperswitchProfileId.
A retry needs somewhere to retry to. If only one
merchant_connector_account is configured, there is no alternate connector and
declines will never retry even with auto-retries enabled. Confirm a second
connector exists — see Connector
Config. The other common cause of
“declines never retry” is a missing GSM rule for the (connector, code) pair.
The gateway_status_map (GSM)
A GSM rule maps a connector’s decline code to a decision. The relevant fields:
Wildcard rows (connector: "*") apply to every connector unless a more
specific (connector, code) rule overrides them — Hyperswitch’s GSM lookup
respects the fallback pattern.
Seeded default rules
POST /v1/smart-retries/gsm/seed with { "connector": "<name>" } iterates a
server-side DEFAULT_GSM_RULES list and posts every rule matching the requested
connector plus the wildcard rules.
DEFAULT_GSM_RULES is duplicated in two places that must be kept in sync by
hand. The authoritative seed dataset is
packages/mvs-pay-sdk/src/data/gsm-seed.ts (typed CreateGsmRuleInput[]), but
apps/mvs-pay-extensions/src/routes/smart-retries.ts re-declares the same
list locally — because the extensions service must not depend on the SDK (the
SDK is a consumer of the service, not a peer). The two lists are identical
today; if you edit one, edit the other.
The default list covers stripe, adyen, cybersource, braintree, and
finix, plus one network-level wildcard. The pattern is: soft declines →
retry (usually with step_up_possible: true), hard declines →
do_not_retry.
Seeding is idempotent. Re-posting an existing (connector, code, sub_flow)
tuple returns a < 500 “already exists” error from Hyperswitch, which the seed
loop swallows and continues. The route returns { rulesSeeded: n } counting
only newly created rules — so a re-seed of an already-seeded connector
correctly returns rulesSeeded: 0. The connector wizard auto-seeds on connector
creation, which is why the call must never fail the wizard. rulesSeeded: 0 can
also mean the connector simply has no default rules.
GSM CRUD
The remaining routes pass straight through to Hyperswitch’s /v1/gsm:
For the full operational procedure (enable, seed, verify, troubleshoot), see the Smart Retries runbook.
What lives where
Why routing and retries are delegated, not rebuilt.
Deploy and operate the MAB service; Redis, sizing, tuning, rollback.
Enable, seed GSM, verify retries fire, troubleshoot.
Add the second connector a retry / split needs to route to.
The /v1/routing and /v1/smart-retries route groups.
Request/response schemas for every route above.
Gaps & caveats
Known gaps an owner should track
- Nothing is deployed yet. Like the rest of the platform, the decision-engine is built but not provisioned — Redis, the Argo CD application, and ExternalSecrets must be stood up before the MAB does anything. See Deployment Readiness.
- Two copies of
DEFAULT_GSM_RULES(gsm-seed.tsandsmart-retries.ts) drift if not edited together. There is no test asserting they match. - The routing Zod schema and the SDK
CreateRoutingInputunion are manually kept in lockstep. Adding a variant means editing bothrouting.tsand the SDK types. volume_split_priorityis API-reachable but has no operator-UI control.- The
MerchantExtensionmirrors (active routing id, Smart Retries flag) are best-effort caches for the UI, not sources of truth. They can silently drift; Hyperswitch is always authoritative. - The Control Center deep-link hostname (
hyperswitch.payments.internal) is hardcoded and internal-only — it will 404 from outside the cluster/VPN. /api/routing/simulateis a Next.js UI route, not part of the SDK or the extensions service; consumers cannot call it programmatically.