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_invitehandler has no filter, every new INVITE is the B2BUA's, as before. - When every
on_invitehandler 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.inboundstill 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
¶
List of requests sent via send_request() (for test assertions).
on_request
¶
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
¶
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
¶
Register a handler for proxy failure (all branches failed).
Handler signature: (request, reply) -> None
on_cancel
staticmethod
¶
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
¶
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
|
None
|
body
|
Optional[Any]
|
Optional body — |
None
|
next_hop
|
Optional[str]
|
Optional next-hop URI override. Outranks a |
None
|
wait_for_response
|
bool
|
When |
False
|
timeout_ms
|
int
|
Response timeout (not meaningfully enforced in the mock). |
2000
|
flow
|
Optional[Any]
|
Send over a flow. A |
None
|
route
|
Optional[list[str]]
|
The route set, one Route header per entry, in order. Not
with a |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
|
set_response_for
¶
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 |
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
¶
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
|
|
In the mock, returns the value of _rate_limit_allow (default True).
sanity_check
¶
Validate request per RFC 3261 (mandatory headers, Max-Forwards, etc.).
Returns:
| Type | Description |
|---|---|
bool
|
|
In the mock, returns _sanity_check_pass (default True).
enum_lookup
async
¶
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. |
required |
suffix
|
str
|
DNS suffix (default |
'e164.arpa.'
|
service
|
str
|
Services field naming the wanted Enumservices,
|
'E2U+sip'
|
Returns:
| Type | Description |
|---|---|
Optional[str]
|
The URI string (e.g. |
Optional[str]
|
|
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.'
|
service
|
str
|
|
'E2U+sip'
|
servers
|
Optional[list[str]]
|
ENUM servers to ask instead of the node's resolver,
|
None
|
timeout_ms
|
Optional[int]
|
per attempt at one server (default 2000); needs
|
None
|
attempts
|
Optional[int]
|
per server (default 2); needs |
None
|
follow_non_terminal
|
bool
|
follow non-terminal records (default True). |
True
|
Raises:
| Type | Description |
|---|---|
ValueError
|
the number holds no digit, |
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
¶
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,reasonas theReasonheader. 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 answeredstatus;@proxy.on_cancelruns 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]
|
|
None
|
toward
|
str
|
|
'both'
|
status
|
int
|
final response for a session still being established, 300-699 (default 487). |
487
|
Returns:
| Type | Description |
|---|---|
Any
|
|
Any
|
Call-ID names no session (nothing is sent; never raises for it). |
Raises:
| Type | Description |
|---|---|
ValueError
|
|
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
¶
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 |
|
|
uris |
every usable URI, in ORDER / PREFERENCE sequence (RFC 6116
section 5.2). Try them in turn; the first is what |
|
records |
every record considered, as dicts with |
|
server |
the server ( |
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 ontooriginatesfor test assertions)b2bua.terminate(call_id)— end a call by SIP Call-ID from any context (records ontoterminatesfor test assertions)b2bua.refer(call_id, target)— transfer a call by SIP Call-ID from any context (records ontorefersfor test assertions)await b2bua.bridge(call_id, with_call_id)— join two answered calls so the two parties hear each other (records ontobridgesfor test assertions)await b2bua.unbridge(call_id)— break a bridge, leaving both legs answered, owned and held (records ontounbridgesfor test assertions)
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 asapplication/sdp(any backend);body=…, content_type=…— the same slot with the type spelled out, for an INVITE whose offer travels as one part of amultipart/*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), sortpengine.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]
|
|
None
|
privacy
|
Optional[str]
|
|
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 |
None
|
media
|
bool
|
True to have siphon anchor the leg on the media backend. |
False
|
profile
|
Optional[str]
|
media profile for |
None
|
ws_uri
|
Optional[str]
|
per-call WebSocket bridge URI for |
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, |
None
|
content_type
|
Optional[str]
|
Content-Type for |
None
|
session_timer
|
Optional[dict]
|
the RFC 4028 session timer to run on the call, over
the |
None
|
route
|
Optional[list]
|
a pre-loaded route set (RFC 3261 §8.1.2), e.g.
|
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 |
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
¶
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'
|
Returns:
| Name | Type | Description |
|---|---|---|
bool |
bool
|
True once the bridge has been accepted and put in motion. |
Raises:
| Type | Description |
|---|---|
ValueError
|
an |
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
¶
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 |
'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: |
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: |
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: |
None
|
format
|
Optional[str]
|
the inline form of the same thing, as on
|
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 |
|
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 ( |
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
¶
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
¶
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 |
required |
conference_id
|
str
|
the room to seat the far party in. |
required |
role
|
str
|
|
'talker'
|
on_owner_leave
|
str
|
|
'end'
|
on_last_leave
|
str
|
|
'keep'
|
dtmf
|
Optional[str]
|
|
None
|
Returns:
| Name | Type | Description |
|---|---|---|
int |
int
|
the status for |
int
|
confirmed dialog here matches (or no |
|
int
|
dialog is not the referrer's; |
|
int
|
state refused the seat; |
|
int
|
siphon-rtp or the engine lacks |
|
int
|
leaves the named call untouched. |
Raises:
| Type | Description |
|---|---|
ValueError
|
outside |
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
¶
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
¶
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
¶
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
¶
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
( |
required |
code
|
int
|
the final status to report, 200-699. |
required |
reason
|
Optional[str]
|
the reason phrase; the usual one for |
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
|
|
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'
|
with_body
|
bool
|
resolve to |
False
|
Returns:
| Type | Description |
|---|---|
Optional[Union[int, tuple]]
|
Optional[Union[int, tuple]]: the final status code (or the
|
Raises:
| Type | Description |
|---|---|
ValueError
|
the call is unknown ( |
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
¶
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, |
Raises:
| Type | Description |
|---|---|
ValueError
|
the call is unknown ( |
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
¶
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 |
'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
¶
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
|
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 |
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
¶
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
¶
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
¶
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
¶
Register handler for call answered (200 OK on B-leg).
Handler signature: (call, reply) -> None
on_failure
staticmethod
¶
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 getscode 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 appcall.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
¶
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
¶
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
¶
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
¶
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).
changeis"joined"or"left".call_idis the SIP Call-ID of the seat that changed.owner_call_idis 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, elseNone.rosteris the room after the change, the dicts ofb2bua.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
¶
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
¶
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
¶
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
¶
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.expiresis the shortest the notifier has stated, in theExpiresof the 2xx (§4.1.2.1) or theexpiresof a NOTIFY's Subscription-State (§4.1.2.2). Onlyhandle.refresh()lengthens it. Schedule the refresh fromhandle.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()orhandle.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
¶
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 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
¶
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
¶
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
¶
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
¶
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
¶
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 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"
)