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.
- voltkit_rtc/server/__init__.py +114 -0
- voltkit_rtc/server/_redaction.py +333 -0
- voltkit_rtc/server/_version.py +3 -0
- voltkit_rtc/server/client.py +846 -0
- voltkit_rtc/server/errors.py +278 -0
- voltkit_rtc/server/py.typed +0 -0
- voltkit_rtc/server/transport.py +208 -0
- voltkit_rtc/server/types.py +448 -0
- voltkit_rtc/server/webhooks.py +184 -0
- voltkit_rtc_server-0.1.0.dist-info/METADATA +877 -0
- voltkit_rtc_server-0.1.0.dist-info/RECORD +13 -0
- voltkit_rtc_server-0.1.0.dist-info/WHEEL +4 -0
- voltkit_rtc_server-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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()
|