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.
- {uhttp_workers-1.2.0/uhttp_workers.egg-info → uhttp_workers-1.3.0}/PKG-INFO +60 -1
- {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/README.md +59 -0
- {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/tests/test_worker.py +112 -0
- {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/uhttp/workers.py +65 -10
- {uhttp_workers-1.2.0 → uhttp_workers-1.3.0/uhttp_workers.egg-info}/PKG-INFO +60 -1
- {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/.github/workflows/publish.yml +0 -0
- {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/.github/workflows/tests.yml +0 -0
- {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/.gitignore +0 -0
- {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/examples/simple_workers.py +0 -0
- {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/examples/sse_workers.py +0 -0
- {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/examples/static/index.html +0 -0
- {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/pyproject.toml +0 -0
- {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/setup.cfg +0 -0
- {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/tests/__init__.py +0 -0
- {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/tests/test_api_handler.py +0 -0
- {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/tests/test_decorators.py +0 -0
- {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/tests/test_dispatcher.py +0 -0
- {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/tests/test_pattern_matching.py +0 -0
- {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/tests/test_request_response.py +0 -0
- {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/tests/test_worker_pool.py +0 -0
- {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/uhttp_workers.egg-info/SOURCES.txt +0 -0
- {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/uhttp_workers.egg-info/dependency_links.txt +0 -0
- {uhttp_workers-1.2.0 → uhttp_workers-1.3.0}/uhttp_workers.egg-info/requires.txt +0 -0
- {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.
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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
|
|
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
|