Skip to content

Non-ASGI protocols (raw TCP)

Most BlackBull traffic is HTTP/1.1, HTTP/2, or WebSocket — protocols that map cleanly onto the ASGI (scope, receive, send) contract. Some protocols do not: MQTT, Redis RESP, a raw line protocol, the PostgreSQL wire protocol. They have no method, path, status, or headers, so forcing them through ASGI would be a leaky abstraction.

The Non-ASGI bridge lets such a protocol run inside the same BlackBull process, sharing the connection machinery and the event system, while speaking the socket directly. You register an async handler with raw_handler and give it a port:

from blackbull import BlackBull

app = BlackBull()

@app.route(path='/')
async def index():
    return "HTTP here; raw TCP echo on :9000."

@app.raw_handler('echo', port=9000)
async def echo(reader, writer, ctx):
    while True:
        data = await reader.read(1024)
        if not data:
            break
        await writer.write(data)

app.run(port=8000)   # HTTP on 8000, echo on 9000

A full runnable version is in examples/echo_tcp.py.

The handler contract

A raw handler is an async callable (reader, writer, ctx) that owns the connection for its entire lifetime — it decides when to stop. This is deliberately unlike HTTP's request-per-connection model; raw protocols are typically long-lived and stateful.

  • reader is an AbstractReader: await reader.read(n), await reader.readexactly(n), await reader.readuntil(sep).
  • writer is an AbstractWriter: await writer.write(data), await writer.close().
  • ctx is a ProtocolContext carrying peername, sockname, ssl, connection_id (an opaque hex string, unique per connection, for log correlation), protocol (the registered name), and aggregator (for emitting events — see below).

When the handler returns, or raises, BlackBull closes the connection. A raised exception is reported through the error event and otherwise isolated — one bad connection never affects another.

register_protocol_handler(name, handler, *, port=...) is the non-decorator form of raw_handler and behaves identically.

How dispatch works

BlackBull routes every accepted connection through a single protocol registry. http1 and http2 are built-in registry bindings on the shared HTTP listener; a raw_handler adds a binding on its own dedicated port. Connections arriving on a raw protocol's port skip HTTP detection entirely and go straight to the handler. WebSocket is unaffected — it is still reached through an HTTP Upgrade on the HTTP listener, not as a top-level protocol.

The bridge is dormant by default: an app with no raw_handler binds only the HTTP port and runs no raw code paths.

Protocol ownership

HTTP-family wire handling stays in BlackBull's core. Non-HTTP protocols register through extensions and raw handlers; do not force them into ASGI request/response events. gRPC already runs over the shared HTTP/2 transport; HTTP/3 is unsupported.

Events

Raw protocols emit the protocol-agnostic Level B events (subscribe with @app.on):

Event Fires when Detail
connection_accepted a connection is accepted peername, protocol
connection_closed the connection closes peername, protocol, duration_ms
message_received the handler reports an inbound message protocol, message_type, payload_size
message_sent the handler reports an outbound message protocol, message_type, payload_size
error the handler raises scope, exception

message_received / message_sent are emitted by the handler itself when it has parsed an application-level message, via the aggregator on ctx:

if ctx.aggregator is not None:
    await ctx.aggregator.on_message_received('echo', 'data', len(data))

connection_accepted and connection_closed fire automatically.

State, and who answers an exchange

A raw protocol is stateful by default: stateful=True says that an exchange can depend on what an earlier one left behind, which is true of a broker, a session-oriented line protocol, and most things worth writing a raw handler for. It decides who may answer:

  • Its own port is served by one worker. app.run(port=8000, workers=4) with a raw_handler scales HTTP across all four workers and runs the raw protocol on one; if that worker crashes it is respawned and re-adopts the listener. The other workers close the descriptor at startup, so nothing else can answer on it even by accident.
  • The shared port is given up once workers multiply. A binding with a detector is also reachable on the HTTP port by first-byte sniffing — but that port is served by every worker, so a later exchange would be answered by a worker that never saw the earlier one. With workers > 1 a stateful binding stops claiming there and stays reachable on its own port. With one worker there is nothing to scatter across, and both routes work.
  • A stateful protocol with no port of its own is refused, before the workers fork, because there is no way to give it one owner. Give it a port, run workers=1, or say stateful=False if it really keeps nothing.
@app.raw_handler('metrics', detector=LineDetector(), stateful=False)
async def metrics(reader, writer, ctx):
    ...        # each exchange stands alone, so any worker may answer it

Limitations

  • TLS on a raw_handler port is the server's certificate. tls=True serves the binding's port through the certificate the server was configured with; the binding cannot name one of its own. An HTTP listener can.
  • --reload pins workers=1 when a port-bound protocol is registered: the exec handoff carries the HTTP sockets only.

A note on the server class

The server class that drives all this is blackbull.server.Server (it serves both ASGI and non-ASGI listeners). Its previous name, ASGIServer, remains as a backward-compatible alias, so existing from blackbull.server import ASGIServer imports keep working.