httpunk 0.2.1__tar.gz → 0.2.2__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 (134) hide show
  1. {httpunk-0.2.1 → httpunk-0.2.2}/Cargo.lock +1 -1
  2. {httpunk-0.2.1 → httpunk-0.2.2}/Cargo.toml +1 -1
  3. {httpunk-0.2.1 → httpunk-0.2.2}/PKG-INFO +1 -1
  4. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/_backend/asyncio.py +16 -5
  5. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/exceptions.py +39 -0
  6. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/h1/client.py +28 -14
  7. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/h2/client.py +19 -11
  8. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/h2/connection.py +7 -1
  9. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/h2/server.py +2 -1
  10. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/h2/share.py +5 -1
  11. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/h2/streams.py +3 -3
  12. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/util/pool.py +4 -1
  13. httpunk-0.2.2/tests/test_error_retention.py +183 -0
  14. {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_h1_server.py +3 -1
  15. {httpunk-0.2.1 → httpunk-0.2.2}/LICENSE +0 -0
  16. {httpunk-0.2.1 → httpunk-0.2.2}/README.md +0 -0
  17. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/Cargo.toml +0 -0
  18. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/LICENSE +0 -0
  19. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/UPSTREAM_VERSION +0 -0
  20. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/codec/error.rs +0 -0
  21. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/codec/mod.rs +0 -0
  22. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/ext.rs +0 -0
  23. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/frame/data.rs +0 -0
  24. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/frame/go_away.rs +0 -0
  25. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/frame/head.rs +0 -0
  26. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/frame/headers.rs +0 -0
  27. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/frame/mod.rs +0 -0
  28. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/frame/ping.rs +0 -0
  29. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/frame/priority.rs +0 -0
  30. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/frame/reason.rs +0 -0
  31. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/frame/reset.rs +0 -0
  32. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/frame/settings.rs +0 -0
  33. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/frame/stream_id.rs +0 -0
  34. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/frame/util.rs +0 -0
  35. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/frame/window_update.rs +0 -0
  36. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/decoder.rs +0 -0
  37. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/encoder.rs +0 -0
  38. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/header.rs +0 -0
  39. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/huffman/mod.rs +0 -0
  40. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/huffman/table.rs +0 -0
  41. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/mod.rs +0 -0
  42. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/hpack/table.rs +0 -0
  43. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/lib.rs +0 -0
  44. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/proto/error.rs +0 -0
  45. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/proto/mod.rs +0 -0
  46. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/proto/streams/flow_control.rs +0 -0
  47. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/proto/streams/mod.rs +0 -0
  48. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-h2/src/proto/streams/state.rs +0 -0
  49. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/Cargo.toml +0 -0
  50. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/LICENSE +0 -0
  51. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/UPSTREAM_VERSION +0 -0
  52. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/body/length.rs +0 -0
  53. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/body/mod.rs +0 -0
  54. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/cfg.rs +0 -0
  55. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/common/date.rs +0 -0
  56. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/common/mod.rs +0 -0
  57. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/error.rs +0 -0
  58. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/ext/h1_reason_phrase.rs +0 -0
  59. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/ext/informational.rs +0 -0
  60. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/ext/mod.rs +0 -0
  61. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/headers.rs +0 -0
  62. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/lib.rs +0 -0
  63. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/h1/decode.rs +0 -0
  64. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/h1/encode.rs +0 -0
  65. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/h1/httpunk.rs +0 -0
  66. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/h1/io.rs +0 -0
  67. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/h1/mod.rs +0 -0
  68. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/h1/role.rs +0 -0
  69. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/proto/mod.rs +0 -0
  70. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper/src/trace.rs +0 -0
  71. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper-util/Cargo.toml +0 -0
  72. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper-util/LICENSE +0 -0
  73. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper-util/UPSTREAM_VERSION +0 -0
  74. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper-util/src/client/mod.rs +0 -0
  75. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper-util/src/client/proxy/matcher.rs +0 -0
  76. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper-util/src/client/proxy/mod.rs +0 -0
  77. {httpunk-0.2.1 → httpunk-0.2.2}/crates/vendor-hyper-util/src/lib.rs +0 -0
  78. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/__init__.py +0 -0
  79. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/_backend/__init__.py +0 -0
  80. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/_backend/tonio.py +0 -0
  81. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/_common.py +0 -0
  82. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/_httpunk.pyi +0 -0
  83. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/asyncio.py +0 -0
  84. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/h1/__init__.py +0 -0
  85. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/h1/connection.py +0 -0
  86. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/h1/server.py +0 -0
  87. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/h1/share.py +0 -0
  88. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/h2/__init__.py +0 -0
  89. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/h2/settings.py +0 -0
  90. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/h2/stream.py +0 -0
  91. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/http.py +0 -0
  92. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/py.typed +0 -0
  93. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/types.py +0 -0
  94. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/util/__init__.py +0 -0
  95. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/util/auto.py +0 -0
  96. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/util/client.py +0 -0
  97. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/util/graceful.py +0 -0
  98. {httpunk-0.2.1 → httpunk-0.2.2}/httpunk/util/proxy.py +0 -0
  99. {httpunk-0.2.1 → httpunk-0.2.2}/pyproject.toml +0 -0
  100. {httpunk-0.2.1 → httpunk-0.2.2}/src/errors.rs +0 -0
  101. {httpunk-0.2.1 → httpunk-0.2.2}/src/h1/codec.rs +0 -0
  102. {httpunk-0.2.1 → httpunk-0.2.2}/src/h1/mod.rs +0 -0
  103. {httpunk-0.2.1 → httpunk-0.2.2}/src/h2/codec.rs +0 -0
  104. {httpunk-0.2.1 → httpunk-0.2.2}/src/h2/mod.rs +0 -0
  105. {httpunk-0.2.1 → httpunk-0.2.2}/src/h2/streams.rs +0 -0
  106. {httpunk-0.2.1 → httpunk-0.2.2}/src/http/mod.rs +0 -0
  107. {httpunk-0.2.1 → httpunk-0.2.2}/src/lib.rs +0 -0
  108. {httpunk-0.2.1 → httpunk-0.2.2}/src/proxy.rs +0 -0
  109. {httpunk-0.2.1 → httpunk-0.2.2}/tests/_client.py +0 -0
  110. {httpunk-0.2.1 → httpunk-0.2.2}/tests/conftest.py +0 -0
  111. {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_asyncio_backend.py +0 -0
  112. {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_asyncio_client_protocol.py +0 -0
  113. {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_asyncio_e2e.py +0 -0
  114. {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_asyncio_protocol.py +0 -0
  115. {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_backend.py +0 -0
  116. {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_h1_codec.py +0 -0
  117. {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_h1_conn.py +0 -0
  118. {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_h1_decode.py +0 -0
  119. {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_h2_codec.py +0 -0
  120. {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_h2_conn.py +0 -0
  121. {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_h2_errors.py +0 -0
  122. {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_h2_flow.py +0 -0
  123. {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_h2_flow_control.py +0 -0
  124. {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_h2_ready.py +0 -0
  125. {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_h2_server.py +0 -0
  126. {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_h2_settings.py +0 -0
  127. {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_h2_state.py +0 -0
  128. {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_headermap.py +0 -0
  129. {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_util_auto.py +0 -0
  130. {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_util_connect.py +0 -0
  131. {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_util_graceful.py +0 -0
  132. {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_util_pool.py +0 -0
  133. {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_util_proxy.py +0 -0
  134. {httpunk-0.2.1 → httpunk-0.2.2}/tests/test_util_tls.py +0 -0
@@ -118,7 +118,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
118
118
 
119
119
  [[package]]
120
120
  name = "httpunk"
121
- version = "0.2.1"
121
+ version = "0.2.2"
122
122
  dependencies = [
123
123
  "bytes",
124
124
  "h2",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "httpunk"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "The Rust HTTP library for Python"
5
5
  authors = [
6
6
  "Giovanni Barillari <g@baro.dev>",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: httpunk
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: BSD License
@@ -20,6 +20,8 @@ import errno as _errno
20
20
  import ssl as _ssl
21
21
  import time as _time
22
22
 
23
+ from ..exceptions import fresh_exc as _fresh_exc
24
+
23
25
 
24
26
  _READ_HIGH_WATER = 2**16 # 64 KiB — pause reading past this (matches StreamReader's default limit)
25
27
 
@@ -65,7 +67,12 @@ class _AsyncioStream(asyncio.Protocol):
65
67
  return True
66
68
 
67
69
  def connection_lost(self, exc):
68
- self._error = exc
70
+ # Store the error PRISTINE (no traceback) and only ever raise copies of
71
+ # it (below): this object outlives the failure, and a stored exception
72
+ # instance re-raised through the caller's stack would accumulate those
73
+ # frames onto its `__traceback__` — a refcount-invisible cycle pinning
74
+ # everything the frames reference until a gen-2 GC (exceptions.fresh_exc).
75
+ self._error = exc.with_traceback(None) if exc is not None else None
69
76
  self._eof = True
70
77
  self._conn_lost = True # writes to a dead socket must now raise (F32)
71
78
  self._wake_reader()
@@ -74,7 +81,7 @@ class _AsyncioStream(asyncio.Protocol):
74
81
  if exc is None:
75
82
  self._drain_waiter.set_result(None)
76
83
  else:
77
- self._drain_waiter.set_exception(exc)
84
+ self._drain_waiter.set_exception(_fresh_exc(exc))
78
85
 
79
86
  def pause_writing(self):
80
87
  self._writing_paused = True
@@ -94,7 +101,9 @@ class _AsyncioStream(asyncio.Protocol):
94
101
  return self._take(max_bytes)
95
102
  if self._eof:
96
103
  if self._error is not None:
97
- raise self._error
104
+ # A fresh copy per raise — never the stored instance (see
105
+ # connection_lost / exceptions.fresh_exc).
106
+ raise _fresh_exc(self._error) from self._error
98
107
  return b""
99
108
  if self._read_waiter is not None:
100
109
  # Single-reader contract: a second concurrent receive_some would overwrite
@@ -110,7 +119,7 @@ class _AsyncioStream(asyncio.Protocol):
110
119
  if self._buffer:
111
120
  return self._take(max_bytes)
112
121
  if self._error is not None:
113
- raise self._error
122
+ raise _fresh_exc(self._error) from self._error
114
123
  return b""
115
124
 
116
125
  async def send_all(self, data):
@@ -119,7 +128,9 @@ class _AsyncioStream(asyncio.Protocol):
119
128
  # EPIPE/ECONNRESET — drivers detect a dead peer via that failure. Surface the
120
129
  # real error, or EPIPE if the close carried none (a clean peer FIN).
121
130
  if self._conn_lost:
122
- raise self._error or BrokenPipeError(_errno.EPIPE, "connection lost")
131
+ if self._error is not None:
132
+ raise _fresh_exc(self._error) from self._error
133
+ raise BrokenPipeError(_errno.EPIPE, "connection lost")
123
134
  self._transport.write(data)
124
135
  if self._writing_paused: # transport buffer over high-water — wait for resume (drain)
125
136
  if self._drain_waiter is None:
@@ -30,6 +30,8 @@ may have begun.
30
30
 
31
31
  from __future__ import annotations
32
32
 
33
+ import copy as _copy
34
+
33
35
  from ._httpunk import (
34
36
  ConnectionClosedError as ConnectionClosedError,
35
37
  H2Error as H2Error,
@@ -49,6 +51,34 @@ def _reason(code):
49
51
  return code # unknown/experimental error code — keep the raw int
50
52
 
51
53
 
54
+ def fresh_exc(exc: BaseException) -> BaseException:
55
+ """A fresh, traceback-free copy of `exc`, for storing or re-raising a saved
56
+ connection/stream error.
57
+
58
+ Runtime-forced divergence from hyper: hyper shares a connection's fatal error
59
+ by cloning an `Arc<Error>`; a Python exception instance instead accumulates
60
+ every propagation's frames onto its `__traceback__`, and a traceback's frames
61
+ keep their locals alive — so a caught exception stored on a long-lived object
62
+ (conn, stream, backend transport) and re-raised as the same shared instance
63
+ pins the entire frame graph of every propagation in a reference cycle only
64
+ cyclic GC can free (observed as stalled-until-gen-2-GC connections downstream).
65
+ The rule throughout httpunk: **store copies, raise copies** — store
66
+ `fresh_exc(exc)` (or strip a consumed-once hand-off in place), and re-raise
67
+ stored errors as `raise fresh_exc(err) from err`, so no stored instance ever
68
+ carries frames.
69
+
70
+ Copies via `copy.copy`: `BaseException.__reduce__` rebuilds from type + args
71
+ and restores `__dict__` extras (e.g. `request_unsent`); `GoAwayError` /
72
+ `StreamResetError` define `__reduce__` because their args don't roundtrip
73
+ through their constructors. The copy carries no `__traceback__` / `__cause__` /
74
+ `__context__`. An uncopyable exception is returned as-is — degraded (shared
75
+ instance) but functional."""
76
+ try:
77
+ return _copy.copy(exc)
78
+ except Exception:
79
+ return exc
80
+
81
+
52
82
  class GoAwayError(H2Error):
53
83
  """The peer sent GOAWAY. Streams with id > `last_stream_id` were not
54
84
  processed and are safe to retry on a new connection."""
@@ -63,6 +93,11 @@ class GoAwayError(H2Error):
63
93
  self.debug_data = debug_data
64
94
  super().__init__(f"GOAWAY(last_stream_id={last_stream_id}, error_code={self.error_code!r})")
65
95
 
96
+ def __reduce__(self):
97
+ # `args` holds the formatted message, not the constructor params — rebuild
98
+ # from the real params so `copy.copy`/pickle (and `fresh_exc`) work.
99
+ return (type(self), (self.last_stream_id, int(self.error_code), self.debug_data), self.__dict__)
100
+
66
101
 
67
102
  class StreamResetError(H2Error):
68
103
  """The peer sent RST_STREAM for this stream."""
@@ -74,3 +109,7 @@ class StreamResetError(H2Error):
74
109
  self.stream_id = stream_id
75
110
  self.error_code = _reason(error_code)
76
111
  super().__init__(f"RST_STREAM(stream_id={stream_id}, error_code={self.error_code!r})")
112
+
113
+ def __reduce__(self):
114
+ # See GoAwayError.__reduce__.
115
+ return (type(self), (self.stream_id, int(self.error_code)), self.__dict__)
@@ -18,7 +18,7 @@ from typing import TYPE_CHECKING, Any
18
18
 
19
19
  from .._common import BaseClientConnection
20
20
  from .._httpunk import H1BodyDecoder, H1Codec
21
- from ..exceptions import ConnectionClosedError
21
+ from ..exceptions import ConnectionClosedError, fresh_exc
22
22
  from ..http import HeaderMap
23
23
  from ..types import Response
24
24
  from .connection import H1ConnectionBase
@@ -226,7 +226,11 @@ class Connection(H1ConnectionBase):
226
226
  data = await transport.receive_some(65536)
227
227
  except Exception as exc:
228
228
  if self._exchange_active:
229
- self._watcher_error = exc # surfaced by _read_head's first read
229
+ # Consumed-once hand-off (nulled at the `_read_head` take, so raising
230
+ # the instance there is safe) — but strip the watcher frames now: while
231
+ # parked here it would otherwise pin this conn/transport in a cycle
232
+ # (exceptions.fresh_exc). Surfaced by _read_head's first read.
233
+ self._watcher_error = exc.with_traceback(None)
230
234
  elif not self._closed:
231
235
  self._fail(exc)
232
236
  return
@@ -248,7 +252,7 @@ class Connection(H1ConnectionBase):
248
252
  await self._acquire()
249
253
  self._slot.release()
250
254
  if self.error is not None:
251
- raise self.error
255
+ raise fresh_exc(self.error) from self.error # a copy per raise (exceptions.fresh_exc)
252
256
  if self._closed:
253
257
  raise ConnectionClosedError("connection closed")
254
258
 
@@ -271,9 +275,11 @@ class Connection(H1ConnectionBase):
271
275
  if self._closed or self.error is not None:
272
276
  self._busy = False
273
277
  self._slot.release()
274
- exc = self.error or ConnectionClosedError("connection closed")
278
+ # The flag rides the raised copy, not the stored error: it holds for
279
+ # THIS give-back, while a stored flag would leak onto unrelated raises.
280
+ exc = fresh_exc(self.error) if self.error is not None else ConnectionClosedError("connection closed")
275
281
  exc.request_unsent = True
276
- raise exc
282
+ raise exc from self.error
277
283
  # `require_empty_read`'s buffered-bytes fast path at send time (conn.rs
278
284
  # L463-465; regression test client_read_bytes_before_writing_request):
279
285
  # a server may not send anything before the client's next request, and
@@ -291,8 +297,9 @@ class Connection(H1ConnectionBase):
291
297
  self.poison_unexpected(len(pending)) # records the error AND closes
292
298
  self._busy = False
293
299
  self._slot.release()
294
- self.error.request_unsent = True
295
- raise self.error
300
+ exc = fresh_exc(self.error)
301
+ exc.request_unsent = True
302
+ raise exc from self.error
296
303
  # The exchange starts NOW: the watcher is not stopped (its parked read
297
304
  # is never cancelled — see `_watch_idle`); this sync flag redirects its
298
305
  # completing read from the idle rules to the exchange, whose `_read_head`
@@ -438,7 +445,7 @@ class Connection(H1ConnectionBase):
438
445
  # for `_read_head`, so DEFER — record it and surface it only if no head
439
446
  # arrives (EOF). Do NOT signal `body_failed`, or we'd race away that buffered
440
447
  # response (F11).
441
- write_error.append(exc)
448
+ write_error.append(exc.with_traceback(None)) # stored past this frame — strip (exceptions.fresh_exc)
442
449
  except Exception as exc:
443
450
  # A BODY-ITERABLE / framing error (the caller's body generator raised, or a
444
451
  # length mismatch): the request can't complete and no valid response is
@@ -447,7 +454,7 @@ class Connection(H1ConnectionBase):
447
454
  # on `receive_some` until the server times out (F12). The transport is closed
448
455
  # by `send_request`'s outer `_fail` once the writer is joined — closing it
449
456
  # here (inside the writer's scope) wedges that join.
450
- write_error.append(exc)
457
+ write_error.append(exc.with_traceback(None)) # stored past this frame — strip (exceptions.fresh_exc)
451
458
  body_failed.set()
452
459
 
453
460
  async def _read_head(self, codec, write_error=None):
@@ -477,15 +484,19 @@ class Connection(H1ConnectionBase):
477
484
  # dispatches are legitimate). Surface the recorded verdict —
478
485
  # the unexpected-bytes poison (hyper `new_unexpected_message`)
479
486
  # or the clean close — not a generic error that would mask it.
480
- raise self.error or ConnectionClosedError("connection closed")
487
+ if self.error is not None:
488
+ raise fresh_exc(self.error) from self.error
489
+ raise ConnectionClosedError("connection closed")
481
490
  while True:
482
491
  if data is None:
483
492
  data = await self.transport.receive_some(65536)
484
493
  if not data:
485
494
  # EOF before a full head. If the body write also failed (server
486
- # closed both directions), surface that as the cause.
495
+ # closed both directions), surface that as the cause. A copy — the
496
+ # instance stays in `write_error`, whose frames would join the
497
+ # raise's traceback (exceptions.fresh_exc).
487
498
  if write_error:
488
- raise write_error[0]
499
+ raise fresh_exc(write_error[0]) from write_error[0]
489
500
  raise ConnectionClosedError("connection closed before the response head")
490
501
  buffered += len(data)
491
502
  head = codec.receive_head(data)
@@ -509,7 +520,7 @@ class Connection(H1ConnectionBase):
509
520
  # NOT fire this (F11 — an early response may be buffered), so the normal path
510
521
  # always lets `_read_head` win the race.
511
522
  await body_failed.wait()
512
- raise write_error[0]
523
+ raise fresh_exc(write_error[0]) from write_error[0]
513
524
 
514
525
  def poison_unexpected(self, nbytes):
515
526
  """The server sent `nbytes` unsolicited bytes outside a response — an
@@ -577,7 +588,10 @@ class Connection(H1ConnectionBase):
577
588
  # inside an exchange (slot held, watcher joined at send_request entry)
578
589
  # or from the watcher itself.
579
590
  if self.error is None and not isinstance(exc, ConnectionClosedError):
580
- self.error = exc
591
+ # A stripped COPY: the caught instance keeps propagating to the caller
592
+ # and would accumulate every frame above onto a stored traceback
593
+ # (exceptions.fresh_exc).
594
+ self.error = fresh_exc(exc)
581
595
  self._closed = True
582
596
  self._close_transport()
583
597
 
@@ -23,7 +23,7 @@ from collections.abc import Awaitable
23
23
  from typing import TYPE_CHECKING, Any
24
24
 
25
25
  from .._common import BaseClientConnection
26
- from ..exceptions import ConnectionClosedError, H2ProtocolError, H2Reason
26
+ from ..exceptions import ConnectionClosedError, H2ProtocolError, H2Reason, fresh_exc
27
27
  from ..types import Response
28
28
  from .connection import PREFACE, H2ConnectionBase
29
29
  from .settings import LocalSettings, Settings
@@ -119,12 +119,16 @@ class ClientStreamManager(StreamManager):
119
119
  # 9113 §8.2.2) — same early-validation rationale as the trailers check
120
120
  # in `send_request` (#925): a caller error, the connection stays usable.
121
121
  self.check_send_headers(headers)
122
+ # All the stored-error raises below raise a COPY per consumer — many streams
123
+ # surface the one stored `_conn.error`/`_goaway`, and a shared raised
124
+ # instance would accumulate every caller's frames (exceptions.fresh_exc).
122
125
  if self._goaway is not None:
123
- raise self._goaway
126
+ raise fresh_exc(self._goaway) from self._goaway
124
127
  await self._acquire_stream_slot() # blocks on the limit; increments the count
125
128
  if self._conn.error is not None or self._goaway is not None:
126
129
  self._release_count()
127
- raise self._conn.error or self._goaway
130
+ src = self._conn.error or self._goaway
131
+ raise fresh_exc(src) from src
128
132
  async with self._new_stream_lock:
129
133
  stream_id = self._next_id
130
134
  if stream_id > _MAX_STREAM_ID:
@@ -152,8 +156,9 @@ class ClientStreamManager(StreamManager):
152
156
  # missed it. Re-check and abort our own stream (idempotent) so a caller
153
157
  # doesn't wait forever on a response head that will never arrive.
154
158
  if self._conn.error is not None or self._goaway is not None:
155
- self._abort_stream(st, self._conn.error or self._goaway)
156
- raise self._conn.error or self._goaway
159
+ src = self._conn.error or self._goaway
160
+ self._abort_stream(st, src)
161
+ raise fresh_exc(src) from src
157
162
  return st
158
163
 
159
164
  # ===== MAX_CONCURRENT_STREAMS (h2: proto/streams/counts.rs) =====
@@ -186,9 +191,9 @@ class ClientStreamManager(StreamManager):
186
191
  # to free a slot, which may never happen (F20). `_on_go_away`/`_on_fail`
187
192
  # set `_slot_evt` to wake us for this re-check.
188
193
  if self._goaway is not None:
189
- raise self._goaway
194
+ raise fresh_exc(self._goaway) from self._goaway
190
195
  if self._conn.error is not None:
191
- raise self._conn.error
196
+ raise fresh_exc(self._conn.error) from self._conn.error
192
197
  await self._slot_evt.wait()
193
198
 
194
199
  def _release_count(self):
@@ -214,9 +219,9 @@ class ClientStreamManager(StreamManager):
214
219
  # existing conn_error (streams.rs L879). Also keeps this consistent with
215
220
  # `open_stream`, which checks `_goaway` first (F20).
216
221
  if self._goaway is not None:
217
- raise self._goaway
222
+ raise fresh_exc(self._goaway) from self._goaway
218
223
  if self._conn.error is not None:
219
- raise self._conn.error
224
+ raise fresh_exc(self._conn.error) from self._conn.error
220
225
  # Non-reserving: just observe capacity (a racy read is fine — we don't
221
226
  # increment; a concurrent open re-applies the gate under the lock).
222
227
  if self._can_open():
@@ -310,7 +315,7 @@ class Connection(H2ConnectionBase):
310
315
  await self._begin(PREFACE, settings)
311
316
  await self._ready_evt.wait()
312
317
  if self.error is not None:
313
- raise self.error
318
+ raise fresh_exc(self.error) from self.error # a copy per raise (exceptions.fresh_exc)
314
319
 
315
320
  def _signal_ready(self):
316
321
  self._ready_evt.set()
@@ -439,7 +444,10 @@ class H2Connection(BaseClientConnection):
439
444
  # bug is a scheduling race where `send_request` re-checks only after the
440
445
  # connection has failed, which a quiet loopback never reproduces.)
441
446
  if stream.status is None: # woken by a reset/failure, not a real response head
442
- raise stream.error or self._conn.error or ConnectionClosedError("connection closed before response")
447
+ src = stream.error or self._conn.error
448
+ if src is not None:
449
+ raise fresh_exc(src) from src
450
+ raise ConnectionClosedError("connection closed before response")
443
451
  return Response(stream.status, stream.headers, H2ResponseBody(stream, self._conn.streams))
444
452
 
445
453
  def _resolve(self, target):
@@ -34,7 +34,7 @@ from .._httpunk import (
34
34
  H2FrameWindowUpdate as WindowUpdate,
35
35
  H2StreamError,
36
36
  )
37
- from ..exceptions import ConnectionClosedError, GoAwayError, H2Error, H2Reason
37
+ from ..exceptions import ConnectionClosedError, GoAwayError, H2Error, H2Reason, fresh_exc
38
38
  from .settings import Action
39
39
  from .streams import _StreamError
40
40
 
@@ -511,6 +511,12 @@ class H2ConnectionBase:
511
511
  def _fail(self, exc):
512
512
  # h2: proto/connection.rs `handle_poll2_result` (L430) -> the connection
513
513
  # error fans out to every stream via streams.rs `Streams::handle_error` (L362).
514
+ # Store/fan out a stripped COPY (h2 clones an Arc'd error the same way):
515
+ # some callers keep propagating the caught instance after this, which would
516
+ # accumulate their frames onto a stored traceback (exceptions.fresh_exc).
517
+ # Sharing the one copy across conn + streams is fine — raise sites raise
518
+ # copies of it, so its traceback never grows.
519
+ exc = fresh_exc(exc)
514
520
  self.error = exc
515
521
  self.streams.fail_all(exc)
516
522
  self._signal_ready() # unblock connect() if the handshake never completed (client); no-op (server)
@@ -37,6 +37,7 @@ from ..exceptions import (
37
37
  ConnectionClosedError,
38
38
  H2ProtocolError,
39
39
  H2Reason,
40
+ fresh_exc,
40
41
  )
41
42
  from ..http import HeaderMap
42
43
  from .connection import PREFACE, H2ConnectionBase
@@ -105,7 +106,7 @@ class ServerRequest:
105
106
  await self._manager.release_capacity(self._stream, len(chunk))
106
107
  yield chunk
107
108
  if self._stream.error is not None:
108
- raise self._stream.error
109
+ raise fresh_exc(self._stream.error) from self._stream.error # a copy per raise (exceptions.fresh_exc)
109
110
 
110
111
  def read(self) -> Awaitable[bytes]:
111
112
  return read_all(self.aiter_bytes())
@@ -10,6 +10,7 @@ Cross-reference: `h2 ...` comments cite hyperium/h2 v0.4.15.
10
10
  """
11
11
 
12
12
  from .._httpunk import H2Reason
13
+ from ..exceptions import fresh_exc
13
14
 
14
15
 
15
16
  class H2ResponseBody:
@@ -44,7 +45,10 @@ class H2ResponseBody:
44
45
  await self._manager.release_capacity(self._stream, len(chunk))
45
46
  yield chunk
46
47
  if self._stream.error is not None:
47
- raise self._stream.error
48
+ # A copy per raise — many readers may surface the one stored stream/conn
49
+ # error, and a shared raised instance would accumulate every consumer's
50
+ # frames onto its traceback (exceptions.fresh_exc).
51
+ raise fresh_exc(self._stream.error) from self._stream.error
48
52
 
49
53
  async def aclose(self):
50
54
  """Cancel the stream if its body wasn't fully read (sends RST_STREAM).
@@ -26,7 +26,7 @@ import threading
26
26
 
27
27
  from .._common import aiter_body
28
28
  from .._httpunk import H2FlowControl
29
- from ..exceptions import H2FlowControlError, H2ProtocolError, H2Reason, StreamResetError
29
+ from ..exceptions import H2FlowControlError, H2ProtocolError, H2Reason, StreamResetError, fresh_exc
30
30
  from .settings import PeerSettings
31
31
 
32
32
 
@@ -314,9 +314,9 @@ class StreamManager:
314
314
  # error to the parked sender). `_abort_stream`/`reset_stream`/
315
315
  # `recv_reset` set window_evt to wake us for this re-check.
316
316
  if st.error is not None:
317
- raise st.error
317
+ raise fresh_exc(st.error) from st.error # a copy per raise (exceptions.fresh_exc)
318
318
  if self._conn.error is not None:
319
- raise self._conn.error
319
+ raise fresh_exc(self._conn.error) from self._conn.error
320
320
  if st.state.is_closed():
321
321
  raise StreamResetError(st.id, int(H2Reason.CANCEL))
322
322
  with self._send_window_lock:
@@ -31,6 +31,7 @@ from typing import Any
31
31
  from urllib.parse import urlsplit
32
32
 
33
33
  from .. import _backend
34
+ from ..exceptions import fresh_exc
34
35
 
35
36
 
36
37
  class Canceled(Exception): # noqa: N818 - `Canceled` is hyper-util's exact name (singleton.rs)
@@ -89,7 +90,9 @@ class Singleton:
89
90
  except BaseException as exc:
90
91
  with self._lock:
91
92
  self._state = "empty" # ditch the round so the next get() retries
92
- self._error = exc
93
+ # A stripped copy — the caught instance keeps propagating below
94
+ # and this pool object is long-lived (exceptions.fresh_exc).
95
+ self._error = fresh_exc(exc)
93
96
  ready.set()
94
97
  raise
95
98
  with self._lock:
@@ -0,0 +1,183 @@
1
+ """The stored-error hygiene rules (`exceptions.fresh_exc`): saved connection/
2
+ stream errors are traceback-free copies, and every raise of a saved error is a
3
+ fresh copy chained to it — so no stored instance ever accumulates a consumer
4
+ stack's frames into a refcount-invisible cycle (the class of bug where a failed
5
+ connection's error pinned live transports until a gen-2 cyclic GC)."""
6
+
7
+ import gc
8
+ import platform
9
+ import weakref
10
+
11
+ import pytest
12
+
13
+ from httpunk._backend.asyncio import AsyncioBackend, _AsyncioStream
14
+ from httpunk.exceptions import GoAwayError, H2Reason, StreamResetError, fresh_exc
15
+ from httpunk.h1.client import Connection as H1Driver
16
+
17
+
18
+ # ----- fresh_exc -----
19
+
20
+
21
+ def _caught(exc):
22
+ """Raise + catch `exc` so it carries a traceback, like any stored real error."""
23
+ try:
24
+ raise exc
25
+ except type(exc) as e:
26
+ return e
27
+
28
+
29
+ def test_fresh_exc_copies_oserror_family():
30
+ err = _caught(ConnectionResetError(54, "Connection reset by peer"))
31
+ cp = fresh_exc(err)
32
+ assert cp is not err
33
+ assert type(cp) is ConnectionResetError
34
+ assert cp.args == err.args
35
+ assert (cp.errno, cp.strerror) == (54, "Connection reset by peer")
36
+ assert cp.__traceback__ is None # the copy starts frame-free
37
+ assert err.__traceback__ is not None # the original is untouched
38
+
39
+
40
+ def test_fresh_exc_carries_dict_extras():
41
+ err = ValueError("poisoned")
42
+ err.request_unsent = True
43
+ cp = fresh_exc(err)
44
+ assert cp is not err
45
+ assert cp.request_unsent is True
46
+
47
+
48
+ def test_fresh_exc_goaway_roundtrip():
49
+ # GoAwayError's args hold the formatted message, so copying needs its
50
+ # __reduce__ — with both a known (enum) and an unknown (raw int) error code.
51
+ for code in (int(H2Reason.ENHANCE_YOUR_CALM), 0x5EAF00D):
52
+ err = _caught(GoAwayError(7, code, b"bye"))
53
+ cp = fresh_exc(err)
54
+ assert cp is not err
55
+ assert type(cp) is GoAwayError
56
+ assert (cp.last_stream_id, cp.error_code, cp.debug_data) == (err.last_stream_id, err.error_code, b"bye")
57
+ assert str(cp) == str(err)
58
+ assert cp.__traceback__ is None
59
+
60
+
61
+ def test_fresh_exc_stream_reset_roundtrip():
62
+ err = _caught(StreamResetError(5, int(H2Reason.CANCEL)))
63
+ cp = fresh_exc(err)
64
+ assert cp is not err
65
+ assert (cp.stream_id, cp.error_code) == (5, H2Reason.CANCEL)
66
+ assert cp.__traceback__ is None
67
+
68
+
69
+ def test_fresh_exc_degrades_to_shared_instance():
70
+ class OddError(Exception):
71
+ def __init__(self, a, b): # args don't roundtrip (message-only super().__init__)
72
+ super().__init__(f"{a}-{b}")
73
+
74
+ err = OddError(1, 2)
75
+ assert fresh_exc(err) is err # uncopyable -> degraded but functional
76
+
77
+
78
+ # ----- the asyncio backend stream -----
79
+
80
+
81
+ @pytest.mark.asyncio
82
+ async def test_connection_lost_strips_stored_traceback():
83
+ s = _AsyncioStream()
84
+ s.connection_made(None)
85
+ s.connection_lost(_caught(ConnectionResetError(54, "peer reset")))
86
+ assert s._error.__traceback__ is None
87
+
88
+
89
+ @pytest.mark.asyncio
90
+ async def test_receive_some_raises_a_fresh_copy():
91
+ s = _AsyncioStream()
92
+ s.connection_made(None)
93
+ s.connection_lost(ConnectionResetError(54, "peer reset"))
94
+ with pytest.raises(ConnectionResetError) as ei:
95
+ await s.receive_some()
96
+ assert ei.value is not s._error # never the stored instance
97
+ assert ei.value.__cause__ is s._error # chained for debuggability
98
+ assert ei.value.args == s._error.args
99
+ assert s._error.__traceback__ is None # the raise left no frames on the stored error
100
+
101
+
102
+ @pytest.mark.asyncio
103
+ async def test_send_all_raises_a_fresh_copy():
104
+ s = _AsyncioStream()
105
+ s.connection_made(None)
106
+ s.connection_lost(ConnectionResetError(54, "peer reset"))
107
+ with pytest.raises(ConnectionResetError) as ei:
108
+ await s.send_all(b"data")
109
+ assert ei.value is not s._error
110
+ assert s._error.__traceback__ is None
111
+
112
+
113
+ class _Sentinel:
114
+ """Stands in for everything a consumer frame holds (a server's response
115
+ transport, a relay generator, ...)."""
116
+
117
+
118
+ @pytest.mark.skipif(
119
+ platform.python_implementation() != "CPython",
120
+ reason="asserts prompt refcount finalization — CPython semantics; on PyPy everything waits for GC regardless",
121
+ )
122
+ @pytest.mark.asyncio
123
+ async def test_failed_stream_frees_consumer_state_without_cyclic_gc():
124
+ # The end-to-end property (the "s04" regression): after a consumer stack
125
+ # catches the read error of a failed connection, everything — the consumer's
126
+ # state AND the stream itself — must be freed by refcounting alone. Before
127
+ # the fresh_exc rules, the stored error's traceback closed a cycle through
128
+ # the stream and pinned the whole graph until a gen-2 collection.
129
+ s = _AsyncioStream()
130
+ s.connection_made(None)
131
+ s.connection_lost(ConnectionResetError(54, "peer reset"))
132
+ stream_ref = weakref.ref(s)
133
+ sentinel = _Sentinel()
134
+ sentinel_ref = weakref.ref(sentinel)
135
+
136
+ async def relay(stream, held):
137
+ try:
138
+ while True:
139
+ await stream.receive_some()
140
+ except ConnectionResetError:
141
+ pass # swallowed, like a server relaying this body would
142
+
143
+ gc.disable()
144
+ try:
145
+ await relay(s, sentinel)
146
+ del sentinel
147
+ assert sentinel_ref() is None # consumer state: freed by refcount
148
+ del s
149
+ assert stream_ref() is None # the stream: no self-pinning cycle either
150
+ finally:
151
+ gc.enable()
152
+
153
+
154
+ # ----- the h1 driver's saved connection error -----
155
+
156
+
157
+ class _DummyTransport:
158
+ def close(self):
159
+ pass
160
+
161
+
162
+ @pytest.mark.asyncio
163
+ async def test_h1_fail_stores_a_stripped_copy():
164
+ # `_fail` runs while the caught instance is still propagating to the caller
165
+ # (send_request stores, then re-raises) — the saved error must be a copy so
166
+ # the continuing propagation can't grow a stored traceback.
167
+ conn = H1Driver(_DummyTransport(), backend=AsyncioBackend())
168
+ err = _caught(ValueError("mid-exchange failure"))
169
+ conn._fail(err)
170
+ assert conn.error is not err
171
+ assert conn.error.args == err.args
172
+ assert conn.error.__traceback__ is None
173
+
174
+
175
+ @pytest.mark.asyncio
176
+ async def test_h1_wait_idle_raises_a_fresh_copy():
177
+ conn = H1Driver(_DummyTransport(), backend=AsyncioBackend())
178
+ conn._fail(_caught(ValueError("dead")))
179
+ with pytest.raises(ValueError) as ei:
180
+ await conn.wait_idle()
181
+ assert ei.value is not conn.error
182
+ assert ei.value.__cause__ is conn.error
183
+ assert conn.error.__traceback__ is None # this raise added nothing to the stored error
@@ -502,7 +502,9 @@ async def test_server_upgrade_tunnel():
502
502
  s.spawn(serve())
503
503
  transport = await _raw_client(host, port)
504
504
  try:
505
- await transport.send_all(b"GET /chat HTTP/1.1\r\nhost: x\r\nconnection: upgrade\r\nupgrade: myproto\r\n\r\n")
505
+ await transport.send_all(
506
+ b"GET /chat HTTP/1.1\r\nhost: x\r\nconnection: upgrade\r\nupgrade: myproto\r\n\r\n"
507
+ )
506
508
  head = await _read_until(transport, b"\r\n\r\n")
507
509
  assert head.startswith(b"HTTP/1.1 101")
508
510
  await transport.send_all(b"ping")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes