Skip to content

Latest commit

 

History

History
415 lines (323 loc) · 12.9 KB

File metadata and controls

415 lines (323 loc) · 12.9 KB

Web Framework Integration (ASGI/WSGI)

This guide covers the optimized ASGI and WSGI modules for integrating Python web frameworks with erlang_python.

Overview

The py_asgi and py_wsgi modules provide high-performance request handling by using optimized C-level marshalling between Erlang and Python. These modules bypass the generic py:call() path with specialized NIFs that:

  • Pre-intern keys - Python string keys are interned once at startup, eliminating per-request string allocation and hashing overhead
  • Cache constants - Common values like HTTP methods, versions, and schemes are reused across requests
  • Pool responses - Thread-local response pooling reduces memory allocation during request processing
  • Direct NIF path - Specialized NIF functions avoid the overhead of generic Python call marshalling

Performance

Compared to generic py:call()-based handling:

Optimization ASGI WSGI
Interned keys +15-20% +15-20%
Response pooling +20-25% N/A
Direct NIF +25-30% +25-30%
Total ~60-80% ~60-80%

ASGI NIF Optimizations

The ASGI module includes six additional NIF-level optimizations:

Optimization Improvement Description
Direct Response Extraction 5-10% Extract (status, headers, body) directly to Erlang terms
Pre-Interned Headers 3-5% 16 common HTTP headers cached as PyBytes
Cached Status Codes 1-2% 14 common status codes cached as PyLong
Zero-Copy Body 10-15% Large bodies (≥1KB) use buffer protocol
Scope Template Caching 15-20% Thread-local cache of 64 scope templates
Lazy Header Conversion 5-10% Headers converted on-demand (≥4 headers)

Total expected improvement: 40-60% for typical ASGI workloads on top of the base optimizations.

These optimizations are automatic and require no code changes.

ASGI Support

Basic Usage

%% Build ASGI scope from your HTTP server (e.g., Cowboy)
Scope = #{
    type => <<"http">>,
    http_version => <<"1.1">>,
    method => <<"GET">>,
    scheme => <<"http">>,
    path => <<"/api/users">>,
    query_string => <<"id=123">>,
    headers => [[<<"host">>, <<"localhost:8080">>]],
    server => {<<"localhost">>, 8080},
    client => {<<"127.0.0.1">>, 54321}
},

%% Execute ASGI application
case py_asgi:run(<<"myapp">>, <<"application">>, Scope, Body) of
    {ok, {Status, Headers, ResponseBody}} ->
        %% Send response
        cowboy_req:reply(Status, Headers, ResponseBody, Req);
    {error, Reason} ->
        %% Handle error
        cowboy_req:reply(500, #{}, <<"Internal Server Error">>, Req)
end.

API Reference

py_asgi:run/4

-spec run(Module, Callable, Scope, Body) -> Result when
    Module :: binary(),
    Callable :: binary(),
    Scope :: scope(),
    Body :: binary(),
    Result :: {ok, {integer(), [{binary(), binary()}], binary()}} | {error, term()}.

Execute an ASGI application with default options.

  • Module - Python module containing the ASGI application (e.g., <<"myapp">>)
  • Callable - Name of the ASGI callable (typically <<"application">> or <<"app">>)
  • Scope - ASGI scope map (see Scope Fields below)
  • Body - Request body as binary

py_asgi:run/5

-spec run(Module, Callable, Scope, Body, Opts) -> Result when
    Module :: binary(),
    Callable :: binary(),
    Scope :: scope(),
    Body :: binary(),
    Opts :: map(),
    Result :: {ok, {integer(), [{binary(), binary()}], binary()}} | {error, term()}.

Execute an ASGI application with options.

Options:

  • runner - Custom Python runner module (default: <<"hornbeam_asgi_runner">>)

py_asgi:build_scope/1,2

-spec build_scope(Scope) -> {ok, reference()} | {error, term()}.
-spec build_scope(Scope, Opts) -> {ok, reference()} | {error, term()}.

Build an optimized Python scope dict with interned keys. The returned reference can be passed to multiple ASGI calls for further optimization when handling many requests with similar scopes.

Scope Fields

Field Type Required Description
type binary Yes Request type (<<"http">> or <<"websocket">>)
asgi map No ASGI version info (default: #{<<"version">> => <<"3.0">>})
http_version binary No HTTP version (<<"1.0">>, <<"1.1">>, <<"2">>)
method binary No HTTP method (<<"GET">>, <<"POST">>, etc.)
scheme binary No URL scheme (<<"http">> or <<"https">>)
path binary Yes Request path
raw_path binary No Raw path (defaults to path)
query_string binary No Query string without leading ?
root_path binary No Root path for mounted apps
headers list No List of [Name, Value] header pairs
server tuple No Server {Host, Port} tuple
client tuple No Client {Host, Port} tuple
state map No Request state dict
extensions map No ASGI extensions

WSGI Support

Basic Usage

%% Build WSGI environ from your HTTP server
Environ = #{
    <<"REQUEST_METHOD">> => <<"GET">>,
    <<"SCRIPT_NAME">> => <<>>,
    <<"PATH_INFO">> => <<"/api/users">>,
    <<"QUERY_STRING">> => <<"id=123">>,
    <<"SERVER_NAME">> => <<"localhost">>,
    <<"SERVER_PORT">> => <<"8080">>,
    <<"SERVER_PROTOCOL">> => <<"HTTP/1.1">>,
    <<"wsgi.url_scheme">> => <<"http">>,
    <<"wsgi.input">> => Body
},

%% Execute WSGI application
case py_wsgi:run(<<"myapp">>, <<"application">>, Environ) of
    {ok, {Status, Headers, ResponseBody}} ->
        %% Parse status line (e.g., "200 OK")
        StatusCode = parse_status(Status),
        cowboy_req:reply(StatusCode, Headers, ResponseBody, Req);
    {error, Reason} ->
        cowboy_req:reply(500, #{}, <<"Internal Server Error">>, Req)
end.

API Reference

py_wsgi:run/3

-spec run(Module, Callable, Environ) -> Result when
    Module :: binary(),
    Callable :: binary(),
    Environ :: environ(),
    Result :: {ok, {binary(), [{binary(), binary()}], binary()}} | {error, term()}.

Execute a WSGI application with default options.

  • Module - Python module containing the WSGI application
  • Callable - Name of the WSGI callable
  • Environ - WSGI environ map (see Environ Fields below)

Note: WSGI returns the status as a binary string (e.g., <<"200 OK">>), not an integer.

py_wsgi:run/4

-spec run(Module, Callable, Environ, Opts) -> Result when
    Module :: binary(),
    Callable :: binary(),
    Environ :: environ(),
    Opts :: map(),
    Result :: {ok, {binary(), [{binary(), binary()}], binary()}} | {error, term()}.

Execute a WSGI application with options.

Options:

  • runner - Custom Python runner module (default: <<"hornbeam_wsgi_runner">>)

Environ Fields

Field Type Required Description
<<"REQUEST_METHOD">> binary Yes HTTP method
<<"SCRIPT_NAME">> binary Yes Initial portion of URL path (can be empty)
<<"PATH_INFO">> binary Yes Remainder of URL path
<<"QUERY_STRING">> binary No Query string without leading ?
<<"CONTENT_TYPE">> binary No Content-Type header value
<<"CONTENT_LENGTH">> binary No Content-Length header value
<<"SERVER_NAME">> binary Yes Server hostname
<<"SERVER_PORT">> binary Yes Server port as string
<<"SERVER_PROTOCOL">> binary Yes Protocol version (e.g., <<"HTTP/1.1">>)
<<"wsgi.version">> tuple No WSGI version tuple (default: {1, 0})
<<"wsgi.url_scheme">> binary Yes URL scheme (<<"http">> or <<"https">>)
<<"wsgi.input">> binary Yes Request body
<<"wsgi.errors">> any No Error stream
<<"wsgi.multithread">> boolean No Default: true
<<"wsgi.multiprocess">> boolean No Default: true
<<"wsgi.run_once">> boolean No Default: false
<<"HTTP_*">> binary No HTTP headers with HTTP_ prefix

Custom Runner Modules

Both ASGI and WSGI support custom runner modules for advanced use cases.

ASGI Runner

# custom_asgi_runner.py
async def run_asgi(app, scope, body):
    """
    Custom ASGI runner.

    Args:
        app: The ASGI application callable
        scope: ASGI scope dict
        body: Request body bytes

    Returns:
        Tuple of (status_code, headers, body)
    """
    # Custom pre-processing
    scope['state']['custom_key'] = 'value'

    # Call the ASGI app
    response = await default_asgi_handler(app, scope, body)

    # Custom post-processing
    return response
%% Use custom runner
py_asgi:run(<<"myapp">>, <<"app">>, Scope, Body, #{
    runner => <<"custom_asgi_runner">>
}).

WSGI Runner

# custom_wsgi_runner.py
def run_wsgi(app, environ):
    """
    Custom WSGI runner.

    Args:
        app: The WSGI application callable
        environ: WSGI environ dict

    Returns:
        Tuple of (status, headers, body)
    """
    # Custom pre-processing
    environ['custom.key'] = 'value'

    # Call the WSGI app
    response = default_wsgi_handler(app, environ)

    # Custom post-processing
    return response
%% Use custom runner
py_wsgi:run(<<"myapp">>, <<"app">>, Environ, #{
    runner => <<"custom_wsgi_runner">>
}).

Sub-interpreter and Free-threading Support

Both py_asgi and py_wsgi fully support Python's sub-interpreter and free-threading modes:

Sub-interpreters (Python 3.12+)

Each request can run in an isolated sub-interpreter, providing:

  • Isolated global state between requests
  • No GIL contention between interpreters
  • True parallelism for CPU-bound Python code

Free-threading (Python 3.13+)

With Python 3.13's experimental free-threading build:

  • No Global Interpreter Lock (GIL)
  • True multi-threaded parallelism
  • Best performance for concurrent requests

The modules automatically detect and use the optimal mode based on your Python installation.

Integration Examples

Cowboy Integration

-module(my_asgi_handler).
-behaviour(cowboy_handler).

-export([init/2]).

init(Req0, State) ->
    Method = cowboy_req:method(Req0),
    Path = cowboy_req:path(Req0),
    QS = cowboy_req:qs(Req0),
    Headers = cowboy_req:headers(Req0),
    {ok, Body, Req1} = cowboy_req:read_body(Req0),

    Scope = #{
        type => <<"http">>,
        method => Method,
        path => Path,
        query_string => QS,
        headers => maps:to_list(Headers),
        scheme => <<"http">>
    },

    case py_asgi:run(<<"myapp">>, <<"app">>, Scope, Body) of
        {ok, {Status, RespHeaders, RespBody}} ->
            Req = cowboy_req:reply(Status, maps:from_list(RespHeaders), RespBody, Req1),
            {ok, Req, State};
        {error, _Reason} ->
            Req = cowboy_req:reply(500, #{}, <<"Internal Server Error">>, Req1),
            {ok, Req, State}
    end.

Elli Integration

-module(my_wsgi_handler).
-behaviour(elli_handler).

-export([handle/2, handle_event/3]).

handle(Req, _Args) ->
    Environ = #{
        <<"REQUEST_METHOD">> => elli_request:method(Req),
        <<"PATH_INFO">> => elli_request:path(Req),
        <<"QUERY_STRING">> => elli_request:query_str(Req),
        <<"SERVER_NAME">> => <<"localhost">>,
        <<"SERVER_PORT">> => <<"8080">>,
        <<"SERVER_PROTOCOL">> => <<"HTTP/1.1">>,
        <<"wsgi.url_scheme">> => <<"http">>,
        <<"wsgi.input">> => elli_request:body(Req)
    },

    case py_wsgi:run(<<"myapp">>, <<"application">>, Environ) of
        {ok, {Status, Headers, Body}} ->
            StatusCode = parse_wsgi_status(Status),
            {StatusCode, Headers, Body};
        {error, _} ->
            {500, [], <<"Internal Server Error">>}
    end.

handle_event(_, _, _) -> ok.

parse_wsgi_status(Status) ->
    [CodeBin | _] = binary:split(Status, <<" ">>),
    binary_to_integer(CodeBin).

Performance Tips

Use erlang_asyncio for Sleep Operations

For ASGI handlers that use await asyncio.sleep(), consider using erlang_asyncio.sleep() instead. This eliminates Python event loop overhead (~0.5-1ms per call) by using Erlang's native timer system:

# In your ASGI application
import erlang_asyncio

async def delay_handler(scope, receive, send):
    # More efficient than asyncio.sleep()
    await erlang_asyncio.sleep(0.001)  # 1ms delay

    await send({
        'type': 'http.response.start',
        'status': 200,
        'headers': [[b'content-type', b'text/plain']],
    })
    await send({
        'type': 'http.response.body',
        'body': b'OK',
    })

For endpoints with short delays (1-10ms), this can improve throughput by 2-3x. See Asyncio for the full API.

See Also