httpware 0.10.1__tar.gz → 0.12.0__tar.gz
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.
- httpware-0.12.0/PKG-INFO +110 -0
- httpware-0.12.0/README.md +80 -0
- {httpware-0.10.1 → httpware-0.12.0}/pyproject.toml +2 -2
- {httpware-0.10.1 → httpware-0.12.0}/src/httpware/__init__.py +2 -0
- {httpware-0.10.1 → httpware-0.12.0}/src/httpware/_internal/observability.py +11 -2
- httpware-0.12.0/src/httpware/_internal/redaction.py +116 -0
- {httpware-0.10.1 → httpware-0.12.0}/src/httpware/_internal/status.py +9 -4
- {httpware-0.10.1 → httpware-0.12.0}/src/httpware/client.py +520 -7
- {httpware-0.10.1 → httpware-0.12.0}/src/httpware/decoders/msgspec.py +2 -0
- {httpware-0.10.1 → httpware-0.12.0}/src/httpware/decoders/pydantic.py +12 -4
- {httpware-0.10.1 → httpware-0.12.0}/src/httpware/errors.py +48 -21
- {httpware-0.10.1 → httpware-0.12.0}/src/httpware/middleware/__init__.py +13 -0
- {httpware-0.10.1 → httpware-0.12.0}/src/httpware/middleware/resilience/_backoff.py +8 -5
- {httpware-0.10.1 → httpware-0.12.0}/src/httpware/middleware/resilience/budget.py +8 -4
- {httpware-0.10.1 → httpware-0.12.0}/src/httpware/middleware/resilience/retry.py +37 -35
- httpware-0.10.1/PKG-INFO +0 -181
- httpware-0.10.1/README.md +0 -151
- {httpware-0.10.1 → httpware-0.12.0}/src/httpware/_internal/__init__.py +0 -0
- {httpware-0.10.1 → httpware-0.12.0}/src/httpware/_internal/exception_mapping.py +0 -0
- {httpware-0.10.1 → httpware-0.12.0}/src/httpware/_internal/import_checker.py +0 -0
- {httpware-0.10.1 → httpware-0.12.0}/src/httpware/decoders/__init__.py +0 -0
- {httpware-0.10.1 → httpware-0.12.0}/src/httpware/middleware/chain.py +0 -0
- {httpware-0.10.1 → httpware-0.12.0}/src/httpware/middleware/resilience/__init__.py +0 -0
- {httpware-0.10.1 → httpware-0.12.0}/src/httpware/middleware/resilience/bulkhead.py +0 -0
- {httpware-0.10.1 → httpware-0.12.0}/src/httpware/middleware/resilience/circuit_breaker.py +0 -0
- {httpware-0.10.1 → httpware-0.12.0}/src/httpware/middleware/resilience/timeout.py +0 -0
- {httpware-0.10.1 → httpware-0.12.0}/src/httpware/py.typed +0 -0
httpware-0.12.0/PKG-INFO
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: httpware
|
|
3
|
+
Version: 0.12.0
|
|
4
|
+
Summary: Resilience-first async HTTP client framework for Python
|
|
5
|
+
Keywords: http,async,client,resilience,retry,circuit-breaker,middleware,httpx,pydantic
|
|
6
|
+
Author: Artur Shiriev
|
|
7
|
+
Author-email: Artur Shiriev <me@shiriev.ru>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
15
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
16
|
+
Classifier: Framework :: AsyncIO
|
|
17
|
+
Requires-Dist: httpx2>=2.0.0,<3.0
|
|
18
|
+
Requires-Dist: httpware[pydantic,msgspec,otel] ; extra == 'all'
|
|
19
|
+
Requires-Dist: msgspec>=0.18 ; extra == 'msgspec'
|
|
20
|
+
Requires-Dist: opentelemetry-api>=1.20 ; extra == 'otel'
|
|
21
|
+
Requires-Dist: pydantic>=2.0,<3.0 ; extra == 'pydantic'
|
|
22
|
+
Requires-Python: >=3.11, <4
|
|
23
|
+
Project-URL: repository, https://github.com/modern-python/httpware
|
|
24
|
+
Project-URL: docs, https://httpware.modern-python.org
|
|
25
|
+
Provides-Extra: all
|
|
26
|
+
Provides-Extra: msgspec
|
|
27
|
+
Provides-Extra: otel
|
|
28
|
+
Provides-Extra: pydantic
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# httpware
|
|
32
|
+
|
|
33
|
+
[](https://pypi.org/project/httpware/)
|
|
34
|
+
[](https://pypi.org/project/httpware/)
|
|
35
|
+
[](https://pypistats.org/packages/httpware)
|
|
36
|
+
[](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
|
|
37
|
+
[](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
|
|
38
|
+
[](https://github.com/modern-python/httpware/blob/main/LICENSE)
|
|
39
|
+
[](https://github.com/modern-python/httpware/stargazers)
|
|
40
|
+
[](https://context7.com/modern-python/httpware)
|
|
41
|
+
[](https://github.com/astral-sh/uv)
|
|
42
|
+
[](https://github.com/astral-sh/ruff)
|
|
43
|
+
[](https://github.com/astral-sh/ty)
|
|
44
|
+
|
|
45
|
+
**A Python HTTP client framework with sync and async clients for building resilient service clients.**
|
|
46
|
+
|
|
47
|
+
## Why httpware
|
|
48
|
+
|
|
49
|
+
- **Typed errors, no `raise_for_status()`** — 4xx/5xx automatically raise a status-keyed exception tree (`NotFoundError`, `RateLimitedError`, …), all under `httpware.StatusError`.
|
|
50
|
+
- **Typed response bodies** — `response_model=YourType` decodes the body straight to your pydantic or msgspec model; a missing decoder fails fast, *before* the request goes out.
|
|
51
|
+
- **Production resilience as composable middleware** — retry + retry-budget, bulkhead, circuit breaker, and timeout, composed at construction — all over standard `httpx2`.
|
|
52
|
+
|
|
53
|
+
Built on `httpx2`: httpware re-exports `httpx2.Request`/`httpx2.Response` and stays a thin wrapper, not a new HTTP abstraction.
|
|
54
|
+
|
|
55
|
+
> **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install httpware # core only — no decoder
|
|
61
|
+
pip install httpware[pydantic] # + PydanticDecoder — BaseModel, dataclasses, primitives, generics
|
|
62
|
+
pip install httpware[msgspec] # + MsgspecDecoder — Struct, dataclasses, primitives, generics
|
|
63
|
+
pip install httpware[pydantic,msgspec] # both — BaseModel routes to pydantic, Struct to msgspec
|
|
64
|
+
pip install httpware[all] # everything (pydantic, msgspec, otel)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Quickstart
|
|
68
|
+
|
|
69
|
+
A typed GET against a live API (needs `pip install httpware[pydantic]`):
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
import asyncio
|
|
73
|
+
|
|
74
|
+
from httpware import AsyncClient
|
|
75
|
+
from pydantic import BaseModel
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class User(BaseModel):
|
|
79
|
+
id: int
|
|
80
|
+
name: str
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def main() -> None:
|
|
84
|
+
async with AsyncClient(base_url="https://jsonplaceholder.typicode.com") as client:
|
|
85
|
+
user = await client.get("/users/1", response_model=User)
|
|
86
|
+
print(user.name) # Leanne Graham
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
asyncio.run(main())
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The sync `Client` is identical — swap `AsyncClient` → `Client` and drop the `await` / `async with`. A 4xx/5xx response raises a typed `StatusError`; a malformed body raises `DecodeError`. Both subclass `httpware.ClientError`.
|
|
93
|
+
|
|
94
|
+
## Documentation
|
|
95
|
+
|
|
96
|
+
Full guides live at **[httpware.modern-python.org](https://httpware.modern-python.org)**:
|
|
97
|
+
|
|
98
|
+
- **[Quickstart & observability](https://httpware.modern-python.org/)** — resilience middleware, streaming, and the stable logger/event contract.
|
|
99
|
+
- **[Middleware](https://httpware.modern-python.org/middleware/)** — write your own (auth, tracing, request-ID propagation).
|
|
100
|
+
- **[Resilience](https://httpware.modern-python.org/resilience/)** — retry + retry-budget, bulkhead, circuit breaker, timeout.
|
|
101
|
+
- **[Errors](https://httpware.modern-python.org/errors/)** — the exception tree and catching strategies.
|
|
102
|
+
- **[Testing](https://httpware.modern-python.org/testing/)** — `httpx2.MockTransport` injection.
|
|
103
|
+
- **[Recipes](https://httpware.modern-python.org/recipes/modern-di/)** — DI wiring, phase-decorator patterns, link-header pagination.
|
|
104
|
+
|
|
105
|
+
## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases) · 📦 [PyPI](https://pypi.org/project/httpware) · 📝 [License](LICENSE)
|
|
106
|
+
|
|
107
|
+
## Part of `modern-python`
|
|
108
|
+
|
|
109
|
+
Browse the full list of templates and libraries in
|
|
110
|
+
[`modern-python`](https://github.com/modern-python) — see the org profile for the categorized index.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# httpware
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/httpware/)
|
|
4
|
+
[](https://pypi.org/project/httpware/)
|
|
5
|
+
[](https://pypistats.org/packages/httpware)
|
|
6
|
+
[](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
|
|
7
|
+
[](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
|
|
8
|
+
[](https://github.com/modern-python/httpware/blob/main/LICENSE)
|
|
9
|
+
[](https://github.com/modern-python/httpware/stargazers)
|
|
10
|
+
[](https://context7.com/modern-python/httpware)
|
|
11
|
+
[](https://github.com/astral-sh/uv)
|
|
12
|
+
[](https://github.com/astral-sh/ruff)
|
|
13
|
+
[](https://github.com/astral-sh/ty)
|
|
14
|
+
|
|
15
|
+
**A Python HTTP client framework with sync and async clients for building resilient service clients.**
|
|
16
|
+
|
|
17
|
+
## Why httpware
|
|
18
|
+
|
|
19
|
+
- **Typed errors, no `raise_for_status()`** — 4xx/5xx automatically raise a status-keyed exception tree (`NotFoundError`, `RateLimitedError`, …), all under `httpware.StatusError`.
|
|
20
|
+
- **Typed response bodies** — `response_model=YourType` decodes the body straight to your pydantic or msgspec model; a missing decoder fails fast, *before* the request goes out.
|
|
21
|
+
- **Production resilience as composable middleware** — retry + retry-budget, bulkhead, circuit breaker, and timeout, composed at construction — all over standard `httpx2`.
|
|
22
|
+
|
|
23
|
+
Built on `httpx2`: httpware re-exports `httpx2.Request`/`httpx2.Response` and stays a thin wrapper, not a new HTTP abstraction.
|
|
24
|
+
|
|
25
|
+
> **Status:** Pre-1.0. Public API is subject to change between minor releases until v1.0.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install httpware # core only — no decoder
|
|
31
|
+
pip install httpware[pydantic] # + PydanticDecoder — BaseModel, dataclasses, primitives, generics
|
|
32
|
+
pip install httpware[msgspec] # + MsgspecDecoder — Struct, dataclasses, primitives, generics
|
|
33
|
+
pip install httpware[pydantic,msgspec] # both — BaseModel routes to pydantic, Struct to msgspec
|
|
34
|
+
pip install httpware[all] # everything (pydantic, msgspec, otel)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
A typed GET against a live API (needs `pip install httpware[pydantic]`):
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import asyncio
|
|
43
|
+
|
|
44
|
+
from httpware import AsyncClient
|
|
45
|
+
from pydantic import BaseModel
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class User(BaseModel):
|
|
49
|
+
id: int
|
|
50
|
+
name: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def main() -> None:
|
|
54
|
+
async with AsyncClient(base_url="https://jsonplaceholder.typicode.com") as client:
|
|
55
|
+
user = await client.get("/users/1", response_model=User)
|
|
56
|
+
print(user.name) # Leanne Graham
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
asyncio.run(main())
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The sync `Client` is identical — swap `AsyncClient` → `Client` and drop the `await` / `async with`. A 4xx/5xx response raises a typed `StatusError`; a malformed body raises `DecodeError`. Both subclass `httpware.ClientError`.
|
|
63
|
+
|
|
64
|
+
## Documentation
|
|
65
|
+
|
|
66
|
+
Full guides live at **[httpware.modern-python.org](https://httpware.modern-python.org)**:
|
|
67
|
+
|
|
68
|
+
- **[Quickstart & observability](https://httpware.modern-python.org/)** — resilience middleware, streaming, and the stable logger/event contract.
|
|
69
|
+
- **[Middleware](https://httpware.modern-python.org/middleware/)** — write your own (auth, tracing, request-ID propagation).
|
|
70
|
+
- **[Resilience](https://httpware.modern-python.org/resilience/)** — retry + retry-budget, bulkhead, circuit breaker, timeout.
|
|
71
|
+
- **[Errors](https://httpware.modern-python.org/errors/)** — the exception tree and catching strategies.
|
|
72
|
+
- **[Testing](https://httpware.modern-python.org/testing/)** — `httpx2.MockTransport` injection.
|
|
73
|
+
- **[Recipes](https://httpware.modern-python.org/recipes/modern-di/)** — DI wiring, phase-decorator patterns, link-header pagination.
|
|
74
|
+
|
|
75
|
+
## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases) · 📦 [PyPI](https://pypi.org/project/httpware) · 📝 [License](LICENSE)
|
|
76
|
+
|
|
77
|
+
## Part of `modern-python`
|
|
78
|
+
|
|
79
|
+
Browse the full list of templates and libraries in
|
|
80
|
+
[`modern-python`](https://github.com/modern-python) — see the org profile for the categorized index.
|
|
@@ -26,7 +26,7 @@ classifiers = [
|
|
|
26
26
|
"Topic :: Internet :: WWW/HTTP",
|
|
27
27
|
"Framework :: AsyncIO",
|
|
28
28
|
]
|
|
29
|
-
version = "0.
|
|
29
|
+
version = "0.12.0"
|
|
30
30
|
dependencies = [
|
|
31
31
|
"httpx2>=2.0.0,<3.0",
|
|
32
32
|
]
|
|
@@ -97,4 +97,4 @@ asyncio_default_fixture_loop_scope = "function"
|
|
|
97
97
|
|
|
98
98
|
[tool.coverage]
|
|
99
99
|
run.concurrency = ["thread"]
|
|
100
|
-
report.exclude_also = ["if typing.TYPE_CHECKING:"]
|
|
100
|
+
report.exclude_also = ["if typing.TYPE_CHECKING:", 'pytest\.fail\(']
|
|
@@ -17,6 +17,7 @@ from httpware.errors import (
|
|
|
17
17
|
NetworkError,
|
|
18
18
|
NotFoundError,
|
|
19
19
|
RateLimitedError,
|
|
20
|
+
ResponseTooLargeError,
|
|
20
21
|
RetryBudgetExhaustedError,
|
|
21
22
|
ServerStatusError,
|
|
22
23
|
ServiceUnavailableError,
|
|
@@ -78,6 +79,7 @@ __all__ = [
|
|
|
78
79
|
"NotFoundError",
|
|
79
80
|
"RateLimitedError",
|
|
80
81
|
"ResponseDecoder",
|
|
82
|
+
"ResponseTooLargeError",
|
|
81
83
|
"Retry",
|
|
82
84
|
"RetryBudget",
|
|
83
85
|
"RetryBudgetExhaustedError",
|
|
@@ -13,6 +13,7 @@ import logging
|
|
|
13
13
|
import typing
|
|
14
14
|
|
|
15
15
|
from httpware._internal import import_checker
|
|
16
|
+
from httpware._internal.redaction import redact_url
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
def _emit_event(
|
|
@@ -25,6 +26,12 @@ def _emit_event(
|
|
|
25
26
|
) -> None:
|
|
26
27
|
"""Emit one observability event to both channels.
|
|
27
28
|
|
|
29
|
+
The ``url`` attribute, when present, is run through
|
|
30
|
+
``redaction.redact_url`` here — at the single emission boundary — so a
|
|
31
|
+
request URL's userinfo and known-sensitive query/fragment secrets never
|
|
32
|
+
reach a log record or span event, regardless of how a caller built the
|
|
33
|
+
attributes dict.
|
|
34
|
+
|
|
28
35
|
1. Always emits a structured log record at ``level`` with ``extra=attributes``
|
|
29
36
|
(so log aggregators that index ``extra`` see structured fields).
|
|
30
37
|
2. If ``opentelemetry-api`` is installed, calls
|
|
@@ -39,7 +46,9 @@ def _emit_event(
|
|
|
39
46
|
the optional-extras isolation invariant: ``import httpware`` must not pull
|
|
40
47
|
``opentelemetry`` into ``sys.modules`` when the extra is absent.
|
|
41
48
|
"""
|
|
42
|
-
|
|
49
|
+
raw_url = attributes.get("url")
|
|
50
|
+
safe_attributes = {**attributes, "url": redact_url(raw_url)} if isinstance(raw_url, str) else attributes
|
|
51
|
+
logger.log(level, message, extra={**safe_attributes, "event": event_name})
|
|
43
52
|
if import_checker.is_otel_installed:
|
|
44
53
|
try:
|
|
45
54
|
from opentelemetry import trace # noqa: PLC0415 — lazy by design (optional-extras isolation)
|
|
@@ -52,4 +61,4 @@ def _emit_event(
|
|
|
52
61
|
# The structured log record above has already fired; CancelledError/KeyboardInterrupt
|
|
53
62
|
# are not Exception subclasses and will still propagate.
|
|
54
63
|
with contextlib.suppress(Exception):
|
|
55
|
-
trace.get_current_span().add_event(event_name, attributes=
|
|
64
|
+
trace.get_current_span().add_event(event_name, attributes=safe_attributes)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""URL sanitation for logs, telemetry, and error messages.
|
|
2
|
+
|
|
3
|
+
Strips ``user:pass@`` userinfo and masks the values of known-sensitive query
|
|
4
|
+
parameters so secrets embedded in URLs do not leak into observability output.
|
|
5
|
+
Shared by ``errors.py`` (StatusError messages) and the resilience middleware
|
|
6
|
+
(event attributes).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
SENSITIVE_QUERY_KEYS = frozenset(
|
|
13
|
+
{
|
|
14
|
+
"api_key",
|
|
15
|
+
"apikey",
|
|
16
|
+
"access_token",
|
|
17
|
+
"refresh_token",
|
|
18
|
+
"token",
|
|
19
|
+
"secret",
|
|
20
|
+
"client_secret",
|
|
21
|
+
"password",
|
|
22
|
+
"passwd",
|
|
23
|
+
"pwd",
|
|
24
|
+
"auth",
|
|
25
|
+
"authorization",
|
|
26
|
+
"sig",
|
|
27
|
+
"signature",
|
|
28
|
+
"key",
|
|
29
|
+
"private_key",
|
|
30
|
+
"session",
|
|
31
|
+
"sessionid",
|
|
32
|
+
"x-api-key",
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_REDACTED = "REDACTED"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _reassemble(scheme: str, netloc: str, path: str, query: str, fragment: str) -> str:
|
|
40
|
+
"""Like ``urlunsplit``, but avoid the spurious triple-slash for an empty authority.
|
|
41
|
+
|
|
42
|
+
``urlunsplit(("http", "", "/path", ...))`` yields ``http:///path`` for a
|
|
43
|
+
netloc-using scheme. When userinfo stripping leaves no host (e.g.
|
|
44
|
+
``http://user:pass@/path``) we want ``http:/path`` (scheme + path), not a
|
|
45
|
+
triple-slash. With a non-empty netloc this delegates to ``urlunsplit``, so
|
|
46
|
+
normal URLs are byte-identical.
|
|
47
|
+
"""
|
|
48
|
+
if netloc:
|
|
49
|
+
return urlunsplit((scheme, netloc, path, query, fragment))
|
|
50
|
+
tail = path
|
|
51
|
+
if query:
|
|
52
|
+
tail += "?" + query
|
|
53
|
+
if fragment:
|
|
54
|
+
tail += "#" + fragment
|
|
55
|
+
return f"{scheme}:{tail}" if scheme else tail
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _strip_userinfo(url: str) -> str:
|
|
59
|
+
if "@" not in url or "://" not in url:
|
|
60
|
+
return url
|
|
61
|
+
parts = urlsplit(url)
|
|
62
|
+
if parts.username is None and parts.password is None:
|
|
63
|
+
return url
|
|
64
|
+
# Strip the "user:pass@" prefix from the raw netloc to preserve host:port
|
|
65
|
+
# exactly (including IPv6 brackets), rather than reconstructing from parts.
|
|
66
|
+
netloc = parts.netloc.split("@", 1)[1] if "@" in parts.netloc else parts.netloc
|
|
67
|
+
return _reassemble(parts.scheme, netloc, parts.path, parts.query, parts.fragment)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _mask_component(component: str) -> tuple[str, bool]:
|
|
71
|
+
"""Mask sensitive key=value pairs in a query or fragment string.
|
|
72
|
+
|
|
73
|
+
Returns ``(masked_component, was_changed)``; when no sensitive key is
|
|
74
|
+
found the original string is returned unchanged (``was_changed=False``).
|
|
75
|
+
"""
|
|
76
|
+
pairs = parse_qsl(component, keep_blank_values=True)
|
|
77
|
+
if not any(key.strip().lower() in SENSITIVE_QUERY_KEYS for key, _ in pairs):
|
|
78
|
+
return component, False
|
|
79
|
+
masked = [(key, _REDACTED if key.strip().lower() in SENSITIVE_QUERY_KEYS else value) for key, value in pairs]
|
|
80
|
+
return urlencode(masked), True
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _mask_query(url: str) -> str:
|
|
84
|
+
parts = urlsplit(url)
|
|
85
|
+
has_query = bool(parts.query)
|
|
86
|
+
has_fragment = bool(parts.fragment)
|
|
87
|
+
|
|
88
|
+
if not has_query and not has_fragment:
|
|
89
|
+
return url
|
|
90
|
+
|
|
91
|
+
new_query = parts.query
|
|
92
|
+
new_fragment = parts.fragment
|
|
93
|
+
changed = False
|
|
94
|
+
|
|
95
|
+
if has_query:
|
|
96
|
+
new_query, q_changed = _mask_component(parts.query)
|
|
97
|
+
changed = changed or q_changed
|
|
98
|
+
|
|
99
|
+
if has_fragment:
|
|
100
|
+
new_fragment, f_changed = _mask_component(parts.fragment)
|
|
101
|
+
changed = changed or f_changed
|
|
102
|
+
|
|
103
|
+
if not changed:
|
|
104
|
+
return url # common-path guard: nothing sensitive, leave bytes untouched
|
|
105
|
+
|
|
106
|
+
return _reassemble(parts.scheme, parts.netloc, parts.path, new_query, new_fragment)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def redact_url(url: str) -> str:
|
|
110
|
+
"""Return ``url`` safe for logs/telemetry/errors.
|
|
111
|
+
|
|
112
|
+
Userinfo is stripped and the values of known-sensitive query parameters are
|
|
113
|
+
replaced with ``REDACTED`` (keys preserved). URLs with no sensitive query
|
|
114
|
+
key are returned byte-identical to the userinfo-stripped input.
|
|
115
|
+
"""
|
|
116
|
+
return _mask_query(_strip_userinfo(url))
|
|
@@ -29,19 +29,24 @@ def _raise_on_status_error(response: httpx2.Response) -> None:
|
|
|
29
29
|
raise exc_class(response)
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
def _is_replayable_type(value: object) -> bool:
|
|
33
|
+
"""Return True if value is a replayable type (safe to replay across retry attempts)."""
|
|
34
|
+
return isinstance(value, (bytes, bytearray, memoryview, str, dict, list, tuple))
|
|
35
|
+
|
|
36
|
+
|
|
32
37
|
def _is_streaming_body_async(value: object) -> bool:
|
|
33
|
-
"""Return True if value is
|
|
38
|
+
"""Return True if value is a non-replayable body (async-iterable or sync non-replayable iterable)."""
|
|
34
39
|
if value is None:
|
|
35
40
|
return False
|
|
36
|
-
if
|
|
41
|
+
if _is_replayable_type(value):
|
|
37
42
|
return False
|
|
38
|
-
return hasattr(value, "__aiter__")
|
|
43
|
+
return hasattr(value, "__aiter__") or hasattr(value, "__iter__")
|
|
39
44
|
|
|
40
45
|
|
|
41
46
|
def _is_streaming_body_sync(value: object) -> bool:
|
|
42
47
|
"""Return True if value is a sync iterable body that cannot be safely replayed for retry."""
|
|
43
48
|
if value is None:
|
|
44
49
|
return False
|
|
45
|
-
if
|
|
50
|
+
if _is_replayable_type(value):
|
|
46
51
|
return False
|
|
47
52
|
return hasattr(value, "__iter__")
|