The public API is snaffle.HTTPClient and three exception classes. The package
ships a PEP 561 py.typed marker, so downstream type checkers use its
annotations without extra configuration.
from snaffle import HTTPClient, HTTPClientError, HTTPConnectionError, ResponseErrorsnaffle.__version__ holds the distribution version as a string. It is read
from the installed distribution's metadata rather than hard-coded, so the
version is stated once, in pyproject.toml. Like HTTPClient it resolves on
first access, so import snaffle does not pay for the metadata lookup.
HTTPClient is resolved through a module-level __getattr__ (PEP 562), so
import snaffle does not import requests. The import happens on first
attribute access. from snaffle import HTTPClient behaves as it would with a
direct import; only the timing differs.
HTTPClient(
timeout: int = 30,
retries: int = 3,
verbose: bool = False,
show_progress: bool = False,
session: requests.Session | None = None,
) -> HTTPClientA client owns a pooled requests.Session for its lifetime. Repeated requests
to the same host reuse the established TCP/TLS connection. Because the client
holds an operating-system resource, close it — use it as a context manager or
call close().
| Argument | Type | Default | Effect |
|---|---|---|---|
timeout |
int |
30 |
Seconds before a request times out. Must be > 0; ValueError otherwise. |
retries |
int |
3 |
Total attempts for a failed request, not retries after the first. Must be > 0; ValueError otherwise. Translated to urllib3.util.retry.Retry(total=retries - 1). |
verbose |
bool |
False |
Print the outgoing request, the response status and headers, and the underlying requests exception behind any failure, each prefixed [VERBOSE]. |
show_progress |
bool |
False |
Stream GET responses and draw a tqdm bar once Content-Length reaches MIN_SIZE_FOR_PROGRESS. A caller who passes stream=True opts out; see make_request. |
session |
requests.Session | None |
None |
The session to send through. None builds the pooled, retrying session described above. A session passed here is used exactly as it arrives; see below. |
Validation runs in __init__, before any network access.
Two rules apply to a session you supply, and neither is guessed at:
retriesdoes not apply to it. The retry policy and the connection pool both come from the session's mounted adapters, so they arrive with the session.retriesbuilds the default session and nothing else — it is still validated, still stored onclient.retries, and has no effect on a session you passed. Passing both is not an error: a caller who mounts a five-attempt adapter may reasonably wantclient.retriesto say5.- The client will not close it.
close()and__exit__close only a session the client built, because one you passed may be shared with other clients or outlive this one. Closing it is yours to do.
It is the last parameter, so existing positional calls are unaffected; pass it by keyword. Its use is substituting the transport — in tests, mounting an adapter on a session you pass is the only substitution above the socket that leaves urllib3's retry loop in place, and it needs no patching. See ADR 0004.
session = requests.Session()
session.mount("https://", my_adapter)
with HTTPClient(session=session) as client: # client sends through my_adapter
client.get("https://example.com")
session.close() # the client did not| Attribute | Type | Notes |
|---|---|---|
timeout |
int |
As constructed. |
retries |
int |
As constructed. Applied to the default session only; see Passing a session. |
verbose |
bool |
As constructed. |
show_progress |
bool |
As constructed. |
allowed_methods |
frozenset[str] |
The methods this instance accepts. Initialised from ALLOWED_METHODS and read on every request. |
session |
requests.Session |
The session requests go through: the one passed to the constructor, or a pooled one with the retrying adapter mounted on http:// and https://. |
The class defines no __slots__: instances stay weak-referenceable and accept
arbitrary attributes.
| Constant | Value | Effect |
|---|---|---|
ALLOWED_METHODS |
frozenset({"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}) |
Any other method raises ValueError before a socket is opened. |
DOWNLOAD_CHUNK_SIZE |
65536 (64 KiB) |
Chunk size for streamed reads. Larger chunks mean fewer syscalls and fewer progress-bar refreshes per megabyte, at the cost of a larger in-flight working set. |
MIN_SIZE_FOR_PROGRESS |
5 * 1024 * 1024 (5 MiB) |
Minimum Content-Length before a progress bar is drawn. The comparison is >=. |
RETRY_STATUSES |
frozenset({408, 425, 429, 500, 502, 503, 504}) |
Statuses eligible for retry. See ADR 0001. |
POOL_SIZE |
16 |
pool_connections and pool_maxsize on the mounted adapter. |
These are class attributes, so subclassing overrides them for all instances of the subclass:
class BigChunkClient(HTTPClient):
DOWNLOAD_CHUNK_SIZE = 1024 * 1024RETRY_STATUSES and POOL_SIZE are read by _build_session during
__init__, so an override must be in place before the client is constructed.
client.get(url: str, **kwargs: Any) -> requests.ResponseThin wrappers over make_request with the method fixed. Every keyword argument
is forwarded to requests.Session.request — json=, data=, headers=,
params=, stream=, allow_redirects=, and the rest of the requests
surface all work.
client.make_request(method: str, url: str, **kwargs: Any) -> requests.ResponseThe single path all seven wrappers take. It validates and upper-cases method,
sets stream=True when it is going to feed a progress bar, sends the request
with the client's timeout, calls raise_for_status(), and translates
requests exceptions into this package's hierarchy.
When progress tracking is active it drains the body into response._content and
marks it consumed, so .text and .json() serve the buffer rather than
re-reading a drained socket.
Passing stream=True yourself opts out of that draining, whatever
show_progress says: you get an unconsumed response to iterate, and no bar is
drawn. The two cannot both hold — a bar is fed by reading the body, and reading
the body is what you asked to do yourself. To have both, drive tqdm from your
own loop; see
Download large files.
Raises:
| Exception | When |
|---|---|
ValueError |
method is not in allowed_methods. Raised before any network access. |
ResponseError |
The response carried a 4xx or 5xx status — including a retryable status that was still failing on the last attempt. |
HTTPConnectionError |
The connection was refused, unresolvable, or timed out while being established, or the adapter exhausted its retries on a connection error. |
HTTPClientError |
Any other requests.RequestException — a read timeout, too many redirects, a malformed URL. |
Two boundaries are easy to get wrong:
- Exhausted status retries surface as
ResponseError, notHTTPConnectionError. The adapter is built withraise_on_status=False, so when a503is still a503on the final attempt urllib3 returns that response rather than raising.raise_for_status()then turns it into aResponseErrorcarrying the real status, which is more useful than a generic connection failure. Only connection retries exhaust intoRetryError, and that is what theHTTPConnectionErrorrow above refers to. - A connect timeout is an
HTTPConnectionError; a read timeout is anHTTPClientError.requests.exceptions.ConnectTimeoutsubclassesConnectionError, so it is caught as a connection failure — which is what it is.ReadTimeoutdoes not, so it falls through to the general case.
client.close() -> NoneCloses the session and releases pooled connections. Idempotent.
Only a session the client built. A session passed to the constructor is left open — see Passing a session.
HTTPClient is a context manager. __enter__ returns the client; __exit__
calls close() and suppresses nothing — including, therefore, leaving an
injected session open.
with HTTPClient() as client:
client.get("https://example.com")A typing.Protocol describing the two tqdm methods the client uses —
update(n) and close(). It is defined in snaffle.http_client, which is its
supported import path. The private snaffle._download module builds the bars
and refers to the protocol under TYPE_CHECKING only, so the run-time
dependency between the two still runs one way. The client does not accept an
injected progress bar.
Defined in snaffle.exceptions and re-exported from snaffle.
Exception
└── HTTPClientError base for everything this package raises
├── HTTPConnectionError the connection failed, timed out, or was refused
└── ResponseError the server answered with a 4xx or 5xx status
Catching HTTPClientError catches all three. Each carries a message string; the
original requests exception is attached as __cause__.
try:
response = client.get(url)
except ResponseError as error:
print(f"Bad status: {error}")
print(f"Underlying: {error.__cause__!r}")
except HTTPConnectionError as error:
print(f"Unreachable: {error}")ValueError from an unsupported method or an invalid constructor argument is
not part of this hierarchy.
Retries live on the session's HTTPAdapter as a urllib3.util.retry.Retry
with backoff_factor=0.3, respect_retry_after_header=True, and
raise_on_status=False. Which failures are retried depends on the method:
| Failure | GET, HEAD, PUT, DELETE, OPTIONS |
POST, PATCH |
|---|---|---|
| Connection never established | retried | retried |
| Read failure after the request landed | retried | not retried |
Status in RETRY_STATUSES |
retried | not retried |
| Any other status (404, 401, ...) | not retried | not retried |
The rationale is in ADR 0001.
raise_on_status=False decides what a caller sees when retries run out: a
status that never recovered comes back as a response and becomes a
ResponseError, while a connection that never succeeded raises RetryError
inside the adapter and becomes an HTTPConnectionError. See
Raises above.
Because retries happen inside the adapter, mocking requests.Session.request
cannot observe them. A test that needs to see them substitutes the session
instead, which leaves the adapter underneath it — see
Passing a session and
contributing.
The policy in this table is the one HTTPClient builds. A session you pass
carries whatever policy its own adapters were mounted with.