uhttp-workers 1.2.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.2.0/uhttp_workers.egg-info → uhttp_workers-1.4.0}/PKG-INFO +97 -1
  2. {uhttp_workers-1.2.0 → uhttp_workers-1.4.0}/README.md +96 -0
  3. {uhttp_workers-1.2.0 → uhttp_workers-1.4.0}/tests/test_dispatcher.py +175 -0
  4. {uhttp_workers-1.2.0 → uhttp_workers-1.4.0}/tests/test_worker.py +112 -0
  5. {uhttp_workers-1.2.0 → uhttp_workers-1.4.0}/uhttp/workers.py +120 -11
  6. {uhttp_workers-1.2.0 → uhttp_workers-1.4.0/uhttp_workers.egg-info}/PKG-INFO +97 -1
  7. {uhttp_workers-1.2.0 → uhttp_workers-1.4.0}/.github/workflows/publish.yml +0 -0
  8. {uhttp_workers-1.2.0 → uhttp_workers-1.4.0}/.github/workflows/tests.yml +0 -0
  9. {uhttp_workers-1.2.0 → uhttp_workers-1.4.0}/.gitignore +0 -0
  10. {uhttp_workers-1.2.0 → uhttp_workers-1.4.0}/examples/simple_workers.py +0 -0
  11. {uhttp_workers-1.2.0 → uhttp_workers-1.4.0}/examples/sse_workers.py +0 -0
  12. {uhttp_workers-1.2.0 → uhttp_workers-1.4.0}/examples/static/index.html +0 -0
  13. {uhttp_workers-1.2.0 → uhttp_workers-1.4.0}/pyproject.toml +0 -0
  14. {uhttp_workers-1.2.0 → uhttp_workers-1.4.0}/setup.cfg +0 -0
  15. {uhttp_workers-1.2.0 → uhttp_workers-1.4.0}/tests/__init__.py +0 -0
  16. {uhttp_workers-1.2.0 → uhttp_workers-1.4.0}/tests/test_api_handler.py +0 -0
  17. {uhttp_workers-1.2.0 → uhttp_workers-1.4.0}/tests/test_decorators.py +0 -0
  18. {uhttp_workers-1.2.0 → uhttp_workers-1.4.0}/tests/test_pattern_matching.py +0 -0
  19. {uhttp_workers-1.2.0 → uhttp_workers-1.4.0}/tests/test_request_response.py +0 -0
  20. {uhttp_workers-1.2.0 → uhttp_workers-1.4.0}/tests/test_worker_pool.py +0 -0
  21. {uhttp_workers-1.2.0 → uhttp_workers-1.4.0}/uhttp_workers.egg-info/SOURCES.txt +0 -0
  22. {uhttp_workers-1.2.0 → uhttp_workers-1.4.0}/uhttp_workers.egg-info/dependency_links.txt +0 -0
  23. {uhttp_workers-1.2.0 → uhttp_workers-1.4.0}/uhttp_workers.egg-info/requires.txt +0 -0
  24. {uhttp_workers-1.2.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.2.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
@@ -234,6 +234,21 @@ class MyWorker(_workers.Worker):
234
234
 
235
235
  Extra keyword arguments from `WorkerPool(...)` are available as `self.kwargs`.
236
236
 
237
+ ### Teardown
238
+
239
+ `teardown()` is called once when the worker process is stopping — use it to release resources:
240
+
241
+ ```python
242
+ class MyWorker(_workers.Worker):
243
+ def setup(self):
244
+ self.db = connect_db()
245
+
246
+ def teardown(self):
247
+ self.db.close()
248
+ ```
249
+
250
+ Called after the run loop exits (clean stop, orphan detection, or pipe close), before the process terminates. Exceptions are logged but do not block shutdown.
251
+
237
252
  ### Configuration Updates
238
253
 
239
254
  Dispatcher can send configuration to workers at runtime via per-worker control queues:
@@ -278,10 +293,29 @@ def process(self, request):
278
293
  # return data with status
279
294
  return {'error': 'not found'}, 404
280
295
 
296
+ # return data with status and headers
297
+ return {'ok': True}, 200, {'X-Custom': 'value'}
298
+
299
+ # return Response object — full control (headers, cookies)
300
+ return _workers.Response(
301
+ None, # request_id is set automatically
302
+ data={'ok': True},
303
+ headers={'X-Custom': 'value'},
304
+ cookies={'session': 'abc123'})
305
+
281
306
  # defer response — worker continues accepting requests
282
307
  return _workers.DEFERRED
283
308
  ```
284
309
 
310
+ `request.respond()` (for deferred responses) also accepts `headers` and `cookies`:
311
+
312
+ ```python
313
+ request.respond(
314
+ data={'result': 'done'},
315
+ headers={'X-Job-Id': '42'},
316
+ cookies={'session': 'abc'})
317
+ ```
318
+
285
319
  ### Deferred Responses
286
320
 
287
321
  Return `DEFERRED` to skip immediate response. The worker stays in the select loop, accepts new requests, and sends the response later via `request.respond()`:
@@ -432,6 +466,17 @@ Return `(data, status)` tuple to reject, or `None` to continue. You can also rai
432
466
 
433
467
  `RejectRequest` accepts optional `data` (default: `{'error': 'Rejected'}`) and `status` (default: `403`).
434
468
 
469
+ `RejectRequest` can also be raised from request handlers — useful for access control within individual endpoints:
470
+
471
+ ```python
472
+ @_workers.api('/admin/users', 'GET')
473
+ def admin_users(self, request):
474
+ if not self.is_admin(request):
475
+ raise _workers.RejectRequest(
476
+ data={'error': 'admin only'}, status=403)
477
+ return {'users': self.list_users()}
478
+ ```
479
+
435
480
  ### Error Handling
436
481
 
437
482
  Override `on_request_error()` on the worker to customize error handling when a request handler raises an exception:
@@ -468,6 +513,43 @@ class MyDispatcher(_workers.Dispatcher):
468
513
  `pending` is a `_PendingRequest` with `client` (original connection) and `pool` (source pool).
469
514
  Requests with `request_id=-1` are ignored by the dispatcher when the worker responds.
470
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
+
471
553
  ## Dispatcher Idle Hook
472
554
 
473
555
  Override `on_idle()` on the dispatcher for periodic background tasks — called on each `select()` timeout (every `SELECT_TIMEOUT` seconds, default 1s):
@@ -523,6 +605,20 @@ class MyWorker(_workers.Worker):
523
605
 
524
606
  Log messages are sent to the dispatcher via the shared response queue and printed in the dispatcher process — no interleaved output from multiple processes.
525
607
 
608
+ The dispatcher itself also has a `self.log` Logger that writes directly via `on_log()` (no queue), so it can be used at any time — including before workers are started:
609
+
610
+ ```python
611
+ class MyDispatcher(_workers.Dispatcher):
612
+ def __init__(self, **kwargs):
613
+ super().__init__(**kwargs)
614
+ self.log.info("Dispatcher starting on port %d", self._port)
615
+
616
+ def on_idle(self):
617
+ self.log.debug("idle tick")
618
+ ```
619
+
620
+ Set dispatcher log level via constructor: `Dispatcher(log_level=LOG_DEBUG, ...)` (default `LOG_INFO`).
621
+
526
622
  **Log levels:** `LOG_DEBUG` (10), `LOG_INFO` (20), `LOG_WARNING` (30), `LOG_ERROR` (40), `LOG_CRITICAL` (50)
527
623
 
528
624
  Check current level with `is_*` properties to skip expensive formatting:
@@ -220,6 +220,21 @@ class MyWorker(_workers.Worker):
220
220
 
221
221
  Extra keyword arguments from `WorkerPool(...)` are available as `self.kwargs`.
222
222
 
223
+ ### Teardown
224
+
225
+ `teardown()` is called once when the worker process is stopping — use it to release resources:
226
+
227
+ ```python
228
+ class MyWorker(_workers.Worker):
229
+ def setup(self):
230
+ self.db = connect_db()
231
+
232
+ def teardown(self):
233
+ self.db.close()
234
+ ```
235
+
236
+ Called after the run loop exits (clean stop, orphan detection, or pipe close), before the process terminates. Exceptions are logged but do not block shutdown.
237
+
223
238
  ### Configuration Updates
224
239
 
225
240
  Dispatcher can send configuration to workers at runtime via per-worker control queues:
@@ -264,10 +279,29 @@ def process(self, request):
264
279
  # return data with status
265
280
  return {'error': 'not found'}, 404
266
281
 
282
+ # return data with status and headers
283
+ return {'ok': True}, 200, {'X-Custom': 'value'}
284
+
285
+ # return Response object — full control (headers, cookies)
286
+ return _workers.Response(
287
+ None, # request_id is set automatically
288
+ data={'ok': True},
289
+ headers={'X-Custom': 'value'},
290
+ cookies={'session': 'abc123'})
291
+
267
292
  # defer response — worker continues accepting requests
268
293
  return _workers.DEFERRED
269
294
  ```
270
295
 
296
+ `request.respond()` (for deferred responses) also accepts `headers` and `cookies`:
297
+
298
+ ```python
299
+ request.respond(
300
+ data={'result': 'done'},
301
+ headers={'X-Job-Id': '42'},
302
+ cookies={'session': 'abc'})
303
+ ```
304
+
271
305
  ### Deferred Responses
272
306
 
273
307
  Return `DEFERRED` to skip immediate response. The worker stays in the select loop, accepts new requests, and sends the response later via `request.respond()`:
@@ -418,6 +452,17 @@ Return `(data, status)` tuple to reject, or `None` to continue. You can also rai
418
452
 
419
453
  `RejectRequest` accepts optional `data` (default: `{'error': 'Rejected'}`) and `status` (default: `403`).
420
454
 
455
+ `RejectRequest` can also be raised from request handlers — useful for access control within individual endpoints:
456
+
457
+ ```python
458
+ @_workers.api('/admin/users', 'GET')
459
+ def admin_users(self, request):
460
+ if not self.is_admin(request):
461
+ raise _workers.RejectRequest(
462
+ data={'error': 'admin only'}, status=403)
463
+ return {'users': self.list_users()}
464
+ ```
465
+
421
466
  ### Error Handling
422
467
 
423
468
  Override `on_request_error()` on the worker to customize error handling when a request handler raises an exception:
@@ -454,6 +499,43 @@ class MyDispatcher(_workers.Dispatcher):
454
499
  `pending` is a `_PendingRequest` with `client` (original connection) and `pool` (source pool).
455
500
  Requests with `request_id=-1` are ignored by the dispatcher when the worker responds.
456
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
+
457
539
  ## Dispatcher Idle Hook
458
540
 
459
541
  Override `on_idle()` on the dispatcher for periodic background tasks — called on each `select()` timeout (every `SELECT_TIMEOUT` seconds, default 1s):
@@ -509,6 +591,20 @@ class MyWorker(_workers.Worker):
509
591
 
510
592
  Log messages are sent to the dispatcher via the shared response queue and printed in the dispatcher process — no interleaved output from multiple processes.
511
593
 
594
+ The dispatcher itself also has a `self.log` Logger that writes directly via `on_log()` (no queue), so it can be used at any time — including before workers are started:
595
+
596
+ ```python
597
+ class MyDispatcher(_workers.Dispatcher):
598
+ def __init__(self, **kwargs):
599
+ super().__init__(**kwargs)
600
+ self.log.info("Dispatcher starting on port %d", self._port)
601
+
602
+ def on_idle(self):
603
+ self.log.debug("idle tick")
604
+ ```
605
+
606
+ Set dispatcher log level via constructor: `Dispatcher(log_level=LOG_DEBUG, ...)` (default `LOG_INFO`).
607
+
512
608
  **Log levels:** `LOG_DEBUG` (10), `LOG_INFO` (20), `LOG_WARNING` (30), `LOG_ERROR` (40), `LOG_CRITICAL` (50)
513
609
 
514
610
  Check current level with `is_*` properties to skip expensive formatting:
@@ -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()
@@ -124,6 +124,14 @@ class RejectWorker(Worker):
124
124
  return {'data': 'ok'}
125
125
 
126
126
 
127
+ class RejectFromHandlerWorker(Worker):
128
+ """Worker where handler raises RejectRequest."""
129
+
130
+ @api('/protected', 'GET')
131
+ def protected(self, request):
132
+ raise RejectRequest(data={'error': 'no access'}, status=403)
133
+
134
+
127
135
  class FailCheckWorker(Worker):
128
136
  """Worker with do_check that raises unexpected exception."""
129
137
 
@@ -163,6 +171,15 @@ class TestWorkerDoCheck(unittest.TestCase):
163
171
  resp = worker._handle_request(req)
164
172
  self.assertEqual(resp.status, 403)
165
173
 
174
+ def test_reject_from_handler(self):
175
+ queues = [mp.Queue() for _ in range(3)]
176
+ worker = RejectFromHandlerWorker(0, *queues)
177
+ worker._build_routes()
178
+ req = Request(1, 'GET', '/protected')
179
+ resp = worker._handle_request(req)
180
+ self.assertEqual(resp.status, 403)
181
+ self.assertEqual(resp.data, {'error': 'no access'})
182
+
166
183
  def test_do_check_exception(self):
167
184
  queues = [mp.Queue() for _ in range(3)]
168
185
  worker = FailCheckWorker(0, *queues)
@@ -558,6 +575,101 @@ class TestSSEWorkerDisconnect(unittest.TestCase):
558
575
  self.assertTrue(found)
559
576
 
560
577
 
578
+ class HeadersCookiesWorker(Worker):
579
+ """Worker that returns headers/cookies in various ways."""
580
+
581
+ @api('/three-tuple', 'GET')
582
+ def three_tuple(self, request):
583
+ return {'ok': True}, 201, {'X-Custom': 'val'}
584
+
585
+ @api('/response-obj', 'GET')
586
+ def response_obj(self, request):
587
+ return Response(
588
+ None, data={'ok': True}, status=200,
589
+ headers={'X-Custom': 'val'},
590
+ cookies={'session': 'abc'})
591
+
592
+ @api('/two-tuple', 'GET')
593
+ def two_tuple(self, request):
594
+ return {'ok': True}, 202
595
+
596
+
597
+ class TestResponseHeadersCookies(unittest.TestCase):
598
+
599
+ def setUp(self):
600
+ queues = [mp.Queue() for _ in range(3)]
601
+ self.worker = HeadersCookiesWorker(0, *queues)
602
+ self.worker._build_routes()
603
+
604
+ def test_three_tuple(self):
605
+ req = Request(1, 'GET', '/three-tuple')
606
+ resp = self.worker._handle_request(req)
607
+ self.assertEqual(resp.status, 201)
608
+ self.assertEqual(resp.data, {'ok': True})
609
+ self.assertEqual(resp.headers, {'X-Custom': 'val'})
610
+ self.assertIsNone(resp.cookies)
611
+
612
+ def test_response_object(self):
613
+ req = Request(7, 'GET', '/response-obj')
614
+ resp = self.worker._handle_request(req)
615
+ self.assertEqual(resp.request_id, 7) # auto-set
616
+ self.assertEqual(resp.headers, {'X-Custom': 'val'})
617
+ self.assertEqual(resp.cookies, {'session': 'abc'})
618
+
619
+ def test_two_tuple_no_headers(self):
620
+ req = Request(1, 'GET', '/two-tuple')
621
+ resp = self.worker._handle_request(req)
622
+ self.assertEqual(resp.status, 202)
623
+ self.assertIsNone(resp.headers)
624
+ self.assertIsNone(resp.cookies)
625
+
626
+ def test_request_respond_with_cookies(self):
627
+ queue = mp.Queue()
628
+ req = Request(5, 'GET', '/x')
629
+ req._response_queue = queue
630
+ req.respond(
631
+ data={'ok': True},
632
+ headers={'X-H': '1'},
633
+ cookies={'sid': 'xyz'})
634
+ msg = queue.get(timeout=1)
635
+ self.assertEqual(msg[2].headers, {'X-H': '1'})
636
+ self.assertEqual(msg[2].cookies, {'sid': 'xyz'})
637
+
638
+
639
+ class TeardownWorker(Worker):
640
+ """Worker that signals teardown via response queue."""
641
+
642
+ def teardown(self):
643
+ self._response_queue.put(('TEARDOWN', self.worker_id, None))
644
+
645
+
646
+ class TestWorkerTeardown(unittest.TestCase):
647
+
648
+ def test_teardown_called_on_stop(self):
649
+ request_queue = mp.Queue()
650
+ control_queue = mp.Queue()
651
+ response_queue = mp.Queue()
652
+ control_queue.put(None) # immediate stop
653
+ worker = TeardownWorker(
654
+ 0, request_queue, control_queue, response_queue)
655
+ worker.heartbeat_interval = 0.1
656
+ worker.start()
657
+ worker.join(timeout=5)
658
+ self.assertFalse(worker.is_alive())
659
+ # find TEARDOWN message
660
+ found = False
661
+ deadline = time.time() + 2
662
+ while time.time() < deadline:
663
+ try:
664
+ msg = response_queue.get(timeout=0.2)
665
+ except Exception:
666
+ break
667
+ if msg[0] == 'TEARDOWN':
668
+ found = True
669
+ break
670
+ self.assertTrue(found)
671
+
672
+
561
673
  class PausingWorker(Worker):
562
674
  """Worker that pauses after first request."""
563
675
 
@@ -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
 
@@ -236,14 +243,16 @@ class Request:
236
243
  _uhttp_server.parse_cookies(raw) if raw else {})
237
244
  return self._cookies
238
245
 
239
- def respond(self, data=None, status=200):
246
+ def respond(self, data=None, status=200, headers=None, cookies=None):
240
247
  """Send deferred response for this request.
241
248
 
242
249
  Use after returning DEFERRED from handler.
243
250
  """
244
251
  self._response_queue.put(
245
252
  (MSG_RESPONSE, self.request_id,
246
- Response(self.request_id, data=data, status=status)))
253
+ Response(
254
+ self.request_id, data=data, status=status,
255
+ headers=headers, cookies=cookies)))
247
256
 
248
257
  def response_stream(self, content_type=None, headers=None, cookies=None):
249
258
  """Start streaming response.
@@ -287,16 +296,19 @@ class Response:
287
296
  status: HTTP status code.
288
297
  data: Response body — dict (JSON), bytes (binary), or None.
289
298
  headers: Response headers dict, or None.
299
+ cookies: Response cookies dict, or None.
290
300
  """
291
301
 
292
- __slots__ = ('request_id', 'status', 'data', 'headers')
302
+ __slots__ = ('request_id', 'status', 'data', 'headers', 'cookies')
293
303
 
294
304
  def __init__(
295
- self, request_id, data=None, status=200, headers=None):
305
+ self, request_id, data=None, status=200,
306
+ headers=None, cookies=None):
296
307
  self.request_id = request_id
297
308
  self.status = status
298
309
  self.data = data
299
310
  self.headers = headers
311
+ self.cookies = cookies
300
312
 
301
313
 
302
314
  # API Handler
@@ -331,10 +343,20 @@ class Logger:
331
343
  level: Minimum log level.
332
344
  """
333
345
 
334
- def __init__(self, name, queue, level=LOG_WARNING):
346
+ def __init__(self, name, queue=None, level=LOG_WARNING, sink=None):
347
+ """Logger that sends to dispatcher via queue or direct callable.
348
+
349
+ Args:
350
+ name: Logger name.
351
+ queue: multiprocessing.Queue (worker context).
352
+ level: Minimum log level.
353
+ sink: Callable(name, level, message) — used instead of queue
354
+ (e.g., dispatcher's on_log).
355
+ """
335
356
  self.name = name
336
357
  self.level = level
337
358
  self._queue = queue
359
+ self._sink = sink
338
360
 
339
361
  @property
340
362
  def is_debug(self):
@@ -359,7 +381,10 @@ class Logger:
359
381
  message = message.format(**kwargs) if kwargs else message
360
382
  except (TypeError, KeyError, IndexError, ValueError):
361
383
  message = f"{msg} {args} {kwargs}"
362
- self._queue.put((MSG_LOG, self.name, level, message))
384
+ if self._sink is not None:
385
+ self._sink(self.name, level, message)
386
+ else:
387
+ self._queue.put((MSG_LOG, self.name, level, message))
363
388
 
364
389
  def critical(self, msg, *args, **kwargs):
365
390
  self._log(LOG_CRITICAL, msg, *args, **kwargs)
@@ -507,6 +532,14 @@ class Worker(_mp.Process):
507
532
  Extra kwargs from WorkerPool are available as self.kwargs.
508
533
  """
509
534
 
535
+ def teardown(self):
536
+ """Called once when worker process is stopping.
537
+
538
+ Override to clean up resources (close DB connections, flush buffers).
539
+ Called after the run loop exits, before the process terminates.
540
+ Exceptions are logged but do not prevent shutdown.
541
+ """
542
+
510
543
  def pause(self):
511
544
  """Stop accepting new requests from queue.
512
545
 
@@ -618,11 +651,25 @@ class Worker(_mp.Process):
618
651
  result = handler(request)
619
652
  if result is DEFERRED:
620
653
  return None
654
+ if isinstance(result, Response):
655
+ result.request_id = request.request_id
656
+ return result
657
+ headers = None
621
658
  if isinstance(result, tuple):
622
- data, status = result
659
+ if len(result) == 3:
660
+ data, status, headers = result
661
+ else:
662
+ data, status = result
623
663
  else:
624
664
  data, status = result, 200
625
- return Response(request.request_id, data=data, status=status)
665
+ return Response(
666
+ request.request_id, data=data,
667
+ status=status, headers=headers)
668
+ except RejectRequest as err:
669
+ return Response(
670
+ request.request_id,
671
+ data=err.data,
672
+ status=err.status)
626
673
  except Exception as err:
627
674
  return self.on_request_error(request, err)
628
675
 
@@ -656,6 +703,16 @@ class Worker(_mp.Process):
656
703
  return
657
704
  req_reader = self._request_queue._reader
658
705
  ctl_reader = self._control_queue._reader
706
+ try:
707
+ self._run_loop(req_reader, ctl_reader)
708
+ finally:
709
+ try:
710
+ self.teardown()
711
+ except Exception:
712
+ self.log.error(
713
+ "teardown() failed:\n%s", _traceback.format_exc())
714
+
715
+ def _run_loop(self, req_reader, ctl_reader):
659
716
  while self._running:
660
717
  read_fds = [ctl_reader] + list(self._readers)
661
718
  if self._accepting:
@@ -941,7 +998,7 @@ class Dispatcher:
941
998
  def __init__(
942
999
  self, port=8080, address='0.0.0.0', pools=None,
943
1000
  static_routes=None, shutdown_timeout=10,
944
- max_pending=1000, **kwargs):
1001
+ max_pending=1000, log_level=LOG_INFO, **kwargs):
945
1002
  """Initialize dispatcher.
946
1003
 
947
1004
  Args:
@@ -974,6 +1031,10 @@ class Dispatcher:
974
1031
  self._writers = {}
975
1032
  self._log_is_tty = _sys.stderr.isatty()
976
1033
  self._running = False
1034
+ self.log = Logger(
1035
+ type(self).__name__,
1036
+ sink=self.on_log,
1037
+ level=log_level)
977
1038
  self._build_sync_routes()
978
1039
 
979
1040
  def _build_sync_routes(self):
@@ -1020,12 +1081,39 @@ class Dispatcher:
1020
1081
  """Called after response is sent to client.
1021
1082
 
1022
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.
1023
1087
 
1024
1088
  Args:
1025
1089
  response: Response object from worker.
1026
1090
  pending: _PendingRequest with client and pool reference.
1027
1091
  """
1028
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
+
1029
1117
  def on_idle(self):
1030
1118
  """Called on each select timeout when no events arrived.
1031
1119
 
@@ -1170,6 +1258,8 @@ class Dispatcher:
1170
1258
  pending = self._pending.pop(request_id, None)
1171
1259
  if pending is not None:
1172
1260
  pending.client.response_stream_end()
1261
+ self._notify_pending_removed(
1262
+ request_id, pending, PENDING_STREAM_CLOSED)
1173
1263
  elif msg_type == MSG_RESPONSE:
1174
1264
  _, request_id, response = msg
1175
1265
  pending = self._pending.pop(request_id, None)
@@ -1177,8 +1267,11 @@ class Dispatcher:
1177
1267
  pending.client.respond(
1178
1268
  response.data,
1179
1269
  status=response.status,
1180
- headers=response.headers)
1270
+ headers=response.headers,
1271
+ cookies=response.cookies)
1181
1272
  self.on_response(response, pending)
1273
+ self._notify_pending_removed(
1274
+ request_id, pending, PENDING_COMPLETED)
1182
1275
 
1183
1276
  def _stream_disconnected(self, request_id, pending):
1184
1277
  """Handle client disconnect during streaming."""
@@ -1188,6 +1281,18 @@ class Dispatcher:
1188
1281
  if pending.worker_id < len(pool._control_queues):
1189
1282
  pool._control_queues[pending.worker_id].put(
1190
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}")
1191
1296
 
1192
1297
  def _process_responses(self):
1193
1298
  """Process all pending messages from response queue."""
@@ -1214,6 +1319,8 @@ class Dispatcher:
1214
1319
  f"{pending.pool.timeout}s")
1215
1320
  pending.client.respond(
1216
1321
  {'error': 'Request timeout'}, status=504)
1322
+ self._notify_pending_removed(
1323
+ request_id, pending, PENDING_TIMEOUT)
1217
1324
 
1218
1325
  def _check_all_workers(self):
1219
1326
  """Check health of all worker pools and queue sizes."""
@@ -1340,12 +1447,14 @@ class Dispatcher:
1340
1447
  except _queue.Empty:
1341
1448
  pass
1342
1449
  # respond 503 to remaining pending
1343
- for pending in self._pending.values():
1450
+ for request_id, pending in self._pending.items():
1344
1451
  try:
1345
1452
  pending.client.respond(
1346
1453
  {'error': 'Server shutting down'}, status=503)
1347
1454
  except Exception:
1348
1455
  pass
1456
+ self._notify_pending_removed(
1457
+ request_id, pending, PENDING_SHUTDOWN)
1349
1458
  self._pending.clear()
1350
1459
  # shutdown all pools
1351
1460
  for pool in self._pools:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: uhttp-workers
3
- Version: 1.2.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
@@ -234,6 +234,21 @@ class MyWorker(_workers.Worker):
234
234
 
235
235
  Extra keyword arguments from `WorkerPool(...)` are available as `self.kwargs`.
236
236
 
237
+ ### Teardown
238
+
239
+ `teardown()` is called once when the worker process is stopping — use it to release resources:
240
+
241
+ ```python
242
+ class MyWorker(_workers.Worker):
243
+ def setup(self):
244
+ self.db = connect_db()
245
+
246
+ def teardown(self):
247
+ self.db.close()
248
+ ```
249
+
250
+ Called after the run loop exits (clean stop, orphan detection, or pipe close), before the process terminates. Exceptions are logged but do not block shutdown.
251
+
237
252
  ### Configuration Updates
238
253
 
239
254
  Dispatcher can send configuration to workers at runtime via per-worker control queues:
@@ -278,10 +293,29 @@ def process(self, request):
278
293
  # return data with status
279
294
  return {'error': 'not found'}, 404
280
295
 
296
+ # return data with status and headers
297
+ return {'ok': True}, 200, {'X-Custom': 'value'}
298
+
299
+ # return Response object — full control (headers, cookies)
300
+ return _workers.Response(
301
+ None, # request_id is set automatically
302
+ data={'ok': True},
303
+ headers={'X-Custom': 'value'},
304
+ cookies={'session': 'abc123'})
305
+
281
306
  # defer response — worker continues accepting requests
282
307
  return _workers.DEFERRED
283
308
  ```
284
309
 
310
+ `request.respond()` (for deferred responses) also accepts `headers` and `cookies`:
311
+
312
+ ```python
313
+ request.respond(
314
+ data={'result': 'done'},
315
+ headers={'X-Job-Id': '42'},
316
+ cookies={'session': 'abc'})
317
+ ```
318
+
285
319
  ### Deferred Responses
286
320
 
287
321
  Return `DEFERRED` to skip immediate response. The worker stays in the select loop, accepts new requests, and sends the response later via `request.respond()`:
@@ -432,6 +466,17 @@ Return `(data, status)` tuple to reject, or `None` to continue. You can also rai
432
466
 
433
467
  `RejectRequest` accepts optional `data` (default: `{'error': 'Rejected'}`) and `status` (default: `403`).
434
468
 
469
+ `RejectRequest` can also be raised from request handlers — useful for access control within individual endpoints:
470
+
471
+ ```python
472
+ @_workers.api('/admin/users', 'GET')
473
+ def admin_users(self, request):
474
+ if not self.is_admin(request):
475
+ raise _workers.RejectRequest(
476
+ data={'error': 'admin only'}, status=403)
477
+ return {'users': self.list_users()}
478
+ ```
479
+
435
480
  ### Error Handling
436
481
 
437
482
  Override `on_request_error()` on the worker to customize error handling when a request handler raises an exception:
@@ -468,6 +513,43 @@ class MyDispatcher(_workers.Dispatcher):
468
513
  `pending` is a `_PendingRequest` with `client` (original connection) and `pool` (source pool).
469
514
  Requests with `request_id=-1` are ignored by the dispatcher when the worker responds.
470
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
+
471
553
  ## Dispatcher Idle Hook
472
554
 
473
555
  Override `on_idle()` on the dispatcher for periodic background tasks — called on each `select()` timeout (every `SELECT_TIMEOUT` seconds, default 1s):
@@ -523,6 +605,20 @@ class MyWorker(_workers.Worker):
523
605
 
524
606
  Log messages are sent to the dispatcher via the shared response queue and printed in the dispatcher process — no interleaved output from multiple processes.
525
607
 
608
+ The dispatcher itself also has a `self.log` Logger that writes directly via `on_log()` (no queue), so it can be used at any time — including before workers are started:
609
+
610
+ ```python
611
+ class MyDispatcher(_workers.Dispatcher):
612
+ def __init__(self, **kwargs):
613
+ super().__init__(**kwargs)
614
+ self.log.info("Dispatcher starting on port %d", self._port)
615
+
616
+ def on_idle(self):
617
+ self.log.debug("idle tick")
618
+ ```
619
+
620
+ Set dispatcher log level via constructor: `Dispatcher(log_level=LOG_DEBUG, ...)` (default `LOG_INFO`).
621
+
526
622
  **Log levels:** `LOG_DEBUG` (10), `LOG_INFO` (20), `LOG_WARNING` (30), `LOG_ERROR` (40), `LOG_CRITICAL` (50)
527
623
 
528
624
  Check current level with `is_*` properties to skip expensive formatting:
File without changes
File without changes