uhttp-workers 1.0.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.
@@ -0,0 +1,35 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ environment: pypi
11
+ permissions:
12
+ id-token: write
13
+
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - name: Set up Python
18
+ uses: actions/setup-python@v5
19
+ with:
20
+ python-version: "3.14"
21
+
22
+ - name: Install build dependencies
23
+ run: pip install build
24
+
25
+ - name: Install package
26
+ run: pip install .
27
+
28
+ - name: Run unit tests
29
+ run: python -m unittest discover -v tests/
30
+
31
+ - name: Build package
32
+ run: python -m build
33
+
34
+ - name: Publish to PyPI
35
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,28 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.10", "3.14"]
15
+
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - name: Set up Python ${{ matrix.python-version }}
20
+ uses: actions/setup-python@v5
21
+ with:
22
+ python-version: ${{ matrix.python-version }}
23
+
24
+ - name: Install package
25
+ run: pip install .
26
+
27
+ - name: Run unit tests
28
+ run: python -m unittest discover -v tests/
@@ -0,0 +1,8 @@
1
+ *.egg-info
2
+ __pycache__
3
+ *.pyc
4
+ .*
5
+ !.gitignore
6
+ !.github
7
+ *.md
8
+ !README*.md
@@ -0,0 +1,453 @@
1
+ Metadata-Version: 2.4
2
+ Name: uhttp-workers
3
+ Version: 1.0.0
4
+ Summary: Multi-process worker dispatcher built on uhttp-server
5
+ Author-email: Pavel Revak <pavelrevak@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/pavelrevak/uhttp
8
+ Project-URL: Repository, https://github.com/pavelrevak/uhttp
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: POSIX
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: uhttp-server
14
+
15
+ # uhttp-workers
16
+
17
+ Multi-process worker dispatcher built on [uhttp-server](https://github.com/cortexm/uhttp-server).
18
+
19
+ Single dispatcher process handles all connections, N worker processes handle business logic in parallel. Communication via `multiprocessing.Queue` with efficient `select()` integration (POSIX only).
20
+
21
+ ## Architecture
22
+
23
+ ```
24
+ ┌─────────────┐
25
+ │ Worker 0 │
26
+ │ Worker 1 │
27
+ request_queue A │ Worker 2 │
28
+ ┌────────────────► │ Worker 3 │ ComputeWorker
29
+ │ └──────┬──────┘
30
+ │ │
31
+ ┌─────────────┤ │
32
+ │ │ ┌──────┴──────┐
33
+ │ Dispatcher │ request_queue B │ Worker 0 │
34
+ │ (main) ├────────────────► │ Worker 1 │ StorageWorker
35
+ │ │ └──────┬──────┘
36
+ │ - static │ │
37
+ │ - sync │ response_queue │
38
+ │ - auth │◄───────────────────────────┘
39
+ │ │ (shared)
40
+ └─────────────┘
41
+ ```
42
+
43
+ **Key design decisions:**
44
+
45
+ - Sockets never leave the dispatcher — only serializable data goes through queues
46
+ - Each worker pool has its own request queue, all pools share one response queue
47
+ - Workers send heartbeats via response queue — dispatcher detects stuck/dead workers
48
+ - Each worker has a private control queue for config updates and stop signals
49
+
50
+ ## Installation
51
+
52
+ ```bash
53
+ pip install uhttp-workers
54
+ ```
55
+
56
+ ## Quick Start
57
+
58
+ ```python
59
+ import uhttp.workers as _workers
60
+
61
+
62
+ class ItemWorker(_workers.Worker):
63
+ def setup(self):
64
+ self.items = {}
65
+ self.next_id = 1
66
+
67
+ @_workers.api('/api/items', 'GET')
68
+ def list_items(self, request):
69
+ return {'items': list(self.items.values())}
70
+
71
+ @_workers.api('/api/item/{id:int}', 'GET')
72
+ def get_item(self, request):
73
+ item = self.items.get(request.path_params['id'])
74
+ if not item:
75
+ return {'error': 'Not found'}, 404
76
+ return item
77
+
78
+
79
+ class MyDispatcher(_workers.Dispatcher):
80
+ def do_check(self, client):
81
+ api_key = client.headers.get('x-api-key')
82
+ if api_key not in VALID_KEYS:
83
+ client.respond({'error': 'unauthorized'}, status=401)
84
+ raise _workers.RejectRequest()
85
+
86
+ @_workers.sync('/health')
87
+ def health(self, client, path_params):
88
+ client.respond({'status': 'ok'})
89
+
90
+
91
+ def main():
92
+ dispatcher = MyDispatcher(
93
+ port=8080,
94
+ pools=[
95
+ _workers.WorkerPool(
96
+ ItemWorker, num_workers=4,
97
+ routes=['/api/**'],
98
+ timeout=30,
99
+ ),
100
+ ],
101
+ )
102
+ dispatcher.run()
103
+
104
+
105
+ if __name__ == '__main__':
106
+ main()
107
+ ```
108
+
109
+ ## Multiple Worker Pools
110
+
111
+ Route different endpoints to different worker pools with independent scaling:
112
+
113
+ ```python
114
+ dispatcher = MyDispatcher(
115
+ port=8080,
116
+ pools=[
117
+ _workers.WorkerPool(
118
+ ComputeWorker, num_workers=4,
119
+ routes=['/api/compute/**'],
120
+ timeout=60,
121
+ ),
122
+ _workers.WorkerPool(
123
+ StorageWorker, num_workers=2,
124
+ routes=['/api/items/**', '/api/item/**'],
125
+ timeout=10,
126
+ ),
127
+ _workers.WorkerPool(
128
+ GeneralWorker, num_workers=1,
129
+ ), # no routes = default/fallback pool
130
+ ],
131
+ )
132
+ ```
133
+
134
+ Request routing order:
135
+ 1. **Static files** — served directly by dispatcher
136
+ 2. **Sync handlers** — run in dispatcher process
137
+ 3. **Worker pools** — first pool with matching route prefix, or fallback pool
138
+ 4. **404** — no match
139
+
140
+ ## API Handlers
141
+
142
+ Group related endpoints under a common URL prefix using `ApiHandler`:
143
+
144
+ ```python
145
+ import uhttp.workers as _workers
146
+
147
+ class UserHandler(_workers.ApiHandler):
148
+ PATTERN = '/api/user'
149
+
150
+ @_workers.api('', 'GET')
151
+ def list_users(self, request):
152
+ return {'users': self.worker.db.list_users()}
153
+
154
+ @_workers.api('/{id:int}', 'GET')
155
+ def get_user(self, request):
156
+ return self.worker.db.get_user(request.path_params['id'])
157
+
158
+ @_workers.api('/{id:int}', 'DELETE')
159
+ def delete_user(self, request):
160
+ self.worker.db.delete_user(request.path_params['id'])
161
+ return {'deleted': request.path_params['id']}
162
+
163
+ class OrderHandler(_workers.ApiHandler):
164
+ PATTERN = '/api/order'
165
+
166
+ @_workers.api('', 'GET')
167
+ def list_orders(self, request):
168
+ return {'orders': []}
169
+
170
+ @_workers.api('/{id:int}', 'GET')
171
+ def get_order(self, request):
172
+ return {'id': request.path_params['id']}
173
+
174
+ class MyWorker(_workers.Worker):
175
+ HANDLERS = [UserHandler, OrderHandler]
176
+
177
+ def setup(self):
178
+ self.db = Database(self.kwargs['db_login'])
179
+ ```
180
+
181
+ `@api` patterns on handlers are automatically prefixed with the handler's `PATTERN`. Handlers access the worker instance via `self.worker`.
182
+
183
+ You can also define `@api` methods directly on the worker class — useful for simple workers that don't need handler grouping.
184
+
185
+ Handlers support inheritance — a subclass inherits all routes from its parent, using the subclass `PATTERN` as prefix.
186
+
187
+ ## Static Files
188
+
189
+ ```python
190
+ dispatcher = Dispatcher(
191
+ port=8080,
192
+ static_routes={
193
+ '/static/': './static/',
194
+ '/images/': '/var/data/images/',
195
+ },
196
+ )
197
+ ```
198
+
199
+ Static files are served directly by the dispatcher process with path traversal protection. Directory requests automatically serve `index.html` if present.
200
+
201
+ ## Sync Handlers
202
+
203
+ Lightweight handlers that run directly in the dispatcher process — no queue overhead. Define them as methods on the dispatcher class with the `@sync` decorator:
204
+
205
+ ```python
206
+ import uhttp.workers as _workers
207
+
208
+ class MyDispatcher(_workers.Dispatcher):
209
+ @_workers.sync('/health')
210
+ def health(self, client, path_params):
211
+ client.respond({
212
+ 'status': 'ok',
213
+ 'pools': [pool.status() for pool in self._pools],
214
+ })
215
+
216
+ @_workers.sync('/version')
217
+ def version(self, client, path_params):
218
+ client.respond({'version': '1.0.0'})
219
+ ```
220
+
221
+ Use sync handlers for fast, non-blocking responses only — long operations block the entire dispatcher.
222
+
223
+ ## Worker Lifecycle
224
+
225
+ ### Setup
226
+
227
+ `setup()` is called once when a worker process starts. Use it to initialize resources that cannot be shared across processes (database connections, models, etc.):
228
+
229
+ ```python
230
+ class MyWorker(_workers.Worker):
231
+ def setup(self):
232
+ self.db = Database(self.kwargs['db_login'])
233
+ ```
234
+
235
+ Extra keyword arguments from `WorkerPool(...)` are available as `self.kwargs`.
236
+
237
+ ### Configuration Updates
238
+
239
+ Dispatcher can send configuration to workers at runtime via per-worker control queues:
240
+
241
+ ```python
242
+ # dispatcher side
243
+ for pool in dispatcher._pools:
244
+ pool.send_config({'rate_limit': 100})
245
+
246
+ # worker side
247
+ class MyWorker(_workers.Worker):
248
+ def on_config(self, config):
249
+ self.rate_limit = config['rate_limit']
250
+ ```
251
+
252
+ ### Health Monitoring
253
+
254
+ Workers send heartbeats automatically via the shared response queue. When a worker takes a request, it reports which `request_id` it is working on. If a worker stops responding:
255
+
256
+ - **Dead worker** (segfault, crash) — detected via `is_alive()`, restarted immediately
257
+ - **Stuck worker** (infinite loop in C code) — detected via heartbeat timeout, killed and restarted
258
+ - **Too many restarts** — pool marked as degraded, returns 503
259
+
260
+ ### Request Handling
261
+
262
+ ```python
263
+ @_workers.api('/process/{id:int}', 'POST')
264
+ def process(self, request):
265
+ # request.request_id — internal ID for dispatcher pairing
266
+ # request.method — 'POST'
267
+ # request.path — '/process/42'
268
+ # request.path_params — {'id': 42}
269
+ # request.query — {'page': '1'} or None
270
+ # request.data — dict (JSON), bytes (binary), or None
271
+ # request.headers — dict
272
+ # request.content_type — 'application/json'
273
+
274
+ # return data (status 200)
275
+ return {'result': 'ok'}
276
+
277
+ # return data with status
278
+ return {'error': 'not found'}, 404
279
+ ```
280
+
281
+ ## URL Patterns
282
+
283
+ Dispatcher uses prefix matching to route requests to pools:
284
+
285
+ ```python
286
+ _workers.WorkerPool(MyWorker, routes=['/api/users/**']) # matches /api/users/anything
287
+ _workers.WorkerPool(MyWorker, routes=['/api/status']) # exact match only
288
+ _workers.WorkerPool(MyWorker) # fallback — catches everything else
289
+ ```
290
+
291
+ Workers use full pattern matching with type conversion:
292
+
293
+ ```python
294
+ @_workers.api('/user/{id:int}', 'GET') # id converted to int
295
+ @_workers.api('/price/{amount:float}', 'GET') # amount converted to float
296
+ @_workers.api('/tag/{name}') # name as str, all methods
297
+ ```
298
+
299
+ ## Authentication
300
+
301
+ Override `do_check()` on the dispatcher — runs before any request is queued:
302
+
303
+ ```python
304
+ class MyDispatcher(_workers.Dispatcher):
305
+ def __init__(self, valid_keys, **kwargs):
306
+ super().__init__(**kwargs)
307
+ self.valid_keys = valid_keys
308
+
309
+ def do_check(self, client):
310
+ api_key = client.headers.get('x-api-key')
311
+ if api_key not in self.valid_keys:
312
+ client.respond({'error': 'unauthorized'}, status=401)
313
+ raise _workers.RejectRequest()
314
+ ```
315
+
316
+ `do_check()` is only called for requests going to worker pools — static files and sync handlers are not affected.
317
+
318
+ ## Post-Response Hook
319
+
320
+ 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:
321
+
322
+ ```python
323
+ class MyDispatcher(_workers.Dispatcher):
324
+ def on_response(self, response, pending):
325
+ if response.status == 200 and pending.pool.name == 'LprWorker':
326
+ storage_pool = self._find_pool('/internal/storage')
327
+ storage_pool.request_queue.put(_workers.Request(
328
+ request_id=-1,
329
+ method='POST',
330
+ path='/internal/storage/save',
331
+ data={
332
+ 'image': pending.client.data,
333
+ 'result': response.data,
334
+ }))
335
+ ```
336
+
337
+ `pending` is a `_PendingRequest` with `client` (original connection) and `pool` (source pool).
338
+ Requests with `request_id=-1` are ignored by the dispatcher when the worker responds.
339
+
340
+ ## Dispatcher Idle Hook
341
+
342
+ Override `on_idle()` on the dispatcher for periodic background tasks — called on each `select()` timeout (every `SELECT_TIMEOUT` seconds, default 1s):
343
+
344
+ ```python
345
+ class MyDispatcher(_workers.Dispatcher):
346
+ def on_idle(self):
347
+ # periodic cleanup, monitoring, etc.
348
+ pass
349
+ ```
350
+
351
+ Workers have their own `on_idle()` hook, called on each `heartbeat_interval` timeout.
352
+
353
+ ## Graceful Shutdown
354
+
355
+ On `SIGTERM` or `SIGINT`:
356
+
357
+ 1. Stop accepting new connections
358
+ 2. Wait for pending responses (up to `shutdown_timeout`)
359
+ 3. Respond 503 to remaining pending requests
360
+ 4. Send stop signal to all workers via control queues
361
+ 5. Wait for workers to finish, kill after timeout
362
+
363
+ ## Monitoring
364
+
365
+ ```python
366
+ class MyDispatcher(_workers.Dispatcher):
367
+ @_workers.sync('/monitor')
368
+ def monitor(self, client, path_params):
369
+ client.respond({
370
+ 'pools': [pool.status() for pool in self._pools],
371
+ 'pending': len(self._pending),
372
+ })
373
+ ```
374
+
375
+ Pool status includes per-worker info: alive, last seen, current request ID, queue size.
376
+
377
+ ## Logging
378
+
379
+ Workers have a built-in `Logger` accessible via `self.log`:
380
+
381
+ ```python
382
+ class MyWorker(_workers.Worker):
383
+ @_workers.api('/item/{id:int}', 'GET')
384
+ def get_item(self, request):
385
+ item_id = request.path_params['id']
386
+ # %-style (Python logging compatible)
387
+ self.log.info("Getting item %d", item_id)
388
+ # {}-style (kwargs)
389
+ self.log.info("Getting item {id}", id=item_id)
390
+ return {'id': item_id}
391
+ ```
392
+
393
+ Log messages are sent to the dispatcher via the shared response queue and printed in the dispatcher process — no interleaved output from multiple processes.
394
+
395
+ **Log levels:** `LOG_DEBUG` (10), `LOG_INFO` (20), `LOG_WARNING` (30), `LOG_ERROR` (40), `LOG_CRITICAL` (50)
396
+
397
+ Set minimum level per pool:
398
+
399
+ ```python
400
+ _workers.WorkerPool(
401
+ MyWorker, num_workers=4,
402
+ log_level=_workers.LOG_INFO, # default: LOG_WARNING
403
+ )
404
+ ```
405
+
406
+ **Output format** — auto-detected at dispatcher init:
407
+ - **Terminal:** ANSI colors — bold red (critical), red (error), yellow (warning), dim (debug)
408
+ - **systemd:** Syslog priority prefixes (`<3>`, `<4>`, etc.) — journalctl colors by priority
409
+
410
+ Override `Dispatcher.on_log(name, level, message)` to customize output or forward to a logging framework.
411
+
412
+ Errors are logged automatically:
413
+ - Handler exceptions → ERROR with full traceback (returns 500 to client)
414
+ - `setup()` crash → CRITICAL with traceback (worker exits and restarts)
415
+ - Worker restart → ERROR with reason (died/stuck)
416
+ - Request timeout → WARNING with request ID and timeout value
417
+
418
+ ## Configuration
419
+
420
+ ### Dispatcher
421
+
422
+ | Parameter | Default | Description |
423
+ |-----------|---------|-------------|
424
+ | `port` | 8080 | Listen port |
425
+ | `address` | `'0.0.0.0'` | Listen address |
426
+ | `pools` | `[]` | List of `WorkerPool` instances |
427
+ | `static_routes` | `{}` | URL prefix → filesystem path |
428
+ | `shutdown_timeout` | 10 | Seconds to wait on shutdown |
429
+ | `max_pending` | 1000 | Max pending requests (503 when exceeded) |
430
+ | `ssl_context` | `None` | `ssl.SSLContext` for HTTPS |
431
+
432
+ ### WorkerPool
433
+
434
+ | Parameter | Default | Description |
435
+ |-----------|---------|-------------|
436
+ | `worker_class` | — | `Worker` subclass |
437
+ | `num_workers` | 1 | Number of worker processes |
438
+ | `routes` | `None` | Prefix patterns (`None` = fallback pool) |
439
+ | `timeout` | 30 | Request timeout in seconds (504) |
440
+ | `stuck_timeout` | 60 | Heartbeat timeout before kill |
441
+ | `heartbeat_interval` | 1 | Seconds between worker heartbeats |
442
+ | `log_level` | `LOG_WARNING` | Minimum log level for worker loggers |
443
+ | `max_restarts` | 10 | Max restarts per `restart_window` |
444
+ | `restart_window` | 300 | Time window for restart counting (seconds) |
445
+ | `queue_warning` | 100 | Log warning when queue size exceeds this (0 = disable) |
446
+
447
+ Extra `**kwargs` on `WorkerPool` are passed to worker constructor (accessible as `self.kwargs`).
448
+
449
+ ## Requirements
450
+
451
+ - Python >= 3.10
452
+ - POSIX system (Linux, macOS) — uses `select()` with `queue._reader`
453
+ - [uhttp-server](https://github.com/cortexm/uhttp-server)