socketflow 0.1.3__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.
- socketflow-0.2.0/PKG-INFO +998 -0
- socketflow-0.2.0/README.md +944 -0
- {socketflow-0.1.3 → socketflow-0.2.0}/setup.py +17 -9
- {socketflow-0.1.3 → socketflow-0.2.0}/socketflow/__init__.py +29 -1
- socketflow-0.2.0/socketflow/client_side/client.py +1015 -0
- {socketflow-0.1.3 → socketflow-0.2.0}/socketflow/global_side/blueprint.py +46 -2
- socketflow-0.2.0/socketflow/global_side/compression.py +276 -0
- socketflow-0.2.0/socketflow/global_side/dispatcher.py +418 -0
- {socketflow-0.1.3 → socketflow-0.2.0}/socketflow/global_side/event.py +21 -0
- socketflow-0.2.0/socketflow/global_side/event_loop.py +324 -0
- {socketflow-0.1.3 → socketflow-0.2.0}/socketflow/global_side/exceptions.py +42 -0
- socketflow-0.2.0/socketflow/global_side/logs.py +339 -0
- {socketflow-0.1.3 → socketflow-0.2.0}/socketflow/global_side/message_handler.py +9 -4
- {socketflow-0.1.3 → socketflow-0.2.0}/socketflow/global_side/message_manager.py +2 -2
- socketflow-0.2.0/socketflow/global_side/metrics.py +230 -0
- socketflow-0.2.0/socketflow/global_side/protocol.py +73 -0
- socketflow-0.2.0/socketflow/global_side/transport.py +421 -0
- socketflow-0.2.0/socketflow/server_side/server.py +1448 -0
- socketflow-0.2.0/socketflow.egg-info/PKG-INFO +998 -0
- {socketflow-0.1.3 → socketflow-0.2.0}/socketflow.egg-info/SOURCES.txt +6 -0
- socketflow-0.2.0/socketflow.egg-info/requires.txt +10 -0
- socketflow-0.1.3/PKG-INFO +0 -308
- socketflow-0.1.3/README.md +0 -261
- socketflow-0.1.3/socketflow/client_side/client.py +0 -383
- socketflow-0.1.3/socketflow/global_side/compression.py +0 -155
- socketflow-0.1.3/socketflow/global_side/dispatcher.py +0 -220
- socketflow-0.1.3/socketflow/server_side/server.py +0 -408
- socketflow-0.1.3/socketflow.egg-info/PKG-INFO +0 -308
- {socketflow-0.1.3 → socketflow-0.2.0}/setup.cfg +0 -0
- {socketflow-0.1.3 → socketflow-0.2.0}/socketflow/client_side/__init__.py +0 -0
- {socketflow-0.1.3 → socketflow-0.2.0}/socketflow/global_side/__init__.py +0 -0
- {socketflow-0.1.3 → socketflow-0.2.0}/socketflow/server_side/__init__.py +0 -0
- {socketflow-0.1.3 → socketflow-0.2.0}/socketflow.egg-info/dependency_links.txt +0 -0
- {socketflow-0.1.3 → socketflow-0.2.0}/socketflow.egg-info/not-zip-safe +0 -0
- {socketflow-0.1.3 → 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)
|