# caprid_relay

> Python API reference for caprid_relay: Connect, publish, subscribe, and supervise connections.

URL: https://www.caprid.com/docs/reference/python/caprid_relay/

From the `caprid-relay` package: `import caprid_relay`

Relay Python SDK.

Owns the Relay product API and authentication convention, built on `moq_core`. The default
transport is native QUIC through aioquic (`caprid_relay.quic`), which reaches a relay natively with no
browser; `create_transport` swaps in another implementation of the same seam.

The public names are the rows of `spec/vocabulary.md`, pinned by `tests/test_exports.py`. From
the core this package re-exports exactly what `@caprid/relay` re-exports, plus `Closed`; every
other protocol type (location filters, fetch, track status, the constants) is imported from
`moq_core`, a declared dependency.

## Functions

### connect_relay_publisher

```python
async def connect_relay_publisher(
    url: str | None = None,
    *,
    namespace: Sequence[str],
    get_token: TokenProvider | None = None,
    cancellation: CancellationToken | None = None,
    connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
    create_transport: RelayTransportFactory | None = None,
) -> RelayPublisher
```

Open a dedicated Relay publisher connection.

Subscribers use [`connect_relay_subscriber`](#connect_relay_subscriber); publishing does not share their transport. One
publisher connection serves one `namespace` and any number of tracks. A relay must solicit the
namespace and subscribe upstream before data can flow. With no `url`, the connection dials
`DEFAULT_ENDPOINT` rooted at the token's `root` claim, which `get_token` must then supply.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/publish.py#L320)</small>

### connect_relay_subscriber

```python
async def connect_relay_subscriber(
    url: str | None = None,
    *,
    get_token: TokenProvider | None = None,
    cancellation: CancellationToken | None = None,
    connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
    request_timeout: float = DEFAULT_REQUEST_TIMEOUT,
    create_transport: RelayTransportFactory | None = None,
) -> RelaySubscriber
```

Open a Relay connection for subscribing to tracks.

One deadline (`connect_timeout`) covers token resolution and both handshakes. `cancellation`
controls the connection's entire lifetime. An explicit `jwt` query parameter on `url` takes
precedence over `get_token`, matching the Relay authentication convention; an empty one is a
`ValueError`. With no `url`, the connection dials `DEFAULT_ENDPOINT` rooted at the token's
`root` claim, which `get_token` must then supply. Publishing uses a separate connection; see
[`connect_relay_publisher`](#connect_relay_publisher).

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/connect.py#L415)</small>

### connect_supervised_publisher

```python
async def connect_supervised_publisher(
    url: str | None = None,
    *,
    namespace: Sequence[str],
    get_token: TokenProvider | None = None,
    cancellation: CancellationToken | None = None,
    connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
    create_transport: RelayTransportFactory | None = None,
    reconnect: SupervisionBackoff | None = None,
    rotate_before: float | None = None,
    on_generation: Callable[[PublisherGeneration], None] | None = None,
    on_status: Callable[[SupervisedStatus, BaseException | None], None] | None = None,
) -> SupervisedPublisher
```

Open a publisher that reconnects: each connection serves `namespace` plus a fresh generation
field, and the writers `SupervisedPublisher.register_track` returns outlive the connections.

Returns once the first generation is up. A first attempt that fails on its arguments, or because
the relay refused the credentials (`UNAUTHORIZED`), raises that failure; any other failure is
retried with backoff (`reconnect`, from 0.5 s doubling up to 10 s), bounded only by
`cancellation`, whose reason is raised. The token must grant `namespace`, the root, since every
generation publishes one field below it. `on_generation` runs once each generation is up and
every track is bound to it; `on_status` on every status change, with what ended the previous
connection or failed the last attempt. `rotate_before` (seconds, default 60) is how long before
the connection's credential expires (`RelayPublisher.expires_at`) the next generation is
brought up to replace it, make-before-break; never earlier than halfway through the time left.
With no `url`, every generation dials the endpoint the first one rooted at its token's root. See
`spec/behavior.md`, "Supervised connections"; [`connect_relay_publisher`](#connect_relay_publisher) never reconnects.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/supervised_publisher.py#L684)</small>

### connect_supervised_subscriber

```python
async def connect_supervised_subscriber(
    url: str | None = None,
    *,
    get_token: TokenProvider | None = None,
    cancellation: CancellationToken | None = None,
    connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
    request_timeout: float = DEFAULT_REQUEST_TIMEOUT,
    create_transport: RelayTransportFactory | None = None,
    reconnect: SupervisionBackoff | None = None,
    rotate_before: float | None = None,
    on_status: Callable[[SupervisedStatus, BaseException | None], None] | None = None,
) -> SupervisedSubscriber
```

Open a subscriber that reconnects and keeps every wanted track subscribed across connections,
publisher restarts and generations.

Returns once the first connection is up. A first attempt that fails on its arguments, or because
the relay refused the credentials (`UNAUTHORIZED`), raises that failure; any other failure is
retried with backoff (`reconnect`, from 0.5 s doubling up to 10 s), bounded only by
`cancellation`, whose reason is raised. `on_status` runs on every status change, with what
ended the previous connection or failed the last attempt. `rotate_before` (seconds, default 60)
is how long before the connection's credential expires (`RelaySubscriber.expires_at`) a
replacement connection is opened, make-before-break; never earlier than halfway through the time
left. With no `url`, every connection dials the endpoint the first one rooted at its token's
root. See `spec/behavior.md`, "Supervised connections"; [`connect_relay_subscriber`](#connect_relay_subscriber) never
reconnects or resubscribes.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/supervised_subscriber.py#L430)</small>

### read_json

```python
def read_json(obj: MoqtObject) -> Any
```

Parse one UTF-8 JSON payload without schema validation; invalid input raises ValueError.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/payload.py#L28)</small>

### read_text

```python
def read_text(obj: MoqtObject) -> str
```

Decode one complete object as UTF-8; malformed bytes raise UnicodeDecodeError.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/payload.py#L19)</small>

## Classes

### CancellationToken

```python
class CancellationToken
```

Cancel every connection that was handed this token, whether still connecting or established.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/cancellation.py#L17)</small>

**Attributes**

- **`cancelled`**: `bool` *(property)* — Whether `cancel` has been called.
- **`reason`**: `BaseException | None` *(property)* — The exception recorded by `cancel`; `None` until then.

#### CancellationToken.cancel

```python
def cancel(self, reason: BaseException | None = None) -> None
```

Cancel once. Later calls are ignored. Registered callbacks run synchronously, in order.

#### CancellationToken.throw_if_cancelled

```python
def throw_if_cancelled(self) -> None
```

Raise the recorded `reason` if the token has been cancelled; return otherwise.

#### CancellationToken.add_callback

```python
def add_callback(self, callback: Callable[[BaseException], None]) -> None
```

Run `callback` with the reason when the token is cancelled. No-op once already cancelled.

#### CancellationToken.remove_callback

```python
def remove_callback(self, callback: Callable[[BaseException], None]) -> None
```

Unregister a callback added with `add_callback`. No-op if it is not registered.

### Closed

```python
class Closed
```

How a session or connection ended. `error` is `None` for a clean close.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/moq-core/src/moq_core/session.py#L90)</small>

**Attributes**

- **`error`**: `BaseException | None`

### ConnectingTransport

```python
class ConnectingTransport
```

What a transport factory hands back: the transport, an awaitable for its handshake, and its framing.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/connect_session.py#L186)</small>

**Attributes**

- **`transport`**: `MoqtTransport`
- **`ready`**: `Awaitable[None]`
- **`framing`**: `Framing` — `"native-quic"` makes the connection send the endpoint's PATH and AUTHORITY in SETUP (TA-1, TA-6).

### Goaway

```python
class Goaway
```

GOAWAY (§9.2): the peer intends to close the session soon and asks for no new requests.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/moq-core/src/moq_core/control_messages.py#L1086)</small>

**Attributes**

- **`kind`**: `Literal['goaway']`
- **`new_session_uri`**: `str` — Where to reconnect; empty means the current URI. A client always sends it empty.
- **`timeout`**: `int` — Milliseconds until the peer closes the session; `0` when it gave none.

### GoawayEvent

```python
class GoawayEvent
```

Request relocation details; no automatic retry.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/_subscription.py#L100)</small>

**Attributes**

- **`value`**: `Goaway`
- **`type`**: `Literal['goaway']`

### KeyValuePair

```python
class KeyValuePair
```

A generic MOQT Key-Value-Pair.

Even types carry a varint value, odd types a byte field. Used for Setup Options and Track/Object
Properties (message parameters use per-type encodings instead; see [`MessageParameters`](#messageparameters)).

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/moq-core/src/moq_core/control_messages.py#L374)</small>

**Attributes**

- **`type`**: `int`
- **`value`**: `int | bytes`

### MessageParameters

```python
class MessageParameters
```

Decoded message parameters.

Unlike Setup Options, message parameters use per-type encodings (uint8 / varint /
length-prefixed), so the codec is registry-driven. Per §9.20 an unknown parameter type is a
PROTOCOL_VIOLATION (parameters cannot be skipped; the block is bounded by count, not length).

`fill_parameters` (§9.20.15) asks the publisher to deliver a fill range on a fetch stream
alongside the subscription; its presence alone requests the fill. It is a nested parameter scope:
only the fill-scoped subset (Table 7) is legal inside, and omitted entries inherit the
subscription's own values.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/moq-core/src/moq_core/control_messages.py#L680)</small>

**Attributes**

- **`object_delivery_timeout`**: `int | None`
- **`subgroup_delivery_timeout`**: `int | None`
- **`rendezvous_timeout`**: `int | None`
- **`fill_timeout`**: `int | None`
- **`authorization_tokens`**: `list[bytes] | None` — Serialized Token structures (§9.20.2). May repeat.
- **`expires`**: `int | None`
- **`largest_object`**: `Location | None`
- **`forward`**: `int | None` — `0` or `1`.
- **`subscriber_priority`**: `int | None`
- **`location_filter`**: `LocationFilter | None`
- **`group_order`**: `GroupOrder | None`
- **`fill_parameters`**: `MessageParameters | None`
- **`new_group_request`**: `int | None`
- **`range_filters`**: `list[RangeFilter] | None`
- **`track_namespace_prefix`**: `TrackNamespace | None`
- **`include_properties`**: `int | None` — INCLUDE_PROPERTIES (§9.20.21): `0` asks the responder to send empty Track Properties. Default `1`.
- **`max_age`**: `int | None` — MAX_AGE (0x915C6, the MoQ Timestamp extension; not a draft-22 parameter).

  The subscriber's max age in milliseconds, 0 to 2^53-1. A publisher that honors it skips, for this subscription
  only, a group once a newer group supersedes it and either that successor arrived at the publisher's hop at least
  this long ago or the group's media time trails the live edge by at least this much; the newest group never
  expires. moq-dev main turns it into the subscription's `max_delay`. A session sends it on SUBSCRIBE and
  REQUEST_UPDATE only when the peer's SETUP declared `SETUP_OPTION.MAX_AGE`, and never on FETCH, TRACK_STATUS or
  SUBSCRIBE_NAMESPACE; see `MoqtSession.max_age_supported`.


### MoqtObject

```python
class MoqtObject
```

An object delivered on a subscription's subgroup stream.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/moq-core/src/moq_core/object_stream.py#L84)</small>

**Attributes**

- **`group_id`**: `int`
- **`object_id`**: `int`
- **`subgroup_id`**: `int`
- **`priority`**: `int | None` — Publisher priority; `None` inherits the subscription's priority.
- **`status`**: `ObjectStatus`
- **`properties`**: `list[KeyValuePair]`
- **`payload`**: `bytes`

### NamespaceEvent

```python
class NamespaceEvent
```

One change in which namespaces under the prefix are announced.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/namespace_events.py#L32)</small>

**Attributes**

- **`type`**: `Literal['announced', 'unannounced']` — `announced`: a publisher announced the namespace, or it was already announced when the request was accepted. `unannounced`: its announce was withdrawn. Each namespace alternates between the two; the relay's repeats are not passed on.
- **`namespace`**: `tuple[str, ...]` — The full namespace: the prefix's fields followed by the rest, without splitting or joining.

### NamespaceEvents

```python
class NamespaceEvents(BoundedEvents[NamespaceEvent])
```

Single-consumer iterator owning one namespace request, never the shared connection.

Create one with `caprid_relay.RelaySubscriber.namespace_events` and use `async with` around
`async for`. At most `capacity` events are retained; overflow discards the queue, cancels the
request and raises [`SubscriptionOverflowError`](#subscriptionoverflowerror) on reads. A relay rejection raises
[`SubscriptionRequestError`](#subscriptionrequesterror), the connection ending [`SubscriptionConnectionClosedError`](#subscriptionconnectionclosederror);
a relay that finishes the request, `close()`, or `break` ends iteration cleanly.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/namespace_events.py#L195)</small>

### ObjectEvent

```python
class ObjectEvent
```

An object, with its payload and protocol metadata.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/_subscription.py#L60)</small>

**Attributes**

- **`value`**: `MoqtObject`
- **`type`**: `Literal['object']`

### PublishDone

```python
class PublishDone
```

PUBLISH_DONE (§9.9): the publisher finished a subscription.

`status_code` is one of the PUBLISH_DONE status codes; `stream_count` is how many data streams
it opened for the subscription, so a subscriber can tell when all of them have arrived.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/moq-core/src/moq_core/control_messages.py#L1191)</small>

**Attributes**

- **`kind`**: `Literal['publish-done']`
- **`status_code`**: `int`
- **`stream_count`**: `int | None` — `None` when the publisher could not count its streams (the 2^64-1 sentinel, §9.9).
- **`reason`**: `str`

### PublisherDoneEvent

```python
class PublisherDoneEvent
```

Publisher completion; trailing objects may still arrive.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/_subscription.py#L68)</small>

**Attributes**

- **`value`**: `PublishDone`
- **`type`**: `Literal['publisher_done']`

### PublisherGeneration

```python
class PublisherGeneration
```

One publisher generation: a connection and the namespace it serves.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/supervised_publisher.py#L49)</small>

**Attributes**

- **`generation`**: `str` — The generation field: start time in fixed-width base 36, then four random characters, so it sorts by age.
- **`namespace`**: `tuple[str, ...]` — The namespace this generation serves: the root followed by `generation`.

### RelayAwaitable

```python
class RelayAwaitable(Generic[_T])
```

A fact the SDK settles once: a subscription's `subscribed` or `ended`, a publisher's
`announced`, or a connection's `closed`.

Await it as often as needed. Each waiter is isolated: a task cancelled while awaiting, or an
`asyncio.wait_for` that times out, never touches the underlying result, where handing out the
future itself would let one impatient waiter destroy the signal for every later one. `done()`,
`result()`, `exception()` and `add_done_callback()` read the result directly and allocate
nothing, so polling is free. It is not an `asyncio.Future`: it cannot be cancelled or
set. Wrap it with `asyncio.ensure_future` for an API that needs a future.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/_awaitable.py#L13)</small>

#### RelayAwaitable.done

```python
def done(self) -> bool
```

Whether the result is in.

#### RelayAwaitable.result

```python
def result(self) -> _T
```

The result; raises what awaiting would, or `asyncio.InvalidStateError` while pending.

#### RelayAwaitable.exception

```python
def exception(self) -> BaseException | None
```

The exception awaiting would raise, `None` for a plain result.

#### RelayAwaitable.add_done_callback

```python
def add_done_callback(
    self,
    callback: Callable[[asyncio.Future[_T]], object],
    *,
    context: contextvars.Context | None = None,
) -> None
```

Call `callback` with the settled future once the result is in.

#### RelayAwaitable.remove_done_callback

```python
def remove_done_callback(self, callback: Callable[[asyncio.Future[_T]], object]) -> int
```

Forget a callback; returns how many registrations were removed.

### RelayCredential

```python
class RelayCredential
```

A token together with when the relay stops honoring it.

moq-relay ends a session at its grant's deadline, which is the token's expiry, and a live session
cannot take a new token; supervised connections replace theirs before then.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/connect_session.py#L33)</small>

**Attributes**

- **`token`**: `str` — The token, carried in the endpoint's `jwt` query parameter. An empty one sends no token.
- **`expires_at`**: `datetime` — When the relay ends a session the token admitted: the JWT's `exp`, or the grant's deadline if earlier.

  Must be timezone-aware.


### RelayGroupWriter

```python
class RelayGroupWriter
```

One open multi-object group on a track. Create one with `RelayTrackWriter.open_group`.

Objects are appended in order with increasing object IDs starting at 0, so the first object is
the group's random-access point in LOC/MSF terms. `close()` FINs the stream, which is how a
complete group is announced; `abort()` resets it unless it already closed cleanly. A group that
fails part-way is reset, and the track stays open for the next group.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/_write_slot.py#L459)</small>

**Attributes**

- **`group_id`**
- **`closed`**: `bool` *(property)* — True once the group was FINed or reset; no further objects can be written.
- **`object_count`**: `int` *(property)* — Objects accepted by the transport so far.

#### RelayGroupWriter.write_text

```python
async def write_text(
    self,
    text: str,
    *,
    properties: Sequence[KeyValuePair] | None = None,
    status: ObjectStatus = 'normal',
) -> None
```

Encode UTF-8 text and write one object with the same semantics as `write`.

#### RelayGroupWriter.write_json

```python
async def write_json(
    self,
    value: object,
    *,
    properties: Sequence[KeyValuePair] | None = None,
    status: ObjectStatus = 'normal',
) -> None
```

Serialize JSON and write one object; serialization errors leave the writer usable.

#### RelayGroupWriter.write

```python
async def write(
    self,
    payload: bytes,
    *,
    properties: Sequence[KeyValuePair] | None = None,
    status: ObjectStatus = 'normal',
) -> None
```

Append one object. Returns when the transport accepts it. Bytes are copied.

Properties are encoded as supplied; the relay may transform them before delivery.
Each call has its own `write_timeout` deadline; expiry resets the group and leaves the
track open. `status` is only meaningful for an empty payload (an end-of-group or end-of-track marker).
On the group's first object, a 0x02 or 0x06 property sets the group's delivery timeout
instead of the track's; on a later object it is only sent. An object older than the
group's OBJECT_DELIVERY_TIMEOUT when it reaches the transport raises `DELIVERY_TIMEOUT`
and resets the group.

#### RelayGroupWriter.close

```python
async def close(self) -> None
```

FIN within `write_timeout`; expiry resets the group. Idempotent once closed cleanly.

#### RelayGroupWriter.abort

```python
def abort(self, reason: BaseException | None = None) -> None
```

Reset the group's stream; objects already sent may be incomplete at the peer.

After a clean `close()` this has no effect: the FIN already ended the stream, and a reset
after complete delivery is ignorable (draft-ietf-moq-transport-22 section 11.3.2).

### RelayPublisher

```python
class RelayPublisher
```

A connection that can only publish, serving one namespace. Create one with [`connect_relay_publisher`](#connect_relay_publisher).

Subscribing uses a separate connection; see [`connect_relay_subscriber`](#connect_relay_subscriber).

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/publish.py#L60)</small>

**Attributes**

- **`expires_at`**: `datetime | None` *(property)* — When the relay will end this connection for its credential's expiry, as an aware UTC datetime.

  The [`RelayCredential`](#relaycredential)'s `expires_at`, or the JWT's `exp` claim, read without
  verifying it; `None` when neither is known. The relay closes the session then with
  `UNAUTHORIZED` in phase `connection`; open a replacement before it. Supervised connections do.

- **`namespace`**: `list[str]` *(property)* — The namespace every track on this connection is published under.
- **`announced`**: `RelayAwaitable[None]` *(property)* — Settles once the namespace announce has reached the wire on a solicitation that covers it.

  That is no earlier than the first `register_track`, and it is the strongest fact the
  publisher has: the relay acknowledges an announce with nothing and routes subscribes for the
  namespace about a round trip later, so a subscribe that races it is rejected (request error
  16 `unroutable` on moq-relay 0.15.8 and later; see `spec/behavior.md`). It stays pending while no
  solicitation covers the namespace, where the first write reports `NAMESPACE_NOT_SOLICITED`,
  and the connection's closure terminates it if that comes first: it raises the closure's
  error, or a `CLOSED` [`RelayError`](#relayerror) after a clean shutdown. Every waiter is isolated, as for `closed`.

- **`closed`**: `RelayAwaitable[Closed]` *(property)* — Resolves when the connection shuts down. Errors are returned as data; this never raises.

  Every waiter is isolated: a timed-out `asyncio.wait_for` or a cancelled task leaves the
  result intact for every other waiter.


#### RelayPublisher.register_track

```python
def register_track(
    self,
    track: str,
    *,
    track_properties: Sequence[KeyValuePair] | None = None,
    write_timeout: float = DEFAULT_WRITE_TIMEOUT,
) -> RelayTrackWriter
```

Register a track this publisher will serve and return its writer.

Nothing is sent: the relay learns of the track when a subscriber asks for it, and bytes move
when the writer's `write()` or `open_group()` runs, after waiting for that relay demand.
No capture starts and no media catalog is published. Track names are unique per publisher
connection, including after their writer closes.
`track_properties` is encoded as supplied; the SDK adds no default timescale. It rides in
every SUBSCRIBE_OK (and TRACK_STATUS_OK), so it must encode to at most 65525 bytes, hold at most 64 pairs (the
relay's limit) and keep 0x0E at most 255 and 0x22 at 1 or 2 (draft-22); otherwise the relay
would refuse every subscriber, so `register_track` raises `ValueError` instead.
The relay may add, drop, or rewrite properties before delivery to subscribers; see README.
Properties 0x02 (OBJECT_DELIVERY_TIMEOUT) and 0x06 (SUBGROUP_DELIVERY_TIMEOUT), in
milliseconds, are also the publisher's delivery timeouts (draft-ietf-moq-transport-22
section 5.2), which the writer enforces; 0 means none, and values above 2147483647 are clamped.
`write_timeout` (seconds) bounds relay demand and the transport write together.

#### RelayPublisher.close

```python
async def close(self) -> None
```

End all tracks, retract the namespace, and wait for transport shutdown. Idempotent.

Before the session ends it waits, for up to the largest `write_timeout` among the tracks,
for the relay to acknowledge the groups that completed and each track's end; any still
unacknowledged after that may be lost, and the close still succeeds. Cancelling the caller
does not stop the shutdown.

#### RelayPublisher.__aenter__

```python
async def __aenter__(self) -> Self
```

### RelaySubscriber

```python
class RelaySubscriber
```

A connection that can only subscribe. Create one with [`connect_relay_subscriber`](#connect_relay_subscriber).

Publishing uses a separate connection and token; see [`connect_relay_publisher`](#connect_relay_publisher).

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/connect.py#L217)</small>

**Attributes**

- **`closed`**: `RelayAwaitable[Closed]` *(property)* — Resolves once for local shutdown, peer closure, or failure. It never raises.

  Every waiter is isolated: a timed-out `asyncio.wait_for` or a cancelled task leaves the
  result intact for every other waiter.

- **`expires_at`**: `datetime | None` *(property)* — When the relay will end this connection for its credential's expiry, as an aware UTC datetime.

  The [`RelayCredential`](#relaycredential)'s `expires_at`, or the JWT's `exp` claim, read without
  verifying it; `None` when neither is known. The relay closes the session then with
  `UNAUTHORIZED` in phase `connection`; open a replacement before it. Supervised connections do.

- **`max_age_supported`**: `bool` *(property)* — Whether the relay's SETUP declared subscriber max age (the MoQ Timestamp extension's MAX_AGE, 0x915C4).

  When it did, a `parameters.max_age` on `subscribe`, `subscribe_switchable`, `subscribe_events`,
  `update()` or `track_source.subscribe` reaches it. While `False` the SDK leaves `max_age` off every
  request, as the extension requires. moq-dev main declares it; moq-relay 0.17.x does not. Fixed for the
  connection's life.

- **`track_source`**: `MoqTrackSource` *(property)* — This connection seen through the generic `MoqTrackSource` contract, for integrations such as players.

  `subscribe`, `fetch`, and `track_status` in protocol terms, plus `closed`. It lists no
  tracks and cannot close the connection; the application that opened the connection owns shutdown.


#### RelaySubscriber.subscribe

```python
def subscribe(
    self,
    namespace: Sequence[str],
    track: str,
    handlers: RelaySubscriptionHandlers | None = None,
    *,
    parameters: MessageParameters | None = None,
) -> RelaySubscription
```

Ask the relay for a track's objects, in any payload format, without discovering or loading a catalog.

Returns at once with a cancellable handle whose `subscribed` settles when the relay answers
and whose `ended` settles once the subscription can no longer deliver;
`handlers.on_subscribed` and `handlers.on_error` report the same answer as callbacks.
`namespace` fields are sent verbatim, without splitting or joining on slashes.
`parameters` carries protocol subscription controls such as the location filter and priority.

#### RelaySubscriber.subscribe_switchable

```python
def subscribe_switchable(
    self,
    namespace: Sequence[str],
    track: str,
    handlers: SwitchableHandlers | None = None,
    *,
    parameters: MessageParameters | None = None,
) -> SwitchableSubscription
```

Subscribe to a track through a handle that can move to another track in the same namespace make-before-break.

`SwitchableSubscription.switch_to` opens the new subscription while this one keeps
delivering, and cuts over once the new one's object passes `until`. Use it to change layers
(simulcast, ABR), or to change a subscription's filter or priority, which `update()` cannot
do against moq-relay. Handlers see only the active subscription, with its track name.

#### RelaySubscriber.subscribe_events

```python
def subscribe_events(
    self,
    namespace: Sequence[str],
    track: str,
    *,
    parameters: MessageParameters | None = None,
    capacity: int = 64,
    cancellation: CancellationToken | None = None,
) -> SubscriptionEvents
```

Create a bounded event iterator. Use `async with` around `async for`.

Owns only this request. `publisher_done` permits trailing objects; it is not EOF.

#### RelaySubscriber.namespace_events

```python
def namespace_events(
    self,
    prefix: Sequence[str],
    *,
    capacity: int = 64,
    cancellation: CancellationToken | None = None,
) -> NamespaceEvents
```

Discover which namespaces under `prefix` are announced, as a bounded event iterator.

Same ownership rules as `subscribe_events`: the iterator owns one SUBSCRIBE_NAMESPACE
request, and `close()`, leaving `async with` or `cancellation` cancels it. It first yields
`announced` for each namespace already announced, then one event per announce and
withdrawal; the relay sends no marker between the two. A relay rejection ends it with
[`SubscriptionRequestError`](#subscriptionrequesterror), the connection ending with
[`SubscriptionConnectionClosedError`](#subscriptionconnectionclosederror), a full queue with
[`SubscriptionOverflowError`](#subscriptionoverflowerror); a relay that finishes the request ends it cleanly, after
the queued events and an `unannounced` for each namespace still announced. See
spec/behavior.md, "Discovery", for the timing. `prefix` follows the namespace rules; one that
equals, contains or falls under a prefix this connection is already discovering raises a
`RelayError` with code `PREFIX_OVERLAP` before anything is sent.

#### RelaySubscriber.close

```python
def close(self) -> None
```

Close this connection and all requests on it. Idempotent.

#### RelaySubscriber.__aenter__

```python
async def __aenter__(self) -> Self
```

### RelaySubscription

```python
class RelaySubscription(Subscription)
```

The handle `caprid_relay.RelaySubscriber.subscribe` returns.

The core handle (`request_id`, `update()`, `cancel()`) plus two awaitables. `subscribed`
settles when the relay answers: it resolves with the acceptance info `on_subscribed` receives
and raises [`SubscriptionRequestError`](#subscriptionrequesterror) when the relay rejected the request or it timed out,
[`SubscriptionConnectionClosedError`](#subscriptionconnectionclosederror) when the connection ended first, or the reason given to
`cancel()` (a `CLOSED` [`RelayError`](#relayerror) when none was given). `ended` settles once,
when the subscription can no longer deliver, with a [`SubscriptionEnd`](#subscriptionend); it never raises.
Publisher completion settles neither, and neither implies exactly-once or gap-free delivery.

It subclasses the core handle so `subscribe()` keeps returning a `Subscription`; every
inherited member is overridden and the core's own slots are never set.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/_subscription.py#L260)</small>

**Attributes**

- **`subscribed`**: `RelayAwaitable[SubscribeOk]` *(property)* — Settles when the relay answers. See the class docstring for what it raises.

  Every waiter is isolated: cancelling one, as `asyncio.wait_for` does on timeout, leaves
  the subscription's readiness intact for every other.

- **`ended`**: `RelayAwaitable[SubscriptionEnd]` *(property)* — Settles once, when the subscription can no longer deliver, with how it ended. Never raises.

  Every waiter is isolated, as for `subscribed`.

- **`request_id`**: `int` *(property)* — The Request ID the SUBSCRIBE carried.

  Raises when the subscription ended before its request was sent (the connection had already
  ended): the error it ended with, or a `CLOSED` [`RelayError`](#relayerror).


#### RelaySubscription.update

```python
def update(self, parameters: MessageParameters) -> None
```

Send REQUEST_UPDATE, or queue it in order until the relay accepts the subscription.

#### RelaySubscription.cancel

```python
def cancel(self, reason: object = None) -> None
```

### RelaySubscriptionHandlers

```python
class RelaySubscriptionHandlers
```

Callbacks for one subscription made through `RelaySubscriber.subscribe`.

Each is optional and runs synchronously as the protocol layer delivers the event; the SDK adds
no queue. The names say what happened between this connection and the relay. The borrowed
`RelaySubscriber.track_source` keeps the core's protocol names (`on_ok`, `on_done`).

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/connect.py#L68)</small>

**Attributes**

- **`on_subscribed`**: `Callable[[SubscribeOk], None] | None` — The relay accepted the subscription (SUBSCRIBE_OK) and will forward objects.

  `info` carries the track alias, the relay's parameters (`largest_object` when the track
  already has content), and the track properties as the relay sends them, which can differ from
  what the publisher declared.

- **`on_error`**: `Callable[[RequestError], None] | None` — The relay answered the request with REQUEST_ERROR.

  It rejected the subscription, or the request failed before acceptance (request timeout, or the
  request stream failing). A REQUEST_ERROR can also arrive after acceptance, for example when the
  relay refuses an `update()`, and it ends the subscription then too (issue #94). Connection
  shutdown and GOAWAY never arrive here: a pending subscription ends through `subscribed` and
  `ended`, and an accepted one surfaces them as subgroup resets and connection closure.

- **`on_object`**: `Callable[[MoqtObject], None] | None` — One object arrived. Payloads are uninterpreted bytes. Objects can arrive after `on_publisher_done`.
- **`on_subgroup_end`**: `Callable[[SubgroupEnd], None] | None` — A subgroup stream finished cleanly (FIN).
- **`on_subgroup_reset`**: `Callable[[SubgroupReset], None] | None` — A subgroup stream was reset; objects from it may be missing.

  Called for every reset the relay forwards. The relay may drop a reset for a subgroup it had not
  begun forwarding, so an abandoned group can arrive as nothing at all, only a gap in group IDs.

- **`on_publisher_done`**: `Callable[[PublishDone], None] | None` — The publisher finished with the track (PUBLISH_DONE).

  A control notification, not the end of the subscription: objects still in transit can arrive
  after it, and the request stays open until `cancel()` or connection shutdown. The SDK does not
  resubscribe, and resubscribing cannot bring back a finished track: against moq-relay, a subscribe
  after the publisher has gone is refused (request error 16 `unroutable`), and one while its
  namespace stays announced replays the track's cached last group and another done one round trip
  later. Back off, and do not resubscribe to a track the publisher ended. A done that follows this
  subscription's own `update()` is different: moq-relay ends the subscription on any update, the
  track is still live, and resubscribing is how to apply the change. After a publisher crash the
  done arrives only at the relay's QUIC idle timeout, about 11 s later on moq-relay 0.17.0. See
  spec/relay-interop.md, "Publisher restarts".

- **`on_update_ok`**: `Callable[[], None] | None` — The relay acknowledged an `update()` on this subscription (REQUEST_OK).

  moq-relay never does: it ends the subscription with PUBLISH_DONE on any update (spec/behavior.md,
  relay interop deviation 4).

- **`on_state_notify`**: `Callable[[MessageParameters], None] | None` — The publisher changed the subscription's state on its own (PUBLISH_STATE_NOTIFY).

  The parameters carry only what changed. Informational.

- **`on_goaway`**: `Callable[[Goaway], None] | None` — GOAWAY on this request stream: the relay wants this request re-issued, possibly elsewhere.

  A GOAWAY on the connection itself ends the connection instead; observe `closed` for that.


### RelayTrackWriter

```python
class RelayTrackWriter
```

One registered track on a publisher connection. Create one with `RelayPublisher.register_track`.

Registration sends nothing; `write` and `open_group` send, after waiting for relay demand.
The behaviour behind this class is the track's write slot (see `CONTEXT.md` and the "Write
phases and interruptions" table in `spec/behavior.md`).

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/_write_slot.py#L586)</small>

**Attributes**

- **`name`**
- **`has_demand`**: `bool` *(property)* — Whether the relay currently has an upstream subscription for this track, so a write would proceed at once.

  Demand is the relay's own subscription, shared by every downstream viewer and ended when
  the relay chooses: it is neither a viewer count nor a liveness signal.
  Always `False` once the track has ended, whatever the relay asks for; read `closed` to
  tell a track that ended apart from one the relay has not subscribed to.

- **`closed`**: `bool` *(property)* — True once the application closed the track or the publisher connection ended.

  Individual write failures reset their stream and leave the track open.


#### RelayTrackWriter.demand_changes

```python
def demand_changes(self) -> AsyncIterator[bool]
```

Iterate relay demand: the current state first, then one value per relay subscription change.

Yields `True` when the relay subscribes or replaces its subscription (so `True` can
repeat) and `False` when its subscription ends, and stops once the track ends, however it
ended. This is how a catalog re-sends its document on every relay subscription. Values are
per relay subscription, never per downstream viewer.

#### RelayTrackWriter.close

```python
def close(self) -> None
```

End the track and cancel an outstanding write or group. The publisher connection stays open.

#### RelayTrackWriter.write_text

```python
async def write_text(
    self,
    text: str,
    *,
    properties: Sequence[KeyValuePair] | None = None,
) -> None
```

Encode UTF-8 text and write one object with the same semantics as `write`.

#### RelayTrackWriter.write_json

```python
async def write_json(
    self,
    value: object,
    *,
    properties: Sequence[KeyValuePair] | None = None,
) -> None
```

Serialize JSON and write one object; serialization errors leave the writer usable.

#### RelayTrackWriter.write

```python
async def write(
    self,
    payload: bytes,
    *,
    properties: Sequence[KeyValuePair] | None = None,
) -> None
```

Send one independent object/group.

Waits for relay demand (see `has_demand`). Returns when the transport accepts the complete group, not when a
downstream viewer acknowledges it. Only one write or group may be pending; await it before
writing again. Bytes are copied. Failure resets this group and leaves the track usable
while the connection is open. Data may already have reached the relay; choose the next
object explicitly (no automatic retry).
Properties are encoded as supplied; the relay may transform them before delivery. A 0x02 or
0x06 property sets this group's delivery timeout instead of the track's. An object older
than its OBJECT_DELIVERY_TIMEOUT when it would reach the transport, counted from this call and
including the wait for demand, raises `DELIVERY_TIMEOUT`: the data is stale, so write fresh
data rather than this object again.

#### RelayTrackWriter.open_group

```python
async def open_group(
    self,
    payload: bytes | None = None,
    *,
    properties: Sequence[KeyValuePair] | None = None,
) -> RelayGroupWriter
```

Open a multi-object group: one stream carrying objects 0, 1, 2, ... until `close()`.

Waits for relay demand, for room under the connection's settling-group limit and for the
stream within `write_timeout`. Only one write or group may be pending per track. If the
relay's subscription changes or ends while the group is open, the group is reset and its
writer raises; the track stays open, so start the next group at the next random-access point.

With `payload`, the group opens with it as object 0 and returns once the transport takes it
(`object_count` is 1). Its delivery timeout counts from this call, so every wait before the
stream opens counts (draft-ietf-moq-transport-22 section 5.2), and a 0x02 or 0x06 property in
`properties` sets the group's delivery timeouts. If it expires this raises
`DELIVERY_TIMEOUT` and the group is reset. Without `payload` there is no object to time,
so only `write_timeout` bounds the wait; `properties` without `payload` is a TypeError.

### RequestError

```python
class RequestError
```

REQUEST_ERROR (§9.4): the peer refused a request.

The session also reports a request that fails on its stream (FIN before any response, a reset,
or the response timeout) in this shape, so a consumer has one failure path.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/moq-core/src/moq_core/control_messages.py#L1163)</small>

**Attributes**

- **`kind`**: `Literal['request-error']`
- **`error_code`**: `int`
- **`retry_interval`**: `int`
- **`reason`**: `str`
- **`redirect`**: `Redirect | None`

### StateNotifyEvent

```python
class StateNotifyEvent
```

Publisher-initiated subscription state changes.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/_subscription.py#L92)</small>

**Attributes**

- **`value`**: `MessageParameters`
- **`type`**: `Literal['state_notify']`

### SubgroupEndEvent

```python
class SubgroupEndEvent
```

Clean FIN of one subgroup, not the whole subscription.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/_subscription.py#L76)</small>

**Attributes**

- **`value`**: `SubgroupEnd`
- **`type`**: `Literal['subgroup_end']`

### SubgroupResetEvent

```python
class SubgroupResetEvent
```

A subgroup reset; objects may be missing.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/_subscription.py#L84)</small>

**Attributes**

- **`value`**: `SubgroupReset`
- **`type`**: `Literal['subgroup_reset']`

### SubscribeOk

```python
class SubscribeOk
```

SUBSCRIBE_OK (§9.7): the publisher accepted a subscription.

`track_alias` is the alias its data streams carry; `parameters` and `track_properties` are
as the publisher sent them.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/moq-core/src/moq_core/control_messages.py#L1142)</small>

**Attributes**

- **`kind`**: `Literal['subscribe-ok']`
- **`track_alias`**: `int`
- **`parameters`**: `MessageParameters`
- **`track_properties`**: `list[KeyValuePair]`

### SubscribedEvent

```python
class SubscribedEvent
```

Subscription acceptance.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/_subscription.py#L52)</small>

**Attributes**

- **`value`**: `SubscribeOk`
- **`type`**: `Literal['subscribed']`

### Subscription

```python
class Subscription
```

A live subscription request.

MOQT has no UNSUBSCRIBE message: teardown is the request stream's lifecycle (§6.4.2.3).

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/moq-core/src/moq_core/session.py#L445)</small>

**Attributes**

- **`request_id`**: `int` *(property)* — The Request ID the SUBSCRIBE carried (§6.4.2.1).

#### Subscription.update

```python
def update(self, parameters: MessageParameters) -> None
```

Modify the subscription (REQUEST_UPDATE, §9.5).

#### Subscription.cancel

```python
def cancel(self, reason: object = None) -> None
```

Tear the subscription down.

### SubscriptionEnd

```python
class SubscriptionEnd
```

How a subscription ended.

`kind` is the lifecycle name; `cause` is what happened on the wire or locally; `accepted`
says whether SUBSCRIBE_OK had arrived; `error` is the [`moq_core.RequestError`](../moq_core/#requesterror) for a
request error, the connection's failure (`None` after a clean shutdown) for closure, and the
caller's reason for a cancel.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/_subscription.py#L141)</small>

**Attributes**

- **`kind`**: `SubscriptionEndKind`
- **`cause`**: `Literal['request-error', 'connection-closed', 'cancelled']`
- **`accepted`**: `bool`
- **`error`**: `object`

### SubscriptionEvents

```python
class SubscriptionEvents(BoundedEvents[SubscriptionEvent])
```

Single-consumer iterator. Use `async with` for deterministic cleanup on break/errors.

Create one with `caprid_relay.RelaySubscriber.subscribe_events`. At most `capacity` events are
retained, including lifecycle events. Overflow discards the queue, cancels only this request and
raises on reads. No transport backpressure is implied. Publisher completion is an event, never
EOF. Explicit close produces EOF; failures raise.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/subscription_events.py#L71)</small>

### SubscriptionHandlers

```python
class SubscriptionHandlers
```

Callbacks for one subscription. Every one is optional and must be synchronous.

An async callback raises `TypeError` at subscribe time; a callback that returns an awaitable
has it discarded with a `RuntimeWarning`. Relay's `subscribe_events()` is the async way in.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/moq-core/src/moq_core/session.py#L133)</small>

**Attributes**

- **`on_ok`**: `Callable[[SubscribeOk], None] | None` — The publisher accepted the subscription.
- **`on_error`**: `Callable[[RequestError], None] | None` — The request failed: a REQUEST_ERROR, or a stream failure or timeout in the same shape.
- **`on_object`**: `Callable[[MoqtObject], None] | None` — An object arrived on one of the subscription's data streams.
- **`on_subgroup_end`**: `Callable[[SubgroupEnd], None] | None` — A subgroup stream finished cleanly.
- **`on_subgroup_reset`**: `Callable[[SubgroupReset], None] | None` — A subgroup stream was reset; see [`SubgroupReset`](../moq_core/#subgroupreset).
- **`on_done`**: `Callable[[PublishDone], None] | None` — PUBLISH_DONE arrived: the publisher finished the subscription.
- **`on_update_ok`**: `Callable[[], None] | None` — Response to a REQUEST_UPDATE sent on this subscription.
- **`on_state_notify`**: `Callable[[MessageParameters], None] | None` — PUBLISH_STATE_NOTIFY (§9.10): the publisher changed subscription state on its own.

  The parameters carry only what changed, with LARGEST_OBJECT marking where. Informational; the
  session sends no reply.

- **`on_goaway`**: `Callable[[Goaway], None] | None` — GOAWAY on this request stream: re-issue the request (possibly elsewhere).

### SupervisedPublisher

```python
class SupervisedPublisher
```

A publisher that reconnects. Create one with [`connect_supervised_publisher`](#connect_supervised_publisher).

Each connection is a new generation with its own namespace; tracks are registered once and
rebound to every generation.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/supervised_publisher.py#L590)</small>

**Attributes**

- **`generation`**: `PublisherGeneration | None` *(property)* — The live generation, or `None` between connections and after closing.
- **`status`**: `SupervisedStatus` *(property)* — Where the supervisor stands; see [`SupervisedStatus`](#supervisedstatus).
- **`closed`**: `RelayAwaitable[Closed]` *(property)* — Resolves once the supervisor stops, with the failure that stopped it or the cancellation's reason as `error` (`None` after `close`). Never raises.

#### SupervisedPublisher.register_track

```python
def register_track(
    self,
    track: str,
    *,
    track_properties: Sequence[KeyValuePair] | None = None,
    write_timeout: float = DEFAULT_WRITE_TIMEOUT,
) -> RelayTrackWriter
```

Register a track every generation serves, and return the writer that outlives the generations.

The same rules as `RelayPublisher.register_track`, checked at once: track names are
unique per supervised publisher, including after their writer closes. Writes wait for demand
on the live generation; while none is up they raise `RECONNECTING`, and an operation the
connection's end interrupts raises `RECONNECTING` too, never `CLOSED`, so a frame writer
treats it as an interruption and resumes at the next keyframe.

#### SupervisedPublisher.rotate

```python
def rotate(self) -> None
```

Replace the live generation make-before-break, as happens on its own `rotate_before` before the
credential expires.

The next generation connects, with a fresh token, and every track is registered on it. Each
track's writer then moves to it at its next write or group, once the track has demand there or
has none on the old one. The old generation closes once every track has moved, after 10 seconds
at most. A no-op while no generation is up.

#### SupervisedPublisher.close

```python
async def close(self) -> None
```

Stop reconnecting and close the live generation as `RelayPublisher.close` does. Idempotent.

#### SupervisedPublisher.__aenter__

```python
async def __aenter__(self) -> Self
```

### SupervisedSubscriber

```python
class SupervisedSubscriber
```

A subscriber that reconnects and keeps wanted tracks subscribed. Create one with
[`connect_supervised_subscriber`](#connect_supervised_subscriber).

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/supervised_subscriber.py#L266)</small>

**Attributes**

- **`status`**: `SupervisedStatus` *(property)* — Where the supervisor stands; see [`SupervisedStatus`](#supervisedstatus).
- **`closed`**: `RelayAwaitable[Closed]` *(property)* — Resolves once the supervisor stops, with the failure that stopped it or the cancellation's reason as `error` (`None` after `close`). Never raises.

#### SupervisedSubscriber.want

```python
def want(
    self,
    key: str,
    namespace: Sequence[str],
    track: str,
    handlers: WantHandlers | None = None,
    *,
    parameters: MessageParameters | None = None,
    generations: bool = False,
    stale_after: float | None = None,
    retry: SupervisionBackoff | None = None,
    until: Until = 'first-object',
    drain: float = 0.0,
) -> None
```

Keep a subscription to a track open until `unwant(key)`.

`generations=True` makes `namespace` a supervised publisher's root: the want follows the
newest generation announced one field below it, and the subscriber's token must allow
discovering the root. `stale_after` (seconds) resubscribes when no object arrived for that
long, for tracks that are never silent while live. `parameters` is sent on every SUBSCRIBE;
`retry` is the backoff between resubscriptions (from 0.25 s, doubling up to 8 s).

A move (a newer generation, or a re-want with another track or parameters) is made before the
break, as `switch_to` makes one: the current subscription keeps delivering until an object
of the replacement passes `until` (`"first-object"`, the default, or a synchronous
predicate), and `on_switched` reports the cutover. Compare the object's own timeline with
what was delivered, never group IDs: each generation and track numbers its own groups.
`drain` (seconds, default 0) keeps the old subscription delivering its in-flight groups
that long after the cutover.

Wanting a key again with the same namespace and `generations` updates the want in place:
the handlers, `retry`, `stale_after`, `until` and `drain` are swapped, and another
track or parameters moves the subscription. Another namespace or `generations` replaces the
want. Arguments are checked at once.

#### SupervisedSubscriber.unwant

```python
def unwant(self, key: str) -> None
```

Cancel a want's subscription and forget it. Unknown keys are ignored.

#### SupervisedSubscriber.rotate

```python
def rotate(self) -> None
```

Replace the connection make-before-break, as happens on its own `rotate_before` before the
credential expires.

A new connection opens, with a fresh token, and every want moves its subscription to it as a
move would, keeping the old one delivering until the cutover. A want whose target is unchanged
cuts over on the first object the old one did not deliver, with no callback: nothing is lost
or repeated. The old connection closes once every want has moved, after 10 seconds at most. A
no-op while no connection is up.

#### SupervisedSubscriber.close

```python
def close(self) -> None
```

Stop reconnecting, cancel every want and close the connection. Idempotent.

#### SupervisedSubscriber.__aenter__

```python
async def __aenter__(self) -> Self
```

### SupervisionBackoff

```python
class SupervisionBackoff
```

The delay before each retry: it starts at `initial_delay` and doubles up to `max_delay`, with jitter.

Both are seconds; `None` takes the default for what is being retried.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/_supervision.py#L31)</small>

**Attributes**

- **`initial_delay`**: `float | None` — The delay before the first retry, in seconds.
- **`max_delay`**: `float | None` — The longest delay between retries, in seconds.

### SwitchableHandlers

```python
class SwitchableHandlers
```

Callbacks for a switchable subscription.

Each runs synchronously, for the active subscription's events only, and receives the active
track's name as its last argument. A pending subscription reaches no handler before its cutover.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/switchable.py#L67)</small>

**Attributes**

- **`on_subscribed`**: `Callable[[SubscribeOk, str], None] | None` — The first subscription accepted (SUBSCRIBE_OK). A later subscription's acceptance arrives with its cutover, in `on_switched`.
- **`on_error`**: `Callable[[RequestError, str], None] | None` — The active subscription ended with REQUEST_ERROR: the first one was rejected, or an accepted one was refused later. The switchable has ended. A pending subscription's rejection fails its `switch_to` instead.
- **`on_object`**: `Callable[[MoqtObject, str], None] | None` — One object from the active subscription. Payloads are uninterpreted bytes.
- **`on_subgroup_end`**: `Callable[[SubgroupEnd, str], None] | None` — One of the active subscription's subgroup streams finished cleanly (FIN).
- **`on_subgroup_reset`**: `Callable[[SubgroupReset, str], None] | None` — One of the active subscription's subgroup streams was reset; objects from it may be missing.
- **`on_publisher_done`**: `Callable[[PublishDone, str], None] | None` — The active track's publisher finished with it (PUBLISH_DONE). Not an ending, as on `RelaySubscriptionHandlers.on_publisher_done`; a pending switch carries on.
- **`on_state_notify`**: `Callable[[MessageParameters, str], None] | None` — The publisher changed the active subscription's state (PUBLISH_STATE_NOTIFY).
- **`on_goaway`**: `Callable[[Goaway, str], None] | None` — GOAWAY on the active subscription's request stream. Informational; nothing is retried.
- **`on_switched`**: `Callable[[SwitchedInfo], None] | None` — A cutover: `to_track` is now active and `from_track` has been cancelled (or is draining). Runs before the first object of `to_track`, which is the one that passed `until`. Decoders drop state that assumed continuity here.

### SwitchableSubscription

```python
class SwitchableSubscription
```

The handle `caprid_relay.RelaySubscriber.subscribe_switchable` returns.

Handlers see one subscription at a time, the active one. `switch_to` moves them to another
track in the same namespace, or the same track with other parameters, make-before-break.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/switchable.py#L253)</small>

**Attributes**

- **`track`**: `str` *(property)* — The active subscription's track name.
- **`pending`**: `str | None` *(property)* — The pending subscription's track name, while a switch is pending.
- **`subscribed`**: `RelayAwaitable[SubscribeOk]` *(property)* — Settles with the acceptance info of the first subscription to be active and accepted.

  That is the first one, or the one a switch made before the first one was answered. Raises as
  `RelaySubscription.subscribed` does if the switchable ends before that.

- **`ended`**: `RelayAwaitable[SubscriptionEnd]` *(property)* — Settles once with the active subscription's ending, when it ends other than by a cutover, or when `cancel` runs. Any pending switch is cancelled with it. Never raises.

#### SwitchableSubscription.switch_to

```python
def switch_to(
    self,
    track: str,
    *,
    parameters: MessageParameters | None = None,
    until: Until = 'first-object',
    drain: float = 0.0,
    cancellation: CancellationToken | None = None,
) -> RelayAwaitable[SwitchedInfo]
```

Subscribe to `track` in the same namespace and cut over once one of its objects passes `until`.

The active subscription keeps delivering until then, and is cancelled at the cutover. The
track may be the active one, to change parameters. `parameters` are not inherited from the
subscription being replaced: omitted means none.

`until` is `"first-object"` (the default), which promotes on the new subscription's first
object, or a synchronous predicate that promotes on the first object it accepts. Objects
before the cutover are discarded, except those of groups later than the one that cuts over
(arrived early, out of order), which follow it. Compare the object's own timeline (a timestamp property,
say) with what the active subscription delivered, so the cutover repeats media rather than
skipping it: group IDs are no such timeline, since each track numbers its own groups. A new
subscription can also start with a stale cached group, or lose its first object through
another relay (issue #243); a timeline predicate absorbs both.

`drain` keeps the old subscription open that many seconds after the cutover, from 0 to
`MAX_TIMEOUT` (2147483.647, as for every SDK deadline). Groups arrive
out of order across streams, most of all through another relay, so an older group of the old
track can still be in flight at the cutover. While it drains, its objects, subgroup ends and
resets from groups no later than the largest it had delivered still reach the handlers, with
the old track's name, after `on_switched`; everything else from it is discarded.

`cancellation` abandons the switch while it is pending: the new subscription is cancelled
and the active one stays. A newer `switch_to` also cancels a pending one, which raises
`CLOSED`. The returned awaitable raises as `RelaySubscription.subscribed` does if the new
subscription ends before its cutover, the token's reason when it is cancelled, or what
`until` raised; the active subscription carries on. Raises at once for an invalid track or
options, a closed connection, or an ended switchable.

#### SwitchableSubscription.cancel

```python
def cancel(self, reason: object = None) -> None
```

Cancel the active subscription and any pending one. Idempotent.

### SwitchedInfo

```python
class SwitchedInfo
```

What `on_switched` receives and `SwitchableSubscription.switch_to` resolves with.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/switchable.py#L55)</small>

**Attributes**

- **`from_track`**: `str` — The track that was active before the cutover.
- **`to_track`**: `str` — The track now active; the same as `from_track` when only the parameters changed.
- **`subscribed`**: `SubscribeOk` — The new subscription's acceptance info.

### UpdateOkEvent

```python
class UpdateOkEvent
```

Acknowledgement of a subscription update.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/_subscription.py#L108)</small>

**Attributes**

- **`value`**: `None`
- **`type`**: `Literal['update_ok']`

### WantHandlers

```python
class WantHandlers
```

The callbacks a want delivers to. Like [`RelaySubscriptionHandlers`](#relaysubscriptionhandlers), they must be synchronous.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/supervised_subscriber.py#L120)</small>

**Attributes**

- **`on_subscribed`**: `Callable[[SubscribeOk, tuple[str, ...]], None] | None` — A subscription was accepted. The second argument is the namespace it was made on: the generation's, when following one. A move's replacement reports its acceptance in `on_switched` instead.
- **`on_object`**: `Callable[[MoqtObject], None] | None` — One object, from whichever subscription is current.
- **`on_subgroup_end`**: `Callable[[SubgroupEnd], None] | None` — A subgroup stream ended cleanly.
- **`on_subgroup_reset`**: `Callable[[SubgroupReset], None] | None` — A subgroup stream was reset.
- **`on_publisher_done`**: `Callable[[PublishDone], None] | None` — The relay sent PUBLISH_DONE on the current subscription; the want resubscribes after its backoff.
- **`on_restarted`**: `Callable[[RestartReason, tuple[str, ...]], None] | None` — An accepted subscription ended and the want will replace it: drop decoder state that assumed continuity. Receives the reason and the namespace the ended subscription was made on. Called before any object of the replacement; the gap is never filled.
- **`on_switched`**: `Callable[[WantSwitchedInfo], None] | None` — A move's cutover: the want now delivers from the `to_` subscription, and the `from_` one was cancelled or drains its in-flight groups. Runs before the replacement's first object, the one that passed `until`. Decoders drop state that assumed continuity here; no `on_restarted` or `on_subscribed` runs for a move.
- **`on_error`**: `Callable[[BaseException], None] | None` — A failure the want does not retry, after which it stops and is removed: a REQUEST_ERROR no wait fixes (credentials, an unsupported or malformed request) as a [`SubscriptionRequestError`](#subscriptionrequesterror), or discovery refusing the root.

### WantSwitchedInfo

```python
class WantSwitchedInfo
```

What `on_switched` receives at a want's cutover.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/supervised_subscriber.py#L104)</small>

**Attributes**

- **`from_namespace`**: `tuple[str, ...]` — The namespace the want delivered from before the cutover: the generation's, when following one.
- **`from_track`**: `str` — The track the want delivered from before the cutover.
- **`to_namespace`**: `tuple[str, ...]` — The namespace it delivers from now.
- **`to_track`**: `str` — The track it delivers from now.
- **`subscribed`**: `SubscribeOk` — The new subscription's acceptance info.

## Protocols

### MoqTrackSource

```python
class MoqTrackSource(Protocol)
```

The subscribe / fetch / track-status subset of a session, plus closure observation, without shutdown.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/moq-core/src/moq_core/track_source.py#L23)</small>

**Attributes**

- **`closed`**: `asyncio.Future[Closed]` *(property)* — Resolves when the owning connection ends; an error denotes failure.

#### MoqTrackSource.subscribe

```python
def subscribe(
    self,
    options: MoqtSubscribeOptions,
    handlers: SubscriptionHandlers | None = None,
) -> Subscription
```

Subscribe to a track, as `MoqtSession.subscribe` does; the caller owns the request.

#### MoqTrackSource.fetch

```python
def fetch(
    self,
    options: MoqtSubscribeOptions,
    handlers: FetchHandlers | None = None,
) -> FetchHandle
```

Fetch a range of a track, as `MoqtSession.fetch` does; the caller owns the request.

#### MoqTrackSource.track_status

```python
def track_status(
    self,
    options: MoqtSubscribeOptions,
    handlers: TrackStatusHandlers | None = None,
) -> None
```

Ask for a track's status, as `MoqtSession.track_status` does.

### MoqtTransport

```python
class MoqtTransport(Protocol)
```

Structural subset of a WebTransport session the session drivers need.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/moq-core/src/moq_core/transport.py#L111)</small>

#### MoqtTransport.incoming_unidirectional_streams

```python
def incoming_unidirectional_streams(self) -> AsyncIterator[ReceiveStream]
```

Iterate peer-initiated unidirectional streams until the transport ends.

#### MoqtTransport.incoming_bidirectional_streams

```python
def incoming_bidirectional_streams(self) -> AsyncIterator[BidirectionalStream]
```

Iterate peer-initiated bidirectional streams until the transport ends.

#### MoqtTransport.create_unidirectional_stream

```python
async def create_unidirectional_stream(self) -> SendStream
```

Open a unidirectional stream to the peer.

#### MoqtTransport.create_bidirectional_stream

```python
async def create_bidirectional_stream(self) -> BidirectionalStream
```

Open a bidirectional stream to the peer.

#### MoqtTransport.close

```python
def close(self, close_code: int = 0, reason: str = '') -> None
```

Close the session. Idempotent; a transport that already ended ignores it.

#### MoqtTransport.wait_closed

```python
async def wait_closed(self) -> None
```

Return once the session ended cleanly (either side); raise the error that ended it otherwise.

## Exceptions

### Cancelled

```python
class Cancelled(Exception)
```

The default reason recorded when a token is cancelled without one.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/cancellation.py#L13)</small>

### RelayError

```python
class RelayError(Exception)
```

An SDK failure with a stable `code` and origin `phase`; neither promises safe replay.

Branch on `code` (see `spec/behavior.md`), never on the message. The underlying exception,
when there is one, is chained as `__cause__`.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/errors.py#L57)</small>

**Attributes**

- **`code`**: `RelayErrorCode`
- **`phase`**: `RelayErrorPhase`

### SubscriptionConnectionClosedError

```python
class SubscriptionConnectionClosedError(RelayError)
```

The connection ended, without proving that subscription delivery was drained.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/_subscription.py#L198)</small>

### SubscriptionOverflowError

```python
class SubscriptionOverflowError(RelayError)
```

A stalled consumer exceeded the event capacity; the subscription was cancelled.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/_subscription.py#L183)</small>

### SubscriptionRequestError

```python
class SubscriptionRequestError(RelayError)
```

Request rejection, timeout, or request failure, with the protocol details preserved.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/_subscription.py#L190)</small>

**Attributes**

- **`request_error`**

## Types

### LocationFilter

```python
LocationFilter: TypeAlias = ...
```

Which Objects a subscription or fetch covers.

Draft-22 opens the LOCATION_FILTER value with a Location Filter Type that selects the fields after
it; draft-20 length-prefixes up to four varints and the number present selects the meaning.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/moq-core/src/moq_core/control_messages.py#L492)</small>

### RestartReason

```python
RestartReason: TypeAlias = ...
```

Why a want replaced its subscription.

`publisher-done`: the relay sent PUBLISH_DONE. `stale`: no object arrived for `stale_after`.
`generation`: the current generation was withdrawn with none newer, or a move to a newer one
could not be made before the break. `connection`:
the subscriber connection ended and was replaced. `terminated`: the relay ended the subscription
with REQUEST_ERROR after accepting it.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/supervised_subscriber.py#L87)</small>

### SubscriptionEndKind

```python
SubscriptionEndKind: TypeAlias = Literal['rejected', 'terminated', 'cancelled']
```

The lifecycle name for how a subscription ended.

`rejected`: the relay answered the request with REQUEST_ERROR before accepting it (including the
request timeout). `terminated`: an accepted subscription can no longer deliver (the connection
ended, or a REQUEST_ERROR arrived after acceptance, which is how a refused `update()` is answered,
issue #94), or the connection ended before any answer. `cancelled`: the application cancelled it.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/_subscription.py#L131)</small>

### SubscriptionEvent

```python
SubscriptionEvent: TypeAlias = ...
```

Typed callback arrivals; no ordering across streams or drain guarantee is implied.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/_subscription.py#L116)</small>

### SupervisedStatus

```python
SupervisedStatus: TypeAlias = Literal['connecting', 'live', 'reconnecting', 'closed']
```

Where a supervised connection stands: `connecting` until the first connection is up, `live`
while one is, `reconnecting` between connections, and `closed` once the application closed it,
its cancellation token was cancelled, or a first attempt failed in a way that is not retried.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/_supervision.py#L25)</small>

## Constants

### DEFAULT_ENDPOINT

```python
DEFAULT_ENDPOINT = 'https://relay.mux.global'
```

The default Relay endpoint pinned in `spec/protocol.json`.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/room_endpoint.py#L20)</small>

### RelayErrorCode

```python
RelayErrorCode = ...
```

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/errors.py#L20)</small>

### RelayErrorPhase

```python
RelayErrorPhase = ...
```

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/errors.py#L46)</small>

### RelayTransportFactory

```python
RelayTransportFactory = Callable[[str, Sequence[str]], ConnectingTransport]
```

Supply a transport for `(url, protocols)`.

`protocols` are the MOQT protocol IDs to offer, in preference order: `moqt-22`, then `moqt-20`
as a fallback. A transport reports the one the peer selected as `protocol_id` (TA-8).

Native QUIC on aioquic (`caprid_relay.quic.connect_quic`) by default; tests supply in-memory pairs.

<small>[Source](https://github.com/caprids/relay-sdks/blob/main/python/packages/caprid-relay/src/caprid_relay/connect_session.py#L196)</small>
