uhttp-workers 1.1.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.1.0/uhttp_workers.egg-info → uhttp_workers-1.3.0}/PKG-INFO +153 -1
- {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/README.md +152 -0
- uhttp_workers-1.3.0/examples/sse_workers.py +80 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/tests/test_dispatcher.py +139 -0
- uhttp_workers-1.3.0/tests/test_worker.py +741 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/uhttp/workers.py +219 -18
- {uhttp_workers-1.1.0 → uhttp_workers-1.3.0/uhttp_workers.egg-info}/PKG-INFO +153 -1
- {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/uhttp_workers.egg-info/SOURCES.txt +1 -0
- uhttp_workers-1.1.0/tests/test_worker.py +0 -327
- {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/.github/workflows/publish.yml +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/.github/workflows/tests.yml +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/.gitignore +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/examples/simple_workers.py +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/examples/static/index.html +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/pyproject.toml +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/setup.cfg +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/tests/__init__.py +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/tests/test_api_handler.py +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/tests/test_decorators.py +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/tests/test_pattern_matching.py +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/tests/test_request_response.py +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/tests/test_worker_pool.py +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/uhttp_workers.egg-info/dependency_links.txt +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/uhttp_workers.egg-info/requires.txt +0 -0
- {uhttp_workers-1.1.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:
|
|
@@ -277,8 +292,120 @@ def process(self, request):
|
|
|
277
292
|
|
|
278
293
|
# return data with status
|
|
279
294
|
return {'error': 'not found'}, 404
|
|
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
|
+
|
|
306
|
+
# defer response — worker continues accepting requests
|
|
307
|
+
return _workers.DEFERRED
|
|
308
|
+
```
|
|
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'})
|
|
280
317
|
```
|
|
281
318
|
|
|
319
|
+
### Deferred Responses
|
|
320
|
+
|
|
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()`:
|
|
322
|
+
|
|
323
|
+
```python
|
|
324
|
+
class MyWorker(_workers.Worker):
|
|
325
|
+
def setup(self):
|
|
326
|
+
self._jobs = {}
|
|
327
|
+
|
|
328
|
+
@_workers.api('/process', 'POST')
|
|
329
|
+
def process(self, request):
|
|
330
|
+
job_id = start_background_work(request.data)
|
|
331
|
+
self._jobs[job_id] = request
|
|
332
|
+
return _workers.DEFERRED
|
|
333
|
+
|
|
334
|
+
def on_work_done(self, job_id, result):
|
|
335
|
+
request = self._jobs.pop(job_id)
|
|
336
|
+
request.respond(data={'result': result})
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
Note: deferred requests are still subject to dispatcher timeout — call `self.keep_alive()` periodically to prevent 504.
|
|
340
|
+
|
|
341
|
+
### Keep Alive
|
|
342
|
+
|
|
343
|
+
Call `self.keep_alive()` during long operations to reset both the request timeout and stuck worker detection:
|
|
344
|
+
|
|
345
|
+
```python
|
|
346
|
+
@_workers.api('/export', 'POST')
|
|
347
|
+
def export(self, request):
|
|
348
|
+
for chunk in generate_large_export():
|
|
349
|
+
self.keep_alive()
|
|
350
|
+
return {'status': 'done'}
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
### Server-Sent Events (SSE)
|
|
354
|
+
|
|
355
|
+
Stream events to clients using the same API as `uhttp.server.Client`:
|
|
356
|
+
|
|
357
|
+
```python
|
|
358
|
+
class MyWorker(_workers.Worker):
|
|
359
|
+
def setup(self):
|
|
360
|
+
self._subscribers = {}
|
|
361
|
+
|
|
362
|
+
@_workers.api('/events', 'GET')
|
|
363
|
+
def events(self, request):
|
|
364
|
+
request.response_stream() # sends headers, keeps connection open
|
|
365
|
+
self._subscribers[request.request_id] = request
|
|
366
|
+
return _workers.DEFERRED
|
|
367
|
+
|
|
368
|
+
def notify(self, data):
|
|
369
|
+
for req in self._subscribers.values():
|
|
370
|
+
req.send_event(data=data, event='update')
|
|
371
|
+
|
|
372
|
+
def on_disconnect(self, request_id):
|
|
373
|
+
self._subscribers.pop(request_id, None)
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
Available streaming methods on `Request`:
|
|
377
|
+
|
|
378
|
+
| Method | Description |
|
|
379
|
+
|--------|-------------|
|
|
380
|
+
| `response_stream(content_type, headers, cookies)` | Start streaming (default: `text/event-stream`) |
|
|
381
|
+
| `send_event(data, event, event_id, retry)` | Send SSE event |
|
|
382
|
+
| `send_chunk(data)` | Send raw data chunk |
|
|
383
|
+
| `response_stream_end()` | End stream and close connection |
|
|
384
|
+
|
|
385
|
+
Streaming requests are excluded from dispatcher timeout expiration. When the client disconnects, the dispatcher notifies the worker via `on_disconnect(request_id)`.
|
|
386
|
+
|
|
387
|
+
### Flow Control
|
|
388
|
+
|
|
389
|
+
Workers can stop accepting new requests when busy. Requests stay in the shared pool queue for other workers to pick up:
|
|
390
|
+
|
|
391
|
+
```python
|
|
392
|
+
class MyWorker(_workers.Worker):
|
|
393
|
+
@_workers.api('/events', 'GET')
|
|
394
|
+
def subscribe(self, request):
|
|
395
|
+
request.response_stream()
|
|
396
|
+
self._subscribers[request.request_id] = request
|
|
397
|
+
if len(self._subscribers) >= 100:
|
|
398
|
+
self.pause()
|
|
399
|
+
return _workers.DEFERRED
|
|
400
|
+
|
|
401
|
+
def on_disconnect(self, request_id):
|
|
402
|
+
self._subscribers.pop(request_id, None)
|
|
403
|
+
if not self._accepting and len(self._subscribers) < 100:
|
|
404
|
+
self.resume()
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
`pause()` excludes the request queue from `select()` — the worker continues processing control messages, custom fd events, and `on_idle()`. `resume()` re-enables request acceptance.
|
|
408
|
+
|
|
282
409
|
## URL Patterns
|
|
283
410
|
|
|
284
411
|
Dispatcher uses prefix matching to route requests to pools:
|
|
@@ -339,6 +466,17 @@ Return `(data, status)` tuple to reject, or `None` to continue. You can also rai
|
|
|
339
466
|
|
|
340
467
|
`RejectRequest` accepts optional `data` (default: `{'error': 'Rejected'}`) and `status` (default: `403`).
|
|
341
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
|
+
|
|
342
480
|
### Error Handling
|
|
343
481
|
|
|
344
482
|
Override `on_request_error()` on the worker to customize error handling when a request handler raises an exception:
|
|
@@ -430,6 +568,20 @@ class MyWorker(_workers.Worker):
|
|
|
430
568
|
|
|
431
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.
|
|
432
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
|
+
|
|
433
585
|
**Log levels:** `LOG_DEBUG` (10), `LOG_INFO` (20), `LOG_WARNING` (30), `LOG_ERROR` (40), `LOG_CRITICAL` (50)
|
|
434
586
|
|
|
435
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:
|
|
@@ -263,8 +278,120 @@ def process(self, request):
|
|
|
263
278
|
|
|
264
279
|
# return data with status
|
|
265
280
|
return {'error': 'not found'}, 404
|
|
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
|
+
|
|
292
|
+
# defer response — worker continues accepting requests
|
|
293
|
+
return _workers.DEFERRED
|
|
294
|
+
```
|
|
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'})
|
|
266
303
|
```
|
|
267
304
|
|
|
305
|
+
### Deferred Responses
|
|
306
|
+
|
|
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()`:
|
|
308
|
+
|
|
309
|
+
```python
|
|
310
|
+
class MyWorker(_workers.Worker):
|
|
311
|
+
def setup(self):
|
|
312
|
+
self._jobs = {}
|
|
313
|
+
|
|
314
|
+
@_workers.api('/process', 'POST')
|
|
315
|
+
def process(self, request):
|
|
316
|
+
job_id = start_background_work(request.data)
|
|
317
|
+
self._jobs[job_id] = request
|
|
318
|
+
return _workers.DEFERRED
|
|
319
|
+
|
|
320
|
+
def on_work_done(self, job_id, result):
|
|
321
|
+
request = self._jobs.pop(job_id)
|
|
322
|
+
request.respond(data={'result': result})
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
Note: deferred requests are still subject to dispatcher timeout — call `self.keep_alive()` periodically to prevent 504.
|
|
326
|
+
|
|
327
|
+
### Keep Alive
|
|
328
|
+
|
|
329
|
+
Call `self.keep_alive()` during long operations to reset both the request timeout and stuck worker detection:
|
|
330
|
+
|
|
331
|
+
```python
|
|
332
|
+
@_workers.api('/export', 'POST')
|
|
333
|
+
def export(self, request):
|
|
334
|
+
for chunk in generate_large_export():
|
|
335
|
+
self.keep_alive()
|
|
336
|
+
return {'status': 'done'}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### Server-Sent Events (SSE)
|
|
340
|
+
|
|
341
|
+
Stream events to clients using the same API as `uhttp.server.Client`:
|
|
342
|
+
|
|
343
|
+
```python
|
|
344
|
+
class MyWorker(_workers.Worker):
|
|
345
|
+
def setup(self):
|
|
346
|
+
self._subscribers = {}
|
|
347
|
+
|
|
348
|
+
@_workers.api('/events', 'GET')
|
|
349
|
+
def events(self, request):
|
|
350
|
+
request.response_stream() # sends headers, keeps connection open
|
|
351
|
+
self._subscribers[request.request_id] = request
|
|
352
|
+
return _workers.DEFERRED
|
|
353
|
+
|
|
354
|
+
def notify(self, data):
|
|
355
|
+
for req in self._subscribers.values():
|
|
356
|
+
req.send_event(data=data, event='update')
|
|
357
|
+
|
|
358
|
+
def on_disconnect(self, request_id):
|
|
359
|
+
self._subscribers.pop(request_id, None)
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
Available streaming methods on `Request`:
|
|
363
|
+
|
|
364
|
+
| Method | Description |
|
|
365
|
+
|--------|-------------|
|
|
366
|
+
| `response_stream(content_type, headers, cookies)` | Start streaming (default: `text/event-stream`) |
|
|
367
|
+
| `send_event(data, event, event_id, retry)` | Send SSE event |
|
|
368
|
+
| `send_chunk(data)` | Send raw data chunk |
|
|
369
|
+
| `response_stream_end()` | End stream and close connection |
|
|
370
|
+
|
|
371
|
+
Streaming requests are excluded from dispatcher timeout expiration. When the client disconnects, the dispatcher notifies the worker via `on_disconnect(request_id)`.
|
|
372
|
+
|
|
373
|
+
### Flow Control
|
|
374
|
+
|
|
375
|
+
Workers can stop accepting new requests when busy. Requests stay in the shared pool queue for other workers to pick up:
|
|
376
|
+
|
|
377
|
+
```python
|
|
378
|
+
class MyWorker(_workers.Worker):
|
|
379
|
+
@_workers.api('/events', 'GET')
|
|
380
|
+
def subscribe(self, request):
|
|
381
|
+
request.response_stream()
|
|
382
|
+
self._subscribers[request.request_id] = request
|
|
383
|
+
if len(self._subscribers) >= 100:
|
|
384
|
+
self.pause()
|
|
385
|
+
return _workers.DEFERRED
|
|
386
|
+
|
|
387
|
+
def on_disconnect(self, request_id):
|
|
388
|
+
self._subscribers.pop(request_id, None)
|
|
389
|
+
if not self._accepting and len(self._subscribers) < 100:
|
|
390
|
+
self.resume()
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
`pause()` excludes the request queue from `select()` — the worker continues processing control messages, custom fd events, and `on_idle()`. `resume()` re-enables request acceptance.
|
|
394
|
+
|
|
268
395
|
## URL Patterns
|
|
269
396
|
|
|
270
397
|
Dispatcher uses prefix matching to route requests to pools:
|
|
@@ -325,6 +452,17 @@ Return `(data, status)` tuple to reject, or `None` to continue. You can also rai
|
|
|
325
452
|
|
|
326
453
|
`RejectRequest` accepts optional `data` (default: `{'error': 'Rejected'}`) and `status` (default: `403`).
|
|
327
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
|
+
|
|
328
466
|
### Error Handling
|
|
329
467
|
|
|
330
468
|
Override `on_request_error()` on the worker to customize error handling when a request handler raises an exception:
|
|
@@ -416,6 +554,20 @@ class MyWorker(_workers.Worker):
|
|
|
416
554
|
|
|
417
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.
|
|
418
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
|
+
|
|
419
571
|
**Log levels:** `LOG_DEBUG` (10), `LOG_INFO` (20), `LOG_WARNING` (30), `LOG_ERROR` (40), `LOG_CRITICAL` (50)
|
|
420
572
|
|
|
421
573
|
Check current level with `is_*` properties to skip expensive formatting:
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""SSE (Server-Sent Events) example with deferred responses.
|
|
2
|
+
|
|
3
|
+
Demonstrates:
|
|
4
|
+
- SSE streaming from worker to client
|
|
5
|
+
- Deferred responses (DEFERRED sentinel)
|
|
6
|
+
- Client disconnect handling (on_disconnect)
|
|
7
|
+
- keep_alive() for long operations
|
|
8
|
+
|
|
9
|
+
Test with:
|
|
10
|
+
curl -N http://localhost:8080/events
|
|
11
|
+
curl -N http://localhost:8080/events?channel=news
|
|
12
|
+
curl -X POST http://localhost:8080/publish -d '{"msg": "hello"}'
|
|
13
|
+
curl -X POST http://localhost:8080/publish -d '{"channel": "news", "msg": "breaking"}'
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import uhttp.workers as _workers
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EventWorker(_workers.Worker):
|
|
20
|
+
"""Worker that manages SSE subscribers and publishes events."""
|
|
21
|
+
|
|
22
|
+
def setup(self):
|
|
23
|
+
self._subscribers = {} # request_id → (request, channel)
|
|
24
|
+
|
|
25
|
+
@_workers.api('/events', 'GET')
|
|
26
|
+
def subscribe(self, request):
|
|
27
|
+
channel = (request.query or {}).get('channel', 'default')
|
|
28
|
+
request.response_stream()
|
|
29
|
+
request.send_event(
|
|
30
|
+
data={'status': 'connected', 'channel': channel},
|
|
31
|
+
event='open')
|
|
32
|
+
self._subscribers[request.request_id] = (request, channel)
|
|
33
|
+
if len(self._subscribers) >= self.kwargs.get('max_subscribers', 100):
|
|
34
|
+
self.pause()
|
|
35
|
+
return _workers.DEFERRED
|
|
36
|
+
|
|
37
|
+
@_workers.api('/publish', 'POST')
|
|
38
|
+
def publish(self, request):
|
|
39
|
+
channel = (request.data or {}).get('channel', 'default')
|
|
40
|
+
msg = (request.data or {}).get('msg', '')
|
|
41
|
+
count = 0
|
|
42
|
+
for req, ch in self._subscribers.values():
|
|
43
|
+
if ch == channel:
|
|
44
|
+
req.send_event(data={'msg': msg}, event='message')
|
|
45
|
+
count += 1
|
|
46
|
+
return {'published': count, 'channel': channel}
|
|
47
|
+
|
|
48
|
+
def on_disconnect(self, request_id):
|
|
49
|
+
sub = self._subscribers.pop(request_id, None)
|
|
50
|
+
if sub:
|
|
51
|
+
self.log.info("Client disconnected: %d", request_id)
|
|
52
|
+
max_subs = self.kwargs.get('max_subscribers', 100)
|
|
53
|
+
if not self._accepting and len(self._subscribers) < max_subs:
|
|
54
|
+
self.resume()
|
|
55
|
+
|
|
56
|
+
def on_idle(self):
|
|
57
|
+
# periodic keepalive to all subscribers
|
|
58
|
+
for req, _ in list(self._subscribers.values()):
|
|
59
|
+
req.send_event(event='ping')
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class MyDispatcher(_workers.Dispatcher):
|
|
63
|
+
@_workers.sync('/health')
|
|
64
|
+
def health(self, client, path_params):
|
|
65
|
+
client.respond({'status': 'ok'})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main():
|
|
69
|
+
pool = _workers.WorkerPool(
|
|
70
|
+
EventWorker, num_workers=2,
|
|
71
|
+
log_level=_workers.LOG_INFO,
|
|
72
|
+
timeout=30)
|
|
73
|
+
dispatcher = MyDispatcher(
|
|
74
|
+
port=8080,
|
|
75
|
+
pools=[pool])
|
|
76
|
+
dispatcher.run()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == '__main__':
|
|
80
|
+
main()
|
|
@@ -10,6 +10,8 @@ from uhttp.workers import (
|
|
|
10
10
|
Dispatcher, Worker, WorkerPool, Request, Response,
|
|
11
11
|
api, sync, RejectRequest,
|
|
12
12
|
MSG_RESPONSE, MSG_HEARTBEAT,
|
|
13
|
+
MSG_SSE_OPEN, MSG_SSE_EVENT, MSG_SSE_CLOSE,
|
|
14
|
+
CTL_DISCONNECT,
|
|
13
15
|
_PendingRequest,
|
|
14
16
|
)
|
|
15
17
|
|
|
@@ -52,6 +54,30 @@ class MockClient:
|
|
|
52
54
|
self.responded = True
|
|
53
55
|
self.file_path = path
|
|
54
56
|
|
|
57
|
+
def response_stream(self, content_type=None, headers=None, cookies=None):
|
|
58
|
+
self.streaming = True
|
|
59
|
+
self.stream_content_type = content_type
|
|
60
|
+
self.stream_headers = headers
|
|
61
|
+
self.stream_cookies = cookies
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
def send_event(self, data=None, event=None, event_id=None, retry=None):
|
|
65
|
+
if not hasattr(self, '_events'):
|
|
66
|
+
self._events = []
|
|
67
|
+
self._events.append({
|
|
68
|
+
'data': data, 'event': event,
|
|
69
|
+
'event_id': event_id, 'retry': retry})
|
|
70
|
+
return getattr(self, '_connected', True)
|
|
71
|
+
|
|
72
|
+
def send_chunk(self, data):
|
|
73
|
+
if not hasattr(self, '_chunks'):
|
|
74
|
+
self._chunks = []
|
|
75
|
+
self._chunks.append(data)
|
|
76
|
+
return getattr(self, '_connected', True)
|
|
77
|
+
|
|
78
|
+
def response_stream_end(self):
|
|
79
|
+
self.stream_ended = True
|
|
80
|
+
|
|
55
81
|
|
|
56
82
|
class TestDispatcherSyncRoutes(unittest.TestCase):
|
|
57
83
|
|
|
@@ -413,5 +439,118 @@ class TestDispatcherRoutePriority(unittest.TestCase):
|
|
|
413
439
|
self.assertEqual(len(d._pending), 0)
|
|
414
440
|
|
|
415
441
|
|
|
442
|
+
class TestDispatcherSSE(unittest.TestCase):
|
|
443
|
+
|
|
444
|
+
def _make_dispatcher(self):
|
|
445
|
+
pool = WorkerPool(DummyWorker, routes=['/api/**'])
|
|
446
|
+
d = Dispatcher.__new__(Dispatcher)
|
|
447
|
+
d._sync_routes = []
|
|
448
|
+
d._static_routes = {}
|
|
449
|
+
d._pools = [pool]
|
|
450
|
+
d._pending = {}
|
|
451
|
+
d._max_pending = 1000
|
|
452
|
+
d._next_request_id = 0
|
|
453
|
+
d._response_queue = mp.Queue()
|
|
454
|
+
d._log_is_tty = False
|
|
455
|
+
d.on_log = lambda *_: None
|
|
456
|
+
return d, pool
|
|
457
|
+
|
|
458
|
+
def test_sse_open(self):
|
|
459
|
+
d, pool = self._make_dispatcher()
|
|
460
|
+
client = MockClient('GET', '/api/events')
|
|
461
|
+
pending = _PendingRequest(client, pool)
|
|
462
|
+
pending.worker_id = 0
|
|
463
|
+
d._pending[1] = pending
|
|
464
|
+
d._process_response(
|
|
465
|
+
(MSG_SSE_OPEN, 1, 'text/event-stream', None, None))
|
|
466
|
+
self.assertTrue(client.streaming)
|
|
467
|
+
self.assertEqual(client.stream_content_type, 'text/event-stream')
|
|
468
|
+
self.assertTrue(pending.streaming)
|
|
469
|
+
# still in pending
|
|
470
|
+
self.assertIn(1, d._pending)
|
|
471
|
+
|
|
472
|
+
def test_sse_send_event(self):
|
|
473
|
+
d, pool = self._make_dispatcher()
|
|
474
|
+
client = MockClient('GET', '/api/events')
|
|
475
|
+
pending = _PendingRequest(client, pool)
|
|
476
|
+
pending.streaming = True
|
|
477
|
+
d._pending[1] = pending
|
|
478
|
+
d._process_response(
|
|
479
|
+
(MSG_SSE_EVENT, 1, {'count': 5}, 'update', '3', None))
|
|
480
|
+
self.assertEqual(len(client._events), 1)
|
|
481
|
+
self.assertEqual(client._events[0]['data'], {'count': 5})
|
|
482
|
+
self.assertEqual(client._events[0]['event'], 'update')
|
|
483
|
+
self.assertEqual(client._events[0]['event_id'], '3')
|
|
484
|
+
|
|
485
|
+
def test_sse_send_chunk(self):
|
|
486
|
+
d, pool = self._make_dispatcher()
|
|
487
|
+
client = MockClient('GET', '/api/stream')
|
|
488
|
+
pending = _PendingRequest(client, pool)
|
|
489
|
+
pending.streaming = True
|
|
490
|
+
d._pending[1] = pending
|
|
491
|
+
# send_chunk: event/event_id/retry are all None
|
|
492
|
+
d._process_response(
|
|
493
|
+
(MSG_SSE_EVENT, 1, b'raw data', None, None, None))
|
|
494
|
+
self.assertEqual(len(client._chunks), 1)
|
|
495
|
+
self.assertEqual(client._chunks[0], b'raw data')
|
|
496
|
+
|
|
497
|
+
def test_sse_close(self):
|
|
498
|
+
d, pool = self._make_dispatcher()
|
|
499
|
+
client = MockClient('GET', '/api/events')
|
|
500
|
+
pending = _PendingRequest(client, pool)
|
|
501
|
+
pending.streaming = True
|
|
502
|
+
d._pending[1] = pending
|
|
503
|
+
d._process_response((MSG_SSE_CLOSE, 1))
|
|
504
|
+
self.assertTrue(client.stream_ended)
|
|
505
|
+
# removed from pending
|
|
506
|
+
self.assertNotIn(1, d._pending)
|
|
507
|
+
|
|
508
|
+
def test_sse_client_disconnect(self):
|
|
509
|
+
d, pool = self._make_dispatcher()
|
|
510
|
+
pool.start(d._response_queue)
|
|
511
|
+
client = MockClient('GET', '/api/events')
|
|
512
|
+
client._connected = False # simulate disconnected client
|
|
513
|
+
pending = _PendingRequest(client, pool)
|
|
514
|
+
pending.streaming = True
|
|
515
|
+
pending.worker_id = 0
|
|
516
|
+
d._pending[1] = pending
|
|
517
|
+
d._process_response(
|
|
518
|
+
(MSG_SSE_EVENT, 1, {'data': 'test'}, 'ping', None, None))
|
|
519
|
+
# removed from pending
|
|
520
|
+
self.assertNotIn(1, d._pending)
|
|
521
|
+
# CTL_DISCONNECT sent to worker's control queue
|
|
522
|
+
msg = pool._control_queues[0].get(timeout=1)
|
|
523
|
+
self.assertEqual(msg, (CTL_DISCONNECT, 1))
|
|
524
|
+
pool.shutdown(timeout=2)
|
|
525
|
+
|
|
526
|
+
def test_streaming_excluded_from_timeout(self):
|
|
527
|
+
d, pool = self._make_dispatcher()
|
|
528
|
+
client = MockClient('GET', '/api/events')
|
|
529
|
+
pending = _PendingRequest(client, pool)
|
|
530
|
+
pending.streaming = True
|
|
531
|
+
pending.timestamp = 0 # very old
|
|
532
|
+
d._pending[1] = pending
|
|
533
|
+
d._expire_pending()
|
|
534
|
+
# streaming request should NOT be expired
|
|
535
|
+
self.assertIn(1, d._pending)
|
|
536
|
+
|
|
537
|
+
def test_non_streaming_expires(self):
|
|
538
|
+
d, pool = self._make_dispatcher()
|
|
539
|
+
client = MockClient('GET', '/api/test')
|
|
540
|
+
pending = _PendingRequest(client, pool)
|
|
541
|
+
pending.timestamp = 0 # very old
|
|
542
|
+
d._pending[1] = pending
|
|
543
|
+
d._expire_pending()
|
|
544
|
+
# non-streaming should be expired
|
|
545
|
+
self.assertNotIn(1, d._pending)
|
|
546
|
+
|
|
547
|
+
def test_sse_event_ignored_after_close(self):
|
|
548
|
+
d, pool = self._make_dispatcher()
|
|
549
|
+
# no pending request with id 99
|
|
550
|
+
d._process_response(
|
|
551
|
+
(MSG_SSE_EVENT, 99, {'data': 'test'}, None, None, None))
|
|
552
|
+
# should not raise, just ignore
|
|
553
|
+
|
|
554
|
+
|
|
416
555
|
if __name__ == '__main__':
|
|
417
556
|
unittest.main()
|