Reply¶
The Reply object wraps a SIP response. It reaches your script in a
@proxy.on_reply handler (every response on a relayed transaction) and in a
@proxy.on_failure handler (the best error after all branches failed).
from siphon import proxy
@proxy.on_reply
def observe(request, reply):
if reply.status_code == 200 and request.method == "INVITE":
reply.set_header("X-Answered-By", "siphon")
reply.relay()
An @proxy.on_failure handler decides what the caller gets: reply.relay()
forwards the failure, request.relay() / request.fork() tries elsewhere,
and request.reply(code, reason) answers the caller itself instead, with the
headers and body it set through request.set_reply_header() /
request.set_reply_body(), as from @proxy.on_request. That reply must be a
final failure (300-699); anything else is logged and the failure goes on. A
handler that does none of these drops the failure silently.
@proxy.on_failure
def failed(request, reply):
if reply.status_code == 408: # the next hop never answered
request.set_reply_body(BODY, "application/3gpp-ims+xml")
request.reply(504, "Server Time-out")
else:
reply.relay()
@proxy.on_reply("INVITE") / @proxy.on_reply("INVITE|UPDATE") narrows a
handler to responses to those request methods, the same filter shape as
@proxy.on_request. Filtered and unfiltered handlers all run, in registration
order, and a response no handler matches is forwarded unchanged.
@proxy.on_register_reply is shorthand for @proxy.on_reply("REGISTER").
A handler sees a request's responses until its final response has gone to the
caller. One response can still follow that: a 2xx to an INVITE from a branch
the proxy had already given up on (another fork branch answered first, the
request was rejected with reply.reject(), or the caller cancelled). RFC 3261
§16.7 has it forwarded to the caller, who ACKs it and ends that dialog with a
BYE, and siphon forwards it as it is: no @proxy.on_reply handler and no
per-relay on_reply= callback runs for it, and it is not the call's answer in
a CDR or on Rf.
The same holds for a retransmission of a 2xx to an INVITE. A handler sees the
first copy; the callee repeats it until the caller's ACK arrives, and siphon
forwards each repeat by its Via stack with the framework's own changes only
(its Via removed, the Contact fixed under nat.fix_contact). What a handler
changed on the first copy, a header or the SDP, is not on the repeats: they
exist to get an answer through that was lost, and the caller takes its dialog
from whichever copy arrives first.
A callee's provisional in a B2BUA script¶
In @b2bua.on_provisional and @b2bua.on_early_media the Reply is a
provisional response of the callee that SIPhon is about to relay to the caller.
It is relayed whether or not the handler calls reply.relay(), and the handler
may decide what the caller is sent of it:
| Call | The caller is sent |
|---|---|
nothing, or reply.relay() |
the response as it is |
reply.relay(body=False) |
the response without its body |
reply.withhold() |
nothing for this response |
The last call decides. reply.has_body("application/sdp") says whether the
response carries a session description and reply.get_header("P-Early-Media")
returns the callee's early media authorisation (RFC 5009), or None.
Only the caller's side changes. The callee's provisional is PRACKed when it was
reliable, @b2bua.on_early_media still runs with the body in place, and a
session description kept from the caller is the answer the caller has not had,
so the 2xx carries it even when the callee's own 2xx has none (RFC 3261
§13.2.1). Toward a caller that uses 100rel, a provisional relayed without its
body is still sent reliably when the callee's was, on SIPhon's own RSeq, and
a withheld one takes no RSeq (RFC 3262 §3).
On any other reply (@proxy.on_reply, @proxy.on_failure,
@b2bua.on_answer) reply.relay(body=False) and reply.withhold() raise
RuntimeError: nothing there would carry the decision out. The
SBC cookbook has the
recipe these exist for.
A SIP response message.
This object is passed as the second argument to reply handlers:
@proxy.on_reply— all responses, or@proxy.on_reply("INVITE")for responses to one method@proxy.on_failure— aggregated failure response@proxy.on_register_reply— REGISTER responses (same as@proxy.on_reply("REGISTER"))
Example::
@proxy.on_reply
async def reply_route(request, reply):
if reply.status_code == 200 and reply.has_body("application/sdp"):
await rtpengine.answer(reply)
reply.relay()
wire
property
¶
The response exactly as siphon received it, or None.
Compact forms, header order, folding and body untouched, whatever the
script has changed on this object since. Set in @proxy.on_reply
handlers and in a relayed request's on_reply callback; None
anywhere else. In a test it is what Reply(wire=...) was given,
None by default.
generated_locally
property
¶
True when siphon synthesized this response, not a peer.
A Timer B / Timer F expiry, a no_response_ms deadline and a
transport failure each produce a response whose status and reason
match one a next hop could send (408, 503); this tells them
apart. False for everything received. In a fork it describes the
response chosen as the best. Settable in a test through
Reply(generated_locally=True).
source_ip
property
¶
Source IP of the entity that sent this response, or None.
Populated on @proxy.on_reply, @proxy.on_failure per-relay
callbacks, and the B2BUA @b2bua.on_answer / @b2bua.on_early_media
replies (the B-leg peer that answered). None on a fork-aggregated
@proxy.on_failure reply, where the "best" error is selected across
branches and no single source applies. Reply-side counterpart of
request.source_ip.
source_port
property
¶
Source port of the entity that sent this response, or None
(see :attr:source_ip for when it is populated).
provisional_relay
property
¶
What the caller is sent of this provisional (test-only).
"whole" unless a handler decided otherwise, "without_body"
after reply.relay(body=False), "withheld" after
:meth:withhold. The last of those calls decides, as on a node.
from_gateway
¶
Check if this response's source IP is a member of a gateway group.
The reply-side counterpart of request.from_gateway /
call.from_gateway — returns True when the source IP of the entity
that sent this response is one of the resolved addresses of the gateway
group group_name (configured under gateway.groups in
siphon.yaml, or via gateway.add_group). Use it on a response to
decide which trunk actually answered — siphon's answer to Kamailio
ds_is_from_list() / OpenSIPS ds_is_in_list() on the reply path.
The match is on IP only (source port ignored) against every resolved address in the group, so a hostname that round-robins across many IPs matches on any of them.
Infallible: returns False (never raises) when the group does not
exist, no gateway is configured, the response source is unknown (see
:attr:source_ip), or the source IP does not parse.
Security: on connection-oriented transports (TCP/TLS/WS/WSS) the source
IP is handshake-verified and trustworthy as an authorization signal; on
UDP it is spoofable, so from_gateway there is a best-effort
direction hint, not an auth gate.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
group_name
|
str
|
Name of the gateway group to test membership against. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
|
Example::
@b2bua.on_answer
async def on_answer(call, reply):
if reply.from_gateway("carriers"):
log.info("answered by the carrier trunk")
get_header
¶
Get the first value of a header (case-insensitive).
When the header has several values this is the first — use
:meth:get_headers for all of them.
get_headers
¶
Every value of a header, in order — [] when it is absent.
:meth:get_header returns only the first value, which silently
truncates a header the peer spread over several lines. Record-Route,
Via, Route, Contact, Supported, Path and the P-* family are all
routinely multi-value (RFC 3261 §7.3.1), so read those with this.
Values come back one entry per header line. A single line holding several comma-separated URIs stays one entry — split it yourself if you need the individual URIs.
To exercise a multi-line header in a test, pass a list for that name when constructing the mock::
request = Request(headers={"Record-Route": [
"<sip:198.51.100.1:5060;lr>",
"<sip:198.51.100.1:5066;lr>",
]})
assert len(request.get_headers("Record-Route")) == 2
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Header name (case-insensitive). |
required |
Returns:
| Type | Description |
|---|---|
list[str]
|
All values for the header, in order. |
set_header
¶
Set (replace) a header value.
On a B2BUA response in @b2bua.on_answer or
@b2bua.on_early_media the value reaches the caller as written: the
header policy neither strips nor rewrites it, and siphon's own
Supported / Allow do not replace it (replaces is still
merged into Supported). siphon keeps its own Contact, drops the
callee's Record-Route, and decides a provisional's reliability
itself: RSeq and the 100rel tag in Require are siphon's own
toward the caller (RFC 3262), so a value set for either here does not
reach it.
remove_header
¶
Remove a header entirely.
On a B2BUA response in @b2bua.on_answer or
@b2bua.on_early_media the removal stands: neither the header policy
nor siphon's own Supported / Allow put the header back.
has_body
¶
Check if the reply has a body matching the given content type.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
content_type
|
str
|
MIME type (e.g. |
required |
take_av
¶
Extract IMS-AKA CK/IK from auth headers and strip ck=/ik=.
Scans WWW-Authenticate, Proxy-Authenticate and
Authentication-Info (in that order). Returns a
:class:MockAuthVectorHandle only when both ck and ik
parsed cleanly; otherwise leaves the headers untouched and returns
None.
Idempotent: after stripping, a second call returns None because
no header still carries ck/ik.
relay
¶
Forward the reply upstream to the UAC.
In @b2bua.on_provisional and @b2bua.on_early_media the callee's
provisional is relayed to the caller whether or not this is called, and
body=False relays it without its body: the status line and the
headers reach the caller, the session description does not.
That is how a B2BUA keeps a calling network generating ringback. A
callee may answer the offer in a reliable 183 "without any
intention of sending early media (this is the case when preconditions
are used)" (RFC 3960 §3.2), and a caller that takes any session
description in a provisional for early media then plays nothing.
Inside an IMS the P-Early-Media header says which it is (RFC
5009); toward a network that is not shown that header, relay the
session description only when it authorises media toward the caller.
Nothing changes on the callee's dialog. The provisional is PRACKed
when it was reliable, @b2bua.on_early_media still runs with the
body in place so the media engine can anchor the callee's side, and
:attr:body / :meth:has_body keep reading it for the rest of the
handler. The caller has then not had the answer, so the 2xx carries
it, also when the callee's own 2xx has no body (RFC 3261 §13.2.1).
Toward a caller that uses 100rel the provisional is still sent
reliably when the callee's was (RFC 3262 §3).
The last call decides: reply.relay() after
reply.relay(body=False) or :meth:withhold relays the response
whole again.
In a test, :attr:provisional_relay reads the decision back after
SipTestHarness.send_provisional().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
body
|
bool
|
|
True
|
Raises:
| Type | Description |
|---|---|
RuntimeError
|
|
Example::
@proxy.on_reply
def handle_reply(request, reply):
reply.relay()
@b2bua.on_provisional
def on_provisional(call, reply):
early_media = reply.get_header("P-Early-Media") or ""
authorised = "sendrecv" in early_media or "sendonly" in early_media
if reply.has_body("application/sdp") and not authorised:
reply.relay(body=False)
withhold
¶
Relay nothing to the caller for this provisional of the callee.
For @b2bua.on_provisional and @b2bua.on_early_media. The
caller is sent no response for this one; what the callee sends next is
relayed as usual. Nothing changes on the callee's dialog: a reliable
provisional is PRACKed at once, since no PRACK of the caller's will
release it, and @b2bua.on_early_media still runs when it carries a
session description. That session description is the answer the
caller has not had, so the 2xx carries it (RFC 3261 §13.2.1). On the
caller's dialog a withheld provisional takes no RSeq (RFC 3262
§3).
reply.relay() afterwards relays the response after all, and
reply.relay(body=False) relays it without its body.
Raises:
| Type | Description |
|---|---|
RuntimeError
|
on any reply but a provisional in
|
Example::
@b2bua.on_provisional
def on_provisional(call, reply):
if reply.status_code == 181:
reply.withhold()
reject
¶
Reject an in-progress proxied INVITE from @proxy.on_reply.
The proxy-side equivalent of the B2BUA's call.reject() — needed
because media authorization (sbi.create_session /
diameter.rx_aar) runs at answer time, when the negotiated SDP is
available, and a media-authorization failure must reject the leg rather
than proceed medialess.
Behaviour depends on the stage of the response this handler is running for:
- Provisional (1xx) — no final answer yet: records the reject and
returns
True. siphon then sendscode reasonupstream to the UAC and CANCELs the pending downstream branch(es). This is the clean path — typically a reliable183 Session Progressin the VoLTE preconditions / early-media flow, where the SDP answer rides the provisional. - Final (>= 200) — UAS already answered: a proxy cannot retract a
2xx, so this is a no-op and returns
False. Branch on the return value: log the failed authorization and :meth:relayto let the answer through (best-effort, no dedicated bearer).
Takes precedence over :meth:relay / :meth:forward when it returns
True.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
code
|
int
|
SIP final-response code in the 400–699 range (e.g. |
required |
reason
|
Optional[str]
|
optional reason phrase; a sensible default is derived from
|
None
|
Returns:
| Type | Description |
|---|---|
bool
|
|
bool
|
send the error + CANCEL); |
bool
|
UAS already sent a final response). |
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
Example::
@proxy.on_reply
async def on_reply(request, reply):
if request.method == "INVITE" and reply.has_body("application/sdp"):
if not await authorize_media(request, reply):
if reply.reject(503, "Media Authorization Failed"):
return # 503 + CANCEL sent by siphon
log.warn("could not reject answered call; proceeding best-effort")
reply.relay()