uhttp-client 2.3.0__tar.gz → 3.0.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 (44) hide show
  1. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/.github/workflows/publish.yml +0 -6
  2. uhttp_client-3.0.0/.github/workflows/tests.yml +87 -0
  3. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/PKG-INFO +183 -122
  4. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/README.md +182 -121
  5. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/examples/client_async.py +42 -54
  6. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/examples/client_stream.py +11 -17
  7. uhttp_client-3.0.0/tests/helpers.py +230 -0
  8. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_async.py +35 -49
  9. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_body_framing.py +3 -35
  10. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_event_mode.py +1 -65
  11. uhttp_client-3.0.0/tests/test_http_compliance.py +278 -0
  12. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_mpy_integration.py +12 -0
  13. uhttp_client-3.0.0/tests/test_mpy_resources.py +234 -0
  14. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_nonblocking_connect.py +38 -67
  15. uhttp_client-3.0.0/tests/test_selectors_v3.py +613 -0
  16. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_unit.py +3 -2
  17. uhttp_client-3.0.0/tests/test_v3_fixes.py +253 -0
  18. uhttp_client-3.0.0/tests/test_v3_review.py +341 -0
  19. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/uhttp/cli.py +2 -10
  20. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/uhttp/client.py +649 -280
  21. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/uhttp_client.egg-info/PKG-INFO +183 -122
  22. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/uhttp_client.egg-info/SOURCES.txt +8 -1
  23. uhttp_client-3.0.0/uhttp_client.egg-info/scm_file_list.json +37 -0
  24. uhttp_client-3.0.0/uhttp_client.egg-info/scm_version.json +8 -0
  25. uhttp_client-2.3.0/.github/workflows/micropython.yml +0 -39
  26. uhttp_client-2.3.0/.github/workflows/tests.yml +0 -33
  27. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/.gitignore +0 -0
  28. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/LICENSE +0 -0
  29. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/examples/client_basic.py +0 -0
  30. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/examples/client_https.py +0 -0
  31. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/pyproject.toml +0 -0
  32. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/setup.cfg +0 -0
  33. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/__init__.py +0 -0
  34. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_100_continue.py +0 -0
  35. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_auth.py +0 -0
  36. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_basic.py +0 -0
  37. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_cli.py +0 -0
  38. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_cookies.py +0 -0
  39. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_errors.py +0 -0
  40. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_integration.py +0 -0
  41. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/tests/test_keepalive.py +0 -0
  42. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/uhttp_client.egg-info/dependency_links.txt +0 -0
  43. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/uhttp_client.egg-info/entry_points.txt +0 -0
  44. {uhttp_client-2.3.0 → uhttp_client-3.0.0}/uhttp_client.egg-info/top_level.txt +0 -0
@@ -25,12 +25,6 @@ jobs:
25
25
  - name: Install package
26
26
  run: pip install .
27
27
 
28
- - name: Install test dependencies
29
- run: pip install uhttp-server
30
-
31
- - name: Run unit tests
32
- run: python -m unittest discover -v tests/
33
-
34
28
  - name: Build package
35
29
  run: python -m build
36
30
 
@@ -0,0 +1,87 @@
1
+ name: Tests
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ push:
6
+ branches: [main, v2]
7
+ pull_request:
8
+ branches: [main, v2]
9
+
10
+ jobs:
11
+ test-ubuntu:
12
+ runs-on: ubuntu-latest
13
+ strategy:
14
+ max-parallel: 1
15
+ matrix:
16
+ python-version: ["3.10", "3.14"]
17
+
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+
21
+ - name: Set up Python ${{ matrix.python-version }}
22
+ uses: actions/setup-python@v5
23
+ with:
24
+ python-version: ${{ matrix.python-version }}
25
+
26
+ - name: Install package
27
+ run: pip install .
28
+
29
+ - name: Install test dependencies
30
+ run: pip install uhttp-server
31
+
32
+ - name: Run unit tests
33
+ run: python -m unittest discover -v tests/
34
+
35
+ test-windows:
36
+ runs-on: windows-latest
37
+ strategy:
38
+ max-parallel: 1
39
+ matrix:
40
+ python-version: ["3.10", "3.14"]
41
+
42
+ steps:
43
+ - uses: actions/checkout@v4
44
+
45
+ - name: Set up Python ${{ matrix.python-version }}
46
+ uses: actions/setup-python@v5
47
+ with:
48
+ python-version: ${{ matrix.python-version }}
49
+
50
+ - name: Install package
51
+ run: pip install .
52
+
53
+ - name: Install test dependencies
54
+ run: pip install uhttp-server
55
+
56
+ - name: Run unit tests
57
+ run: python -m unittest discover -v tests/
58
+
59
+ micropython:
60
+ if: github.event_name == 'push'
61
+ needs: [test-ubuntu, test-windows]
62
+ runs-on: [self-hosted, Linux]
63
+ # One device, two maintained branches - queue instead of colliding, and
64
+ # never cancel: interrupting mpytool mid-operation wedges the REPL.
65
+ concurrency:
66
+ group: micropython-${{ matrix.device }}
67
+ cancel-in-progress: false
68
+ strategy:
69
+ fail-fast: false
70
+ matrix:
71
+ device: [ESP32]
72
+
73
+ steps:
74
+ - uses: actions/checkout@v4
75
+
76
+ - name: Install packages
77
+ run: |
78
+ $HOME/actions-runner/.venv/bin/pip install -e .
79
+ $HOME/actions-runner/.venv/bin/pip install 'mpytool>=2.4.0'
80
+
81
+ - name: MicroPython tests (${{ matrix.device }})
82
+ run: |
83
+ export PATH="$HOME/actions-runner/.venv/bin:$PATH"
84
+ export MPY_TEST_PORT="$(cat $HOME/actions-runner/.config/mpytool/${{ matrix.device }})"
85
+ export MPY_WIFI_SSID="$(jq -r .ssid $HOME/actions-runner/.config/uhttp/wifi.json)"
86
+ export MPY_WIFI_PASSWORD="$(jq -r .password $HOME/actions-runner/.config/uhttp/wifi.json)"
87
+ python -m unittest tests.test_mpy_integration -v
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: uhttp-client
3
- Version: 2.3.0
3
+ Version: 3.0.0
4
4
  Summary: Micro HTTP client for Python and MicroPython
5
5
  Author-email: Pavel Revak <pavelrevak@gmail.com>
6
6
  License: MIT
@@ -21,7 +21,7 @@ Dynamic: license-file
21
21
  ## Features
22
22
 
23
23
  - MicroPython and CPython compatible
24
- - Fully non-blocking: TCP connect, SSL handshake, and HTTP I/O via select
24
+ - Fully non-blocking: TCP connect, SSL handshake, and HTTP I/O via `selectors`
25
25
  - Keep-alive connections with automatic reuse
26
26
  - Fluent API: `response = client.get('/path').wait()`
27
27
  - URL parsing with automatic SSL detection
@@ -164,10 +164,11 @@ client.close()
164
164
 
165
165
  ## Async (non-blocking) mode
166
166
 
167
- Everything is non-blocking by default — TCP connect, SSL handshake, and HTTP I/O all happen through `select()`. This is critical for embedded devices on slow networks (4G modems, ESP32 PPP) where each phase can take seconds.
167
+ Everything is non-blocking by default — TCP connect, SSL handshake, and HTTP I/O all happen through a `selectors.BaseSelector`. This is critical for embedded devices on slow networks (4G modems, ESP32 PPP) where each phase can take seconds.
168
+
169
+ The client registers its socket in a selector and is driven either by its own `wait()`, or by a shared-selector loop dispatching through `key.data.handle_event()`.
168
170
 
169
171
  ```python
170
- import select
171
172
  import uhttp.client
172
173
 
173
174
  client = uhttp.client.HttpClient('http://httpbin.org')
@@ -175,112 +176,109 @@ client = uhttp.client.HttpClient('http://httpbin.org')
175
176
  # Start request (non-blocking, including connect)
176
177
  client.get('/delay/2')
177
178
 
178
- # Manual select loop - handles connect, send, and receive
179
- while True:
180
- r, w, _ = select.select(
181
- client.read_sockets,
182
- client.write_sockets,
183
- [], 10.0
184
- )
185
-
186
- response = client.process_events(r, w)
187
- if response:
188
- print(response.status)
189
- break
179
+ # Manual selector loop - handles connect, send, and receive
180
+ done = False
181
+ while not done:
182
+ for key, mask in client.selector.select(10.0):
183
+ if key.data.handle_event(key.fileobj, mask) is not None:
184
+ print(client.response.status)
185
+ done = True
186
+ client.maintenance() # enforces the deadline; no event can trigger it
190
187
 
191
188
  client.close()
192
189
  ```
193
190
 
191
+ `handle_event()` returns the client itself when a result is ready (not the
192
+ value — `EVENT_RESPONSE` is `0` and would be falsy); read it from
193
+ `client.response` or `client.event`.
194
+
195
+ **Breaking change in v3:** `read_sockets`, `write_sockets` and
196
+ `process_events()` are gone. `select.select()` is no longer used.
197
+
194
198
  ### State machine
195
199
 
196
- After `client.get('/path')`, the client progresses through states automatically via `process_events()`:
200
+ After `client.get('/path')`, the client progresses through states automatically via `handle_event()`:
197
201
 
198
- | State | Description | select watches |
202
+ | State | Description | selector interest |
199
203
  |---|---|---|
200
- | `STATE_CONNECTING` | TCP connect in progress | write |
201
- | `STATE_SSL_HANDSHAKE` | SSL handshake in progress | read or write |
202
- | `STATE_SENDING` | Sending request data | write |
203
- | `STATE_RECEIVING_HEADERS` | Waiting for response headers | read |
204
- | `STATE_RECEIVING_BODY` | Receiving response body | read |
204
+ | `STATE_CONNECTING` | TCP connect in progress | `EVENT_WRITE` |
205
+ | `STATE_SSL_HANDSHAKE` | SSL handshake in progress | `EVENT_READ`/`EVENT_WRITE` |
206
+ | `STATE_SENDING` | Sending request data | `EVENT_WRITE` |
207
+ | `STATE_RECEIVING_HEADERS` | Waiting for response headers | `EVENT_READ` |
208
+ | `STATE_RECEIVING_BODY` | Receiving response body | `EVENT_READ` |
205
209
  | `STATE_COMPLETE` | Response ready | — |
206
210
 
207
211
  The `state` property exposes the current state. The `is_connected` property returns `True` only after connect and handshake are complete.
208
212
 
209
213
  ### Parallel requests
210
214
 
211
- All clients share one select loop. Connect, handshake, and data transfer happen concurrently:
215
+ Pass one selector to every client and a single loop drives them all. Connect, handshake, and data transfer happen concurrently:
212
216
 
213
217
  ```python
214
- import select
218
+ import selectors
215
219
  import uhttp.client
216
220
 
221
+ selector = selectors.DefaultSelector()
217
222
  clients = [
218
- uhttp.client.HttpClient('http://httpbin.org'),
219
- uhttp.client.HttpClient('http://httpbin.org'),
220
- uhttp.client.HttpClient('http://httpbin.org'),
223
+ uhttp.client.HttpClient('http://httpbin.org', selector=selector)
224
+ for _ in range(3)
221
225
  ]
222
226
 
223
227
  # Start all requests (non-blocking connects begin immediately)
224
228
  for i, client in enumerate(clients):
225
229
  client.get('/delay/1', query={'n': i})
226
230
 
227
- # Single select loop handles all clients
231
+ # Single selector loop handles all clients
228
232
  results = {}
229
233
  while len(results) < len(clients):
230
- read_socks = []
231
- write_socks = []
232
- for c in clients:
233
- read_socks.extend(c.read_sockets)
234
- write_socks.extend(c.write_sockets)
235
-
236
- r, w, _ = select.select(read_socks, write_socks, [], 10.0)
237
-
238
- for i, client in enumerate(clients):
239
- if i not in results:
240
- resp = client.process_events(r, w)
241
- if resp:
242
- results[i] = resp
234
+ for key, mask in selector.select(10.0):
235
+ ready = key.data.handle_event(key.fileobj, mask)
236
+ if ready is not None:
237
+ results[clients.index(ready)] = ready.response
238
+ for client in clients:
239
+ while client.next(): # drain results buffered from one recv
240
+ results[clients.index(client)] = client.response
241
+ client.maintenance()
243
242
 
244
243
  for client in clients:
245
244
  client.close()
245
+ selector.close()
246
246
  ```
247
247
 
248
248
  ### Combined with HttpServer
249
249
 
250
- Server and client in the same select loop — true single-threaded concurrency:
250
+ Server and client in the same selector loop — true single-threaded concurrency. Both register in the same selector, and `key.data.handle_event()` dispatches to whichever owns the ready socket:
251
251
 
252
252
  ```python
253
- import select
253
+ import selectors
254
254
  import uhttp.server
255
255
  import uhttp.client
256
256
 
257
- server = uhttp.server.HttpServer(port=8080)
258
- backend = uhttp.client.HttpClient('http://api.example.com')
257
+ selector = selectors.DefaultSelector()
258
+ server = uhttp.server.HttpServer(port=8080, selector=selector)
259
+ backend = uhttp.client.HttpClient('http://api.example.com', selector=selector)
259
260
 
261
+ incoming = None
260
262
  while True:
261
- r, w, _ = select.select(
262
- server.read_sockets + backend.read_sockets,
263
- server.write_sockets + backend.write_sockets,
264
- [], 1.0
265
- )
266
-
267
- # Handle incoming requests
268
- incoming = server.process_events(r, w)
269
- if incoming:
270
- backend.get('/data', query=incoming.query)
271
-
272
- # Handle backend response
273
- response = backend.process_events(r, w)
274
- if response:
275
- incoming.respond(data=response.data)
263
+ for key, mask in selector.select(1.0):
264
+ ready = key.data.handle_event(key.fileobj, mask)
265
+ if ready is None:
266
+ continue
267
+ if isinstance(ready, uhttp.server.HttpConnection):
268
+ incoming = ready # request in
269
+ backend.get('/data', query=ready.query)
270
+ elif ready is backend and incoming:
271
+ incoming.respond(data=backend.response.data) # response out
272
+ incoming = None
273
+ server.maintenance()
274
+ backend.maintenance() # a hung backend has no event to time out on
276
275
  ```
277
276
 
278
277
  ### HTTPS with non-blocking handshake
279
278
 
280
- SSL handshake is also non-blocking. The client tracks whether `do_handshake()` needs to read or write, and exposes the socket only in the correct direction to prevent `select()` from spinning:
279
+ SSL handshake is also non-blocking. The client tracks whether `do_handshake()` needs to read or write, and arms only that direction to prevent the selector from spinning:
281
280
 
282
281
  ```python
283
- import select
284
282
  import ssl
285
283
  import uhttp.client
286
284
 
@@ -288,19 +286,16 @@ ctx = ssl.create_default_context()
288
286
  client = uhttp.client.HttpClient(
289
287
  'api.example.com', port=443, ssl_context=ctx)
290
288
 
291
- # Connect + SSL handshake + request all happen via select
289
+ # Connect + SSL handshake + request all happen via the selector
292
290
  client.get('/data')
293
291
 
294
- while True:
295
- r, w, _ = select.select(
296
- client.read_sockets,
297
- client.write_sockets,
298
- [], 10.0
299
- )
300
- response = client.process_events(r, w)
301
- if response:
302
- print(response.json())
303
- break
292
+ done = False
293
+ while not done:
294
+ for key, mask in client.selector.select(10.0):
295
+ if key.data.handle_event(key.fileobj, mask) is not None:
296
+ print(client.response.json())
297
+ done = True
298
+ client.maintenance()
304
299
 
305
300
  client.close()
306
301
  ```
@@ -308,7 +303,7 @@ client.close()
308
303
  ### Multiple HTTPS clients in parallel
309
304
 
310
305
  ```python
311
- import select
306
+ import selectors
312
307
  import uhttp.client
313
308
 
314
309
  urls = [
@@ -317,28 +312,26 @@ urls = [
317
312
  'https://api3.example.com/data',
318
313
  ]
319
314
 
320
- clients = [uhttp.client.HttpClient(url) for url in urls]
315
+ selector = selectors.DefaultSelector()
316
+ clients = [
317
+ uhttp.client.HttpClient(url, selector=selector) for url in urls]
321
318
  for c in clients:
322
319
  c.get('/') # All start non-blocking connects + SSL handshakes
323
320
 
324
321
  responses = [None] * len(clients)
325
322
  while not all(responses):
326
- read_socks = []
327
- write_socks = []
323
+ for key, mask in selector.select(10.0):
324
+ ready = key.data.handle_event(key.fileobj, mask)
325
+ if ready is not None:
326
+ responses[clients.index(ready)] = ready.response
328
327
  for c in clients:
329
- read_socks.extend(c.read_sockets)
330
- write_socks.extend(c.write_sockets)
331
-
332
- r, w, _ = select.select(read_socks, write_socks, [], 10.0)
333
-
334
- for i, c in enumerate(clients):
335
- if responses[i] is None:
336
- resp = c.process_events(r, w)
337
- if resp:
338
- responses[i] = resp
328
+ while c.next(): # drain results buffered from one recv
329
+ responses[clients.index(c)] = c.response
330
+ c.maintenance()
339
331
 
340
332
  for c in clients:
341
333
  c.close()
334
+ selector.close()
342
335
  ```
343
336
 
344
337
 
@@ -346,9 +339,10 @@ for c in clients:
346
339
 
347
340
  For large or open-ended responses (downloads, NDJSON, MJPEG, SSE) the client
348
341
  offers an **event mode** that mirrors uhttp-server's `HttpConnection` API.
349
- With `event_mode=True`, `wait()` / `process_events()` return `EVENT_*`
350
- constants instead of an `HttpResponse`, and you choose how the body is
351
- delivered after the headers arrive.
342
+ With `event_mode=True`, `wait()` returns `EVENT_*` constants instead of an
343
+ `HttpResponse`, and you choose how the body is delivered after the headers
344
+ arrive. (`handle_event()` returns `self`/`None` in both modes — read the value
345
+ from `client.event`.)
352
346
 
353
347
  ### Events
354
348
 
@@ -360,7 +354,7 @@ delivered after the headers arrive.
360
354
  | `EVENT_COMPLETE` | Body fully received |
361
355
  | `EVENT_ERROR` | Connection or decode error → message in `client.error` (no exception) |
362
356
 
363
- Names and numeric values match uhttp-server, so the same select loop can drive
357
+ Names and numeric values match uhttp-server, so the same selector loop can drive
364
358
  both a server and a client.
365
359
 
366
360
  ### Body delivery (choose after `EVENT_HEADERS`)
@@ -378,7 +372,6 @@ new event type.
378
372
  ### NDJSON streaming
379
373
 
380
374
  ```python
381
- import select
382
375
  from uhttp.client import (
383
376
  HttpClient, EVENT_HEADERS, EVENT_DATA, EVENT_COMPLETE, EVENT_ERROR)
384
377
 
@@ -386,8 +379,7 @@ client = HttpClient('http://api.example.com', event_mode=True)
386
379
  client.get('/events.ndjson', stream=True) # stream until close if unframed
387
380
 
388
381
  while True:
389
- r, w, _ = select.select(client.read_sockets, client.write_sockets, [], 30)
390
- event = client.process_events(r, w)
382
+ event = client.wait(30) # drains next() first, then selects
391
383
 
392
384
  if event == EVENT_HEADERS:
393
385
  client.accept_ndjson()
@@ -414,8 +406,7 @@ client = HttpClient('http://example.com', event_mode=True)
414
406
  client.get('/firmware.bin')
415
407
 
416
408
  while True:
417
- r, w, _ = select.select(client.read_sockets, client.write_sockets, [], 10)
418
- event = client.process_events(r, w)
409
+ event = client.wait(10)
419
410
  if event == EVENT_HEADERS:
420
411
  client.accept_body_to_file('/sd/firmware.bin')
421
412
  elif event == EVENT_COMPLETE:
@@ -500,8 +491,12 @@ Parameters:
500
491
  - `connect_timeout` - Connection timeout in seconds (default: 10)
501
492
  - `timeout` - Response timeout in seconds (default: 30)
502
493
  - `max_response_length` - Maximum buffered body size (default: 1MB)
503
- - `event_mode` - If `True`, `wait()`/`process_events()` return `EVENT_*`
504
- constants instead of `HttpResponse` (see [Streaming & Event Mode](#streaming--event-mode))
494
+ - `event_mode` - If `True`, `wait()` returns `EVENT_*` constants instead of
495
+ `HttpResponse` (see [Streaming & Event Mode](#streaming--event-mode))
496
+ - `selector` - A `selectors.BaseSelector` to register the socket in (default: a
497
+ `DefaultSelector` the client owns and closes). Pass the same instance to
498
+ several clients / servers to drive them from one loop — then drive it with
499
+ `handle_event()` + `maintenance()`, because `wait()` needs an owned selector.
505
500
 
506
501
  #### Properties
507
502
 
@@ -512,8 +507,9 @@ Parameters:
512
507
  - `state` - Current state (STATE_IDLE, STATE_CONNECTING, STATE_SSL_HANDSHAKE, STATE_SENDING, etc.)
513
508
  - `auth` - Authentication credentials tuple (username, password) or None
514
509
  - `cookies` - Cookies dict (persistent across requests)
515
- - `read_sockets` - Sockets to monitor for reading (for select)
516
- - `write_sockets` - Sockets to monitor for writing (for select)
510
+ - `selector` - The `selectors.BaseSelector` the client registers its socket in.
511
+ Read events from it and dispatch via `key.data.handle_event()` for a
512
+ shared-selector loop across several clients / servers / your own sockets.
517
513
 
518
514
  Event-mode properties (available once headers are received):
519
515
 
@@ -560,22 +556,53 @@ Start HTTP request (async). Returns `self` for chaining.
560
556
 
561
557
  Wait for response (blocking).
562
558
 
559
+ Single-client convenience backed by the client's own selector: it drains
560
+ `next()` first, then selects and dispatches.
561
+
562
+ **Requires an owned selector.** A blocking wait cannot service the other
563
+ owners' ready keys of a shared selector, and level-triggered readiness would
564
+ hand them back on every call — a busy spin. With an injected selector `wait()`
565
+ raises `HttpClientError`; drive that loop yourself with `handle_event()` and
566
+ `maintenance()`.
567
+
563
568
  - Classic mode: returns `HttpResponse` when complete; raises `HttpTimeoutError`
564
- if the request timeout expires; returns `None` if the wait timeout expires
565
- (connection stays open, can call again).
566
- - Event mode: returns the next `EVENT_*` constant, or `None` when the wait
567
- timeout expires with nothing new.
569
+ (and closes the connection) when the request or wait timeout expires.
570
+ - Event mode: returns the next `EVENT_*` constant, or `None` when the timeout
571
+ expires with nothing new (the connection stays open call again).
568
572
  - `timeout` - Max time to spend in wait() call. If `None`, uses request timeout.
569
573
 
570
- **`process_events(read_sockets, write_sockets)`**
574
+ **`handle_event(fileobj, mask)`**
575
+
576
+ Owner dispatch for a selector event (the client is stored as `key.data`).
577
+ Drives read/write for the given readiness `mask` and returns the client itself
578
+ when a result is ready, else `None`. Read the result from `response` (classic
579
+ mode) or `event` (event mode) — it returns `self` rather than the value
580
+ because `EVENT_RESPONSE` is `0` and would be falsy.
581
+
582
+ In classic mode connection errors raise; in event mode they surface as
583
+ `EVENT_ERROR` with the message in `error`.
584
+
585
+ **`next()`**
571
586
 
572
- Process select events from an external select loop.
587
+ Process a result already buffered locally, returning `True` while another one
588
+ is ready. One `recv()` can carry several NDJSON records or body chunks, and an
589
+ SSL socket can hold decrypted bytes the selector will never report — both are
590
+ invisible to the selector, so drain with `next()` before blocking again.
591
+ `wait()` does this for you.
573
592
 
574
- - Classic mode: returns `HttpResponse` when complete (`None` otherwise); raises
575
- on errors.
576
- - Event mode: returns an `EVENT_*` constant (`None` when nothing new yet);
577
- connection/decode errors surface as `EVENT_ERROR` with the message in
578
- `client.error`.
593
+ **`maintenance()`**
594
+
595
+ Enforce the request deadline and expire an idle kept-alive connection. A
596
+ shared-selector loop only calls `handle_event()` for *ready* keys, so a hung
597
+ peer would otherwise leave the request pending forever — call this once per
598
+ loop iteration (`wait()` does it for you). It reports rather than raises, in
599
+ both modes: the client is returned with the reason in `error` (event mode also
600
+ sets `event` to `EVENT_ERROR`), else `None`. One hung peer must not abort a
601
+ loop that serves other owners — only `wait()` raises `HttpTimeoutError`.
602
+
603
+ It also applies the server's `Keep-Alive` hint to an idle connection, though
604
+ the hint is honoured on reuse as well, so a plain `get().wait()` caller does
605
+ not have to schedule `maintenance()` for that alone.
579
606
 
580
607
  #### Event-mode body methods
581
608
 
@@ -738,6 +765,30 @@ for i in range(10):
738
765
  client.close()
739
766
  ```
740
767
 
768
+ ### How it actually works
769
+
770
+ HTTP/1.1 keep-alive sends **nothing** over the wire while idle — it is only an
771
+ agreement not to close the socket after the response. (The thing that does send
772
+ idle probes is TCP `SO_KEEPALIVE`, a different layer handled by the kernel.)
773
+ Either side may close at any time without announcing it, so the client handles
774
+ it in three ways:
775
+
776
+ 1. **The idle socket stays armed for reading.** On an idle HTTP/1.1 connection
777
+ the server must not send anything, so readability means the peer closed —
778
+ the client drops the socket right away instead of discovering it on the next
779
+ request. Needs a running loop (`wait()` or a shared selector).
780
+ 2. **The `Keep-Alive: timeout=5, max=100` hint is honoured.** If the server
781
+ advertises its idle limit, `maintenance()` closes slightly before it (90% of
782
+ the advertised timeout), so a new request never races the server's close.
783
+ 3. **One transparent replay.** If a *reused* connection dies before any
784
+ response byte arrives, the client reconnects and resends — once, and only
785
+ for idempotent methods (GET/HEAD/PUT/DELETE/OPTIONS/TRACE). A non-idempotent
786
+ request may already have been processed by the server, so it is reported
787
+ instead.
788
+
789
+ Together these mean an idle connection that the server recycles is normally
790
+ invisible to your code.
791
+
741
792
 
742
793
  ## Timeouts
743
794
 
@@ -777,21 +828,31 @@ Both `connect_timeout` and `timeout` are checked during connect/handshake phases
777
828
 
778
829
  ### Wait timeout
779
830
 
780
- Time to spend in `wait()` call. When expired, returns `None` but keeps connection open.
781
- Useful for polling or interleaving with other work.
831
+ Time to spend in a single `wait()` call.
832
+
833
+ In **classic mode** an expired wait raises `HttpTimeoutError` and closes the
834
+ connection — it is not a poll. To interleave with other work, use event mode,
835
+ where `wait()` returns `None` on expiry and the request stays alive:
782
836
 
783
837
  ```python
784
838
  import uhttp.client
839
+ from uhttp.client import EVENT_RESPONSE, EVENT_ERROR
785
840
 
786
- client = uhttp.client.HttpClient('https://example.com', timeout=60) # request timeout
841
+ client = uhttp.client.HttpClient(
842
+ 'https://example.com', timeout=60, event_mode=True)
787
843
  client.get('/slow')
788
844
 
789
- # Try for 5 seconds, then do something else
790
- response = client.wait(timeout=5)
791
- if response is None:
792
- print("Still waiting, doing other work...")
793
- # Can call wait() again
794
- response = client.wait(timeout=10)
845
+ while True:
846
+ event = client.wait(timeout=5) # None once per idle 5s slice
847
+ if event is None:
848
+ print("Still waiting, doing other work...")
849
+ continue
850
+ if event == EVENT_RESPONSE:
851
+ print(client.response.status)
852
+ break
853
+ if event == EVENT_ERROR:
854
+ print(client.error)
855
+ break
795
856
  ```
796
857
 
797
858
 
@@ -832,7 +893,7 @@ MAX_RESPONSE_LENGTH = 1MB
832
893
  See [examples/](../examples/) directory:
833
894
  - `client_basic.py` - Basic blocking examples
834
895
  - `client_https.py` - HTTPS examples
835
- - `client_async.py` - Async select loop examples
896
+ - `client_async.py` - Async selector loop examples (incl. shared selector)
836
897
  - `client_stream.py` - Event-mode streaming (download-to-file, chunks, NDJSON)
837
898
  - `client_with_server.py` - Combined server + client examples
838
899