uhttp-workers 1.1.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.1.0/uhttp_workers.egg-info → uhttp_workers-1.2.0}/PKG-INFO +94 -1
- {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/README.md +93 -0
- uhttp_workers-1.2.0/examples/sse_workers.py +80 -0
- {uhttp_workers-1.1.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.1.0 → uhttp_workers-1.2.0}/uhttp/workers.py +156 -10
- {uhttp_workers-1.1.0 → uhttp_workers-1.2.0/uhttp_workers.egg-info}/PKG-INFO +94 -1
- {uhttp_workers-1.1.0 → uhttp_workers-1.2.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.2.0}/.github/workflows/publish.yml +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/.github/workflows/tests.yml +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/.gitignore +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/examples/simple_workers.py +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/examples/static/index.html +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/pyproject.toml +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/setup.cfg +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/tests/__init__.py +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/tests/test_api_handler.py +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/tests/test_decorators.py +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/tests/test_pattern_matching.py +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/tests/test_request_response.py +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/tests/test_worker_pool.py +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/uhttp_workers.egg-info/dependency_links.txt +0 -0
- {uhttp_workers-1.1.0 → uhttp_workers-1.2.0}/uhttp_workers.egg-info/requires.txt +0 -0
- {uhttp_workers-1.1.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
|
|
@@ -277,8 +277,101 @@ def process(self, request):
|
|
|
277
277
|
|
|
278
278
|
# return data with status
|
|
279
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'}
|
|
317
|
+
```
|
|
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()
|
|
280
371
|
```
|
|
281
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
|
+
|
|
282
375
|
## URL Patterns
|
|
283
376
|
|
|
284
377
|
Dispatcher uses prefix matching to route requests to pools:
|
|
@@ -263,8 +263,101 @@ def process(self, request):
|
|
|
263
263
|
|
|
264
264
|
# return data with status
|
|
265
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'}
|
|
303
|
+
```
|
|
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()
|
|
266
357
|
```
|
|
267
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
|
+
|
|
268
361
|
## URL Patterns
|
|
269
362
|
|
|
270
363
|
Dispatcher uses prefix matching to route requests to pools:
|
|
@@ -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()
|