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.
Files changed (25) hide show
  1. {uhttp_workers-1.0.0/uhttp_workers.egg-info → uhttp_workers-1.2.0}/PKG-INFO +141 -1
  2. {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/README.md +140 -0
  3. uhttp_workers-1.2.0/examples/sse_workers.py +80 -0
  4. {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/tests/test_dispatcher.py +139 -0
  5. uhttp_workers-1.2.0/tests/test_worker.py +629 -0
  6. {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/uhttp/workers.py +233 -19
  7. {uhttp_workers-1.0.0 → uhttp_workers-1.2.0/uhttp_workers.egg-info}/PKG-INFO +141 -1
  8. {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/uhttp_workers.egg-info/SOURCES.txt +1 -0
  9. uhttp_workers-1.0.0/tests/test_worker.py +0 -230
  10. {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/.github/workflows/publish.yml +0 -0
  11. {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/.github/workflows/tests.yml +0 -0
  12. {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/.gitignore +0 -0
  13. {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/examples/simple_workers.py +0 -0
  14. {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/examples/static/index.html +0 -0
  15. {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/pyproject.toml +0 -0
  16. {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/setup.cfg +0 -0
  17. {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/tests/__init__.py +0 -0
  18. {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/tests/test_api_handler.py +0 -0
  19. {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/tests/test_decorators.py +0 -0
  20. {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/tests/test_pattern_matching.py +0 -0
  21. {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/tests/test_request_response.py +0 -0
  22. {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/tests/test_worker_pool.py +0 -0
  23. {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/uhttp_workers.egg-info/dependency_links.txt +0 -0
  24. {uhttp_workers-1.0.0 → uhttp_workers-1.2.0}/uhttp_workers.egg-info/requires.txt +0 -0
  25. {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.0.0
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()