uhttp-workers 1.3.0__tar.gz → 1.4.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.4.0}/PKG-INFO +38 -1
- {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/README.md +37 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/tests/test_dispatcher.py +175 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/uhttp/workers.py +55 -1
- {uhttp_workers-1.3.0 → uhttp_workers-1.4.0/uhttp_workers.egg-info}/PKG-INFO +38 -1
- {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/.github/workflows/publish.yml +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/.github/workflows/tests.yml +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/.gitignore +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/examples/simple_workers.py +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/examples/sse_workers.py +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/examples/static/index.html +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/pyproject.toml +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/setup.cfg +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/tests/__init__.py +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/tests/test_api_handler.py +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/tests/test_decorators.py +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/tests/test_pattern_matching.py +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/tests/test_request_response.py +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/tests/test_worker.py +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/tests/test_worker_pool.py +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/uhttp_workers.egg-info/SOURCES.txt +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/uhttp_workers.egg-info/dependency_links.txt +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/uhttp_workers.egg-info/requires.txt +0 -0
- {uhttp_workers-1.3.0 → uhttp_workers-1.4.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.4.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
|
|
@@ -513,6 +513,43 @@ class MyDispatcher(_workers.Dispatcher):
|
|
|
513
513
|
`pending` is a `_PendingRequest` with `client` (original connection) and `pool` (source pool).
|
|
514
514
|
Requests with `request_id=-1` are ignored by the dispatcher when the worker responds.
|
|
515
515
|
|
|
516
|
+
`on_response()` fires only for the happy path (handler returned a response). For lifecycle
|
|
517
|
+
cleanup that must run regardless of outcome — timeouts, client disconnects, shutdown — use
|
|
518
|
+
`on_pending_removed()` (see below).
|
|
519
|
+
|
|
520
|
+
## Request Lifecycle Hook
|
|
521
|
+
|
|
522
|
+
Override `on_pending_removed(request_id, pending, reason)` on the dispatcher when you keep
|
|
523
|
+
side-state keyed by `request_id` and need it cleaned up exactly once, no matter how the
|
|
524
|
+
request ended:
|
|
525
|
+
|
|
526
|
+
```python
|
|
527
|
+
class MyDispatcher(_workers.Dispatcher):
|
|
528
|
+
def on_pending_removed(self, request_id, pending, reason):
|
|
529
|
+
self._side_state.pop(request_id, None)
|
|
530
|
+
if reason == _workers.PENDING_TIMEOUT:
|
|
531
|
+
self._metrics.timeouts += 1
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
Reason is one of:
|
|
535
|
+
|
|
536
|
+
| Constant | When |
|
|
537
|
+
|---|---|
|
|
538
|
+
| `PENDING_COMPLETED` | Handler returned a response, client got it. `on_response()` runs first. |
|
|
539
|
+
| `PENDING_TIMEOUT` | Request exceeded `pool.timeout`; client got 504. Worker may still be processing. |
|
|
540
|
+
| `PENDING_DISCONNECTED` | Client disconnected mid-stream; worker was notified via control queue (race possible). |
|
|
541
|
+
| `PENDING_STREAM_CLOSED` | Worker ended the SSE stream cleanly. |
|
|
542
|
+
| `PENDING_SHUTDOWN` | Dispatcher is shutting down; client got 503. |
|
|
543
|
+
|
|
544
|
+
The hook is invoked after the client-facing action (respond / disconnect / control queue put)
|
|
545
|
+
so dispatcher state is finalized when it runs. Exceptions raised by the hook are logged at
|
|
546
|
+
`LOG_ERROR` and swallowed — they will not crash the dispatcher loop.
|
|
547
|
+
|
|
548
|
+
Override `on_response()` if you only care about the happy path (e.g. cross-pool forwarding).
|
|
549
|
+
Override `on_pending_removed()` if you need exactly-once cleanup. Overriding both is allowed
|
|
550
|
+
but discouraged — for the `PENDING_COMPLETED` reason, `on_response()` is called immediately
|
|
551
|
+
before `on_pending_removed()`.
|
|
552
|
+
|
|
516
553
|
## Dispatcher Idle Hook
|
|
517
554
|
|
|
518
555
|
Override `on_idle()` on the dispatcher for periodic background tasks — called on each `select()` timeout (every `SELECT_TIMEOUT` seconds, default 1s):
|
|
@@ -499,6 +499,43 @@ class MyDispatcher(_workers.Dispatcher):
|
|
|
499
499
|
`pending` is a `_PendingRequest` with `client` (original connection) and `pool` (source pool).
|
|
500
500
|
Requests with `request_id=-1` are ignored by the dispatcher when the worker responds.
|
|
501
501
|
|
|
502
|
+
`on_response()` fires only for the happy path (handler returned a response). For lifecycle
|
|
503
|
+
cleanup that must run regardless of outcome — timeouts, client disconnects, shutdown — use
|
|
504
|
+
`on_pending_removed()` (see below).
|
|
505
|
+
|
|
506
|
+
## Request Lifecycle Hook
|
|
507
|
+
|
|
508
|
+
Override `on_pending_removed(request_id, pending, reason)` on the dispatcher when you keep
|
|
509
|
+
side-state keyed by `request_id` and need it cleaned up exactly once, no matter how the
|
|
510
|
+
request ended:
|
|
511
|
+
|
|
512
|
+
```python
|
|
513
|
+
class MyDispatcher(_workers.Dispatcher):
|
|
514
|
+
def on_pending_removed(self, request_id, pending, reason):
|
|
515
|
+
self._side_state.pop(request_id, None)
|
|
516
|
+
if reason == _workers.PENDING_TIMEOUT:
|
|
517
|
+
self._metrics.timeouts += 1
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
Reason is one of:
|
|
521
|
+
|
|
522
|
+
| Constant | When |
|
|
523
|
+
|---|---|
|
|
524
|
+
| `PENDING_COMPLETED` | Handler returned a response, client got it. `on_response()` runs first. |
|
|
525
|
+
| `PENDING_TIMEOUT` | Request exceeded `pool.timeout`; client got 504. Worker may still be processing. |
|
|
526
|
+
| `PENDING_DISCONNECTED` | Client disconnected mid-stream; worker was notified via control queue (race possible). |
|
|
527
|
+
| `PENDING_STREAM_CLOSED` | Worker ended the SSE stream cleanly. |
|
|
528
|
+
| `PENDING_SHUTDOWN` | Dispatcher is shutting down; client got 503. |
|
|
529
|
+
|
|
530
|
+
The hook is invoked after the client-facing action (respond / disconnect / control queue put)
|
|
531
|
+
so dispatcher state is finalized when it runs. Exceptions raised by the hook are logged at
|
|
532
|
+
`LOG_ERROR` and swallowed — they will not crash the dispatcher loop.
|
|
533
|
+
|
|
534
|
+
Override `on_response()` if you only care about the happy path (e.g. cross-pool forwarding).
|
|
535
|
+
Override `on_pending_removed()` if you need exactly-once cleanup. Overriding both is allowed
|
|
536
|
+
but discouraged — for the `PENDING_COMPLETED` reason, `on_response()` is called immediately
|
|
537
|
+
before `on_pending_removed()`.
|
|
538
|
+
|
|
502
539
|
## Dispatcher Idle Hook
|
|
503
540
|
|
|
504
541
|
Override `on_idle()` on the dispatcher for periodic background tasks — called on each `select()` timeout (every `SELECT_TIMEOUT` seconds, default 1s):
|
|
@@ -12,6 +12,9 @@ from uhttp.workers import (
|
|
|
12
12
|
MSG_RESPONSE, MSG_HEARTBEAT,
|
|
13
13
|
MSG_SSE_OPEN, MSG_SSE_EVENT, MSG_SSE_CLOSE,
|
|
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
|
|
|
@@ -552,5 +555,177 @@ class TestDispatcherSSE(unittest.TestCase):
|
|
|
552
555
|
# should not raise, just ignore
|
|
553
556
|
|
|
554
557
|
|
|
558
|
+
class TestDispatcherPendingRemoved(unittest.TestCase):
|
|
559
|
+
"""Tests for the on_pending_removed lifecycle hook."""
|
|
560
|
+
|
|
561
|
+
def _make_dispatcher(self, dispatcher_cls=Dispatcher):
|
|
562
|
+
pool = WorkerPool(DummyWorker, routes=['/api/**'])
|
|
563
|
+
d = dispatcher_cls.__new__(dispatcher_cls)
|
|
564
|
+
d._sync_routes = []
|
|
565
|
+
d._static_routes = {}
|
|
566
|
+
d._pools = [pool]
|
|
567
|
+
d._pending = {}
|
|
568
|
+
d._max_pending = 1000
|
|
569
|
+
d._next_request_id = 0
|
|
570
|
+
d._response_queue = mp.Queue()
|
|
571
|
+
d._log_is_tty = False
|
|
572
|
+
d.log_calls = []
|
|
573
|
+
d.on_log = lambda name, level, msg: d.log_calls.append(
|
|
574
|
+
(name, level, msg))
|
|
575
|
+
d.recorded = []
|
|
576
|
+
return d, pool
|
|
577
|
+
|
|
578
|
+
def test_completed_fires_hook(self):
|
|
579
|
+
|
|
580
|
+
class RecordingDispatcher(Dispatcher):
|
|
581
|
+
def on_pending_removed(self, request_id, pending, reason):
|
|
582
|
+
self.recorded.append((request_id, reason))
|
|
583
|
+
|
|
584
|
+
d, pool = self._make_dispatcher(RecordingDispatcher)
|
|
585
|
+
client = MockClient('GET', '/api/test')
|
|
586
|
+
pending = _PendingRequest(client, pool)
|
|
587
|
+
d._pending[1] = pending
|
|
588
|
+
response = Response(request_id=1, data={'ok': True}, status=200)
|
|
589
|
+
d._process_response((MSG_RESPONSE, 1, response))
|
|
590
|
+
self.assertEqual(d.recorded, [(1, PENDING_COMPLETED)])
|
|
591
|
+
|
|
592
|
+
def test_completed_calls_on_response_before_hook(self):
|
|
593
|
+
|
|
594
|
+
class RecordingDispatcher(Dispatcher):
|
|
595
|
+
def on_response(self, response, pending):
|
|
596
|
+
self.recorded.append(('on_response', response.request_id))
|
|
597
|
+
|
|
598
|
+
def on_pending_removed(self, request_id, pending, reason):
|
|
599
|
+
self.recorded.append(('hook', request_id, reason))
|
|
600
|
+
|
|
601
|
+
d, pool = self._make_dispatcher(RecordingDispatcher)
|
|
602
|
+
client = MockClient('GET', '/api/test')
|
|
603
|
+
pending = _PendingRequest(client, pool)
|
|
604
|
+
d._pending[1] = pending
|
|
605
|
+
response = Response(request_id=1, data={'ok': True}, status=200)
|
|
606
|
+
d._process_response((MSG_RESPONSE, 1, response))
|
|
607
|
+
self.assertEqual(d.recorded, [
|
|
608
|
+
('on_response', 1),
|
|
609
|
+
('hook', 1, PENDING_COMPLETED),
|
|
610
|
+
])
|
|
611
|
+
|
|
612
|
+
def test_timeout_fires_hook(self):
|
|
613
|
+
|
|
614
|
+
class RecordingDispatcher(Dispatcher):
|
|
615
|
+
def on_pending_removed(self, request_id, pending, reason):
|
|
616
|
+
self.recorded.append((request_id, reason))
|
|
617
|
+
|
|
618
|
+
d, pool = self._make_dispatcher(RecordingDispatcher)
|
|
619
|
+
client = MockClient('GET', '/api/test')
|
|
620
|
+
pending = _PendingRequest(client, pool)
|
|
621
|
+
pending.timestamp = 0 # very old
|
|
622
|
+
d._pending[1] = pending
|
|
623
|
+
d._expire_pending()
|
|
624
|
+
self.assertNotIn(1, d._pending)
|
|
625
|
+
self.assertEqual(client.response_status, 504)
|
|
626
|
+
self.assertEqual(d.recorded, [(1, PENDING_TIMEOUT)])
|
|
627
|
+
|
|
628
|
+
def test_stream_closed_fires_hook(self):
|
|
629
|
+
|
|
630
|
+
class RecordingDispatcher(Dispatcher):
|
|
631
|
+
def on_pending_removed(self, request_id, pending, reason):
|
|
632
|
+
self.recorded.append((request_id, reason))
|
|
633
|
+
|
|
634
|
+
d, pool = self._make_dispatcher(RecordingDispatcher)
|
|
635
|
+
client = MockClient('GET', '/api/events')
|
|
636
|
+
pending = _PendingRequest(client, pool)
|
|
637
|
+
pending.streaming = True
|
|
638
|
+
d._pending[1] = pending
|
|
639
|
+
d._process_response((MSG_SSE_CLOSE, 1))
|
|
640
|
+
self.assertTrue(client.stream_ended)
|
|
641
|
+
self.assertEqual(d.recorded, [(1, PENDING_STREAM_CLOSED)])
|
|
642
|
+
|
|
643
|
+
def test_disconnect_fires_hook(self):
|
|
644
|
+
|
|
645
|
+
class RecordingDispatcher(Dispatcher):
|
|
646
|
+
def on_pending_removed(self, request_id, pending, reason):
|
|
647
|
+
self.recorded.append((request_id, reason))
|
|
648
|
+
|
|
649
|
+
d, pool = self._make_dispatcher(RecordingDispatcher)
|
|
650
|
+
client = MockClient('GET', '/api/events')
|
|
651
|
+
client._connected = False
|
|
652
|
+
pending = _PendingRequest(client, pool)
|
|
653
|
+
pending.streaming = True
|
|
654
|
+
pending.worker_id = None # skip control queue routing
|
|
655
|
+
d._pending[1] = pending
|
|
656
|
+
d._process_response(
|
|
657
|
+
(MSG_SSE_EVENT, 1, {'data': 'x'}, 'ping', None, None))
|
|
658
|
+
self.assertNotIn(1, d._pending)
|
|
659
|
+
self.assertEqual(d.recorded, [(1, PENDING_DISCONNECTED)])
|
|
660
|
+
|
|
661
|
+
def test_shutdown_fires_hook(self):
|
|
662
|
+
|
|
663
|
+
class RecordingDispatcher(Dispatcher):
|
|
664
|
+
def on_pending_removed(self, request_id, pending, reason):
|
|
665
|
+
self.recorded.append((request_id, reason))
|
|
666
|
+
|
|
667
|
+
d, pool = self._make_dispatcher(RecordingDispatcher)
|
|
668
|
+
|
|
669
|
+
class FakeHttpServer:
|
|
670
|
+
def close(self):
|
|
671
|
+
pass
|
|
672
|
+
|
|
673
|
+
class FakePool:
|
|
674
|
+
def shutdown(self, timeout):
|
|
675
|
+
pass
|
|
676
|
+
|
|
677
|
+
d._http_server = FakeHttpServer()
|
|
678
|
+
d._pools = [FakePool()]
|
|
679
|
+
d._shutdown_timeout = 0.0 # skip drain loop immediately
|
|
680
|
+
|
|
681
|
+
client_a = MockClient('GET', '/api/a')
|
|
682
|
+
client_b = MockClient('GET', '/api/b')
|
|
683
|
+
d._pending[1] = _PendingRequest(client_a, pool)
|
|
684
|
+
d._pending[2] = _PendingRequest(client_b, pool)
|
|
685
|
+
|
|
686
|
+
d._shutdown()
|
|
687
|
+
|
|
688
|
+
self.assertEqual(client_a.response_status, 503)
|
|
689
|
+
self.assertEqual(client_b.response_status, 503)
|
|
690
|
+
self.assertEqual(sorted(d.recorded), [
|
|
691
|
+
(1, PENDING_SHUTDOWN),
|
|
692
|
+
(2, PENDING_SHUTDOWN),
|
|
693
|
+
])
|
|
694
|
+
self.assertEqual(d._pending, {})
|
|
695
|
+
|
|
696
|
+
def test_hook_exception_is_swallowed_and_logged(self):
|
|
697
|
+
|
|
698
|
+
class BrokenDispatcher(Dispatcher):
|
|
699
|
+
def on_pending_removed(self, request_id, pending, reason):
|
|
700
|
+
raise RuntimeError('boom')
|
|
701
|
+
|
|
702
|
+
d, pool = self._make_dispatcher(BrokenDispatcher)
|
|
703
|
+
client = MockClient('GET', '/api/test')
|
|
704
|
+
pending = _PendingRequest(client, pool)
|
|
705
|
+
d._pending[1] = pending
|
|
706
|
+
response = Response(request_id=1, data={'ok': True}, status=200)
|
|
707
|
+
# must not propagate
|
|
708
|
+
d._process_response((MSG_RESPONSE, 1, response))
|
|
709
|
+
# client still got the response
|
|
710
|
+
self.assertTrue(client.responded)
|
|
711
|
+
self.assertEqual(client.response_status, 200)
|
|
712
|
+
# error was logged
|
|
713
|
+
error_logs = [
|
|
714
|
+
msg for _, level, msg in d.log_calls if level == LOG_ERROR]
|
|
715
|
+
self.assertEqual(len(error_logs), 1)
|
|
716
|
+
self.assertIn('on_pending_removed', error_logs[0])
|
|
717
|
+
self.assertIn('boom', error_logs[0])
|
|
718
|
+
|
|
719
|
+
def test_default_hook_is_noop(self):
|
|
720
|
+
d, pool = self._make_dispatcher()
|
|
721
|
+
client = MockClient('GET', '/api/test')
|
|
722
|
+
pending = _PendingRequest(client, pool)
|
|
723
|
+
d._pending[1] = pending
|
|
724
|
+
response = Response(request_id=1, data={'ok': True}, status=200)
|
|
725
|
+
# base Dispatcher has no-op on_pending_removed → must not raise
|
|
726
|
+
d._process_response((MSG_RESPONSE, 1, response))
|
|
727
|
+
self.assertTrue(client.responded)
|
|
728
|
+
|
|
729
|
+
|
|
555
730
|
if __name__ == '__main__':
|
|
556
731
|
unittest.main()
|
|
@@ -29,6 +29,13 @@ CTL_STOP = 'STOP'
|
|
|
29
29
|
CTL_CONFIG = 'CONFIG'
|
|
30
30
|
CTL_DISCONNECT = 'DISCONNECT'
|
|
31
31
|
|
|
32
|
+
# Reasons for on_pending_removed
|
|
33
|
+
PENDING_COMPLETED = 'COMPLETED'
|
|
34
|
+
PENDING_TIMEOUT = 'TIMEOUT'
|
|
35
|
+
PENDING_DISCONNECTED = 'DISCONNECTED'
|
|
36
|
+
PENDING_STREAM_CLOSED = 'STREAM_CLOSED'
|
|
37
|
+
PENDING_SHUTDOWN = 'SHUTDOWN'
|
|
38
|
+
|
|
32
39
|
# Sentinel for deferred response
|
|
33
40
|
DEFERRED = object()
|
|
34
41
|
|
|
@@ -1074,12 +1081,39 @@ class Dispatcher:
|
|
|
1074
1081
|
"""Called after response is sent to client.
|
|
1075
1082
|
|
|
1076
1083
|
Override to post-process, e.g., forward data to another pool.
|
|
1084
|
+
Fires only on a real handler response (PENDING_COMPLETED path);
|
|
1085
|
+
use on_pending_removed() for lifecycle cleanup that must run
|
|
1086
|
+
regardless of outcome.
|
|
1077
1087
|
|
|
1078
1088
|
Args:
|
|
1079
1089
|
response: Response object from worker.
|
|
1080
1090
|
pending: _PendingRequest with client and pool reference.
|
|
1081
1091
|
"""
|
|
1082
1092
|
|
|
1093
|
+
def on_pending_removed(self, request_id, pending, reason):
|
|
1094
|
+
"""Called exactly once per dispatched request, whatever the outcome.
|
|
1095
|
+
|
|
1096
|
+
Override for side-state cleanup keyed by request_id. Fires after
|
|
1097
|
+
the client-facing action (respond/disconnect/control queue put) so
|
|
1098
|
+
the dispatcher state is already finalized when this runs.
|
|
1099
|
+
Exceptions raised here are logged and swallowed.
|
|
1100
|
+
|
|
1101
|
+
Reason values:
|
|
1102
|
+
PENDING_COMPLETED - handler returned a response, client got it.
|
|
1103
|
+
on_response() is invoked first.
|
|
1104
|
+
PENDING_TIMEOUT - request exceeded pool.timeout; client got 504.
|
|
1105
|
+
Worker may still be processing the request.
|
|
1106
|
+
PENDING_DISCONNECTED - client disconnected mid-stream; worker was
|
|
1107
|
+
notified via control queue (race possible).
|
|
1108
|
+
PENDING_STREAM_CLOSED - worker ended the SSE stream cleanly.
|
|
1109
|
+
PENDING_SHUTDOWN - dispatcher is shutting down; client got 503.
|
|
1110
|
+
|
|
1111
|
+
Args:
|
|
1112
|
+
request_id: The request id being removed.
|
|
1113
|
+
pending: _PendingRequest snapshot (client, pool, worker_id, ...).
|
|
1114
|
+
reason: One of the PENDING_* constants above.
|
|
1115
|
+
"""
|
|
1116
|
+
|
|
1083
1117
|
def on_idle(self):
|
|
1084
1118
|
"""Called on each select timeout when no events arrived.
|
|
1085
1119
|
|
|
@@ -1224,6 +1258,8 @@ class Dispatcher:
|
|
|
1224
1258
|
pending = self._pending.pop(request_id, None)
|
|
1225
1259
|
if pending is not None:
|
|
1226
1260
|
pending.client.response_stream_end()
|
|
1261
|
+
self._notify_pending_removed(
|
|
1262
|
+
request_id, pending, PENDING_STREAM_CLOSED)
|
|
1227
1263
|
elif msg_type == MSG_RESPONSE:
|
|
1228
1264
|
_, request_id, response = msg
|
|
1229
1265
|
pending = self._pending.pop(request_id, None)
|
|
@@ -1234,6 +1270,8 @@ class Dispatcher:
|
|
|
1234
1270
|
headers=response.headers,
|
|
1235
1271
|
cookies=response.cookies)
|
|
1236
1272
|
self.on_response(response, pending)
|
|
1273
|
+
self._notify_pending_removed(
|
|
1274
|
+
request_id, pending, PENDING_COMPLETED)
|
|
1237
1275
|
|
|
1238
1276
|
def _stream_disconnected(self, request_id, pending):
|
|
1239
1277
|
"""Handle client disconnect during streaming."""
|
|
@@ -1243,6 +1281,18 @@ class Dispatcher:
|
|
|
1243
1281
|
if pending.worker_id < len(pool._control_queues):
|
|
1244
1282
|
pool._control_queues[pending.worker_id].put(
|
|
1245
1283
|
(CTL_DISCONNECT, request_id))
|
|
1284
|
+
self._notify_pending_removed(
|
|
1285
|
+
request_id, pending, PENDING_DISCONNECTED)
|
|
1286
|
+
|
|
1287
|
+
def _notify_pending_removed(self, request_id, pending, reason):
|
|
1288
|
+
"""Invoke on_pending_removed, log and swallow exceptions."""
|
|
1289
|
+
try:
|
|
1290
|
+
self.on_pending_removed(request_id, pending, reason)
|
|
1291
|
+
except Exception as exc:
|
|
1292
|
+
self.on_log(
|
|
1293
|
+
pending.pool.name, LOG_ERROR,
|
|
1294
|
+
f"on_pending_removed({reason}) raised for "
|
|
1295
|
+
f"request {request_id}: {exc!r}")
|
|
1246
1296
|
|
|
1247
1297
|
def _process_responses(self):
|
|
1248
1298
|
"""Process all pending messages from response queue."""
|
|
@@ -1269,6 +1319,8 @@ class Dispatcher:
|
|
|
1269
1319
|
f"{pending.pool.timeout}s")
|
|
1270
1320
|
pending.client.respond(
|
|
1271
1321
|
{'error': 'Request timeout'}, status=504)
|
|
1322
|
+
self._notify_pending_removed(
|
|
1323
|
+
request_id, pending, PENDING_TIMEOUT)
|
|
1272
1324
|
|
|
1273
1325
|
def _check_all_workers(self):
|
|
1274
1326
|
"""Check health of all worker pools and queue sizes."""
|
|
@@ -1395,12 +1447,14 @@ class Dispatcher:
|
|
|
1395
1447
|
except _queue.Empty:
|
|
1396
1448
|
pass
|
|
1397
1449
|
# respond 503 to remaining pending
|
|
1398
|
-
for pending in self._pending.
|
|
1450
|
+
for request_id, pending in self._pending.items():
|
|
1399
1451
|
try:
|
|
1400
1452
|
pending.client.respond(
|
|
1401
1453
|
{'error': 'Server shutting down'}, status=503)
|
|
1402
1454
|
except Exception:
|
|
1403
1455
|
pass
|
|
1456
|
+
self._notify_pending_removed(
|
|
1457
|
+
request_id, pending, PENDING_SHUTDOWN)
|
|
1404
1458
|
self._pending.clear()
|
|
1405
1459
|
# shutdown all pools
|
|
1406
1460
|
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.4.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
|
|
@@ -513,6 +513,43 @@ class MyDispatcher(_workers.Dispatcher):
|
|
|
513
513
|
`pending` is a `_PendingRequest` with `client` (original connection) and `pool` (source pool).
|
|
514
514
|
Requests with `request_id=-1` are ignored by the dispatcher when the worker responds.
|
|
515
515
|
|
|
516
|
+
`on_response()` fires only for the happy path (handler returned a response). For lifecycle
|
|
517
|
+
cleanup that must run regardless of outcome — timeouts, client disconnects, shutdown — use
|
|
518
|
+
`on_pending_removed()` (see below).
|
|
519
|
+
|
|
520
|
+
## Request Lifecycle Hook
|
|
521
|
+
|
|
522
|
+
Override `on_pending_removed(request_id, pending, reason)` on the dispatcher when you keep
|
|
523
|
+
side-state keyed by `request_id` and need it cleaned up exactly once, no matter how the
|
|
524
|
+
request ended:
|
|
525
|
+
|
|
526
|
+
```python
|
|
527
|
+
class MyDispatcher(_workers.Dispatcher):
|
|
528
|
+
def on_pending_removed(self, request_id, pending, reason):
|
|
529
|
+
self._side_state.pop(request_id, None)
|
|
530
|
+
if reason == _workers.PENDING_TIMEOUT:
|
|
531
|
+
self._metrics.timeouts += 1
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
Reason is one of:
|
|
535
|
+
|
|
536
|
+
| Constant | When |
|
|
537
|
+
|---|---|
|
|
538
|
+
| `PENDING_COMPLETED` | Handler returned a response, client got it. `on_response()` runs first. |
|
|
539
|
+
| `PENDING_TIMEOUT` | Request exceeded `pool.timeout`; client got 504. Worker may still be processing. |
|
|
540
|
+
| `PENDING_DISCONNECTED` | Client disconnected mid-stream; worker was notified via control queue (race possible). |
|
|
541
|
+
| `PENDING_STREAM_CLOSED` | Worker ended the SSE stream cleanly. |
|
|
542
|
+
| `PENDING_SHUTDOWN` | Dispatcher is shutting down; client got 503. |
|
|
543
|
+
|
|
544
|
+
The hook is invoked after the client-facing action (respond / disconnect / control queue put)
|
|
545
|
+
so dispatcher state is finalized when it runs. Exceptions raised by the hook are logged at
|
|
546
|
+
`LOG_ERROR` and swallowed — they will not crash the dispatcher loop.
|
|
547
|
+
|
|
548
|
+
Override `on_response()` if you only care about the happy path (e.g. cross-pool forwarding).
|
|
549
|
+
Override `on_pending_removed()` if you need exactly-once cleanup. Overriding both is allowed
|
|
550
|
+
but discouraged — for the `PENDING_COMPLETED` reason, `on_response()` is called immediately
|
|
551
|
+
before `on_pending_removed()`.
|
|
552
|
+
|
|
516
553
|
## Dispatcher Idle Hook
|
|
517
554
|
|
|
518
555
|
Override `on_idle()` on the dispatcher for periodic background tasks — called on each `select()` timeout (every `SELECT_TIMEOUT` seconds, default 1s):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|