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)

Which INVITEs the B2BUA takes

Any registered @b2bua.* handler makes every new INVITE a B2BUA call, and @proxy.on_request never sees one. To be a B2BUA for some INVITEs and a proxy for the rest, give each @b2bua.on_invite a request_uri= filter:

from siphon import b2bua, proxy

@b2bua.on_invite(request_uri=r"^sip:transfer@")
async def transfer(call):
    call.dial(call.ruri)

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

The filter is a regular expression in Rust regex syntax (no look-around or backreferences), searched for anywhere in the Request-URI as it is serialised, so anchor it with ^ and $ when it should match the whole URI. A pattern that does not compile raises ValueError at decoration.

  • When at least one on_invite handler has no filter, every new INVITE is the B2BUA's, as before.
  • When every on_invite handler is filtered, the B2BUA takes an initial INVITE (no To-tag) whose Request-URI some filter matches, and runs only the handlers that match. Every other INVITE takes the proxy path exactly as in a script with no B2BUA handlers, and so does an in-dialog INVITE that belongs to no B2BUA call (a re-INVITE on a dialog the proxy set up), whatever its Request-URI.
  • control.inbound still takes every INVITE.

The rest of a dialog follows the call it belongs to: an ACK, BYE, CANCEL, UPDATE, INFO, PRACK, REFER or NOTIFY reaches the B2BUA when it matches one of its calls and the proxy path otherwise.

proxy namespace

Mock proxy namespace with decorator registration and utility stubs.

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

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_or_filter: Union[Callable, str, None] = None,
) -> Any

Register a handler for SIP replies.

Handler signature: (request, reply) -> None

Can be used as
  • @proxy.on_reply — every response
  • @proxy.on_reply() — same, explicit call
  • @proxy.on_reply("REGISTER") — responses to REGISTER only
  • @proxy.on_reply("INVITE|UPDATE") — pipe-separated filter

The filter matches the method of the request the response answers (its CSeq method), not anything in the response itself. Filtered and unfiltered handlers all run, in registration order, as with @proxy.on_request. A response that no handler matches is forwarded unchanged, exactly as when no reply handler is registered.

Filtering is how a reply handler that awaits for one method stays off the asyncio driver for every other response::

@proxy.on_reply("INVITE")
async def anchor_answer(request, reply):
    if reply.has_body("application/sdp"):
        await rtpengine.answer(reply)
    reply.relay()

@proxy.on_reply
def every_reply(request, reply):
    reply.relay()

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.

Shorthand for @proxy.on_reply("REGISTER"): it runs alongside any unfiltered @proxy.on_reply handler, in registration order.

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,
    flow: Optional[Any] = None,
    route: Optional[list[str]] = None,
) -> 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
flow Optional[Any]

Send over a flow. A registration.flow(impu) is the UE's own SA: the request leaves over it with route as its Route headers (TS 24.229 5.1.2A.1.1). With listen.mtu set, a request over that SA that is over the threshold (RFC 3261 18.1.1) leaves over TCP from the UE's protected client port to the P-CSCF's protected server port, each request by its own size, and goes over UDP when the connection cannot be opened. A callee's flow is used with no route set and yields to one, as in call.dial; it keeps its transport, as does a flow already on a stream transport.

None
route Optional[list[str]]

The route set, one Route header per entry, in order. Not with a Route in headers.

None

Raises:

Type Description
ValueError

flow with next_hop, or route with a Route in headers.

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, proxy.release, and proxy.memory_used_pct.

Ending a session from outside a transaction

await proxy.release(call_id, reason=None, toward="both", status=487) ends a session the proxy Record-Routed, from a Diameter handler, @sbi.on_terminate, a media timeout or a timer. It needs dialog.proxy_release: true, which keeps the dialog state of every INVITE the script Record-Routes.

A P-CSCF told by the PCRF that the bearer is gone (Rx ASR, TS 29.214 §4.4.6.1):

from siphon import diameter, proxy

@diameter.on_request("Rx:ASR")
async def on_abort(request):
    call_id = rx_sessions.pop(request.session_id, None)
    if call_id:
        await proxy.release(call_id, reason='SIP;cause=503;text="Bearer released"')
    return request.answer(2001)
State of the session What goes on the wire
Confirmed A BYE toward each end in toward (TS 24.229 §5.2.8.1.2), built from the stored dialog: Request-URI from that end's Contact, the dialog's From, To and Call-ID, the CSeq one above the last seen in that direction, the route set toward that end, reason as the Reason header (RFC 3326). Toward a UE on IPsec it leaves from the SA's port, as a relayed in-dialog request does.
Being established Every pending branch CANCELled (with reason), the caller answered status, @proxy.on_cancel run once (§5.2.8.1.1).
Unknown, ended, never Record-Routed Nothing; resolves False.

While its BYEs are out, any other request on the released dialog is answered 481 (§5.2.8.1.3). The Rf ACR-STOP and the CDR are closed as for a relayed BYE; the media and QoS sessions belong to the script in proxy mode, and it ends them itself (rtpengine.delete, diameter.rx_str). TS 24.229 §5.2.8.1.2 has a P-CSCF that lost the bearer of the user it serves send its BYE toward the other user only: toward="callee" when it serves the caller, toward="caller" when it serves the callee. A spiral (one proxy on both access legs of one Call-ID) is released from its first leg: the BYE toward the callee follows that leg's route set back through siphon onto the second.

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.

Resolves the number through ENUM (RFC 6116): the NAPTR records of the number under suffix are taken by order, then preference, and the first terminal (u flag) record that offers one of the Enumservices in service and whose regular expression matches the number gives the result. The expression is matched against the number written with its leading + and digits only, and the record's replacement is returned with its back-references (\1 to \9) filled in. Non-terminal records are not followed.

Parameters:

Name Type Description Default
number str

E.164 number (e.g. "+441632960083"). Characters other than digits are ignored.

required
suffix str

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

'e164.arpa.'
service str

Services field naming the wanted Enumservices, "E2U+enumservice[+enumservice...]" (default "E2U+sip"). Compared without case; a record matches when it offers any one of them.

'E2U+sip'

Returns:

Type Description
Optional[str]

The URI string (e.g. "sip:+441632960083@example.com") or

Optional[str]

None when no record yields one.

In the mock, looks up _enum_results dict.

set_enum_answer

set_enum_answer(
    number: str,
    outcome: str = "ok",
    uris: Optional[list[str]] = None,
    records: Optional[list[dict]] = None,
    server: Optional[str] = None,
) -> None

Test helper: what enum_query answers for number.

enum_query

enum_query(
    number: str,
    suffix: str = "e164.arpa.",
    service: str = "E2U+sip",
    servers: Optional[list[str]] = None,
    timeout_ms: Optional[int] = None,
    attempts: Optional[int] = None,
    follow_non_terminal: bool = True,
) -> Any

Run an ENUM query (RFC 6116) and report what it came to. Awaitable; resolves to an :class:MockEnumAnswer (EnumAnswer).

enum_lookup returns one URI or None and cannot tell "this number has no ENUM entry" from "the ENUM service did not answer". This does: answer.outcome is "ok", "no_such_name" (NXDOMAIN), "no_records" (NODATA), "no_usable_record", "server_failure" (SERVFAIL, REFUSED, no server reachable) or "timeout", and answer.uris holds every usable URI in ORDER / PREFERENCE sequence, so the caller can move on to the next when one does not work (RFC 6116 section 5.2, RFC 3402 section 3.3).

A non-terminal record (empty Flags) names the next domain to ask in its Replacement (RFC 6116 sections 3.4.2 and 5.2.1); with follow_non_terminal (the default) that domain's records are walked in its place, up to five in a chain, a loop back to a name already asked ending the chain rather than the query.

Parameters:

Name Type Description Default
number str

the E.164 number; characters other than digits are ignored.

required
suffix str

the ENUM domain (default "e164.arpa.").

'e164.arpa.'
service str

E2U+enumservice[+enumservice...], e.g. "E2U+sip+pstn:tel"; a record offering any of them qualifies.

'E2U+sip'
servers Optional[list[str]]

ENUM servers to ask instead of the node's resolver, "ip" (port 53) or "ip:port", in order. The first that answers wins; one that times out or answers with an error is held down for enum.server_hold_down_secs (default 30) and asked only after the others (siphon_enum_server_up).

None
timeout_ms Optional[int]

per attempt at one server (default 2000); needs servers.

None
attempts Optional[int]

per server (default 2); needs servers.

None
follow_non_terminal bool

follow non-terminal records (default True).

True

Raises:

Type Description
ValueError

the number holds no digit, service is not an E2U field, servers is empty, or timeout_ms / attempts is given without servers.

Example::

answer = await proxy.enum_query(
    "+15550100106", service="E2U+sip+pstn:tel",
    servers=["192.0.2.10:53", "192.0.2.20:53"], timeout_ms=500,
)
if answer.outcome in ("server_failure", "timeout"):
    ...   # the ENUM service is down: retry or alarm
for uri in answer.uris:
    ...   # try in turn

In the mock, the answer given to set_enum_answer() for the number; else "ok" with the _enum_results URI for it, or "no_such_name". Every call is recorded on enum_queries.

add_releasable_session

add_releasable_session(call_id: str) -> None

Test helper: make call_id a session release() finds, as a Record-Routed call is on a node with dialog.proxy_release: true.

release

release(
    call_id: str,
    reason: Optional[str] = None,
    toward: str = "both",
    status: int = 487,
) -> Any

End a session this proxy Record-Routed, by its Call-ID. Awaitable.

Callable from any context: a Diameter handler (Rx:ASR), @sbi.on_terminate, a media timeout, a timer. Needs dialog.proxy_release: true in siphon.yaml, which keeps the dialog state of every INVITE the script Record-Routes.

  • A confirmed dialog gets a BYE toward each end in toward, built from the stored dialog (TS 24.229 §5.2.8.1.2): Request-URI from that end's Contact, the dialog's From, To and Call-ID, the CSeq one above the last seen in that direction, the stored route set, reason as the Reason header. Toward a UE on IPsec it leaves from the SA's port, as a relayed in-dialog request does.
  • A session still being established has every pending branch CANCELled (with reason) and the caller answered status; @proxy.on_cancel runs once (§5.2.8.1.1).

The Rf ACR-STOP and the CDR close as for a relayed BYE. Media and QoS sessions are the script's to end, as in its BYE handler. Until the BYEs are answered, other requests on the dialog are answered 481 (§5.2.8.1.3).

Parameters:

Name Type Description Default
call_id str

the session's Call-ID.

required
reason Optional[str]

Reason header value, e.g. 'SIP;cause=503;text="Bearer released"'.

None
toward str

"both" (default), "caller" or "callee".

'both'
status int

final response for a session still being established, 300-699 (default 487).

487

Returns:

Type Description
Any

True once the release is on the wire, False when the

Any

Call-ID names no session (nothing is sent; never raises for it).

Raises:

Type Description
ValueError

toward or status is not one it accepts.

Example::

@diameter.on_request("Rx:ASR")
async def on_abort(request):
    call_id = sessions.pop(request.session_id, None)
    if call_id:
        await proxy.release(call_id, reason='SIP;cause=503;text="Bearer released"')
    return request.answer(2001)

In the mock, True for a Call-ID given to add_releasable_session() (once), False otherwise; every call is recorded on releases.

memory_used_pct

memory_used_pct() -> int

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

In the mock, returns _memory_pct (default 25).

ENUM with outcome and failover

proxy.enum_lookup gives one URI or None. proxy.enum_query says what the query came to, so "no ENUM entry" and "ENUM service down" take different paths, and gives every candidate so the next one can be tried:

from siphon import proxy

ENUM_SERVERS = ["192.0.2.10:53", "192.0.2.20:53"]

@proxy.on_request("INVITE")
async def route(request):
    answer = await proxy.enum_query(
        request.ruri.user, service="E2U+sip+pstn:tel",
        servers=ENUM_SERVERS, timeout_ms=500, attempts=1,
    )
    if answer.outcome in ("server_failure", "timeout"):
        request.reply(503, "Service Unavailable")
        return
    sip_uris = [uri for uri in answer.uris if uri.startswith("sip:")]
    if sip_uris:
        request.relay(sip_uris[0])
    else:
        request.relay()   # off-net: the default route

A server that times out or answers with an error is passed over for enum.server_hold_down_secs (default 30); siphon_enum_server_up{server} shows which are in use and siphon_enum_queries_total{outcome} counts the outcomes.

The result of proxy.enum_query (EnumAnswer).

Attributes:

Name Type Description
outcome

"ok", "no_such_name" (NXDOMAIN: the number has no entry), "no_records" (NODATA), "no_usable_record", "server_failure" or "timeout". The last two are failures of the ENUM service, worth a retry or an alarm; the middle three are answers.

uris

every usable URI, in ORDER / PREFERENCE sequence (RFC 6116 section 5.2). Try them in turn; the first is what enum_lookup would pick.

records

every record considered, as dicts with order, preference, flags, services, regexp, replacement, domain and rejected (None, or why it gave no URI).

server

the server ("ip:port") that answered, when servers= was given and one did; else None.

b2bua namespace

Mock B2BUA namespace with decorator registration.

Decorators
  • @b2bua.on_invite — new call
  • @b2bua.on_provisional: every provisional response relayed (101-199)
  • @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_reoffer / @b2bua.on_reoffer_answered — an in-dialog re-INVITE or UPDATE (hold, resume, refresh), before it is relayed and once it is answered
  • @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,
    route: Optional[list] = 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
route Optional[list]

a pre-loaded route set (RFC 3261 §8.1.2), e.g. ["<sip:orig@scscf.example.com;lr>"]: the INVITE's Route headers, in order, copied onto its CANCEL and the ACK of a failure. With no next_hop the INVITE goes to the topmost entry. How an application server hands a request it originates to its S-CSCF with the session case in the Route entry (TS 24.229 §5.7.3); next_hop only says where the packet goes.

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)

hold_call

hold_call(
    caller: HeldLeg,
    callee: HeldLeg,
    state: str = "answered",
) -> HeldCall

Make the mock node hold a call between caller and callee, for :meth:adopt_replaced to find (test helper). state is "answered" for a confirmed call; anything else is an early one.

replaces_call_facts

replaces_call_facts(
    replaces: Optional[dict],
) -> Optional[dict]

What call.refer_replaces_call reads: the facts of the leg the Replaces names, or None when the node holds no such dialog.

adopt_replaced async

adopt_replaced(
    call: Any,
    conference_id: str,
    role: str = "talker",
    on_owner_leave: str = "end",
    on_last_leave: str = "keep",
    dtmf: Optional[str] = None,
) -> int

Merge the call a REFER's Replaces names into a conference room on this node (RFC 4579 section 5.5, a handset's "merge").

Called from @b2bua.on_refer on the conference dialog, after call.accept_refer(mode="script"). The named dialog is the referrer's own leg of a call this node holds (its caller or callee leg). The call's other leg, the far party, is seated in conference_id in place (siphon-rtp conference_seat): no SIP goes to that party, its media keeps the same ports and the same session. The named leg is then sent a BYE with Reason: SIP;cause=200; text="Merged into conference" (RFC 3891).

The adopted call stays a call: its Ro session, maximum duration, session timer and CDR carry on, with the far party as its one SIP leg. A re-INVITE or UPDATE from the far party is answered against the session in force (@b2bua.on_reoffer sees it first); its BYE, a credit or duration teardown, or the seat going silent ends it, which takes the seat out of the room.

siphon first checks the dialog belongs to the referrer: the same auth_user when both legs carry one, else the same connection on a stream transport, else the same source address and the same user. call.refer_replaces_call shows the facts compared, so a script can refuse more; it cannot accept what siphon refuses.

Parameters:

Name Type Description Default
call Any

the Call of the @b2bua.on_refer handler.

required
conference_id str

the room to seat the far party in.

required
role str

"talker" (default), "listener" or "muted".

'talker'
on_owner_leave str

"end" (default) tears the adopted call down with a BYE (Reason: Q.850;cause=16) when the conference dialog that adopted it ends; "keep" leaves it in the room.

'end'
on_last_leave str

"keep" (default) leaves the conference dialog alone. "end_owner" ends it, with a BYE carrying Reason: Q.850;cause=16, when the last call adopted under it has left the room and no seat but the dialog's own remains: a conference of one is over. Held by siphon, not the script.

'keep'
dtmf Optional[str]

None or "events" (default): the adopted party's digits stay @rtpengine.on_dtmf events. "forward" has the room regenerate them toward the other seats; an engine that does not list conference_dtmf refuses it (501). Anything else raises ValueError.

None

Returns:

Name Type Description
int int

the status for b2bua.complete_refer: 200; 481 no

int

confirmed dialog here matches (or no Replaces); 403 the

int

dialog is not the referrer's; 488 the engine or the call's

int

state refused the seat; 501 the media backend is not

int

siphon-rtp or the engine lacks conference_seat. A refusal

int

leaves the named call untouched.

Raises:

Type Description
ValueError

outside @b2bua.on_refer, an empty conference_id, or an unknown role / on_owner_leave / on_last_leave.

In the mock the comparison is applied for real to the calls :meth:hold_call registered, and the far leg is seated in rtpengine.conferences[conference_id] under its tag. The referrer's source is call.flow.remote_addr ("<source_ip>:5060" without a flow). Prime the engine with rtpengine.set_conference_seat_supported(False) (501) or rtpengine.refuse_conference_seat(reason) (488). Each merge is recorded on adoptions; :meth:conference_dialog_ended plays the conference dialog hanging up.

Usage::

@b2bua.on_refer
async def on_refer(call):
    call.accept_refer(mode="script")
    status = await b2bua.adopt_replaced(call, room_of(call))
    b2bua.complete_refer(call.call_id, status)

conference_dialog_ended

conference_dialog_ended(owner_call_id: str) -> list[str]

Play the conference dialog owner_call_id hanging up (test helper): every call it adopted with on_owner_leave="end" is ended with Reason: Q.850;cause=16 and its seat leaves the room, each reported to @b2bua.on_conference_change as a "left". Returns the Call-IDs of the calls ended; each is also on terminates. The dialog's own seat is the script's to free, as in siphon (rtpengine.delete(call) in @b2bua.on_bye).

adopted_call_ended

adopted_call_ended(call_id: str) -> bool

Play the far party of an adopted call hanging up, or the call ending any other way (test helper): the call goes and its seat leaves the room, reported to @b2bua.on_conference_change. With on_last_leave="end_owner" the last such call going also ends the conference dialog. True when call_id was an adopted call (@b2bua.on_bye is not run: dispatch it with harness.send_bye, which calls this).

conference_roster

conference_roster(conference_id: str) -> list[dict]

The seats currently in a conference room, from siphon's own state.

A list of dicts, in the order the seats were taken, one per seat: call_id (the SIP Call-ID of the dialog with the seated party), from_tag, party_uri (the seated party's own URI, or None), role ("talker", "listener", "muted", "whisper" or "monitor"), dtmf ("events" or "forward": whether the room sends the seat's digits on to the other seats), joined_at (unix seconds, a float), kind ("joined" for a seat taken with rtpengine.conference_join / conference_answer, "adopted" for one :meth:adopt_replaced made) and owner_call_id (the SIP Call-ID of the conference dialog that adopted the call, None for a joined seat). An empty list for a room nobody is seated in.

Nothing here is the script's: it reads the same after a reload, so a stateless script can build a conference-info body (RFC 4575) from it at any time.

Example::

users = [seat["party_uri"] for seat in b2bua.conference_roster(room)]

complete_refer

complete_refer(
    call_id: str, code: int, reason: Optional[str] = None
) -> bool

Report how a REFER the script accepted with call.accept_refer(mode="script") went, ending its implicit subscription: a NOTIFY whose message/sipfrag body is the status line of code (RFC 3515 section 2.4.5), with Subscription-State: terminated. A 2xx tells the referrer it succeeded, anything else that it failed.

Parameters:

Name Type Description Default
call_id str

the SIP Call-ID of the dialog the REFER came in (call.call_id in @b2bua.on_refer).

required
code int

the final status to report, 200-699.

required
reason Optional[str]

the reason phrase; the usual one for code when omitted.

None

Returns:

Name Type Description
bool bool

True once the NOTIFY is sent. False when no REFER on that

bool

call is awaiting its report (already reported, its deadline passed

bool

and siphon reported 503 for it, or the referrer hung up).

Raises:

Type Description
ValueError

code is not 200-699, or reason has a line break.

In the mock, records {"call_id", "code", "reason"} on refer_reports for a call in open_script_refers (which accept_refer(mode="script") adds to) and returns True.

Usage::

@b2bua.on_refer
async def on_refer(call):
    call.accept_refer(mode="script")
    code = await add_to_conference(call.refer_to)
    b2bua.complete_refer(call.call_id, code)

send_info async

send_info(
    call_id: str,
    body: Optional[Union[str, bytes]] = None,
    content_type: Optional[str] = None,
    headers: Optional[dict] = None,
    timeout_ms: int = 5000,
    leg: str = "caller",
    with_body: bool = False,
) -> Optional[Union[int, tuple]]

Send an INFO (RFC 6086) in the caller's dialog of a call (on a call siphon answered itself, its only dialog), or with leg="callee" in the answered callee's. The CSeq is the dialog's, the request goes to the remote target through the route set, and on UDP it is retransmitted until answered.

Parameters:

Name Type Description Default
call_id str

the SIP Call-ID of the call.

required
body Optional[Union[str, bytes]]

str or bytes.

None
content_type Optional[str]

the body's Content-Type.

None
headers Optional[dict]

extra headers; the ones that place a request in its dialog (Call-ID, CSeq, From, To, Via, Route, Contact, ...) are siphon's and refused.

None
timeout_ms int

how long to wait for the final response.

5000
leg str

"caller" or "callee".

'caller'
with_body bool

resolve to (status, body, content_type) instead.

False

Returns:

Type Description
Optional[Union[int, tuple]]

Optional[Union[int, tuple]]: the final status code (or the (status, body, content_type) tuple), or None when none arrived in timeout_ms.

Raises:

Type Description
ValueError

the call is unknown (not_found), a header may not be set (bad_request), or the call has no dialog yet (invalid_state).

In the mock, records {"call_id", "body", "content_type", "headers", "leg"} on sent_infos and returns info_status (200), or (info_status, info_body, info_content_type).

Usage::

status = await b2bua.send_info(call.call_id, body,
                               "application/example+xml")

session_sdp

session_sdp(
    call_id: str, leg: str = "caller"
) -> Optional[str]

The session in force toward one party of a call, by its SIP Call-ID: the description siphon last sent the caller, or with leg="callee" the answered callee. None when the call or party is unknown or siphon has sent it none.

In the mock, what the test put in session_sdps[(call_id, leg)].

reoffer async

reoffer(
    call_id: str,
    sdp: Optional[str] = None,
    leg: str = "caller",
    timeout_ms: int = 32000,
) -> Optional[int]

Re-offer one party of a call by its SIP Call-ID: a re-INVITE in the caller's dialog (on a call siphon answered itself, its only dialog), or with leg="callee" in the answered callee's, carrying sdp, or the session in force toward that party when sdp is None. siphon puts its own o= on it. The other party is told nothing.

For telling a party something about the session it did not ask about: RFC 7866's a=record, when a recording starts or stops on its own.

Returns:

Type Description
Optional[int]

Optional[int]: the final status code, 491 without anything sent when an INVITE toward that party is already in progress (wait and send again), or None when no final response arrived in timeout_ms.

Raises:

Type Description
ValueError

the call is unknown (not_found), or the party's dialog is not established or was never sent a session (invalid_state).

In the mock, records {"call_id", "sdp", "leg"} on reoffers and returns reoffer_status (200).

Usage::

status = await b2bua.reoffer(call_id, sdp=session + "a=record:off\r\n")

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: Optional[Callable] = None,
    *,
    request_uri: Optional[str] = None
) -> Callable

Register handler for new INVITE (new call).

Handler signature: (call) -> None

Usage::

@b2bua.on_invite                                  # every new INVITE
@b2bua.on_invite(request_uri=r"^sip:transfer@")   # only these

request_uri is a regular expression searched for anywhere in the Request-URI as it is serialised (anchor it with ^ / $ to match the whole URI). The runtime compiles it with Rust's regex syntax (no look-around, no backreferences); an invalid pattern raises ValueError at decoration. The mock checks it with Python's re.

With no filter, any @b2bua.* handler makes every new INVITE a B2BUA call. When every on_invite handler carries a filter, the B2BUA takes only the initial INVITEs (no To-tag) a filter matches and runs only the handlers that match (plus any unfiltered ones). Every other INVITE, an in-dialog one included, takes the proxy path, where @proxy.on_request sees it as in a script with no B2BUA handlers. control.inbound still takes every INVITE. Drive both paths with :meth:siphon_sdk.testing.SipTestHarness.dispatch_invite.

Not every inbound INVITE reaches this handler. With b2bua.inbound_limit configured, a call past max_concurrent_calls or max_calls_per_second is refused by siphon first (503 and Retry-After unless configured otherwise) and no handler runs for it: there is no Call, and nothing for a script to log or re-route. The refusal is visible as a CDR (:attr:siphon_sdk.cdr.CallDetailRecord.is_refused) and in siphon_b2bua_inbound_calls_refused_total.

max_calls_per_second counts INVITEs, so a call this handler challenges with auth.require_proxy_digest() spends two: the challenged INVITE and the retry that carries credentials.

Test it with :meth:siphon_sdk.testing.SipTestHarness.set_inbound_limit.

on_provisional staticmethod

on_provisional(fn: Callable) -> Callable

Register handler for every provisional response relayed to the caller.

Called for each 101-199 the callee sends that siphon relays to the caller, with or without SDP: a plain 180 Ringing as much as a 183 with early media. Not for a 100 Trying, which is hop by hop, nor for a provisional siphon drops (one that arrives after the answer, or on a leg that has already ended). Use it to mark the response the caller sees, or to note that the callee is being alerted.

Handler signature: (call, reply) -> None

reply is the callee's response. A header set or removed on it reaches the caller as the handler left it, as in on_answer; the dialog headers stay siphon's. A provisional with SDP goes on to on_early_media afterwards, with the same reply.

The handler may also decide what the caller is sent of the response: reply.relay(body=False) relays it without its body, and reply.withhold() relays nothing for it. Either way the callee's dialog goes on as it would have, and the caller gets the answer in the 2xx. A handler that does neither leaves the response relayed as it is.

Example::

@b2bua.on_provisional
def provisional(call, reply):
    if reply.status_code == 180:
        reply.set_header("Privacy", "id")

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. A provisional without SDP runs on_provisional only.

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_reoffer staticmethod

on_reoffer(fn: Callable) -> Callable

Register handler for an in-dialog offer on a bridged call.

Handler signature: (call, offer) -> None where offer is a :class:siphon_sdk.reoffer.Reoffer.

Called when either party sends a re-INVITE or an UPDATE (RFC 3311, the early-dialog UPDATE included) that siphon would relay, before it is relayed. offer.side says who sent it, offer.sdp is the offer, offer.request the request for its headers, and offer.kind reads it against the session in force: "hold", "resume", "refresh" or "change".

Doing nothing relays the offer. offer.reject(code, reason) refuses it; offer.answer(sdp=None) answers it here and relays nothing. A handler that raises decides nothing: the offer is relayed. Not called for a 491 siphon answers to a crossing offer, on a call siphon answered itself whose media session the engine answers for, or on a controller bridge. Called, with nobody to relay to, for a conference seat and for a call merged into a room with b2bua.adopt_replaced.

Several handlers: every registered one runs, in registration order, on the same offer. Each answer() / reject() replaces the decision before it, so the last handler to decide wins. A handler that raises stops the rest and the offer is relayed as it arrived, an earlier handler's decision dropped with it. A script with several should have each return early for calls that are not its own (a conference handler for the merged or seated calls it tracks, the hold handler for the rest).

Example::

@b2bua.on_reoffer
def hold(call, offer):
    if offer.kind == "hold":
        offer.answer()

on_conference_change staticmethod

on_conference_change(fn: Callable) -> Callable

Register handler for a change of who is seated in a conference room.

Handler signature: (conference_id, owner_call_id, change, call_id, roster) -> None, sync or async.

Called once for each seat that comes in (rtpengine.conference_join / conference_answer, a successful b2bua.adopt_replaced) and once for each seat that goes out, whatever the reason (a delete, rtpengine.conference_leave, the adopted call's BYE, the conference dialog ending the calls it adopted: one "left" per seat).

  • change is "joined" or "left".
  • call_id is the SIP Call-ID of the seat that changed.
  • owner_call_id is the SIP Call-ID of the conference dialog that adopted that seat; for a joined seat, the dialog the adopted seats in the room agree on, else None.
  • roster is the room after the change, the dicts of b2bua.conference_roster(conference_id).

A raise is swallowed (siphon logs it) and changes nothing. In the mock the handler runs to completion before the call that caused the change returns; siphon runs it off the SIP message path.

Example::

@b2bua.on_conference_change
async def changed(conference_id, owner_call_id, change, call_id, roster):
    for handle in proxy.subscribe_state.for_resource(focus_uri(conference_id), "conference"):
        await handle.notify(body=conference_info(roster),
                            content_type="application/conference-info+xml")

on_reoffer_answered staticmethod

on_reoffer_answered(fn: Callable) -> Callable

Register handler for the final response to an in-dialog offer.

Handler signature: (call, offer) -> None.

Called once the party that sent a re-INVITE or an UPDATE on a bridged call has its final response, whoever gave it: offer.status_code, offer.answer_sdp, offer.answer_direction and offer.answered_locally say how it ended. A notification: a raise changes nothing.

Example::

@b2bua.on_reoffer_answered
async def held(call, offer):
    if offer.kind == "hold" and offer.side == "a" and offer.status_code == 200:
        await rtpengine.play_media(call, file="/srv/moh.wav",
                                   repeat="inf", wait=False)

on_info staticmethod

on_info(fn: Callable) -> Callable

Register handler for an in-dialog INFO (RFC 6086) on a call siphon answered itself, or from either party of a bridged call.

Handler signature: (call, request) -> None.

Read request.body and request.content_type; answer with request.set_reply_body(body, content_type) (optional) and request.reply(code, reason), a final status. Without a handler, or when it answers nothing, siphon answers a bare 200 OK. A retransmission gets the same response without running the handler again. On a call with a far leg the handler sees an INFO from either party; one it answers is not relayed, one it leaves is relayed.

Example::

@b2bua.on_info
async def info(call, request):
    if request.content_type == "application/example+xml":
        request.set_reply_body(b"<ok/>", "application/example+xml")
        request.reply(200, "OK")
    else:
        request.reply(415, "Unsupported Media Type")

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 stages the 200 response with the dialog's To-tag and negotiated Expires, which siphon sends when the handler returns. An unknown in-dialog request receives 481 and returns None. Refresh returns the same handle without resetting NOTIFY CSeq or event-body version. Immediately await handle.notify(body=..., content_type=...); for Expires zero, await handle.terminate(reason="deactivated", body=..., content_type=...) instead. A NOTIFY sent from the handler that accepted the subscription is held until that handler returns and goes on the wire after the 200 (RFC 6665 §4.1.2.3). One sent later, from a timer or a background task, goes out immediately. 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.

proxy.subscribe_state.for_resource(uri, event) lists the live subscriptions this node is the notifier of for a resource and event package, oldest first, as SubscribeHandles: the subscribers to NOTIFY when the resource changes (a conference focus URI and "conference", RFC 4575). The URI is compared exactly after the normalisation a stored resource has (name-addr brackets and display name stripped), the event is the package token without its ;id= parameter, case-insensitive. Terminated and expired subscriptions are left out, and so are subscriptions the node originated as the subscriber. Like find, it reads the in-process store: the Redis cache is keyed by dialog id and cannot be searched by resource, so a subscription held only there (another replica's, or from before a restart) is listed after await proxy.subscribe_state.get(id) has loaded it.

As a subscriber, handle = await proxy.subscribe_state.send(ruri, event=..., expires=...) sends the SUBSCRIBE and returns once the 2xx is in. The subscription exists from the moment the SUBSCRIBE leaves, because the notifier's first NOTIFY may arrive before that 2xx (RFC 6665 §4.1.2.4). siphon matches such a NOTIFY on Call-ID, the To tag and the Event header and establishes the dialog from it (§4.4.1), route set included, before the NOTIFY handler runs. So proxy.subscribe_state.find(request.call_id, request.to_tag, request.from_tag) returns the same handle in either order, and None means the NOTIFY belongs to no subscription: answer it 481.

A UE subscribes over its own security association with send(..., flow=registration.flow(impu), route=[<P-CSCF protected URI>] + registration.service_route(impu)): the SUBSCRIBE leaves from the UE's protected client port to the P-CSCF's protected server port with the route set as Route headers, and handle.refresh() / handle.terminate() go over the SA set the registration holds when each is sent (see the IMS multi-UE cookbook). proxy.send_request() takes the same flow= and route= for a MESSAGE or any other request.

What the SUBSCRIBE transaction then decides, per the subscriber state machine of §4.1.2:

The SUBSCRIBE gets after a NOTIFY established the subscription with no NOTIFY yet
a 2xx send() returns the handle; the dialog stays the NOTIFY's send() returns the handle; the 2xx establishes the dialog
no final response within timeout_ms send() returns the handle: the subscription stands send() raises; nothing is left
a non-2xx send() raises; the subscription is withdrawn (§4.1.2.1) send() raises; nothing is left

A NOTIFY with Subscription-State: terminated ends the subscription in any order. The NOTIFY handler still finds it with find() and answers 200; siphon removes it when the handlers have returned (§4.4.1), after which the handle raises LookupError.

The 2xx and the NOTIFYs travel separately and race, so what send() returns depends on which of them arrived and never on their order. A terminating NOTIFY is reported to the NOTIFY handler and nowhere else: send() returns the handle on the 2xx whether that NOTIFY came first or second, and in both cases the subscription no longer exists once the handler has returned. A one-shot fetch (expires=0) therefore reads its result in the NOTIFY handler and gets back a handle that is already spent.

One send() tracks one dialog: the first NOTIFY's, or the 2xx's when no NOTIFY came before it. A NOTIFY for the same SUBSCRIBE from another notifier tag, which a forked SUBSCRIBE produces, is answered 481 by siphon without running the handler, and a 2xx from another fork does not change the dialog (§5.4.9).

What the dialog holds is the notifier's to say, and is likewise the same in either order:

  • Duration. handle.expires is the shortest the notifier has stated, in the Expires of the 2xx (§4.1.2.1) or the expires of a NOTIFY's Subscription-State (§4.1.2.2). Only handle.refresh() lengthens it. Schedule the refresh from handle.expires, not from the value you asked for.
  • Target. A NOTIFY is a target refresh request, so its Contact is where the next handle.refresh() or handle.terminate() goes (§4.4.1). The 2xx supplies the target only until a NOTIFY has.
  • Route set. Fixed by whichever message established the dialog (RFC 3261 §12.1). A 2xx that would have given another one is logged at warn.

Four things end a subscription before it expires:

What happened What siphon does
A NOTIFY with Subscription-State: terminated Removes it once the NOTIFY handlers have returned
handle.terminate() Sends SUBSCRIBE Expires: 0; the subscription stays until the notifier's terminating NOTIFY (§4.1.2.3), which the handler finds and answers 200, or 32 s (Timer N)
A 2xx with no NOTIFY inside 32 s (Timer N, §4.1.2.4) Removes it: the attempt failed
A non-2xx after send() had returned on its timeout Removes it: no subscription was created (§4.1.2.1)

A subscription read back with proxy.subscribe_state.get(id), after a restart or on another replica, is tracked the same way from the moment it is loaded.

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.

expire_unanswered

expire_unanswered() -> list[str]

Test helper: let Timer N run out.

Removes every subscription a script unsubscribed with handle.terminate() whose terminating NOTIFY never came, as siphon does 32 seconds after the SUBSCRIBE, and returns their ids.

accept

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

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

Stages the 200 (To-tag, negotiated Expires, Contact); siphon sends it when the handler returns. A handle.notify() / handle.terminate() awaited in the same handler is held and goes on the wire after that 200 (RFC 6665 section 4.1.2.3), so a test can expect 200 then NOTIFY::

handle = proxy.subscribe_state.accept(request, expires=3600)
if handle is not None:
    await handle.notify(body=summary, content_type=MWI_TYPE)

A refresh inside a known dialog may leave out its Contact: the remote target stays as it was (RFC 3261 section 12.2.2). An initial SUBSCRIBE without a Contact has no remote target (RFC 6665 section 4.1.2.1): siphon's accept() raises ValueError("SUBSCRIBE missing Contact") and stores nothing, so check the header first and answer request.reply(400, "Bad Request"). The mock does not enforce that.

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,
    flow: Optional[Any] = None,
    route: Optional[list[str]] = None,
    body: Optional[Any] = None,
    content_type: Optional[str] = None,
) -> MockSubscribeHandle

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

flow=registration.flow(impu) subscribes as that UE over its SA, with route (the P-CSCF's protected URI, then the Service-Route) as the SUBSCRIBE's Route headers; siphon sends the refreshes and the unsubscribe over the SA set the registration holds at the time, and refuses a callee's flow with ValueError. With listen.mtu set, each of those requests that is over the threshold (RFC 3261 18.1.1) goes over TCP from the UE's protected client port to the P-CSCF's protected server port, each by its own size, and over UDP when the connection cannot be opened. route cannot be combined with target_uri or a Route in headers.

body (str or bytes) and content_type put a body on the SUBSCRIBE, with a matching Content-Length, and on every refresh of the subscription (TS 24.379 section 9.2.1.4); the unsubscribe carries none. Give both or neither: one without the other raises ValueError. The recorded sends entry holds them as body (bytes) and content_type::

handle = await proxy.subscribe_state.send(
    ruri, event="conference", expires=600,
    body=mcptt_info_xml,
    content_type="application/vnd.3gpp.mcptt-info+xml",
    flow=flow, route=route,
)

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

In siphon the subscription exists from the moment the SUBSCRIBE is sent. The notifier's first NOTIFY may arrive before the 2xx (RFC 6665 section 4.1.2.4); it then establishes the dialog (section 4.4.1), and :meth:find in the NOTIFY handler returns the handle this call returns once the 2xx is in::

@proxy.on_request("NOTIFY")
def notify(request):
    handle = proxy.subscribe_state.find(
        request.call_id, request.to_tag, request.from_tag
    )
    if handle is None:
        request.reply(481, "Subscription Does Not Exist")
        return
    request.reply(200, "OK")

A subscription a NOTIFY established stands when the SUBSCRIBE gets no final response within timeout_ms: its handle is returned then too (section 4.1.2).

One call tracks one dialog: the first NOTIFY's, or the 2xx's when no NOTIFY came before it. siphon itself answers 481 to a NOTIFY for the same SUBSCRIBE from another notifier tag, without running the handler, and a 2xx from another fork does not change the dialog (section 5.4.9).

What this call returns depends on which messages arrived, never on their order. A NOTIFY with Subscription-State: terminated is reported to the NOTIFY handler and nowhere else, so a one-shot fetch (expires=0) returns its handle whether that NOTIFY came before the 2xx or after. The subscription is gone by then or soon after, and the handle raises LookupError once it is: take what you need from the NOTIFY in its handler.

Raises RuntimeError on a non-2xx response (no subscription was created, even if a NOTIFY came first, section 4.1.2.1) and on a timeout with no NOTIFY received. Nothing of the attempt is left in either case.

What the subscription then holds is the notifier's to say. Its duration is the shortest the notifier states, in the Expires of the 2xx or the expires of a NOTIFY's Subscription-State, so schedule a refresh from handle.expires and not from the value you asked for. siphon also ends a subscription on its own in two cases: a 2xx that no NOTIFY follows within 32 seconds (Timer N, section 4.1.2.4), and a non-2xx that arrives after this call stopped waiting and returned a subscription a NOTIFY had established.

In the mock the SUBSCRIBE is accepted at once. Deliver the NOTIFYs with harness.send_request("NOTIFY", ...): the harness places them against the subscription the way siphon does.

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.

On a NOTIFY, local_tag is the To tag and remote_tag the From tag. In siphon the first NOTIFY of a :meth:send that is still awaiting its 2xx is found too, so None means the NOTIFY belongs to no subscription and 481 is the answer (RFC 6665 section 4.1.3).

A NOTIFY with Subscription-State: terminated still finds its subscription. siphon removes the subscription once the NOTIFY handlers have returned (section 4.4.1): read what you need from the handle inside the handler, afterwards it raises LookupError.

for_resource

for_resource(
    uri: str, event: str
) -> list[MockSubscribeHandle]

The live subscriptions this node is the notifier of for one resource and event package, oldest first.

uri is compared exactly after the normalisation a stored resource has (name-addr brackets and display name stripped); event is the package token, case-insensitive, any ;id= parameter aside. Terminated and expired subscriptions are left out, and so are subscriptions the script originated with :meth:send.

siphon reads its in-process store, as :meth:find does: the Redis cache is keyed by dialog id and cannot be searched by resource, so a subscription held only there is listed once await proxy.subscribe_state.get(id) has loaded it. The mock has one tier.

Example::

for handle in proxy.subscribe_state.for_resource(
        "sip:room-1@conf.example.com", "conference"):
    await handle.notify(body=xml, content_type="application/conference-info+xml")

SubscribeHandle

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

Its properties read this instance's own dialog state, so none of them can wait on the network — which is what makes them safe to touch from an async def handler, where blocking would stop every other coroutine on that asyncio driver. They are still live reads, not a snapshot: a SUBSCRIBE refresh landing on the store shows up through a handle built before it. Once the dialog is gone (terminated, or reaped on expiry) they raise LookupError rather than answering stale, and await handle.reload() is the explicit re-read through the L2 cache for the cross-replica case.

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)
await handle.notify(body=body, content_type="application/reginfo+xml")

reload

reload() -> bool

Awaitable re-read of the dialog; True when it is still live.

In siphon this goes to the L2 cache, so a replica can recover a dialog another one owns after its own entry was reaped. The mock has one tier, so this is a liveness check::

if not await handle.reload():
    return

terminate

terminate(
    reason: Optional[str] = None,
    body=None,
    content_type: Optional[str] = None,
) -> bool

Terminate the subscription. Recorded on the parent's terminates.

A dialog accepted from a SUBSCRIBE (notifier role) gets its final NOTIFY and is removed at once.

A subscription made with send() (watcher role) is unsubscribed: siphon sends SUBSCRIBE with Expires: 0, and the subscription is over when the notifier's terminating NOTIFY arrives (RFC 6665 section 4.1.2.3). It therefore stays until then, so the NOTIFY handler finds it and answers 200::

await handle.terminate()
# later, in @proxy.on_request("NOTIFY"):
#   proxy.subscribe_state.find(...) still returns the handle, and
#   siphon removes it once the handler has returned.

siphon removes it after 32 seconds (Timer N) when no such NOTIFY comes; in a test, deliver the NOTIFY or call proxy.subscribe_state.expire_unanswered().

refresh

refresh(
    expires: Optional[int] = None,
    timeout_ms: int = 2000,
    body=None,
    content_type: Optional[str] = None,
) -> 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).

The refresh carries the body the subscription was sent with (send(body=, content_type=)); each entry of refreshes records it as body and content_type. body= and content_type=, given together, replace it for this refresh and the ones after it; one without the other raises ValueError::

await handle.refresh(
    body=info_xml, content_type="application/vnd.3gpp.mcptt-info+xml"
)