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.
Files changed (25) hide show
  1. {uhttp_workers-1.3.0/uhttp_workers.egg-info → uhttp_workers-1.5.0}/PKG-INFO +64 -2
  2. {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/README.md +62 -0
  3. {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/pyproject.toml +1 -1
  4. {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/tests/test_dispatcher.py +218 -1
  5. {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/tests/test_worker.py +33 -1
  6. {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/uhttp/workers.py +82 -1
  7. {uhttp_workers-1.3.0 → uhttp_workers-1.5.0/uhttp_workers.egg-info}/PKG-INFO +64 -2
  8. uhttp_workers-1.5.0/uhttp_workers.egg-info/requires.txt +1 -0
  9. uhttp_workers-1.3.0/uhttp_workers.egg-info/requires.txt +0 -1
  10. {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/.github/workflows/publish.yml +0 -0
  11. {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/.github/workflows/tests.yml +0 -0
  12. {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/.gitignore +0 -0
  13. {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/examples/simple_workers.py +0 -0
  14. {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/examples/sse_workers.py +0 -0
  15. {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/examples/static/index.html +0 -0
  16. {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/setup.cfg +0 -0
  17. {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/tests/__init__.py +0 -0
  18. {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/tests/test_api_handler.py +0 -0
  19. {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/tests/test_decorators.py +0 -0
  20. {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/tests/test_pattern_matching.py +0 -0
  21. {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/tests/test_request_response.py +0 -0
  22. {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/tests/test_worker_pool.py +0 -0
  23. {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/uhttp_workers.egg-info/SOURCES.txt +0 -0
  24. {uhttp_workers-1.3.0 → uhttp_workers-1.5.0}/uhttp_workers.egg-info/dependency_links.txt +0 -0
  25. {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.0
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):
@@ -13,7 +13,7 @@ authors = [
13
13
  license = "MIT"
14
14
  requires-python = ">=3.10"
15
15
  dependencies = [
16
- "uhttp-server",
16
+ "uhttp-server>=2.5.2",
17
17
  ]
18
18
  classifiers = [
19
19
  "Programming Language :: Python :: 3",
@@ -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.values():
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.0
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