Sync and async HTTP clients built on httpx, with stamina-driven
retries (exponential backoff + jitter) and a 30 s default timeout.
Pylib wires httpx + stamina + loguru; Prometheus / structlog
hooks are Stamina-library opt-ins you wire at your own site if
needed. Replaces ad-hoc requests / httpx usage where Bandit's
B113 (request without timeout) keeps biting.
pip install scalo[http]from scalo.http import HttpClient
client = HttpClient(base_url="https://api.example.com")
response = client.get("/users/123")
data = response.json()- Default 30 s timeout silences Bandit B113 and stops hung connections from stealing a worker forever.
- Stamina retries cover the cases everyone forgets: transport errors and 5xx server errors, with exponential backoff and jitter. 4xx errors surface immediately — retrying client errors is a bug.
- Stamina exposes Prometheus / structlog hooks; scalo-py does not wire
them. If you want retry attempts on
/metrics, follow Stamina's own setup at your site. stamina.set_testing(True)disables backoff in tests so suites stay fast and deterministic.
Pick by call site:
# Sync — startup probes, CLI tools, hyperi-ci checks
from scalo.http import HttpClient
with HttpClient(base_url="https://api.example.com") as client:
r = client.get("/health")
assert r.status_code == 200
# Async — anything running inside an asyncio loop
from scalo.http import AsyncHttpClient
async with AsyncHttpClient(base_url="https://api.example.com") as client:
r = await client.get("/users/123")Both expose the same surface: get, post, put, patch, delete,
head, options. All return httpx.Response.
def _is_retryable(exc):
if isinstance(exc, httpx.TransportError):
return True
if isinstance(exc, httpx.HTTPStatusError):
return 500 <= exc.response.status_code < 600
return False| Setting | Default |
|---|---|
attempts |
3 (configurable via retries=) |
wait_initial |
0.5 s |
wait_max |
10 s |
wait_jitter |
1.0 s |
| Retry on | httpx.TransportError, 5xx |
| Never retry on | 4xx |
Override via constructor:
client = HttpClient(timeout=60.0, retries=5, base_url="https://slow.api")response.raise_for_status() runs before each retry decision — 4xx
escapes immediately, 5xx feeds back into the retry loop.
Extra kwargs on the constructor flow into httpx.Client(...):
client = HttpClient(
base_url="https://api.example.com",
headers={"User-Agent": "my-service/1.0"},
verify="/etc/ssl/ca-bundle.pem",
follow_redirects=True,
auth=("user", "pass"),
)Extra kwargs on each call flow into client.request(...):
client.post("/data", json={"k": "v"}, headers={"X-Trace-Id": trace_id})
client.get("/search", params={"q": "term", "limit": 10})Both clients build their TLS context with scalo.crypto.ssl_context and the PROD profile: TLS 1.2 minimum, AES-256-GCM ciphers, peer certificate and hostname verified. cafile= or capath= trusts that CA in place of the system store, crypto_profile=CryptoProfile.HIGHSEC raises the minimum to TLS 1.3, and crypto_profile=None falls back to plain httpx defaults. Any verify= kwarg, like the CA bundle in the example above, replaces the scalo context outright.
Two environment variables change the context scalo builds. They are read when a client is constructed and apply to every client in the process that lets scalo build its context, including the OpenBao secrets provider. Each logs one warning per process when it takes effect.
| Variable | Default | Effect |
|---|---|---|
SCALO_TLS_VERIFY |
unset: verify | false, 0, no or off stops checking the peer certificate and hostname. The TLS 1.2 minimum and cipher list stay. |
SCALO_TLS_ALLOW_WEAK |
unset: floor enforced | true, 1, yes or on drops the minimum to TLS 1.0 and opens the cipher list at OpenSSL security level 0, which admits SHA-1, short keys and CBC. HIGHSEC ignores it. |
When to set them:
SCALO_TLS_VERIFY=falseis for a dev or test endpoint with a self-signed certificate. Anything on the network path can then impersonate the server, so never set it in production. Pointingcafile=at the issuing CA keeps verification on and is the better fix.SCALO_TLS_ALLOW_WEAK=trueweakens TLS. It exists only for a legacy endpoint that cannot negotiate TLS 1.2 with AES-256-GCM, and only until that endpoint is upgraded. It applies to every client in the process, so to confine it to the one legacy peer passverify=scalo.crypto.ssl_context(allow_weak=True)to that client instead.
Always close the client — connection pools and the underlying HTTP/2 multiplex live until you do.
# Context manager (preferred)
with HttpClient() as client:
...
async with AsyncHttpClient() as client:
...
# Manual
client = HttpClient()
try:
...
finally:
client.close() # sync
await client.aclose() # asyncPylib wires the HTTP client to httpx + stamina + loguru only.
Stamina has its own Prometheus / structlog integration hooks --
opt in at your own site if you want retry-attempt metrics or
structured retry logs. Pylib doesn't auto-wire them.
For request-level metrics (latency histograms, status-code counters)
wire the HTTP middleware from
core-pillars/METRICS.md.
import stamina
def test_my_service():
stamina.set_testing(True) # No backoff between retries
...Single global flag, no per-client wiring. Use httpx's MockTransport
for fully offline tests:
import httpx
from scalo.http import HttpClient
def handler(request):
return httpx.Response(200, json={"ok": True})
# Pass a custom transport through to httpx.Client
client = HttpClient(transport=httpx.MockTransport(handler))
assert client.get("/anything").json() == {"ok": True}Errors that escape after retries are exhausted:
httpx.TransportError— DNS, TCP, TLS, read/write errorshttpx.HTTPStatusError— 5xx afterattemptsretries, or any 4xx immediatelyhttpx.TimeoutException— exceededtimeoutper attempt
Wrap with try/except at the call site; the clients never swallow
exceptions.
- Circuit breaker around downstream: compose with
RESILIENCE.md'sCircuitBreaker. The HTTP client itself is retry-only; circuit breaking is a separate concern. - Streaming responses: drop down to raw
httpx—HttpClient._clientis the underlying instance if you really need it, but prefer building a sibling helper that exposes the bits you need. - HTTP/2 server push, WebSockets: out of scope. Use
httpxorwebsocketsdirectly.