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,
    ServerConfig,
    StorageQueue,
    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 = ServerConfig(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, ServerConfig

# 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 = ServerConfig(port=8000)
start_server(config, handler, store_queue=store_queue)

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.2.0.tar.gz (10.2 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.2.0-py3-none-any.whl (10.9 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: freetser-0.2.0.tar.gz
  • Upload date:
  • Size: 10.2 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.2.0.tar.gz
Algorithm Hash digest
SHA256 82fafdf3270e7889d533e3282e086a2d39658d2272122abbc441c48a2a78d38f
MD5 63fb3ad0d1578723766ac8074e75d7e5
BLAKE2b-256 c42d7a7f8a816effb375060fb18c1c097baa2ae81ee330d27de6f0006bf04666

See more details on using hashes here.

File details

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

File metadata

  • Download URL: freetser-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 10.9 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.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 26dfa26ae7d9472a474b409b7035f9d10b7e9f939043cd4b439946968fb6992f
MD5 d2aae980ff378d658f46301664ae771c
BLAKE2b-256 d8c808c418c0ef9ac0ea247e998365c2a06d41a35ac6d17a46143522811aec9f

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