Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/api-refs/decide-gateway-sr-based.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ curl --location "$BASE_URL/decide-gateway" \
"rankingAlgorithm": "SR_BASED_ROUTING",
"eliminationEnabled": true,
"enableMultiObjective": false,
"stickyRouting": true,
"paymentInfo": {
"paymentId": "sr_001",
"customerId": "cust_123",
"amount": 1000,
"currency": "USD",
"country": "US",
Expand All @@ -34,6 +36,13 @@ curl --location "$BASE_URL/decide-gateway" \
}'
```

`stickyRouting` and `paymentInfo.customerId` are optional. When the merchant's
`sticky_routing_enabled` flag is on and a `customerId` is present, the engine pins the
customer's most-successful connector for this payment-method combo over the SR pick —
unless the connector's current score fell below the health threshold (outage/elimination),
or `stickyRouting` is `false` (a per-request opt-out mirroring `enableMultiObjective`).
An applied sticky pin reports `"routing_approach": "STICKY_ROUTING"`.

## Response

```json
Expand Down
14 changes: 13 additions & 1 deletion docs/api-refs/update-gateway-score.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,22 @@ curl --location "$BASE_URL/update-gateway-score" \
"gatewayReferenceId": null,
"status": "CHARGED",
"paymentId": "sr_001",
"enforceDynamicRoutingFailure": null
"enforceDynamicRoutingFailure": null,
"customerId": "cust_123",
"paymentMethod": "INTERAC",
"paymentMethodType": "RTP"
}'
```

`customerId`, `paymentMethod`, and `paymentMethodType` are optional and feed the sticky-routing
habit counters (per-merchant flag `sticky_routing_enabled`): `CHARGED`/`AUTHORIZED`/
`PARTIAL_CHARGED` add one, failure statuses (`AUTHENTICATION_FAILED`, `AUTHORIZATION_FAILED`,
`JUSPAY_DECLINED`, `FAILURE`) subtract one, floored at zero — a failure never creates state. When absent they are recovered
from the decide-time snapshot, which expires 30 minutes after the decide call — send them
explicitly if your success webhooks can arrive later than that. Echo the decide call's
`paymentInfo` values: `paymentMethod`/`paymentMethodType` are matched case-insensitively,
while `customerId` must match byte-exactly.

## Response

```text
Expand Down
31 changes: 31 additions & 0 deletions docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -5338,6 +5338,13 @@
"enableMultiObjective": {
"type": "boolean",
"description": "Per-request override for the multi-objective (cost-aware) post-step. true forces it on, false forces it off; omitted falls back to the merchant's multi_objective_routing_enabled feature flag."
},
"stickyRouting": {
"type": [
"boolean",
"null"
],
"description": "Per-request sticky-routing override, mirroring enableMultiObjective: false disables the pin for this payment; true or omitted leaves it eligible, still gated by the merchant's sticky_routing_enabled feature flag and a customerId in paymentInfo."
}
}
},
Expand Down Expand Up @@ -5600,6 +5607,30 @@
"null"
],
"description": "Set by the orchestrator when this call is a smart-retry attempt."
},
"customerId": {
"type": [
"string",
"null"
],
"example": "cust_123",
"description": "Customer behind the payment; feeds the sticky-routing habit counter (successes add one, failure statuses subtract one, floored at zero). Optional — recovered from the decide-time snapshot when absent, but required for feedback arriving more than 30 minutes after the decide call."
},
"paymentMethod": {
"type": [
"string",
"null"
],
"example": "INTERAC",
"description": "Payment method for the sticky-routing write when the decide-time snapshot has expired. Snapshot fallback when absent."
},
"paymentMethodType": {
"type": [
"string",
"null"
],
"example": "RTP",
"description": "Payment method type; same role as paymentMethod."
}
}
},
Expand Down
117 changes: 117 additions & 0 deletions src/decider/gatewaydecider/flow_new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ pub async fn decider_full_payload_hs_function(
cpu_start,
ab_test_sr_override,
dreq_.enable_multi_objective,
dreq_.sticky_routing,
)
.await;

Expand Down Expand Up @@ -298,6 +299,7 @@ async fn perform_hybrid_routing(
cpu_start,
None,
dreq_.enable_multi_objective,
dreq_.sticky_routing,
)
.await;

Expand Down Expand Up @@ -377,6 +379,7 @@ pub async fn run_decider_flow(
cpu_start: Instant,
ab_test_sr_override: Option<crate::euclid::types::SrConfigOverride>,
enable_multi_objective_override: Option<bool>,
sticky_routing_override: Option<bool>,
) -> Result<T::DecidedGateway, T::ErrorResponse> {
let txnCreationTime = deciderParams
.dpTxnDetail
Expand Down Expand Up @@ -754,6 +757,120 @@ pub async fn run_decider_flow(
}
}

// Sticky routing runs last: pin the customer's proven connector over the
// SR/cost/volume pick — and over the default ordering when SR is off, so
// the precedence is sticky > SR > default. Explicit orders (priority
// logic, merchant preference, downtime relabels) and hedging exploration
// stay untouched. Fails open on any miss.
let sticky_allowed = sticky_routing_override.unwrap_or(true)
&& !hedging_on
&& matches!(
decider_flow.writer.gwDeciderApproach,
T::GatewayDeciderApproach::SrSelection
| T::GatewayDeciderApproach::SrSelectionV2Routing
| T::GatewayDeciderApproach::SrSelectionV3Routing
| T::GatewayDeciderApproach::SrSelectionMultiObjective
| T::GatewayDeciderApproach::SrSelectionVolumeCommitment
| T::GatewayDeciderApproach::Default
);
if sticky_allowed {
let scoring_data = decider_flow.writer.gateway_scoring_data.clone();
if let Some(customer_id) = scoring_data.customerId.clone() {
if is_feature_enabled(
crate::sticky_routing::STICKY_ROUTING_FEATURE.to_string(),
merchant_id_text.clone(),
kvRedis(),
)
.await
{
match crate::sticky_routing::read_sticky_data(
&merchant_id_text,
&customer_id,
)
.await
{
Ok(Some(sticky_data)) => {
// Health veto: never resurrect a connector the outage/
// elimination penalties just buried.
let min_score = maxScore.unwrap_or(0.0)
* crate::sticky_routing::sticky_min_score_ratio().await;
let candidates = sticky_data.connectors_for_combo(
&scoring_data.paymentMethod,
&scoring_data.paymentMethodType,
);
let had_candidates = !candidates.is_empty();
let pick = candidates.into_iter().find(|(gateway, _)| {
currentGatewayScoreMap
.get(gateway)
.is_some_and(|score| *score >= min_score)
});
if pick.is_none() {
// Counts existed but every connector failed the
// eligibility/health filter — the veto did its job.
crate::metrics::STICKY_ROUTING_DECISION_COUNTER
.with_label_values(&[if had_candidates {
"vetoed"
} else {
"no_state"
}])
.inc();
}
if let Some((sticky_gateway, success_count)) = pick {
logger::info!(
action = "sticky_routing",
tag = "sticky_routing",
"sticky pin {} ({} successes) over {:?}",
sticky_gateway,
success_count,
decidedGateway
);
// Every applied pin reports STICKY_ROUTING — a
// returning customer's repeat combo reading as SR
// would confuse callers. The SRv3 scoring gates
// admit the label, so pinned outcomes keep feeding
// the windows either way; agree-vs-diverge stays
// visible in the sticky decision metrics.
if decidedGateway.as_ref() != Some(&sticky_gateway) {
decidedGateway = Some(sticky_gateway);
// Recompute fallbacks around the sticky winner,
// and drop the now-superseded cost/volume claims
// so analytics can't credit a pick that didn't
// reach the customer.
cost_fallbacks_override = None;
decider_flow.writer.multi_objective_info = None;
decider_flow.writer.volume_steer_info = None;
crate::metrics::STICKY_ROUTING_DECISION_COUNTER
.with_label_values(&["overridden"])
.inc();
} else {
crate::metrics::STICKY_ROUTING_DECISION_COUNTER
.with_label_values(&["pinned_agreeing"])
.inc();
}
decider_flow.writer.gwDeciderApproach =
T::GatewayDeciderApproach::StickyRouting;
}
}
Ok(None) => {
crate::metrics::STICKY_ROUTING_DECISION_COUNTER
.with_label_values(&["no_state"])
.inc();
}
Err(error) => {
crate::metrics::STICKY_ROUTING_DECISION_COUNTER
.with_label_values(&["read_error"])
.inc();
logger::warn!(
action = "sticky_routing",
tag = "sticky_routing",
"sticky read failed, using SR pick: {error}"
);
}
}
}
}
}

let stateBindings = (
decider_flow.writer.srElminiationApproachInfo.clone(),
decider_flow.writer.isOptimizedBasedOnSRMetricEnabled,
Expand Down
13 changes: 13 additions & 0 deletions src/decider/gatewaydecider/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,7 @@ pub fn initial_decider_state(date_created: String) -> DeciderState {
udfs: None,
udfs_consumed_for_routing: None,
gatewayReferenceId: None,
customerId: None,
},
ab_test_sr_override: None,
}
Expand Down Expand Up @@ -582,6 +583,10 @@ pub struct GatewayScoringData {
pub gatewayReferenceId: Option<String>,
pub udfs: Option<UDFs>,
pub udfs_consumed_for_routing: Option<String>,
/// Customer behind the payment, stashed so the feedback path can key sticky-routing
/// counts. Default keeps snapshots written before this field deserializing.
#[serde(default)]
pub customerId: Option<String>,
}

impl GatewayScoringData {
Expand Down Expand Up @@ -661,6 +666,8 @@ pub enum GatewayDeciderApproach {
/// A volume-contract nudge moved the payment off the SR head — the volume-driven sibling of
/// [`Self::SrSelectionMultiObjective`].
SrSelectionVolumeCommitment,
/// Sticky routing pinned the customer's proven connector over the SR pick.
StickyRouting,
}

#[derive(Debug, Serialize, Deserialize, PartialEq)]
Expand Down Expand Up @@ -980,6 +987,11 @@ pub struct DomainDeciderRequestForApiCallV2 {
pub elimination_enabled: Option<bool>,
#[serde(default)]
pub enable_multi_objective: Option<bool>,
/// Per-request sticky-routing override, mirroring `enable_multi_objective`: false
/// disables the pin for this payment; absent/true leaves it to the merchant-level
/// `sticky_routing_enabled` feature flag and a present customerId.
#[serde(default)]
pub sticky_routing: Option<bool>,
}

pub fn deserialize_optional_udfs_to_hashmap<'de, D>(
Expand Down Expand Up @@ -1603,6 +1615,7 @@ impl fmt::Display for GatewayDeciderApproach {
Self::SrSelectionVolumeCommitment => {
write!(f, "SR_SELECTION_VOLUME_COMMITMENT")
}
Self::StickyRouting => write!(f, "STICKY_ROUTING"),
}
}
}
Expand Down
7 changes: 7 additions & 0 deletions src/decider/gatewaydecider/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2035,6 +2035,7 @@ pub fn get_default_gateway_scoring_data(
udfs,
udfs_consumed_for_routing: None,
gatewayReferenceId: gatewayRefId,
customerId: None,
}
}

Expand Down Expand Up @@ -2109,6 +2110,12 @@ pub async fn get_gateway_scoring_data(
Some(decider_flow.get().dpOrder.udfs.clone()),
is_legacy_decider_flow,
);
default_gateway_scoring_data.customerId = decider_flow
.get()
.dpOrder
.customerId
.clone()
.map(|customer| customer.0);
let updated_gateway_scoring_data = match txn_card_info.paymentMethodType.as_str() {
UPI => {
let handle_and_package_based_routing = is_feature_enabled(
Expand Down
Loading