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.
Files changed (27) hide show
  1. httpware-0.12.0/PKG-INFO +110 -0
  2. httpware-0.12.0/README.md +80 -0
  3. {httpware-0.10.1 → httpware-0.12.0}/pyproject.toml +2 -2
  4. {httpware-0.10.1 → httpware-0.12.0}/src/httpware/__init__.py +2 -0
  5. {httpware-0.10.1 → httpware-0.12.0}/src/httpware/_internal/observability.py +11 -2
  6. httpware-0.12.0/src/httpware/_internal/redaction.py +116 -0
  7. {httpware-0.10.1 → httpware-0.12.0}/src/httpware/_internal/status.py +9 -4
  8. {httpware-0.10.1 → httpware-0.12.0}/src/httpware/client.py +520 -7
  9. {httpware-0.10.1 → httpware-0.12.0}/src/httpware/decoders/msgspec.py +2 -0
  10. {httpware-0.10.1 → httpware-0.12.0}/src/httpware/decoders/pydantic.py +12 -4
  11. {httpware-0.10.1 → httpware-0.12.0}/src/httpware/errors.py +48 -21
  12. {httpware-0.10.1 → httpware-0.12.0}/src/httpware/middleware/__init__.py +13 -0
  13. {httpware-0.10.1 → httpware-0.12.0}/src/httpware/middleware/resilience/_backoff.py +8 -5
  14. {httpware-0.10.1 → httpware-0.12.0}/src/httpware/middleware/resilience/budget.py +8 -4
  15. {httpware-0.10.1 → httpware-0.12.0}/src/httpware/middleware/resilience/retry.py +37 -35
  16. httpware-0.10.1/PKG-INFO +0 -181
  17. httpware-0.10.1/README.md +0 -151
  18. {httpware-0.10.1 → httpware-0.12.0}/src/httpware/_internal/__init__.py +0 -0
  19. {httpware-0.10.1 → httpware-0.12.0}/src/httpware/_internal/exception_mapping.py +0 -0
  20. {httpware-0.10.1 → httpware-0.12.0}/src/httpware/_internal/import_checker.py +0 -0
  21. {httpware-0.10.1 → httpware-0.12.0}/src/httpware/decoders/__init__.py +0 -0
  22. {httpware-0.10.1 → httpware-0.12.0}/src/httpware/middleware/chain.py +0 -0
  23. {httpware-0.10.1 → httpware-0.12.0}/src/httpware/middleware/resilience/__init__.py +0 -0
  24. {httpware-0.10.1 → httpware-0.12.0}/src/httpware/middleware/resilience/bulkhead.py +0 -0
  25. {httpware-0.10.1 → httpware-0.12.0}/src/httpware/middleware/resilience/circuit_breaker.py +0 -0
  26. {httpware-0.10.1 → httpware-0.12.0}/src/httpware/middleware/resilience/timeout.py +0 -0
  27. {httpware-0.10.1 → httpware-0.12.0}/src/httpware/py.typed +0 -0
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/httpware.svg)](https://pypi.org/project/httpware/)
34
+ [![Supported Python versions](https://img.shields.io/pypi/pyversions/httpware.svg)](https://pypi.org/project/httpware/)
35
+ [![Downloads](https://img.shields.io/pypi/dm/httpware.svg)](https://pypistats.org/packages/httpware)
36
+ [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
37
+ [![CI](https://github.com/modern-python/httpware/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
38
+ [![License](https://img.shields.io/github/license/modern-python/httpware.svg)](https://github.com/modern-python/httpware/blob/main/LICENSE)
39
+ [![GitHub stars](https://img.shields.io/github/stars/modern-python/httpware)](https://github.com/modern-python/httpware/stargazers)
40
+ [![Context7](https://img.shields.io/badge/Context7-docs-blue)](https://context7.com/modern-python/httpware)
41
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
42
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
43
+ [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](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
+ [![PyPI version](https://img.shields.io/pypi/v/httpware.svg)](https://pypi.org/project/httpware/)
4
+ [![Supported Python versions](https://img.shields.io/pypi/pyversions/httpware.svg)](https://pypi.org/project/httpware/)
5
+ [![Downloads](https://img.shields.io/pypi/dm/httpware.svg)](https://pypistats.org/packages/httpware)
6
+ [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
7
+ [![CI](https://github.com/modern-python/httpware/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
8
+ [![License](https://img.shields.io/github/license/modern-python/httpware.svg)](https://github.com/modern-python/httpware/blob/main/LICENSE)
9
+ [![GitHub stars](https://img.shields.io/github/stars/modern-python/httpware)](https://github.com/modern-python/httpware/stargazers)
10
+ [![Context7](https://img.shields.io/badge/Context7-docs-blue)](https://context7.com/modern-python/httpware)
11
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
12
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
13
+ [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](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.10.1"
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
- logger.log(level, message, extra={**attributes, "event": event_name})
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=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 an async-iterable that cannot be safely replayed for retry."""
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 isinstance(value, (bytes, bytearray, memoryview, str, dict)):
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 isinstance(value, (bytes, bytearray, memoryview, str, dict, list, tuple)):
50
+ if _is_replayable_type(value):
46
51
  return False
47
52
  return hasattr(value, "__iter__")