Skip to content

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()

status_code property

status_code: int

SIP status code (e.g. 200, 404, 503).

wire property

wire: Optional[bytes]

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.

reason property

reason: str

Reason phrase (e.g. "OK", "Not Found").

generated_locally property

generated_locally: bool

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).

from_uri property

from_uri: Optional[SipUri]

From header URI.

to_uri property

to_uri: Optional[SipUri]

To header URI.

call_id property

call_id: Optional[str]

Call-ID header value.

body property

body: Optional[bytes]

Response body (e.g. SDP), or None.

content_type property

content_type: Optional[str]

Content-Type header value.

source_ip property

source_ip: Optional[str]

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: Optional[int]

Source port of the entity that sent this response, or None (see :attr:source_ip for when it is populated).

actions property

actions: list[Action]

All actions recorded (test-only).

last_action property

last_action: Optional[Action]

Most recent action, or None.

provisional_relay property

provisional_relay: str

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

from_gateway(group_name: str) -> bool

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

True if the response source IP belongs to the group.

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_header(name: str) -> Optional[str]

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.

header

header(name: str) -> Optional[str]

Alias for :meth:get_header.

get_headers

get_headers(name: str) -> list[str]

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_header(name: str, value: str) -> None

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_header(name: str) -> None

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_header

has_header(name: str) -> bool

Check if a header exists (case-insensitive).

has_body

has_body(content_type: str) -> bool

Check if the reply has a body matching the given content type.

Parameters:

Name Type Description Default
content_type str

MIME type (e.g. "application/sdp").

required

take_av

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

relay(body: bool = True) -> None

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

False to relay a callee's provisional without its body.

True

Raises:

Type Description
RuntimeError

body=False on any reply but a provisional in @b2bua.on_provisional / @b2bua.on_early_media.

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)

forward

forward() -> None

Alias for :meth:relay.

withhold

withhold() -> None

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 @b2bua.on_provisional / @b2bua.on_early_media.

Example::

@b2bua.on_provisional
def on_provisional(call, reply):
    if reply.status_code == 181:
        reply.withhold()

reject

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

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 sends code reason upstream to the UAC and CANCELs the pending downstream branch(es). This is the clean path — typically a reliable 183 Session Progress in 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:relay to 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. 503).

required
reason Optional[str]

optional reason phrase; a sensible default is derived from code when omitted.

None

Returns:

Type Description
bool

True if the reject was accepted (provisional stage — siphon will

bool

send the error + CANCEL); False if it could not be applied (the

bool

UAS already sent a final response).

Raises:

Type Description
ValueError

if code is outside 400–699.

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()