Least-Cost Routing (LCR)¶
Route outbound calls across carriers by cost, with an external API making the decision and siphon executing it against its gateway health/failover machinery.
siphon is not a rating engine. The decision — which carrier, in what order, at what cost — is owned by an external HTTP JSON API you run (rate decks, prefix match, quality, margin, balance). siphon asks that API, caches the answer, and executes the ordered route set: it resolves each carrier's gateway pool to a healthy member, tries them cheapest-first with sequential failover, and stamps the winning carrier onto the CDR.
The split: API owns cost order (cached). siphon owns liveness + execution (healthy-member selection, dead-carrier skip, sequential failover, per-call CDR).
Why B2BUA-only (not proxy)¶
LCR in siphon is exposed only on the B2BUA (call.route(...)). There is no
proxy LCR path, on purpose. The decisive reason is dialog hygiene.
A proxy doing serial LCR is transparent end-to-end, so it keeps the same Call-ID toward every carrier it tries. That is the classic Kamailio serial-fork footgun:
- Ghost dialogs / double-connect. If carrier A actually set up state before you failed over (a 200 racing your CANCEL, a half-answer), carrier B now sees the same Call-ID. Some carriers reject it as a duplicate; worse, you can be billed on two carriers for one call.
- CDRs you can't separate. Every attempt shares one Call-ID, so "attempt to A (failed) / attempt to B (answered)" can't be told apart per carrier for ASR / billing.
- No mid-call control. In-dialog re-INVITE / BYE follow the established route set; the proxy isn't in the dialog, so it can't reroute or tear down on credit.
A B2BUA mints a fresh B-leg dialog — new Call-ID / From-tag / CSeq — per carrier attempt. Carriers never collide, per-carrier CDRs separate cleanly, and the B2BUA owns both dialogs (retry, per-carrier media, credit teardown). The customer-facing A-leg Call-ID stays stable regardless of which carrier wins.
B2BUA is also required for online charging (mid-call credit teardown) and per-carrier media/codec handling.
The flow¶
from siphon import b2bua, cdr, lcr, log
@b2bua.on_invite
async def route(call):
call.rewrite_identities("ims-e164@2026") # normalize the dialed number
decision = await lcr.route(call, trunk_group="cust-trunks")
if decision is None: # API down, no fallback
call.reject(503, "Route Unavailable")
return
if decision.reject: # API-side block
call.reject(decision.reject["code"], decision.reject["reason"])
return
if not decision.routes:
call.reject(404, "No Route")
return
call.route(decision.routes) # sequential failover
@b2bua.on_answer
def answered(call, reply):
route = call.active_route # the carrier that won
if route:
cdr.write(call, extra={"carrier_id": route.carrier_id,
"rate": f"{route.rate:.5f}",
"route_source": "lcr"})
@b2bua.on_failure
def failed(call, code, reason): # only after all carriers tried
call.reject(code, reason)
decision.routes is an ordered list[Route] — routing policy stays in Python,
so the script may filter or reorder (drop carriers over a rate ceiling, prefer a
region) before call.route(...). siphon resolves and dials from there.
Full example: examples/lcr_b2bua.py
+ .yaml. Reference API (FastAPI): examples/lcr_api_server.py.
Sequential failover¶
call.route([...]) tries carriers one at a time, in order:
- Dials the first routable carrier and arms its ring timeout (
timeout_secs, elsecall.route(timeout=…), else 30s). - On a carrier reject (4xx/5xx), or a ring-timeout before the carrier
has shown progress, advances to the next carrier, a fresh B-leg dialog
each time. A carrier that has sent a
180/183keeps the call past itstimeout_secs; see ring timeout and progress. - A
6xxfrom a carrier stops the sequence (global rejection, RFC 3261 §16.7 spirit). - The A-leg receives a failure only once every carrier is exhausted, and it
is the best of the carriers' failures (RFC 3261 §16.7), not the last one:
a carrier that rang out (
408) outranks a later one's503, which in turn reaches the caller as a500.@b2bua.on_failurefires once, with that code, and what it decides is carried out:call.reject()picks the caller's response, andcall.dial()or a freshcall.route()tries somewhere else (a backup trunk, voicemail). - When the sequence ends on a ring timeout instead (the last carrier rang
out, or one whose route does not reroute on
408), siphon answers the caller itself. If that carrier never sent a180/183, no carrier got as far as the callee and the caller gets503 Service Unavailable, a503of siphon's own that is not turned into500. If it had, the callee was reached and did not answer, and the caller gets408.@b2bua.on_failuregets the same code. The attempt itself is recorded as408either way. - When the sequence moves on and none of the carriers left can be dialled,
it ends on them: the caller and
@b2bua.on_failureget siphon's own503, whatever the carrier before them did. A carrier withreroute_after_progressthat rang and then rang out gives503here, not408, and a carrier's503is not turned into500. Each of those carriers is its own503attempt, withdialed: False. A sequential control-planedialreports the same code inDialFailed. - On answer,
call.active_routeis the carrier that won. call.route_attemptslists the carriers it burned to get there — one entry per failed attempt (carrier_id,status,elapsed_ms,dialed), oldest first. Empty when the first carrier answered.- A carrier siphon could not reach (its gateway group unknown or entirely
down, or its destination would not resolve) is skipped immediately — the
sequence does not wait out that carrier's ring timeout for an INVITE it never
sent. It is still recorded, with
dialed: False.
Seeing a failover happen¶
A failover is an operational event, so it is reported at info: siphon logs
each failed attempt with its carrier, status and elapsed time, and logs the
advance to the next carrier.
Two ways to keep it rather than just read it:
@b2bua.on_route_failure
def carrier_failed(call, route, code):
"""Fires once per failed attempt, including the last."""
if code in (408, 503): # what YOU count as the carrier's fault
carrier_failures.labels(carrier=route.carrier_id).inc()
@b2bua.on_answer
def answered(call, reply):
for attempt in call.route_attempts:
log.warn(f"burned {attempt['carrier_id']} "
f"{attempt['status']} after {attempt['elapsed_ms']}ms")
@b2bua.on_route_failure fires for every non-2xx a carrier returns — a
definitive 486 Busy as much as a 503, and a ring timeout as 408. That is
the same set call.route_attempts records, so the two never disagree; filter on
code for what you actually treat as a carrier health signal. It is purely a
notification: the failover decision is already made, and raising in it does not
change the call.
Not every burned carrier is the carrier's fault¶
An attempt's dialed says whether siphon actually put an INVITE on the wire for
that carrier. False means it did not — the gateway group was unknown or
entirely down, or the next-hop would not resolve — so status (503) is
siphon's own verdict on the route, not something the carrier said. The carrier
never saw the call.
Filter on it before counting a failure against a carrier. A stale DNS name or a down gateway group is a local configuration problem, and trending it as carrier quality sends you to the carrier with figures they cannot reconcile:
@b2bua.on_route_failure
def carrier_failed(call, route, code):
attempt = call.route_attempts[-1]
if not attempt["dialed"]:
# siphon never reached this carrier — alert your own config, not them.
unroutable_carriers.labels(carrier=route.carrier_id).inc()
return
if code in (408, 503):
carrier_failures.labels(carrier=route.carrier_id).inc()
siphon logs an undialled carrier at info too (LCR: carrier burned without
dialling), so it is visible without a handler.
When cdr.auto_emit is on, the same list is stamped onto the CDR as
lcr_attempts (a compact JSON array), alongside the winning carrier's
cdr_fields — so a completed call that burned a carrier records that in the
billing pipeline instead of only in the log.
call.fork(strategy="sequential") uses the same engine for a bare target list
(this now actually fails over — previously the strategy was ignored). Captured
inbound flows (WebSocket connection reuse) are not carried on the sequential
path; use strategy="parallel" for WebSocket callees. A sequential fork is a
hunt through phones, and every phone that rings sends a 180, so its targets
move on when their timeout passes whether they rang or not: the progress rule
below applies to LCR carriers, not to fork targets. A sequential dial on the
control plane hunts the same way.
Ring timeout and progress¶
A route's timeout_secs bounds how long siphon waits for the carrier to show
progress, not how long it waits for the answer. Progress is any provisional
from 101 to 199 from the carrier in flight. A 100 Trying is hop by hop and
does not count. That is the same line RFC 3261 §16.7 step 2 draws for a proxy's
Timer C: once the next hop sends something past a 100, it is working on the
request.
- No progress by
timeout_secs: siphon CANCELs the carrier and dials the next one, fast failover for a carrier that is down or black-holing calls. With no next carrier to dial, or a route that does not reroute on408, the call fails with503, since no carrier reached the callee. The attempt is still a408. - Progress before it: the carrier is ringing the callee, and cutting it off
would drop the caller mid-ring onto a carrier that has to start again. Its
deadline moves to the later of its own
timeout_secsand the sequence's ring bound (call.route(timeout=…), 30 s by default), both counted from when that carrier was dialled, so progress never shortens a ring. If that deadline passes too, siphon CANCELs the carrier and fails the call with408without trying the remaining carriers. The attempt goes oncall.route_attemptsas408and@b2bua.on_route_failurefires for it first, as for any carrier that rings out. Then@b2bua.on_failureruns as for any other failure and can still route the call somewhere else. - A final failure after progress (a
503, say) still fails over as usual. Only the timeout changes. - With
call.route(timeout=0)there is no ring bound, so a carrier that has shown progress rings until it answers, fails or the caller hangs up.
Here carrier-a has 6 s to send a 180/183. If it does, it rings for up to 45 s
from its dial before the call fails with 408.
Some carriers answer 183 with ringback they generate themselves, before they
have reached anyone. That reads as progress it does not have, so the API can put
one carrier back on failing over at timeout_secs whatever it has sent:
{ "carrier_id": "carrier-x", "gateway_group": "carrier-x",
"timeout_secs": 6, "reroute_after_progress": true }
Gateway integration¶
A route names a gateway_group (a gateway: carrier pool). At dial time
siphon picks a healthy member (weighted/round-robin/hash per the group), and
skips the carrier entirely if the pool is down — health-probing you already
configured, no round-trip to the LCR API. A route may instead pin an explicit
next_hop, or override the whole Request-URI with ruri.
If the API names a gateway_group siphon doesn't know (a typo, or a group not
yet created), siphon warns and falls back to the route's next_hop if it has
one, else skips that carrier and fails over — never a silent hang. Groups don't
need a restart: gateway.add_group(...) / gateway.remove_group(...) add and
remove them at runtime from a script (e.g. a timer that syncs your carrier
inventory), and LCR routes that name them work immediately. Only YAML-defined
groups are fixed at boot.
Per-carrier shaping (prefix, headers)¶
Carriers want the number in different shapes:
number_policy— a namednumber_policies:preset for this carrier. It shapes the dialled number in the Request-URI and the identity headers (From/To/PAI) alike, so the two never disagree, and a failover to a second carrier reshapes independently. A route that names none getsb2bua.default_number_policy, the same ascall.dial(). A name that is not configured shapes nothing and is logged at warn with the carrier.tech_prefix— a dial/tech-prefix ("1010288","#31#") prepended to the Request-URI userpart per carrier, afternumber_policyhas shaped the number. Many carriers route or bill on a prefix in front of the number: under aplainpolicy+12025550123goes to that carrier as101028812025550123, and with no policy at all it keeps the shape it arrived in (1010288+12025550123). The prefix never reachesTo.ruri— full Request-URI override when a carrier wants its own host. The number in it is still shaped by the route's policy, when there is one.headers— per-carrier headers injected on the B-leg INVITE (an account token, a routing tag), applied after the header policy so they always land.cdr_fields— key/value fields siphon auto-stamps onto the CDR when this carrier wins, so the API can push billing/routing metadata straight into the record without the script naming each field (cdr.write(extra=…)still overrides on a key clash).
{ "carrier_id": "carrier-a", "gateway_group": "carrier-a",
"tech_prefix": "1010288", "headers": { "X-Account": "42" }, "rate": 0.0042 }
Presented CLI and CLIR (per carrier)¶
Two more route fields decide what the calling party looks like to that
carrier. number_policy above reshapes the format of whatever number is
already there; these two substitute a different one, and withhold it.
-
caller_id— the number this carrier is presented, onFromand onP-Asserted-Identity/P-Preferred-Identity. It is a field rather than something forheadersbecause it goes through the tag-preserving identity path: aFromwritten by hand loses the dialog tag, the INVITE still goes out, and the breakage only surfaces later on the ACK.The PAI is inserted when the leg has none, which is the usual case: the header policy strips
P-*off an untrusted access leg, correctly, so there is nothing left to rewrite by the time a route'scaller_idis applied. siphon asserts its own rather than relaying the UE's (b2bua.assert_identity, on by default — set it tofalsefor a next hop genuinely outside the trust domain). -caller_id_presentation—"allowed"(the default) or"restricted"for CLIR (RFC 3323 §4.1, 3GPP TS 24.607).restricteddoes four things together: -Frombecomes"Anonymous" <sip:anonymous@anonymous.invalid>, tag intact -Privacy: idis asserted (RFC 3325 §7), appended to any existing value -P-Asserted-Identitycarries the real identity to the trusted next hop, so the network can still identify the caller for regulatory and emergency purposes — asserted from theFromjust before it is anonymised when the leg has none, becausePrivacy: idover an absent PAI is a privacy request the next hop cannot honour and a regulatory gap at once -P-Preferred-Identityis removed, since it is the UA's request for what to assert and forwarding it past a privacy boundary re-leaks the numberThey move together on purpose. Asserting
Privacy: idwhile leaving the real number inFromleaks it to every carrier that rendersFromrather than PAI, which defeats CLIR while looking like it works.
{
"routes": [
{ "carrier_id": "carrier-a", "gateway_group": "carrier-a",
"caller_id": "+13105550100" },
{ "carrier_id": "wholesale-b", "gateway_group": "wholesale-b",
"caller_id": "+13105550100", "caller_id_presentation": "restricted" }
]
}
Both are per route, not per response — unlike destination, there is no
answer-level default for a carrier to inherit. A failover from carrier A to
carrier B therefore never carries A's presentation across by accident; a route
that wants CLIR has to say so. Set it on every route in the sequence when the
call is withheld, or the call goes out anonymous on the cheapest carrier and
with the real number on the failover.
Ordering inside one route is fixed: caller_id substitutes first, the identity
is asserted second, number_policy reshapes formats third, and anonymisation
runs last. Each step depends on the one before it. Asserting after the
substitution is what puts the presented number in the PAI rather than the
caller's own; asserting before the number policy is what keeps the PAI and the
From in the same format; and asserting before the anonymisation is what gives
Privacy: id a real identity to withhold on a restricted route that names no
caller_id. No policy ever tries to reformat anonymous as a number. An unrecognised caller_id_presentation is
logged and treated as restricted, because a withheld call going out with the
real number is the failure that matters.
For deployments not using the LCR API the script-level twins are
call.set_caller_id(number) and call.restrict_caller_id(), called before
call.dial() / call.route().
Reroute causes (some carriers don't play nice)¶
Failover only happens on a reroute cause — a SIP code that means "this
carrier failed, try another", not "the call is over". A 486 Busy or 603
Decline is forwarded to the caller as-is (trying another carrier won't help); a
503/408 fails over.
The default reroute set is [408, 500, 502, 503, 504]. Override it at three
levels (most specific wins):
- Generic —
lcr.reroute_causesinsiphon.yaml. - Per-gateway —
gateway.groups[].reroute_causes, for a carrier that sends non-standard codes (e.g.404/403for "no circuits"). - Per-route — the API's
reroute_causeson aRoute, when the API knows a specific carrier misbehaves.
lcr:
reroute_causes: [408, 500, 502, 503, 504] # generic (this is also the default)
gateway:
groups:
- name: "carrier-x"
reroute_causes: [404, 408, 500, 503] # carrier-x sends 404 for no-circuits
destinations: [ ... ]
Caching and fallback¶
The LCR API is on the call-setup path, so:
- Decisions are cached in a named cache (
lcr.cache), keyed bytrunk_group:dialed_number, for the API-providedcache_ttl_secs(or the configured default). With a Redis-backed cache, a decision cached on one node is reused fleet-wide. Acache_ttl_secsof0/absent means don't cache. - A static fallback (
lcr.fallback_gateway_group) is used when the API is unreachable or times out, so routing degrades instead of every call failing. Without a fallback, an API failure surfaces to the script asNone(answer a 5xx).
Config¶
lcr:
api_url: "${LCR_API_URL:-http://127.0.0.1:8080/route}"
timeout_ms: 2000
cache: "lcr" # a name from the cache: list (optional)
cache_ttl_secs: 300 # default TTL when the API omits one
auth_header: "Bearer ${LCR_TOKEN}" # optional
fallback_gateway_group: "carrier-a" # optional
cache:
- name: "lcr"
url: "redis://127.0.0.1:6379"
local_ttl_secs: 60
gateway:
groups:
- name: "carrier-a"
probe: { enabled: true, interval_secs: 15, failure_threshold: 3 }
destinations:
- { uri: "sip:gw1.carrier-a.example:5060", address: "198.51.100.11:5060", weight: 2 }
- name: "carrier-b"
probe: { enabled: true }
destinations:
- { uri: "sip:gw1.carrier-b.example:5060", address: "203.0.113.21:5060" }
The JSON contract is in the LCR API reference; the
typed models operators build against ship in the siphon-sip SDK
(from siphon_sdk.lcr import LcrRequest, LcrResponse, Route).
Charging¶
The carrier that won is on call.active_route (a Route: carrier_id,
gateway_group, rate, currency), so it flows into all three charging paths:
- CDR. Stamp it into the record, as in the
@b2bua.on_answerhandler above:cdr.write(call, extra={"carrier_id": route.carrier_id, "rate": ...}). Withcdr.auto_emiton, that attaches to the record siphon is already keeping for the call — the carrier fields and the call's duration land on one row, not two. - Rf offline. Stamp the carrier's trunk group onto the offline record with
call.set_charging_param("outgoing-trunk-group-id", route.gateway_group). The Rf ACR auto-emit carries it asOutgoing-Trunk-Group-Id(TS 32.260). - Ro online. With the
ro:block enabled, SIPhon reserves credit before connect, re-authorises mid-call, and drops the call when the OCS refuses further credit (4012 CREDIT_LIMIT_REACHED). Because LCR is B2BUA-only, that mid-call teardown applies to every LCR call.
See the Online charging (OCS) recipe for the full Ro setup (reserve-before-connect gate, re-auth timer, CGRateS).