Skip to main content

A free-threaded HTTP server built on top of h11

Project description

freetser

freetser is a free-threaded HTTP/1.1 server, 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.

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.

Architecture

  • Thread-per-connection (no thread pool), no async
  • Uses a pure Python HTTP/1.1 library (h11)
  • socket from the standard library for the actual TCP reading/writing
  • Single SQLite database thread (using sqlite3 from the standard library), other threads simply send Python functions through a queue for it to execute

Quick start

Ensure you are using a free-threaded build of Python, see documentation here. If using uv, which I highly recommend, you could use uv python pin 3.14t (where the t stands for the free-threaded version).

Then, install freetser from PyPI with uv add freetser.

Finally, create a Python script (e.g. main.py) with the following code:

import logging

from freetser import (
    Request,
    Response,
    StorageQueue,
    TcpServerConfig,
    setup_logging,
    start_server,
)

logger = logging.getLogger("freetser.handler")

def handler(req: Request, store_queue: StorageQueue | None) -> Response:
    if req.path == "/":
        return Response.text("Hello world!")

    return Response.text("Not found!", status_code=404)


def main():
    listener = setup_logging()
    listener.start()

    config = TcpServerConfig(port=8000)
    try:
        start_server(config, handler)
    except KeyboardInterrupt:
        print("\nShutting down...")
    finally:
        listener.stop()


if __name__ == "__main__":
    main()

Then if you do uv run main.py, it will start the server on the default port of 8000.

Using the built-in storage

freetser provides a built-in key-value (KV) store built on top of SQLite. It is not designed for speed, but for simplicity. First, start the storage thread before starting the server:

from freetser import start_storage_thread, start_server, TcpServerConfig

# Start the storage thread (do this before starting the server)
store_queue = start_storage_thread(db_file="mydb.sqlite", db_tables=["USERS"])

# Pass the queue to the server
config = TcpServerConfig(port=8000)
start_server(config, handler, store_queue=store_queue)

You can also use a Unix domain socket instead of TCP:

from freetser import start_server, UnixServerConfig

config = UnixServerConfig(path="/tmp/myserver.sock")
start_server(config, handler)

Then, define a database routine:

def get_or_create(store: Storage) -> str:
    key = "my_user_email"
    result = store.get("USERS", key)
    if result is None:
        value = b"my.user@freetser.com"
        store.add("USERS", key, value, 0)
        return f"Created: {key}\n"
    else:
        value, counter = result
        return f"Found: {key}, counter={counter}\n"

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.

This has some important consequences:

  • 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.
  • 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).
  • 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.

For more examples, see main.py in the tests directory or check out the tests themselves.

Development

To run the linters and type checker:

uv run ruff check      # Linting
uv run ruff format     # Code formatting
uv run basedpyright    # Type checking

To run the tests, first start the test server in the background, then run pytest:

uv run python tests/main.py &   # Start test server (optionally pass a port number)
uv run pytest tests/ -v         # Run tests

Benchmarks

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.

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.

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.

Benchmark machine specs: Intel Core Ultra 7 155H (22 vCPUs), Linux 6.17, 32GB LPDDR5x-7467 memory

Background

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.

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.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

freetser-0.3.0.tar.gz (10.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

freetser-0.3.0-py3-none-any.whl (11.2 kB view details)

Uploaded Python 3

File details

Details for the file freetser-0.3.0.tar.gz.

File metadata

  • Download URL: freetser-0.3.0.tar.gz
  • Upload date:
  • Size: 10.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.9.11 {"installer":{"name":"uv","version":"0.9.11"},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for freetser-0.3.0.tar.gz
Algorithm Hash digest
SHA256 f77c56622369c1b615a302c7e35edbef5df2c6b6f2da23428062ad582608c62d
MD5 316f00221fd88b18c00b45d36d5a6d7e
BLAKE2b-256 19998b47909beec58a649140513f9e8806307d781ab3e4a23c8a998ece5cff7a

See more details on using hashes here.

File details

Details for the file freetser-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: freetser-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 11.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.9.11 {"installer":{"name":"uv","version":"0.9.11"},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for freetser-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 3fe9c96a120170517063d7ce0a4c466a32f4616e689c0f32d50bcbf7e70e6419
MD5 6de16fc53625d0fed56929ffa3cc7275
BLAKE2b-256 3abbd36af5aa2ca7fbef112ad311720037718a477f99e362118b9871dd53f9c9

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page