Skip to content

syshttp.client

A synchronous HTTP client on the standard library. syshttp.fetch is syshttp.client.fetch.

HTTPClient(transport=...) sends through any object with send() and close() (the Transport protocol), such as a transport built on requests or httpx, or a fake in a test. The client owns redirects, credentials, cookies, tokens, hooks, retries and the deadline; the transport owns routing, TLS verification, connection reuse and content decoding. A transport written elsewhere therefore handles proxies and TLS itself, as its library does; prepare_request() gives it the checks and body framing of syshttp's own, and SYSHTTP_DEFAULT_TRANSPORT makes it the default (see Settings).

syshttp.client

An HTTP client on the standard library, with a replaceable transport.

HTTPClient sends a request through a transport (an object with send() and close(): the default HTTPTransport on http.client, or one written against the Transport protocol) and follows redirects under one rule for credentials: Authorization, Cookie and Proxy-Authorization go to the origin they were given for and to no other scheme, host or port. A reply comes back as an HTTPResponse.

Importing this package loads http.client and ssl. Nothing here needs a third-party library; requests in particular is not used.

CA_BUNDLE = 'CA_BUNDLE' module-attribute

The setting naming a CA bundle (a PEM file) that replaces the system's trust store for origins when verification is on and the caller gave no context.

COOKIE_HOSTS = 'SYSHTTP_COOKIE_HOSTS' module-attribute

The setting declaring hosts that share cookies: hosts split by ,, groups by ;.

DEFAULT_TIMEOUT = (10, 60) module-attribute

(connect, read) timeout, in seconds, a client sends with every request unless it is given timeout (HTTPClient(timeout=...) or per request); timeout=None waits without a limit.

DEFAULT_TRANSPORT = 'SYSHTTP_DEFAULT_TRANSPORT' module-attribute

The setting naming the transport of a client given none: an object reference module:attr or <file>.py:attr (a dotted attr is allowed). The object is the transport when it has send(); otherwise it is called with no arguments to build one (a class, a factory). It names code to import: never let an untrusted party set it.

HTTPClient(transport=None, *, headers=None, timeout=DEFAULT_TIMEOUT, deadline=None, user_agent=None, env=None, proxymap=None, ssl_context=None, proxy_ssl_context=None, verify=True, cookies=False, tokens=False, auth_provider=None, retries=3, backoff=1, status_forcelist=TRANSIENT_STATUSES, cookie_hosts=None)

A transport with the settings every request is sent with, the rules for what happens after a redirect, and the state that belongs to a client.

transport is any object with a send() method as HTTPTransport.send(); one it is given stays the caller's. Without one the client builds the transport the SYSHTTP_DEFAULT_TRANSPORT setting names, else an HTTPTransport, when it is made, and closes it with close() or at the end of a with block. proxymap, ssl_context, proxy_ssl_context and verify configure that HTTPTransport and are ValueError beside a transport of your own; env (default HTTPEnv()) is where the client and its transport read their settings. headers are sent with every request, request-specific headers merged over them by name, over a User-Agent of user_agent (syshttp/<version> by default) and Accept: */*. Every request gets timeout=DEFAULT_TIMEOUT ((10, 60) seconds) unless timeout here or in the call says otherwise (None waits without limit). timeout bounds each wait; deadline (seconds, None for no limit, the default) bounds the whole request: redirects, retries and the reading of the body included, each wait's timeout is held to the time that remains, and TimeoutError is raised once it has passed. A read already blocked can overrun it by at most the timeout of that wait.

The client, not the transport, follows redirects. GET, HEAD and OPTIONS follow up to 30; a redirect that changes the scheme, host or port drops Authorization, Cookie and Proxy-Authorization (and nothing else; the jar's cookies are chosen again for each hop). Any other method is sent without following: a 307 or 308 (for PROPFIND also a 301, 302 or 303) to the same scheme, host and port is sent once more with the same method and body, and any other 3xx, a redirect to another origin or a second redirect raises OSError(EIO).

State, chosen by the host of the URL asked for (the origin), never by a proxy's or a gateway's. A client keeps the cookies its responses set, in memory and, with cookies, in a jar: True is a file jar under jar.store_root() by host, a string one key for every host, a jar object is used as given. cookie_hosts (default SYSHTTP_COOKIE_HOSTS) declares groups of hosts that share cookies. tokens is read the same way and becomes a per-request Authorization (Bearer <token>, or verbatim when it names a scheme) that follows no redirect to another origin. In request() (not in redirect()): auth_provider.ensure(client, url) runs first, its errors swallowed; the SYSHTTP_HOOKS_<KEY>_PY hooks run on the caller's URL, and a request a hook makes through the client runs none of them again (the guard is per thread); a status in status_forcelist, or a failed connection (ConnectionError), is retried up to retries times for a method that is safe to send twice, after backoff seconds doubled each time and never less than the Retry-After of the reply, and a status that outlasts the retries is returned, not raised.

Source code in src/syshttp/client/_client.py
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
def __init__(
    self,
    transport: "_ty.Any" = None,
    *,
    headers: "_ty.Mapping[str, str] | None" = None,
    timeout: "float | tuple | None" = DEFAULT_TIMEOUT,
    deadline: "float | None" = None,
    user_agent: "str | None" = None,
    env: "HTTPEnv | None" = None,
    proxymap: "_ty.Any" = None,
    ssl_context: "_ssl.SSLContext | None" = None,
    proxy_ssl_context: "_ssl.SSLContext | None" = None,
    verify: "bool | str | _os.PathLike[str]" = True,
    cookies: "bool | str | CookieJar | None" = False,
    tokens: "bool | str | TokenJar | None" = False,
    auth_provider: "AuthProvider | None" = None,
    retries: int = 3,
    backoff: float = 1,
    status_forcelist: "_ty.Iterable[int]" = TRANSIENT_STATUSES,
    cookie_hosts: "_ty.Iterable[_ty.Iterable[str]] | None" = None,
):
    if transport is not None and not callable(getattr(transport, "send", None)):
        raise TypeError("a transport is an object with a send() method")
    if isinstance(retries, bool) or not isinstance(retries, int) or retries < 0:
        raise ValueError("retries is a count of retries, zero or more")
    if isinstance(backoff, bool) or not isinstance(backoff, (int, float)) or not 0 <= backoff < float("inf"):
        raise ValueError("backoff is a number of seconds, zero or more")
    self._env = HTTPEnv() if env is None else env
    options = {
        "proxymap": proxymap,
        "ssl_context": ssl_context,
        "proxy_ssl_context": proxy_ssl_context,
        "verify": verify,
    }
    self._transport = transport
    self._built = None
    self._transport_lock = _threading.Lock()
    if transport is not None:
        if any(value is not (True if name == "verify" else None) for name, value in options.items()):
            raise ValueError(
                "proxymap, ssl_context, proxy_ssl_context and verify configure the default transport: "
                "a client given a transport leaves them to it"
            )
    else:
        self._make, self._owned = transport_source(self._env, options)
        self.transport  # made now, so a setting that cannot be used is refused now
    self._cookies = make_cookies(cookies, self._env, cookie_aliases(cookie_hosts, self._env))
    self._tokens = make_tokens(tokens, self._env)
    self._auth_provider = auth_provider
    self._retries = retries
    self._backoff = float(backoff)
    self._status_forcelist = frozenset(status_forcelist)
    self._busy = _threading.local()
    self.headers = dict(headers or {})
    self.timeout = timeout
    self.deadline = check_deadline(deadline)
    self.user_agent = user_agent or f"syshttp/{__version__}"

transport property

The transport: the one the client was given, or the one it made (again, after close()). Read once per request.

close()

Closes the transport this client built for itself; one it was given stays the caller's. Harmless when called again.

Source code in src/syshttp/client/_client.py
182
183
184
185
186
187
188
189
190
def close(self) -> None:
    """Closes the transport this client built for itself; one it was given
    stays the caller's. Harmless when called again."""
    with self._transport_lock:
        built, self._built = self._built, None
        if built is not None:
            self._transport = None
    if built is not None:
        built.close()

redirect(method, url, resp, *, headers=None, stream=False, timeout=_UNSET, deadline=_UNSET, auth=None)

Send method to where resp, the answer to a request for url, redirects to, and follow what it redirects to in turn. The request carries what url is sent with, except that Authorization, Cookie and Proxy-Authorization are left off whenever it goes to another scheme, host or port than url's. A redirect with no usable Location is OSError(EIO). Cookies apply to every hop; the token, hooks and retries belong to request().

Source code in src/syshttp/client/_client.py
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
def redirect(
    self,
    method: str,
    url: str,
    resp: HTTPResponse,
    *,
    headers: "_ty.Mapping[str, str] | None" = None,
    stream: bool = False,
    timeout=_UNSET,
    deadline=_UNSET,
    auth: "tuple[str | bytes, str | bytes] | str | None" = None,
) -> HTTPResponse:
    """Send `method` to where `resp`, the answer to a request for `url`,
    redirects to, and follow what it redirects to in turn. The request
    carries what `url` is sent with, except that `Authorization`, `Cookie`
    and `Proxy-Authorization` are left off whenever it goes to another
    scheme, host or port than `url`'s. A
    redirect with no usable `Location` is `OSError(EIO)`. Cookies apply to
    every hop; the token, hooks and retries belong to `request()`."""
    sent = self._sent_headers(headers, auth)
    location = _follow.redirect_location(url, resp)
    if location is None:
        raise _follow.refused_redirect(method, url, resp, None, "no usable Location")
    clock = self._clock(deadline, method, location, first=url)
    return self._dispatch(method, location, sent, None, None, stream, timeout, clock, first=url)

request(method, url, *, data=None, headers=None, stream=False, allow_redirects=None, timeout=_UNSET, deadline=_UNSET, auth=None)

Send method to url and return the final response. A data that is not bytes (a file, an iterable) cannot be sent twice, so it is not re-sent after a redirect, a dropped connection or a retry. allow_redirects=False returns the first answer of a GET, HEAD or OPTIONS as it is. auth is a (user, password) pair sent as Authorization: Basic, or a str sent as the Authorization value ("Bearer <token>"), to the origin of url unless a header names an Authorization; it beats a token of the client. ValueError for a URL (credentials in it included) or a header with a control character, before anything is sent.

The auth provider, the hooks, the token and the retries of the client apply here. A hook is called as hook(client, method, url, kwargs) with the keyword arguments given (data, headers, stream, allow_redirects, timeout, deadline, auth), which it may change in place, and may return a response to be used instead of sending.

With stream=False the body is read before this returns, and a decoded body over MAX_REPLY_BYTES is OSError(EFBIG); with stream=True the caller reads it, and closes the response.

Source code in src/syshttp/client/_client.py
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
def request(
    self,
    method: str,
    url: str,
    *,
    data=None,
    headers: "_ty.Mapping[str, str] | None" = None,
    stream: bool = False,
    allow_redirects: "bool | None" = None,
    timeout=_UNSET,
    deadline=_UNSET,
    auth: "tuple[str | bytes, str | bytes] | str | None" = None,
) -> HTTPResponse:
    """Send `method` to `url` and return the final response. A `data` that
    is not `bytes` (a file, an iterable) cannot be sent twice, so it is not
    re-sent after a redirect, a dropped connection or a retry.
    `allow_redirects=False` returns the first answer of a `GET`, `HEAD` or
    `OPTIONS` as it is. `auth` is a `(user, password)` pair sent as
    `Authorization: Basic`, or a `str` sent as the `Authorization` value
    (`"Bearer <token>"`), to the origin of `url` unless a header names an
    `Authorization`; it beats a token of the client. `ValueError` for a URL
    (credentials in it included) or a header with a control character,
    before anything is sent.

    The auth provider, the hooks, the token and the retries of the client
    apply here. A hook is called as `hook(client, method, url, kwargs)` with
    the keyword arguments given (`data`, `headers`, `stream`,
    `allow_redirects`, `timeout`, `deadline`, `auth`), which it may change
    in place, and may return a response to be used instead of sending.

    With `stream=False` the body is read before this returns, and a decoded
    body over `MAX_REPLY_BYTES` is `OSError(EFBIG)`; with `stream=True` the
    caller reads it, and closes the response."""
    given: "dict[str, _ty.Any]" = {
        name: value
        for name, value, default in (
            ("data", data, None),
            ("headers", headers, None),
            ("stream", stream, False),
            ("allow_redirects", allow_redirects, None),
            ("timeout", timeout, _UNSET),
            ("deadline", deadline, _UNSET),
            ("auth", auth, None),
        )
        if value is not default
    }
    self._ensure(url)
    if not self._guarded("in_hook"):
        self._busy.in_hook = True
        try:
            hooked = _hooks.call_py_hooks(self, method, url, given, env=self._env)
        finally:
            self._busy.in_hook = False
        if hooked is not None:
            return hooked
    unknown = sorted(set(given) - _REQUEST_OPTIONS)
    if unknown:
        raise TypeError("a hook set %s, which request() does not take" % ", ".join(unknown))
    return self._send(
        method,
        url,
        given.get("data"),
        given.get("headers"),
        given.get("stream", False),
        given.get("allow_redirects"),
        given.get("timeout", _UNSET),
        given.get("deadline", _UNSET),
        given.get("auth"),
    )

HTTPResponse(status, reason, headers, url, body=b'', *, decode=True)

One response: status, reason, headers (a read-only mapping that ignores the case of a name), url (the URL the request went to) and the body, read with read() or iter_bytes(). A context manager that closes.

The body is decoded when the response says it is gzip or deflate encoded, unless the request set its own Accept-Encoding; headers are the headers as received, so Content-Length counts the encoded bytes. A body that ends before the length the server stated is OSError(EIO), never a shorter body that reads as complete.

A transport builds one with body as bytes, or as an object with read(amount) -> bytes (empty at the end) and close(), and decode=False for a body it already decoded or that was not asked for in a coding.

Source code in src/syshttp/client/_response.py
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
def __init__(
    self,
    status: int,
    reason: str,
    headers: "_ty.Mapping[str, str] | _ty.Iterable[tuple[str, str]]",
    url: str,
    body: "bytes | _ty.Any" = b"",
    *,
    decode: bool = True,
):
    self._closed = False
    self._ended = False
    self.status = int(status)
    self.reason = reason or ""
    self.headers = headers if isinstance(headers, Headers) else Headers(headers)
    self.url = url
    self._kept = False
    self._content: "bytes | None" = None
    if isinstance(body, (bytes, bytearray, memoryview)):
        body = _Memory(body)
        self._kept = True  # in memory: readable after close
    self._source = body
    self._inflater = _inflater_for(self.headers) if decode else None
    self._leftover = b""

content property

The rest of the decoded body, read on first use and kept, so that content, text() and json() agree however often they are used. A body longer than MAX_REPLY_BYTES is OSError(EFBIG). Bytes already taken with read() or iter_bytes() are not in it.

buffered(limit=None)

Reads the rest of the body into memory, closes the connection and returns this response, which can then be read after it is closed. A transport returns its response this way when send() is called with stream=False. A body that decodes to more than limit bytes (default MAX_REPLY_BYTES) is OSError(EFBIG), and its connection is closed.

Source code in src/syshttp/client/_response.py
279
280
281
282
283
284
285
286
def buffered(self, limit: "int | None" = None) -> "HTTPResponse":
    """Reads the rest of the body into memory, closes the connection and
    returns this response, which can then be read after it is closed. A
    transport returns its response this way when `send()` is called with
    `stream=False`. A body that decodes to more than `limit` bytes (default
    `MAX_REPLY_BYTES`) is `OSError(EFBIG)`, and its connection is closed."""
    limit = _settings.MAX_REPLY_BYTES if limit is None else limit
    return self._buffered(limit, too_large(shown(self.url), limit))

close()

Releases the connection. Harmless when called again. A body that was not read to its end is abandoned, and its connection is closed.

Source code in src/syshttp/client/_response.py
272
273
274
275
276
277
def close(self) -> None:
    """Releases the connection. Harmless when called again. A body that was
    not read to its end is abandoned, and its connection is closed."""
    if not getattr(self, "_closed", True):
        self._closed = True
        self._source.close()

iter_bytes(chunk_size=CHUNK)

The rest of the body in pieces of at most chunk_size bytes, each as soon as it has arrived.

Source code in src/syshttp/client/_response.py
222
223
224
225
226
227
228
229
230
231
def iter_bytes(self, chunk_size: int = CHUNK) -> "_ty.Iterator[bytes]":
    """The rest of the body in pieces of at most `chunk_size` bytes, each
    as soon as it has arrived."""
    self._check_open()
    while True:
        chunk = self._leftover or self._next(chunk_size)
        if not chunk:
            return
        self._leftover = chunk[chunk_size:]
        yield chunk[:chunk_size]

json()

content parsed as JSON; None for an empty body. A body that is not JSON is ResponseError (a ValueError) naming the status.

Source code in src/syshttp/client/_response.py
256
257
258
259
260
261
262
263
264
265
def json(self) -> _ty.Any:
    """`content` parsed as JSON; `None` for an empty body. A body that is
    not JSON is `ResponseError` (a `ValueError`) naming the status."""
    data = self.content
    if not data.strip():
        return None
    try:
        return _json.loads(data)
    except ValueError as error:
        raise ResponseError(f"response was not valid JSON (HTTP {self.status})") from error

read(size=-1)

Up to size bytes of the decoded body, fewer only at its end; the whole rest of it when size is negative or None.

Source code in src/syshttp/client/_response.py
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
def read(self, size: "int | None" = -1) -> bytes:
    """Up to `size` bytes of the decoded body, fewer only at its end; the
    whole rest of it when `size` is negative or `None`."""
    self._check_open()
    parts = []
    have = 0
    while size is None or size < 0 or have < size:
        wanted = CHUNK if size is None or size < 0 else size - have
        chunk = self._leftover or self._next(wanted)
        if not chunk:
            break
        if size is not None and 0 <= size < have + len(chunk):
            self._leftover = chunk[size - have :]
            chunk = chunk[: size - have]
        else:
            self._leftover = b""
        parts.append(chunk)
        have += len(chunk)
    return b"".join(parts)

text(encoding=None, errors='replace')

content decoded with encoding, else the charset of the Content-Type header, else UTF-8; characters that do not decode are replaced unless errors says otherwise.

Source code in src/syshttp/client/_response.py
245
246
247
248
249
250
251
252
253
254
def text(self, encoding: "str | None" = None, errors: str = "replace") -> str:
    """`content` decoded with `encoding`, else the `charset` of the
    `Content-Type` header, else UTF-8; characters that do not decode are
    replaced unless `errors` says otherwise."""
    charset = encoding or self._charset() or "utf-8"
    try:
        _codecs.lookup(charset)
    except LookupError:
        charset = "utf-8"
    return self.content.decode(charset, errors)

HTTPStatusError(response)

Bases: Exception

A reply with an error status (4xx or 5xx). response is the reply.

Source code in src/syshttp/client/_status.py
16
17
18
def __init__(self, response: HTTPResponse):
    super().__init__(response.status)
    self.response = response

HTTPTransport(*, proxymap=None, env=None, ssl_context=None, proxy_ssl_context=None, verify=True)

Sends HTTP requests on the standard library: http.client over a socket, with TLS from ssl, and no third-party code.

send() makes exactly one request: it follows no redirect, adds no credentials of the caller's and retries only as described below. A transport is a context manager, and close() closes every connection it holds.

Routing. Each request asks proxymap (a proxylib proxy map; default proxy.proxy_map(env=env): SYSHTTP_PROXY, the *_PROXY variables, NO_PROXY, then the system's configuration) which entries it may use, and takes the first that accepts a connection (proxylib.Probed; with no entry reachable and no direct one, proxylib.NoReachableProxyError). A direct entry connects to the origin. An http: or https: proxy gets an absolute-form request for an http: origin and a CONNECT for an https: one; its headers (a credential) go to it alone, in the CONNECT or on the plain-http request, never through the tunnel. A prefix+http(s) gateway is asked for <gateway><path><URL> with its credential as a header of that request, and the response keeps the URL asked for. SOCKS entries are not spoken (proxylib.UnsupportedProxyError when nothing else is offered). A map with no opinion for a URL, {} included, means a direct connection.

TLS. https: origins are verified against ssl_context when given, else the CA bundle named by the CA_BUNDLE setting, else the system's context (tls.os_ssl_context()). verify=False is the one way to turn verification off, verify=<path> names a bundle, and an ssl_context beside either is ValueError. An https: proxy or gateway is spoken to with proxy_ssl_context, else the system's context.

Each thread keeps one idle connection per route (at most 8), which a later request reuses unless the server has closed it or sent bytes on it since; the connections of a thread are closed when it ends. A request that fails before any response byte on a reused connection because the server had closed it is sent once more on a new connection, but only if it is repeatable (GET, HEAD, OPTIONS, PUT, DELETE, PROPFIND) and its body is None or in memory. Any other request reuses a connection only if it carried a response within the last second.

Source code in src/syshttp/client/_transport.py
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
def __init__(
    self,
    *,
    proxymap: "_ty.Any" = None,
    env: "HTTPEnv | None" = None,
    ssl_context: "_ssl.SSLContext | None" = None,
    proxy_ssl_context: "_ssl.SSLContext | None" = None,
    verify: "bool | str | _os.PathLike[str]" = True,
):
    self._env = HTTPEnv() if env is None else env
    self._tls = TLSChoice(self._env, ssl_context, proxy_ssl_context, verify)
    if proxymap is None:
        # Decided at every request; a malformed setting is refused now.
        _proxy.configured_proxy(env=self._env)
        self._proxymap: "_ty.Any" = _proxy.proxy_map(env=self._env)
    else:
        self._proxymap = _ty.cast(_ty.Any, proxylib.ProxyMap)(proxymap)
    if self._tls.unverified:
        _log.warning("HTTPTransport(verify=False): TLS certificates are not verified")
    self._pool = _pool.Pool()
    self._finalizer = _pool.finalize_with(self, self._pool)

close()

Closes every connection, idle or in use. Harmless when called again; a later request connects afresh.

Source code in src/syshttp/client/_transport.py
329
330
331
332
def close(self) -> None:
    """Closes every connection, idle or in use. Harmless when called again;
    a later request connects afresh."""
    self._pool.close()

send(method, url, *, headers, body=None, timeout, stream=False)

One request, one response.

headers are sent as given; Accept-Encoding: gzip, deflate is added (and a coded body decoded) unless they name an Accept-Encoding. A body whose length is known without reading it goes under a Content-Length, any other chunked; a Content-Length in headers is used as it is. timeout is seconds for the connection and for each wait on the server: a number, a (connect, read) pair, or None to wait for ever. With stream=False the body has been read when this returns (a body that decodes to more than MAX_REPLY_BYTES is OSError(EFBIG)); with stream=True it is read from the response, which must be closed.

Raises ValueError before anything is sent for a header or URL with a control character, a URL with credentials or another scheme; TimeoutError, ConnectionError and ssl.SSLError for the network; OSError(EIO) for a reply that is not valid HTTP or ends early. The route adds proxylib.NoReachableProxyError, proxylib.UnsupportedProxyError and, for a setting that cannot be used, ConfigurationError.

Source code in src/syshttp/client/_transport.py
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
def send(
    self,
    method: str,
    url: str,
    *,
    headers: "_ty.Mapping[str, str]",
    body: "bytes | _ty.BinaryIO | _ty.Iterable[bytes] | None" = None,
    timeout: "float | tuple | None",
    stream: bool = False,
) -> HTTPResponse:
    """One request, one response.

    `headers` are sent as given; `Accept-Encoding: gzip, deflate` is added
    (and a coded body decoded) unless they name an `Accept-Encoding`.
    A `body` whose length is known without reading it goes under a
    `Content-Length`, any other chunked; a `Content-Length` in `headers`
    is used as it is. `timeout` is seconds for the connection and for each wait on the
    server: a number, a `(connect, read)` pair, or `None` to wait for ever.
    With `stream=False` the body has been read when this returns (a body
    that decodes to more than `MAX_REPLY_BYTES` is `OSError(EFBIG)`); with
    `stream=True` it is read from the response, which must be closed.

    Raises `ValueError` before anything is sent for a header or URL with
    a control character, a URL with credentials or another scheme;
    `TimeoutError`, `ConnectionError` and `ssl.SSLError` for the network;
    `OSError(EIO)` for a reply that is not valid HTTP or ends early. The
    route adds `proxylib.NoReachableProxyError`,
    `proxylib.UnsupportedProxyError` and, for a setting that cannot be used,
    `ConfigurationError`."""
    method = check_method(method)
    target = check_url(url)
    pairs = check_headers(headers)
    connect_timeout, read_timeout = check_timeout(timeout)
    payload = _Payload(body, pairs)
    names = {name.lower() for name, _ in pairs}
    decode = wants_decoding(pairs)
    # As the server will see it: no fragment, "/" for an empty path.
    plain_url = f"{target.scheme}://{target.authority}{target.path}"
    scope = _pool.scope_id()
    route = self._route(target, plain_url, connect_timeout, scope)
    response = self._attempt(
        route, method, pairs, names, payload, connect_timeout, read_timeout, plain_url, decode, scope
    )
    return response if stream else response.buffered()

Headers(pairs=())

Bases: Mapping

The headers of a response: a read-only mapping whose names compare without regard to case. A name sent more than once reads as its values joined with ", " (RFC 9110 5.3); get_all() returns them separately.

Source code in src/syshttp/client/_headers.py
26
27
28
29
30
31
32
33
34
35
36
def __init__(self, pairs: "_ty.Iterable[tuple[str, str]] | _abc.Mapping" = ()):
    if isinstance(pairs, _abc.Mapping):
        pairs = pairs.items()
    self._pairs = tuple((str(name), str(value)) for name, value in pairs)
    grouped: "dict[str, list[str]]" = {}
    self._spelling: "dict[str, str]" = {}
    for name, value in self._pairs:
        key = name.lower()
        grouped.setdefault(key, []).append(value)
        self._spelling.setdefault(key, name)
    self._joined = {key: ", ".join(values) for key, values in grouped.items()}

get_all(name)

Every value sent for name, in order.

Source code in src/syshttp/client/_headers.py
50
51
52
53
def get_all(self, name: str) -> "list[str]":
    """Every value sent for `name`, in order."""
    key = name.lower()
    return [value for field, value in self._pairs if field.lower() == key]

PreparedRequest

Bases: NamedTuple

A request that passed the checks of the transport contract.

headers hold text values, with the Content-Length of a body whose length is known (0 for a PATCH, POST or PUT without a body) and an Accept-Encoding of gzip, deflate unless the caller named one. body is None, bytes, an object that sends exactly the Content-Length bytes when it is read or iterated, or an iterable of bytes to send chunked. timeout is (connect, read). decode says whether the response decodes a gzip or deflate body: build it with HTTPResponse(..., decode=decode).

Transport

Bases: Protocol

What HTTPClient sends through: one request, one response, no redirect, no credentials added.

send() takes the arguments prepare_request() checks and returns an HTTPResponse (stream=False: its body already read; stream=True: the caller closes it); close() releases whatever the transport holds. The client does the redirects, cookies, tokens, hooks, retries and deadline above it. Routing (the proxy map, CONNECT, gateways), TLS verification, connection reuse and decoding a gzip or deflate body are the transport's: HTTPTransport does them with syshttp's proxy map and the system's TLS, and a transport on another library brings that library's.

drop_body(resp, limit=None)

Gives up the body of a reply nobody looks at: it is read, up to limit bytes (default MAX_REPLY_BYTES), so the connection can serve the next request, and resp is closed (which closes the connection when the body was longer). A failure while reading it is not the caller's concern: the status was already used.

Source code in src/syshttp/client/_status.py
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
def drop_body(resp: HTTPResponse, limit: "int | None" = None) -> None:
    """Gives up the body of a reply nobody looks at: it is read, up to `limit`
    bytes (default `MAX_REPLY_BYTES`), so the connection can serve the next
    request, and `resp` is closed (which closes the connection when the body
    was longer). A failure while reading it is not the caller's concern: the
    status was already used."""
    remaining = _settings.MAX_REPLY_BYTES if limit is None else limit
    try:
        while remaining > 0:
            chunk = resp.read(min(CHUNK, remaining))
            if not chunk:
                break
            remaining -= len(chunk)
    except (OSError, ValueError):
        pass
    finally:
        resp.close()

fetch(url, method='GET', *, headers=None, data=None, json=None, timeout=DEFAULT_TIMEOUT, client=None, transport=None, reauth=None, **client_options)

Send method to url and return the response with its body read whole (MAX_REPLY_BYTES bounds it), so the caller takes what it needs from it: .status, .headers, .content, .text() or .json(). A status is never an exception: raise_for_status() is the caller's choice.

data is bytes, text, a mapping (form-encoded) or a binary file; json a value sent as JSON (None sends none: use data=b"null" for that); giving both is ValueError. timeout is seconds or a (connect, read) pair. The request goes through client when given (left open), else through a new HTTPClient(transport, **client_options) closed after the call: transport is any object implementing the transport protocol (None: the SYSHTTP_DEFAULT_TRANSPORT setting's, else syshttp's own), and client_options are the other keywords of HTTPClient (env, proxymap, ssl_context, retries, ...). A client beside transport or client_options is ValueError: a given client already has them.

reauth is called as reauth(client, url) (an object with ensure(client, url), an AuthProvider, is called through that) once after a 401 or 403, and the request is sent one more time; its errors are the caller's. The body must then be one that can be sent twice (bytes, text, a mapping or json).

Source code in src/syshttp/client/_fetch.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
def fetch(
    url: str,
    method: str = "GET",
    *,
    headers: "_ty.Mapping[str, str] | None" = None,
    data: _ty.Any = None,
    json: _ty.Any = None,
    timeout: "float | tuple | None" = DEFAULT_TIMEOUT,
    client: "HTTPClient | None" = None,
    transport: _ty.Any = None,
    reauth: _ty.Any = None,
    **client_options: _ty.Any,
) -> HTTPResponse:
    """Send `method` to `url` and return the response with its body read whole
    (`MAX_REPLY_BYTES` bounds it), so the caller takes what it needs from it:
    `.status`, `.headers`, `.content`, `.text()` or `.json()`. A status is never
    an exception: `raise_for_status()` is the caller's choice.

    `data` is bytes, text, a mapping (form-encoded) or a binary file; `json` a
    value sent as JSON (`None` sends none: use `data=b"null"` for that);
    giving both is `ValueError`. `timeout` is seconds or a `(connect, read)`
    pair. The request goes through `client` when given (left open), else
    through a new `HTTPClient(transport, **client_options)` closed after the
    call: `transport` is any object implementing the transport protocol (`None`:
    the `SYSHTTP_DEFAULT_TRANSPORT` setting's, else syshttp's own), and
    `client_options` are the other keywords of `HTTPClient` (`env`, `proxymap`,
    `ssl_context`, `retries`, ...). A `client` beside `transport` or
    `client_options` is `ValueError`: a given client already has them.

    `reauth` is called as `reauth(client, url)` (an object with
    `ensure(client, url)`, an `AuthProvider`, is called through that) once after
    a 401 or 403, and the request is sent one more time; its errors are the
    caller's. The body must then be one that can be sent twice (bytes, text, a
    mapping or `json`)."""
    if client is not None and (transport is not None or client_options):
        raise ValueError(
            "a client was given, and it has its transport and settings already: "
            "pass transport and client options to fetch() alone, or set them on the client"
        )
    body, sent = _body(data, json, headers)
    if reauth is not None and not (body is None or isinstance(body, (bytes, bytearray, memoryview, str))):
        raise ValueError("reauth sends the request twice: the body must be bytes, text, a mapping or json")
    own = client is None
    used = HTTPClient(transport, **client_options) if client is None else client
    try:
        response = used.request(method, url, data=body, headers=sent, timeout=timeout)
        if reauth is not None and response.status in REAUTH_STATUSES:
            _reauthenticate(reauth, used, url)
            response = used.request(method, url, data=body, headers=sent, timeout=timeout)
        return response
    finally:
        if own:
            used.close()

json_body(resp, what, limit=None)

The JSON document in the body of resp (at most limit bytes, default MAX_REPLY_BYTES, else OSError(EFBIG) naming what); ValueError when it is not one.

Source code in src/syshttp/client/_status.py
55
56
57
58
59
def json_body(resp: HTTPResponse, what: object, limit: "int | None" = None):
    """The JSON document in the body of `resp` (at most `limit` bytes, default
    `MAX_REPLY_BYTES`, else `OSError(EFBIG)` naming `what`); `ValueError` when
    it is not one."""
    return _json.loads(read_limited(resp, what, limit))

peer_text(text, limit=120)

text that came from the other end (a reason phrase, a Location, a header value) as an error message may show it: characters a terminal or a log would act on (controls, line and bidirectional separators) escaped, and cut to limit characters.

Source code in src/syshttp/client/_headers.py
60
61
62
63
64
65
66
67
68
69
70
def peer_text(text: object, limit: int = 120) -> str:
    """`text` that came from the other end (a reason phrase, a `Location`, a
    header value) as an error message may show it: characters a terminal or a
    log would act on (controls, line and bidirectional separators) escaped, and
    cut to `limit` characters."""
    text = str(text)
    cut = len(text) > limit
    shown = "".join(
        char if char.isprintable() else char.encode("unicode_escape").decode("ascii") for char in text[:limit]
    )
    return shown + "..." if cut else shown

prepare_request(method, url, *, headers, body=None, timeout)

The arguments of send() as the contract reads them.

method is upper-cased. Raises ValueError, before anything is sent, for a method or header name that is not an HTTP token, a header value or URL with a control character, a URL with credentials or another scheme, a Content-Length that is not a count of bytes or exceeds the body, and a timeout that is not a positive number, None or a pair of them; TypeError for a str body. None of the messages quotes the URL or a header value. The fragment is dropped from url.

Source code in src/syshttp/client/_prepare.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
def prepare_request(
    method: str,
    url: str,
    *,
    headers: "_ty.Mapping[str, str] | None",
    body: "bytes | _ty.BinaryIO | _ty.Iterable[bytes] | None" = None,
    timeout: "float | tuple | None",
) -> PreparedRequest:
    """The arguments of `send()` as the contract reads them.

    `method` is upper-cased. Raises `ValueError`, before anything is sent, for
    a method or header name that is not an HTTP token, a header value or URL
    with a control character, a URL with credentials or another scheme, a
    `Content-Length` that is not a count of bytes or exceeds the body, and a
    `timeout` that is not a positive number, `None` or a pair of them;
    `TypeError` for a `str` body. None of the messages quotes the URL or a
    header value. The fragment is dropped from `url`."""
    method = _check_method(method)
    _check_url(url)
    _check_body(body)
    timeout = _check_timeout(timeout)
    pairs = _check_headers(headers)
    sent = dict(pairs)
    body, length = _prepare_body(body, pairs)
    if length is not None:
        sent["Content-Length"] = str(length)
    elif not _has_header(sent, "Content-Length"):
        if isinstance(body, (bytes, bytearray, memoryview)):
            sent["Content-Length"] = str(memoryview(body).nbytes)
        elif body is None and method in _EXPECTING_BODY:
            sent["Content-Length"] = "0"
    decode = _wants_decoding(sent.items())
    if not _has_header(sent, "Accept-Encoding"):
        sent["Accept-Encoding"] = "gzip, deflate"
    return PreparedRequest(method, url.split("#", 1)[0], sent, body, timeout, decode)

raise_for_status(resp)

Raises HTTPStatusError for a reply with status 400 or above.

Source code in src/syshttp/client/_status.py
21
22
23
24
def raise_for_status(resp: HTTPResponse) -> None:
    """Raises `HTTPStatusError` for a reply with status 400 or above."""
    if resp.status >= 400:
        raise HTTPStatusError(resp)

read_limited(resp, what, limit=None)

The decoded body of resp, which must be at most limit bytes (default MAX_REPLY_BYTES): OSError(EFBIG) naming what otherwise, with the connection closed.

Source code in src/syshttp/client/_status.py
27
28
29
30
31
32
33
def read_limited(resp: HTTPResponse, what: object, limit: "int | None" = None) -> bytes:
    """The decoded body of `resp`, which must be at most `limit` bytes (default
    `MAX_REPLY_BYTES`): `OSError(EFBIG)` naming `what` otherwise, with the
    connection closed."""
    limit = _settings.MAX_REPLY_BYTES if limit is None else limit
    resp._buffered(limit, too_large(what, limit))
    return resp.read()

split_userinfo(url)

Split the userinfo out of an absolute URL: (url_without_userinfo, (user, password) | None), both parts percent-decoded to the octets they stand for. Credentials inside a request URL would end up in HTTPResponse.url, in error text and in redirect handling; they are sent in an Authorization header instead.

Source code in src/syshttp/client/_follow.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
def split_userinfo(url: str) -> "tuple[str, tuple[str|bytes, str|bytes] | None]":
    """Split the userinfo out of an absolute URL: `(url_without_userinfo,
    (user, password) | None)`, both parts percent-decoded to the octets they
    stand for. Credentials inside a request URL would end up in
    `HTTPResponse.url`, in error text and in redirect handling; they are sent
    in an `Authorization` header instead."""
    match = _RE_URL_SCHEME.match(url)
    if not match:
        return url, None
    start = match.end()
    end = len(url)
    for sep in "/?#":
        index = url.find(sep, start)
        if index != -1 and index < end:
            end = index
    userinfo, at, hostport = url[start:end].rpartition("@")
    if not at:
        return url, None
    user, _, password = userinfo.partition(":")
    auth = (credential(user), credential(password))
    return url[:start] + hostport + url[end:], (auth if any(auth) else None)