socketflow 0.1.4__tar.gz → 0.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 (35) hide show
  1. socketflow-0.2.0/PKG-INFO +998 -0
  2. socketflow-0.2.0/README.md +944 -0
  3. {socketflow-0.1.4 → socketflow-0.2.0}/setup.py +17 -9
  4. {socketflow-0.1.4 → socketflow-0.2.0}/socketflow/__init__.py +29 -1
  5. socketflow-0.2.0/socketflow/client_side/client.py +1015 -0
  6. {socketflow-0.1.4 → socketflow-0.2.0}/socketflow/global_side/blueprint.py +46 -2
  7. socketflow-0.2.0/socketflow/global_side/compression.py +276 -0
  8. socketflow-0.2.0/socketflow/global_side/dispatcher.py +418 -0
  9. {socketflow-0.1.4 → socketflow-0.2.0}/socketflow/global_side/event.py +14 -0
  10. socketflow-0.2.0/socketflow/global_side/event_loop.py +324 -0
  11. {socketflow-0.1.4 → socketflow-0.2.0}/socketflow/global_side/exceptions.py +42 -0
  12. socketflow-0.2.0/socketflow/global_side/logs.py +339 -0
  13. {socketflow-0.1.4 → socketflow-0.2.0}/socketflow/global_side/message_handler.py +9 -4
  14. {socketflow-0.1.4 → socketflow-0.2.0}/socketflow/global_side/message_manager.py +2 -2
  15. socketflow-0.2.0/socketflow/global_side/metrics.py +230 -0
  16. socketflow-0.2.0/socketflow/global_side/protocol.py +73 -0
  17. socketflow-0.2.0/socketflow/global_side/transport.py +421 -0
  18. socketflow-0.2.0/socketflow/server_side/server.py +1448 -0
  19. socketflow-0.2.0/socketflow.egg-info/PKG-INFO +998 -0
  20. {socketflow-0.1.4 → socketflow-0.2.0}/socketflow.egg-info/SOURCES.txt +6 -0
  21. socketflow-0.2.0/socketflow.egg-info/requires.txt +10 -0
  22. socketflow-0.1.4/PKG-INFO +0 -308
  23. socketflow-0.1.4/README.md +0 -261
  24. socketflow-0.1.4/socketflow/client_side/client.py +0 -391
  25. socketflow-0.1.4/socketflow/global_side/compression.py +0 -155
  26. socketflow-0.1.4/socketflow/global_side/dispatcher.py +0 -220
  27. socketflow-0.1.4/socketflow/server_side/server.py +0 -413
  28. socketflow-0.1.4/socketflow.egg-info/PKG-INFO +0 -308
  29. {socketflow-0.1.4 → socketflow-0.2.0}/setup.cfg +0 -0
  30. {socketflow-0.1.4 → socketflow-0.2.0}/socketflow/client_side/__init__.py +0 -0
  31. {socketflow-0.1.4 → socketflow-0.2.0}/socketflow/global_side/__init__.py +0 -0
  32. {socketflow-0.1.4 → socketflow-0.2.0}/socketflow/server_side/__init__.py +0 -0
  33. {socketflow-0.1.4 → socketflow-0.2.0}/socketflow.egg-info/dependency_links.txt +0 -0
  34. {socketflow-0.1.4 → socketflow-0.2.0}/socketflow.egg-info/not-zip-safe +0 -0
  35. {socketflow-0.1.4 → socketflow-0.2.0}/socketflow.egg-info/top_level.txt +0 -0
@@ -0,0 +1,998 @@
1
+ Metadata-Version: 2.4
2
+ Name: socketflow
3
+ Version: 0.2.0
4
+ Summary: TCP networking for Python, without the boilerplate.
5
+ Home-page: https://github.com/ayammaximilian/socketflow
6
+ Author: SocketFlow Team
7
+ Author-email: contact@socketflow.dev
8
+ License: MIT
9
+ Project-URL: Bug Reports, https://github.com/ayammaximilian/socketflow/issues
10
+ Project-URL: Source, https://github.com/ayammaximilian/socketflow
11
+ Project-URL: Documentation, https://socketflow.dev
12
+ Keywords: networking,tcp,socket,server,client,real-time,messaging,rpc,events,middleware,blueprint,ssl,tls,encryption,mtls,authentication,compression,backpressure,async,observability
13
+ Platform: any
14
+ Classifier: Development Status :: 5 - Production/Stable
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.7
19
+ Classifier: Programming Language :: Python :: 3.8
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Topic :: Internet
25
+ Classifier: Topic :: Internet :: WWW/HTTP
26
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
27
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
28
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
29
+ Classifier: Topic :: System :: Networking
30
+ Classifier: Topic :: Communications
31
+ Classifier: Topic :: Security :: Cryptography
32
+ Requires-Python: >=3.7
33
+ Description-Content-Type: text/markdown
34
+ Provides-Extra: zstd
35
+ Requires-Dist: zstandard>=0.15; extra == "zstd"
36
+ Provides-Extra: brotli
37
+ Requires-Dist: Brotli>=1.0; extra == "brotli"
38
+ Provides-Extra: all
39
+ Requires-Dist: zstandard>=0.15; extra == "all"
40
+ Requires-Dist: Brotli>=1.0; extra == "all"
41
+ Dynamic: author
42
+ Dynamic: author-email
43
+ Dynamic: classifier
44
+ Dynamic: description
45
+ Dynamic: description-content-type
46
+ Dynamic: home-page
47
+ Dynamic: keywords
48
+ Dynamic: license
49
+ Dynamic: platform
50
+ Dynamic: project-url
51
+ Dynamic: provides-extra
52
+ Dynamic: requires-python
53
+ Dynamic: summary
54
+
55
+ # SocketFlow
56
+
57
+ A high-performance, dependency-free TCP networking library for Python with advanced features like compression, event handling, bidirectional keepalive, and more.
58
+
59
+ ## Features
60
+
61
+ - **Zero Dependencies** - Uses only Python's standard library
62
+ - **Bidirectional Keepalive** - Both client and server independently monitor connection health
63
+ - **TCP-Level Keepalive** - OS-managed keepalive for reliable connection detection
64
+ - **Compression** - Support for zlib, lzma, and bz2 compression
65
+ - **Event-Driven Architecture** - Flexible event dispatcher for handling server/client events
66
+ - **Blueprint System** - Organize your code with reusable blueprints
67
+ - **Middleware Support** - Add custom middleware to request/response processing
68
+ - **Path-Based Routing** - Route messages to specific handlers using paths
69
+ - **Per-Path Serialisation** - `block=True` runs a path one message at a time per client, for handlers that must not interleave
70
+ - **Status Codes** - Attach HTTP-style status codes to replies, defaulting to 200
71
+ - **Automatic Error Reporting** - Unknown paths return 404, handler crashes return 500
72
+ - **Efficient Buffer Handling** - O(N) buffer processing with offset pattern
73
+ - **Type Hints** - Full type annotations for better IDE support
74
+ - **Cross-Platform** - Works on Windows, Linux, and macOS
75
+
76
+ ## Installation
77
+
78
+ ```bash
79
+ pip install socketflow
80
+ ```
81
+
82
+ **Full documentation available at:** https://socketflow.dev/
83
+
84
+ Or install from source:
85
+
86
+ ```bash
87
+ git clone https://github.com/ayammaximilian/socketflow.git
88
+ cd socketflow
89
+ pip install .
90
+ ```
91
+
92
+ ## Quick Start
93
+
94
+ ### Server Example
95
+
96
+ ```python
97
+ from socketflow import TcpServer, EventType
98
+
99
+ # Create server
100
+ server = TcpServer(
101
+ host="127.0.0.1",
102
+ port=8080,
103
+ keepalive_interval=30.0,
104
+ keepalive_max_missed=3,
105
+ compress=True
106
+ )
107
+
108
+ # Register event handler
109
+ @server.event(EventType.Server.MESSAGE)
110
+ def handle_message(data):
111
+ print(f"Received: {data.data}")
112
+ # Handlers do NOT reply by returning. Send the reply explicitly,
113
+ # echoing the data_id so the caller can match it to its request.
114
+ server.send_client(data.client_addr, "Response", data.data_id)
115
+
116
+ # Start server
117
+ server.start()
118
+ server.wait() # Keep server running
119
+ ```
120
+
121
+ ### Client Example
122
+
123
+ ```python
124
+ from socketflow import TcpClient, EventType
125
+
126
+ # Create client
127
+ client = TcpClient(
128
+ host="127.0.0.1",
129
+ port=8080,
130
+ keepalive_interval=30.0,
131
+ keepalive_max_missed=3,
132
+ compress=True
133
+ )
134
+
135
+ # Register event handler BEFORE connecting
136
+ @client.event(EventType.Client.MESSAGE)
137
+ def handle_message(data):
138
+ # Only reached for messages sent without wait_response.
139
+ print(f"Received: {data.data}")
140
+
141
+ # Connect to server
142
+ client.connect()
143
+
144
+ # Send message and wait for the reply
145
+ response = client.send("Hello, Server!", wait_response=True, wait_response_timeout=5)
146
+ print(f"Server response: {response.data}")
147
+
148
+ # Disconnect
149
+ client.disconnect()
150
+ ```
151
+
152
+ Output:
153
+
154
+ ```
155
+ Received: Hello, Server!
156
+ Server response: Response
157
+ ```
158
+
159
+ ### How replies work
160
+
161
+ This trips people up, so it is worth being explicit:
162
+
163
+ | You want | Do this |
164
+ |---|---|
165
+ | Server answers a request | `server.send_client(data.client_addr, reply, data.data_id)` |
166
+ | Client waits for the answer | `client.send(msg, wait_response=True)` |
167
+ | Fire and forget, no answer | `client.send(msg)` |
168
+ | Client handles messages on its own | `@client.event(EventType.Client.MESSAGE)` |
169
+ | Reply with a status code | add `status_code=404` to `send_client` |
170
+ | Check the outcome on the client | `reply.status_code` (see below) |
171
+
172
+ Three things to remember:
173
+
174
+ - **Returning a value from a handler does nothing.** Handlers must call
175
+ `send_client` (or `send_client_async`) to reply.
176
+ - **A reply that matches a pending `wait_response` request never reaches
177
+ `Client.MESSAGE`.** It resolves the `send()` call instead. Your event handler
178
+ only sees messages that nobody was waiting for.
179
+
180
+ If the server never replies, `wait_response=True` raises `NoResponse` once
181
+ `wait_response_timeout` expires rather than hanging forever. Always pass a timeout.
182
+
183
+ ## Status Codes and Error Handling
184
+
185
+ Every reply carries a status code, the same idea as an HTTP status. It defaults to
186
+ `200`, so nothing changes unless you set one:
187
+
188
+ ```python
189
+ @server.path("sf_download_size")
190
+ def get_size(data):
191
+ path = data.data["file_path"]
192
+ if not os.path.exists(path):
193
+ return server.send_client(
194
+ data.client_addr,
195
+ {"error": "Not Found", "detail": "no such file"},
196
+ data.data_id,
197
+ status_code=404,
198
+ )
199
+ return server.send_client(
200
+ data.client_addr, {"size": os.path.getsize(path)}, data.data_id
201
+ )
202
+ ```
203
+
204
+ The caller reads it off the reply:
205
+
206
+ ```python
207
+ reply = client.send(
208
+ {"file_path": file_path},
209
+ path="sf_download_size",
210
+ wait_response=True,
211
+ wait_response_timeout=timeout,
212
+ )
213
+
214
+ if reply.status_code == 404:
215
+ return False, reply.data["detail"]
216
+ return int(reply.data["size"])
217
+ ```
218
+
219
+ `send_async` works the same way: `request.result().status_code`. Handlers receive
220
+ incoming messages with `data.status_code`, and a non-waiting message still carries it.
221
+
222
+ Two details worth knowing:
223
+
224
+ - **A `200` is never written to the wire.** The code is only attached to the frame when it
225
+ differs from the default, so normal traffic stays byte-for-byte unchanged and older
226
+ peers keep working. They simply never send a status code, and always appear as `200`.
227
+ - **A non-2xx code is not an error.** The exchange completes normally, `.data` and
228
+ `.data_id` are intact, and nothing is raised. You decide what a code means.
229
+
230
+ ### Automatic error reporting
231
+
232
+ You do not have to catch anything to get the common failures reported back. SocketFlow
233
+ handles these itself:
234
+
235
+ | Situation | Status | `data` |
236
+ |---|---|---|
237
+ | No handler registered for the path | `404` | `{"error": "Not Found", "detail": "..."}` |
238
+ | A handler or middleware raised | `500` | `{"error": "Internal Server Error", "detail": "ValueError: ..."}` |
239
+ | Handler replied, then raised | the first reply is kept | — |
240
+ | Anything else | `200` unless you set one | your payload |
241
+
242
+ So this needs no `try`/`except` to be useful:
243
+
244
+ ```python
245
+ @server.path("sf_download_size")
246
+ def get_size(data):
247
+ raise Exception("fake error") # client gets 500 with the reason
248
+ ```
249
+
250
+ The client stops waiting immediately instead of hanging until the timeout, and the real
251
+ cause arrives instead of a generic "no response". This is a big difference from an
252
+ unanswered request, which still raises `NoResponse` on timeout.
253
+
254
+ Log both cases centrally with one handler:
255
+
256
+ ```python
257
+ @server.event(EventType.Global.ERROR)
258
+ def on_error(data):
259
+ print(f"{data.context}: {data.error}")
260
+ ```
261
+
262
+ `data.error` is the original exception, or a `PathNotFound` for an unknown path.
263
+
264
+ Notes:
265
+ - Automatic replies are only sent when the request used `wait_response=True`, since
266
+ otherwise nobody is waiting. A fire-and-forget message to an unknown path is still
267
+ logged through `EventType.Global.ERROR`.
268
+ - A reply you already sent wins, so a handler that replies and then raises will not
269
+ produce a confusing double answer.
270
+ - A missing path is reported fast. If a client requests a path the server does not
271
+ implement, it fails on the first attempt instead of waiting out the full timeout.
272
+
273
+
274
+ ## Non-Blocking Request/Reply
275
+
276
+ `send_async` returns a request handle immediately instead of blocking the calling thread:
277
+
278
+ ```python
279
+ request = client.send_async("Hello, Server!", path="echo", timeout=5.0)
280
+ print("Sent, not waiting")
281
+
282
+ # Check later
283
+ if request.done():
284
+ print(request.result())
285
+
286
+ # Or wait only as long as you want
287
+ try:
288
+ response = request.result(timeout=2)
289
+ except NoResponse:
290
+ print("Timed out")
291
+ ```
292
+
293
+ Handles also support callbacks and cancellation:
294
+
295
+ ```python
296
+ def on_reply(handle):
297
+ print("Reply:", handle.result().data)
298
+
299
+ request = client.send_async("Hello!", path="echo")
300
+ request.add_done_callback(on_reply)
301
+
302
+ # Give up on a request that is no longer needed
303
+ request.cancel()
304
+ ```
305
+
306
+ Handle methods: `done()`, `result(timeout=None)`, `exception(timeout=None)`, `cancel()`, `cancelled()`, `add_done_callback(fn)`, and the `data_id` attribute.
307
+
308
+ The server works the same way:
309
+
310
+ ```python
311
+ request = server.send_client_async(client_addr, "push", path="push")
312
+ response = request.result(timeout=5)
313
+ ```
314
+
315
+ Notes:
316
+ - `send(..., wait_response=True)` still works and is now built on the same handle.
317
+ - Timeouts run on one shared timer thread per client/server, not one thread per request.
318
+ - Cancelling or timing out removes the request from tracking immediately.
319
+
320
+ ### Pending request tracking
321
+
322
+ Every in-flight request is held in `pending_responses` until it completes. The default
323
+ `timeout=30.0` is what keeps this bounded: when it expires, the request is removed
324
+ automatically even if no reply ever arrives.
325
+
326
+ ```python
327
+ request = client.send_async("hello", path="echo", timeout=30.0)
328
+ ```
329
+
330
+ Use `timeout=None` only when a reply is genuinely optional. In that case the request is
331
+ kept until the connection closes, so `len(client.pending_responses)` grows with every
332
+ unanswered request:
333
+
334
+ ```python
335
+ request = client.send_async("fire-and-forget", path="notify", timeout=None)
336
+ print(len(client.pending_responses)) # grows while replies are missing
337
+ ```
338
+
339
+ To keep a hard ceiling, cancel explicitly or check the count before sending.
340
+
341
+ ## Installation
342
+
343
+ SocketFlow has **no required dependencies**. It runs on the Python standard library
344
+ alone, so installing it never pulls anything else in.
345
+
346
+ ```bash
347
+ pip install socketflow
348
+ ```
349
+
350
+ ### Optional compression codecs
351
+
352
+ Four codecs are built in: `zlib`, `lzma`, `bz2`, and `gzip`. Two more are available
353
+ through extras:
354
+
355
+ ```bash
356
+ pip install socketflow[zstd] # adds zstandard
357
+ pip install socketflow[brotli] # adds Brotli
358
+ pip install socketflow[all] # adds both
359
+ ```
360
+
361
+ Then use them like any other codec:
362
+
363
+ ```python
364
+ server = TcpServer(compression_type="zstd")
365
+ ```
366
+
367
+ If a codec is not installed, you get a message telling you exactly what to do:
368
+
369
+ ```
370
+ Compression method 'zstd' is not available because 'zstandard' is not
371
+ installed. Install it with 'pip install socketflow[zstd]', or choose one of:
372
+ lzma, bz2, zlib, gzip.
373
+ ```
374
+
375
+ Check what your machine can do:
376
+
377
+ ```python
378
+ from socketflow.global_side.compression import MultiCompressor
379
+ print(MultiCompressor.available_methods())
380
+ ```
381
+
382
+ ## Logging and Metrics
383
+
384
+ Two tools for seeing what your server is doing. **Both are off by default**, so adding
385
+ them changes nothing until you ask for them.
386
+
387
+ ### Logging
388
+
389
+ Instead of reading log lines and guessing, each entry carries labeled fields.
390
+
391
+ ```python
392
+ from socketflow import logs
393
+
394
+ logs.configure(level="INFO") # human-readable, to stderr
395
+ logs.configure(level="INFO", json_output=True) # one JSON object per line
396
+ ```
397
+
398
+ Sample output:
399
+
400
+ ```json
401
+ {"time": "2026-09-26T02:00:08.616Z", "level": "INFO", "logger": "socketflow.server",
402
+ "message": "client connected", "client_identity": "test-client", "active_clients": 1}
403
+ ```
404
+
405
+ Send logs somewhere else by giving it a sink:
406
+
407
+ ```python
408
+ logs.configure(level="DEBUG", sinks=[logs.MemorySink(limit=500)])
409
+ logs.configure(level="DEBUG", sinks=[logs.CallbackSink(my_function)])
410
+ logs.configure(level="DEBUG", sinks=[]) # silence completely
411
+ ```
412
+
413
+ | Level | Shows |
414
+ |---|---|
415
+ | `DEBUG` | Every message, sent and received |
416
+ | `INFO` | Connections, server start/stop, drains |
417
+ | `WARNING` | Rejected connections, disconnects |
418
+ | `ERROR` | Failures |
419
+
420
+ Write your own logs the same way:
421
+
422
+ ```python
423
+ log = logs.get_logger("my.app").bind(service="billing")
424
+ log.info("charge accepted", order_id=123) # every line now has service=billing
425
+ ```
426
+
427
+ #### Long messages
428
+
429
+ `message` is always written out in full. A 50,000-character message is logged as
430
+ 50,000 characters — it is not silently shortened, and JSON output stays valid.
431
+
432
+ To stop one huge value from flooding a sink, set a limit:
433
+
434
+ ```python
435
+ logs.configure(level="INFO", json_output=True, max_message_length=2000)
436
+ ```
437
+
438
+ Anything longer is cut and marked:
439
+
440
+ ```
441
+ "message": "A very long value ... [truncated 48213 chars]"
442
+ ```
443
+
444
+ The limit applies to `message` and to any string field. It is off by default
445
+ (`None`), so nothing changes unless you ask for it.
446
+
447
+ #### Payloads are never logged
448
+
449
+ SocketFlow logs metadata about messages, never their content. Sending a 2 MB message
450
+ produces `{"message": "message received", "path": "echo"}` — the data is not written
451
+ anywhere.
452
+
453
+ The thing to watch is your own code. `log.info(f"got {payload}")` will happily log a
454
+ 2 MB payload. Log identifiers and sizes, not bodies.
455
+
456
+ ### Metrics
457
+
458
+ Counters, gauges, and timing histograms. The server records them automatically.
459
+
460
+ ```python
461
+ print(server.metrics.counter_value("messages_received_total", path="echo"))
462
+ print(server.metrics.gauge_value("connections_active"))
463
+ print(server.metrics_snapshot()) # nested dict, JSON-friendly
464
+ print(server.metrics_text()) # Prometheus format
465
+ ```
466
+
467
+ | Metric | Type | Meaning |
468
+ |---|---|---|
469
+ | `connections_accepted_total` | counter | Clients that connected |
470
+ | `connections_rejected_total` | counter | Clients turned away |
471
+ | `connections_active` | gauge | Connected right now |
472
+ | `messages_received_total` | counter | By path |
473
+ | `messages_sent_total` | counter | To clients |
474
+ | `bytes_sent_total` | counter | Bytes written |
475
+ | `errors_total` | counter | By context (`server.handle_data`, `client.receive`, …) |
476
+ | `backpressure_total` | counter | Rejections from full queues |
477
+
478
+ ### Feeding Prometheus
479
+
480
+ `metrics_text()` is already Prometheus format, so expose it over HTTP:
481
+
482
+ ```python
483
+ # In test_server.py
484
+ class Handler(BaseHTTPRequestHandler):
485
+ def do_GET(self):
486
+ body = server.metrics_text().encode()
487
+ self.send_response(200)
488
+ self.send_header("Content-Type", "text/plain; version=0.0.4")
489
+ self.end_headers()
490
+ self.wfile.write(body)
491
+ ```
492
+
493
+ Then scrape `http://your-host:9090/metrics`:
494
+
495
+ ```
496
+ socketflow_connections_accepted_total 1
497
+ socketflow_connections_active 1
498
+ socketflow_messages_received_total{path="echo"} 4
499
+ ```
500
+
501
+ ### The catch
502
+
503
+ Labels create one time series per value. If you label with something unique — a
504
+ `data_id`, a full URL with an ID, a raw socket address — your metrics will grow
505
+ endlessly and can slow the server down. Label with small, bounded values like
506
+ `path` or `reason`.
507
+
508
+ The library labels by `path` and `reason` only, so this is safe unless you add your own.
509
+
510
+ ## Mutual TLS (Client Certificates)
511
+
512
+ Like a door that checks **both** badges. The server proves who it is, and the client
513
+ proves who it is. After the handshake, the server knows the client's name.
514
+
515
+ ```python
516
+ server = TcpServer(
517
+ tls_enabled=True,
518
+ tls_certfile="server.crt",
519
+ tls_keyfile="server.key",
520
+ tls_client_ca="clients.crt", # CA that signs client certs
521
+ tls_require_client_cert=True, # no cert, no entry
522
+ )
523
+
524
+ client = TcpClient(
525
+ tls_enabled=True,
526
+ tls_ca_certs="ca.crt",
527
+ tls_server_hostname="server.example.com",
528
+ tls_certfile="me.crt", # my badge
529
+ tls_keyfile="me.key",
530
+ )
531
+ ```
532
+
533
+ Handlers see who is talking:
534
+
535
+ ```python
536
+ @server.path("whoami")
537
+ def whoami(message):
538
+ print(message.client_identity) # "laptop-07"
539
+ server.send_client(message.client_addr, message.client_identity, message.data_id)
540
+
541
+ @server.event(EventType.Server.CLIENT_CONNECT)
542
+ def on_connect(data):
543
+ print(f"{data.client_identity} joined") # "laptop-07"
544
+ ```
545
+
546
+ Or outside a handler:
547
+
548
+ ```python
549
+ server.get_client_identity(client_addr) # "laptop-07" or None
550
+ ```
551
+
552
+ ### Required vs optional
553
+
554
+ | Setting | No client cert | Bad client cert |
555
+ |---|---|---|
556
+ | `tls_require_client_cert=True` | Rejected 🔒 | Rejected 🔒 |
557
+ | `tls_client_ca` only (optional) | Allowed, `client_identity` is `None` | Rejected 🔒 |
558
+ | No `tls_client_ca` | Normal one-way TLS | Normal one-way TLS |
559
+
560
+ ### The catch
561
+
562
+ Certificates expire. If one does, the client is locked out until you issue a new one.
563
+ Plan renewal before the expiry date, and keep the CA private key safe — anyone holding
564
+ it can mint a certificate your server will trust.
565
+
566
+ ## Protocol Version Negotiation
567
+
568
+ Like a phone that only works on certain networks. Both sides say which versions they
569
+ speak, then agree on one.
570
+
571
+ It is **off by default**, so existing code is unaffected. Turn it on by passing
572
+ `protocol_version` or a version range:
573
+
574
+ ```python
575
+ server = TcpServer(protocol_version=1) # speaks exactly version 1
576
+ client = TcpClient(protocol_version=1) # speaks exactly version 1
577
+ client.connect()
578
+ print(client.negotiated_protocol_version) # 1
579
+ ```
580
+
581
+ Ranges let old and new builds talk to each other:
582
+
583
+ ```python
584
+ server = TcpServer(min_protocol_version=1, max_protocol_version=3)
585
+ client = TcpClient(min_protocol_version=1, max_protocol_version=5)
586
+
587
+ client.connect()
588
+ print(client.negotiated_protocol_version) # 3, the highest both support
589
+ ```
590
+
591
+ If there is no overlap, the connection is refused with a clear reason:
592
+
593
+ ```python
594
+ client = TcpClient(min_protocol_version=7, max_protocol_version=9)
595
+
596
+ try:
597
+ client.connect()
598
+ except ProtocolVersionError as error:
599
+ print(error)
600
+ # No common protocol version: client supports 7-9, server supports 1-1
601
+ ```
602
+
603
+ The check runs during the handshake, **before authentication**, so a mismatched peer
604
+ never reaches your credentials.
605
+
606
+ | | Old | New |
607
+ |---|---|---|
608
+ | Mismatched versions | Weird errors, or silent breakage | Clear `ProtocolVersionError` |
609
+ | Upgrading server | May break old clients | Old clients keep working |
610
+ | Knowing what runs | Guesswork | `negotiated_protocol_version` |
611
+
612
+ Notes:
613
+ - Both sides must opt in. If only one side negotiates, no version is recorded and the
614
+ handshake proceeds as before.
615
+ - The server stores the agreed version per connection; the client exposes it as
616
+ `negotiated_protocol_version`.
617
+
618
+ ## Graceful Shutdown
619
+
620
+ Draining lets in-flight work finish before connections are closed, so replies that are
621
+ already being produced are not lost.
622
+
623
+ ```python
624
+ # Stop accepting new clients, wait for handlers and queued replies, then close.
625
+ finished = server.drain(timeout=10.0)
626
+ print("drained cleanly:", finished)
627
+ ```
628
+
629
+ While draining:
630
+
631
+ - New connections are refused immediately.
632
+ - Existing clients stay connected.
633
+ - Running and queued handlers are allowed to finish.
634
+ - Replies those handlers queued are flushed to the socket.
635
+
636
+ `drain()` returns `True` if everything finished before the timeout, `False` if the
637
+ timeout expired first. It does not close connections by itself.
638
+
639
+ ```python
640
+ # Combined stop, with draining
641
+ server.stop(drain=True, drain_timeout=10.0)
642
+
643
+ # shutdown() drains by default
644
+ server.shutdown() # drains, then stops
645
+ server.shutdown(drain=False) # immediate, no drain
646
+ ```
647
+
648
+ To observe a drain starting:
649
+
650
+ ```python
651
+ @server.event(EventType.Server.DRAINING)
652
+ def on_draining(data):
653
+ print(f"draining: {data.connected_clients} clients still connected")
654
+ ```
655
+
656
+ `server.draining` is `True` while a drain is in progress.
657
+
658
+ Notes:
659
+ - Always pass a `drain_timeout`. A handler that blocks forever will make draining wait
660
+ until the timeout, then return `False`.
661
+ - Draining waits for handler tasks, not for clients to disconnect. Long-lived clients
662
+ that send nothing will still hold connections open until `stop()` closes them.
663
+
664
+ ## Connection Protection
665
+
666
+ Use TLS together with token or username/password authentication:
667
+
668
+ ```python
669
+ server = TcpServer(
670
+ host="0.0.0.0",
671
+ port=8080,
672
+ tls_enabled=True,
673
+ tls_certfile="server.crt",
674
+ tls_keyfile="server.key",
675
+ auth_token="replace-with-a-secret",
676
+ )
677
+
678
+ client = TcpClient(
679
+ host="server.example.com",
680
+ port=8080,
681
+ tls_enabled=True,
682
+ tls_ca_certs="ca.crt",
683
+ tls_server_hostname="server.example.com",
684
+ auth_token="replace-with-a-secret",
685
+ )
686
+ ```
687
+
688
+ The client must trust the server certificate and use the matching server name. Authentication happens before normal application messages are accepted. The client also waits for the server's final `handshake_ok` confirmation. Username/password authentication is also available through `auth_username` and `auth_password`.
689
+
690
+ ## Configuration
691
+
692
+ ### Server Options
693
+
694
+ | Parameter | Type | Default | Description |
695
+ |-----------|------|---------|-------------|
696
+ | `host` | str | "127.0.0.1" | Server host address |
697
+ | `port` | int | 8080 | Server port |
698
+ | `compression_type` | str | "zlib" | Codec: zlib, lzma, bz2, gzip (built in), or zstd, brotli (optional) |
699
+ | `compression_level` | int | 6 | Compression level (1-9) |
700
+ | `compress` | bool | True | Enable compression |
701
+ | `keepalive_interval` | float | 30.0 | Keepalive interval in seconds |
702
+ | `keepalive_max_missed` | int | 3 | Max missed keepalives before disconnect |
703
+ | `recv_buffer_size` | int | 65536 | Receive buffer size |
704
+ | `send_buffer_size` | int | 65536 | Send buffer size |
705
+ | `max_frame_size` | int | 8388608 | Maximum inbound/outbound frame size |
706
+ | `max_outbound_queue_bytes` | int | 16777216 | Per-client queued outbound bytes |
707
+ | `max_pending_writes` | int | 1000 | Maximum queued outbound frames per client |
708
+ | `max_dispatch_workers` | int | 32 | Maximum application handler workers |
709
+ | `max_pending_tasks` | int | 1000 | Maximum queued/running handler tasks; also the cap on messages waiting in one `block=True` queue |
710
+ | `dispatch_queue_timeout` | float | 1.0 | Seconds to wait when dispatch capacity is exhausted |
711
+ | `max_connections` | int | 1000 | Maximum concurrently accepted clients |
712
+ | `allow_pickle` | bool | False | Explicitly allow legacy pickle payloads from trusted peers |
713
+ | `tls_enabled` | bool | False | Encrypt the connection with TLS |
714
+ | `tls_certfile` | str | None | Server TLS certificate file |
715
+ | `tls_keyfile` | str | None | Server TLS private key file |
716
+ | `tls_client_ca` | str | None | CA used to verify client certificates (mutual TLS) |
717
+ | `tls_require_client_cert` | bool | False | Reject clients that do not present a certificate |
718
+ | `tls_handshake_timeout` | float | 10.0 | TLS handshake timeout |
719
+ | `auth_enabled` | bool | False | Require the authentication handshake |
720
+ | `auth_token` | str | None | Shared authentication token |
721
+ | `auth_username` | str | None | Username for authentication |
722
+ | `auth_password` | str | None | Password for authentication |
723
+ | `auth_timeout` | float | 30.0 | Authentication timeout |
724
+ | `require_handshake` | bool | True | Require the security handshake before messages |
725
+ | `handshake_timeout` | float | None | Full TLS/server-ready/auth handshake timeout; defaults to the connection timeout |
726
+ | `allow_legacy_clients` | bool | False | Accept pre-handshake (0.1.x) clients; accepts pickled payloads inbound only |
727
+ | `max_memory_bytes` | int | 268435456 | Shared memory budget for connection buffers and queued writes |
728
+ | `max_decompressed_size` | int | 16777216 | Maximum size after decompression |
729
+ | `use_event_loop` | bool | True | Use the shared selector loop for server connections |
730
+
731
+ ### Client Options
732
+
733
+ | Parameter | Type | Default | Description |
734
+ |-----------|------|---------|-------------|
735
+ | `host` | str | "127.0.0.1" | Server host address |
736
+ | `port` | int | 8080 | Server port |
737
+ | `compression_type` | str | "zlib" | Codec: zlib, lzma, bz2, gzip (built in), or zstd, brotli (optional) |
738
+ | `compression_level` | int | 6 | Compression level (1-9) |
739
+ | `compress` | bool | True | Enable compression |
740
+ | `keepalive_interval` | float | 30.0 | Keepalive interval in seconds |
741
+ | `keepalive_max_missed` | int | 3 | Max missed keepalives before disconnect |
742
+ | `connection_timeout` | float | 10.0 | Connection timeout in seconds |
743
+ | `recv_buffer_size` | int | 65536 | Receive buffer size |
744
+ | `send_buffer_size` | int | 65536 | Send buffer size |
745
+ | `max_frame_size` | int | 8388608 | Maximum inbound/outbound frame size |
746
+ | `max_outbound_queue_bytes` | int | 16777216 | Maximum queued outbound bytes |
747
+ | `max_pending_writes` | int | 1000 | Maximum queued outbound frames |
748
+ | `max_dispatch_workers` | int | 32 | Maximum application handler workers |
749
+ | `max_pending_tasks` | int | 1000 | Maximum queued/running handler tasks; also the cap on messages waiting in one `block=True` queue |
750
+ | `dispatch_queue_timeout` | float | 1.0 | Seconds to wait when dispatch capacity is exhausted |
751
+ | `allow_pickle` | bool | False | Explicitly allow legacy pickle payloads from trusted peers |
752
+ | `tls_enabled` | bool | False | Encrypt the connection with TLS |
753
+ | `tls_ca_certs` | str | None | Trusted server CA certificate file |
754
+ | `tls_server_hostname` | str | None | Name used to verify the server certificate |
755
+ | `tls_certfile` | str | None | Client certificate presented for mutual TLS |
756
+ | `tls_keyfile` | str | None | Private key for the client certificate |
757
+ | `auth_enabled` | bool | False | Send the authentication handshake |
758
+ | `auth_token` | str | None | Shared authentication token |
759
+ | `auth_username` | str | None | Username for authentication |
760
+ | `auth_password` | str | None | Password for authentication |
761
+ | `auth_timeout` | float | 30.0 | Authentication timeout |
762
+ | `require_handshake` | bool | True | Require the security handshake before messages |
763
+ | `handshake_timeout` | float | None | Full TLS/server-ready/auth handshake timeout; defaults to the authentication timeout |
764
+ | `allow_legacy_server` | bool | False | Allow a pre-handshake (0.1.x) server; peer is auto-detected |
765
+ | `legacy_probe_timeout` | float | 1.0 | Grace period for detecting a 0.1.x server when `allow_legacy_server` is set |
766
+ | `max_memory_bytes` | int | 67108864 | Shared memory budget for this client's buffers and queued writes |
767
+ | `max_decompressed_size` | int | 16777216 | Maximum size after decompression |
768
+
769
+ ## Transport Reliability
770
+
771
+ - Inbound frames are length-prefixed and validated against `max_frame_size`.
772
+ - Each connection has one serialized outbound writer, preventing frame interleaving.
773
+ - Outbound queues are bounded by both bytes and frame count; overload raises `Backpressure`.
774
+ - Application handlers use a bounded worker pool instead of one thread per message.
775
+ - Pending responses are isolated per client connection on the server.
776
+ - `max_memory_bytes` limits buffered receive data and queued outgoing data across all connections.
777
+ - `max_decompressed_size` rejects compressed messages that expand beyond the allowed size.
778
+ - Server connections use one shared `selectors` event loop by default; application handlers still use the bounded worker pool.
779
+ - `TCP_NODELAY` is enabled for low-latency request/response traffic.
780
+ - Use `shutdown()` when permanently closing a client or server; use `stop()`/`disconnect()` when a restartable lifecycle is needed.
781
+ - Compressed application payloads use safe JSON/bytes serialization by default; `allow_pickle=True` is only for explicitly trusted legacy peers.
782
+ - `allow_legacy_clients` / `allow_legacy_server` widen what is *decoded*, not what is encoded. A connection that completes the version handshake always receives the safe JSON format; only a connection positively identified as pre-handshake 0.1.x is sent pickle.
783
+ - Server side: a client is identified as legacy when it sends application data before the handshake.
784
+ - Client side: a server is identified as legacy when no `__server_ready__` arrives within `legacy_probe_timeout` (default `1.0` s), so a modern server on the other end still receives the safe JSON format.
785
+
786
+ ## API Reference
787
+
788
+ ### TcpServer
789
+
790
+ #### Methods
791
+
792
+ - `start()` - Start the server
793
+ - `stop(drain=False, drain_timeout=10.0)` - Stop the server and disconnect all clients
794
+ - `drain(timeout=10.0, reason="shutdown")` - Finish in-flight work, then report success
795
+ - `shutdown(drain=True, drain_timeout=10.0)` - Permanently stop the server and dispatcher
796
+ - `draining` - True while a drain is in progress
797
+ - `wait()` - Block until server stops
798
+ - `start_and_wait()` - Start server and block
799
+ - `send_client(client_addr, data, data_id=None, path=None, wait_response=False, wait_response_timeout=30.0, status_code=200)` - Send data to specific client
800
+ - `send_client_async(client_addr, data, data_id=None, path=None, timeout=30.0, status_code=200)` - Send without blocking, returns a request handle
801
+ - `disconnect_client(client_addr)` - Disconnect a specific client
802
+ - `get_connected_clients()` - Get number of connected clients
803
+ - `get_client_identity(client_addr)` - Certificate common name for a client, or None
804
+ - `metrics` - The `MetricsRegistry` recording this server's metrics
805
+ - `metrics_snapshot()` - All metrics as a nested dict
806
+ - `metrics_text()` - All metrics in Prometheus text format
807
+ - `is_connected(client_addr)` - Check if client is connected
808
+ - `event(event_type)` - Decorator to register event handler
809
+ - `path(path, middleware=None, block=False)` - Decorator to register path handler; `block=True` serialises messages for this path per client
810
+ - `register_blueprint(blueprint)` - Register a blueprint
811
+
812
+ ### TcpClient
813
+
814
+ #### Methods
815
+
816
+ - `connect()` - Connect to server
817
+ - `disconnect()` - Disconnect from server
818
+ - `shutdown()` - Permanently disconnect and stop the dispatcher
819
+ - `send(data, data_id=None, path=None, wait_response=False, wait_response_timeout=30.0, status_code=200)` - Send data to server
820
+ - `send_async(data, data_id=None, path=None, timeout=30.0, status_code=200)` - Send without blocking, returns a request handle
821
+ - `metrics` - The `MetricsRegistry` recording this client's metrics
822
+ - `wait()` - Block until client disconnects
823
+ - `connect_and_wait()` - Connect and block
824
+ - `is_connected()` - Check if connected
825
+ - `event(event_type)` - Decorator to register event handler
826
+ - `path(path, middleware=None, block=False)` - Decorator to register path handler; `block=True` serialises messages for this path per peer
827
+ - `register_blueprint(blueprint)` - Register a blueprint
828
+
829
+ ## Events
830
+
831
+ ### Server Events
832
+
833
+ - `EventType.Server.START` - Server started
834
+ - `EventType.Server.DRAINING` - Server started draining in-flight work
835
+ - `EventType.Server.STOP` - Server stopped
836
+ - `EventType.Server.CLIENT_CONNECT` - Client connected
837
+ - `EventType.Server.CLIENT_DISCONNECT` - Client disconnected
838
+ - `EventType.Server.MESSAGE` - Message received from client
839
+
840
+ ### Client Events
841
+
842
+ - `EventType.Client.CONNECT` - Connected to server
843
+ - `EventType.Client.DISCONNECT` - Disconnected from server
844
+ - `EventType.Client.MESSAGE` - Message received from server
845
+
846
+ ### Global Events
847
+
848
+ - `EventType.Global.ERROR` - Error occurred, including failed path handlers and unknown
849
+ paths. `data.error` is the exception, `data.context` names the path.
850
+
851
+ ## Path-Based Routing
852
+
853
+ Send messages to specific handlers using paths:
854
+
855
+ ```python
856
+ # Server
857
+ @server.path("/user/login")
858
+ def handle_login(data):
859
+ # Handle login
860
+ pass
861
+
862
+ @server.path("/user/register")
863
+ def handle_register(data):
864
+ # Handle registration
865
+ pass
866
+
867
+ # Client
868
+ client.send(data, path="/user/login")
869
+ ```
870
+
871
+ Requesting a path with no registered handler returns `404` with a `Not Found` payload
872
+ instead of waiting for the timeout. A handler that raises returns `500` with the reason.
873
+ See [Status Codes and Error Handling](#status-codes-and-error-handling).
874
+
875
+ ### Serialising a path with `block=True`
876
+
877
+ `block=True` means **handle one message at a time for this path** — a queue ticket for a
878
+ single client. It is a rule about messages, not about the network.
879
+
880
+ Despite the name, it does **not**:
881
+
882
+ - block the network or hold the socket still,
883
+ - make the client wait for a reply (that is `wait_response=True` on the sender),
884
+ - move the handler onto the main thread.
885
+
886
+ Either way the handler runs on a background worker drawn from the thread pool. What
887
+ `block=True` adds is a queue for that path+client pair, so only one message for that
888
+ client is inside the handler at a time. The queue is released even if the handler
889
+ raises.
890
+
891
+ ```python
892
+ # Default (block=False): two messages from one client may run at the same time,
893
+ # so a read-modify-write sequence can interleave with itself.
894
+ @server.path("/counter/increment")
895
+ def increment(data):
896
+ ...
897
+ ```
898
+
899
+ ```python
900
+ # block=True: the same client is never inside this handler twice at once.
901
+ @server.path("/counter/reset", block=True)
902
+ def reset(data):
903
+ ...
904
+ ```
905
+
906
+ The queue is keyed on **the path and the client together**, which is what keeps it from
907
+ becoming a global bottleneck:
908
+
909
+ | Situation | Runs in parallel? |
910
+ | --- | --- |
911
+ | Same path, same client | No — strictly one at a time, in arrival order |
912
+ | Same path, different clients | Yes — each client gets its own queue |
913
+ | Different paths, same client | Yes — separate queues |
914
+ | Different paths, different clients | Yes — separate queues |
915
+
916
+ All paths matching one parameterised pattern share a single queue per client, so
917
+ `/item/1` and `/item/2` from the same client are serialised against *each other*, not
918
+ just against themselves.
919
+
920
+ A waiting message does **not** occupy a worker thread. Each blocking path+client pair
921
+ gets a queue, and a single worker drains it one message at a time, so a large backlog
922
+ costs queue space instead of pool capacity. That means one slow client cannot exhaust
923
+ the pool and stall unrelated paths or unrelated clients. Messages are handled in arrival
924
+ order. A per-key queue holds at most `max_pending_tasks` messages; beyond that, sending
925
+ raises `DispatcherError` rather than growing without bound.
926
+
927
+ ## Blueprints
928
+
929
+ Organize your code with blueprints:
930
+
931
+ ```python
932
+ from socketflow import Blueprint
933
+
934
+ user_bp = Blueprint("user")
935
+
936
+ @user_bp.path("/login")
937
+ def login(data):
938
+ pass
939
+
940
+ @user_bp.path("/register")
941
+ def register(data):
942
+ pass
943
+
944
+ # Register blueprint
945
+ server.register_blueprint(user_bp)
946
+ ```
947
+
948
+ ## Keepalive
949
+
950
+ SocketFlow implements bidirectional keepalive at two levels:
951
+
952
+ 1. **Application-Level Keepalive** - Custom ping/pong messages
953
+ 2. **TCP-Level Keepalive** - OS-managed keepalive probes
954
+
955
+ Both client and server independently monitor connection health based on their own configurations.
956
+
957
+ ## Compression
958
+
959
+ Support for multiple compression algorithms:
960
+
961
+ - **zlib** - Fast compression, good balance
962
+ - **lzma** - High compression ratio, slower
963
+ - **bz2** - Good compression, moderate speed
964
+
965
+ ## Error Handling
966
+
967
+ SocketFlow provides custom exception types:
968
+
969
+ - `NotConnected` - Connection not established
970
+ - `ConnectionTimeout` - Connection attempt timed out
971
+ - `KeepaliveTimeout` - Keepalive timeout
972
+ - `CompressionError` - Compression/decompression error
973
+ - `InvalidData` - Invalid message format
974
+ - `NoResponse` - No response received within timeout
975
+ - `MessageHandlerError` - Message handling error
976
+ - `Backpressure` - Outbound or dispatch capacity was exhausted
977
+ - `DispatcherError` - Dispatcher queue or lifecycle failure
978
+ - `AuthenticationError` - Connection credentials were missing or invalid
979
+ - `TlsError` - TLS setup or certificate verification failed
980
+ - `HandshakeError` - The security handshake did not complete
981
+
982
+ ## License
983
+
984
+ MIT License - see LICENSE file for details
985
+
986
+ ## Contributing
987
+
988
+ Contributions are welcome! Please feel free to submit a Pull Request.
989
+
990
+ ## Support
991
+
992
+ - GitHub Issues: https://github.com/ayammaximilian/socketflow/issues
993
+ - Documentation: https://socketflow.dev/
994
+
995
+ ## Requirements
996
+
997
+ - Python 3.7+
998
+ - No external dependencies (uses only standard library)