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.
Files changed (24) hide show
  1. {uhttp_workers-1.3.0/uhttp_workers.egg-info → uhttp_workers-1.4.0}/PKG-INFO +38 -1
  2. {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/README.md +37 -0
  3. {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/tests/test_dispatcher.py +175 -0
  4. {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/uhttp/workers.py +55 -1
  5. {uhttp_workers-1.3.0 → uhttp_workers-1.4.0/uhttp_workers.egg-info}/PKG-INFO +38 -1
  6. {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/.github/workflows/publish.yml +0 -0
  7. {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/.github/workflows/tests.yml +0 -0
  8. {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/.gitignore +0 -0
  9. {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/examples/simple_workers.py +0 -0
  10. {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/examples/sse_workers.py +0 -0
  11. {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/examples/static/index.html +0 -0
  12. {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/pyproject.toml +0 -0
  13. {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/setup.cfg +0 -0
  14. {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/tests/__init__.py +0 -0
  15. {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/tests/test_api_handler.py +0 -0
  16. {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/tests/test_decorators.py +0 -0
  17. {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/tests/test_pattern_matching.py +0 -0
  18. {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/tests/test_request_response.py +0 -0
  19. {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/tests/test_worker.py +0 -0
  20. {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/tests/test_worker_pool.py +0 -0
  21. {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/uhttp_workers.egg-info/SOURCES.txt +0 -0
  22. {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/uhttp_workers.egg-info/dependency_links.txt +0 -0
  23. {uhttp_workers-1.3.0 → uhttp_workers-1.4.0}/uhttp_workers.egg-info/requires.txt +0 -0
  24. {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.0
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.values():
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.0
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