voltkit-rtc-server 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,278 @@
1
+ """
2
+ Errors, and the shared registry of error codes.
3
+
4
+ The controller answers errors as ``{"error", "message"}`` with a meaningful
5
+ status. Collapsing those into a generic exception loses the one thing a caller
6
+ needs to decide what to do: ``capacity-exhausted`` means page someone and
7
+ retry, ``room-quota`` means your own cleanup is behind, ``bad-request`` means
8
+ fix the call. Keep the code.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Mapping
14
+ from dataclasses import dataclass
15
+ from types import MappingProxyType
16
+ from typing import Optional
17
+
18
+ __all__ = [
19
+ "StreamError",
20
+ "ErrorInfo",
21
+ "ERROR_CODES",
22
+ "REST_ERROR_CODES",
23
+ "error_info",
24
+ "is_terminal",
25
+ ]
26
+
27
+
28
+ class StreamError(Exception):
29
+ """
30
+ Every failure this SDK reports.
31
+
32
+ ``code`` is the machine-readable code, e.g. ``"capacity-exhausted"``:
33
+ ``"network"`` if the request never landed, ``"http-<status>"`` if the reply
34
+ named none, ``"redirected"`` if the server answered a key-carrying request
35
+ with a redirect. A newer controller may send a code this release does not
36
+ know; it is passed through unchanged, so switch on the code, never on the
37
+ message.
38
+ """
39
+
40
+ def __init__(self, code: str, status: int, message: str, retryable: bool = False) -> None:
41
+ super().__init__(message)
42
+ #: Machine-readable code, from the registry or the server.
43
+ self.code = code
44
+ #: HTTP status, or 0 when the request never reached the server.
45
+ self.status = status
46
+ #: What happened, in words. Never contains the API key, so it is safe to log.
47
+ self.message = message
48
+ #: True when trying again later could plausibly succeed.
49
+ self.retryable = retryable
50
+
51
+ def __str__(self) -> str:
52
+ return self.message
53
+
54
+ def __repr__(self) -> str:
55
+ return "StreamError(%r, %r, %r, retryable=%r)" % (self.code, self.status, self.message, self.retryable)
56
+
57
+ @property
58
+ def is_capacity_exhausted(self) -> bool:
59
+ """The fleet is full. This is the one to alert on: it is about capacity, not correctness."""
60
+ return self.code in ("capacity-exhausted", "no-nodes")
61
+
62
+ @property
63
+ def is_quota(self) -> bool:
64
+ """You are at your own concurrent-room limit; rooms are leaking or the quota is too low."""
65
+ return self.code == "room-quota" or self.status == 429
66
+
67
+ @property
68
+ def is_out_of_minutes(self) -> bool:
69
+ """
70
+ The account cannot use more right now: out of minutes, or a daily or
71
+ monthly usage limit reached. Anything live is warned and then ended.
72
+ """
73
+ return self.status == 402 or self.code in (
74
+ "quota-exhausted",
75
+ "daily-limit-reached",
76
+ "monthly-limit-reached",
77
+ )
78
+
79
+ @property
80
+ def is_broadcast_disabled(self) -> bool:
81
+ """Broadcast is not switched on for this deployment. Not something a retry fixes."""
82
+ return self.code == "broadcast-disabled"
83
+
84
+ @property
85
+ def is_billing_unavailable(self) -> bool:
86
+ """
87
+ The project is billed from its wallet and no price list is active yet,
88
+ or the controller has no wallet billing. An operator fixes this, not a
89
+ retry -- so, although it is a 503, it is never retried.
90
+ """
91
+ return self.code == "billing-unavailable"
92
+
93
+ @property
94
+ def is_not_found(self) -> bool:
95
+ """The room is gone, or was never yours. Rooms are tenant-scoped, so these are the same answer."""
96
+ return self.status == 404
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class ErrorInfo:
101
+ """What the shared registry says about one code."""
102
+
103
+ #: ``"server"`` (the controller or a media node, passed through unchanged) or ``"sdk"`` (raised here).
104
+ origin: str
105
+ #: A join or reconnect refused with it must not be retried. Client SDKs act on
106
+ #: this; it is kept so the registries agree.
107
+ terminal: bool
108
+ #: Trying the same call again later can succeed.
109
+ retryable: bool
110
+
111
+
112
+ # Every error code a VoltKit RTC SDK can report, in the order of the shared
113
+ # registry (docs/sdk-contract.json in the VoltKit RTC repository), which also
114
+ # says what each one means. A copy rather than a read of that file: the
115
+ # package ships without the docs, and a repository check fails when the two
116
+ # drift apart (`scripts/test/sdk-contract-sdks.mjs` reads the lines between
117
+ # the braces: keep one code per line).
118
+ #
119
+ # Most of these only ever reach a client on its socket. REST_ERROR_CODES are
120
+ # the ones a call from this SDK can fail with.
121
+ _REGISTRY: dict[str, ErrorInfo] = {
122
+ "bad-json": ErrorInfo("server", terminal=False, retryable=False),
123
+ "bad-params": ErrorInfo("server", terminal=False, retryable=False),
124
+ "unknown-method": ErrorInfo("server", terminal=False, retryable=False),
125
+ "too-many-requests": ErrorInfo("server", terminal=False, retryable=True),
126
+ "not-joined": ErrorInfo("server", terminal=False, retryable=False),
127
+ "already-joined": ErrorInfo("server", terminal=False, retryable=False),
128
+ "internal": ErrorInfo("server", terminal=False, retryable=True),
129
+ "node-error": ErrorInfo("server", terminal=False, retryable=True),
130
+ "no-token": ErrorInfo("server", terminal=True, retryable=False),
131
+ "bad-token": ErrorInfo("server", terminal=True, retryable=False),
132
+ "wrong-tenant": ErrorInfo("server", terminal=True, retryable=False),
133
+ "token-mismatch": ErrorInfo("server", terminal=False, retryable=False),
134
+ "no-room": ErrorInfo("server", terminal=True, retryable=False),
135
+ "room-ended": ErrorInfo("server", terminal=True, retryable=False),
136
+ "kicked": ErrorInfo("server", terminal=True, retryable=False),
137
+ "no-node": ErrorInfo("server", terminal=False, retryable=True),
138
+ "no-capacity": ErrorInfo("server", terminal=False, retryable=True),
139
+ "stage-full": ErrorInfo("server", terminal=False, retryable=True),
140
+ "participant-quota": ErrorInfo("server", terminal=False, retryable=True),
141
+ "viewer-quota": ErrorInfo("server", terminal=False, retryable=True),
142
+ "quota-exhausted": ErrorInfo("server", terminal=True, retryable=False),
143
+ "daily-limit-reached": ErrorInfo("server", terminal=True, retryable=False),
144
+ "monthly-limit-reached": ErrorInfo("server", terminal=True, retryable=False),
145
+ "tenant-suspended": ErrorInfo("server", terminal=True, retryable=False),
146
+ "billing-unavailable": ErrorInfo("server", terminal=True, retryable=False),
147
+ "forbidden": ErrorInfo("server", terminal=True, retryable=False),
148
+ "not-in-room": ErrorInfo("server", terminal=False, retryable=False),
149
+ "no-participant": ErrorInfo("server", terminal=False, retryable=False),
150
+ "bad-transport": ErrorInfo("server", terminal=False, retryable=False),
151
+ "no-transport": ErrorInfo("server", terminal=False, retryable=False),
152
+ "no-producer": ErrorInfo("server", terminal=False, retryable=False),
153
+ "no-consumer": ErrorInfo("server", terminal=False, retryable=False),
154
+ "cannot-consume": ErrorInfo("server", terminal=False, retryable=False),
155
+ "consumer-cap": ErrorInfo("server", terminal=False, retryable=True),
156
+ "producer-cap": ErrorInfo("server", terminal=False, retryable=True),
157
+ "room-cap": ErrorInfo("server", terminal=False, retryable=True),
158
+ "draining": ErrorInfo("server", terminal=False, retryable=True),
159
+ "bad-request": ErrorInfo("server", terminal=False, retryable=False),
160
+ "source-already-published": ErrorInfo("server", terminal=False, retryable=False),
161
+ "force-muted": ErrorInfo("server", terminal=False, retryable=False),
162
+ "media-disabled": ErrorInfo("server", terminal=False, retryable=False),
163
+ "frame-rate-not-allowed": ErrorInfo("server", terminal=False, retryable=False),
164
+ "video-quality-not-allowed": ErrorInfo("server", terminal=False, retryable=False),
165
+ "audio-quality-not-allowed": ErrorInfo("server", terminal=False, retryable=False),
166
+ "video-not-allowed": ErrorInfo("server", terminal=False, retryable=False),
167
+ "audio-not-allowed": ErrorInfo("server", terminal=False, retryable=False),
168
+ "video-subscription-limit": ErrorInfo("server", terminal=False, retryable=False),
169
+ "slot-taken": ErrorInfo("server", terminal=False, retryable=False),
170
+ "slot-blocked": ErrorInfo("server", terminal=False, retryable=False),
171
+ "slot-occupied": ErrorInfo("server", terminal=False, retryable=False),
172
+ "chat-muted": ErrorInfo("server", terminal=False, retryable=False),
173
+ "rate-limited": ErrorInfo("server", terminal=False, retryable=True),
174
+ "no-message": ErrorInfo("server", terminal=False, retryable=False),
175
+ "chat-unavailable": ErrorInfo("server", terminal=False, retryable=False),
176
+ "closed": ErrorInfo("server", terminal=False, retryable=True),
177
+ "unauthorized": ErrorInfo("server", terminal=False, retryable=False),
178
+ "bad-signature": ErrorInfo("server", terminal=False, retryable=False),
179
+ "operator-only": ErrorInfo("server", terminal=False, retryable=False),
180
+ "bad-role": ErrorInfo("server", terminal=False, retryable=False),
181
+ "room-exists": ErrorInfo("server", terminal=False, retryable=False),
182
+ "room-quota": ErrorInfo("server", terminal=False, retryable=True),
183
+ "no-nodes": ErrorInfo("server", terminal=False, retryable=True),
184
+ "capacity-exhausted": ErrorInfo("server", terminal=False, retryable=True),
185
+ "unavailable": ErrorInfo("server", terminal=False, retryable=True),
186
+ "not-found": ErrorInfo("server", terminal=False, retryable=False),
187
+ "broadcast-disabled": ErrorInfo("server", terminal=False, retryable=False),
188
+ "chat-disabled": ErrorInfo("server", terminal=False, retryable=False),
189
+ "no-stream": ErrorInfo("server", terminal=False, retryable=False),
190
+ "stream-exists": ErrorInfo("server", terminal=False, retryable=False),
191
+ "stream-quota": ErrorInfo("server", terminal=False, retryable=True),
192
+ "network": ErrorInfo("sdk", terminal=False, retryable=True),
193
+ "rates-unconfigured": ErrorInfo("sdk", terminal=False, retryable=False),
194
+ "redirected": ErrorInfo("sdk", terminal=False, retryable=False),
195
+ "not-connected": ErrorInfo("sdk", terminal=False, retryable=True),
196
+ "timeout": ErrorInfo("sdk", terminal=False, retryable=True),
197
+ "connect-failed": ErrorInfo("sdk", terminal=False, retryable=True),
198
+ "replaced": ErrorInfo("sdk", terminal=True, retryable=False),
199
+ "reconnect-gave-up": ErrorInfo("sdk", terminal=True, retryable=False),
200
+ "devices-gone": ErrorInfo("sdk", terminal=False, retryable=False),
201
+ "unpublished": ErrorInfo("sdk", terminal=False, retryable=False),
202
+ "frame-rate-fallback": ErrorInfo("sdk", terminal=False, retryable=False),
203
+ "codec-fallback": ErrorInfo("sdk", terminal=False, retryable=False),
204
+ "quality-unavailable": ErrorInfo("sdk", terminal=False, retryable=False),
205
+ "screen-share-active": ErrorInfo("sdk", terminal=False, retryable=False),
206
+ "screen-share-unsupported": ErrorInfo("sdk", terminal=False, retryable=False),
207
+ "screen-share-no-video": ErrorInfo("sdk", terminal=False, retryable=False),
208
+ "disconnected": ErrorInfo("sdk", terminal=False, retryable=True),
209
+ "produce-failed": ErrorInfo("sdk", terminal=False, retryable=True),
210
+ "consume-failed": ErrorInfo("sdk", terminal=False, retryable=True),
211
+ "media-unavailable": ErrorInfo("sdk", terminal=False, retryable=False),
212
+ "music-unavailable": ErrorInfo("sdk", terminal=False, retryable=False),
213
+ "no-camera": ErrorInfo("sdk", terminal=False, retryable=False),
214
+ "camera-failed": ErrorInfo("sdk", terminal=False, retryable=True),
215
+ "unknown-producer": ErrorInfo("sdk", terminal=False, retryable=False),
216
+ "screen-share-denied": ErrorInfo("sdk", terminal=False, retryable=True),
217
+ "screen-share-service-required": ErrorInfo("sdk", terminal=False, retryable=False),
218
+ "screen-share-source-required": ErrorInfo("sdk", terminal=False, retryable=False),
219
+ "screen-share-setup-required": ErrorInfo("sdk", terminal=False, retryable=False),
220
+ "screen-share-start-timeout": ErrorInfo("sdk", terminal=False, retryable=True),
221
+ "screen-share-ended": ErrorInfo("sdk", terminal=False, retryable=True),
222
+ "screen-share-failed": ErrorInfo("sdk", terminal=False, retryable=True),
223
+ "screen-share-size-unavailable": ErrorInfo("sdk", terminal=False, retryable=True),
224
+ "screen-audio-unavailable": ErrorInfo("sdk", terminal=False, retryable=False),
225
+ "screen-stop-failed": ErrorInfo("sdk", terminal=False, retryable=False),
226
+ "degradation-preference-failed": ErrorInfo("sdk", terminal=False, retryable=False),
227
+ }
228
+ ERROR_CODES: Mapping[str, ErrorInfo] = MappingProxyType(_REGISTRY)
229
+
230
+ # The registry codes whose surfaces include the tenant REST API: what a call
231
+ # from this SDK can fail with, besides `http-<status>` for a reply that names
232
+ # no code. get_rates also passes the website API's own codes through.
233
+ REST_ERROR_CODES: tuple[str, ...] = (
234
+ "internal",
235
+ "no-room",
236
+ "viewer-quota",
237
+ "quota-exhausted",
238
+ "daily-limit-reached",
239
+ "monthly-limit-reached",
240
+ "tenant-suspended",
241
+ "billing-unavailable",
242
+ "bad-request",
243
+ "rate-limited",
244
+ "no-message",
245
+ "unauthorized",
246
+ "bad-signature",
247
+ "operator-only",
248
+ "bad-role",
249
+ "room-exists",
250
+ "room-quota",
251
+ "no-nodes",
252
+ "capacity-exhausted",
253
+ "unavailable",
254
+ "not-found",
255
+ "broadcast-disabled",
256
+ "chat-disabled",
257
+ "no-stream",
258
+ "stream-exists",
259
+ "stream-quota",
260
+ "network",
261
+ "rates-unconfigured",
262
+ "redirected",
263
+ )
264
+
265
+
266
+ def error_info(code: str) -> Optional[ErrorInfo]:
267
+ """What the registry says about ``code``; None for one this release does not know."""
268
+ return ERROR_CODES.get(code)
269
+
270
+
271
+ def is_terminal(code: str) -> bool:
272
+ """
273
+ Whether a join refused with ``code`` must not be retried. Unknown codes are
274
+ never terminal: a newer server's code must not stop a reconnect that could
275
+ succeed.
276
+ """
277
+ info = ERROR_CODES.get(code)
278
+ return info.terminal if info is not None else False
File without changes
@@ -0,0 +1,208 @@
1
+ """
2
+ How bytes reach the controller: a Transport the client calls once per attempt,
3
+ and the default one on http.client.
4
+
5
+ The default is standard library only and makes one connection per call, which
6
+ keeps the client safe to share between threads without a pool and a lock. Pass
7
+ your own Transport for a proxy, instrumentation, an httpx client, or a test
8
+ double; the client's retries, redaction and parsing sit above it and do not
9
+ change.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import http.client
16
+ import socket
17
+ import ssl
18
+ import time
19
+ from collections.abc import Mapping
20
+ from dataclasses import dataclass
21
+ from typing import Optional, Protocol
22
+ from urllib.parse import unquote, urljoin, urlsplit
23
+
24
+ __all__ = [
25
+ "HttpClientTransport",
26
+ "Transport",
27
+ "TransportError",
28
+ "TransportRequest",
29
+ "TransportResponse",
30
+ ]
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class TransportRequest:
35
+ """One HTTP exchange, as the client asks for it."""
36
+
37
+ method: str
38
+ #: The URL as the client was configured, credentials included.
39
+ url: str
40
+ headers: Mapping[str, str]
41
+ #: The JSON body, already encoded; None when the request has none.
42
+ body: Optional[bytes]
43
+ #: Seconds for the whole exchange, from connecting to the last byte of the body.
44
+ timeout: float
45
+ #: True only for the public rates, which carry no credential. A request
46
+ #: with the API key must get the 3xx back as the reply, never followed.
47
+ follow_redirects: bool
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class TransportResponse:
52
+ status: int
53
+ headers: Mapping[str, str]
54
+ body: bytes
55
+
56
+
57
+ class TransportError(Exception):
58
+ """
59
+ Nothing usable came back. A transport raises this (or an OSError, or an
60
+ http.client.HTTPException) when the request failed in transit; a TimeoutError
61
+ when its deadline passed. The client reports each as `network`, retryable,
62
+ with the transport's words redacted, and "timed out after" for the deadline.
63
+ """
64
+
65
+
66
+ class Transport(Protocol):
67
+ def send(self, request: TransportRequest) -> TransportResponse:
68
+ """
69
+ Perform one exchange and return the reply, whatever its status.
70
+
71
+ Must honour `request.timeout` as a total deadline, and must not follow
72
+ a redirect unless `request.follow_redirects` is set.
73
+ """
74
+ ...
75
+
76
+
77
+ _REDIRECTS = (301, 302, 303, 307, 308)
78
+ _CHUNK = 65536
79
+
80
+
81
+ class HttpClientTransport:
82
+ """
83
+ The default transport: http.client, one connection per exchange.
84
+
85
+ `timeout` is one deadline for the whole exchange, checked before the
86
+ connect, before the send, and before each read of the body, so a reply
87
+ whose headers arrive and whose body then stalls fails like a server that
88
+ never answered. http.client never follows a redirect, so a request with
89
+ the key gets its 3xx back; the key-less rates request follows up to
90
+ `max_redirects` of them. Credentials in the URL (`https://user:pass@host`)
91
+ are sent as Basic authentication, as curl and browsers do.
92
+ """
93
+
94
+ def __init__(self, *, ssl_context: Optional[ssl.SSLContext] = None, max_redirects: int = 5) -> None:
95
+ self._ssl_context = ssl_context
96
+ self._max_redirects = max_redirects
97
+
98
+ def send(self, request: TransportRequest) -> TransportResponse:
99
+ deadline = time.monotonic() + request.timeout
100
+ method, url, body = request.method, request.url, request.body
101
+ headers = dict(request.headers)
102
+ hops = 0
103
+ while True:
104
+ response = _exchange(method, url, headers, body, deadline, self._ssl_context)
105
+ if not request.follow_redirects or response.status not in _REDIRECTS or hops >= self._max_redirects:
106
+ return response
107
+ location = _header(response.headers, "location")
108
+ if not location:
109
+ return response
110
+ hops += 1
111
+ url = urljoin(url, location)
112
+ # As fetch and browsers do: a 303, and a 301 or 302 answering a
113
+ # POST, continue as a GET without the body.
114
+ if response.status == 303 or (response.status in (301, 302) and method == "POST"):
115
+ method, body = "GET", None
116
+ headers = {name: value for name, value in headers.items() if name.lower() != "content-type"}
117
+
118
+
119
+ def _header(headers: Mapping[str, str], name: str) -> Optional[str]:
120
+ for key, value in headers.items():
121
+ if key.lower() == name:
122
+ return value
123
+ return None
124
+
125
+
126
+ def _left(deadline: float) -> float:
127
+ left = deadline - time.monotonic()
128
+ if left <= 0:
129
+ raise TimeoutError("the deadline passed")
130
+ return left
131
+
132
+
133
+ def _exchange(
134
+ method: str,
135
+ url: str,
136
+ headers: Mapping[str, str],
137
+ body: Optional[bytes],
138
+ deadline: float,
139
+ ssl_context: Optional[ssl.SSLContext],
140
+ ) -> TransportResponse:
141
+ parts = urlsplit(url)
142
+ scheme = parts.scheme.lower()
143
+ if scheme not in ("http", "https"):
144
+ raise TransportError("the URL's scheme is not http or https")
145
+ host = parts.hostname
146
+ if not host:
147
+ raise TransportError("the URL names no host")
148
+ try:
149
+ port = parts.port
150
+ except ValueError:
151
+ raise TransportError("the URL's port is not a number") from None
152
+ if port is None:
153
+ port = 443 if scheme == "https" else 80
154
+ if ":" in host:
155
+ host = "[%s]" % host
156
+ path = parts.path or "/"
157
+ if parts.query:
158
+ path = "%s?%s" % (path, parts.query)
159
+
160
+ conn: http.client.HTTPConnection
161
+ if scheme == "https":
162
+ context = ssl_context if ssl_context is not None else ssl.create_default_context()
163
+ conn = http.client.HTTPSConnection(host, port, timeout=_left(deadline), context=context)
164
+ else:
165
+ conn = http.client.HTTPConnection(host, port, timeout=_left(deadline))
166
+ try:
167
+ try:
168
+ conn.connect()
169
+ sock = conn.sock
170
+ sock.settimeout(_left(deadline))
171
+ conn.putrequest(method, path)
172
+ for name, value in headers.items():
173
+ conn.putheader(name, value)
174
+ if parts.username is not None and _header(headers, "authorization") is None:
175
+ userinfo = "%s:%s" % (unquote(parts.username), unquote(parts.password or ""))
176
+ credential = base64.b64encode(userinfo.encode("utf-8")).decode("ascii")
177
+ conn.putheader("Authorization", "Basic " + credential)
178
+ if body is not None:
179
+ conn.putheader("Content-Length", str(len(body)))
180
+ conn.endheaders(body)
181
+ sock.settimeout(_left(deadline))
182
+ response = conn.getresponse()
183
+ chunks: list[bytes] = []
184
+ while not response.isclosed():
185
+ # One receive per read1, with the socket's timeout re-armed to
186
+ # what is left of the deadline before each: read(n) would wait
187
+ # for n bytes however slowly they drip, and the deadline is
188
+ # checked only between reads. http.client closes the body's
189
+ # file once the last byte is in, and the socket with it when
190
+ # the server said Connection: close.
191
+ sock.settimeout(_left(deadline))
192
+ chunk = response.read1(_CHUNK)
193
+ if not chunk:
194
+ break
195
+ chunks.append(chunk)
196
+ if response.length:
197
+ # The server announced more than it sent: read1 returns the
198
+ # short body quietly, as read(n) does, where read() would raise.
199
+ raise http.client.IncompleteRead(b"".join(chunks), response.length)
200
+ out: dict[str, str] = {}
201
+ for name, value in response.getheaders():
202
+ out.setdefault(name, value)
203
+ return TransportResponse(response.status, out, b"".join(chunks))
204
+ except socket.timeout as err:
205
+ # Its own class on Python 3.9; an alias of TimeoutError since 3.10.
206
+ raise TimeoutError(str(err) or "timed out") from None
207
+ finally:
208
+ conn.close()