cancellable-http-client 1.0__py3-none-any.whl

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.
@@ -0,0 +1,204 @@
1
+ Metadata-Version: 2.4
2
+ Name: cancellable-http-client
3
+ Version: 1.0
4
+ Summary: A tiny, dependency-free HTTP client for Python with cancellable in-flight requests and hard wall-clock timeout.
5
+ Author: Sakilabo Corporation Ltd.
6
+ License-Expression: UPL-1.0
7
+ Project-URL: Homepage, https://github.com/sakilabo/cancellable-http-client
8
+ Project-URL: Issues, https://github.com/sakilabo/cancellable-http-client/issues
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.7
14
+ Classifier: Programming Language :: Python :: 3.8
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Internet :: WWW/HTTP
22
+ Requires-Python: >=3.7
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Dynamic: license-file
26
+
27
+ # cancellable_http_client
28
+
29
+ A tiny, dependency-free HTTP client for Python with **cancellable in-flight requests** and **hard wall-clock timeout**.
30
+
31
+ - Standard library only — no `requests`, no `httpx`, no `urllib3`.
32
+ - Single file, ~300 lines.
33
+ - Synchronous API that plays well with `threading`-based workers.
34
+ - Safe `close()` from any thread, at any time, including mid-transfer.
35
+ - Hard wall-clock `timeout` that bounds the entire request.
36
+
37
+ ## Why it exists
38
+
39
+ Python has no clean way to interrupt a thread that is blocked on a socket read. `concurrent.futures.Future.cancel()` does nothing once the task has started, and `requests` / `urllib.urlopen()` give you no handle to abort an in-flight request.
40
+
41
+ The one primitive that *does* work is closing the underlying socket: any pending `recv()` immediately unblocks with an error. This module wraps that trick behind a tiny, boring API so you don't have to reinvent it — or worry about the lifecycle edge cases — every time you need it.
42
+
43
+ If your codebase is built around `asyncio`, you don't need this; use `httpx` and `task.cancel()` instead. This module targets the very real case where you have existing threaded code and you want one HTTP call in the middle of it to be cancellable, without rewriting everything to be async.
44
+
45
+ ## Usage
46
+
47
+ ```python
48
+ import time
49
+ import cancellable_http_client as client
50
+
51
+ req = client.Request("https://example.com/")
52
+ req.start() # the actual TCP connection happens here
53
+ start = time.monotonic()
54
+ while not req.done:
55
+ if time.monotonic() - start > 5:
56
+ print("taking too long, aborting...")
57
+ req.close() # interrupts the request if it's still in-flight
58
+ req.wait(0.1) # wait a bit before checking again
59
+ if req.error:
60
+ print(f"failed: {req.error}")
61
+ elif req.response and req.response.status == 200:
62
+ print(req.response.body)
63
+ req.close() # safe to call any time, even mid-flight
64
+ ```
65
+
66
+ You can also use it as a context manager:
67
+
68
+ ```python
69
+ with client.Request("https://example.com/") as req:
70
+ req.start()
71
+ req.wait(timeout=5)
72
+ ...
73
+ # close() is called automatically on exit
74
+ ```
75
+
76
+ ### Sharing a thread pool
77
+
78
+ By default each `Request` spawns its own daemon thread. To reuse a pool instead, assign an `Executor` to the module-level attribute:
79
+
80
+ ```python
81
+ from concurrent.futures import ThreadPoolExecutor
82
+ client.executor = ThreadPoolExecutor(max_workers=8)
83
+ ```
84
+
85
+ ## API
86
+
87
+ ### `Request(url, method="GET", headers=None, body=b"", socket_timeout=30, timeout=None)`
88
+
89
+ Construct a request. No network I/O happens here — connection failures are reported via `error` after `start()`.
90
+
91
+ - **`socket_timeout`** — per-socket-operation timeout in seconds, passed to `http.client.HTTPConnection`.
92
+ - **`timeout`** — wall-clock limit in seconds for the entire request. Triggers `close()` automatically if the request is not done in time. `None` disables.
93
+ - **`start()`** — kick off the request. Non-blocking.
94
+ - **`wait(timeout=None) -> bool`** — block until the request finishes. Returns `True` on completion, `False` on timeout.
95
+ - **`close()`** — abort the request and release resources. Safe to call any time, from any thread, any number of times.
96
+ - **`done`** *(property)* — `True` once the request has finished (success, failure, or close).
97
+ - **`response`** — a `Response` object on success, otherwise `None`.
98
+ - **`error`** — the exception raised during the request, or `None`.
99
+
100
+ ### `Response`
101
+
102
+ A read-only, socket-free container exposing the same attributes as `http.client.HTTPResponse`:
103
+
104
+ - `status`, `reason`, `version`
105
+ - `headers` (an `http.client.HTTPMessage`)
106
+ - `body` (`bytes`, eagerly read)
107
+ - `getheader(name, default=None)`, `getheaders()`
108
+
109
+ ## Robust timeout
110
+
111
+ Most Python HTTP clients set a *per-socket-operation* timeout (`socket.settimeout`). This leaves several gaps:
112
+
113
+ - **Slow drip** — a server that sends one byte every 29 seconds never triggers a 30-second socket timeout, yet the total transfer can take arbitrarily long.
114
+ - **DNS resolution** — `socket.getaddrinfo()` is a blocking C library call with no timeout parameter. Python cannot interrupt it.
115
+ - **Total elapsed time** — there is no built-in way to cap the wall-clock time of an entire request across connection, TLS handshake, sending, and receiving.
116
+
117
+ `cancellable_http_client` addresses this with two separate knobs:
118
+
119
+ | Parameter | Scope | Default |
120
+ |---|---|---|
121
+ | `socket_timeout` | Per socket operation (connect, send, recv) | 30 s |
122
+ | `timeout` | Wall-clock limit on the entire request | None (no limit) |
123
+
124
+ When `timeout` fires it calls `close()`, which immediately unblocks any pending socket operation by closing the underlying connection. This gives you a hard upper bound on how long `wait()` will block — something that `socket_timeout` alone cannot guarantee.
125
+
126
+ ## Comparison with existing libraries
127
+
128
+ | | cancellable_http_client | httpx | requests |
129
+ |---|---|---|---|
130
+ | Cancel an in-flight request from another thread | ✅ | ⚠️ async, [unreliable](https://github.com/encode/httpx/issues/1461) | ⚠️ hacky |
131
+ | Hard wall-clock timeout on entire request | ✅ | ⚠️ per-operation | ⚠️ per-operation |
132
+ | Synchronous API | ✅ | ✅ (also async) | ✅ |
133
+ | No third-party dependencies | ✅ | ❌ | ❌ |
134
+ | Line count | ~300 | thousands | thousands |
135
+ | Fits a threading-based worker | ✅ | ❌ | ⚠️ |
136
+ | Redirects, cookies, User-Agent | ⚠️ manual | ✅ | ✅ |
137
+
138
+ `httpx` is a good choice if you are already in an `asyncio` world, though `task.cancel()` on in-flight requests [can leave the connection pool in a broken state](https://github.com/encode/httpx/issues/1461). `requests` does not offer a reliable way to interrupt an in-flight call; `Session.close()` does not forcibly close active sockets ([psf/requests#5633](https://github.com/psf/requests/issues/5633)).
139
+
140
+ ## Limitations
141
+
142
+ This library is a thin wrapper around `http.client` and does not provide the high-level conveniences found in `requests` or `httpx`:
143
+
144
+ - No automatic redirect following
145
+ - No cookie management
146
+ - No default User-Agent header
147
+ - No HTTP/2 support
148
+
149
+ These are all `http.client` limitations, not restrictions added by this library. You can still handle them manually via the `headers` parameter.
150
+
151
+ ## Tests
152
+
153
+ ```
154
+ python -m unittest discover -s tests -v
155
+ ```
156
+
157
+ No third-party test dependencies. Tests use local throwaway servers (normal, slow, blackhole, mid-body disconnect) to exercise cancellation, timeout, and error paths without touching the network.
158
+
159
+ ## License
160
+
161
+ Copyright 2026 Sakilabo Corporation Ltd.
162
+ Licensed under the Universal Permissive License v 1.0
163
+ ([UPL-1.0](https://oss.oracle.com/licenses/upl/)).
164
+
165
+ ---
166
+
167
+ ## References (for the eventual public release)
168
+
169
+ Background reading and related work collected during the design of this module. Useful as citations in the README or a launch blog post.
170
+
171
+ ### Prior art / closest existing work
172
+
173
+ - **["TIL: Stopping Requests Mid Flight" — haykot.dev](https://haykot.dev/blog/til-stopping-requests-mid-flight/)**
174
+ A blog post describing the "close the socket from another thread" trick as a personal discovery. The same underlying idea as this module, but kept as a snippet rather than packaged as a library.
175
+
176
+ - **[httpcore on PyPI](https://pypi.org/project/httpcore/)**
177
+ the low-level HTTP engine behind `httpx`. Supports cancellation via async task cancellation; relies on `asyncio` or `trio`.
178
+
179
+ - **[HTTPX](https://www.python-httpx.org/)**
180
+ modern high-level HTTP client; cancellation is done via `task.cancel()` in an async context.
181
+
182
+ - **[asyncio-cancel-token](https://asyncio-cancel-token.readthedocs.io/en/latest/cancel_token.html)**
183
+ a cancellation-token utility for `asyncio`-based code.
184
+
185
+ ### The underlying Python pain points
186
+
187
+ - **[Graceful exit from ThreadPoolExecutor when blocked on IO — discuss.python.org](https://discuss.python.org/t/graceful-exit-from-threadpoolexecutor-when-blocked-on-io-problem-and-possible-enhancement/80380)**
188
+ Ongoing discussion acknowledging that Python has no clean way to cancel a worker that is blocked on I/O. This module is effectively a targeted workaround for the HTTP-specific case.
189
+
190
+ - **[`threading` — Python docs](https://docs.python.org/3/library/threading.html)**
191
+ `Thread` has no `cancel()` / `interrupt()`; cooperation via `Event` is the only sanctioned approach.
192
+
193
+ - **[`Session.close()` does not close underlying sockets — psf/requests#5633](https://github.com/psf/requests/issues/5633)**
194
+ Illustrates why "just use `requests` and close the session" is not a reliable answer.
195
+
196
+ - **[Unclosed socket in urllib when ftp request times out after connect — cpython#140691](https://github.com/python/cpython/issues/140691)**
197
+ A related stdlib lifecycle bug — background for why we deliberately take full control of the connection object.
198
+
199
+ ### Related stdlib primitives this module builds on
200
+
201
+ - **[`http.client` — Python docs](https://docs.python.org/3/library/http.client.html)**
202
+ the low-level HTTP protocol implementation we wrap.
203
+ - **[`threading.Event` — Python docs](https://docs.python.org/3/library/threading.html#event-objects)**
204
+ used internally to signal completion.
@@ -0,0 +1,6 @@
1
+ cancellable_http_client.py,sha256=WD9k57UPewmxcff-N1EsfttOdXZbr2kbsvKxKsK9Gt0,10100
2
+ cancellable_http_client-1.0.dist-info/licenses/LICENSE,sha256=x5gxhbq1vPgZSPLb-MfxI0hriq_JXCGEaXFkQSVNLaA,1831
3
+ cancellable_http_client-1.0.dist-info/METADATA,sha256=Yzikypm9eCewb2OhOoGvBS5vzivwRYszpUXbGIHB1-I,10283
4
+ cancellable_http_client-1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
5
+ cancellable_http_client-1.0.dist-info/top_level.txt,sha256=i2x1SMr759WwB4gwhwLXWqnjgjpgOCh4yWWvU8mHypc,24
6
+ cancellable_http_client-1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,15 @@
1
+ Copyright 2026 Sakilabo Corporation Ltd.
2
+
3
+ The Universal Permissive License (UPL), Version 1.0
4
+
5
+ Subject to the condition set forth below, permission is hereby granted to any person obtaining a copy of this software, associated documentation and/or data (collectively the "Software"), free of charge and under any and all copyright rights in the Software, and any and all patent rights owned or freely licensable by each licensor hereunder covering either (i) the unmodified Software as contributed to or provided by such licensor, or (ii) the Larger Works (as defined below), to deal in both
6
+
7
+ (a) the Software, and
8
+ (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if one is included with the Software (each a "Larger Work" to which the Software is contributed by such licensors),
9
+
10
+ without restriction, including without limitation the rights to copy, create derivative works of, display, perform, and distribute the Software and make, use, sell, offer for sale, import, export, have made, and have sold the Software and the Larger Work(s), and to sublicense the foregoing rights on either these or other terms.
11
+
12
+ This license is subject to the following condition:
13
+ The above copyright notice and either this complete permission notice or at a minimum a reference to the UPL must be included in all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1 @@
1
+ cancellable_http_client
@@ -0,0 +1,289 @@
1
+ # Copyright 2026 Sakilabo Corporation Ltd.
2
+ # SPDX-License-Identifier: UPL-1.0
3
+ #
4
+ # Licensed under the Universal Permissive License v 1.0 as shown at
5
+ # https://oss.oracle.com/licenses/upl/
6
+
7
+ """Cancellable HTTP client.
8
+
9
+ A tiny standard-library-only HTTP client whose in-flight request can be
10
+ aborted at any time by closing the underlying socket.
11
+
12
+ Usage
13
+ -----
14
+ import time
15
+ import cancellable_http_client as client
16
+
17
+ req = client.Request("https://example.com/")
18
+ req.start() # the actual TCP connection happens here
19
+ start = time.monotonic()
20
+ while not req.done:
21
+ if time.monotonic() - start > 5:
22
+ print("taking too long, aborting...")
23
+ req.close() # this will interrupt the request if it's still in-flight
24
+ req.wait(0.1) # wait a bit before checking again (when done, wait() returns immediately)
25
+ if req.error:
26
+ print(f"failed: {req.error}")
27
+ elif req.response and req.response.status == 200:
28
+ print(req.response.body)
29
+ req.close() # safe to call any time, even mid-flight
30
+
31
+ The ``timeout`` parameter triggers ``close()`` automatically after the
32
+ given number of seconds, providing a hard wall-clock limit on the entire
33
+ request — unlike ``socket_timeout``, which only limits individual socket
34
+ operations and cannot bound the total elapsed time.
35
+
36
+ By default each Request spawns its own daemon thread. To share a pool,
37
+ assign a concurrent.futures.Executor to ``cancellable_http_client.executor``.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import http.client
43
+ import logging
44
+ import threading
45
+ import urllib.parse
46
+
47
+ from concurrent.futures import Executor
48
+ from typing import Callable
49
+
50
+ _logger = logging.getLogger(__name__)
51
+
52
+
53
+ # If the user assigns an Executor here, requests are dispatched to it
54
+ # instead of spawning a fresh daemon thread per call.
55
+ executor: Executor | None = None
56
+
57
+
58
+ class Response:
59
+ """A read-only HTTPResponse-compatible object.
60
+
61
+ Exposes the same attributes as ``http.client.HTTPResponse``
62
+ (``status``, ``reason``, ``version``, ``headers``, ``body``) but holds
63
+ its body in memory, so it can be inspected freely after the underlying
64
+ socket has been closed.
65
+ """
66
+
67
+ def __init__(self, resp: http.client.HTTPResponse) -> None:
68
+ self.status: int = resp.status
69
+ self.reason: str = resp.reason
70
+ self.version: int = resp.version
71
+ self.headers: http.client.HTTPMessage = resp.msg
72
+ self.body: bytes = resp.read()
73
+
74
+ def getheader(self, name: str, default: str | None = None) -> str | None:
75
+ return self.headers.get(name, default)
76
+
77
+ def getheaders(self) -> list[tuple[str, str]]:
78
+ return list(self.headers.items())
79
+
80
+ def __repr__(self) -> str:
81
+ return f"<Response status={self.status} reason={self.reason!r}>"
82
+
83
+
84
+ class Request:
85
+ """A single cancellable HTTP request.
86
+
87
+ Parameters
88
+ ----------
89
+ url : Target URL (scheme://host[:port]/path?query).
90
+ method : HTTP method.
91
+ headers : Request headers.
92
+ body : Request body bytes.
93
+ socket_timeout : Socket timeout in seconds (per socket operation).
94
+ timeout : Total request timeout in seconds. If the request does not
95
+ complete within this time after ``start()``, it is automatically
96
+ closed. ``None`` means no limit.
97
+
98
+ Attributes
99
+ ----------
100
+ done : True once the request has finished (success, failure, or close).
101
+ response : Response object on success, otherwise None.
102
+ error : Exception raised during the request, or None on success.
103
+ """
104
+
105
+ def __init__(
106
+ self,
107
+ url: str,
108
+ method: str = "GET",
109
+ headers: dict[str, str] | None = None,
110
+ body: bytes = b"",
111
+ socket_timeout: float = 30,
112
+ timeout: float | None = None,
113
+ ) -> None:
114
+ self.response: Response | None = None
115
+ self.error: Exception | None = None
116
+
117
+ self._event = threading.Event()
118
+ self._lock = threading.Lock()
119
+ self._closed: bool = False
120
+ self._started: bool = False
121
+ self._conn: http.client.HTTPConnection | None = None
122
+ self._callbacks: list[Callable[["Request"], None]] = []
123
+ self._timeout: float | None = timeout
124
+ self._timer: threading.Timer | None = None
125
+
126
+ self._url: str = url
127
+ parsed = urllib.parse.urlparse(url)
128
+ self._method: str = method
129
+ self._headers: dict[str, str] = headers or {}
130
+ self._req_body: bytes = body
131
+ self._path: str = parsed.path or "/"
132
+ if parsed.query:
133
+ self._path = f"{self._path}?{parsed.query}"
134
+
135
+ try:
136
+ if parsed.scheme == "https":
137
+ self._conn = http.client.HTTPSConnection(
138
+ parsed.hostname or "", parsed.port, timeout=socket_timeout
139
+ )
140
+ else:
141
+ self._conn = http.client.HTTPConnection(
142
+ parsed.hostname or "", parsed.port, timeout=socket_timeout
143
+ )
144
+ except Exception as e:
145
+ self.error = e
146
+ self._event.set()
147
+
148
+ def __enter__(self) -> "Request":
149
+ return self
150
+
151
+ def __exit__(self, *_exc) -> None:
152
+ self.close()
153
+
154
+ def __repr__(self) -> str:
155
+ if self.response is not None:
156
+ state = f"status={self.response.status}"
157
+ elif self.error is not None:
158
+ state = f"error={self.error!r}"
159
+ elif self._closed:
160
+ state = "closed"
161
+ elif self._started:
162
+ state = "running"
163
+ else:
164
+ state = "pending"
165
+ return f"<Request {self._method} {self._url} {state}>"
166
+
167
+ @property
168
+ def done(self) -> bool:
169
+ """True once the request has finished (success, failure, or close).
170
+
171
+ When ``done`` is True, all finalize callbacks have already run.
172
+ """
173
+ return self._event.is_set()
174
+
175
+ def start(self) -> None:
176
+ """Start the request.
177
+
178
+ Calling start() more than once, or after close(), is a no-op.
179
+ If scheduling the worker fails, ``error`` is set and the request
180
+ finishes immediately.
181
+ """
182
+ with self._lock:
183
+ if self._started or self._closed:
184
+ return
185
+ self._started = True
186
+
187
+ if self._timeout is not None:
188
+ self._timer = threading.Timer(self._timeout, self.close)
189
+ self._timer.daemon = True
190
+ self._timer.start()
191
+
192
+ try:
193
+ if executor is not None:
194
+ executor.submit(self._run)
195
+ else:
196
+ threading.Thread(
197
+ target=self._run, daemon=True, name="cancellable_http_client"
198
+ ).start()
199
+ except Exception as e:
200
+ self.error = e
201
+ with self._lock:
202
+ conn, self._conn = self._conn, None
203
+ self._finalize(conn)
204
+
205
+ def add_finalize_callback(self, fn: Callable[["Request"], None]) -> None:
206
+ """Register ``fn`` to be invoked just before the request finishes.
207
+
208
+ Unlike ``concurrent.futures.Future.add_done_callback``, the callback
209
+ runs *before* ``done`` becomes True and before ``wait()`` returns.
210
+ This means an observer that sees ``done == True`` is guaranteed
211
+ that every registered callback has already completed — useful for
212
+ callbacks that populate fields the observer wants to read.
213
+
214
+ The callback receives this Request as its only argument and runs
215
+ on the worker thread that completes the request. Registering a
216
+ callback on a Request that has already finished is a no-op.
217
+
218
+ Exceptions raised by the callback are logged and swallowed.
219
+ """
220
+ with self._lock:
221
+ self._callbacks.append(fn)
222
+
223
+ def wait(self, timeout: float | None = None) -> bool:
224
+ """Block until done. Returns True if completed, False on timeout.
225
+
226
+ Raises RuntimeError if called before ``start()`` on a request that
227
+ has not already finished (e.g. due to an initialisation failure).
228
+ """
229
+ if not self._started and not self.done:
230
+ raise RuntimeError("Request has not been started")
231
+ return self._event.wait(timeout)
232
+
233
+ def close(self) -> None:
234
+ """Discard everything and finish.
235
+
236
+ Safe to call at any time, including from another thread while the
237
+ request is in flight. After close(), ``done`` is True and any
238
+ ``wait()`` call returns immediately.
239
+ """
240
+ with self._lock:
241
+ if self._closed:
242
+ return
243
+ self._closed = True
244
+ conn, self._conn = self._conn, None
245
+ self._finalize(conn)
246
+
247
+ def _run(self) -> None:
248
+ try:
249
+ conn = self._conn
250
+ if conn is None:
251
+ raise RuntimeError("connection is not available")
252
+ conn.request(
253
+ self._method, self._path, body=self._req_body, headers=self._headers
254
+ )
255
+ # If close() was called while conn.request() was in progress,
256
+ # the socket may not have been cleaned up — bail out here.
257
+ with self._lock:
258
+ closed = self._closed
259
+ if closed:
260
+ conn.close()
261
+ return
262
+ resp = Response(conn.getresponse())
263
+ with self._lock:
264
+ if not self._closed:
265
+ self.response = resp
266
+ except Exception as e:
267
+ # interruption by close() also lands here
268
+ with self._lock:
269
+ closed = self._closed
270
+ if not closed:
271
+ self.error = e
272
+ finally:
273
+ self._conn = None
274
+ self._finalize(conn)
275
+
276
+ def _finalize(self, conn: http.client.HTTPConnection | None) -> None:
277
+ if conn is not None:
278
+ try:
279
+ conn.close()
280
+ except Exception:
281
+ pass
282
+ with self._lock:
283
+ callbacks, self._callbacks = self._callbacks, []
284
+ for fn in callbacks:
285
+ try:
286
+ fn(self)
287
+ except Exception:
288
+ _logger.exception("finalize callback raised")
289
+ self._event.set()