Skip to content

Proxy & B2BUA

The proxy and b2bua namespaces register the event handlers that make routing decisions, plus the helpers those handlers lean on (rate limiting, sanity checks, ENUM lookup) and the generic SUBSCRIBE-dialog state store.

from siphon import proxy, b2bua

@proxy.on_request
def route(request):
    request.relay()

@b2bua.on_invite
async def call(call):
    call.dial(call.ruri)

proxy namespace

Mock proxy namespace with decorator registration and utility stubs.

Decorators
  • @proxy.on_request / @proxy.on_request("INVITE")
  • @proxy.on_reply
  • @proxy.on_failure
  • @proxy.on_cancel
  • @proxy.on_register_reply

Example::

from siphon import proxy

@proxy.on_request("REGISTER")
def handle_register(request):
    request.reply(200, "OK")

sent_requests property

sent_requests: list[dict]

List of requests sent via send_request() (for test assertions).

on_request

on_request(
    fn_or_filter: Union[Callable, str, None] = None,
) -> Any

Register a handler for incoming SIP requests.

Can be used as
  • @proxy.on_request — handle all methods
  • @proxy.on_request() — same, explicit call
  • @proxy.on_request("REGISTER") — single method filter
  • @proxy.on_request("INVITE|SUBSCRIBE") — pipe-separated filter

A filtered handler does not replace the unfiltered one — both run, in registration order; an unfiltered handler matches every method.

They also share one action slot. reply() / relay() / fork() assign it rather than sending, and only its final value is executed, so a later handler silently replaces an earlier one's routing decision::

@proxy.on_request("OPTIONS")
def probe(request):
    request.reply(200, "OK")      # discarded below

@proxy.on_request          # also runs for OPTIONS
def route(request):
    request.relay(NEXT_HOP)

The probe is not answered and then relayed — it is only relayed. Use request.stop_propagation() to keep a decision, or branch inside one handler rather than registering two. Side effects (set_header, record_route, logging, metrics) do happen from every handler; it is only the routing decision that is last-writer-wins.

(@diameter.on_request takes a filter of the same shape but dispatches only the single most specific match: a Diameter request needs exactly one answer, where a SIP request can legitimately interest several handlers at once.)

on_reply staticmethod

on_reply(fn: Callable) -> Callable

Register a handler for SIP replies.

Handler signature: (request, reply) -> None

on_failure staticmethod

on_failure(fn: Callable) -> Callable

Register a handler for proxy failure (all branches failed).

Handler signature: (request, reply) -> None

on_cancel staticmethod

on_cancel(fn: Callable) -> Callable

Register a handler for a CANCELled INVITE (RFC 3261 §9).

Handler signature: (request) -> None

Fires once, with the original INVITE, when a relayed INVITE is CANCELled before any final response — the one teardown that neither on_reply nor on_failure delivers (the proxy answers the CANCEL with 487 at the transaction layer and the session is gone). Use it to release per-call resources that no BYE will ever clear: Diameter Rx / N5 QoS sessions, rtpengine media anchors, charging maps.

Fire-and-forget — it does not gate or alter the 487 sent to the UAC.

Example::

@proxy.on_cancel
async def handle_cancel(request):
    await _release_qos(request.call_id)
    await rtpengine.delete(request)

on_register_reply staticmethod

on_register_reply(fn: Callable) -> Callable

Register a handler for REGISTER replies.

Handler signature: (request, reply) -> None

send_request async

send_request(
    method: str,
    ruri: str,
    headers: Optional[dict[str, str]] = None,
    body: Optional[Any] = None,
    next_hop: Optional[str] = None,
    wait_for_response: bool = False,
    timeout_ms: int = 2000,
) -> Any

Originate an outbound SIP request.

Always returns an awaitable — scripts must await it. Fire-and-forget by default; when wait_for_response=True, the awaitable resolves to a configured mock Reply (or None on timeout).

Parameters:

Name Type Description Default
method str

SIP method name (e.g. "NOTIFY", "OPTIONS", "MESSAGE").

required
ruri str

Request-URI string (e.g. "sip:alice@10.0.0.1:5060").

required
headers Optional[dict[str, str]]

Optional dict of header name → value to add. When a Route header is supplied without next_hop, the request is sent to the first Route entry's URI (its ;lr loose-route target) per RFC 3261 §8.1.2 — the R-URI stays in the Request-Line. Use this to steer a request straight to a known next hop (e.g. a served IMPU's serving S-CSCF) instead of resolving the R-URI's home domain.

None
body Optional[Any]

Optional body — str or bytes.

None
next_hop Optional[str]

Optional next-hop URI override. Outranks a Route header for next-hop selection.

None
wait_for_response bool

When True, return the configured mock reply.

False
timeout_ms int

Response timeout (not meaningfully enforced in the mock).

2000

set_response_for

set_response_for(
    method: str, ruri: str, reply: Any
) -> None

Test helper: configure the mock reply returned by send_request(wait_for_response=True) for a given (method, ruri).

Parameters:

Name Type Description Default
method str

SIP method (e.g. "OPTIONS").

required
ruri str

Request-URI the script will pass.

required
reply Any

Any object (often a MockReply) — returned to the script.

required

proxy utilities

Reached as proxy.rate_limit, proxy.sanity_check, proxy.enum_lookup, and proxy.memory_used_pct.

Mock proxy._utils namespace.

Provides rate limiting, sanity checking, ENUM lookup, and memory stats. In the mock, these return configurable defaults.

rate_limit

rate_limit(
    request: Any, window_secs: float, max_requests: int
) -> bool

Check if a request is within the rate limit.

Parameters:

Name Type Description Default
request Any

The SIP request object.

required
window_secs float

Sliding window duration in seconds.

required
max_requests int

Maximum requests allowed in the window.

required

Returns:

Type Description
bool

True if allowed, False if rate-limited.

In the mock, returns the value of _rate_limit_allow (default True).

sanity_check

sanity_check(request: Any) -> bool

Validate request per RFC 3261 (mandatory headers, Max-Forwards, etc.).

Returns:

Type Description
bool

True if valid, False otherwise.

In the mock, returns _sanity_check_pass (default True).

enum_lookup async

enum_lookup(
    number: str,
    suffix: str = "e164.arpa.",
    service: str = "E2U+sip",
) -> Optional[str]

DNS NAPTR lookup for phone number to SIP URI.

Parameters:

Name Type Description Default
number str

E.164 number (e.g. "+14155552671").

required
suffix str

DNS suffix (default "e164.arpa.").

'e164.arpa.'
service str

Service type (default "E2U+sip").

'E2U+sip'

Returns:

Type Description
Optional[str]

SIP URI string or None.

In the mock, looks up _enum_results dict.

memory_used_pct

memory_used_pct() -> int

Process RSS memory usage as percentage (0–100).

In the mock, returns _memory_pct (default 25).

b2bua namespace

Mock B2BUA namespace with decorator registration.

Decorators
  • @b2bua.on_invite — new call
  • @b2bua.on_early_media — provisional response with SDP (183/180)
  • @b2bua.on_answer — call answered
  • @b2bua.on_failure — all B-legs failed
  • @b2bua.on_bye — call ended
  • @b2bua.on_refer — call transfer (RFC 3515)
  • @b2bua.on_cancel — unanswered call cancelled (RFC 3261 §9)
Imperative
  • b2bua.originate(to=...) — place an outbound call with no inbound INVITE behind it (records onto originates for test assertions)
  • b2bua.terminate(call_id) — end a call by SIP Call-ID from any context (records onto terminates for test assertions)
  • b2bua.refer(call_id, target) — transfer a call by SIP Call-ID from any context (records onto refers for test assertions)
  • await b2bua.bridge(call_id, with_call_id) — join two answered calls so the two parties hear each other (records onto bridges for test assertions)
  • await b2bua.unbridge(call_id) — break a bridge, leaving both legs answered, owned and held (records onto unbridges for test assertions)

clear

clear() -> None

Reset recorded imperative calls (called by reset()).

originate

originate(
    to: str,
    from_uri: Optional[str] = None,
    from_display: Optional[str] = None,
    to_display: Optional[str] = None,
    next_hop: Optional[str] = None,
    p_asserted_identity: Optional[str] = None,
    privacy: Optional[str] = None,
    headers: Optional[dict] = None,
    sdp: Optional[str] = None,
    media: bool = False,
    profile: Optional[str] = None,
    ws_uri: Optional[str] = None,
    timeout: int = 30,
    body: Optional[Union[str, bytes]] = None,
    content_type: Optional[str] = None,
    session_timer: Optional[dict] = None,
) -> str

Place an outbound call siphon owns, with no inbound INVITE behind it.

The primitive under click-to-dial, callbacks and outbound notification. Unlike call.dial() (which builds a B-leg off a call that already arrived), this creates a call from nothing.

Asynchronous. Returns as soon as the INVITE is on the wire, with the new leg's SIP Call-ID — it does not wait for the callee. Ringing and answer arrive through the ordinary handlers: @b2bua.on_answer fires with (call, reply), @b2bua.on_failure with (call, code, reason), @b2bua.on_bye on teardown. Feed the returned Call-ID to b2bua.terminate() / b2bua.refer().

Exactly one media plan is required — an INVITE with no offer and no way to answer the callee's would connect a call with no audio:

  • sdp="v=0..." — your own offer, carried verbatim as application/sdp (any backend);
  • body=…, content_type=… — the same slot with the type spelled out, for an INVITE whose offer travels as one part of a multipart/* body (RFC 5621 §3) beside a part SIP does not interpret: ISUP on a SIP-I trunk, a PIDF-LO location object, an operator-specific document. You assemble the multipart; siphon carries it verbatim and derives Content-Length from it;
  • media=True — siphon anchors the leg on the configured media backend (siphon-rtp), so rtpengine.play_media(), DTMF and the WebSocket tee all work against it.

Whichever spelling, the body must carry an SDP offer — bare application/sdp, or a multipart/* with an application/sdp part in it. Live siphon raises for one that carries none, because a callee that reads the INVITE as offerless offers in its own 2xx and this plan has nothing to answer that with (RFC 3261 §13.2.2.4).

Parameters:

Name Type Description Default
to str

called party — the Request-URI and the To URI.

required
from_uri Optional[str]

calling identity (From URI). Defaults to siphon's own advertised address.

None
from_display Optional[str]

From display name.

None
to_display Optional[str]

To display name.

None
next_hop Optional[str]

route the INVITE here while the R-URI keeps the called party's shape (IMS edge / trunk steering).

None
p_asserted_identity Optional[str]

P-Asserted-Identity for a trusted next hop.

None
privacy Optional[str]

"allowed" or "restricted". Restricted anonymises From and asserts Privacy: id, keeping the real identity in P-Asserted-Identity.

None
headers Optional[dict]

dict of extra headers applied last. Dialog-defining headers (Via/From/To/Call-ID/CSeq/Contact/Route/…) are ignored.

None
sdp Optional[str]

your own SDP offer, carried as application/sdp.

None
media bool

True to have siphon anchor the leg on the media backend.

False
profile Optional[str]

media profile for media=True (default "rtp_passthrough").

None
ws_uri Optional[str]

per-call WebSocket bridge URI for media=True.

None
timeout int

ring timeout in seconds; the call is CANCELled when it elapses. 0 disables it.

30
body Optional[Union[str, bytes]]

the INVITE body, str or bytes — the offer itself, or a multipart carrying it. Mutually exclusive with sdp.

None
content_type Optional[str]

Content-Type for body (e.g. "multipart/mixed;boundary=…"). Defaults to "application/sdp", which makes body identical to sdp.

None
session_timer Optional[dict]

the RFC 4028 session timer to run on the call, over the session_timer: block: a dict with expires, min_se and refresher, each defaulting as in call.session_timer() (1800, 90, "b2bua"). The INVITE asks for it, the callee's 2xx decides who refreshes, and siphon refreshes the dialog or ends the call at expiry. None runs the configured timer, if any.

None

Returns:

Name Type Description
str str

the new leg's SIP Call-ID.

Raises:

Type Description
ValueError

no media plan, two media plans, both spellings of the offer, a content_type with nothing to describe, or an unrecognised privacy value. Live siphon also raises for unparseable URIs, no route, a media plan the backend cannot serve, and a body carrying no offer — never a silent None for a call that was never placed.

In the mock, records the full argument set on originates and returns a synthetic Call-ID. Inspect via siphon.get_b2bua().originates.

Usage::

@timer.every(seconds=60)
def reminders():
    for number in due_numbers():
        b2bua.originate(
            to=f"sip:{number}@carrier.example",
            from_uri="sip:+14035550100@siphon.example",
            media=True,
        )

bridge async

bridge(
    call_id: str,
    with_call_id: str,
    on_peer_hangup: str = "hangup",
) -> bool

Join two answered calls siphon owns, so the two parties hear each other.

The primitive under callback-and-connect and attended hand-off: every other B2BUA verb acts on one call, this one joins two. Both legs are named by SIP Call-ID, so it works from an event callback or a timer where no call object is in scope.

Awaitable. It resolves once the media has been re-pointed and the first re-INVITE is on the wire — the same "the local action was performed" contract :meth:originate has. A bridge is two RFC 3261 §14 re-INVITEs across two dialogs; whether the far ends accept them is a far-end outcome, delivered on the control rail as ChannelBridged / BridgeFailed.

Parameters:

Name Type Description Default
call_id str

SIP Call-ID of the leg that keeps its media anchor — its ports, and anything attached to them, survive the bridge.

required
with_call_id str

SIP Call-ID of the leg joined to it. Its own media session is deleted; it becomes the second party on the first's.

required
on_peer_hangup str

what happens to the survivor when one party leaves. "hangup" (default) tears it down too; "hold" keeps it up and held so it can be bridged to somebody else.

'hangup'

Returns:

Name Type Description
bool bool

True once the bridge has been accepted and put in motion.

Raises:

Type Description
ValueError

an on_peer_hangup that is neither "hangup" nor "hold" — guessing at a teardown policy is how calls get stranded. Live siphon also raises when a leg is unknown, has not answered, is already bridged, has a re-INVITE outstanding, carries no media description, or the media backend cannot express the bridge. The message is prefixed with the stable cause token (not_found, invalid_state, bad_request, unsupported_verb, unavailable) — never a hollow success.

In the mock, records {"call_id", "with_call_id", "on_peer_hangup"} on bridges and returns True. Inspect via siphon.get_b2bua().bridges.

Usage::

# Callback-and-connect: join the parked caller to the leg siphon
# placed for them, once that leg has answered.
@b2bua.on_answer
async def connect(call, reply):
    parked = await cache.fetch("callbacks", call.call_id)
    if parked:
        await b2bua.bridge(parked, call.call_id)

unbridge async

unbridge(call_id: str, reason: str = 'unbridged') -> bool

Break a bridge, leaving both legs answered, owned and held.

Each leg falls back to exactly the state a freshly answered, unbridged leg is in — siphon re-offers it a=sendonly (RFC 3264 §8.4; RFC 6337 §3.1 prefers that to c=0.0.0.0), so the endpoint stops sending and hears nothing. Neither leg is hung up: that would make unbridge indistinguishable from two :meth:terminate calls. A later :meth:bridge re-offers sendrecv.

Awaitable.

Parameters:

Name Type Description Default
call_id str

SIP Call-ID of either leg of the bridge.

required
reason str

carried on the control-plane ChannelUnbridged event.

'unbridged'

Returns:

Name Type Description
bool bool

True once both legs have been held and the bridge dropped.

Raises:

Type Description
ValueError

live siphon raises when the leg is unknown, is not bridged, or its bridge is still forming. The message carries the same stable cause token as :meth:bridge.

In the mock, records {"call_id", "reason"} on unbridges and returns True. Inspect via siphon.get_b2bua().unbridges.

Usage::

@b2bua.on_bye
async def split(call, initiator):
    await b2bua.unbridge(call.call_id, reason="supervisor split")

replace_peer

replace_peer(
    call_id: str,
    target: str,
    next_hop: Optional[str] = None,
    replace_a_leg: bool = False,
    profile: Optional[str] = None,
    timeout: int = 30,
    number_policy: Optional[str] = None,
    format: Optional[str] = None,
) -> bool

Replace one leg of an answered call with a freshly dialed target.

The transfer siphon already knows how to run, reachable without a remote party asking for it: it dials target as a new leg on the same call, re-anchors the surviving party's media onto it, and when the target answers promotes it into the surviving pair and BYEs the leg it replaced. An IVR that has decided where a caller goes next, a controller handing a call from an AI to a human, a supervisor take-over.

The replaced leg stays up while the target rings and is released only once the target answers, so the surviving party hears ringback rather than silence, and a target that rejects or never answers leaves the original call exactly as it was.

Acts now, so it works from an out-of-band event callback (@rtpengine.on_dtmf), a timer, or a normal handler.

Parameters:

Name Type Description Default
call_id str

SIP Call-ID of the call to act on.

required
target str

URI to dial as the replacement.

required
next_hop Optional[str]

steer egress without reshaping the R-URI, as on :meth:siphon_sdk.call.Call.dial.

None
replace_a_leg bool

False (default) replaces the callee and keeps the caller; True does the reverse.

False
profile Optional[str]

media profile for the pair this creates. Required when the call is anchored with a direction-bound profile — the inherited one describes the party that is leaving.

None
timeout int

seconds to wait for the target to answer. 0 means no ring policy, only siphon's guard against a target that never answers.

30
number_policy Optional[str]

reshape the target's number (and the triggered INVITE's identity headers) the way :meth:siphon_sdk.call.Call.dial does — this named policy, else b2bua.default_number_policy, else no reshaping. A replacement leg does not re-enter @b2bua.on_invite, so this is where a carrier's number format gets applied to it.

None
format Optional[str]

the inline form of the same thing, as on rewrite_identities(format=...): "e164", "plain", "international" or "national". Pass this or number_policy, never both.

None

Returns:

Name Type Description
bool bool

True once the INVITE is on the wire. It does not wait for the

bool

target — the replacement completes later, and @b2bua.on_bye

bool

fires for the leg that was replaced.

Raises:

Type Description
ValueError

the call is unknown, has not answered, has no peer leg to replace, already has a replacement in flight, or the target will not route. The message is prefixed with the stable cause token (not_found, invalid_state, bad_request, unavailable).

In the mock, records every argument on replacements and returns True; an empty target raises ValueError the way live siphon refuses one it cannot route. Inspect via siphon.get_b2bua().replacements.

Usage::

@rtpengine.on_dtmf
def on_ivr_dtmf(call_id, from_tag, digit, duration_ms, volume):
    if digit == "0":
        b2bua.replace_peer(call_id, "sip:operator@pbx.example",
                           timeout=45)

terminate

terminate(
    call_id: str, reason: str = "Normal Clearing"
) -> bool

Imperatively end a B2BUA call by its SIP Call-ID.

Unlike call.terminate() (deferred until its handler returns), this acts immediately and is keyed by SIP Call-ID, so it works from an out-of-band event callback (@rtpengine.on_dtmf, @rtpengine.on_media_timeout), a timer, or a normal handler.

Parameters:

Name Type Description Default
call_id str

the SIP Call-ID of the call to end.

required
reason str

free-text hangup reason (RFC 3326 Reason on the BYE).

'Normal Clearing'

Returns:

Name Type Description
bool bool

True if a matching call was found and torn down, False if the

bool

Call-ID is unknown / already gone. Never raises.

In the mock, records {"call_id", "reason"} on terminates and returns True. Inspect via siphon.get_b2bua().terminates.

Usage::

@rtpengine.on_dtmf
def on_ivr_dtmf(call_id, from_tag, digit, duration_ms, volume):
    if digit == "#":
        b2bua.terminate(call_id)

refer

refer(
    call_id: str,
    target: str,
    replaces: Optional[dict] = None,
) -> bool

Imperatively transfer a B2BUA call by its SIP Call-ID.

The imperative twin of :meth:Call.refer. Unlike call.refer() (a deferred call action, honoured after its handler returns), this acts immediately and is keyed by SIP Call-ID, so it works from an out-of-band event callback (@rtpengine.on_dtmf, a timer) where no call object is in scope and deferred actions are no-ops — the same reason :meth:terminate exists alongside call.terminate().

Parameters:

Name Type Description Default
call_id str

the SIP Call-ID of the call to transfer.

required
target str

the Refer-To URI (transfer destination).

required
replaces Optional[dict]

optional attended-transfer dict (RFC 3891) with call_id / from_tag / to_tag (and an optional early_only); None for a blind transfer.

None

Returns:

Name Type Description
bool bool

True if a matching call was found and the REFER was

bool

originated, False if the Call-ID is unknown / already gone.

bool

Never raises for a missing call.

Raises:

Type Description
ValueError

if replaces is given but missing any of call_id / from_tag / to_tag.

In the mock, records {"call_id", "target", "replaces"} on refers and returns True. Inspect via siphon.get_b2bua().refers.

Usage::

@rtpengine.on_dtmf
def on_ivr_dtmf(call_id, from_tag, digit, duration_ms, volume):
    if digit == "*":
        b2bua.refer(call_id, "sip:+15550142@example.com")

on_invite staticmethod

on_invite(fn: Callable) -> Callable

Register handler for new INVITE (new call).

Handler signature: (call) -> None

on_early_media staticmethod

on_early_media(fn: Callable) -> Callable

Register handler for provisional response with SDP (183/180).

Called when the B-leg sends a provisional response containing SDP (early media). Use this to process the SDP through RTPEngine so early media is anchored correctly.

Handler signature: (call, reply) -> None

Example::

@b2bua.on_early_media
async def early_media(call, reply):
    await rtpengine.answer(reply)

on_answer staticmethod

on_answer(fn: Callable) -> Callable

Register handler for call answered (200 OK on B-leg).

Handler signature: (call, reply) -> None

on_failure staticmethod

on_failure(fn: Callable) -> Callable

Register handler for a call that could not be connected.

Handler signature: (call, code, reason) -> None

Runs once, before the caller hears anything. code is what the call failed on: the best of its branches' failures (RFC 3261 §16.7), 408 for the ring timeout, 503 when the B-leg INVITE never left, no LCR carrier was routable, an LCR sequence ended on the ring timeout of a carrier that never sent a 101-199, or it moved on and none of the carriers left could be dialled, 500 when @b2bua.on_answer raised or ended an answered call, 420 ("Bad Extension") when the caller Require-s an extension the call cannot honour under its header policy (the default response lists them in Unsupported; a re-route under a policy that relays the extension goes through). What the handler leaves on call is carried out:

  • nothing, or call.terminate(): the caller gets code
  • call.reject(code, reason): the caller gets that response instead (3xx-6xx)
  • call.dial(), call.fork(), call.route(): the call is routed again, and this handler runs again if that fails too (at most 10 re-routes per call)
  • call.handover(app): the unanswered call goes to a control app
  • call.answer(): siphon answers the caller itself

A handler that raises decides nothing, and the call ends with code.

Example::

@b2bua.on_failure
def failed(call, code, reason):
    if code in (408, 480, 486):
        call.dial("sip:voicemail@198.51.100.20")

on_bye staticmethod

on_bye(fn: Callable) -> Callable

Register handler for BYE (call ended).

Handler signature: (call, initiator) -> None

initiator is a :class:ByeInitiator with a .side property ("a" or "b").

on_refer staticmethod

on_refer(fn: Callable) -> Callable

Register handler for REFER (call transfer, RFC 3515).

Handler signature is single-arg (call) -> None. A REFER is a SIP request, not a response, so there is no reply object — do NOT write (call, reply) and do NOT call rtpengine.answer() here. Read the transfer target off :attr:Call.refer_to (and :attr:Call.refer_replaces for an attended transfer), then decide with :meth:Call.accept_refer or :meth:Call.reject_refer.

Example::

@b2bua.on_refer
def handle_refer(call):
    log.info(f"Transfer requested to {call.refer_to}")
    call.accept_refer()

on_cancel staticmethod

on_cancel(fn: Callable) -> Callable

Register handler for a CANCELled call (RFC 3261 §9).

Handler signature: (call) -> None

Fires once, with the Call object, when an unanswered call (Calling/Ringing) is CANCELled — the teardown that on_failure (B-leg error) and on_bye (answered call) never cover. A 2xx that wins the CANCEL/answer glare is ACK+BYE'd by the framework and never delivers on_answer, so this hook only ever sees a genuinely abandoned call. Use it to release per-call resources that no BYE will clear: rtpengine media anchors, QoS sessions.

Example::

@b2bua.on_cancel
async def handle_cancel(call):
    await rtpengine.delete(call)

on_route_failure staticmethod

on_route_failure(fn: Callable) -> Callable

Register handler for one failed carrier of an LCR sequence.

Handler signature: (call, route, code) -> None

Fires once per failed attempt of a call.route(...) sequential failover — including the last one, and for every non-2xx a carrier returns (a definitive 486 as much as a 503) plus a ring timeout, reported as 408. That is the same set :attr:~siphon_sdk.call.Call.route_attempts records, so the two can never disagree; filter on code for whatever you count as the carrier's fault.

Purely a notification: the failover decision is already made by the time this runs, and unlike @b2bua.on_answer raising here does not change the call's outcome. Use it to count a carrier out, alert, or feed your own health view.

Example::

@b2bua.on_route_failure
def carrier_failed(call, route, code):
    if code in (408, 503):
        log.warn(f"carrier {route.carrier_id} failed {code}")

proxy.subscribe_state

Generic SUBSCRIBE-dialog state (RFC 6665) for any event package, with optional Redis-backed persistence.

Use handle = proxy.subscribe_state.accept(request, expires=seconds) after authenticating the subscriber and authorizing the event package and resource. It sends the 200 response with the dialog's To-tag and negotiated Expires. An unknown in-dialog request receives 481 and returns None. Refresh returns the same handle without resetting NOTIFY CSeq or event-body version. Immediately call handle.notify(body=..., content_type=...); for Expires zero, call handle.terminate(reason="deactivated", body=..., content_type=...) instead. The notifier tag is available as handle.local_tag. Expiry sends a terminating NOTIFY automatically; scripts still own package content and change notifications.

Direct subscriptions remember the received peer and transport. Background NOTIFY uses that exact live stream, never another phone sharing the same proxy address; a closed stream fails visibly. The Contact remains the NOTIFY Request-URI. Subscriptions with Record-Route follow their established route set.

Mock of the Rust proxy.subscribe_state namespace.

Used from scripts under test as proxy.subscribe_state.create(request). Records NOTIFY and terminate invocations on notifies / terminates lists for test assertions.

accept

accept(
    request: Any, expires: Optional[int] = None
) -> Optional[MockSubscribeHandle]

Accept or refresh an authorized notifier subscription; body is script-owned.

send

send(
    ruri: str,
    event: str,
    expires: int,
    accept: Optional[str] = None,
    target_uri: Optional[str] = None,
    headers: Optional[dict] = None,
    timeout_ms: int = 2000,
) -> MockSubscribeHandle

Mock outbound SUBSCRIBE — records the call and synthesises a dialog.

Tests can assert on the recorded self.sends list to verify a script originated a SUBSCRIBE with the expected parameters.

find

find(
    call_id: str, local_tag: str, remote_tag: str
) -> Optional[MockSubscribeHandle]

Mock dialog lookup by tags. Returns the first live dialog matching all three identity fields, or None.

SubscribeHandle

A single subscription dialog returned by proxy.subscribe_state.create(...).

Mock of the Rust SubscribeHandle.

In the mock, NOTIFY / terminate calls are recorded on the parent MockSubscribeState for test assertions. No real SIP message is produced.

event_version property

event_version: int

Current event-package body version (read-only).

Mirrors the Rust SubscribeHandle.event_version — used for RFC 3680 reginfo / RFC 4235 dialog-info / RFC 4575 conference bodies that require a monotonic version= attribute.

next_event_version

next_event_version() -> int

Atomically increment and return the next event-package body version.

Call before building a NOTIFY body whose monotonicity matters::

version = handle.next_event_version()
body = registrar.reginfo_xml(aor, state="full", version=version)
handle.notify(body=body, content_type="application/reginfo+xml")

refresh

refresh(
    expires: Optional[int] = None, timeout_ms: int = 2000
) -> bool

Mock refresh — records the call and updates the dialog's expiry.

Tests can assert on the parent's refreshes list. Raises if the dialog wasn't created via send() (consistent with the Rust contract that refresh is only valid on outbound dialogs).