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.
- freetser-0.1.0/PKG-INFO +113 -0
- freetser-0.1.0/README.md +103 -0
- freetser-0.1.0/pyproject.toml +36 -0
- freetser-0.1.0/src/freetser/__init__.py +23 -0
- freetser-0.1.0/src/freetser/server.py +413 -0
- freetser-0.1.0/src/freetser/storage.py +169 -0
freetser-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
freetser-0.1.0/README.md
ADDED
|
@@ -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}")
|