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,846 @@
1
+ """
2
+ The REST client: rooms, chat, broadcast, usage and billing.
3
+
4
+ Your backend does two things: decide who may do what, and hand the client a
5
+ short-lived room token. It is never in the media path -- there is no stream to
6
+ proxy, no worker to run, nothing to scale with viewers.
7
+
8
+ The API secret must never leave your server. It is tenant-wide: anything
9
+ holding it can create rooms, mint host tokens for any identity, and end
10
+ anyone's broadcast. Clients get a room token instead -- one room, one
11
+ identity, one role, ten minutes. It is attached only to requests for the
12
+ controller: the public rates come from another host, and are fetched
13
+ without it.
14
+
15
+ The same endpoints, bodies, retry policy and errors as the Node SDK
16
+ (``@voltkit-rtc/server``), with snake_case names; replies are the controller's
17
+ JSON as plain dicts and lists, camelCase keys included.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import http.client
23
+ import json
24
+ import logging
25
+ import math
26
+ import os
27
+ import random
28
+ import socket
29
+ import time
30
+ from collections.abc import Mapping
31
+ from typing import Any, Callable, Optional, cast
32
+ from urllib.parse import quote, urlencode, urlsplit
33
+
34
+ from ._redaction import KeyRedactor, check_api_key, hide_url_credentials
35
+ from ._version import __version__
36
+ from .errors import StreamError
37
+ from .transport import HttpClientTransport, Transport, TransportError, TransportRequest
38
+ from .types import (
39
+ BroadcastStream,
40
+ BroadcastStreamWithPublish,
41
+ ChatInfo,
42
+ ChatMessage,
43
+ ChatToken,
44
+ DailyUsage,
45
+ Participant,
46
+ PlaybackPolicy,
47
+ ProjectBalance,
48
+ Role,
49
+ RoomList,
50
+ RoomToken,
51
+ RoomView,
52
+ RoomWithHostToken,
53
+ RtcRates,
54
+ StreamAction,
55
+ StreamToken,
56
+ UsageSummary,
57
+ )
58
+
59
+ __all__ = ["DEFAULT_RETRIES", "DEFAULT_TIMEOUT", "MAX_TIMEOUT", "RATES_PATH", "USER_AGENT", "StreamClient"]
60
+
61
+ #: Where the website API serves the public rates.
62
+ RATES_PATH = "/api/v1/billing/rtc/rates/current"
63
+ #: Sent with every request, to the controller and to the rates host.
64
+ USER_AGENT = "voltkit-rtc-python/%s" % __version__
65
+ #: Seconds per attempt, headers and body.
66
+ DEFAULT_TIMEOUT = 10.0
67
+ #: Extra attempts after a transient failure on a call that is safe to repeat.
68
+ DEFAULT_RETRIES = 2
69
+ #: The longest deadline held, in seconds: 2^31-1 ms, about 24.8 days (the longest a Node timer waits).
70
+ MAX_TIMEOUT = 2147483.647
71
+
72
+ _log = logging.getLogger("voltkit_rtc.server")
73
+
74
+ _Json = Any
75
+
76
+
77
+ class _Secrets:
78
+ """
79
+ The key, the URLs as given (credentials and all: where requests go) and
80
+ the key's redactor, on one attribute whose repr says nothing. vars(client)
81
+ and a debugger show ``_secrets=<secrets>``; __slots__ keeps vars() off the
82
+ object itself.
83
+ """
84
+
85
+ __slots__ = ("api_key", "base_url", "rates_base_url", "redact_key")
86
+
87
+ def __init__(self, api_key: str, base_url: str, rates_base_url: Optional[str]) -> None:
88
+ self.api_key = api_key
89
+ self.base_url = base_url
90
+ self.rates_base_url = rates_base_url
91
+ self.redact_key = KeyRedactor(api_key)
92
+
93
+ def __repr__(self) -> str:
94
+ return "<secrets>"
95
+
96
+
97
+ class StreamClient:
98
+ """
99
+ A client for the tenant REST API. Make one when your server starts and
100
+ share it: it holds no per-call state and is safe to use from any thread.
101
+
102
+ ``base_url`` is the controller, e.g. ``https://api.voltkit.in`` (the API
103
+ base URL on your project's Credentials tab); ``api_key`` your project's
104
+ API secret, server-side only. ``rates_base_url`` is the website API's
105
+ origin, needed only by :meth:`get_rates` and never sent the key.
106
+ ``timeout`` is the deadline for each attempt in seconds, headers and body;
107
+ ``retries`` how many more attempts a call that is safe to repeat gets
108
+ after a transient failure. ``transport`` replaces the HTTP layer (a proxy,
109
+ instrumentation, a test double); ``sleep`` and ``jitter`` are the backoff's
110
+ clock and randomness, for tests.
111
+ """
112
+
113
+ def __init__(
114
+ self,
115
+ base_url: str,
116
+ api_key: str,
117
+ *,
118
+ rates_base_url: Optional[str] = None,
119
+ timeout: Optional[float] = DEFAULT_TIMEOUT,
120
+ retries: Optional[int] = DEFAULT_RETRIES,
121
+ transport: Optional[Transport] = None,
122
+ sleep: Optional[Callable[[float], None]] = None,
123
+ jitter: Optional[Callable[[], float]] = None,
124
+ ) -> None:
125
+ origin = _check_origin("base_url", base_url)
126
+ if origin is None:
127
+ raise TypeError("base_url is required")
128
+ sent = check_api_key(api_key)
129
+ # Empty counts as unset, so get_rates says what is missing rather than
130
+ # requesting a relative URL.
131
+ rates = _check_origin("rates_base_url", rates_base_url)
132
+ self._secrets = _Secrets(sent, origin, rates)
133
+ #: ``base_url`` as error messages show it: credentials as [redacted].
134
+ self.base_url = hide_url_credentials(origin)
135
+ #: ``rates_base_url`` the same way, or None.
136
+ self.rates_base_url = hide_url_credentials(rates) if rates is not None else None
137
+ self.timeout = _check_timeout(timeout)
138
+ self.retries = _check_retries(retries)
139
+ self.transport: Transport = transport if transport is not None else HttpClientTransport()
140
+ self._sleep = sleep if sleep is not None else time.sleep
141
+ self._jitter = jitter if jitter is not None else random.random
142
+
143
+ @classmethod
144
+ def from_env(
145
+ cls,
146
+ *,
147
+ environ: Optional[Mapping[str, str]] = None,
148
+ rates_base_url: Optional[str] = None,
149
+ timeout: Optional[float] = DEFAULT_TIMEOUT,
150
+ retries: Optional[int] = DEFAULT_RETRIES,
151
+ transport: Optional[Transport] = None,
152
+ ) -> StreamClient:
153
+ """
154
+ A client from ``VOLTKIT_RTC_URL`` and ``VOLTKIT_RTC_SECRET``, and
155
+ ``VOLTKIT_API_URL`` for the rates when it is set. The plain constructor
156
+ reads nothing from the environment; this one says so in its name.
157
+ """
158
+ env = os.environ if environ is None else environ
159
+ base_url = env.get("VOLTKIT_RTC_URL")
160
+ api_key = env.get("VOLTKIT_RTC_SECRET")
161
+ if not base_url:
162
+ raise TypeError("StreamClient.from_env needs VOLTKIT_RTC_URL in the environment: the API base URL")
163
+ if not api_key:
164
+ raise TypeError("StreamClient.from_env needs VOLTKIT_RTC_SECRET in the environment: the API secret")
165
+ if rates_base_url is None:
166
+ rates_base_url = env.get("VOLTKIT_API_URL") or None
167
+ return cls(
168
+ base_url,
169
+ api_key,
170
+ rates_base_url=rates_base_url,
171
+ timeout=timeout,
172
+ retries=retries,
173
+ transport=transport,
174
+ )
175
+
176
+ # --- the client never shows its secrets ---------------------------------------
177
+
178
+ def __repr__(self) -> str:
179
+ return "StreamClient(base_url=%r, rates_base_url=%r, timeout=%r, retries=%r)" % (
180
+ self.base_url,
181
+ self.rates_base_url,
182
+ self.timeout,
183
+ self.retries,
184
+ )
185
+
186
+ __str__ = __repr__
187
+
188
+ def __getstate__(self) -> dict[str, Any]:
189
+ # pickle, copy and deepcopy would carry the key into a file or a
190
+ # queue; a client is made where its key is, not moved.
191
+ raise TypeError("StreamClient cannot be pickled or copied: it holds the API key")
192
+
193
+ # --- rooms -----------------------------------------------------------------
194
+
195
+ def create_room(
196
+ self, host_identity: str, *, room_id: Optional[str] = None, metadata: _Json = None
197
+ ) -> RoomWithHostToken:
198
+ """
199
+ Create a room. Fails with ``room-exists`` if the id is taken; the server
200
+ makes an id up when ``room_id`` is left out.
201
+
202
+ For "go live", prefer :meth:`ensure_room`: a host reconnecting after a
203
+ dropped call hits the existing-room path, and this turns that into two
204
+ round trips. Never retried: a lost reply would turn a success into a
205
+ spurious ``room-exists``.
206
+ """
207
+ value, _ = self._request(
208
+ "POST",
209
+ "/v1/rooms",
210
+ body=_compact({"roomId": room_id, "hostIdentity": host_identity, "metadata": metadata}),
211
+ safe_to_repeat=False,
212
+ )
213
+ return cast(RoomWithHostToken, dict(value if isinstance(value, dict) else {}, existed=False))
214
+
215
+ def ensure_room(self, room_id: str, host_identity: str, *, metadata: _Json = None) -> RoomWithHostToken:
216
+ """
217
+ Create the room, or return it with a fresh host token if it already
218
+ exists. One round trip whether or not the host has been here before.
219
+ ``existed`` in the reply says which happened; ``metadata`` is applied
220
+ only when the room is created.
221
+ """
222
+ value, status = self._request(
223
+ "PUT",
224
+ "/v1/rooms/%s" % _segment("roomId", room_id),
225
+ body=_compact({"hostIdentity": host_identity, "metadata": metadata}),
226
+ safe_to_repeat=True,
227
+ )
228
+ return cast(RoomWithHostToken, dict(value if isinstance(value, dict) else {}, existed=status == 200))
229
+
230
+ def create_token(self, room_id: str, identity: str, role: Role) -> RoomToken:
231
+ """
232
+ Authorise one of your users. YOUR backend decides the role -- it is
233
+ signed into the token and the client cannot change it.
234
+ """
235
+ value, _ = self._request(
236
+ "POST",
237
+ "/v1/rooms/%s/tokens" % _segment("roomId", room_id),
238
+ body={"identity": identity, "role": role},
239
+ # Minting twice is harmless: tokens are independent and short-lived.
240
+ safe_to_repeat=True,
241
+ )
242
+ return cast(RoomToken, value)
243
+
244
+ def get_room(self, room_id: str) -> RoomView:
245
+ """The authoritative live view: ``{"room", "participants", "producers"}``, as sent."""
246
+ value, _ = self._request("GET", "/v1/rooms/%s" % _segment("roomId", room_id), safe_to_repeat=True)
247
+ return cast(RoomView, value)
248
+
249
+ def list_rooms(self) -> RoomList:
250
+ """Every live room for your project, as ``{"rooms": [...]}`` (an object, not a list)."""
251
+ value, _ = self._request("GET", "/v1/rooms", safe_to_repeat=True)
252
+ return cast(RoomList, value)
253
+
254
+ def list_participants(self, room_id: str) -> list[Participant]:
255
+ """Who is in the room now. A point-in-time read, not a presence feed."""
256
+ value, _ = self._request(
257
+ "GET", "/v1/rooms/%s/participants" % _segment("roomId", room_id), safe_to_repeat=True
258
+ )
259
+ return cast(list[Participant], _unwrap_list(value, "participants"))
260
+
261
+ def delete_room(self, room_id: str) -> None:
262
+ """End a room. Everyone is disconnected and the room is gone."""
263
+ self._request(
264
+ "DELETE",
265
+ "/v1/rooms/%s" % _segment("roomId", room_id),
266
+ safe_to_repeat=True,
267
+ # A retry that finds the room gone means the lost attempt ended it.
268
+ gone_is_done=True,
269
+ )
270
+
271
+ # --- chat -------------------------------------------------------------------
272
+ #
273
+ # A channel shares its name with the room of the same name: the people in
274
+ # room "show-42" and the people only in chat "show-42" are one conversation.
275
+ # A channel with no room behind it is chat on its own.
276
+
277
+ def create_chat_token(self, channel: str, identity: str, *, role: Optional[Role] = None) -> ChatToken:
278
+ """
279
+ Admit one of your users to a channel's chat, with no media rights. For
280
+ someone who is not in the room -- an audience watching a broadcast,
281
+ say. People in the room already have chat on the socket they hold.
282
+ ``role`` defaults to viewer on the server.
283
+ """
284
+ value, _ = self._request(
285
+ "POST",
286
+ "/v1/chat/%s/tokens" % _segment("channel", channel),
287
+ body=_compact({"identity": identity, "role": role}),
288
+ safe_to_repeat=True,
289
+ )
290
+ return cast(ChatToken, value)
291
+
292
+ def get_chat_info(self, channel: str) -> ChatInfo:
293
+ """How many identities are connected to a channel, in the room or only in its chat."""
294
+ value, _ = self._request("GET", "/v1/chat/%s" % _segment("channel", channel), safe_to_repeat=True)
295
+ return cast(ChatInfo, value)
296
+
297
+ def list_messages(
298
+ self, channel: str, *, limit: Optional[int] = None, before: Optional[str] = None
299
+ ) -> list[ChatMessage]:
300
+ """
301
+ The newest ``limit`` messages (50 by default, at most 100), oldest
302
+ first, direct messages included: your backend owns the conversation
303
+ and may need to moderate it. ``before`` is the id of the oldest message
304
+ you hold, to page back.
305
+ """
306
+ query: list[tuple[str, str]] = []
307
+ if limit:
308
+ query.append(("limit", str(limit)))
309
+ if before:
310
+ query.append(("before", before))
311
+ suffix = "?" + urlencode(query) if query else ""
312
+ value, _ = self._request(
313
+ "GET", "/v1/chat/%s/messages%s" % (_segment("channel", channel), suffix), safe_to_repeat=True
314
+ )
315
+ return cast(list[ChatMessage], _unwrap_list(value, "messages"))
316
+
317
+ def send_message(
318
+ self,
319
+ channel: str,
320
+ *,
321
+ text: Optional[str] = None,
322
+ type: Optional[str] = None,
323
+ from_: Optional[str] = None,
324
+ to: Optional[str] = None,
325
+ metadata: _Json = None,
326
+ ephemeral: Optional[bool] = None,
327
+ ) -> ChatMessage:
328
+ """
329
+ Speak from your backend: a system notice, a gift announcement, a
330
+ warning. Skips the per-socket rate limit and the mute list, both of
331
+ which exist to restrain clients. ``type`` defaults to "text" and
332
+ ``from_`` (the wire's ``from``) to "system"; ``to`` makes it a direct
333
+ message; ``ephemeral`` delivers without storing. Never retried: a lost
334
+ reply would become the same message twice.
335
+ """
336
+ value, _ = self._request(
337
+ "POST",
338
+ "/v1/chat/%s/messages" % _segment("channel", channel),
339
+ body=_compact(
340
+ {"text": text, "type": type, "from": from_, "to": to, "metadata": metadata, "ephemeral": ephemeral}
341
+ ),
342
+ safe_to_repeat=False,
343
+ )
344
+ return cast(ChatMessage, value)
345
+
346
+ def delete_message(self, channel: str, message_id: str) -> None:
347
+ """Remove a message. Everyone who could see it is told to drop it."""
348
+ self._request(
349
+ "DELETE",
350
+ "/v1/chat/%s/messages/%s" % (_segment("channel", channel), _segment("messageId", message_id)),
351
+ safe_to_repeat=True,
352
+ gone_is_done=True,
353
+ )
354
+
355
+ def list_chat_mutes(self, channel: str) -> list[str]:
356
+ """Identities currently muted in a channel's chat."""
357
+ value, _ = self._request("GET", "/v1/chat/%s/mutes" % _segment("channel", channel), safe_to_repeat=True)
358
+ return cast(list[str], _unwrap_list(value, "muted"))
359
+
360
+ def mute_chat(self, channel: str, identity: str) -> None:
361
+ """
362
+ Silence an identity in a channel's chat. Held for the channel, not the
363
+ connection, so reconnecting does not undo it. They can still read.
364
+ """
365
+ self._request(
366
+ "PUT",
367
+ "/v1/chat/%s/mutes/%s" % (_segment("channel", channel), _segment("identity", identity)),
368
+ safe_to_repeat=True,
369
+ )
370
+
371
+ def unmute_chat(self, channel: str, identity: str) -> None:
372
+ """Lift a mute."""
373
+ self._request(
374
+ "DELETE",
375
+ "/v1/chat/%s/mutes/%s" % (_segment("channel", channel), _segment("identity", identity)),
376
+ safe_to_repeat=True,
377
+ gone_is_done=True,
378
+ )
379
+
380
+ # --- broadcast --------------------------------------------------------------
381
+ #
382
+ # One publisher, any number of viewers, over LL-HLS and WebRTC. Answers
383
+ # `broadcast-disabled` (404) on a deployment without it, VoltKit cloud today
384
+ # included.
385
+
386
+ def create_stream(
387
+ self, stream_id: str, *, playback: Optional[PlaybackPolicy] = None, metadata: _Json = None
388
+ ) -> BroadcastStreamWithPublish:
389
+ """
390
+ Create a stream. The reply's ``publish.whipUrl`` is complete -- hand it
391
+ to the encoder as it is. This is the ONLY call that returns a publish
392
+ token; reading or listing a stream never does. ``playback`` is
393
+ "public" (the default) or "token". Never retried, for the reason
394
+ :meth:`create_room` is not.
395
+ """
396
+ value, _ = self._request(
397
+ "POST",
398
+ "/v1/streams",
399
+ body=_compact({"streamId": stream_id, "playback": playback, "metadata": metadata}),
400
+ safe_to_repeat=False,
401
+ )
402
+ return cast(BroadcastStreamWithPublish, value)
403
+
404
+ def get_stream(self, stream_id: str) -> BroadcastStream:
405
+ """A stream's state, viewers and playback URLs; never its publish token."""
406
+ value, _ = self._request("GET", "/v1/streams/%s" % _segment("streamId", stream_id), safe_to_repeat=True)
407
+ return cast(BroadcastStream, value)
408
+
409
+ def list_streams(self) -> list[BroadcastStream]:
410
+ """Every stream of your project."""
411
+ value, _ = self._request("GET", "/v1/streams", safe_to_repeat=True)
412
+ return cast(list[BroadcastStream], _unwrap_list(value, "streams"))
413
+
414
+ def update_stream(
415
+ self, stream_id: str, *, playback: Optional[PlaybackPolicy] = None, metadata: _Json = None
416
+ ) -> BroadcastStream:
417
+ """Change who may watch, or the stream's metadata."""
418
+ value, _ = self._request(
419
+ "PATCH",
420
+ "/v1/streams/%s" % _segment("streamId", stream_id),
421
+ body=_compact({"playback": playback, "metadata": metadata}),
422
+ safe_to_repeat=True,
423
+ )
424
+ return cast(BroadcastStream, value)
425
+
426
+ def delete_stream(self, stream_id: str) -> None:
427
+ """Delete a stream. Its publish and playback URLs stop working at once."""
428
+ self._request(
429
+ "DELETE",
430
+ "/v1/streams/%s" % _segment("streamId", stream_id),
431
+ safe_to_repeat=True,
432
+ gone_is_done=True,
433
+ )
434
+
435
+ def create_stream_token(
436
+ self,
437
+ stream_id: str,
438
+ *,
439
+ action: Optional[StreamAction] = None,
440
+ identity: Optional[str] = None,
441
+ ttl_seconds: Optional[int] = None,
442
+ ) -> StreamToken:
443
+ """
444
+ Mint a playback token for a viewer of a token-protected stream, or a
445
+ fresh publish token for an encoder whose URL has expired. The reply
446
+ carries the URLs the token belongs in. ``action`` is "play" (the
447
+ default) or "publish"; ``ttl_seconds`` is capped at 24 hours by the
448
+ service.
449
+ """
450
+ value, _ = self._request(
451
+ "POST",
452
+ "/v1/streams/%s/tokens" % _segment("streamId", stream_id),
453
+ body=_compact({"action": action, "identity": identity, "ttlSeconds": ttl_seconds}),
454
+ safe_to_repeat=True,
455
+ )
456
+ return cast(StreamToken, value)
457
+
458
+ # --- usage ------------------------------------------------------------------
459
+
460
+ def get_usage(self) -> UsageSummary:
461
+ """Your allowance, and usage for today, this month and lifetime."""
462
+ value, _ = self._request("GET", "/v1/usage", safe_to_repeat=True)
463
+ return cast(UsageSummary, value)
464
+
465
+ def get_usage_series(self, days: int = 30) -> list[DailyUsage]:
466
+ """
467
+ One entry per UTC day, oldest first, ending today. Days with no usage
468
+ are present and zero, so a chart drawn from it has no gaps. Up to 90
469
+ days.
470
+ """
471
+ value, _ = self._request("GET", "/v1/usage/series?days=%s" % quote(str(days), safe=""), safe_to_repeat=True)
472
+ return cast(list[DailyUsage], _unwrap_list(value, "series"))
473
+
474
+ # --- billing ----------------------------------------------------------------
475
+
476
+ def get_balance(self) -> ProjectBalance:
477
+ """
478
+ What your project can spend right now, and whether a newcomer would be
479
+ admitted. Read only: recharges happen on the website, never through
480
+ this API. Amounts are cu (1 credit = 6,000,000 cu) as decimal strings;
481
+ do arithmetic with int().
482
+ """
483
+ value, _ = self._request("GET", "/v1/billing/balance", safe_to_repeat=True)
484
+ return cast(ProjectBalance, value)
485
+
486
+ def get_rates(self) -> RtcRates:
487
+ """
488
+ The public price list. It comes from the website API
489
+ (``rates_base_url``), where prices are set, rather than the controller
490
+ -- and carries no credential, since it is public and on another host.
491
+ """
492
+ if not self._secrets.rates_base_url:
493
+ raise StreamError(
494
+ "rates-unconfigured",
495
+ 0,
496
+ "getRates needs ratesBaseUrl: the website API origin that serves the public rates, "
497
+ "e.g. https://api.example.com",
498
+ False,
499
+ )
500
+ value, status = self._request(
501
+ "GET", RATES_PATH, safe_to_repeat=True, public_origin=self._secrets.rates_base_url
502
+ )
503
+ data = value.get("data") if isinstance(value, dict) else None
504
+ if not isinstance(data, dict):
505
+ # Not the API's { success, data } envelope: rates_base_url most
506
+ # likely names some other service, and asking again gets the same
507
+ # answer.
508
+ raise StreamError(
509
+ "http-%d" % status,
510
+ status,
511
+ "GET %s: the reply carries no rates; is ratesBaseUrl the website API origin?" % RATES_PATH,
512
+ False,
513
+ )
514
+ return cast(RtcRates, data)
515
+
516
+ # --- transport -------------------------------------------------------------
517
+
518
+ def _request(
519
+ self,
520
+ method: str,
521
+ path: str,
522
+ *,
523
+ safe_to_repeat: bool,
524
+ body: Optional[dict[str, Any]] = None,
525
+ gone_is_done: bool = False,
526
+ public_origin: Optional[str] = None,
527
+ ) -> tuple[Any, int]:
528
+ """
529
+ One call, retried while it is transient and harmless to repeat.
530
+
531
+ Whether repeating is harmless is the caller's decision rather than the
532
+ verb's: retrying a create would turn a lost response into a spurious
533
+ "already exists"; retrying a token mint costs nothing. ``gone_is_done``
534
+ marks a delete: when an attempt failed in a way that may have reached
535
+ the server (a network failure or a 5xx) and a retry then answers 404,
536
+ only the earlier reply was lost, and the call is done.
537
+ ``public_origin`` sends the request to another origin with no API key:
538
+ the website API's public rates.
539
+ """
540
+ # Once, before anything is sent: a body JSON cannot carry is the
541
+ # caller's to fix, and no attempt would go differently.
542
+ payload = None if body is None else _serialize(method, path, body)
543
+ attempts = self.retries + 1 if safe_to_repeat else 1
544
+ last: Optional[StreamError] = None
545
+ # Whether an earlier attempt may have reached the server and done the
546
+ # work, its reply lost on the way back. A 429 is a refusal: that
547
+ # attempt did nothing.
548
+ may_have_landed = False
549
+ for attempt in range(1, attempts + 1):
550
+ if attempt > 1:
551
+ # Exponential with jitter: a fleet-wide blip should not produce
552
+ # a synchronised retry from every backend at once.
553
+ self._sleep(0.2 * 2 ** (attempt - 2) + self._jitter() * 0.1)
554
+ started = time.monotonic()
555
+ try:
556
+ value, status = self._once(method, path, payload, public_origin)
557
+ except StreamError as err:
558
+ _log.debug(
559
+ "%s %s: %s after %s (attempt %d of %d)",
560
+ method,
561
+ path,
562
+ err.code,
563
+ _since(started),
564
+ attempt,
565
+ attempts,
566
+ )
567
+ if gone_is_done and may_have_landed and _is_gone(err):
568
+ # The earlier attempt deleted it; this one finds it gone.
569
+ return None, err.status
570
+ if not err.retryable:
571
+ raise
572
+ last = err
573
+ if err.status != 429:
574
+ may_have_landed = True
575
+ continue
576
+ _log.debug(
577
+ "%s %s: %d after %s (attempt %d of %d)", method, path, status, _since(started), attempt, attempts
578
+ )
579
+ return value, status
580
+ assert last is not None
581
+ raise last
582
+
583
+ def _once(
584
+ self, method: str, path: str, payload: Optional[bytes], public_origin: Optional[str]
585
+ ) -> tuple[Any, int]:
586
+ """One attempt. ``payload`` is the body as JSON, made once by _request."""
587
+ # The key is attached here and only here, and only for the controller.
588
+ with_key = public_origin is None
589
+ headers: dict[str, str] = {}
590
+ if with_key:
591
+ headers["X-API-Key"] = self._secrets.api_key
592
+ if payload is not None:
593
+ headers["Content-Type"] = "application/json"
594
+ headers["User-Agent"] = USER_AGENT
595
+ origin = self._secrets.base_url if public_origin is None else public_origin
596
+ request = TransportRequest(
597
+ method=method,
598
+ url=origin + path,
599
+ headers=headers,
600
+ body=payload,
601
+ timeout=self.timeout,
602
+ # Never follow a redirect with the key: following one would hand
603
+ # X-API-Key to whatever host the redirect names. The public rates
604
+ # carry no credential and may follow one (a website API behind a
605
+ # www. or https redirect).
606
+ follow_redirects=not with_key,
607
+ )
608
+ try:
609
+ reply = self.transport.send(request)
610
+ except (TimeoutError, socket.timeout):
611
+ raise StreamError(
612
+ "network", 0, "%s %s: timed out after %s" % (method, path, _ms(self.timeout)), True
613
+ ) from None
614
+ except (OSError, http.client.HTTPException, TransportError) as err:
615
+ # The transport's own words may quote the URL, credentials and
616
+ # all, or a header value it refused: neither may reach a message
617
+ # that will end up in someone's logs. The cause is dropped for the
618
+ # same reason.
619
+ raise StreamError(
620
+ "network", 0, "%s %s: %s" % (method, path, self._redact(_words_of(err))), True
621
+ ) from None
622
+
623
+ if with_key and 300 <= reply.status < 400:
624
+ # The 3xx itself is the answer: it is not retried (the same
625
+ # base_url redirects the same way) and it is not followed. The
626
+ # target is named so the fix is obvious; it is cut for a log line
627
+ # after the key is taken out, so the cut cannot leave part of it.
628
+ location = _header_of(reply.headers, "location")
629
+ to = " to %s" % self._redact(location)[:200] if location else ""
630
+ raise StreamError(
631
+ "redirected",
632
+ reply.status,
633
+ "%s %s: the server answered %d%s; a request carrying the API key is never redirected. "
634
+ "Point baseUrl at the controller itself (its exact scheme and host)"
635
+ % (method, path, reply.status, to),
636
+ False,
637
+ )
638
+ return _parse_reply(method, path, reply.status, reply.body.decode("utf-8-sig", "replace"), self._redact)
639
+
640
+ def _redact(self, text: str) -> str:
641
+ """``text`` with the API key, and the credentials of any URL in it, replaced by [redacted]."""
642
+ return hide_url_credentials(self._secrets.redact_key(text))
643
+
644
+
645
+ # --- checks when the client is made --------------------------------------------
646
+
647
+
648
+ def _check_origin(name: str, url: object) -> Optional[str]:
649
+ """``url`` without one trailing slash; None when it is unset (None, "" or "/")."""
650
+ if url is None or url == "":
651
+ return None
652
+ if not isinstance(url, str):
653
+ raise TypeError("%s must be a str, got %s" % (name, type(url).__name__))
654
+ origin = url[:-1] if url.endswith("/") else url
655
+ if not origin:
656
+ return None
657
+ parts = urlsplit(origin)
658
+ if not parts.scheme or not parts.netloc:
659
+ # Not quoted: a URL may hold credentials, and a scheme-less one is not
660
+ # one the redaction can read.
661
+ raise ValueError(
662
+ "%s must be an absolute URL such as https://api.voltkit.in: it names no scheme or host" % name
663
+ )
664
+ return origin
665
+
666
+
667
+ def _check_timeout(timeout: object) -> float:
668
+ """``timeout`` in seconds as a deadline can hold it, or an error now."""
669
+ if timeout is None:
670
+ return DEFAULT_TIMEOUT
671
+ if isinstance(timeout, bool) or not isinstance(timeout, (int, float)):
672
+ raise TypeError("timeout must be a number of seconds, got %r" % (timeout,))
673
+ value = float(timeout)
674
+ # NaN fails every comparison, so it is named.
675
+ if math.isnan(value) or value <= 0:
676
+ raise ValueError("timeout must be a number of seconds above 0, got %r" % (timeout,))
677
+ return min(value, MAX_TIMEOUT)
678
+
679
+
680
+ def _check_retries(retries: object) -> int:
681
+ """``retries`` as given, or an error now, instead of a failure far from the mistake."""
682
+ if retries is None:
683
+ return DEFAULT_RETRIES
684
+ if isinstance(retries, bool) or not isinstance(retries, (int, float)):
685
+ raise TypeError("retries must be a number, got %r" % (retries,))
686
+ # is_integer is False for NaN and the infinities as well as fractions.
687
+ if (isinstance(retries, float) and not retries.is_integer()) or retries < 0:
688
+ raise ValueError("retries must be a whole number of 0 or more, got %r" % (retries,))
689
+ return int(retries)
690
+
691
+
692
+ # --- requests ------------------------------------------------------------------
693
+
694
+
695
+ def _segment(name: str, value: str) -> str:
696
+ """
697
+ An id, as one path segment. "", "." and ".." are refused: a URL reads them
698
+ as steps through the path, spelt %2e or not, so no encoding keeps them in
699
+ their segment -- delete_room("..") would send DELETE /v1/, with the key.
700
+ Every other id is percent-encoded as JavaScript's encodeURIComponent does
701
+ (quote() keeps A-Z a-z 0-9 - _ . ~; these five are the others it leaves
702
+ alone), so logs read the same whichever SDK wrote them. The names in the
703
+ message are the API's (roomId, messageId), the same in every VoltKit SDK.
704
+ """
705
+ if not isinstance(value, str):
706
+ raise TypeError("%s must be a str, got %s" % (name, type(value).__name__))
707
+ if value in ("", ".", ".."):
708
+ raise StreamError(
709
+ "bad-request",
710
+ 0,
711
+ '%s %s: an id may not be "", "." or "..", which a URL resolves to another endpoint; nothing was sent'
712
+ % (name, json.dumps(value)),
713
+ False,
714
+ )
715
+ return quote(value, safe="!*'()")
716
+
717
+
718
+ def _compact(body: dict[str, Any]) -> dict[str, Any]:
719
+ # JSON.stringify drops undefined fields; an optional argument left at None
720
+ # is likewise not sent, rather than sent as null for the server to reject.
721
+ return {key: value for key, value in body.items() if value is not None}
722
+
723
+
724
+ def _serialize(method: str, path: str, body: dict[str, Any]) -> bytes:
725
+ """
726
+ The body as JSON, or a TypeError naming the call. A structure that refers
727
+ to itself, a set, a datetime, or a NaN in ``metadata`` used to fail inside
728
+ the request, as a retryable network error; no retry could change it.
729
+ """
730
+ try:
731
+ return json.dumps(body, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")
732
+ except (TypeError, ValueError, OverflowError, RecursionError) as err:
733
+ raise TypeError("%s %s: the body cannot be sent as JSON: %s" % (method, path, err)) from err
734
+
735
+
736
+ def _ms(seconds: float) -> str:
737
+ ms = round(seconds * 1000, 6)
738
+ if float(ms).is_integer():
739
+ return "%dms" % int(ms)
740
+ return ("%.6f" % ms).rstrip("0").rstrip(".") + "ms"
741
+
742
+
743
+ def _since(started: float) -> str:
744
+ return "%dms" % int((time.monotonic() - started) * 1000)
745
+
746
+
747
+ def _words_of(err: BaseException) -> str:
748
+ text = str(err).strip()
749
+ name = type(err).__name__
750
+ return "%s: %s" % (name, text) if text else name
751
+
752
+
753
+ def _header_of(headers: Mapping[str, str], name: str) -> Optional[str]:
754
+ for key, value in headers.items():
755
+ if key.lower() == name:
756
+ return value or None
757
+ return None
758
+
759
+
760
+ # --- replies -------------------------------------------------------------------
761
+
762
+
763
+ def _parse_reply(method: str, path: str, status: int, text: str, redact: Callable[[str], str]) -> tuple[Any, int]:
764
+ """
765
+ A whole reply, read: the value on a 2xx, a StreamError otherwise. The
766
+ server's words go through ``redact`` before they reach a message, and
767
+ before they are cut short, so a cut cannot leave part of the key behind.
768
+ """
769
+ if not 200 <= status < 300:
770
+ reply_code, reply_message = _error_reply(text)
771
+ # Redacted too: a server answering {"error": "Invalid API key: <key>"}
772
+ # put the key in the code. An ordinary code holds no key and passes
773
+ # unchanged.
774
+ code = redact(reply_code) if reply_code is not None else "http-%d" % status
775
+ message = redact(reply_message) if reply_message is not None else redact(text)[:300]
776
+ # 5xx and 429 may pass; a 4xx is a decision the server made and will
777
+ # make again. billing-unavailable is the 503 that will not: it lasts
778
+ # until an operator activates a price list, or wires wallet billing in.
779
+ retryable = (status >= 500 or status == 429) and code != "billing-unavailable"
780
+ raise StreamError(code, status, "%s %s: %s" % (method, path, message), retryable)
781
+ if not text:
782
+ return None, status
783
+ try:
784
+ return _loads(text), status
785
+ except ValueError:
786
+ # A success that is not JSON came from something other than the API
787
+ # -- a web page, a proxy -- and a retry would fetch the same thing.
788
+ raise StreamError(
789
+ "http-%d" % status,
790
+ status,
791
+ "%s %s: the reply is not JSON: %s" % (method, path, redact(text)[:100]),
792
+ False,
793
+ ) from None
794
+
795
+
796
+ def _error_reply(text: str) -> tuple[Optional[str], Optional[str]]:
797
+ """
798
+ The code and message of an error reply. The controller answers
799
+ ``{"error", "message"}``; the website API
800
+ ``{"success": false, "error": {"code", "message"}}``. Anything else -- a
801
+ proxy's HTML page -- has neither.
802
+ """
803
+ try:
804
+ body = _loads(text)
805
+ except ValueError:
806
+ return None, None
807
+ if not isinstance(body, dict):
808
+ return None, None
809
+ error = body.get("error")
810
+ if isinstance(error, dict):
811
+ code, message = error.get("code"), error.get("message")
812
+ elif isinstance(error, list):
813
+ code, message = None, None
814
+ else:
815
+ code, message = error, body.get("message")
816
+ return _non_empty(code), _non_empty(message)
817
+
818
+
819
+ def _non_empty(value: Any) -> Optional[str]:
820
+ return value if isinstance(value, str) and value else None
821
+
822
+
823
+ def _reject_constant(name: str) -> Any:
824
+ # NaN and Infinity are not JSON; JavaScript's JSON.parse refuses them too.
825
+ raise ValueError("not JSON: %s" % name)
826
+
827
+
828
+ def _loads(text: str) -> Any:
829
+ return json.loads(text, parse_constant=_reject_constant)
830
+
831
+
832
+ def _unwrap_list(value: Any, key: str) -> list[Any]:
833
+ # The API wraps every list in an object ({ participants }, { messages },
834
+ # ...); the method is documented as the list itself.
835
+ if isinstance(value, dict):
836
+ items = value.get(key)
837
+ return items if isinstance(items, list) else []
838
+ return []
839
+
840
+
841
+ def _is_gone(err: StreamError) -> bool:
842
+ """
843
+ A 404 saying the thing is not there. Not one saying chat or broadcast is
844
+ switched off on this deployment: that deleted nothing.
845
+ """
846
+ return err.status == 404 and err.code not in ("chat-disabled", "broadcast-disabled")