freetser 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.3
2
+ Name: freetser
3
+ Version: 0.1.0
4
+ Summary: A free-threaded HTTP server built on top of h11
5
+ Author: Tip ten Brink
6
+ Author-email: Tip ten Brink <tip@tenbrinkmeijs.com>
7
+ Requires-Dist: h11>=0.16.0,<0.17.0
8
+ Requires-Python: >=3.14
9
+ Description-Content-Type: text/markdown
10
+
11
+ # freetser
12
+
13
+ **freetser** is a **free**-**t**hreaded HTTP/1.1 **ser**ver, i.e. it relies on a free-threaded build of Python where the GIL is disabled. Furthermore, it provides a built-in KV storage layer (which uses SQLite under the hood). It has only a single dependency outside the standard library, the wonderful pure Python sans IO-style HTTP/1.1 library known as [h11](https://github.com/python-hyper/h11).
14
+
15
+ It aims to be a very simple synchronous web server that should work for a lot of projects that do not require multiple thousands of requests per second, although with a lot of cores and threads it can actually achieve about 10,000 requests per second on an untuned Linux system.
16
+
17
+ ## Architecture
18
+
19
+ - Thread-per-connection (no thread pool), **no async**
20
+ - Uses a pure Python HTTP/1.1 library (h11)
21
+ - `socket` from the standard library for the actual TCP reading/writing
22
+ - Single SQLite database thread (using `sqlite3` from the standard library), other threads simply send Python functions through a queue for it to execute
23
+
24
+ ## Quick start
25
+
26
+ Ensure you are using a **free-threaded** build of Python, see [documentation here](https://docs.python.org/3/howto/free-threading-python.html). If using [`uv`](https://github.com/astral-sh/uv), which I highly recommend, you could use `uv python pin 3.14t` (where the `t` stands for the free-threaded version).
27
+
28
+ Then, install `freetser` from PyPI with `uv add freetser`.
29
+
30
+ Finally, create a Python script (e.g. `main.py`) with the following code:
31
+
32
+ ```python
33
+ import logging
34
+
35
+ from freetser import (
36
+ Request,
37
+ Response,
38
+ ServerConfig,
39
+ Storage,
40
+ setup_logging,
41
+ start_server,
42
+ )
43
+ from freetser.server import StorageQueue
44
+
45
+ logger = logging.getLogger("freetser.handler")
46
+
47
+ def handler(req: Request, store_queue: StorageQueue | None) -> Response:
48
+ if req.path == "/":
49
+ return Response.text("Hello world!")
50
+
51
+ return Response.text("Not found!", status_code=404)
52
+
53
+
54
+ def main():
55
+ listener = setup_logging()
56
+ listener.start()
57
+
58
+ config = ServerConfig(port=8000)
59
+ try:
60
+ start_server(config, handler)
61
+ except KeyboardInterrupt:
62
+ print("\nShutting down...")
63
+ finally:
64
+ listener.stop()
65
+
66
+
67
+ if __name__ == "__main__":
68
+ main()
69
+ ```
70
+
71
+ Then if you do `uv run main.py`, it will start the server on the default port of 8000.
72
+
73
+ ## Using the built-in storage
74
+
75
+ `freetser` provides a built-in key-value (KV) store built on top of SQLite. It is not designed for speed, but for simplicity. First, define a database routine:
76
+
77
+ ```python
78
+ def get_or_create(store: Storage) -> str:
79
+ key = "my_user_email"
80
+ result = store.get("USERS", key)
81
+ if result is None:
82
+ value = b"my.user@freetser.com"
83
+ store.add("USERS", key, value, 0)
84
+ return f"Created: {key}\n"
85
+ else:
86
+ value, counter = result
87
+ return f"Found: {key}, counter={counter}\n"
88
+ ```
89
+
90
+ A database routine is simply a Python function or lambda that takes a `Storage` argument. Then, in your handler, call `result = store_queue.execute(get_or_create)`. The server will then execute this function on the database thread in a transaction, ensuring it either succeeds or fails.
91
+
92
+ This has some important consequences:
93
+ - If any non-expected database error occurs (i.e. it does not derive from `StorageError`), the database thread will crash and the server will have to be restarted.
94
+ - Therefore, you should **not** throw when you encounter application errors inside the routine, but instead return a value and handle the error in the handler. You can even just raise the error there, as when errors occur in the handler the server will simply return a 500 Internal Server Error but not crash (since the error is raised in the connection thread).
95
+ - Furthermore, try to only perform simple logic in the routines. Since we only have 1 database thread, all other threads have to wait on your routine to finish. So don't make any HTTP calls or other blocking requests. Put those in the handler instead. If this is a limitation, **don't use this library**. This library is explicitly not designed for highly concurrent use cases or high performance applications with hundreds of thousands of users. However, it should handle thousands just fine.
96
+
97
+ For more examples, see `main.py` in the tests directory or check out the tests themselves.
98
+
99
+ ## Benchmarks
100
+
101
+ In some basic stress testing on a local machine, we found that requests basically always take around 41-42 ms, giving a basic 25 requests per second. Note that we always perform at least a single SQLite operation, so every request has to go through the database thread.
102
+
103
+ However, throughput can rise all the way to 5500 requests per second (using 300 request threads firing off requests on a keep-alive connection) without any real hit to latency, with requests still taking around 43 ms to complete on average (and at most 160 ms, hats off to the OS scheduler). From that point on, average request times start to climb as you add threads.
104
+
105
+ Using 750 threads, throughput reaches 10,000 requests per second with an average request taking 53 ms. Using 1000 threads barely helps as throughput starts to level off rapidly, reaching 10,900 requests per second. 1200 threads wasn't possible to test without tuning OS settings.
106
+
107
+ Benchmark machine specs: Intel Core Ultra 7 155H (22 vCPUs), Linux 6.17, 32GB LPDDR5x-7467 memory
108
+
109
+ ## Background
110
+
111
+ I wanted to minimize dependencies and use the standard library's sqlite3 interface. However, sqlite3 is not made for async. Therefore, I wanted a synchronous web server. However, while there exists projects like Flask and Bottle, I simply could not grok how to easily integrate them with sqlite3. Furthermore, they are not designed to utilize Python's recent free-threaded build.
112
+
113
+ Most of all, I wanted a project where everyone can read the code and understand what it's doing, while also providing a built-in storage mechanism so you can use it for small-scale production use cases.
@@ -0,0 +1,103 @@
1
+ # freetser
2
+
3
+ **freetser** is a **free**-**t**hreaded HTTP/1.1 **ser**ver, i.e. it relies on a free-threaded build of Python where the GIL is disabled. Furthermore, it provides a built-in KV storage layer (which uses SQLite under the hood). It has only a single dependency outside the standard library, the wonderful pure Python sans IO-style HTTP/1.1 library known as [h11](https://github.com/python-hyper/h11).
4
+
5
+ It aims to be a very simple synchronous web server that should work for a lot of projects that do not require multiple thousands of requests per second, although with a lot of cores and threads it can actually achieve about 10,000 requests per second on an untuned Linux system.
6
+
7
+ ## Architecture
8
+
9
+ - Thread-per-connection (no thread pool), **no async**
10
+ - Uses a pure Python HTTP/1.1 library (h11)
11
+ - `socket` from the standard library for the actual TCP reading/writing
12
+ - Single SQLite database thread (using `sqlite3` from the standard library), other threads simply send Python functions through a queue for it to execute
13
+
14
+ ## Quick start
15
+
16
+ Ensure you are using a **free-threaded** build of Python, see [documentation here](https://docs.python.org/3/howto/free-threading-python.html). If using [`uv`](https://github.com/astral-sh/uv), which I highly recommend, you could use `uv python pin 3.14t` (where the `t` stands for the free-threaded version).
17
+
18
+ Then, install `freetser` from PyPI with `uv add freetser`.
19
+
20
+ Finally, create a Python script (e.g. `main.py`) with the following code:
21
+
22
+ ```python
23
+ import logging
24
+
25
+ from freetser import (
26
+ Request,
27
+ Response,
28
+ ServerConfig,
29
+ Storage,
30
+ setup_logging,
31
+ start_server,
32
+ )
33
+ from freetser.server import StorageQueue
34
+
35
+ logger = logging.getLogger("freetser.handler")
36
+
37
+ def handler(req: Request, store_queue: StorageQueue | None) -> Response:
38
+ if req.path == "/":
39
+ return Response.text("Hello world!")
40
+
41
+ return Response.text("Not found!", status_code=404)
42
+
43
+
44
+ def main():
45
+ listener = setup_logging()
46
+ listener.start()
47
+
48
+ config = ServerConfig(port=8000)
49
+ try:
50
+ start_server(config, handler)
51
+ except KeyboardInterrupt:
52
+ print("\nShutting down...")
53
+ finally:
54
+ listener.stop()
55
+
56
+
57
+ if __name__ == "__main__":
58
+ main()
59
+ ```
60
+
61
+ Then if you do `uv run main.py`, it will start the server on the default port of 8000.
62
+
63
+ ## Using the built-in storage
64
+
65
+ `freetser` provides a built-in key-value (KV) store built on top of SQLite. It is not designed for speed, but for simplicity. First, define a database routine:
66
+
67
+ ```python
68
+ def get_or_create(store: Storage) -> str:
69
+ key = "my_user_email"
70
+ result = store.get("USERS", key)
71
+ if result is None:
72
+ value = b"my.user@freetser.com"
73
+ store.add("USERS", key, value, 0)
74
+ return f"Created: {key}\n"
75
+ else:
76
+ value, counter = result
77
+ return f"Found: {key}, counter={counter}\n"
78
+ ```
79
+
80
+ A database routine is simply a Python function or lambda that takes a `Storage` argument. Then, in your handler, call `result = store_queue.execute(get_or_create)`. The server will then execute this function on the database thread in a transaction, ensuring it either succeeds or fails.
81
+
82
+ This has some important consequences:
83
+ - If any non-expected database error occurs (i.e. it does not derive from `StorageError`), the database thread will crash and the server will have to be restarted.
84
+ - Therefore, you should **not** throw when you encounter application errors inside the routine, but instead return a value and handle the error in the handler. You can even just raise the error there, as when errors occur in the handler the server will simply return a 500 Internal Server Error but not crash (since the error is raised in the connection thread).
85
+ - Furthermore, try to only perform simple logic in the routines. Since we only have 1 database thread, all other threads have to wait on your routine to finish. So don't make any HTTP calls or other blocking requests. Put those in the handler instead. If this is a limitation, **don't use this library**. This library is explicitly not designed for highly concurrent use cases or high performance applications with hundreds of thousands of users. However, it should handle thousands just fine.
86
+
87
+ For more examples, see `main.py` in the tests directory or check out the tests themselves.
88
+
89
+ ## Benchmarks
90
+
91
+ In some basic stress testing on a local machine, we found that requests basically always take around 41-42 ms, giving a basic 25 requests per second. Note that we always perform at least a single SQLite operation, so every request has to go through the database thread.
92
+
93
+ However, throughput can rise all the way to 5500 requests per second (using 300 request threads firing off requests on a keep-alive connection) without any real hit to latency, with requests still taking around 43 ms to complete on average (and at most 160 ms, hats off to the OS scheduler). From that point on, average request times start to climb as you add threads.
94
+
95
+ Using 750 threads, throughput reaches 10,000 requests per second with an average request taking 53 ms. Using 1000 threads barely helps as throughput starts to level off rapidly, reaching 10,900 requests per second. 1200 threads wasn't possible to test without tuning OS settings.
96
+
97
+ Benchmark machine specs: Intel Core Ultra 7 155H (22 vCPUs), Linux 6.17, 32GB LPDDR5x-7467 memory
98
+
99
+ ## Background
100
+
101
+ I wanted to minimize dependencies and use the standard library's sqlite3 interface. However, sqlite3 is not made for async. Therefore, I wanted a synchronous web server. However, while there exists projects like Flask and Bottle, I simply could not grok how to easily integrate them with sqlite3. Furthermore, they are not designed to utilize Python's recent free-threaded build.
102
+
103
+ Most of all, I wanted a project where everyone can read the code and understand what it's doing, while also providing a built-in storage mechanism so you can use it for small-scale production use cases.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.9.11,<0.10.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "freetser"
7
+ version = "0.1.0"
8
+ description = "A free-threaded HTTP server built on top of h11"
9
+ authors = [
10
+ { name = "Tip ten Brink", email = "tip@tenbrinkmeijs.com" },
11
+ ]
12
+ readme = "README.md"
13
+ requires-python = ">=3.14"
14
+ dependencies = [
15
+ "h11>=0.16.0,<0.17.0",
16
+ ]
17
+
18
+ [dependency-groups]
19
+ dev = [
20
+ "basedpyright>=1.34.0",
21
+ "httpx>=0.28.1",
22
+ "pytest>=9.0.1",
23
+ "ruff>=0.14.6",
24
+ ]
25
+
26
+ [tool.basedpyright]
27
+ typeCheckingMode = "standard"
28
+ pythonVersion = "3.14"
29
+ include = ["src", "tests"]
30
+ exclude = ["**/__pycache__", ".venv", "**/.venv"]
31
+
32
+ [tool.ruff]
33
+ target-version = "py314"
34
+
35
+ [tool.pytest.ini_options]
36
+ testpaths = ["tests"]
@@ -0,0 +1,23 @@
1
+ from .server import (
2
+ Request,
3
+ Response,
4
+ ServerConfig,
5
+ setup_logging,
6
+ start_server,
7
+ )
8
+ from .storage import (
9
+ EntryAlreadyExists,
10
+ Storage,
11
+ StorageError,
12
+ )
13
+
14
+ __all__ = [
15
+ "start_server",
16
+ "ServerConfig",
17
+ "Request",
18
+ "Response",
19
+ "setup_logging",
20
+ "Storage",
21
+ "StorageError",
22
+ "EntryAlreadyExists",
23
+ ]
@@ -0,0 +1,413 @@
1
+ import json
2
+ import logging
3
+ import queue
4
+ import socket
5
+ import sys
6
+ import threading
7
+ from dataclasses import dataclass, field
8
+ from logging.handlers import QueueHandler, QueueListener
9
+ from pathlib import Path
10
+ from queue import SimpleQueue
11
+ from typing import Callable, Optional, cast
12
+
13
+ import h11
14
+
15
+ import freetser.storage as storage
16
+
17
+ MAX_RECV = 2**16
18
+ logger = logging.getLogger("freetser.server")
19
+
20
+
21
+ @dataclass
22
+ class ServerConfig:
23
+ host: str = "127.0.0.1"
24
+ port: int = 8000
25
+ max_header_size: int = 16 * 1024
26
+ max_body_size: int = 2 * 1024 * 1024
27
+ # Parameter passed to `socket.listen()`
28
+ listen_backlog: int = 1024
29
+ # Path to sqlite database file, will be created if it does not exist
30
+ db_file: str | None = None
31
+ # All tables that must be present, will be created if they do not exist
32
+ db_tables: list[str] | None = None
33
+
34
+
35
+ @dataclass
36
+ class Request:
37
+ method: str
38
+ path: str
39
+ headers: list[tuple[bytes, bytes]]
40
+ body: bytes
41
+
42
+
43
+ @dataclass
44
+ class Response:
45
+ status_code: int
46
+ headers: list[tuple[bytes, bytes]]
47
+ body: bytes
48
+
49
+ @staticmethod
50
+ def text(content: str, status_code: int = 200) -> "Response":
51
+ body = content.encode("utf-8")
52
+ return Response(
53
+ status_code=status_code,
54
+ headers=[
55
+ (b"Content-Type", b"text/plain; charset=utf-8"),
56
+ (b"Content-Length", str(len(body)).encode("ascii")),
57
+ ],
58
+ body=body,
59
+ )
60
+
61
+ @staticmethod
62
+ def empty(
63
+ headers: list[tuple[bytes, bytes]] | None = None, status_code: int = 200
64
+ ) -> "Response":
65
+ if headers is None:
66
+ headers = []
67
+ headers.append(
68
+ (b"Content-Length", "0".encode("ascii")),
69
+ )
70
+ return Response(status_code=status_code, headers=headers, body=b"")
71
+
72
+ @staticmethod
73
+ def json(
74
+ content,
75
+ headers: list[tuple[bytes, bytes]] | None = None,
76
+ status_code: int = 200,
77
+ ) -> "Response":
78
+ if headers is None:
79
+ headers = []
80
+ body = json.dumps(content).encode("utf-8")
81
+ headers.append(
82
+ (b"Content-Length", str(len(body)).encode("ascii")),
83
+ )
84
+ headers.append(
85
+ (b"Content-Type", b"application/json"),
86
+ )
87
+ return Response(status_code=status_code, headers=headers, body=body)
88
+
89
+
90
+ @dataclass
91
+ class ConnectionContext:
92
+ client_socket: socket.socket
93
+ client_address: tuple
94
+ store_queue: StorageQueue | None
95
+ config: ServerConfig
96
+
97
+
98
+ def setup_logging() -> QueueListener:
99
+ """Configures non-blocking logging via QueueHandler/QueueListener."""
100
+ log_queue = queue.SimpleQueue()
101
+ queue_handler = QueueHandler(log_queue)
102
+
103
+ root = logging.getLogger()
104
+ root.setLevel(logging.INFO)
105
+ for h in root.handlers[:]:
106
+ root.removeHandler(h)
107
+ root.addHandler(queue_handler)
108
+
109
+ console_handler = logging.StreamHandler(sys.stdout)
110
+ console_handler.setFormatter(logging.Formatter("[%(threadName)s] %(message)s"))
111
+
112
+ listener = QueueListener(log_queue, console_handler)
113
+ return listener
114
+
115
+
116
+ type Handler = Callable[[Request, StorageQueue | None], Response]
117
+
118
+
119
+ def handle_client(ctx: ConnectionContext, handler: Handler):
120
+ logger.info(f"Connection from {ctx.client_address}")
121
+ conn = h11.Connection(
122
+ h11.SERVER, max_incomplete_event_size=ctx.config.max_header_size
123
+ )
124
+
125
+ try:
126
+ while True:
127
+ try:
128
+ # This is where we actually handle the request and response
129
+ if not handle_request_response(conn, ctx, handler):
130
+ break
131
+
132
+ if (
133
+ conn.our_state is h11.MUST_CLOSE
134
+ or conn.their_state is h11.MUST_CLOSE
135
+ ):
136
+ break
137
+
138
+ if conn.our_state is h11.DONE and conn.their_state is h11.DONE:
139
+ conn.start_next_cycle()
140
+ else:
141
+ break
142
+ except h11.RemoteProtocolError as e:
143
+ logger.error(f"Protocol error: {e}")
144
+ send_error_response(conn, ctx, 400, f"Bad Request: {e}")
145
+ break
146
+ except Exception as e:
147
+ logger.error(f"Unexpected error: {e}")
148
+ finally:
149
+ ctx.client_socket.close()
150
+ logger.info("Connection closed")
151
+
152
+
153
+ def handle_request_response(
154
+ conn: h11.Connection, ctx: ConnectionContext, handler: Handler
155
+ ) -> bool:
156
+ event = get_next_event(conn, ctx.client_socket)
157
+ if event is None:
158
+ logger.debug("No event, ending request/response")
159
+ return False
160
+
161
+ if isinstance(event, h11.ConnectionClosed):
162
+ return False
163
+
164
+ if not isinstance(event, h11.Request):
165
+ raise Exception(f"Unexpected event: {event}!")
166
+
167
+ if event.http_version != b"1.1":
168
+ logger.debug(f"Rejecting HTTP/{event.http_version.decode()}")
169
+ send_error_response(conn, ctx, 505, "HTTP Version Not Supported")
170
+ return False
171
+
172
+ logger.info(f"{event.method.decode()} {event.target.decode()}")
173
+
174
+ if conn.they_are_waiting_for_100_continue:
175
+ logger.debug("Sending 100 Continue")
176
+ ctx.client_socket.sendall(
177
+ conn.send(h11.InformationalResponse(status_code=100, headers=[]))
178
+ )
179
+
180
+ try:
181
+ request_body = read_request_body(conn, ctx)
182
+ except ValueError as e:
183
+ logger.error(f"Body too large: {e}")
184
+ send_error_response(conn, ctx, 413, f"Payload Too Large: {e}")
185
+ return False
186
+
187
+ req = Request(
188
+ method=event.method.decode(),
189
+ path=event.target.decode(),
190
+ headers=list(event.headers),
191
+ body=request_body,
192
+ )
193
+
194
+ # Call the actual request handler that determines the response
195
+ try:
196
+ resp = handler(req, ctx.store_queue)
197
+ except Exception as e:
198
+ logger.error(f"Handler error: {e}")
199
+ send_error_response(conn, ctx, 500, "Internal Server Error")
200
+ # Close connection in case of internal server error
201
+ return False
202
+
203
+ send_response(conn, ctx, resp)
204
+ return True
205
+
206
+
207
+ def get_next_event(conn: h11.Connection, sock: socket.socket) -> Optional[h11.Event]:
208
+ while True:
209
+ event = conn.next_event()
210
+ # We shouldn't ever be paused here because then we didn't properly call start_next_cycle
211
+ assert event is not h11.PAUSED
212
+ if event is h11.NEED_DATA:
213
+ try:
214
+ data = sock.recv(MAX_RECV)
215
+ # `receive_data` sees an empty `bytes` object as EOF, which matches `recv`
216
+ conn.receive_data(data)
217
+ except Exception as e:
218
+ logger.error(f"Socket error: {e}")
219
+ return None
220
+ else:
221
+ # We know it has to be Event in this case
222
+ # Unfortunately the 'sentinel' stuff means the type checker cannot narrow properly
223
+ return cast(h11.Event, event)
224
+
225
+
226
+ def read_request_body(conn: h11.Connection, ctx: ConnectionContext) -> bytes:
227
+ """Throws ValueError if max body size is exceeded."""
228
+ body_parts = []
229
+ total_size = 0
230
+ while True:
231
+ event = get_next_event(conn, ctx.client_socket)
232
+ if event is None:
233
+ break
234
+ if isinstance(event, h11.Data):
235
+ total_size += len(event.data)
236
+ if total_size > ctx.config.max_body_size:
237
+ raise ValueError(f"Exceeded limit {ctx.config.max_body_size}")
238
+ body_parts.append(event.data)
239
+ elif isinstance(event, h11.EndOfMessage):
240
+ break
241
+ return b"".join(body_parts)
242
+
243
+
244
+ def send_error_response(
245
+ conn: h11.Connection, ctx: ConnectionContext, status_code: int, message: str
246
+ ):
247
+ if conn.our_state not in {h11.IDLE, h11.SEND_RESPONSE}:
248
+ return
249
+ body = f"{status_code} Error: {message}\n".encode("utf-8")
250
+ try:
251
+ ctx.client_socket.sendall(
252
+ conn.send(
253
+ h11.Response(
254
+ status_code=status_code,
255
+ headers=[
256
+ (b"Content-Type", b"text/plain; charset=utf-8"),
257
+ (b"Content-Length", str(len(body)).encode("ascii")),
258
+ (b"Connection", b"close"),
259
+ ],
260
+ )
261
+ )
262
+ )
263
+ ctx.client_socket.sendall(conn.send(h11.Data(data=body)))
264
+ ctx.client_socket.sendall(conn.send(h11.EndOfMessage()))
265
+ except Exception as e:
266
+ logger.error(f"Send error: {e}")
267
+
268
+
269
+ def send_response(conn: h11.Connection, ctx: ConnectionContext, response: Response):
270
+ try:
271
+ ctx.client_socket.sendall(
272
+ conn.send(
273
+ h11.Response(status_code=response.status_code, headers=response.headers)
274
+ )
275
+ )
276
+ ctx.client_socket.sendall(conn.send(h11.Data(data=response.body)))
277
+ ctx.client_socket.sendall(conn.send(h11.EndOfMessage()))
278
+ except Exception as e:
279
+ logger.error(f"Send error: {e}")
280
+
281
+
282
+ class UnsetType:
283
+ """Sentinel value indicating that a value has not been set yet."""
284
+
285
+ def __repr__(self):
286
+ return "<UNSET>"
287
+
288
+
289
+ UNSET = UnsetType()
290
+
291
+
292
+ class StorageQueue:
293
+ queue: SimpleQueue
294
+
295
+ def __init__(self):
296
+ self.queue = SimpleQueue()
297
+
298
+ def execute[T](self, procedure: Callable[[storage.Storage], T]) -> T:
299
+ """Execute the given function on the storage thread. Can raise a StorageException, in which case the execution was rolled back."""
300
+ item: QueueItem[T] = QueueItem(procedure=procedure)
301
+ self.queue.put(item)
302
+ item.event.wait()
303
+ # StorageException is caught by the database thread and returned
304
+ if item.exception is not None:
305
+ raise item.exception
306
+ if isinstance(item.to_return, UnsetType):
307
+ raise RuntimeError("Event signaled but value not set")
308
+ return item.to_return
309
+
310
+
311
+ @dataclass
312
+ class QueueItem[T]:
313
+ procedure: Callable[[storage.Storage], T]
314
+ # The `field` is important, since otherwise we share a singel event for all items
315
+ event: threading.Event = field(default_factory=threading.Event)
316
+ to_return: T | UnsetType = UNSET
317
+ exception: storage.StorageError | None = None
318
+
319
+
320
+ def run_store(store_queue: StorageQueue, db_file: str, db_tables: list[str] | None):
321
+ if db_file == ":memory:":
322
+ # Special string that opens a new database in memory
323
+ path = db_file
324
+ else:
325
+ # Get the absolute path of the parent and then rejoin to make sure what we show is what is
326
+ # actually used.
327
+ path = Path(db_file)
328
+ path_parent = path.parent.resolve(strict=True)
329
+ path = path_parent.joinpath(path.parts[-1])
330
+ store = storage.Storage(db_path=path, tables=db_tables)
331
+ logger.info(f"Opened SQLite database at {path}.")
332
+ while True:
333
+ try:
334
+ # We put a timeout just so that we occasionally do something in this thread
335
+ item = store_queue.queue.get(block=True, timeout=0.1)
336
+ except queue.Empty:
337
+ continue
338
+
339
+ if not isinstance(item, QueueItem):
340
+ continue
341
+ store.conn.execute("BEGIN IMMEDIATE")
342
+ try:
343
+ value = item.procedure(store)
344
+ except storage.StorageError as e:
345
+ # Rollback the transaction for recoverable errors
346
+ store.conn.rollback()
347
+ # Return the error to the caller instead of crashing the storage thread
348
+ item.exception = e
349
+ item.event.set()
350
+ continue
351
+ except Exception as e:
352
+ # Still try to rollback if we can
353
+ store.conn.rollback()
354
+ raise e
355
+
356
+ store.conn.commit()
357
+ item.to_return = value
358
+ item.event.set()
359
+
360
+
361
+ def start_server(
362
+ config: ServerConfig,
363
+ handler: Handler,
364
+ ready_event: threading.Event | None = None,
365
+ ):
366
+ """Start the HTTP server and begin accepting connections.
367
+
368
+ Args:
369
+ config: Server configuration options.
370
+ handler: Function called to handle each request and produce a response.
371
+ ready_event: If provided, this event is set once the server is ready to
372
+ accept connections (after socket binding and before the accept loop).
373
+ """
374
+ server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
375
+ server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
376
+ server_socket.bind((config.host, config.port))
377
+ server_socket.listen(config.listen_backlog)
378
+
379
+ logger.info(f"Server listening on {config.host}:{config.port}")
380
+ logger.info(f"Limits: Header={config.max_header_size}, Body={config.max_body_size}")
381
+
382
+ # We run 1 thread that has access to the SQLite database
383
+ # This ensures every operation is atomic
384
+ # Procedures are sent through the `store_queue`
385
+ # `daemon=True` ensures that we do not wait for the thread to finish when exiting
386
+ if config.db_file is not None:
387
+ store_queue = StorageQueue()
388
+ threading.Thread(
389
+ target=run_store,
390
+ args=(store_queue, config.db_file, config.db_tables),
391
+ daemon=True,
392
+ ).start()
393
+ else:
394
+ store_queue = None
395
+
396
+ if ready_event is not None:
397
+ ready_event.set()
398
+
399
+ try:
400
+ # This is the main accept loop, we create a new thread for every new connection
401
+ while True:
402
+ try:
403
+ client, addr = server_socket.accept()
404
+ ctx = ConnectionContext(client, addr, store_queue, config)
405
+ threading.Thread(
406
+ target=handle_client, args=(ctx, handler), daemon=True
407
+ ).start()
408
+ except Exception as e:
409
+ logger.error(f"Accept error: {e}")
410
+ except KeyboardInterrupt:
411
+ pass
412
+ finally:
413
+ server_socket.close()
@@ -0,0 +1,169 @@
1
+ import logging
2
+ import sqlite3
3
+ from os import PathLike
4
+
5
+ logger = logging.getLogger("freetser.storage")
6
+
7
+
8
+ def check_integrity_error(
9
+ e: sqlite3.IntegrityError, column: str, category: str
10
+ ) -> bool:
11
+ """For column 'email' in table 'user', 'user.email' would be the column. For category, at least 'unique' works."""
12
+ assert isinstance(e.args[0], str)
13
+ return column in e.args[0] and category in e.args[0].lower()
14
+
15
+
16
+ class StorageError(Exception):
17
+ """Base exception for storage errors."""
18
+
19
+ pass
20
+
21
+
22
+ class EntryAlreadyExists(StorageError):
23
+ """Raised when trying to add a key that already exists."""
24
+
25
+ pass
26
+
27
+
28
+ class Storage:
29
+ """
30
+ Creates the necessary tables and sets up connections.
31
+ """
32
+
33
+ conn: sqlite3.Connection
34
+
35
+ def __init__(self, db_path: str | PathLike[str], tables: list[str] | None = None):
36
+ # Set persistent settings (WAL mode persists across connections)
37
+ # And create tables
38
+ # We use LEGACY_TRANSACTION_CONTROL so that we can use isolation_leve=None
39
+ # This allows us to manually perform transaction control so there are no transactions
40
+ # implicitly opened and we can avoid "database is locked" errors when readers upgrade to
41
+ # writers.
42
+ conn = sqlite3.connect(
43
+ db_path,
44
+ autocommit=sqlite3.LEGACY_TRANSACTION_CONTROL, # pyright: ignore [reportArgumentType]
45
+ isolation_level=None,
46
+ )
47
+ conn.execute("PRAGMA journal_mode = WAL;")
48
+ conn.execute("PRAGMA synchronous = NORMAL;")
49
+ conn.execute("PRAGMA cache_size = -64000;")
50
+ conn.execute("PRAGMA busy_timeout = 500;")
51
+ conn.commit()
52
+ if tables:
53
+ conn.execute("BEGIN IMMEDIATE")
54
+ for table in tables:
55
+ conn.execute(f"""
56
+ CREATE TABLE IF NOT EXISTS {table} (
57
+ key TEXT PRIMARY KEY,
58
+ counter INTEGER NOT NULL,
59
+ expiration INTEGER NOT NULL,
60
+ value BLOB NOT NULL
61
+ ) STRICT;
62
+ """)
63
+ conn.commit()
64
+
65
+ self.conn = conn
66
+
67
+ def close(self):
68
+ self.conn.close()
69
+
70
+ def get(
71
+ self, table_name: str, key: str, timestamp=None
72
+ ) -> tuple[bytes, int] | None:
73
+ """
74
+ Retrieve a value and its version counter.
75
+ """
76
+ cursor = self.conn.execute(
77
+ f"SELECT value, counter, expiration FROM {table_name} WHERE key = ?", (key,)
78
+ )
79
+ row = cursor.fetchone()
80
+ if row is None:
81
+ return None
82
+
83
+ expiration: int = row[2]
84
+ # If timestamp is provided, the expiration is set to positive and if expired, return None
85
+ if timestamp is not None and expiration > 0 and timestamp > expiration:
86
+ return None
87
+
88
+ return row[0], row[1]
89
+
90
+ def add(
91
+ self, table_name: str, key: str, value: bytes, expires_at=0, timestamp=None
92
+ ) -> None:
93
+ """
94
+ Add a new key-value pair.
95
+ If timestamp is provided and an entry exists but is expired, overwrite it.
96
+ """
97
+
98
+ # Validate that we're not adding an already-expired entry
99
+ if timestamp is not None and expires_at > 0 and timestamp > expires_at:
100
+ raise ValueError(
101
+ f"Cannot add entry that is already expired: expires_at={expires_at} < timestamp={timestamp}"
102
+ )
103
+
104
+ if timestamp is None:
105
+ # Simple insert without expiration check
106
+ try:
107
+ self.conn.execute(
108
+ f"INSERT INTO {table_name} (key, value, counter, expiration) VALUES (?, ?, 0, ?)",
109
+ (key, value, expires_at),
110
+ )
111
+ except sqlite3.IntegrityError as e:
112
+ self.conn.rollback()
113
+ if check_integrity_error(e, f"{table_name}.key", "unique"):
114
+ raise EntryAlreadyExists(f"Key already exists: {key}") from e
115
+ raise e
116
+ else:
117
+ # Insert with expiration check - allow overwriting expired entries
118
+ cursor = self.conn.execute(
119
+ f"""
120
+ INSERT INTO {table_name} (key, value, counter, expiration)
121
+ VALUES (?, ?, 0, ?)
122
+ ON CONFLICT(key) DO UPDATE
123
+ SET value = excluded.value,
124
+ counter = 0,
125
+ expiration = excluded.expiration
126
+ WHERE expiration > 0 AND ? > expiration
127
+ RETURNING key
128
+ """,
129
+ (key, value, expires_at, timestamp),
130
+ )
131
+ if cursor.fetchone() is None:
132
+ # Conflict occurred but WHERE clause prevented update (key exists and not expired)
133
+ raise EntryAlreadyExists(f"Key already exists: {key}")
134
+
135
+ def update(
136
+ self,
137
+ table_name: str,
138
+ key: str,
139
+ value: bytes,
140
+ counter: int,
141
+ expires_at=0,
142
+ assert_updated: bool = True,
143
+ ) -> bool:
144
+ cursor = self.conn.execute(
145
+ f"""
146
+ UPDATE {table_name}
147
+ SET value = ?, counter = counter + 1, expiration = ?
148
+ WHERE key = ? AND counter = ?
149
+ """,
150
+ (value, expires_at, key, counter),
151
+ )
152
+ result = cursor.rowcount == 1
153
+ if assert_updated:
154
+ assert result
155
+ return result
156
+
157
+ def delete(self, table_name: str, key: str) -> bool:
158
+ cursor = self.conn.execute(f"DELETE FROM {table_name} WHERE key = ?", (key,))
159
+ return cursor.rowcount == 1
160
+
161
+ def list_keys(self, table_name: str) -> list[str]:
162
+ cursor = self.conn.execute(f"SELECT key FROM {table_name}")
163
+ result = [row[0] for row in cursor.fetchall()]
164
+ return result
165
+
166
+ def clear(self, table_name: str) -> None:
167
+ """Remove all entries from the table."""
168
+
169
+ self.conn.execute(f"DELETE FROM {table_name}")