uhttp-workers 1.0.0__tar.gz → 1.2.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.0.0/uhttp_workers.egg-info → uhttp_workers-1.2.0}/PKG-INFO +141 -1
- {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/README.md +140 -0
- uhttp_workers-1.2.0/examples/sse_workers.py +80 -0
- {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/tests/test_dispatcher.py +139 -0
- uhttp_workers-1.2.0/tests/test_worker.py +629 -0
- {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/uhttp/workers.py +233 -19
- {uhttp_workers-1.0.0 → uhttp_workers-1.2.0/uhttp_workers.egg-info}/PKG-INFO +141 -1
- {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/uhttp_workers.egg-info/SOURCES.txt +1 -0
- uhttp_workers-1.0.0/tests/test_worker.py +0 -230
- {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/.github/workflows/publish.yml +0 -0
- {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/.github/workflows/tests.yml +0 -0
- {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/.gitignore +0 -0
- {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/examples/simple_workers.py +0 -0
- {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/examples/static/index.html +0 -0
- {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/pyproject.toml +0 -0
- {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/setup.cfg +0 -0
- {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/tests/__init__.py +0 -0
- {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/tests/test_api_handler.py +0 -0
- {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/tests/test_decorators.py +0 -0
- {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/tests/test_pattern_matching.py +0 -0
- {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/tests/test_request_response.py +0 -0
- {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/tests/test_worker_pool.py +0 -0
- {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/uhttp_workers.egg-info/dependency_links.txt +0 -0
- {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/uhttp_workers.egg-info/requires.txt +0 -0
- {uhttp_workers-1.0.0 → uhttp_workers-1.2.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.2.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
|
|
@@ -269,6 +269,7 @@ def process(self, request):
|
|
|
269
269
|
# request.query — {'page': '1'} or None
|
|
270
270
|
# request.data — dict (JSON), bytes (binary), or None
|
|
271
271
|
# request.headers — dict
|
|
272
|
+
# request.cookies — dict (lazy-parsed from Cookie header)
|
|
272
273
|
# request.content_type — 'application/json'
|
|
273
274
|
|
|
274
275
|
# return data (status 200)
|
|
@@ -276,8 +277,101 @@ def process(self, request):
|
|
|
276
277
|
|
|
277
278
|
# return data with status
|
|
278
279
|
return {'error': 'not found'}, 404
|
|
280
|
+
|
|
281
|
+
# defer response — worker continues accepting requests
|
|
282
|
+
return _workers.DEFERRED
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Deferred Responses
|
|
286
|
+
|
|
287
|
+
Return `DEFERRED` to skip immediate response. The worker stays in the select loop, accepts new requests, and sends the response later via `request.respond()`:
|
|
288
|
+
|
|
289
|
+
```python
|
|
290
|
+
class MyWorker(_workers.Worker):
|
|
291
|
+
def setup(self):
|
|
292
|
+
self._jobs = {}
|
|
293
|
+
|
|
294
|
+
@_workers.api('/process', 'POST')
|
|
295
|
+
def process(self, request):
|
|
296
|
+
job_id = start_background_work(request.data)
|
|
297
|
+
self._jobs[job_id] = request
|
|
298
|
+
return _workers.DEFERRED
|
|
299
|
+
|
|
300
|
+
def on_work_done(self, job_id, result):
|
|
301
|
+
request = self._jobs.pop(job_id)
|
|
302
|
+
request.respond(data={'result': result})
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Note: deferred requests are still subject to dispatcher timeout — call `self.keep_alive()` periodically to prevent 504.
|
|
306
|
+
|
|
307
|
+
### Keep Alive
|
|
308
|
+
|
|
309
|
+
Call `self.keep_alive()` during long operations to reset both the request timeout and stuck worker detection:
|
|
310
|
+
|
|
311
|
+
```python
|
|
312
|
+
@_workers.api('/export', 'POST')
|
|
313
|
+
def export(self, request):
|
|
314
|
+
for chunk in generate_large_export():
|
|
315
|
+
self.keep_alive()
|
|
316
|
+
return {'status': 'done'}
|
|
279
317
|
```
|
|
280
318
|
|
|
319
|
+
### Server-Sent Events (SSE)
|
|
320
|
+
|
|
321
|
+
Stream events to clients using the same API as `uhttp.server.Client`:
|
|
322
|
+
|
|
323
|
+
```python
|
|
324
|
+
class MyWorker(_workers.Worker):
|
|
325
|
+
def setup(self):
|
|
326
|
+
self._subscribers = {}
|
|
327
|
+
|
|
328
|
+
@_workers.api('/events', 'GET')
|
|
329
|
+
def events(self, request):
|
|
330
|
+
request.response_stream() # sends headers, keeps connection open
|
|
331
|
+
self._subscribers[request.request_id] = request
|
|
332
|
+
return _workers.DEFERRED
|
|
333
|
+
|
|
334
|
+
def notify(self, data):
|
|
335
|
+
for req in self._subscribers.values():
|
|
336
|
+
req.send_event(data=data, event='update')
|
|
337
|
+
|
|
338
|
+
def on_disconnect(self, request_id):
|
|
339
|
+
self._subscribers.pop(request_id, None)
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Available streaming methods on `Request`:
|
|
343
|
+
|
|
344
|
+
| Method | Description |
|
|
345
|
+
|--------|-------------|
|
|
346
|
+
| `response_stream(content_type, headers, cookies)` | Start streaming (default: `text/event-stream`) |
|
|
347
|
+
| `send_event(data, event, event_id, retry)` | Send SSE event |
|
|
348
|
+
| `send_chunk(data)` | Send raw data chunk |
|
|
349
|
+
| `response_stream_end()` | End stream and close connection |
|
|
350
|
+
|
|
351
|
+
Streaming requests are excluded from dispatcher timeout expiration. When the client disconnects, the dispatcher notifies the worker via `on_disconnect(request_id)`.
|
|
352
|
+
|
|
353
|
+
### Flow Control
|
|
354
|
+
|
|
355
|
+
Workers can stop accepting new requests when busy. Requests stay in the shared pool queue for other workers to pick up:
|
|
356
|
+
|
|
357
|
+
```python
|
|
358
|
+
class MyWorker(_workers.Worker):
|
|
359
|
+
@_workers.api('/events', 'GET')
|
|
360
|
+
def subscribe(self, request):
|
|
361
|
+
request.response_stream()
|
|
362
|
+
self._subscribers[request.request_id] = request
|
|
363
|
+
if len(self._subscribers) >= 100:
|
|
364
|
+
self.pause()
|
|
365
|
+
return _workers.DEFERRED
|
|
366
|
+
|
|
367
|
+
def on_disconnect(self, request_id):
|
|
368
|
+
self._subscribers.pop(request_id, None)
|
|
369
|
+
if not self._accepting and len(self._subscribers) < 100:
|
|
370
|
+
self.resume()
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
`pause()` excludes the request queue from `select()` — the worker continues processing control messages, custom fd events, and `on_idle()`. `resume()` re-enables request acceptance.
|
|
374
|
+
|
|
281
375
|
## URL Patterns
|
|
282
376
|
|
|
283
377
|
Dispatcher uses prefix matching to route requests to pools:
|
|
@@ -315,6 +409,43 @@ class MyDispatcher(_workers.Dispatcher):
|
|
|
315
409
|
|
|
316
410
|
`do_check()` is only called for requests going to worker pools — static files and sync handlers are not affected.
|
|
317
411
|
|
|
412
|
+
### Worker-Level Validation
|
|
413
|
+
|
|
414
|
+
Override `do_check()` on the worker — runs before routing to handler:
|
|
415
|
+
|
|
416
|
+
```python
|
|
417
|
+
class MyWorker(_workers.Worker):
|
|
418
|
+
def do_check(self, request):
|
|
419
|
+
token = request.cookies.get('session')
|
|
420
|
+
if not token:
|
|
421
|
+
return {'error': 'unauthorized'}, 401
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
Return `(data, status)` tuple to reject, or `None` to continue. You can also raise `RejectRequest`:
|
|
425
|
+
|
|
426
|
+
```python
|
|
427
|
+
def do_check(self, request):
|
|
428
|
+
if not self.validate_token(request.cookies.get('session')):
|
|
429
|
+
raise _workers.RejectRequest(
|
|
430
|
+
data={'error': 'forbidden'}, status=403)
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
`RejectRequest` accepts optional `data` (default: `{'error': 'Rejected'}`) and `status` (default: `403`).
|
|
434
|
+
|
|
435
|
+
### Error Handling
|
|
436
|
+
|
|
437
|
+
Override `on_request_error()` on the worker to customize error handling when a request handler raises an exception:
|
|
438
|
+
|
|
439
|
+
```python
|
|
440
|
+
class MyWorker(_workers.Worker):
|
|
441
|
+
def on_request_error(self, request, err):
|
|
442
|
+
if isinstance(err, DatabaseError):
|
|
443
|
+
self.db.reconnect()
|
|
444
|
+
return super().on_request_error(request, err)
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
Default behavior logs the error with traceback and returns 500.
|
|
448
|
+
|
|
318
449
|
## Post-Response Hook
|
|
319
450
|
|
|
320
451
|
Override `on_response()` on the dispatcher to post-process after a response is sent to the client — e.g., forward data to another worker pool:
|
|
@@ -394,6 +525,15 @@ Log messages are sent to the dispatcher via the shared response queue and printe
|
|
|
394
525
|
|
|
395
526
|
**Log levels:** `LOG_DEBUG` (10), `LOG_INFO` (20), `LOG_WARNING` (30), `LOG_ERROR` (40), `LOG_CRITICAL` (50)
|
|
396
527
|
|
|
528
|
+
Check current level with `is_*` properties to skip expensive formatting:
|
|
529
|
+
|
|
530
|
+
```python
|
|
531
|
+
if self.log.is_debug:
|
|
532
|
+
self.log.debug("Details: %s", expensive_computation())
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
Available: `is_debug`, `is_info`, `is_warning`, `is_error`.
|
|
536
|
+
|
|
397
537
|
Set minimum level per pool:
|
|
398
538
|
|
|
399
539
|
```python
|
|
@@ -255,6 +255,7 @@ def process(self, request):
|
|
|
255
255
|
# request.query — {'page': '1'} or None
|
|
256
256
|
# request.data — dict (JSON), bytes (binary), or None
|
|
257
257
|
# request.headers — dict
|
|
258
|
+
# request.cookies — dict (lazy-parsed from Cookie header)
|
|
258
259
|
# request.content_type — 'application/json'
|
|
259
260
|
|
|
260
261
|
# return data (status 200)
|
|
@@ -262,8 +263,101 @@ def process(self, request):
|
|
|
262
263
|
|
|
263
264
|
# return data with status
|
|
264
265
|
return {'error': 'not found'}, 404
|
|
266
|
+
|
|
267
|
+
# defer response — worker continues accepting requests
|
|
268
|
+
return _workers.DEFERRED
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### Deferred Responses
|
|
272
|
+
|
|
273
|
+
Return `DEFERRED` to skip immediate response. The worker stays in the select loop, accepts new requests, and sends the response later via `request.respond()`:
|
|
274
|
+
|
|
275
|
+
```python
|
|
276
|
+
class MyWorker(_workers.Worker):
|
|
277
|
+
def setup(self):
|
|
278
|
+
self._jobs = {}
|
|
279
|
+
|
|
280
|
+
@_workers.api('/process', 'POST')
|
|
281
|
+
def process(self, request):
|
|
282
|
+
job_id = start_background_work(request.data)
|
|
283
|
+
self._jobs[job_id] = request
|
|
284
|
+
return _workers.DEFERRED
|
|
285
|
+
|
|
286
|
+
def on_work_done(self, job_id, result):
|
|
287
|
+
request = self._jobs.pop(job_id)
|
|
288
|
+
request.respond(data={'result': result})
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Note: deferred requests are still subject to dispatcher timeout — call `self.keep_alive()` periodically to prevent 504.
|
|
292
|
+
|
|
293
|
+
### Keep Alive
|
|
294
|
+
|
|
295
|
+
Call `self.keep_alive()` during long operations to reset both the request timeout and stuck worker detection:
|
|
296
|
+
|
|
297
|
+
```python
|
|
298
|
+
@_workers.api('/export', 'POST')
|
|
299
|
+
def export(self, request):
|
|
300
|
+
for chunk in generate_large_export():
|
|
301
|
+
self.keep_alive()
|
|
302
|
+
return {'status': 'done'}
|
|
265
303
|
```
|
|
266
304
|
|
|
305
|
+
### Server-Sent Events (SSE)
|
|
306
|
+
|
|
307
|
+
Stream events to clients using the same API as `uhttp.server.Client`:
|
|
308
|
+
|
|
309
|
+
```python
|
|
310
|
+
class MyWorker(_workers.Worker):
|
|
311
|
+
def setup(self):
|
|
312
|
+
self._subscribers = {}
|
|
313
|
+
|
|
314
|
+
@_workers.api('/events', 'GET')
|
|
315
|
+
def events(self, request):
|
|
316
|
+
request.response_stream() # sends headers, keeps connection open
|
|
317
|
+
self._subscribers[request.request_id] = request
|
|
318
|
+
return _workers.DEFERRED
|
|
319
|
+
|
|
320
|
+
def notify(self, data):
|
|
321
|
+
for req in self._subscribers.values():
|
|
322
|
+
req.send_event(data=data, event='update')
|
|
323
|
+
|
|
324
|
+
def on_disconnect(self, request_id):
|
|
325
|
+
self._subscribers.pop(request_id, None)
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Available streaming methods on `Request`:
|
|
329
|
+
|
|
330
|
+
| Method | Description |
|
|
331
|
+
|--------|-------------|
|
|
332
|
+
| `response_stream(content_type, headers, cookies)` | Start streaming (default: `text/event-stream`) |
|
|
333
|
+
| `send_event(data, event, event_id, retry)` | Send SSE event |
|
|
334
|
+
| `send_chunk(data)` | Send raw data chunk |
|
|
335
|
+
| `response_stream_end()` | End stream and close connection |
|
|
336
|
+
|
|
337
|
+
Streaming requests are excluded from dispatcher timeout expiration. When the client disconnects, the dispatcher notifies the worker via `on_disconnect(request_id)`.
|
|
338
|
+
|
|
339
|
+
### Flow Control
|
|
340
|
+
|
|
341
|
+
Workers can stop accepting new requests when busy. Requests stay in the shared pool queue for other workers to pick up:
|
|
342
|
+
|
|
343
|
+
```python
|
|
344
|
+
class MyWorker(_workers.Worker):
|
|
345
|
+
@_workers.api('/events', 'GET')
|
|
346
|
+
def subscribe(self, request):
|
|
347
|
+
request.response_stream()
|
|
348
|
+
self._subscribers[request.request_id] = request
|
|
349
|
+
if len(self._subscribers) >= 100:
|
|
350
|
+
self.pause()
|
|
351
|
+
return _workers.DEFERRED
|
|
352
|
+
|
|
353
|
+
def on_disconnect(self, request_id):
|
|
354
|
+
self._subscribers.pop(request_id, None)
|
|
355
|
+
if not self._accepting and len(self._subscribers) < 100:
|
|
356
|
+
self.resume()
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
`pause()` excludes the request queue from `select()` — the worker continues processing control messages, custom fd events, and `on_idle()`. `resume()` re-enables request acceptance.
|
|
360
|
+
|
|
267
361
|
## URL Patterns
|
|
268
362
|
|
|
269
363
|
Dispatcher uses prefix matching to route requests to pools:
|
|
@@ -301,6 +395,43 @@ class MyDispatcher(_workers.Dispatcher):
|
|
|
301
395
|
|
|
302
396
|
`do_check()` is only called for requests going to worker pools — static files and sync handlers are not affected.
|
|
303
397
|
|
|
398
|
+
### Worker-Level Validation
|
|
399
|
+
|
|
400
|
+
Override `do_check()` on the worker — runs before routing to handler:
|
|
401
|
+
|
|
402
|
+
```python
|
|
403
|
+
class MyWorker(_workers.Worker):
|
|
404
|
+
def do_check(self, request):
|
|
405
|
+
token = request.cookies.get('session')
|
|
406
|
+
if not token:
|
|
407
|
+
return {'error': 'unauthorized'}, 401
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
Return `(data, status)` tuple to reject, or `None` to continue. You can also raise `RejectRequest`:
|
|
411
|
+
|
|
412
|
+
```python
|
|
413
|
+
def do_check(self, request):
|
|
414
|
+
if not self.validate_token(request.cookies.get('session')):
|
|
415
|
+
raise _workers.RejectRequest(
|
|
416
|
+
data={'error': 'forbidden'}, status=403)
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
`RejectRequest` accepts optional `data` (default: `{'error': 'Rejected'}`) and `status` (default: `403`).
|
|
420
|
+
|
|
421
|
+
### Error Handling
|
|
422
|
+
|
|
423
|
+
Override `on_request_error()` on the worker to customize error handling when a request handler raises an exception:
|
|
424
|
+
|
|
425
|
+
```python
|
|
426
|
+
class MyWorker(_workers.Worker):
|
|
427
|
+
def on_request_error(self, request, err):
|
|
428
|
+
if isinstance(err, DatabaseError):
|
|
429
|
+
self.db.reconnect()
|
|
430
|
+
return super().on_request_error(request, err)
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
Default behavior logs the error with traceback and returns 500.
|
|
434
|
+
|
|
304
435
|
## Post-Response Hook
|
|
305
436
|
|
|
306
437
|
Override `on_response()` on the dispatcher to post-process after a response is sent to the client — e.g., forward data to another worker pool:
|
|
@@ -380,6 +511,15 @@ Log messages are sent to the dispatcher via the shared response queue and printe
|
|
|
380
511
|
|
|
381
512
|
**Log levels:** `LOG_DEBUG` (10), `LOG_INFO` (20), `LOG_WARNING` (30), `LOG_ERROR` (40), `LOG_CRITICAL` (50)
|
|
382
513
|
|
|
514
|
+
Check current level with `is_*` properties to skip expensive formatting:
|
|
515
|
+
|
|
516
|
+
```python
|
|
517
|
+
if self.log.is_debug:
|
|
518
|
+
self.log.debug("Details: %s", expensive_computation())
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
Available: `is_debug`, `is_info`, `is_warning`, `is_error`.
|
|
522
|
+
|
|
383
523
|
Set minimum level per pool:
|
|
384
524
|
|
|
385
525
|
```python
|
|
@@ -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()
|