uhttp-workers 1.2.0__tar.gz → 1.3.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.3.0}/PKG-INFO +60 -1
  2. {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/README.md +59 -0
  3. {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/tests/test_worker.py +112 -0
  4. {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/uhttp/workers.py +65 -10
  5. {uhttp_workers-1.2.0 → uhttp_workers-1.3.0/uhttp_workers.egg-info}/PKG-INFO +60 -1
  6. {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/.github/workflows/publish.yml +0 -0
  7. {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/.github/workflows/tests.yml +0 -0
  8. {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/.gitignore +0 -0
  9. {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/examples/simple_workers.py +0 -0
  10. {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/examples/sse_workers.py +0 -0
  11. {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/examples/static/index.html +0 -0
  12. {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/pyproject.toml +0 -0
  13. {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/setup.cfg +0 -0
  14. {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/tests/__init__.py +0 -0
  15. {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/tests/test_api_handler.py +0 -0
  16. {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/tests/test_decorators.py +0 -0
  17. {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/tests/test_dispatcher.py +0 -0
  18. {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/tests/test_pattern_matching.py +0 -0
  19. {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/tests/test_request_response.py +0 -0
  20. {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/tests/test_worker_pool.py +0 -0
  21. {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/uhttp_workers.egg-info/SOURCES.txt +0 -0
  22. {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/uhttp_workers.egg-info/dependency_links.txt +0 -0
  23. {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/uhttp_workers.egg-info/requires.txt +0 -0
  24. {uhttp_workers-1.2.0 → uhttp_workers-1.3.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.3.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:
@@ -523,6 +568,20 @@ class MyWorker(_workers.Worker):
523
568
 
524
569
  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
570
 
571
+ 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:
572
+
573
+ ```python
574
+ class MyDispatcher(_workers.Dispatcher):
575
+ def __init__(self, **kwargs):
576
+ super().__init__(**kwargs)
577
+ self.log.info("Dispatcher starting on port %d", self._port)
578
+
579
+ def on_idle(self):
580
+ self.log.debug("idle tick")
581
+ ```
582
+
583
+ Set dispatcher log level via constructor: `Dispatcher(log_level=LOG_DEBUG, ...)` (default `LOG_INFO`).
584
+
526
585
  **Log levels:** `LOG_DEBUG` (10), `LOG_INFO` (20), `LOG_WARNING` (30), `LOG_ERROR` (40), `LOG_CRITICAL` (50)
527
586
 
528
587
  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:
@@ -509,6 +554,20 @@ class MyWorker(_workers.Worker):
509
554
 
510
555
  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
556
 
557
+ 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:
558
+
559
+ ```python
560
+ class MyDispatcher(_workers.Dispatcher):
561
+ def __init__(self, **kwargs):
562
+ super().__init__(**kwargs)
563
+ self.log.info("Dispatcher starting on port %d", self._port)
564
+
565
+ def on_idle(self):
566
+ self.log.debug("idle tick")
567
+ ```
568
+
569
+ Set dispatcher log level via constructor: `Dispatcher(log_level=LOG_DEBUG, ...)` (default `LOG_INFO`).
570
+
512
571
  **Log levels:** `LOG_DEBUG` (10), `LOG_INFO` (20), `LOG_WARNING` (30), `LOG_ERROR` (40), `LOG_CRITICAL` (50)
513
572
 
514
573
  Check current level with `is_*` properties to skip expensive formatting:
@@ -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
 
@@ -236,14 +236,16 @@ class Request:
236
236
  _uhttp_server.parse_cookies(raw) if raw else {})
237
237
  return self._cookies
238
238
 
239
- def respond(self, data=None, status=200):
239
+ def respond(self, data=None, status=200, headers=None, cookies=None):
240
240
  """Send deferred response for this request.
241
241
 
242
242
  Use after returning DEFERRED from handler.
243
243
  """
244
244
  self._response_queue.put(
245
245
  (MSG_RESPONSE, self.request_id,
246
- Response(self.request_id, data=data, status=status)))
246
+ Response(
247
+ self.request_id, data=data, status=status,
248
+ headers=headers, cookies=cookies)))
247
249
 
248
250
  def response_stream(self, content_type=None, headers=None, cookies=None):
249
251
  """Start streaming response.
@@ -287,16 +289,19 @@ class Response:
287
289
  status: HTTP status code.
288
290
  data: Response body — dict (JSON), bytes (binary), or None.
289
291
  headers: Response headers dict, or None.
292
+ cookies: Response cookies dict, or None.
290
293
  """
291
294
 
292
- __slots__ = ('request_id', 'status', 'data', 'headers')
295
+ __slots__ = ('request_id', 'status', 'data', 'headers', 'cookies')
293
296
 
294
297
  def __init__(
295
- self, request_id, data=None, status=200, headers=None):
298
+ self, request_id, data=None, status=200,
299
+ headers=None, cookies=None):
296
300
  self.request_id = request_id
297
301
  self.status = status
298
302
  self.data = data
299
303
  self.headers = headers
304
+ self.cookies = cookies
300
305
 
301
306
 
302
307
  # API Handler
@@ -331,10 +336,20 @@ class Logger:
331
336
  level: Minimum log level.
332
337
  """
333
338
 
334
- def __init__(self, name, queue, level=LOG_WARNING):
339
+ def __init__(self, name, queue=None, level=LOG_WARNING, sink=None):
340
+ """Logger that sends to dispatcher via queue or direct callable.
341
+
342
+ Args:
343
+ name: Logger name.
344
+ queue: multiprocessing.Queue (worker context).
345
+ level: Minimum log level.
346
+ sink: Callable(name, level, message) — used instead of queue
347
+ (e.g., dispatcher's on_log).
348
+ """
335
349
  self.name = name
336
350
  self.level = level
337
351
  self._queue = queue
352
+ self._sink = sink
338
353
 
339
354
  @property
340
355
  def is_debug(self):
@@ -359,7 +374,10 @@ class Logger:
359
374
  message = message.format(**kwargs) if kwargs else message
360
375
  except (TypeError, KeyError, IndexError, ValueError):
361
376
  message = f"{msg} {args} {kwargs}"
362
- self._queue.put((MSG_LOG, self.name, level, message))
377
+ if self._sink is not None:
378
+ self._sink(self.name, level, message)
379
+ else:
380
+ self._queue.put((MSG_LOG, self.name, level, message))
363
381
 
364
382
  def critical(self, msg, *args, **kwargs):
365
383
  self._log(LOG_CRITICAL, msg, *args, **kwargs)
@@ -507,6 +525,14 @@ class Worker(_mp.Process):
507
525
  Extra kwargs from WorkerPool are available as self.kwargs.
508
526
  """
509
527
 
528
+ def teardown(self):
529
+ """Called once when worker process is stopping.
530
+
531
+ Override to clean up resources (close DB connections, flush buffers).
532
+ Called after the run loop exits, before the process terminates.
533
+ Exceptions are logged but do not prevent shutdown.
534
+ """
535
+
510
536
  def pause(self):
511
537
  """Stop accepting new requests from queue.
512
538
 
@@ -618,11 +644,25 @@ class Worker(_mp.Process):
618
644
  result = handler(request)
619
645
  if result is DEFERRED:
620
646
  return None
647
+ if isinstance(result, Response):
648
+ result.request_id = request.request_id
649
+ return result
650
+ headers = None
621
651
  if isinstance(result, tuple):
622
- data, status = result
652
+ if len(result) == 3:
653
+ data, status, headers = result
654
+ else:
655
+ data, status = result
623
656
  else:
624
657
  data, status = result, 200
625
- return Response(request.request_id, data=data, status=status)
658
+ return Response(
659
+ request.request_id, data=data,
660
+ status=status, headers=headers)
661
+ except RejectRequest as err:
662
+ return Response(
663
+ request.request_id,
664
+ data=err.data,
665
+ status=err.status)
626
666
  except Exception as err:
627
667
  return self.on_request_error(request, err)
628
668
 
@@ -656,6 +696,16 @@ class Worker(_mp.Process):
656
696
  return
657
697
  req_reader = self._request_queue._reader
658
698
  ctl_reader = self._control_queue._reader
699
+ try:
700
+ self._run_loop(req_reader, ctl_reader)
701
+ finally:
702
+ try:
703
+ self.teardown()
704
+ except Exception:
705
+ self.log.error(
706
+ "teardown() failed:\n%s", _traceback.format_exc())
707
+
708
+ def _run_loop(self, req_reader, ctl_reader):
659
709
  while self._running:
660
710
  read_fds = [ctl_reader] + list(self._readers)
661
711
  if self._accepting:
@@ -941,7 +991,7 @@ class Dispatcher:
941
991
  def __init__(
942
992
  self, port=8080, address='0.0.0.0', pools=None,
943
993
  static_routes=None, shutdown_timeout=10,
944
- max_pending=1000, **kwargs):
994
+ max_pending=1000, log_level=LOG_INFO, **kwargs):
945
995
  """Initialize dispatcher.
946
996
 
947
997
  Args:
@@ -974,6 +1024,10 @@ class Dispatcher:
974
1024
  self._writers = {}
975
1025
  self._log_is_tty = _sys.stderr.isatty()
976
1026
  self._running = False
1027
+ self.log = Logger(
1028
+ type(self).__name__,
1029
+ sink=self.on_log,
1030
+ level=log_level)
977
1031
  self._build_sync_routes()
978
1032
 
979
1033
  def _build_sync_routes(self):
@@ -1177,7 +1231,8 @@ class Dispatcher:
1177
1231
  pending.client.respond(
1178
1232
  response.data,
1179
1233
  status=response.status,
1180
- headers=response.headers)
1234
+ headers=response.headers,
1235
+ cookies=response.cookies)
1181
1236
  self.on_response(response, pending)
1182
1237
 
1183
1238
  def _stream_disconnected(self, request_id, pending):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: uhttp-workers
3
- Version: 1.2.0
3
+ Version: 1.3.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:
@@ -523,6 +568,20 @@ class MyWorker(_workers.Worker):
523
568
 
524
569
  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
570
 
571
+ 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:
572
+
573
+ ```python
574
+ class MyDispatcher(_workers.Dispatcher):
575
+ def __init__(self, **kwargs):
576
+ super().__init__(**kwargs)
577
+ self.log.info("Dispatcher starting on port %d", self._port)
578
+
579
+ def on_idle(self):
580
+ self.log.debug("idle tick")
581
+ ```
582
+
583
+ Set dispatcher log level via constructor: `Dispatcher(log_level=LOG_DEBUG, ...)` (default `LOG_INFO`).
584
+
526
585
  **Log levels:** `LOG_DEBUG` (10), `LOG_INFO` (20), `LOG_WARNING` (30), `LOG_ERROR` (40), `LOG_CRITICAL` (50)
527
586
 
528
587
  Check current level with `is_*` properties to skip expensive formatting:
File without changes
File without changes