uhttp-workers 1.3.0__tar.gz → 1.5.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.
- {uhttp_workers-1.3.0/uhttp_workers.egg-info → uhttp_workers-1.5.0}/PKG-INFO +64 -2
- {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/README.md +62 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/pyproject.toml +1 -1
- {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/tests/test_dispatcher.py +218 -1
- {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/tests/test_worker.py +33 -1
- {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/uhttp/workers.py +82 -1
- {uhttp_workers-1.3.0 → uhttp_workers-1.5.0/uhttp_workers.egg-info}/PKG-INFO +64 -2
- uhttp_workers-1.5.0/uhttp_workers.egg-info/requires.txt +1 -0
- uhttp_workers-1.3.0/uhttp_workers.egg-info/requires.txt +0 -1
- {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/.github/workflows/publish.yml +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/.github/workflows/tests.yml +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/.gitignore +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/examples/simple_workers.py +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/examples/sse_workers.py +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/examples/static/index.html +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/setup.cfg +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/tests/__init__.py +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/tests/test_api_handler.py +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/tests/test_decorators.py +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/tests/test_pattern_matching.py +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/tests/test_request_response.py +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/tests/test_worker_pool.py +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/uhttp_workers.egg-info/SOURCES.txt +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/uhttp_workers.egg-info/dependency_links.txt +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/uhttp_workers.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: uhttp-workers
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: Multi-process worker dispatcher built on uhttp-server
|
|
5
5
|
Author-email: Pavel Revak <pavelrevak@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -10,7 +10,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
10
10
|
Classifier: Operating System :: POSIX
|
|
11
11
|
Requires-Python: >=3.10
|
|
12
12
|
Description-Content-Type: text/markdown
|
|
13
|
-
Requires-Dist: uhttp-server
|
|
13
|
+
Requires-Dist: uhttp-server>=2.5.2
|
|
14
14
|
|
|
15
15
|
# uhttp-workers
|
|
16
16
|
|
|
@@ -384,6 +384,31 @@ Available streaming methods on `Request`:
|
|
|
384
384
|
|
|
385
385
|
Streaming requests are excluded from dispatcher timeout expiration. When the client disconnects, the dispatcher notifies the worker via `on_disconnect(request_id)`.
|
|
386
386
|
|
|
387
|
+
### NDJSON Streaming
|
|
388
|
+
|
|
389
|
+
Stream JSON objects line-by-line (`application/x-ndjson`) — one JSON value per line, terminated by `\n`. Useful for incremental APIs that aren't event-shaped (long lists, log tails, progress updates):
|
|
390
|
+
|
|
391
|
+
```python
|
|
392
|
+
class MyWorker(_workers.Worker):
|
|
393
|
+
@_workers.api('/devices/scan', 'GET')
|
|
394
|
+
def scan(self, request):
|
|
395
|
+
request.response_ndjson()
|
|
396
|
+
for device in self.discover_devices():
|
|
397
|
+
request.send_ndjson({'id': device.id, 'name': device.name})
|
|
398
|
+
request.response_stream_end()
|
|
399
|
+
return _workers.DEFERRED
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
NDJSON methods on `Request`:
|
|
403
|
+
|
|
404
|
+
| Method | Description |
|
|
405
|
+
|--------|-------------|
|
|
406
|
+
| `response_ndjson(headers, cookies)` | Start NDJSON stream (wrapper over `response_stream` with `application/x-ndjson`) |
|
|
407
|
+
| `send_ndjson(obj)` | Send one JSON-serializable value as a line |
|
|
408
|
+
| `response_stream_end()` | End stream and close connection (shared with SSE) |
|
|
409
|
+
|
|
410
|
+
Same lifecycle as SSE: excluded from timeout expiration, client disconnect triggers `on_disconnect(request_id)`.
|
|
411
|
+
|
|
387
412
|
### Flow Control
|
|
388
413
|
|
|
389
414
|
Workers can stop accepting new requests when busy. Requests stay in the shared pool queue for other workers to pick up:
|
|
@@ -513,6 +538,43 @@ class MyDispatcher(_workers.Dispatcher):
|
|
|
513
538
|
`pending` is a `_PendingRequest` with `client` (original connection) and `pool` (source pool).
|
|
514
539
|
Requests with `request_id=-1` are ignored by the dispatcher when the worker responds.
|
|
515
540
|
|
|
541
|
+
`on_response()` fires only for the happy path (handler returned a response). For lifecycle
|
|
542
|
+
cleanup that must run regardless of outcome — timeouts, client disconnects, shutdown — use
|
|
543
|
+
`on_pending_removed()` (see below).
|
|
544
|
+
|
|
545
|
+
## Request Lifecycle Hook
|
|
546
|
+
|
|
547
|
+
Override `on_pending_removed(request_id, pending, reason)` on the dispatcher when you keep
|
|
548
|
+
side-state keyed by `request_id` and need it cleaned up exactly once, no matter how the
|
|
549
|
+
request ended:
|
|
550
|
+
|
|
551
|
+
```python
|
|
552
|
+
class MyDispatcher(_workers.Dispatcher):
|
|
553
|
+
def on_pending_removed(self, request_id, pending, reason):
|
|
554
|
+
self._side_state.pop(request_id, None)
|
|
555
|
+
if reason == _workers.PENDING_TIMEOUT:
|
|
556
|
+
self._metrics.timeouts += 1
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
Reason is one of:
|
|
560
|
+
|
|
561
|
+
| Constant | When |
|
|
562
|
+
|---|---|
|
|
563
|
+
| `PENDING_COMPLETED` | Handler returned a response, client got it. `on_response()` runs first. |
|
|
564
|
+
| `PENDING_TIMEOUT` | Request exceeded `pool.timeout`; client got 504. Worker may still be processing. |
|
|
565
|
+
| `PENDING_DISCONNECTED` | Client disconnected mid-stream; worker was notified via control queue (race possible). |
|
|
566
|
+
| `PENDING_STREAM_CLOSED` | Worker ended the SSE stream cleanly. |
|
|
567
|
+
| `PENDING_SHUTDOWN` | Dispatcher is shutting down; client got 503. |
|
|
568
|
+
|
|
569
|
+
The hook is invoked after the client-facing action (respond / disconnect / control queue put)
|
|
570
|
+
so dispatcher state is finalized when it runs. Exceptions raised by the hook are logged at
|
|
571
|
+
`LOG_ERROR` and swallowed — they will not crash the dispatcher loop.
|
|
572
|
+
|
|
573
|
+
Override `on_response()` if you only care about the happy path (e.g. cross-pool forwarding).
|
|
574
|
+
Override `on_pending_removed()` if you need exactly-once cleanup. Overriding both is allowed
|
|
575
|
+
but discouraged — for the `PENDING_COMPLETED` reason, `on_response()` is called immediately
|
|
576
|
+
before `on_pending_removed()`.
|
|
577
|
+
|
|
516
578
|
## Dispatcher Idle Hook
|
|
517
579
|
|
|
518
580
|
Override `on_idle()` on the dispatcher for periodic background tasks — called on each `select()` timeout (every `SELECT_TIMEOUT` seconds, default 1s):
|
|
@@ -370,6 +370,31 @@ Available streaming methods on `Request`:
|
|
|
370
370
|
|
|
371
371
|
Streaming requests are excluded from dispatcher timeout expiration. When the client disconnects, the dispatcher notifies the worker via `on_disconnect(request_id)`.
|
|
372
372
|
|
|
373
|
+
### NDJSON Streaming
|
|
374
|
+
|
|
375
|
+
Stream JSON objects line-by-line (`application/x-ndjson`) — one JSON value per line, terminated by `\n`. Useful for incremental APIs that aren't event-shaped (long lists, log tails, progress updates):
|
|
376
|
+
|
|
377
|
+
```python
|
|
378
|
+
class MyWorker(_workers.Worker):
|
|
379
|
+
@_workers.api('/devices/scan', 'GET')
|
|
380
|
+
def scan(self, request):
|
|
381
|
+
request.response_ndjson()
|
|
382
|
+
for device in self.discover_devices():
|
|
383
|
+
request.send_ndjson({'id': device.id, 'name': device.name})
|
|
384
|
+
request.response_stream_end()
|
|
385
|
+
return _workers.DEFERRED
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
NDJSON methods on `Request`:
|
|
389
|
+
|
|
390
|
+
| Method | Description |
|
|
391
|
+
|--------|-------------|
|
|
392
|
+
| `response_ndjson(headers, cookies)` | Start NDJSON stream (wrapper over `response_stream` with `application/x-ndjson`) |
|
|
393
|
+
| `send_ndjson(obj)` | Send one JSON-serializable value as a line |
|
|
394
|
+
| `response_stream_end()` | End stream and close connection (shared with SSE) |
|
|
395
|
+
|
|
396
|
+
Same lifecycle as SSE: excluded from timeout expiration, client disconnect triggers `on_disconnect(request_id)`.
|
|
397
|
+
|
|
373
398
|
### Flow Control
|
|
374
399
|
|
|
375
400
|
Workers can stop accepting new requests when busy. Requests stay in the shared pool queue for other workers to pick up:
|
|
@@ -499,6 +524,43 @@ class MyDispatcher(_workers.Dispatcher):
|
|
|
499
524
|
`pending` is a `_PendingRequest` with `client` (original connection) and `pool` (source pool).
|
|
500
525
|
Requests with `request_id=-1` are ignored by the dispatcher when the worker responds.
|
|
501
526
|
|
|
527
|
+
`on_response()` fires only for the happy path (handler returned a response). For lifecycle
|
|
528
|
+
cleanup that must run regardless of outcome — timeouts, client disconnects, shutdown — use
|
|
529
|
+
`on_pending_removed()` (see below).
|
|
530
|
+
|
|
531
|
+
## Request Lifecycle Hook
|
|
532
|
+
|
|
533
|
+
Override `on_pending_removed(request_id, pending, reason)` on the dispatcher when you keep
|
|
534
|
+
side-state keyed by `request_id` and need it cleaned up exactly once, no matter how the
|
|
535
|
+
request ended:
|
|
536
|
+
|
|
537
|
+
```python
|
|
538
|
+
class MyDispatcher(_workers.Dispatcher):
|
|
539
|
+
def on_pending_removed(self, request_id, pending, reason):
|
|
540
|
+
self._side_state.pop(request_id, None)
|
|
541
|
+
if reason == _workers.PENDING_TIMEOUT:
|
|
542
|
+
self._metrics.timeouts += 1
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
Reason is one of:
|
|
546
|
+
|
|
547
|
+
| Constant | When |
|
|
548
|
+
|---|---|
|
|
549
|
+
| `PENDING_COMPLETED` | Handler returned a response, client got it. `on_response()` runs first. |
|
|
550
|
+
| `PENDING_TIMEOUT` | Request exceeded `pool.timeout`; client got 504. Worker may still be processing. |
|
|
551
|
+
| `PENDING_DISCONNECTED` | Client disconnected mid-stream; worker was notified via control queue (race possible). |
|
|
552
|
+
| `PENDING_STREAM_CLOSED` | Worker ended the SSE stream cleanly. |
|
|
553
|
+
| `PENDING_SHUTDOWN` | Dispatcher is shutting down; client got 503. |
|
|
554
|
+
|
|
555
|
+
The hook is invoked after the client-facing action (respond / disconnect / control queue put)
|
|
556
|
+
so dispatcher state is finalized when it runs. Exceptions raised by the hook are logged at
|
|
557
|
+
`LOG_ERROR` and swallowed — they will not crash the dispatcher loop.
|
|
558
|
+
|
|
559
|
+
Override `on_response()` if you only care about the happy path (e.g. cross-pool forwarding).
|
|
560
|
+
Override `on_pending_removed()` if you need exactly-once cleanup. Overriding both is allowed
|
|
561
|
+
but discouraged — for the `PENDING_COMPLETED` reason, `on_response()` is called immediately
|
|
562
|
+
before `on_pending_removed()`.
|
|
563
|
+
|
|
502
564
|
## Dispatcher Idle Hook
|
|
503
565
|
|
|
504
566
|
Override `on_idle()` on the dispatcher for periodic background tasks — called on each `select()` timeout (every `SELECT_TIMEOUT` seconds, default 1s):
|
|
@@ -10,8 +10,11 @@ from uhttp.workers import (
|
|
|
10
10
|
Dispatcher, Worker, WorkerPool, Request, Response,
|
|
11
11
|
api, sync, RejectRequest,
|
|
12
12
|
MSG_RESPONSE, MSG_HEARTBEAT,
|
|
13
|
-
MSG_SSE_OPEN, MSG_SSE_EVENT, MSG_SSE_CLOSE,
|
|
13
|
+
MSG_SSE_OPEN, MSG_SSE_EVENT, MSG_SSE_CLOSE, MSG_NDJSON,
|
|
14
14
|
CTL_DISCONNECT,
|
|
15
|
+
PENDING_COMPLETED, PENDING_TIMEOUT, PENDING_DISCONNECTED,
|
|
16
|
+
PENDING_STREAM_CLOSED, PENDING_SHUTDOWN,
|
|
17
|
+
LOG_ERROR,
|
|
15
18
|
_PendingRequest,
|
|
16
19
|
)
|
|
17
20
|
|
|
@@ -75,6 +78,12 @@ class MockClient:
|
|
|
75
78
|
self._chunks.append(data)
|
|
76
79
|
return getattr(self, '_connected', True)
|
|
77
80
|
|
|
81
|
+
def send_ndjson(self, obj):
|
|
82
|
+
if not hasattr(self, '_ndjson'):
|
|
83
|
+
self._ndjson = []
|
|
84
|
+
self._ndjson.append(obj)
|
|
85
|
+
return getattr(self, '_connected', True)
|
|
86
|
+
|
|
78
87
|
def response_stream_end(self):
|
|
79
88
|
self.stream_ended = True
|
|
80
89
|
|
|
@@ -544,6 +553,42 @@ class TestDispatcherSSE(unittest.TestCase):
|
|
|
544
553
|
# non-streaming should be expired
|
|
545
554
|
self.assertNotIn(1, d._pending)
|
|
546
555
|
|
|
556
|
+
def test_ndjson_send(self):
|
|
557
|
+
d, pool = self._make_dispatcher()
|
|
558
|
+
client = MockClient('GET', '/api/stream')
|
|
559
|
+
pending = _PendingRequest(client, pool)
|
|
560
|
+
pending.streaming = True
|
|
561
|
+
d._pending[1] = pending
|
|
562
|
+
d._process_response(
|
|
563
|
+
(MSG_NDJSON, 1, {'devices': [1, 2, 3]}))
|
|
564
|
+
self.assertEqual(len(client._ndjson), 1)
|
|
565
|
+
self.assertEqual(client._ndjson[0], {'devices': [1, 2, 3]})
|
|
566
|
+
# still pending — stream open
|
|
567
|
+
self.assertIn(1, d._pending)
|
|
568
|
+
|
|
569
|
+
def test_ndjson_client_disconnect(self):
|
|
570
|
+
d, pool = self._make_dispatcher()
|
|
571
|
+
pool.start(d._response_queue)
|
|
572
|
+
client = MockClient('GET', '/api/stream')
|
|
573
|
+
client._connected = False
|
|
574
|
+
pending = _PendingRequest(client, pool)
|
|
575
|
+
pending.streaming = True
|
|
576
|
+
pending.worker_id = 0
|
|
577
|
+
d._pending[1] = pending
|
|
578
|
+
d._process_response((MSG_NDJSON, 1, {'x': 1}))
|
|
579
|
+
# removed from pending
|
|
580
|
+
self.assertNotIn(1, d._pending)
|
|
581
|
+
# CTL_DISCONNECT sent to worker's control queue
|
|
582
|
+
msg = pool._control_queues[0].get(timeout=1)
|
|
583
|
+
self.assertEqual(msg, (CTL_DISCONNECT, 1))
|
|
584
|
+
pool.shutdown(timeout=2)
|
|
585
|
+
|
|
586
|
+
def test_ndjson_ignored_after_close(self):
|
|
587
|
+
d, pool = self._make_dispatcher()
|
|
588
|
+
# no pending request with id 99
|
|
589
|
+
d._process_response((MSG_NDJSON, 99, {'x': 1}))
|
|
590
|
+
# should not raise, just ignore
|
|
591
|
+
|
|
547
592
|
def test_sse_event_ignored_after_close(self):
|
|
548
593
|
d, pool = self._make_dispatcher()
|
|
549
594
|
# no pending request with id 99
|
|
@@ -552,5 +597,177 @@ class TestDispatcherSSE(unittest.TestCase):
|
|
|
552
597
|
# should not raise, just ignore
|
|
553
598
|
|
|
554
599
|
|
|
600
|
+
class TestDispatcherPendingRemoved(unittest.TestCase):
|
|
601
|
+
"""Tests for the on_pending_removed lifecycle hook."""
|
|
602
|
+
|
|
603
|
+
def _make_dispatcher(self, dispatcher_cls=Dispatcher):
|
|
604
|
+
pool = WorkerPool(DummyWorker, routes=['/api/**'])
|
|
605
|
+
d = dispatcher_cls.__new__(dispatcher_cls)
|
|
606
|
+
d._sync_routes = []
|
|
607
|
+
d._static_routes = {}
|
|
608
|
+
d._pools = [pool]
|
|
609
|
+
d._pending = {}
|
|
610
|
+
d._max_pending = 1000
|
|
611
|
+
d._next_request_id = 0
|
|
612
|
+
d._response_queue = mp.Queue()
|
|
613
|
+
d._log_is_tty = False
|
|
614
|
+
d.log_calls = []
|
|
615
|
+
d.on_log = lambda name, level, msg: d.log_calls.append(
|
|
616
|
+
(name, level, msg))
|
|
617
|
+
d.recorded = []
|
|
618
|
+
return d, pool
|
|
619
|
+
|
|
620
|
+
def test_completed_fires_hook(self):
|
|
621
|
+
|
|
622
|
+
class RecordingDispatcher(Dispatcher):
|
|
623
|
+
def on_pending_removed(self, request_id, pending, reason):
|
|
624
|
+
self.recorded.append((request_id, reason))
|
|
625
|
+
|
|
626
|
+
d, pool = self._make_dispatcher(RecordingDispatcher)
|
|
627
|
+
client = MockClient('GET', '/api/test')
|
|
628
|
+
pending = _PendingRequest(client, pool)
|
|
629
|
+
d._pending[1] = pending
|
|
630
|
+
response = Response(request_id=1, data={'ok': True}, status=200)
|
|
631
|
+
d._process_response((MSG_RESPONSE, 1, response))
|
|
632
|
+
self.assertEqual(d.recorded, [(1, PENDING_COMPLETED)])
|
|
633
|
+
|
|
634
|
+
def test_completed_calls_on_response_before_hook(self):
|
|
635
|
+
|
|
636
|
+
class RecordingDispatcher(Dispatcher):
|
|
637
|
+
def on_response(self, response, pending):
|
|
638
|
+
self.recorded.append(('on_response', response.request_id))
|
|
639
|
+
|
|
640
|
+
def on_pending_removed(self, request_id, pending, reason):
|
|
641
|
+
self.recorded.append(('hook', request_id, reason))
|
|
642
|
+
|
|
643
|
+
d, pool = self._make_dispatcher(RecordingDispatcher)
|
|
644
|
+
client = MockClient('GET', '/api/test')
|
|
645
|
+
pending = _PendingRequest(client, pool)
|
|
646
|
+
d._pending[1] = pending
|
|
647
|
+
response = Response(request_id=1, data={'ok': True}, status=200)
|
|
648
|
+
d._process_response((MSG_RESPONSE, 1, response))
|
|
649
|
+
self.assertEqual(d.recorded, [
|
|
650
|
+
('on_response', 1),
|
|
651
|
+
('hook', 1, PENDING_COMPLETED),
|
|
652
|
+
])
|
|
653
|
+
|
|
654
|
+
def test_timeout_fires_hook(self):
|
|
655
|
+
|
|
656
|
+
class RecordingDispatcher(Dispatcher):
|
|
657
|
+
def on_pending_removed(self, request_id, pending, reason):
|
|
658
|
+
self.recorded.append((request_id, reason))
|
|
659
|
+
|
|
660
|
+
d, pool = self._make_dispatcher(RecordingDispatcher)
|
|
661
|
+
client = MockClient('GET', '/api/test')
|
|
662
|
+
pending = _PendingRequest(client, pool)
|
|
663
|
+
pending.timestamp = 0 # very old
|
|
664
|
+
d._pending[1] = pending
|
|
665
|
+
d._expire_pending()
|
|
666
|
+
self.assertNotIn(1, d._pending)
|
|
667
|
+
self.assertEqual(client.response_status, 504)
|
|
668
|
+
self.assertEqual(d.recorded, [(1, PENDING_TIMEOUT)])
|
|
669
|
+
|
|
670
|
+
def test_stream_closed_fires_hook(self):
|
|
671
|
+
|
|
672
|
+
class RecordingDispatcher(Dispatcher):
|
|
673
|
+
def on_pending_removed(self, request_id, pending, reason):
|
|
674
|
+
self.recorded.append((request_id, reason))
|
|
675
|
+
|
|
676
|
+
d, pool = self._make_dispatcher(RecordingDispatcher)
|
|
677
|
+
client = MockClient('GET', '/api/events')
|
|
678
|
+
pending = _PendingRequest(client, pool)
|
|
679
|
+
pending.streaming = True
|
|
680
|
+
d._pending[1] = pending
|
|
681
|
+
d._process_response((MSG_SSE_CLOSE, 1))
|
|
682
|
+
self.assertTrue(client.stream_ended)
|
|
683
|
+
self.assertEqual(d.recorded, [(1, PENDING_STREAM_CLOSED)])
|
|
684
|
+
|
|
685
|
+
def test_disconnect_fires_hook(self):
|
|
686
|
+
|
|
687
|
+
class RecordingDispatcher(Dispatcher):
|
|
688
|
+
def on_pending_removed(self, request_id, pending, reason):
|
|
689
|
+
self.recorded.append((request_id, reason))
|
|
690
|
+
|
|
691
|
+
d, pool = self._make_dispatcher(RecordingDispatcher)
|
|
692
|
+
client = MockClient('GET', '/api/events')
|
|
693
|
+
client._connected = False
|
|
694
|
+
pending = _PendingRequest(client, pool)
|
|
695
|
+
pending.streaming = True
|
|
696
|
+
pending.worker_id = None # skip control queue routing
|
|
697
|
+
d._pending[1] = pending
|
|
698
|
+
d._process_response(
|
|
699
|
+
(MSG_SSE_EVENT, 1, {'data': 'x'}, 'ping', None, None))
|
|
700
|
+
self.assertNotIn(1, d._pending)
|
|
701
|
+
self.assertEqual(d.recorded, [(1, PENDING_DISCONNECTED)])
|
|
702
|
+
|
|
703
|
+
def test_shutdown_fires_hook(self):
|
|
704
|
+
|
|
705
|
+
class RecordingDispatcher(Dispatcher):
|
|
706
|
+
def on_pending_removed(self, request_id, pending, reason):
|
|
707
|
+
self.recorded.append((request_id, reason))
|
|
708
|
+
|
|
709
|
+
d, pool = self._make_dispatcher(RecordingDispatcher)
|
|
710
|
+
|
|
711
|
+
class FakeHttpServer:
|
|
712
|
+
def close(self):
|
|
713
|
+
pass
|
|
714
|
+
|
|
715
|
+
class FakePool:
|
|
716
|
+
def shutdown(self, timeout):
|
|
717
|
+
pass
|
|
718
|
+
|
|
719
|
+
d._http_server = FakeHttpServer()
|
|
720
|
+
d._pools = [FakePool()]
|
|
721
|
+
d._shutdown_timeout = 0.0 # skip drain loop immediately
|
|
722
|
+
|
|
723
|
+
client_a = MockClient('GET', '/api/a')
|
|
724
|
+
client_b = MockClient('GET', '/api/b')
|
|
725
|
+
d._pending[1] = _PendingRequest(client_a, pool)
|
|
726
|
+
d._pending[2] = _PendingRequest(client_b, pool)
|
|
727
|
+
|
|
728
|
+
d._shutdown()
|
|
729
|
+
|
|
730
|
+
self.assertEqual(client_a.response_status, 503)
|
|
731
|
+
self.assertEqual(client_b.response_status, 503)
|
|
732
|
+
self.assertEqual(sorted(d.recorded), [
|
|
733
|
+
(1, PENDING_SHUTDOWN),
|
|
734
|
+
(2, PENDING_SHUTDOWN),
|
|
735
|
+
])
|
|
736
|
+
self.assertEqual(d._pending, {})
|
|
737
|
+
|
|
738
|
+
def test_hook_exception_is_swallowed_and_logged(self):
|
|
739
|
+
|
|
740
|
+
class BrokenDispatcher(Dispatcher):
|
|
741
|
+
def on_pending_removed(self, request_id, pending, reason):
|
|
742
|
+
raise RuntimeError('boom')
|
|
743
|
+
|
|
744
|
+
d, pool = self._make_dispatcher(BrokenDispatcher)
|
|
745
|
+
client = MockClient('GET', '/api/test')
|
|
746
|
+
pending = _PendingRequest(client, pool)
|
|
747
|
+
d._pending[1] = pending
|
|
748
|
+
response = Response(request_id=1, data={'ok': True}, status=200)
|
|
749
|
+
# must not propagate
|
|
750
|
+
d._process_response((MSG_RESPONSE, 1, response))
|
|
751
|
+
# client still got the response
|
|
752
|
+
self.assertTrue(client.responded)
|
|
753
|
+
self.assertEqual(client.response_status, 200)
|
|
754
|
+
# error was logged
|
|
755
|
+
error_logs = [
|
|
756
|
+
msg for _, level, msg in d.log_calls if level == LOG_ERROR]
|
|
757
|
+
self.assertEqual(len(error_logs), 1)
|
|
758
|
+
self.assertIn('on_pending_removed', error_logs[0])
|
|
759
|
+
self.assertIn('boom', error_logs[0])
|
|
760
|
+
|
|
761
|
+
def test_default_hook_is_noop(self):
|
|
762
|
+
d, pool = self._make_dispatcher()
|
|
763
|
+
client = MockClient('GET', '/api/test')
|
|
764
|
+
pending = _PendingRequest(client, pool)
|
|
765
|
+
d._pending[1] = pending
|
|
766
|
+
response = Response(request_id=1, data={'ok': True}, status=200)
|
|
767
|
+
# base Dispatcher has no-op on_pending_removed → must not raise
|
|
768
|
+
d._process_response((MSG_RESPONSE, 1, response))
|
|
769
|
+
self.assertTrue(client.responded)
|
|
770
|
+
|
|
771
|
+
|
|
555
772
|
if __name__ == '__main__':
|
|
556
773
|
unittest.main()
|
|
@@ -8,7 +8,7 @@ from uhttp.workers import (
|
|
|
8
8
|
Worker, Request, Response, api, RejectRequest, DEFERRED,
|
|
9
9
|
Logger, LOG_DEBUG, LOG_INFO, LOG_WARNING, LOG_ERROR,
|
|
10
10
|
MSG_RESPONSE, MSG_HEARTBEAT,
|
|
11
|
-
MSG_SSE_OPEN, MSG_SSE_EVENT, MSG_SSE_CLOSE,
|
|
11
|
+
MSG_SSE_OPEN, MSG_SSE_EVENT, MSG_SSE_CLOSE, MSG_NDJSON,
|
|
12
12
|
CTL_DISCONNECT)
|
|
13
13
|
|
|
14
14
|
|
|
@@ -520,6 +520,38 @@ class TestSSERequest(unittest.TestCase):
|
|
|
520
520
|
self.assertIsNone(msg[4])
|
|
521
521
|
self.assertIsNone(msg[5])
|
|
522
522
|
|
|
523
|
+
def test_response_ndjson(self):
|
|
524
|
+
self.req.response_ndjson(
|
|
525
|
+
headers={'X-Custom': '1'},
|
|
526
|
+
cookies={'sid': 'abc'})
|
|
527
|
+
msg = self.queue.get(timeout=1)
|
|
528
|
+
self.assertEqual(msg[0], MSG_SSE_OPEN)
|
|
529
|
+
self.assertEqual(msg[1], 10)
|
|
530
|
+
self.assertEqual(msg[2], 'application/x-ndjson')
|
|
531
|
+
self.assertEqual(msg[3], {'X-Custom': '1'})
|
|
532
|
+
self.assertEqual(msg[4], {'sid': 'abc'})
|
|
533
|
+
|
|
534
|
+
def test_response_ndjson_defaults(self):
|
|
535
|
+
self.req.response_ndjson()
|
|
536
|
+
msg = self.queue.get(timeout=1)
|
|
537
|
+
self.assertEqual(msg[0], MSG_SSE_OPEN)
|
|
538
|
+
self.assertEqual(msg[2], 'application/x-ndjson')
|
|
539
|
+
self.assertIsNone(msg[3])
|
|
540
|
+
self.assertIsNone(msg[4])
|
|
541
|
+
|
|
542
|
+
def test_send_ndjson(self):
|
|
543
|
+
self.req.send_ndjson({'devices': [1, 2, 3]})
|
|
544
|
+
msg = self.queue.get(timeout=1)
|
|
545
|
+
self.assertEqual(msg[0], MSG_NDJSON)
|
|
546
|
+
self.assertEqual(msg[1], 10)
|
|
547
|
+
self.assertEqual(msg[2], {'devices': [1, 2, 3]})
|
|
548
|
+
|
|
549
|
+
def test_send_ndjson_keepalive(self):
|
|
550
|
+
self.req.send_ndjson({})
|
|
551
|
+
msg = self.queue.get(timeout=1)
|
|
552
|
+
self.assertEqual(msg[0], MSG_NDJSON)
|
|
553
|
+
self.assertEqual(msg[2], {})
|
|
554
|
+
|
|
523
555
|
def test_response_stream_end(self):
|
|
524
556
|
self.req.response_stream_end()
|
|
525
557
|
msg = self.queue.get(timeout=1)
|
|
@@ -23,12 +23,20 @@ MSG_LOG = 'LOG'
|
|
|
23
23
|
MSG_SSE_OPEN = 'SSE_OPEN'
|
|
24
24
|
MSG_SSE_EVENT = 'SSE_EVENT'
|
|
25
25
|
MSG_SSE_CLOSE = 'SSE_CLOSE'
|
|
26
|
+
MSG_NDJSON = 'NDJSON'
|
|
26
27
|
|
|
27
28
|
# Worker control messages
|
|
28
29
|
CTL_STOP = 'STOP'
|
|
29
30
|
CTL_CONFIG = 'CONFIG'
|
|
30
31
|
CTL_DISCONNECT = 'DISCONNECT'
|
|
31
32
|
|
|
33
|
+
# Reasons for on_pending_removed
|
|
34
|
+
PENDING_COMPLETED = 'COMPLETED'
|
|
35
|
+
PENDING_TIMEOUT = 'TIMEOUT'
|
|
36
|
+
PENDING_DISCONNECTED = 'DISCONNECTED'
|
|
37
|
+
PENDING_STREAM_CLOSED = 'STREAM_CLOSED'
|
|
38
|
+
PENDING_SHUTDOWN = 'SHUTDOWN'
|
|
39
|
+
|
|
32
40
|
# Sentinel for deferred response
|
|
33
41
|
DEFERRED = object()
|
|
34
42
|
|
|
@@ -275,6 +283,25 @@ class Request:
|
|
|
275
283
|
(MSG_SSE_EVENT, self.request_id,
|
|
276
284
|
data, event, event_id, retry))
|
|
277
285
|
|
|
286
|
+
def response_ndjson(self, headers=None, cookies=None):
|
|
287
|
+
"""Start NDJSON streaming response (application/x-ndjson).
|
|
288
|
+
|
|
289
|
+
Thin wrapper over response_stream(). Use with DEFERRED — call from
|
|
290
|
+
handler, then send_ndjson() later. Call response_stream_end() to finish.
|
|
291
|
+
"""
|
|
292
|
+
self._response_queue.put(
|
|
293
|
+
(MSG_SSE_OPEN, self.request_id,
|
|
294
|
+
'application/x-ndjson', headers, cookies))
|
|
295
|
+
|
|
296
|
+
def send_ndjson(self, obj):
|
|
297
|
+
"""Send one JSON-serializable object as an NDJSON line.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
obj: any JSON-serializable value (dict/list/str/int/float/bool/None)
|
|
301
|
+
"""
|
|
302
|
+
self._response_queue.put(
|
|
303
|
+
(MSG_NDJSON, self.request_id, obj))
|
|
304
|
+
|
|
278
305
|
def response_stream_end(self):
|
|
279
306
|
"""End streaming response and close connection."""
|
|
280
307
|
self._response_queue.put(
|
|
@@ -1074,12 +1101,39 @@ class Dispatcher:
|
|
|
1074
1101
|
"""Called after response is sent to client.
|
|
1075
1102
|
|
|
1076
1103
|
Override to post-process, e.g., forward data to another pool.
|
|
1104
|
+
Fires only on a real handler response (PENDING_COMPLETED path);
|
|
1105
|
+
use on_pending_removed() for lifecycle cleanup that must run
|
|
1106
|
+
regardless of outcome.
|
|
1077
1107
|
|
|
1078
1108
|
Args:
|
|
1079
1109
|
response: Response object from worker.
|
|
1080
1110
|
pending: _PendingRequest with client and pool reference.
|
|
1081
1111
|
"""
|
|
1082
1112
|
|
|
1113
|
+
def on_pending_removed(self, request_id, pending, reason):
|
|
1114
|
+
"""Called exactly once per dispatched request, whatever the outcome.
|
|
1115
|
+
|
|
1116
|
+
Override for side-state cleanup keyed by request_id. Fires after
|
|
1117
|
+
the client-facing action (respond/disconnect/control queue put) so
|
|
1118
|
+
the dispatcher state is already finalized when this runs.
|
|
1119
|
+
Exceptions raised here are logged and swallowed.
|
|
1120
|
+
|
|
1121
|
+
Reason values:
|
|
1122
|
+
PENDING_COMPLETED - handler returned a response, client got it.
|
|
1123
|
+
on_response() is invoked first.
|
|
1124
|
+
PENDING_TIMEOUT - request exceeded pool.timeout; client got 504.
|
|
1125
|
+
Worker may still be processing the request.
|
|
1126
|
+
PENDING_DISCONNECTED - client disconnected mid-stream; worker was
|
|
1127
|
+
notified via control queue (race possible).
|
|
1128
|
+
PENDING_STREAM_CLOSED - worker ended the SSE stream cleanly.
|
|
1129
|
+
PENDING_SHUTDOWN - dispatcher is shutting down; client got 503.
|
|
1130
|
+
|
|
1131
|
+
Args:
|
|
1132
|
+
request_id: The request id being removed.
|
|
1133
|
+
pending: _PendingRequest snapshot (client, pool, worker_id, ...).
|
|
1134
|
+
reason: One of the PENDING_* constants above.
|
|
1135
|
+
"""
|
|
1136
|
+
|
|
1083
1137
|
def on_idle(self):
|
|
1084
1138
|
"""Called on each select timeout when no events arrived.
|
|
1085
1139
|
|
|
@@ -1219,11 +1273,20 @@ class Dispatcher:
|
|
|
1219
1273
|
event_id=event_id, retry=retry)
|
|
1220
1274
|
if not ok:
|
|
1221
1275
|
self._stream_disconnected(request_id, pending)
|
|
1276
|
+
elif msg_type == MSG_NDJSON:
|
|
1277
|
+
_, request_id, obj = msg
|
|
1278
|
+
pending = self._pending.get(request_id)
|
|
1279
|
+
if pending is not None:
|
|
1280
|
+
ok = pending.client.send_ndjson(obj)
|
|
1281
|
+
if not ok:
|
|
1282
|
+
self._stream_disconnected(request_id, pending)
|
|
1222
1283
|
elif msg_type == MSG_SSE_CLOSE:
|
|
1223
1284
|
_, request_id = msg
|
|
1224
1285
|
pending = self._pending.pop(request_id, None)
|
|
1225
1286
|
if pending is not None:
|
|
1226
1287
|
pending.client.response_stream_end()
|
|
1288
|
+
self._notify_pending_removed(
|
|
1289
|
+
request_id, pending, PENDING_STREAM_CLOSED)
|
|
1227
1290
|
elif msg_type == MSG_RESPONSE:
|
|
1228
1291
|
_, request_id, response = msg
|
|
1229
1292
|
pending = self._pending.pop(request_id, None)
|
|
@@ -1234,6 +1297,8 @@ class Dispatcher:
|
|
|
1234
1297
|
headers=response.headers,
|
|
1235
1298
|
cookies=response.cookies)
|
|
1236
1299
|
self.on_response(response, pending)
|
|
1300
|
+
self._notify_pending_removed(
|
|
1301
|
+
request_id, pending, PENDING_COMPLETED)
|
|
1237
1302
|
|
|
1238
1303
|
def _stream_disconnected(self, request_id, pending):
|
|
1239
1304
|
"""Handle client disconnect during streaming."""
|
|
@@ -1243,6 +1308,18 @@ class Dispatcher:
|
|
|
1243
1308
|
if pending.worker_id < len(pool._control_queues):
|
|
1244
1309
|
pool._control_queues[pending.worker_id].put(
|
|
1245
1310
|
(CTL_DISCONNECT, request_id))
|
|
1311
|
+
self._notify_pending_removed(
|
|
1312
|
+
request_id, pending, PENDING_DISCONNECTED)
|
|
1313
|
+
|
|
1314
|
+
def _notify_pending_removed(self, request_id, pending, reason):
|
|
1315
|
+
"""Invoke on_pending_removed, log and swallow exceptions."""
|
|
1316
|
+
try:
|
|
1317
|
+
self.on_pending_removed(request_id, pending, reason)
|
|
1318
|
+
except Exception as exc:
|
|
1319
|
+
self.on_log(
|
|
1320
|
+
pending.pool.name, LOG_ERROR,
|
|
1321
|
+
f"on_pending_removed({reason}) raised for "
|
|
1322
|
+
f"request {request_id}: {exc!r}")
|
|
1246
1323
|
|
|
1247
1324
|
def _process_responses(self):
|
|
1248
1325
|
"""Process all pending messages from response queue."""
|
|
@@ -1269,6 +1346,8 @@ class Dispatcher:
|
|
|
1269
1346
|
f"{pending.pool.timeout}s")
|
|
1270
1347
|
pending.client.respond(
|
|
1271
1348
|
{'error': 'Request timeout'}, status=504)
|
|
1349
|
+
self._notify_pending_removed(
|
|
1350
|
+
request_id, pending, PENDING_TIMEOUT)
|
|
1272
1351
|
|
|
1273
1352
|
def _check_all_workers(self):
|
|
1274
1353
|
"""Check health of all worker pools and queue sizes."""
|
|
@@ -1395,12 +1474,14 @@ class Dispatcher:
|
|
|
1395
1474
|
except _queue.Empty:
|
|
1396
1475
|
pass
|
|
1397
1476
|
# respond 503 to remaining pending
|
|
1398
|
-
for pending in self._pending.
|
|
1477
|
+
for request_id, pending in self._pending.items():
|
|
1399
1478
|
try:
|
|
1400
1479
|
pending.client.respond(
|
|
1401
1480
|
{'error': 'Server shutting down'}, status=503)
|
|
1402
1481
|
except Exception:
|
|
1403
1482
|
pass
|
|
1483
|
+
self._notify_pending_removed(
|
|
1484
|
+
request_id, pending, PENDING_SHUTDOWN)
|
|
1404
1485
|
self._pending.clear()
|
|
1405
1486
|
# shutdown all pools
|
|
1406
1487
|
for pool in self._pools:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: uhttp-workers
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: Multi-process worker dispatcher built on uhttp-server
|
|
5
5
|
Author-email: Pavel Revak <pavelrevak@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -10,7 +10,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
10
10
|
Classifier: Operating System :: POSIX
|
|
11
11
|
Requires-Python: >=3.10
|
|
12
12
|
Description-Content-Type: text/markdown
|
|
13
|
-
Requires-Dist: uhttp-server
|
|
13
|
+
Requires-Dist: uhttp-server>=2.5.2
|
|
14
14
|
|
|
15
15
|
# uhttp-workers
|
|
16
16
|
|
|
@@ -384,6 +384,31 @@ Available streaming methods on `Request`:
|
|
|
384
384
|
|
|
385
385
|
Streaming requests are excluded from dispatcher timeout expiration. When the client disconnects, the dispatcher notifies the worker via `on_disconnect(request_id)`.
|
|
386
386
|
|
|
387
|
+
### NDJSON Streaming
|
|
388
|
+
|
|
389
|
+
Stream JSON objects line-by-line (`application/x-ndjson`) — one JSON value per line, terminated by `\n`. Useful for incremental APIs that aren't event-shaped (long lists, log tails, progress updates):
|
|
390
|
+
|
|
391
|
+
```python
|
|
392
|
+
class MyWorker(_workers.Worker):
|
|
393
|
+
@_workers.api('/devices/scan', 'GET')
|
|
394
|
+
def scan(self, request):
|
|
395
|
+
request.response_ndjson()
|
|
396
|
+
for device in self.discover_devices():
|
|
397
|
+
request.send_ndjson({'id': device.id, 'name': device.name})
|
|
398
|
+
request.response_stream_end()
|
|
399
|
+
return _workers.DEFERRED
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
NDJSON methods on `Request`:
|
|
403
|
+
|
|
404
|
+
| Method | Description |
|
|
405
|
+
|--------|-------------|
|
|
406
|
+
| `response_ndjson(headers, cookies)` | Start NDJSON stream (wrapper over `response_stream` with `application/x-ndjson`) |
|
|
407
|
+
| `send_ndjson(obj)` | Send one JSON-serializable value as a line |
|
|
408
|
+
| `response_stream_end()` | End stream and close connection (shared with SSE) |
|
|
409
|
+
|
|
410
|
+
Same lifecycle as SSE: excluded from timeout expiration, client disconnect triggers `on_disconnect(request_id)`.
|
|
411
|
+
|
|
387
412
|
### Flow Control
|
|
388
413
|
|
|
389
414
|
Workers can stop accepting new requests when busy. Requests stay in the shared pool queue for other workers to pick up:
|
|
@@ -513,6 +538,43 @@ class MyDispatcher(_workers.Dispatcher):
|
|
|
513
538
|
`pending` is a `_PendingRequest` with `client` (original connection) and `pool` (source pool).
|
|
514
539
|
Requests with `request_id=-1` are ignored by the dispatcher when the worker responds.
|
|
515
540
|
|
|
541
|
+
`on_response()` fires only for the happy path (handler returned a response). For lifecycle
|
|
542
|
+
cleanup that must run regardless of outcome — timeouts, client disconnects, shutdown — use
|
|
543
|
+
`on_pending_removed()` (see below).
|
|
544
|
+
|
|
545
|
+
## Request Lifecycle Hook
|
|
546
|
+
|
|
547
|
+
Override `on_pending_removed(request_id, pending, reason)` on the dispatcher when you keep
|
|
548
|
+
side-state keyed by `request_id` and need it cleaned up exactly once, no matter how the
|
|
549
|
+
request ended:
|
|
550
|
+
|
|
551
|
+
```python
|
|
552
|
+
class MyDispatcher(_workers.Dispatcher):
|
|
553
|
+
def on_pending_removed(self, request_id, pending, reason):
|
|
554
|
+
self._side_state.pop(request_id, None)
|
|
555
|
+
if reason == _workers.PENDING_TIMEOUT:
|
|
556
|
+
self._metrics.timeouts += 1
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
Reason is one of:
|
|
560
|
+
|
|
561
|
+
| Constant | When |
|
|
562
|
+
|---|---|
|
|
563
|
+
| `PENDING_COMPLETED` | Handler returned a response, client got it. `on_response()` runs first. |
|
|
564
|
+
| `PENDING_TIMEOUT` | Request exceeded `pool.timeout`; client got 504. Worker may still be processing. |
|
|
565
|
+
| `PENDING_DISCONNECTED` | Client disconnected mid-stream; worker was notified via control queue (race possible). |
|
|
566
|
+
| `PENDING_STREAM_CLOSED` | Worker ended the SSE stream cleanly. |
|
|
567
|
+
| `PENDING_SHUTDOWN` | Dispatcher is shutting down; client got 503. |
|
|
568
|
+
|
|
569
|
+
The hook is invoked after the client-facing action (respond / disconnect / control queue put)
|
|
570
|
+
so dispatcher state is finalized when it runs. Exceptions raised by the hook are logged at
|
|
571
|
+
`LOG_ERROR` and swallowed — they will not crash the dispatcher loop.
|
|
572
|
+
|
|
573
|
+
Override `on_response()` if you only care about the happy path (e.g. cross-pool forwarding).
|
|
574
|
+
Override `on_pending_removed()` if you need exactly-once cleanup. Overriding both is allowed
|
|
575
|
+
but discouraged — for the `PENDING_COMPLETED` reason, `on_response()` is called immediately
|
|
576
|
+
before `on_pending_removed()`.
|
|
577
|
+
|
|
516
578
|
## Dispatcher Idle Hook
|
|
517
579
|
|
|
518
580
|
Override `on_idle()` on the dispatcher for periodic background tasks — called on each `select()` timeout (every `SELECT_TIMEOUT` seconds, default 1s):
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uhttp-server>=2.5.2
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
uhttp-server
|
|
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
|