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