<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: TOKUJI</title>
    <description>The latest articles on DEV Community by TOKUJI (@tokuji_30).</description>
    <link>https://dev.to/tokuji_30</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3988942%2F54c0c60b-e8a4-4908-9347-2a58d37564ba.png</url>
      <title>DEV Community: TOKUJI</title>
      <link>https://dev.to/tokuji_30</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tokuji_30"/>
    <language>en</language>
    <item>
      <title>Does a from-scratch async server actually hold up? 16,384 Slowloris connections and a 2-hour soak</title>
      <dc:creator>TOKUJI</dc:creator>
      <pubDate>Mon, 20 Jul 2026 12:50:59 +0000</pubDate>
      <link>https://dev.to/tokuji_30/does-a-from-scratch-async-server-actually-hold-up-16384-slowloris-connections-and-a-2-hour-soak-38fk</link>
      <guid>https://dev.to/tokuji_30/does-a-from-scratch-async-server-actually-hold-up-16384-slowloris-connections-and-a-2-hour-soak-38fk</guid>
      <description>&lt;p&gt;It's one thing for a from-scratch server to pass a conformance suite on a good day. It's another for it to survive a client that opens sixteen thousand connections and then just… stops talking. Writing your own HTTP server is a great way to learn protocols; it's also a great way to discover, in production, that you never handled the slow-client case. So before I trust &lt;a href="https://github.com/TOKUJI/BlackBull" rel="noopener noreferrer"&gt;BlackBull&lt;/a&gt; — my pure-Python protocol framework — with anything, I wanted two boring numbers: does it stay responsive under a &lt;strong&gt;Slowloris&lt;/strong&gt; attack, and does it leak over a &lt;strong&gt;long soak&lt;/strong&gt;? Here's how I measured both, and what came back.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Part of my ongoing &lt;a href="https://github.com/TOKUJI/BlackBull" rel="noopener noreferrer"&gt;BlackBull&lt;/a&gt; series. Earlier posts cover the HTTP/2 and multi-protocol internals; this one is purely about robustness.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 1: Slowloris — the attack of doing nothing, slowly
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://en.wikipedia.org/wiki/Slowloris_(cyber_attack)" rel="noopener noreferrer"&gt;Slowloris&lt;/a&gt; is the laziest denial of service there is. You don't flood the server; you open a connection, send &lt;em&gt;part&lt;/em&gt; of a request — a request line and one header — and then dribble a byte every few seconds, forever. Each half-open request ties up a slot. Open enough of them and a naive server runs out of room to accept anyone real. There's no bandwidth spike to alarm on; the server is simply, quietly, full.&lt;/p&gt;

&lt;p&gt;The defence is a &lt;strong&gt;deadline&lt;/strong&gt;. BlackBull gives every request a header-completion budget (&lt;code&gt;BB_HEADER_TIMEOUT&lt;/code&gt;, 10 s by default): if the request headers aren't finished in time, the connection is reaped with a &lt;code&gt;408&lt;/code&gt; before it ever reaches a handler or occupies a request task. That's the design. The question is whether it &lt;em&gt;works&lt;/em&gt; — and, more usefully, what the curve looks like as the attacker scales up.&lt;/p&gt;

&lt;p&gt;So I wrote a characterisation that holds &lt;strong&gt;N&lt;/strong&gt; Slowloris connections open — each having sent a partial request head and nothing more — and then, while they're held, fires a burst of ordinary, legitimate requests and measures how long each one takes to be accepted and served. Sweep N from 0 to 16,384. A healthy server keeps serving real traffic with flat latency; a vulnerable one climbs, then starts timing out. On a clean &lt;code&gt;c7i.2xlarge&lt;/code&gt;, 200 legitimate probes per point:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Slowloris connections held&lt;/th&gt;
&lt;th&gt;legitimate p99&lt;/th&gt;
&lt;th&gt;failures&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;4.2 ms&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,024&lt;/td&gt;
&lt;td&gt;2.9 ms&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4,096&lt;/td&gt;
&lt;td&gt;3.1 ms&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8,192&lt;/td&gt;
&lt;td&gt;3.1 ms&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;16,384&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.0 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Fresh-connection latency is &lt;strong&gt;flat at ~3 ms all the way to 16,384 held connections, with zero failures across a thousand probes.&lt;/strong&gt; The curve has no knee. The deadline reaper frees each slow slot before it can crowd the accept path, so the slow connections never occupy a request task — they cost a socket and nothing more.&lt;/p&gt;

&lt;p&gt;The nice part is that the server &lt;em&gt;tells&lt;/em&gt; you it's happening. With logging at INFO, each reaped connection prints exactly why:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;408 Request Timeout (slowloris defence) — peer=('10.0.0.7', 41288)
    sent 23 bytes in 10.0s without completing headers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the mechanism the number is measuring, made visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 2: the soak — leaks don't show up in five seconds
&lt;/h2&gt;

&lt;p&gt;A throughput benchmark runs for 30 seconds and tells you nothing about a slow resource leak. File descriptors that never close, actor inboxes that grow unbounded, a buffer that's retained one request too long — these only show up over &lt;em&gt;hours&lt;/em&gt;, and only if you're watching the right counters. So the second test is a &lt;strong&gt;soak&lt;/strong&gt;: a fixed, moderate load held for a long time, with the process sampled the whole way through.&lt;/p&gt;

&lt;p&gt;Two hours, 256 concurrent connections, mixed traffic (plaintext, JSON, a 1 KiB body), sampling RSS, file descriptors, connection count, and &lt;code&gt;tracemalloc&lt;/code&gt; totals every 60 seconds. The pass/fail question is simple: after the initial warm-up, does anything &lt;em&gt;trend&lt;/em&gt;?&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;metric&lt;/th&gt;
&lt;th&gt;start&lt;/th&gt;
&lt;th&gt;end&lt;/th&gt;
&lt;th&gt;2nd-half drift&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VmRSS&lt;/td&gt;
&lt;td&gt;48 MB&lt;/td&gt;
&lt;td&gt;78 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+0.1 %&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tracemalloc.current&lt;/td&gt;
&lt;td&gt;9.1 MB&lt;/td&gt;
&lt;td&gt;6.1 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−33 %&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;established connections&lt;/td&gt;
&lt;td&gt;256&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;open file descriptors&lt;/td&gt;
&lt;td&gt;265&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Reading it: RSS climbs once during warm-up (workers fork, buffers and steady-state structures allocate) and then &lt;strong&gt;plateaus&lt;/strong&gt; — the drift across the &lt;em&gt;second&lt;/em&gt; hour is +0.1 %, i.e. flat. Python-tracked allocations actually trend &lt;em&gt;down&lt;/em&gt; over the run, the opposite of a leak. And when the load stops, connections drain to zero and file descriptors fall back to eight — everything the run acquired, it gave back. Verdict: &lt;strong&gt;leak-free plateau.&lt;/strong&gt; 120 samples with no trend is a much stronger statement than a clean 30-second window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it holds: the actor model does the bookkeeping
&lt;/h2&gt;

&lt;p&gt;Neither result is an accident of tuning; both fall out of how connections are structured. Every connection is owned by a &lt;code&gt;ConnectionActor&lt;/code&gt; with its own lifecycle, and the deadline subsystem is a single scanner that reaps connections whose header/body/idle budgets have expired. There's no per-connection timer to leak and no shared lock to contend on — a slow or idle connection is just an entry the scanner will collect. When a connection ends, its actor and everything it owns are torn down together, which is why the file-descriptor count returns exactly to baseline.&lt;/p&gt;

&lt;p&gt;The honest caveats, because a single run is a single run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not a security audit.&lt;/strong&gt; This is a resilience &lt;em&gt;characterisation&lt;/em&gt;, not adversarial red-teaming. Real hostile testing covers many more shapes — HTTP/2 &lt;code&gt;RST_STREAM&lt;/code&gt; floods, malformed-frame fuzzing, header-table attacks. This measures one well-known attack and one stability question.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One soak, moderate load.&lt;/strong&gt; Two hours at 256 connections is enough to catch an obvious leak; it isn't days at saturation. A genuinely slow leak, or one that only appears under a specific protocol mix, could still be hiding. It's a floor, not a proof.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single-host numbers.&lt;/strong&gt; The absolute latencies include loopback and co-tenancy; treat the &lt;em&gt;shape&lt;/em&gt; (flat, no trend) as the result, not the millisecond values.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The Slowloris characterisation and the soak harness are both in the repo, and both are self-contained — the Slowloris tool even boots its own server, so it's a one-liner against a clean box:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;blackbull
git clone https://github.com/TOKUJI/BlackBull &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;BlackBull

&lt;span class="c"&gt;# Slowloris: sweep held-connection counts, measure legitimate-request latency&lt;/span&gt;
python bench/hostile_repro/characterize_slowloris.py &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--sweep&lt;/span&gt; 0,1024,4096,8192,16384 &lt;span class="nt"&gt;--probes&lt;/span&gt; 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point it at your own server if you like — the interesting question isn't whether BlackBull holds up, it's whether &lt;em&gt;yours&lt;/em&gt; does. If your framework doesn't enforce a header-completion deadline, the curve above is the one to go looking for.&lt;/p&gt;

&lt;p&gt;Source, the harnesses, and the full numbers are at &lt;a href="https://github.com/TOKUJI/BlackBull" rel="noopener noreferrer"&gt;github.com/TOKUJI/BlackBull&lt;/a&gt;. The &lt;a href="https://dev.to/tokuji_30"&gt;rest of the series&lt;/a&gt; covers how the pure-Python HTTP/2 and multi-protocol layers underneath actually work.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;BlackBull v0.57.0, 2026-07-18.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>http</category>
      <category>learning</category>
    </item>
    <item>
      <title>gRPC Without grpcio: How BlackBull Serves All Four RPC Shapes in Pure Python</title>
      <dc:creator>TOKUJI</dc:creator>
      <pubDate>Tue, 14 Jul 2026 15:23:13 +0000</pubDate>
      <link>https://dev.to/tokuji_30/grpc-without-grpcio-how-blackbull-serves-all-four-rpc-shapes-in-pure-python-1da</link>
      <guid>https://dev.to/tokuji_30/grpc-without-grpcio-how-blackbull-serves-all-four-rpc-shapes-in-pure-python-1da</guid>
      <description>&lt;p&gt;If you have ever wanted to serve a gRPC endpoint from Python without compiling &lt;code&gt;grpcio&lt;/code&gt;'s C++ core — or wondered what gRPC actually looks like on the wire — this post is for you. It introduces &lt;strong&gt;BlackBull&lt;/strong&gt;, a pure-Python ASGI framework I've been building, and walks through how its gRPC support works, what it's good for, and — just as importantly — what it's &lt;em&gt;not&lt;/em&gt; for. By the end you'll have a server that a stock &lt;code&gt;grpcurl&lt;/code&gt; can call: &lt;code&gt;pip install&lt;/code&gt;, one file, &lt;strong&gt;no certificates, no &lt;code&gt;protoc&lt;/code&gt;, no config&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(This continues my earlier series: &lt;a href="https://dev.to/tokuji_30/blackbull-goes-multi-protocol-part-1-first-it-had-to-forget-it-was-http-1bf9"&gt;BlackBull goes multi-protocol&lt;/a&gt; and &lt;a href="https://dev.to/tokuji_30/how-a-from-scratch-http2-server-actually-works-part-1-connection-to-first-request-4e9p"&gt;How a from-scratch HTTP/2 server actually works&lt;/a&gt;. You don't need either to follow along.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What is BlackBull?
&lt;/h2&gt;

&lt;p&gt;BlackBull is a &lt;strong&gt;pure-Python ASGI 3.0 web framework&lt;/strong&gt; that implements HTTP/1.1, HTTP/2, and WebSocket at the protocol level — its own frame parsing, its own HPACK, its own flow control. No C extensions, no wrapping a foreign HTTP library. A few things distinguish it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero-ceremony deploy.&lt;/strong&gt; &lt;code&gt;app.run()&lt;/code&gt; is the whole story. There is no separate ASGI runner to pick, no &lt;code&gt;gunicorn -k some.worker.Class&lt;/code&gt;, no YAML. For a static site, &lt;code&gt;blackbull serve ./public&lt;/code&gt; gives you ETag handling and HTTP/2 in one command.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every byte is debuggable.&lt;/strong&gt; Because there are no C extensions, you can set a breakpoint anywhere — inside HPACK header decoding, inside the flow-control window accounting — and step through it with &lt;code&gt;pdb&lt;/code&gt;. If you've ever tried to figure out why a server sent &lt;code&gt;RST_STREAM&lt;/code&gt;, you know why this matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-protocol, one process.&lt;/strong&gt; HTTP/1.1, HTTP/2, WebSocket, gRPC, and MQTT 5.0 are all served from a single &lt;code&gt;python app.py&lt;/code&gt;. Not five servers behind a reverse proxy — one process, and for the HTTP/2-based protocols, one port.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actor-model internals.&lt;/strong&gt; Each connection is owned by a &lt;code&gt;ConnectionActor&lt;/code&gt; that spawns per-connection protocol actors, each with its own inbox loop. Isolation between connections comes from the actor structure, not from shared locks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RFC-grade conformance.&lt;/strong&gt; The HTTP/2 stack passes &lt;code&gt;h2spec&lt;/code&gt;, the WebSocket stack passes the Autobahn test suite, and HTTP behaviour is checked by a differential oracle against nginx.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Typed throughout&lt;/strong&gt; (PEP 561), and — unusually — &lt;strong&gt;programmable fault injection&lt;/strong&gt;: the same protocol code can be told to misbehave on command, so you can use BlackBull in CI to test how &lt;em&gt;your&lt;/em&gt; HTTP clients handle a server that sends malformed frames or stalls mid-response.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One honest disclosure up front: BlackBull is a &lt;strong&gt;personal learning project&lt;/strong&gt;. Correctness over the wire matters more than API stability, and the versioning is ZeroVer (&lt;code&gt;0.MINOR.PATCH&lt;/code&gt; — v0.53.3 as of this writing). If you need a stability guarantee, that's a real consideration. If you want to understand or test protocols, it's arguably a feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  gRPC is just HTTP/2 (mostly)
&lt;/h2&gt;

&lt;p&gt;Here is the observation that makes BlackBull's gRPC support possible: &lt;strong&gt;gRPC is not a separate transport.&lt;/strong&gt; It is HTTP/2 with a few extra rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Requests are &lt;code&gt;POST /package.Service/Method&lt;/code&gt; with &lt;code&gt;content-type: application/grpc&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Each message on the wire is &lt;strong&gt;length-prefixed&lt;/strong&gt;: 1 byte of compression flag, 4 bytes of big-endian length, then the payload.&lt;/li&gt;
&lt;li&gt;The status code (&lt;code&gt;grpc-status&lt;/code&gt;) is delivered in HTTP/2 &lt;strong&gt;trailing headers&lt;/strong&gt; after the last message.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's most of it. If you already have a complete HTTP/2 implementation — frames, HPACK, flow control, trailers — then gRPC is a thin layer on top. And BlackBull does, so its entire gRPC layer is about &lt;strong&gt;six files&lt;/strong&gt; in &lt;code&gt;blackbull/grpc/&lt;/code&gt;: the registry, the codec (that 5-byte prefix), compression, status codes, and the bridge to the framework. No new protocol actor, no second listening socket. A gRPC call is just another HTTP/2 stream to the connection layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  The simplest possible gRPC server
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;blackbull&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BlackBull&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;blackbull.grpc&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;GrpcServiceRegistry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;GrpcStatus&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BlackBull&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;grpc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GrpcServiceRegistry&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="nd"&gt;@grpc.method&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/echo.Echo/Echo&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;echo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GrpcStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INVALID_ARGUMENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;empty request message&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;


&lt;span class="c1"&gt;# Server-streaming: an async generator that yields response messages.
&lt;/span&gt;&lt;span class="nd"&gt;@grpc.method&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/echo.Echo/Split&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;


&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enable_grpc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;grpc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nd"&gt;@app.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;REST here; gRPC service echo.Echo on the same port.&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;


&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;50051&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# cleartext HTTP/2 — add certfile=/keyfile= for TLS
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install with &lt;code&gt;pip install 'blackbull[grpc]'&lt;/code&gt; and run the file. That's it — note what's &lt;em&gt;missing&lt;/em&gt;: no certificate step, no &lt;code&gt;protoc&lt;/code&gt;, no config. gRPC normally rides on TLS + ALPN, but BlackBull also accepts &lt;strong&gt;cleartext HTTP/2&lt;/strong&gt; (h2c, prior knowledge), so any stock gRPC client in plaintext mode — &lt;code&gt;grpcurl -plaintext&lt;/code&gt;, &lt;code&gt;ghz --insecure&lt;/code&gt;, a &lt;code&gt;grpcio&lt;/code&gt; &lt;code&gt;insecure_channel&lt;/code&gt; — can call &lt;code&gt;/echo.Echo/Echo&lt;/code&gt; on &lt;code&gt;localhost:50051&lt;/code&gt; immediately. (For a production-like setup, pass &lt;code&gt;certfile=&lt;/code&gt;/&lt;code&gt;keyfile=&lt;/code&gt; and the same code serves TLS + ALPN.) The &lt;code&gt;/&lt;/code&gt; route in the same file is not decoration: &lt;strong&gt;REST, WebSocket, and gRPC genuinely share one port.&lt;/strong&gt; BlackBull dispatches on the &lt;code&gt;content-type&lt;/code&gt;, so there is no separate gRPC server process to run or proxy to.&lt;/p&gt;

&lt;h3&gt;
  
  
  All four RPC shapes, detected from the signature
&lt;/h3&gt;

&lt;p&gt;gRPC defines four call shapes, and BlackBull supports all of them. You never declare which one a handler is — the registry infers it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unary&lt;/strong&gt;: &lt;code&gt;async def handler(request: bytes, context) -&amp;gt; bytes&lt;/code&gt; — one message in, one out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server-streaming&lt;/strong&gt;: write the handler as an &lt;strong&gt;async generator&lt;/strong&gt; and &lt;code&gt;yield&lt;/code&gt; each response message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client-streaming&lt;/strong&gt;: take a &lt;code&gt;request_iter&lt;/code&gt; parameter instead of &lt;code&gt;request&lt;/code&gt; — an async iterator of incoming messages — and return one response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bidirectional&lt;/strong&gt;: &lt;code&gt;request_iter&lt;/code&gt; in, async generator out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Error handling goes through the context: &lt;code&gt;context.abort(GrpcStatus.NOT_FOUND, 'no such thing')&lt;/code&gt; ends the call with a proper status in the trailers, and &lt;code&gt;context.metadata(name)&lt;/code&gt; reads call metadata (request headers). An unexpected exception in a handler is isolated and reported as &lt;code&gt;INTERNAL&lt;/code&gt; — a handler bug never takes down the connection or its sibling streams.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bring your own protobuf
&lt;/h3&gt;

&lt;p&gt;Notice what the example above &lt;strong&gt;doesn't&lt;/strong&gt; import: protobuf. BlackBull's gRPC handlers exchange &lt;strong&gt;raw &lt;code&gt;bytes&lt;/code&gt;&lt;/strong&gt;. The framework handles the HTTP/2 and gRPC framing; message serialisation is entirely yours. Use &lt;code&gt;grpc_tools.protoc&lt;/code&gt;-generated classes, the &lt;code&gt;protobuf&lt;/code&gt; package directly, or hand-roll the encoding — the handler contract is the same:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@grpc.method&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/helloworld.Greeter/SayHello&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;say_hello&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;helloworld_pb2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HelloRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FromString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;reply&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;helloworld_pb2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HelloReply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Hello, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;!&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SerializeToString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a deliberate layering decision, not a missing feature. gRPC-the-protocol and protobuf-the-format are separable, and keeping them separate means the core has no codegen step and no protobuf dependency.&lt;/p&gt;

&lt;p&gt;When you &lt;em&gt;do&lt;/em&gt; want the batteries, &lt;code&gt;pip install 'blackbull[protobuf]'&lt;/code&gt; adds a separate integration layer: &lt;strong&gt;servicer classes&lt;/strong&gt; (register a whole service from generated stubs), &lt;strong&gt;server reflection&lt;/strong&gt; (&lt;code&gt;grpc.reflection.v1alpha&lt;/code&gt; — so &lt;code&gt;grpcurl&lt;/code&gt; and Postman can discover and call your service with no local &lt;code&gt;.proto&lt;/code&gt; file), &lt;strong&gt;health checking&lt;/strong&gt; (&lt;code&gt;grpc.health.v1&lt;/code&gt;, the protocol Kubernetes probes and load balancers expect), and &lt;strong&gt;rich error details&lt;/strong&gt; delivered in &lt;code&gt;grpc-status-details-bin&lt;/code&gt; trailers.&lt;/p&gt;

&lt;p&gt;The repo's &lt;code&gt;examples/grpc_greeter.py&lt;/code&gt; sits nicely in the middle: it implements the canonical &lt;code&gt;helloworld.Greeter&lt;/code&gt; service with real protobuf on the wire, but hand-rolls the (trivial) encoding of its two single-field messages in ~20 lines — so it needs no &lt;code&gt;protoc&lt;/code&gt; step, yet a stock &lt;code&gt;grpcurl&lt;/code&gt; or &lt;code&gt;grpcio&lt;/code&gt; client using the standard &lt;code&gt;helloworld.proto&lt;/code&gt; talks to it unmodified.&lt;/p&gt;

&lt;h3&gt;
  
  
  The parts you'd only notice if they were missing
&lt;/h3&gt;

&lt;p&gt;A few implementation details are worth calling out, because they're the difference between "speaks the protocol" and "usable":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Write-coalescing for server-streaming.&lt;/strong&gt; A naive implementation sends one DATA frame per yielded message, paying a full trip through the event loop's sender each time. BlackBull batches consecutive small messages into a single DATA frame — valid on the wire, since gRPC clients frame on the 5-byte length prefix, not on DATA frame boundaries. For streams of thousands of small messages this removes almost all of the per-message overhead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Message size cap.&lt;/strong&gt; Incoming messages are capped at 4 MiB by default — the same default as &lt;code&gt;grpcio&lt;/code&gt; — so a misbehaving client can't buffer you into the ground.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deadline enforcement.&lt;/strong&gt; The &lt;code&gt;grpc-timeout&lt;/code&gt; request header is honoured; an expired call is terminated with &lt;code&gt;DEADLINE_EXCEEDED&lt;/code&gt;, including mid-stream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compression.&lt;/strong&gt; gzip message compression is supported, with a minimum-size threshold so that compressing tiny messages doesn't make them &lt;em&gt;larger&lt;/em&gt; (gzip has fixed overhead that swamps small payloads).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And it's not just self-certified: the CI pipeline includes a &lt;code&gt;grpc-interop&lt;/code&gt; job that runs &lt;strong&gt;real &lt;code&gt;grpcio&lt;/code&gt; clients&lt;/strong&gt; against BlackBull over h2c sockets — success paths, every error status, streaming, and concurrent multiplexed calls. Interop with the reference implementation is tested on every push, not assumed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who is this for?
&lt;/h2&gt;

&lt;p&gt;Honest positioning time. BlackBull + gRPC is a good fit if you are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Avoiding the native stack.&lt;/strong&gt; &lt;code&gt;grpcio&lt;/code&gt; ships a large C++ core. If you're on an unusual platform or ARM board where wheels are scarce, or you just don't want a compiled dependency in the way, a pure-Python server that stock gRPC clients can talk to is genuinely useful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Building IoT or edge gateways.&lt;/strong&gt; One Python process serving HTTP for a dashboard, gRPC for device RPCs, and MQTT 5.0 for telemetry — on constrained hardware, collapsing three servers into one process is the whole value proposition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Building protocol-level test tooling.&lt;/strong&gt; Because the fault-injection hooks reach the protocol layer, you can stand up a gRPC server in CI that misbehaves &lt;em&gt;on command&lt;/em&gt; — truncated messages, stalled streams, hostile frames — and verify your client's error handling actually works. That's very hard to do with a production-grade server that's engineered never to misbehave.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Learning how gRPC actually works.&lt;/strong&gt; You can put a breakpoint in the HPACK decoder and step through an entire gRPC call — header decompression, length-prefixed message framing, trailer delivery — in &lt;code&gt;pdb&lt;/code&gt;. No other stack I know of lets you do that in pure Python end to end.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And it is &lt;strong&gt;not&lt;/strong&gt; for you if you need the full gRPC ecosystem today: client-side load balancing, xDS, gRPC-Web proxying. BlackBull is a server-side framework that speaks the gRPC wire protocol well; it is not a gRPC platform, and it isn't trying to be one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it (60 seconds, no certificates)
&lt;/h2&gt;

&lt;p&gt;The library comes from PyPI; the runnable examples live in the repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s1"&gt;'blackbull[grpc]'&lt;/span&gt;       &lt;span class="c"&gt;# core gRPC (raw bytes)&lt;/span&gt;
git clone https://github.com/TOKUJI/BlackBull &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;BlackBull

python examples/grpc_greeter.py &lt;span class="nt"&gt;--port&lt;/span&gt; 50051   &lt;span class="c"&gt;# the canonical helloworld.Greeter&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No cert step — this serves gRPC over cleartext HTTP/2 on &lt;code&gt;localhost:50051&lt;/code&gt;. To call it with &lt;code&gt;grpcurl&lt;/code&gt;, save the canonical proto as &lt;code&gt;helloworld.proto&lt;/code&gt; so it knows how to encode the request (the core install deliberately has no reflection — that ships in the &lt;code&gt;[protobuf]&lt;/code&gt; extra):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight protobuf"&gt;&lt;code&gt;&lt;span class="na"&gt;syntax&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"proto3"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;package&lt;/span&gt; &lt;span class="nn"&gt;helloworld&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;service&lt;/span&gt; &lt;span class="n"&gt;Greeter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;rpc&lt;/span&gt; &lt;span class="n"&gt;SayHello&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HelloRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HelloReply&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;rpc&lt;/span&gt; &lt;span class="n"&gt;SayManyHellos&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HelloRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stream&lt;/span&gt; &lt;span class="n"&gt;HelloReply&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;message&lt;/span&gt; &lt;span class="nc"&gt;HelloRequest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;message&lt;/span&gt; &lt;span class="nc"&gt;HelloReply&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="kd"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;grpcurl &lt;span class="nt"&gt;-plaintext&lt;/span&gt; &lt;span class="nt"&gt;-proto&lt;/span&gt; helloworld.proto &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"name": "BlackBull"}'&lt;/span&gt; localhost:50051 helloworld.Greeter/SayHello
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hello, BlackBull!"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a stock gRPC client talking to a pure-Python server, end to end. Call &lt;code&gt;SayManyHellos&lt;/code&gt; instead to watch server-streaming deliver three messages; &lt;code&gt;curl http://localhost:50051/&lt;/code&gt; hits the REST route on the same port. The raw-bytes echo server from earlier in this post is &lt;code&gt;examples/grpc_server.py&lt;/code&gt;, and &lt;code&gt;pip install 'blackbull[protobuf]'&lt;/code&gt; adds the servicer/reflection/health layer.&lt;/p&gt;

&lt;p&gt;For a TLS setup (what you'd deploy), generate a dev cert and add &lt;code&gt;--cert&lt;/code&gt;/&lt;code&gt;--key&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl req &lt;span class="nt"&gt;-x509&lt;/span&gt; &lt;span class="nt"&gt;-newkey&lt;/span&gt; rsa:2048 &lt;span class="nt"&gt;-keyout&lt;/span&gt; key.pem &lt;span class="nt"&gt;-out&lt;/span&gt; cert.pem &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-days&lt;/span&gt; 365 &lt;span class="nt"&gt;-nodes&lt;/span&gt; &lt;span class="nt"&gt;-subj&lt;/span&gt; &lt;span class="s1"&gt;'/CN=localhost'&lt;/span&gt;
python examples/grpc_greeter.py &lt;span class="nt"&gt;--port&lt;/span&gt; 8443 &lt;span class="nt"&gt;--cert&lt;/span&gt; cert.pem &lt;span class="nt"&gt;--key&lt;/span&gt; key.pem
grpcurl &lt;span class="nt"&gt;-insecure&lt;/span&gt; &lt;span class="nt"&gt;-proto&lt;/span&gt; helloworld.proto &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"name": "BlackBull"}'&lt;/span&gt; localhost:8443 helloworld.Greeter/SayHello
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source, examples, and docs are at &lt;a href="https://github.com/TOKUJI/BlackBull" rel="noopener noreferrer"&gt;github.com/TOKUJI/BlackBull&lt;/a&gt; — and if the idea of one debuggable Python process speaking five protocols appeals to you, the &lt;a href="https://dev.to/tokuji_30"&gt;earlier series&lt;/a&gt; digs into how the HTTP/2 and multi-protocol layers underneath all this actually work.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;BlackBull v0.53.3, 2026-07-14.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>grpc</category>
      <category>webdev</category>
      <category>http</category>
    </item>
    <item>
      <title>BlackBull goes multi-protocol (part 3) — one `app.py`, two protocols</title>
      <dc:creator>TOKUJI</dc:creator>
      <pubDate>Wed, 01 Jul 2026 17:22:36 +0000</pubDate>
      <link>https://dev.to/tokuji_30/blackbull-goes-multi-protocol-part-3-one-apppy-two-protocols-3gep</link>
      <guid>https://dev.to/tokuji_30/blackbull-goes-multi-protocol-part-3-one-apppy-two-protocols-3gep</guid>
      <description>&lt;p&gt;The first two posts were the engineering: &lt;a href="https://dev.to/tokuji_30/blackbull-goes-multi-protocol-part-1-first-it-had-to-forget-it-was-http-1bf9"&gt;part 1&lt;/a&gt; rebuilt the connection&lt;br&gt;
dispatcher so BlackBull's core stopped assuming HTTP, and &lt;a href="https://dev.to/tokuji_30/blackbull-goes-multi-protocol-part-2-an-mqtt-5-broker-with-no-locks-and-a-single-owner-lep"&gt;part 2&lt;/a&gt; put a lock-free,&lt;br&gt;
single-owner MQTT 5 broker on top of that seam. This post is the payoff — what&lt;br&gt;
all of it looks like from an application author's chair.&lt;/p&gt;

&lt;p&gt;The short version: one file, one &lt;code&gt;pip install&lt;/code&gt;, two protocols.&lt;/p&gt;


&lt;h2&gt;
  
  
  One decorator for HTTP, one for MQTT
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;blackbull&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BlackBull&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;blackbull.mqtt&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;MQTTExtension&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BlackBull&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;mqtt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_extension&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MQTTExtension&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1883&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="nd"&gt;@app.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HTTP here; MQTT broker on :1883.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="nd"&gt;@mqtt.on_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topic&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sensors/{room}/temperature&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_temperature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;room&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;room&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# {room} captured like a path param
&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# HTTP on 8000, MQTT on 1883
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;mqtt.on_message&lt;/code&gt; deliberately mirrors &lt;code&gt;app.route&lt;/code&gt;: both decorate an async&lt;br&gt;
function, both match on an address pattern, and &lt;code&gt;{room}&lt;/code&gt; captures a topic level&lt;br&gt;
the same way &lt;code&gt;{task_id}&lt;/code&gt; captures a URL path segment. The difference is&lt;br&gt;
semantics — an HTTP route &lt;strong&gt;is&lt;/strong&gt; the response; an MQTT tap &lt;strong&gt;observes&lt;/strong&gt; the&lt;br&gt;
broker's routing, which delivers to subscribers whether or not any handler is&lt;br&gt;
registered.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;MQTTExtension&lt;/code&gt; registers itself through the single extension seam&lt;br&gt;
(&lt;code&gt;app.add_extension&lt;/code&gt;) and binds &lt;code&gt;:1883&lt;/code&gt;. The core &lt;code&gt;BlackBull&lt;/code&gt; class carries zero&lt;br&gt;
MQTT-specific code — the broker lives entirely in &lt;code&gt;blackbull.mqtt&lt;/code&gt;.&lt;/p&gt;


&lt;h2&gt;
  
  
  Drive it with the tools you already have
&lt;/h2&gt;

&lt;p&gt;It's a real MQTT 5 broker — CONNECT / SUBSCRIBE / PUBLISH at QoS 0–2, retained&lt;br&gt;
messages, and Last-Will — so standard clients just work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mosquitto_sub &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s1"&gt;'sensors/#'&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 1883 &lt;span class="nt"&gt;-V&lt;/span&gt; 5            &lt;span class="c"&gt;# terminal 1&lt;/span&gt;
mosquitto_pub &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s1"&gt;'sensors/room1/temperature'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s1"&gt;'21.5'&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 1883 &lt;span class="nt"&gt;-V&lt;/span&gt; 5                            &lt;span class="c"&gt;# terminal 2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The message appears in the subscriber's terminal and fires the &lt;code&gt;on_temperature&lt;/code&gt;&lt;br&gt;
handler above. One process. No &lt;code&gt;apt install mosquitto&lt;/code&gt;, no broker sidecar, no C&lt;br&gt;
extension.&lt;/p&gt;


&lt;h2&gt;
  
  
  When HTTP and MQTT share a process
&lt;/h2&gt;

&lt;p&gt;The real reason to put two protocols in one process isn't cleaner ops — it's&lt;br&gt;
&lt;strong&gt;shared memory without a sidecar.&lt;/strong&gt; Here's a tiny temperature dashboard that&lt;br&gt;
would normally need a broker, a web server, &lt;em&gt;and&lt;/em&gt; a Redis instance to bridge&lt;br&gt;
them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;blackbull&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BlackBull&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;blackbull.mqtt&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;MQTTExtension&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BlackBull&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;mqtt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_extension&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MQTTExtension&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1883&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;# Plain dict — no locks, no Redis, no cross-process serialization.
&lt;/span&gt;&lt;span class="n"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="nd"&gt;@mqtt.on_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topic&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sensors/{room}/temperature&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ingest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;room&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;room&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@app.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/sensors&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;dashboard&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;latest&lt;/span&gt;   &lt;span class="c1"&gt;# {"room1": 21.5, "room2": 30.1}
&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A sensor publishes &lt;code&gt;21.5&lt;/code&gt; to &lt;code&gt;sensors/room1/temperature&lt;/code&gt; via MQTT. The broker&lt;br&gt;
routes it to subscribers &lt;em&gt;and&lt;/em&gt; fires &lt;code&gt;ingest&lt;/code&gt;, which updates the dictionary. A&lt;br&gt;
browser hits &lt;code&gt;/sensors&lt;/code&gt; and sees the current state as JSON. One variable, two&lt;br&gt;
protocols, zero glue infrastructure.&lt;/p&gt;

&lt;p&gt;This is what "multi-protocol" buys you in practice: the broker and the web&lt;br&gt;
server live in the same memory space, so the bridge between them is just a&lt;br&gt;
function call that writes to a dict. No &lt;code&gt;redis-py&lt;/code&gt;, no &lt;code&gt;pika&lt;/code&gt;, no&lt;br&gt;
serialization — just Python.&lt;/p&gt;


&lt;h2&gt;
  
  
  Machine-readable docs for both protocols
&lt;/h2&gt;

&lt;p&gt;BlackBull already auto-generates an OpenAPI 3.1 document from your route&lt;br&gt;
signatures:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enable_openapi&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;        &lt;span class="c1"&gt;# publishes /openapi.json and /docs
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;OpenAPI is an HTTP request/response vocabulary, though — it has nothing to say&lt;br&gt;
about topics and publish/subscribe. The messaging world's counterpart is&lt;br&gt;
&lt;strong&gt;AsyncAPI&lt;/strong&gt;, and BlackBull ships the counterpart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;blackbull.mqtt&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AsyncAPIExtension&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_extension&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AsyncAPIExtension&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Sensor Gateway&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1.0.0&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After &lt;code&gt;app.run()&lt;/code&gt; the document is served at &lt;code&gt;/asyncapi.json&lt;/code&gt;, with a CDN-hosted&lt;br&gt;
HTML viewer at &lt;code&gt;/asyncapi&lt;/code&gt; (no new Python dependency). Each &lt;code&gt;on_message&lt;/code&gt; topic&lt;br&gt;
filter becomes a channel; each callback a &lt;code&gt;receive&lt;/code&gt; operation. It's generated&lt;br&gt;
lazily, so taps registered after the extension are still documented. Your HTTP&lt;br&gt;
surface describes itself with OpenAPI; your MQTT surface describes itself with&lt;br&gt;
AsyncAPI; same idea, right vocabulary for each.&lt;/p&gt;




&lt;h2&gt;
  
  
  The whole arc
&lt;/h2&gt;

&lt;p&gt;Start to finish, that's the story:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;BlackBull &lt;strong&gt;decided&lt;/strong&gt; to be a multi-protocol server.&lt;/li&gt;
&lt;li&gt;It &lt;strong&gt;prepared the ground&lt;/strong&gt; — a protocol-agnostic dispatcher (peek-and-replay),
built behind a regression oracle so the live HTTP server broke nothing.&lt;/li&gt;
&lt;li&gt;It &lt;strong&gt;built on that ground&lt;/strong&gt; — a lock-free, single-owner MQTT 5 broker on the
actor model, with HTTP throughput actually a little higher than before.&lt;/li&gt;
&lt;li&gt;And it stayed &lt;strong&gt;one &lt;code&gt;app.py&lt;/code&gt;&lt;/strong&gt;: HTTP and MQTT side by side, sharing memory,
each self-documenting — no sidecars, no C extensions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What's next
&lt;/h3&gt;

&lt;p&gt;MQTT is the first consumer of the multi-protocol seam, not the last.&lt;br&gt;
&lt;code&gt;@app.raw_handler&lt;/code&gt; already lets you bind raw TCP to a port and handle the&lt;br&gt;
reader/writer yourself for custom protocols. A first-class gRPC handler — riding&lt;br&gt;
the existing HTTP/2 stream, flow-control, and trailers machinery — is the&lt;br&gt;
natural next step. Same pattern: register a &lt;code&gt;ProtocolBinding&lt;/code&gt;, let the core&lt;br&gt;
detect and dispatch. The core won't have to learn a thing about gRPC. That's&lt;br&gt;
the whole point.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s1"&gt;'blackbull[mqtt]'&lt;/span&gt;
python examples/mqtt_broker.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/TOKUJI/BlackBull" rel="noopener noreferrer"&gt;github.com/TOKUJI/BlackBull&lt;/a&gt;&lt;br&gt;
Docs: &lt;a href="https://tokuji.github.io/BlackBull/" rel="noopener noreferrer"&gt;tokuji.github.io/BlackBull&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>mqtt</category>
      <category>http</category>
      <category>python</category>
    </item>
    <item>
      <title>BlackBull goes multi-protocol (part 2) — an MQTT 5 broker with no locks and a single owner</title>
      <dc:creator>TOKUJI</dc:creator>
      <pubDate>Sun, 28 Jun 2026 11:24:24 +0000</pubDate>
      <link>https://dev.to/tokuji_30/blackbull-goes-multi-protocol-part-2-an-mqtt-5-broker-with-no-locks-and-a-single-owner-lep</link>
      <guid>https://dev.to/tokuji_30/blackbull-goes-multi-protocol-part-2-an-mqtt-5-broker-with-no-locks-and-a-single-owner-lep</guid>
      <description>&lt;p&gt;&lt;a href="https://dev.to/tokuji_30/blackbull-goes-multi-protocol-part-1-first-it-had-to-forget-it-was-http-1bf9"&gt;Part 1&lt;/a&gt; rebuilt BlackBull's connection dispatcher so BlackBull's core no longer&lt;br&gt;
assumes every connection is HTTP. With that seam in place, a non-HTTP protocol&lt;br&gt;
can register a binding and be served like any other. This post is the first real&lt;br&gt;
consumer of that seam: a &lt;strong&gt;full MQTT 5.0 publish/subscribe broker&lt;/strong&gt;, running in&lt;br&gt;
the same process as your HTTP routes, on the standard &lt;code&gt;1883&lt;/code&gt; port.&lt;/p&gt;

&lt;p&gt;If you haven't crossed paths with MQTT: it's a lightweight&lt;br&gt;
publish/subscribe protocol that powers most IoT sensor-to-cloud communication.&lt;br&gt;
Clients publish messages to &lt;strong&gt;topics&lt;/strong&gt; (like &lt;code&gt;sensors/room1/temperature&lt;/code&gt;), and&lt;br&gt;
a broker routes them to every client that subscribed to a matching pattern.&lt;br&gt;
No polling, no request/response pairing — a sensor just publishes and moves on.&lt;br&gt;
It's simple on the outside; building the broker that routes those messages&lt;br&gt;
correctly under concurrency is where it gets interesting.&lt;/p&gt;

&lt;p&gt;A broker is the hard case for concurrency. Subscriptions, sessions, retained&lt;br&gt;
messages, and Last-Will templates are all shared mutable state, touched by every&lt;br&gt;
connection at once. The obvious implementation reaches for locks. BlackBull's&lt;br&gt;
reaches for the actor model — and the result needs to own that state safely in&lt;br&gt;
&lt;strong&gt;two&lt;/strong&gt; dimensions: &lt;em&gt;within&lt;/em&gt; a worker process, and &lt;em&gt;across&lt;/em&gt; several of them.&lt;/p&gt;

&lt;p&gt;---&lt;/p&gt;

&lt;h2&gt;
  
  
  Dimension one: three guarantees inside a worker
&lt;/h2&gt;

&lt;p&gt;The actor model gives the broker three things that a lock-based design would&lt;br&gt;
have to build by hand: &lt;strong&gt;shared state without locks&lt;/strong&gt;, &lt;strong&gt;trivial lifetime&lt;br&gt;
management&lt;/strong&gt;, and &lt;strong&gt;backpressure isolation&lt;/strong&gt;. All three fall out of the same&lt;br&gt;
rule — one actor owns the state, no one else touches it.&lt;/p&gt;

&lt;h3&gt;
  
  
  No locks on shared state
&lt;/h3&gt;

&lt;p&gt;Inside a single process the broker is three kinds of actor, each in its own&lt;br&gt;
module:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Actor&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;Owns&lt;/th&gt;
&lt;th&gt;Inbox carries&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BrokerActor&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1 per process&lt;/td&gt;
&lt;td&gt;all routing / session / retained state&lt;/td&gt;
&lt;td&gt;client control events&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MQTT5Actor&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1 per connection&lt;/td&gt;
&lt;td&gt;one socket's write side&lt;/td&gt;
&lt;td&gt;outbound packets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TapActor&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1 per process&lt;/td&gt;
&lt;td&gt;nothing (stateless dispatch)&lt;/td&gt;
&lt;td&gt;published messages for &lt;code&gt;on\_message&lt;/code&gt; taps&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The load-bearing property: &lt;strong&gt;&lt;code&gt;BrokerActor&lt;/code&gt; processes its inbox serially.&lt;/strong&gt; Two&lt;br&gt;
PUBLISH packets from two different connections are handled one after another, never&lt;br&gt;
concurrently. So the routing table and the session dictionaries are plain Python&lt;br&gt;
objects — &lt;strong&gt;no locks, no &lt;code&gt;asyncio.Lock&lt;/code&gt;, no shared mutable state reached from two&lt;br&gt;
tasks at once.&lt;/strong&gt; That is the core reason to use the actor model here: serial&lt;br&gt;
inbox processing turns "shared mutable state" into "state owned by exactly one&lt;br&gt;
actor," and the need for locks evaporates.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;**Actor model vs locks — when does each win?**&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Actor (serial inbox)&lt;/th&gt;
&lt;th&gt;Lock (&lt;code&gt;asyncio.Lock&lt;/code&gt;)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Many writers, one shared state&lt;/td&gt;
&lt;td&gt;**Wins.** Messages queue; throughput stays flat regardless of writer count.&lt;/td&gt;
&lt;td&gt;Contention grows with writers; throughput degrades.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Few writers, mostly reads&lt;/td&gt;
&lt;td&gt;Message dispatch overhead per access — paid even when uncontested.&lt;/td&gt;
&lt;td&gt;**Wins.** Fast acquire/release; reads often lock-free with &lt;code&gt;asyncio&lt;/code&gt; patterns.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parallelism within the state owner&lt;/td&gt;
&lt;td&gt;Can't. One actor = one task = one core.&lt;/td&gt;
&lt;td&gt;**Wins.** Fine-grained locking lets multiple cores work on different parts.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debugging&lt;/td&gt;
&lt;td&gt;Harder. Bugs span actor boundaries; stack traces don't cross &lt;code&gt;send()&lt;/code&gt; calls.&lt;/td&gt;
&lt;td&gt;**Wins.** &lt;code&gt;PYTHONASYNCIODEBUG=1&lt;/code&gt; surfaces slow locks; deadlock detection built in.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deadlock risk&lt;/td&gt;
&lt;td&gt;Low, but not zero. Circular &lt;code&gt;send()&lt;/code&gt; between two actors each awaiting the other's reply can deadlock.&lt;/td&gt;
&lt;td&gt;**Real risk.** Two locks acquired in different order → classic deadlock.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error isolation&lt;/td&gt;
&lt;td&gt;**Wins.** A crashed actor doesn't corrupt its peers' state.&lt;/td&gt;
&lt;td&gt;One task holding a lock that crashes can poison the lock (needs &lt;code&gt;try/finally&lt;/code&gt; discipline).&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A broker hits the first row on every connection: many writers converging on one&lt;br&gt;
routing table. That's why the actor model fits here — not because it's always&lt;br&gt;
better, but because the access pattern aligns with what actors are good at.&lt;br&gt;
For a different workload (say, a connection pool with mostly reads), a lock&lt;br&gt;
may well be the simpler, faster choice.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;MQTT5Actor&lt;/code&gt; is the &lt;strong&gt;sole writer&lt;/strong&gt; to its socket. Its &lt;code&gt;run()&lt;/code&gt; loop drains an&lt;br&gt;
inbox of outbound packets; a sibling reader task decodes the wire and &lt;code&gt;send&lt;/code&gt;s&lt;br&gt;
control messages to the &lt;code&gt;BrokerActor&lt;/code&gt;. Even replies the connection could answer by&lt;br&gt;
itself — PINGRESP, AUTH — are routed back through its &lt;em&gt;own&lt;/em&gt; inbox, so &lt;code&gt;run()&lt;/code&gt;&lt;br&gt;
stays the only thing that ever touches the socket's write side. No cross-task&lt;br&gt;
write races, by construction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Last-Will delivery is free with actors
&lt;/h3&gt;

&lt;p&gt;MQTT's Last-Will-and-Testament is a message the broker delivers on behalf of a&lt;br&gt;
client that disconnects uncleanly — "if I disappear, tell everyone X." The&lt;br&gt;
hitch: by the time the broker notices the disconnect, the client's connection&lt;br&gt;
actor is already tearing itself down. Who owns the Will at that moment?&lt;/p&gt;

&lt;p&gt;Because &lt;code&gt;BrokerActor&lt;/code&gt; outlives every connection, the answer is straightforward:&lt;br&gt;
the dying &lt;code&gt;MQTT5Actor&lt;/code&gt; hands the Will template to &lt;code&gt;BrokerActor&lt;/code&gt; during&lt;br&gt;
teardown, and &lt;code&gt;BrokerActor&lt;/code&gt; routes it to live subscribers. No global registry&lt;br&gt;
kept alive just for cleanup, no cross-actor lifetime coupling — the long-lived&lt;br&gt;
broker makes Will delivery a trivial handoff between two actors that already&lt;br&gt;
exist.&lt;/p&gt;

&lt;h3&gt;
  
  
  Slow observers can't stall the broker
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;@mqtt.on\_message&lt;/code&gt; handlers — application taps that &lt;em&gt;observe&lt;/em&gt; broker traffic —&lt;br&gt;
run on a separate plane via &lt;code&gt;TapActor&lt;/code&gt;, which consumes a &lt;strong&gt;bounded&lt;/strong&gt; inbox. The&lt;br&gt;
connection &lt;em&gt;offers&lt;/em&gt; each published message without blocking and returns&lt;br&gt;
immediately. If taps fall behind, the newest message is dropped and a running&lt;br&gt;
dropped-count is logged (silent loss is the one unacceptable outcome, so the&lt;br&gt;
count is always surfaced). A slow tap back-pressures &lt;strong&gt;nothing&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here is a controlled side-by-side — one build, one machine, one session, with the&lt;br&gt;
dispatch mode (&lt;code&gt;inline&lt;/code&gt; vs &lt;code&gt;actor&lt;/code&gt;) as the only variable, tap queue depth 256:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;tap delay&lt;/th&gt;
&lt;th&gt;inline throughput&lt;/th&gt;
&lt;th&gt;actor throughput&lt;/th&gt;
&lt;th&gt;inline p99&lt;/th&gt;
&lt;th&gt;actor p99&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;61,063/s&lt;/td&gt;
&lt;td&gt;52,529/s&lt;/td&gt;
&lt;td&gt;5.64 ms&lt;/td&gt;
&lt;td&gt;6.59 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 ms&lt;/td&gt;
&lt;td&gt;881/s&lt;/td&gt;
&lt;td&gt;52,835/s&lt;/td&gt;
&lt;td&gt;448.66 ms&lt;/td&gt;
&lt;td&gt;6.31 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 ms&lt;/td&gt;
&lt;td&gt;193/s&lt;/td&gt;
&lt;td&gt;59,817/s&lt;/td&gt;
&lt;td&gt;2,047.97 ms&lt;/td&gt;
&lt;td&gt;5.81 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;25 ms&lt;/td&gt;
&lt;td&gt;39/s&lt;/td&gt;
&lt;td&gt;59,500/s&lt;/td&gt;
&lt;td&gt;10,135.94 ms&lt;/td&gt;
&lt;td&gt;5.82 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Inline delivery collapses as &lt;code&gt;1/delay&lt;/code&gt; with p99 climbing past ten seconds; actor&lt;br&gt;
delivery stays flat — &lt;strong&gt;~52–60k msg/s independent of tap speed.&lt;/strong&gt; The trade is&lt;br&gt;
explicit: stable delivery bought with bounded tap coverage (and a logged drop&lt;br&gt;
count under overload).&lt;/p&gt;

&lt;p&gt;A historical note worth its own line: &lt;strong&gt;MQTT is the first production code in&lt;br&gt;
BlackBull that uses the actor inbox for real.&lt;/strong&gt; The HTTP actors override &lt;code&gt;run()&lt;/code&gt;&lt;br&gt;
and call each other through direct method calls — the inbox was defined but&lt;br&gt;
latent on that path. The broker is what proves the inbox works at scale (451&lt;br&gt;
conformance tests, all green).&lt;/p&gt;

&lt;p&gt;---&lt;/p&gt;

&lt;h2&gt;
  
  
  Dimension two: a single owner across workers
&lt;/h2&gt;

&lt;p&gt;Serial inboxes solve concurrency &lt;em&gt;inside&lt;/em&gt; one process. But BlackBull runs HTTP&lt;br&gt;
across multiple worker processes for throughput — and that collides head-on with&lt;br&gt;
a broker.&lt;/p&gt;

&lt;p&gt;The MQTT 5 spec assumes one logical broker: a client's session, its queued&lt;br&gt;
messages, and its subscriptions are &lt;strong&gt;one coherent piece of state&lt;/strong&gt;. Shard the&lt;br&gt;
broker across four worker processes with no shared store and a client that&lt;br&gt;
reconnects to a different worker finds its session gone. So the broker &lt;strong&gt;must&lt;br&gt;
have a single owner&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The naive resolution is "any stateful protocol forces &lt;code&gt;workers=1&lt;/code&gt; for the whole&lt;br&gt;
process" — which throws away HTTP's multi-worker scaling to satisfy MQTT. v0.44.1&lt;br&gt;
does something better:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;master process binds the broker's &lt;code&gt;:1883&lt;/code&gt; socket once&lt;/strong&gt; and hands it to
&lt;strong&gt;worker 0 only.&lt;/strong&gt; The broker lives there, single-owner, exactly as the spec
wants.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP is stateless, so it scales on every worker.&lt;/strong&gt; &lt;code&gt;app.run(port=8000, workers=4)&lt;/code&gt; alongside &lt;code&gt;MQTTExtension(port=1883)&lt;/code&gt; now runs HTTP on all four
workers while MQTT runs on worker 0.&lt;/li&gt;
&lt;li&gt;If worker 0 crashes, the master &lt;strong&gt;respawns it and it re-inherits the still-open
listener&lt;/strong&gt; — the broker's single-owner identity is tied to "worker 0," not to a
particular PID.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;BB\_SOCKET\_REUSEPORT=1&lt;/code&gt; gives each HTTP worker its own kernel accept queue for
the best load distribution; the broker port is always bound &lt;em&gt;without&lt;/em&gt;
&lt;code&gt;SO\_REUSEPORT&lt;/code&gt;, by design — a single owner must not have its socket
load-balanced away.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;(Auto-reload (&lt;code&gt;--reload&lt;/code&gt;) still pins &lt;code&gt;workers=1&lt;/code&gt; — the exec-based socket handoff&lt;br&gt;
doesn't yet carry protocol listeners. A known edge, documented.)&lt;/p&gt;

&lt;p&gt;So the two dimensions compose: &lt;strong&gt;within&lt;/strong&gt; a worker, the actor model guarantees&lt;br&gt;
lock-free state, trivial lifetimes, and backpressure isolation; &lt;strong&gt;across&lt;/strong&gt;&lt;br&gt;
workers, single-owner affinity means the broker stays coherent while HTTP&lt;br&gt;
scales past it.&lt;/p&gt;

&lt;p&gt;---&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cost the HTTP path
&lt;/h2&gt;

&lt;p&gt;Nothing. Adding the broker is a self-contained extension on the part-1 seam — the&lt;br&gt;
core &lt;code&gt;BlackBull&lt;/code&gt; class carries zero MQTT-specific code. The same release that&lt;br&gt;
shipped the broker measured zero regression on the HTTP/1.1 hot path. A second&lt;br&gt;
protocol arrived and the first one didn't notice.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A closing thought.&lt;/em&gt; BlackBull already has a second extension on PyPI —&lt;br&gt;
&lt;a href="https://pypi.org/project/blackbull-htcpcp/" rel="noopener noreferrer"&gt;&lt;code&gt;blackbull-htcpcp&lt;/code&gt;&lt;/a&gt;, an&lt;br&gt;
implementation of HTCPCP (RFC 2324), the HTTP extension for controlling&lt;br&gt;
coffee pots. Now imagine an MQTT-enabled coffee machine: the HTCPCP extension&lt;br&gt;
could serve &lt;code&gt;BREW /pot&lt;/code&gt; on &lt;code&gt;:80&lt;/code&gt; while the broker receives brew-complete&lt;br&gt;
notifications on &lt;code&gt;:1883&lt;/code&gt;, sharing the pot's state in one Python dict. Two&lt;br&gt;
RFCs, one process, no sidecar. The MQTT broker is just the first proof that&lt;br&gt;
the part-1 seam delivers on that promise.&lt;/p&gt;

&lt;p&gt;Next, part 3: what all this looks like from an application author's chair — one&lt;br&gt;
&lt;code&gt;app.py&lt;/code&gt;, two protocols, and machine-readable docs for both.&lt;/p&gt;

&lt;p&gt;---&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://github.com/TOKUJI/BlackBull" rel="noopener noreferrer"&gt;github.com/TOKUJI/BlackBull&lt;/a&gt;&lt;br&gt;
Docs: &lt;a href="https://tokuji.github.io/BlackBull/" rel="noopener noreferrer"&gt;tokuji.github.io/BlackBull&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>mqtt</category>
      <category>http</category>
    </item>
    <item>
      <title>BlackBull goes multi-protocol (part 1) — first it had to forget it was HTTP</title>
      <dc:creator>TOKUJI</dc:creator>
      <pubDate>Fri, 26 Jun 2026 11:03:38 +0000</pubDate>
      <link>https://dev.to/tokuji_30/blackbull-goes-multi-protocol-part-1-first-it-had-to-forget-it-was-http-1bf9</link>
      <guid>https://dev.to/tokuji_30/blackbull-goes-multi-protocol-part-1-first-it-had-to-forget-it-was-http-1bf9</guid>
      <description>&lt;p&gt;What does it take to teach an HTTP server to speak a protocol that isn't HTTP at&lt;br&gt;
all? Not "add a WebSocket upgrade" — that still starts with an HTTP handshake.&lt;br&gt;
I mean a protocol that arrives on its own port, speaks its own wire format, and&lt;br&gt;
has never heard of a request-line.&lt;/p&gt;

&lt;p&gt;That's the problem I walked into with BlackBull, a pure-Python ASGI framework&lt;br&gt;
with its own HTTP/1.1, HTTP/2, and WebSocket stack. I'd recently gotten it to a&lt;br&gt;
stable, spec-passing HTTP server — and hit a wall. A clean HTTP server, however&lt;br&gt;
well-built, is a solved problem; there are dozens. What I actually wanted to&lt;br&gt;
know was whether BlackBull's foundation could carry something HTTP couldn't&lt;br&gt;
reach: a second, unrelated protocol sharing the same process. And HTTP alone&lt;br&gt;
couldn't tell me — every connection was being handled by machinery that quietly&lt;br&gt;
assumed HTTP from the first byte.&lt;/p&gt;

&lt;p&gt;So I decided to add an MQTT 5 broker (part 2 of this series) — a protocol with&lt;br&gt;
no request-line, no status codes, no HTTP at all. The first thing I discovered:&lt;br&gt;
the core didn't know how to &lt;em&gt;welcome&lt;/em&gt; a second protocol. It assumed every&lt;br&gt;
connection was HTTP — not as a bug, but as an identity. Before BlackBull could&lt;br&gt;
host anything else, it had to forget it was an HTTP server.&lt;/p&gt;

&lt;p&gt;And it had to do that surgery &lt;strong&gt;under a working HTTP server, without breaking a&lt;br&gt;
single feature.&lt;/strong&gt; That constraint — &lt;em&gt;change the foundation, break nothing&lt;/em&gt; — is&lt;br&gt;
the whole story. Whether the foundation actually held is the question the rest of&lt;br&gt;
this post answers.&lt;/p&gt;


&lt;h2&gt;
  
  
  Where HTTP was hiding in the dispatcher
&lt;/h2&gt;

&lt;p&gt;Every incoming TCP connection is owned by a &lt;code&gt;ConnectionActor&lt;/code&gt;. Its job is to&lt;br&gt;
figure out what protocol the peer is speaking and hand the socket to the right&lt;br&gt;
handler. The trouble: "figure out what protocol" had HTTP baked into it in three&lt;br&gt;
places.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The ALPN path&lt;/strong&gt; read exactly &lt;strong&gt;24 bytes&lt;/strong&gt; — the length of the HTTP/2
connection preface (&lt;code&gt;PRI * HTTP/2.0\r\n\r\n…&lt;/code&gt;, RFC 9113 §3.4).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The cleartext path&lt;/strong&gt; read &lt;strong&gt;until &lt;code&gt;\r\n&lt;/code&gt;&lt;/strong&gt; — the HTTP/1.1 request-line
delimiter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The slowloris timeout&lt;/strong&gt; wrote back &lt;code&gt;HTTP/1.1 408 Request Timeout&lt;/code&gt; — an HTTP
string, on a connection we hadn't yet proven was HTTP.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For an HTTP-only server this is fine; the assumptions are always true. But a&lt;br&gt;
protocol whose detection needs a different number of bytes, or a different&lt;br&gt;
delimiter, or that should never receive an HTTP 408, &lt;strong&gt;cannot participate at&lt;br&gt;
all&lt;/strong&gt;. The dispatcher wasn't a dispatcher — it was an HTTP parser wearing a&lt;br&gt;
dispatcher's coat.&lt;/p&gt;


&lt;h2&gt;
  
  
  Peek, then replay
&lt;/h2&gt;

&lt;p&gt;The fix is a detection primitive that reads from the socket &lt;em&gt;without consuming&lt;/em&gt;&lt;br&gt;
what it read: &lt;code&gt;PrefixReader&lt;/code&gt;. It peeks at the opening bytes, lets each registered&lt;br&gt;
protocol look at them, and then — crucially — &lt;strong&gt;replays&lt;/strong&gt; those same bytes to&lt;br&gt;
whichever protocol claims the connection, so the handler sees the stream from&lt;br&gt;
byte zero as if nothing had been read.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProtocolBinding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# How many bytes detection needs before it can decide.
&lt;/span&gt;    &lt;span class="n"&gt;detection_read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;     &lt;span class="c1"&gt;# -1 = until \r\n, 0 = none, N = exactly N
&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;detect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alpn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ConnectionView&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ConnectionActor&lt;/code&gt; no longer knows what 24 means, or why &lt;code&gt;\r\n&lt;/code&gt; matters. It groups&lt;br&gt;
the registered bindings by their declared &lt;code&gt;detection_read&lt;/code&gt;, peeks exactly what&lt;br&gt;
the group needs, offers the prefix to each binding's &lt;code&gt;detect()&lt;/code&gt;, and calls&lt;br&gt;
&lt;code&gt;serve()&lt;/code&gt; on the winner. HTTP/1.1, HTTP/2, and WebSocket became three ordinary&lt;br&gt;
bindings. The HTTP knowledge moved &lt;em&gt;out&lt;/em&gt; of the dispatcher and &lt;em&gt;into&lt;/em&gt; the HTTP&lt;br&gt;
bindings, where it belongs.&lt;/p&gt;

&lt;p&gt;One honest caveat about that API: &lt;code&gt;detection_read&lt;/code&gt; only expresses two shapes —&lt;br&gt;
&lt;em&gt;read N fixed bytes&lt;/em&gt; or &lt;em&gt;read until &lt;code&gt;\r\n&lt;/code&gt;&lt;/em&gt;. The &lt;code&gt;\r\n&lt;/code&gt; sentinel is a pragmatic&lt;br&gt;
default, not a universal delimiter; a protocol that frames on something else (a&lt;br&gt;
C-style &lt;code&gt;\0&lt;/code&gt;, say) can't yet describe its own detection need. The seam is in the&lt;br&gt;
right place — a binding declares what detection requires — but the vocabulary&lt;br&gt;
isn't fully protocol-neutral. Generalizing &lt;code&gt;detection_read&lt;/code&gt; to a per-binding&lt;br&gt;
predicate is the natural next step, and a known one.&lt;/p&gt;

&lt;p&gt;Two structural things fell out of this.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why a whole class could be deleted
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;RawProtocolActor&lt;/code&gt; is gone — and &lt;em&gt;why&lt;/em&gt; it could go is the real point. To feel the&lt;br&gt;
deletion you have to know what it did. In the old design &lt;code&gt;ConnectionActor&lt;/code&gt; was&lt;br&gt;
HTTP-shaped: it spoke in request-lines and response writers, and it could only&lt;br&gt;
hand a connection to something that fit that shape. A non-HTTP handler didn't. So&lt;br&gt;
there was an adapter — an extra actor layer everyone called "L2" — whose entire&lt;br&gt;
job was to wrap a raw byte handler in enough HTTP-actor costume that the&lt;br&gt;
dispatcher would deign to talk to it. It was pure translation, a class that&lt;br&gt;
existed &lt;em&gt;only&lt;/em&gt; because the dispatcher couldn't address anything that wasn't HTTP.&lt;/p&gt;

&lt;p&gt;Once &lt;code&gt;serve(conn)&lt;/code&gt; became the single door every protocol walks through, that&lt;br&gt;
translation had no one left to translate for — there was only one shape now. L2&lt;br&gt;
wasn't refactored; it was obviated. The clearest sign a seam is in the right&lt;br&gt;
place is when a whole layer that existed to bridge two shapes wakes up to find&lt;br&gt;
there's only one.&lt;/p&gt;

&lt;h3&gt;
  
  
  A lifecycle event that finally fires everywhere
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;connection_closed&lt;/code&gt; now fires for HTTP too. Because teardown used to live on the&lt;br&gt;
HTTP-specific path, the lifecycle event never fired for HTTP connections — only&lt;br&gt;
for the raw ones. Unifying the path fixed that asymmetry as a side effect.&lt;/p&gt;

&lt;p&gt;The headline payoff is forward-looking: &lt;strong&gt;a new protocol now registers a binding&lt;br&gt;
and changes zero lines of &lt;code&gt;ConnectionActor&lt;/code&gt;.&lt;/strong&gt; gRPC, Redis RESP, a raw TCP echo —&lt;br&gt;
each is a &lt;code&gt;ProtocolBinding&lt;/code&gt;, not a dispatcher edit.&lt;/p&gt;




&lt;h2&gt;
  
  
  The part that actually mattered: breaking nothing
&lt;/h2&gt;

&lt;p&gt;Rewriting the code that decides what every connection &lt;em&gt;is&lt;/em&gt; — under an HTTP server&lt;br&gt;
that already passes &lt;code&gt;h2spec&lt;/code&gt; and Autobahn — is the kind of change that quietly&lt;br&gt;
introduces a regression you discover in production six weeks later. So the&lt;br&gt;
refactor was gated on two things, both built &lt;strong&gt;before&lt;/strong&gt; the dispatcher was&lt;br&gt;
touched:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A regression oracle.&lt;/strong&gt; A golden-path test suite
(&lt;code&gt;test_connection_dispatch_golden.py&lt;/code&gt;) pins the exact detection outcome for
every protocol/ALPN/prefix combination — HTTP/1.1 cleartext, HTTP/2 prior
knowledge, ALPN &lt;code&gt;h2&lt;/code&gt;, WebSocket upgrade, the slowloris timeout. The refactor
had to keep every one of those outcomes byte-identical. The oracle ran red
first (proving it tests something), then stayed green through the change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A performance gate.&lt;/strong&gt; The detection path is on &lt;em&gt;every&lt;/em&gt; connection, so a
careless rewrite taxes every request. The change was held to a ±2% budget
against the pre-refactor HTTP/1.1 throughput baseline, measured on the
HttpArena suite.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result cleared both bars. HttpArena's validation run came back unchanged —&lt;br&gt;
&lt;strong&gt;47 passed / 0 failed&lt;/strong&gt;, the full suite, plus 7/7 WebSocket — and HTTP/1.1&lt;br&gt;
throughput held within the ±2% budget against baseline. No regression. The&lt;br&gt;
foundation was rebuilt under the house and not one wall cracked.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why tell this story before the MQTT one?
&lt;/h2&gt;

&lt;p&gt;Because the MQTT broker (part 2) is the &lt;em&gt;easy&lt;/em&gt; part. It's a self-contained&lt;br&gt;
extension. The hard, valuable engineering was making BlackBull's core able to&lt;br&gt;
&lt;em&gt;receive&lt;/em&gt; a second protocol without the core learning anything HTTP-specific&lt;br&gt;
about it — and proving the existing HTTP behavior survived the operation&lt;br&gt;
unchanged.&lt;/p&gt;

&lt;p&gt;Next: the broker itself — a publish/subscribe engine with &lt;strong&gt;no locks&lt;/strong&gt; and a&lt;br&gt;
&lt;strong&gt;single owner&lt;/strong&gt;, built entirely on top of this seam.&lt;/p&gt;




&lt;p&gt;Source: &lt;a href="https://github.com/TOKUJI/BlackBull" rel="noopener noreferrer"&gt;github.com/TOKUJI/BlackBull&lt;/a&gt;&lt;br&gt;
Docs: &lt;a href="https://tokuji.github.io/BlackBull/" rel="noopener noreferrer"&gt;tokuji.github.io/BlackBull&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>mqtt</category>
      <category>http</category>
      <category>python</category>
    </item>
    <item>
      <title>How a from-scratch HTTP/2 server actually works (part 2) — flow control, DATA, and security</title>
      <dc:creator>TOKUJI</dc:creator>
      <pubDate>Sun, 21 Jun 2026 13:04:32 +0000</pubDate>
      <link>https://dev.to/tokuji_30/how-a-from-scratch-http2-server-actually-works-part-2-flow-control-data-and-security-54ei</link>
      <guid>https://dev.to/tokuji_30/how-a-from-scratch-http2-server-actually-works-part-2-flow-control-data-and-security-54ei</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Recap from part 1:&lt;/strong&gt; We traced a connection from the 24-byte preface through SETTINGS exchange, the 9-byte frame header, the seven-loop guard tower, and stream birth — how a HEADERS frame passes admission control and creates a new stream.  The stream is now OPEN.  This part follows what happens next: body delivery, stream death, connection shutdown, and the security guards every from-scratch implementer needs.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  1. Body delivery — DATA, flow control, content-length (§6.1, §6.9.1)
&lt;/h2&gt;

&lt;p&gt;Once the stream is OPEN, the peer sends DATA frames.  Each DATA frame consumes flow-control credit — and HTTP/2 has two independent windows.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why two windows?
&lt;/h3&gt;

&lt;p&gt;The &lt;strong&gt;stream window&lt;/strong&gt; stops any one stream from hogging the connection — fairness between streams.  The &lt;strong&gt;connection window&lt;/strong&gt; caps the total data in flight across &lt;em&gt;all&lt;/em&gt; streams — a bound on how much the receiver has to buffer.  Both must have positive credit before a DATA frame may be sent.&lt;/p&gt;

&lt;p&gt;The connection window is the easy half to forget, and forgetting it fails &lt;em&gt;late&lt;/em&gt;: credit only the stream window and everything works until ~65,535 cumulative bytes have flowed across all streams, at which point the shared connection window reaches zero and &lt;em&gt;every&lt;/em&gt; stream stalls — even ones with plenty of their own credit.&lt;/p&gt;

&lt;p&gt;After delivering a DATA frame to the application, &lt;code&gt;HTTP2Actor._on_data_frame()&lt;/code&gt; sends &lt;strong&gt;two&lt;/strong&gt; WINDOW_UPDATEs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# HTTP2Actor._on_data_frame() — after delivering DATA to the application
&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_frame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;window_update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stream_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# stream
&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_frame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;window_update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;                  &lt;span class="c1"&gt;# connection
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Back-pressure
&lt;/h3&gt;

&lt;p&gt;The WINDOW_UPDATE is sent only after &lt;code&gt;put_DATAFrame&lt;/code&gt; returns &lt;code&gt;True&lt;/code&gt; — the recipient's queue accepted the frame.  A full application queue withholds the credit, naturally stalling the peer.  This is the RFC's intended back-pressure mechanism: flow control doubles as a signalling channel between the TCP receive buffer and the application handler.&lt;/p&gt;

&lt;h3&gt;
  
  
  Content-length enforcement (§8.1.2.6)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;stream.received_data_bytes&lt;/code&gt; accumulates across DATA frames (padding excluded).  If the declared &lt;code&gt;content-length&lt;/code&gt; is exceeded on any frame → RST_STREAM PROTOCOL_ERROR immediately.  If END_STREAM arrives and the total is short → same error.  §8.1.1 makes the server, not the application, responsible for this check — a malformed request must never reach handler code.&lt;/p&gt;

&lt;h3&gt;
  
  
  HALF_CLOSED_REMOTE
&lt;/h3&gt;

&lt;p&gt;When a DATA frame carries END_STREAM, the stream transitions from OPEN to HALF_CLOSED_REMOTE.  The peer has finished sending.  DATA on a stream already in HALF_CLOSED_REMOTE or CLOSED → RST_STREAM STREAM_CLOSED.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Stream death — cleanup and late frames (§5.1)
&lt;/h2&gt;

&lt;p&gt;A stream reaches CLOSED when either the response END_STREAM is sent or an RST_STREAM is received.  At that point &lt;code&gt;HTTP2Actor._make_done_cb()&lt;/code&gt; — the task done-callback — fires:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# HTTP2Actor._make_done_cb()
&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_closed_streams&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;stream_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;  &lt;span class="c1"&gt;# closed via END_STREAM (closed_via_rst=False)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Stream&lt;/code&gt; node is pruned from the priority tree, but the stream ID lives on in &lt;code&gt;_closed_streams: dict[int, bool]&lt;/code&gt;.  This is important: keeping a full &lt;code&gt;Stream&lt;/code&gt; object per completed request would cost hundreds of bytes each; a dict entry costs ~72 bytes.  The boolean tracks &lt;em&gt;how&lt;/em&gt; the stream closed — &lt;code&gt;False&lt;/code&gt; means clean END_STREAM, &lt;code&gt;True&lt;/code&gt; means RST_STREAM — which affects which error code a late frame triggers.&lt;/p&gt;

&lt;p&gt;Late frames on a closed stream still hit the CLOSED branch of §5.1 validation using just an integer lookup.  PRIORITY is always allowed through; HEADERS and CONTINUATION on a closed stream are a connection STREAM_CLOSED error; everything else gets a stream-level RST_STREAM.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Connection death — GOAWAY (§6.8)
&lt;/h2&gt;

&lt;p&gt;GOAWAY is asymmetric.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Incoming&lt;/strong&gt; (&lt;code&gt;HTTP2Actor._on_goaway_frame()&lt;/code&gt;): the peer is closing the connection.  We echo a GOAWAY back so the peer knows which stream IDs we processed, then inject &lt;code&gt;http.disconnect&lt;/code&gt; into every active recipient and return from &lt;code&gt;_frame_loop&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_on_goaway_frame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_stream_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_frame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goaway&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last_stream_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nf"&gt;_signal_recipients&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_recipients&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Outgoing&lt;/strong&gt; (&lt;code&gt;HTTP2Actor._connection_error()&lt;/code&gt;): we detected a protocol error and must close.  Build a GOAWAY with &lt;code&gt;_last_peer_stream_id&lt;/code&gt; (the highest stream we've ever seen from the peer), flush it, then call &lt;code&gt;writer.close()&lt;/code&gt; so the peer sees FIN after the GOAWAY.  The &lt;code&gt;_goaway_sent&lt;/code&gt; flag makes this idempotent — a second connection error doesn't send a second GOAWAY.&lt;/p&gt;




&lt;blockquote&gt;
&lt;h3&gt;
  
  
  Sidebar: WebSocket over HTTP/2 (RFC 8441) — the actor reuse trick
&lt;/h3&gt;

&lt;p&gt;RFC 8441 bootstraps WebSocket over HTTP/2 via an extended CONNECT request with &lt;code&gt;:protocol=websocket&lt;/code&gt;.  There's no 101 Switching Protocols — the response is a 200 HEADERS frame and data flows as DATA frames on the same stream.&lt;/p&gt;

&lt;p&gt;BlackBull reuses &lt;code&gt;WebSocketActor&lt;/code&gt; without modification.  &lt;code&gt;WebSocketActor._send()&lt;/code&gt; reads a callback from &lt;code&gt;scope['_ws_send_101']&lt;/code&gt;.  For HTTP/2 WebSocket, that callback is replaced with &lt;code&gt;_ws_send_200&lt;/code&gt;, which sends a 200 HEADERS frame instead of 101.  The actor never knows it's running over HTTP/2.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;HTTP2WSReader&lt;/code&gt; and &lt;code&gt;HTTP2WSWriter&lt;/code&gt; bridge the HTTP/2 frame-level I/O to the WebSocket actor's &lt;code&gt;readexactly&lt;/code&gt; / &lt;code&gt;write&lt;/code&gt; interface, and they implement &lt;code&gt;backpressures_via_credit = True&lt;/code&gt; — flow control credit is withheld until the reader buffer drains, using HTTP/2's own flow-control mechanism as the back-pressure signal.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;blockquote&gt;
&lt;h3&gt;
  
  
  Sidebar: Three security guards
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Rapid Reset (CVE-2023-44487, RFC 9113 §10.5).&lt;/strong&gt;  Disclosed in October 2023, this attack sends HEADERS + RST_STREAM in a tight loop — the server spawns a task for each stream, but the stream is already reset by the time the handler starts.  &lt;code&gt;SETTINGS_MAX_CONCURRENT_STREAMS&lt;/code&gt; doesn't help because the streams close too fast to accumulate.  BlackBull uses a rolling 1-second RST_STREAM rate limit (default 20/sec) in &lt;code&gt;_frame_loop()&lt;/code&gt;, checked before stream-state validation so RSTs on idle/unknown streams also count.  Exceeded → GOAWAY ENHANCE_YOUR_CALM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CONTINUATION flood (CVE-2024-27983, RFC 9113 §10.5.1).&lt;/strong&gt;  A HEADERS frame with &lt;code&gt;END_HEADERS=0&lt;/code&gt; followed by unlimited CONTINUATION frames can grow the header accumulator until the process OOMs.  BlackBull checks &lt;code&gt;len(header_frame.raw_block) &amp;gt; self._header_max_total&lt;/code&gt; (default 64 KiB — the same budget as HTTP/1.1) and resets with ENHANCE_YOUR_CALM &lt;em&gt;before&lt;/em&gt; calling &lt;code&gt;parse_payload()&lt;/code&gt;.  nginx and Envoy use the same approach and the same error code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;stream_id==0 for stream-only frames (RFC 9113 §6.1, §6.2, §6.4, §6.6, §6.10).&lt;/strong&gt;  Less flashy but load-bearing: DATA, HEADERS, RST_STREAM, PUSH_PROMISE, PRIORITY, and CONTINUATION MUST NOT appear on stream 0.  &lt;code&gt;_STREAM_ONLY_FRAME_TYPES&lt;/code&gt; is a class-level frozenset that consolidates six independent checks into one lookup.  Violation → connection PROTOCOL_ERROR.  BlackBull fixed the PUSH_PROMISE and RST_STREAM cases (previously missing from the individual checks) in v0.42.1 and v0.42.2 respectively.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Where to go from here
&lt;/h2&gt;

&lt;p&gt;The full RFC→code table, with section numbers, method names, and file:line references, lives at&lt;br&gt;
&lt;a href="https://TOKUJI.github.io/BlackBull/about/rfc9113-implementation/" rel="noopener noreferrer"&gt;&lt;code&gt;docs/about/rfc9113-implementation.md&lt;/code&gt;&lt;/a&gt; in the BlackBull docs.&lt;/p&gt;

&lt;p&gt;Part 1 covered the connection preface, frame reading, the seven-loop guard tower, and stream birth — read it if you want the full lifecycle from the beginning.&lt;/p&gt;

&lt;p&gt;The source is &lt;a href="https://github.com/TOKUJI/BlackBull/blob/master/blackbull/server/http2_actor.py" rel="noopener noreferrer"&gt;&lt;code&gt;blackbull/server/http2_actor.py&lt;/code&gt;&lt;/a&gt; — 1200+ lines, nearly every block annotated with its RFC cite.  gRPC over HTTP/2 is the natural next step: the frame layer already handles multiplexed streams; what's missing is the Protobuf framing and trailers.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;BlackBull is a personal learning project — pure-Python HTTP/1.1, HTTP/2, and WebSocket, no C extensions in the protocol stack.  Issues and PRs welcome.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>python</category>
      <category>http</category>
      <category>learning</category>
    </item>
    <item>
      <title>How a from-scratch HTTP/2 server actually works (part 1) — connection to first request</title>
      <dc:creator>TOKUJI</dc:creator>
      <pubDate>Sun, 21 Jun 2026 13:02:39 +0000</pubDate>
      <link>https://dev.to/tokuji_30/how-a-from-scratch-http2-server-actually-works-part-1-connection-to-first-request-4e9p</link>
      <guid>https://dev.to/tokuji_30/how-a-from-scratch-http2-server-actually-works-part-1-connection-to-first-request-4e9p</guid>
      <description>&lt;p&gt;RFC 9113 is 80 pages.  What does it take to turn those 80 pages into working code?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/TOKUJI/BlackBull" rel="noopener noreferrer"&gt;BlackBull&lt;/a&gt; is a pure-Python ASGI web framework whose HTTP/2 stack is built directly on TCP — no &lt;code&gt;h2&lt;/code&gt; library, no C extensions in the protocol layer.  Because the frame layer and the server integration live in the same source files, you can trace a single RFC requirement end-to-end: from the 9-byte header parse through stream state validation to the application queue that withholds flow-control credit for back-pressure.  Nearly every block carries the RFC section number it implements, so you can grep for &lt;code&gt;§6.9.1&lt;/code&gt; and land on the dual-window logic, then follow the call chain in both directions.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A note on &lt;code&gt;h2&lt;/code&gt;:&lt;/strong&gt; The most widely used Python package for HTTP/2 is the&lt;br&gt;
&lt;a href="https://python-hyper.org/projects/h2/en/stable/" rel="noopener noreferrer"&gt;&lt;code&gt;h2&lt;/code&gt; library&lt;/a&gt; — a sans-I/O&lt;br&gt;
state machine where you call &lt;code&gt;receive_data(bytes)&lt;/code&gt; and react to a list of&lt;br&gt;
events (&lt;code&gt;RequestReceived&lt;/code&gt;, &lt;code&gt;DataReceived&lt;/code&gt;, &lt;code&gt;WindowUpdated&lt;/code&gt;…).  &lt;code&gt;h2&lt;/code&gt; is a&lt;br&gt;
frame parser.  It does not tell you how to wire those events into an event&lt;br&gt;
loop, manage stream state across concurrent tasks, propagate back-pressure to&lt;br&gt;
handlers, or shut down a connection cleanly — those concerns live in a&lt;br&gt;
separate codebase (Daphne, Hypercorn, etc.).&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  1. The connection preface — how HTTP/2 begins (§3.4, §6.5)
&lt;/h2&gt;

&lt;p&gt;Before any frames flow, the client must prove it speaks HTTP/2.  RFC 9113 §3.4 specifies a 24-byte magic string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;BlackBull handles this in &lt;code&gt;ConnectionActor&lt;/code&gt; — the per-TCP-connection supervisor that detects the protocol and spawns the appropriate handler.  The preface string is the same regardless of TLS; what differs is how the server knows to expect it.  When TLS negotiates ALPN &lt;code&gt;h2&lt;/code&gt;, the protocol is already decided — &lt;code&gt;ConnectionActor._dispatch()&lt;/code&gt; reads exactly 24 bytes and validates them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ConnectionActor._dispatch() — ALPN-h2 path
&lt;/span&gt;&lt;span class="n"&gt;preface&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readexactly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_HTTP2_PREFACE_FIRST_LINE&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;_HTTP2_PREFACE_REMAINDER&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;preface&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Send a raw GOAWAY before closing so a legitimate HTTP/2 peer
&lt;/span&gt;    &lt;span class="c1"&gt;# gets a clean diagnosis rather than a mysterious TCP RST.
&lt;/span&gt;    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Invalid HTTP/2 preface: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;preface&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a cleartext connection there is no ALPN, so the same dispatch method sniffs the first line: if it matches &lt;code&gt;PRI * HTTP/2.0\r\n&lt;/code&gt;, the remaining 8 bytes are read and the same validation runs.  If it doesn't match, the connection falls through to HTTP/1.1.&lt;/p&gt;

&lt;p&gt;Once the preface is validated, &lt;code&gt;HTTP2Actor&lt;/code&gt; takes over.  Its &lt;code&gt;run()&lt;/code&gt; method immediately sends the server's SETTINGS frame (§6.5):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# HTTP2Actor.run() — sends SETTINGS as the very first frame
&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_frame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;enable_connect_protocol&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;h2_enable_websocket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;initial_window_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;h2_initial_window_size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_concurrent_streams&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_concurrent_streams&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SETTINGS is how both endpoints agree on parameters: initial flow-control window, maximum frame size, header table size, and whether server push or WebSocket-over-H2 is enabled.  Incoming SETTINGS (with and without ACK) are handled by &lt;code&gt;SettingsResponder.respond()&lt;/code&gt;, which validates every parameter against RFC 9113 §6.5.2 ranges — &lt;code&gt;SETTINGS_INITIAL_WINDOW_SIZE&lt;/code&gt; must not exceed 2³¹−1, &lt;code&gt;SETTINGS_MAX_FRAME_SIZE&lt;/code&gt; must be between 16384 and 16777215, and so on.&lt;/p&gt;

&lt;p&gt;After SETTINGS, the connection optionally expands its inbound flow-control window beyond the RFC default of 65535 bytes, then enters &lt;code&gt;_frame_loop&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Reading a frame — the 9-byte header (§4.1)
&lt;/h2&gt;

&lt;p&gt;Every HTTP/2 frame starts with a 9-byte header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+-----------------------------------------------+
|                 Length (24)                   |
+---------------+---------------+---------------+
|   Type (8)    |   Flags (8)   |
+-+-------------+---------------+-------------------------------+
|R|                 Stream Identifier (31)                      |
+=+=============================================================+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;HTTP2Actor.receive()&lt;/code&gt; reads exactly 9 bytes, extracts the frame length, then reads exactly that many more:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# HTTP2Actor.receive()
&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readexactly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;frame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_parser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readexactly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;frame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_parser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse_payload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;frame&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine lines.  The complexity lives in what happens next.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. The frame loop guard tower — seven checks before dispatch (§4.2, §5.5, §6.3, §6.4, §6.10)
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;_frame_loop&lt;/code&gt; processes one frame at a time.  Before dispatching to a specific handler, it runs a sequence of guards — checks that apply regardless of stream state.  The ordering matters: each guard catches cases that would otherwise be misclassified by a later check.&lt;/p&gt;

&lt;h3&gt;
  
  
  Guard 1 — CONTINUATION expectation (§6.10)
&lt;/h3&gt;

&lt;p&gt;If the previous HEADERS or PUSH_PROMISE had &lt;code&gt;END_HEADERS=0&lt;/code&gt;, the &lt;em&gt;only&lt;/em&gt; legal next frame is CONTINUATION.  Any other type → connection PROTOCOL_ERROR.  This must be checked first because a stray HEADERS or DATA arriving mid-header-block would otherwise be dispatched to the wrong handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# HTTP2Actor._frame_loop() — Guard 1
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;waiting_continuation&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;frame_type&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;FrameTypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CONTINUATION&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_connection_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ErrorCodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PROTOCOL_ERROR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Guard 2 — unknown frame types (§5.5)
&lt;/h3&gt;

&lt;p&gt;Frames with unrecognized type codes MUST be silently ignored for forward compatibility:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# HTTP2Actor._frame_loop() — Guard 2
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;frame_type&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Guard 3 — Rapid Reset rate limit (CVE-2023-44487)
&lt;/h3&gt;

&lt;p&gt;The Rapid Reset attack sends HEADERS immediately followed by RST_STREAM in a tight loop — the server spawns a task for each stream, but by the time the handler starts the stream is already reset.  &lt;code&gt;max_concurrent_streams&lt;/code&gt; doesn't catch it because the stream lifecycle is too short.&lt;/p&gt;

&lt;p&gt;BlackBull uses a rolling 1-second window.  More than 20 RST_STREAMs per second → GOAWAY ENHANCE_YOUR_CALM.  This check runs &lt;em&gt;before&lt;/em&gt; stream-state validation so that RST_STREAM on idle/unknown streams also counts toward the budget.&lt;/p&gt;

&lt;h3&gt;
  
  
  Guard 4 — frame size check (§4.2)
&lt;/h3&gt;

&lt;p&gt;A frame whose payload exceeds the receiver's advertised &lt;code&gt;SETTINGS_MAX_FRAME_SIZE&lt;/code&gt; is a FRAME_SIZE_ERROR.  The severity depends on the frame type: header-block and connection-state frames (HEADERS, CONTINUATION, PUSH_PROMISE, SETTINGS) are connection-fatal — they corrupt shared HPACK state.  Everything else is stream-fatal.  &lt;code&gt;_FRAME_SIZE_CONNECTION_ERROR_TYPES&lt;/code&gt; is a class-level frozenset that encodes this distinction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Guard 5 — stream_id==0 for stream-only frames (§6.1, §6.2, §6.4, §6.6, §6.10)
&lt;/h3&gt;

&lt;p&gt;DATA, HEADERS, RST_STREAM, PUSH_PROMISE, PRIORITY, and CONTINUATION MUST NOT appear on stream 0 — that's reserved for connection-control frames (SETTINGS, PING, GOAWAY).  &lt;code&gt;_STREAM_ONLY_FRAME_TYPES&lt;/code&gt; is a class-level frozenset:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# HTTP2Actor._frame_loop() — Guard 5
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;frame_type&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_STREAM_ONLY_FRAME_TYPES&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stream_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_connection_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ErrorCodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PROTOCOL_ERROR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Guard 6 — CONTINUATION without preceding HEADERS (§6.10)
&lt;/h3&gt;

&lt;p&gt;A CONTINUATION that arrives outside an open header block (i.e., &lt;code&gt;waiting_continuation&lt;/code&gt; is not set) is a connection PROTOCOL_ERROR.  This check must precede stream-state validation — otherwise a stray CONTINUATION on a half-closed or closed stream would be rejected with the wrong error type (STREAM_CLOSED instead of PROTOCOL_ERROR).&lt;/p&gt;

&lt;h3&gt;
  
  
  Guard 7 — PRIORITY frame length (§6.3)
&lt;/h3&gt;

&lt;p&gt;A PRIORITY frame MUST be exactly 5 octets; anything else is a stream FRAME_SIZE_ERROR.  This is enforced before stream-state lookup so a malformed PRIORITY on a not-yet-seen stream still gets the correct error.&lt;/p&gt;

&lt;p&gt;After all seven guards, the remaining frames enter a &lt;code&gt;match&lt;/code&gt; dispatch on frame type.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Stream birth — HEADERS, state machine, CONTINUATION (§5.1, §6.2, §6.10)
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;stream&lt;/strong&gt; is an independent, bidirectional flow of frames within an HTTP/2 connection, identified by a 31-bit integer.  Multiple streams coexist on a single TCP connection — this is multiplexing, the defining feature of HTTP/2.  Every stream follows a strict lifecycle through a set of states defined in RFC 9113 §5.1.&lt;/p&gt;

&lt;p&gt;The RFC defines eight stream states; four matter for a server.  Here is the complete lifecycle of a single request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IDLE  →(HEADERS received)→  OPEN
OPEN  →(END_STREAM received on DATA)→  HALF_CLOSED_REMOTE
OPEN or HALF_CLOSED_REMOTE  →(response END_STREAM sent)→  CLOSED
any  →(RST_STREAM)→  CLOSED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This section covers the IDLE→OPEN transition — everything that happens when a HEADERS frame first arrives.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stream ID rules (§5.1.1)
&lt;/h3&gt;

&lt;p&gt;Before accepting a HEADERS frame, &lt;code&gt;HTTP2Actor._frame_loop()&lt;/code&gt; validates the stream identifier.  Peer-initiated streams MUST use odd identifiers, strictly increasing — an even id or one ≤ &lt;code&gt;_last_peer_stream_id&lt;/code&gt; is a connection PROTOCOL_ERROR.  This monotonic-odd rule is what lets both ends allocate stream IDs without a round-trip; a violation means the peer's state has diverged from ours and the connection is no longer trustworthy.&lt;/p&gt;

&lt;h3&gt;
  
  
  The HEADERS admission gauntlet
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;HTTP2Actor._on_headers_frame()&lt;/code&gt; runs three checks before any application code sees the request:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Concurrency check&lt;/strong&gt; — if &lt;code&gt;_active_stream_count &amp;gt;= max_concurrent_streams&lt;/code&gt;, respond RST_STREAM REFUSED_STREAM immediately.  This is a stream-level error, not a connection error: the client did nothing wrong, it just bumped into a ceiling the server set for itself.  The client keeps all its other in-flight streams.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;END_HEADERS check&lt;/strong&gt; — if &lt;code&gt;END_HEADERS=0&lt;/code&gt;, the header block continues across CONTINUATION frames.  The method returns &lt;code&gt;False&lt;/code&gt; and the caller sets &lt;code&gt;waiting_continuation = True&lt;/code&gt;, stashing the frame for accumulation.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Malformed check&lt;/strong&gt; (§8.1.1) — after &lt;code&gt;parse_payload()&lt;/code&gt; + &lt;code&gt;parse_headers()&lt;/code&gt; decode the header block, the &lt;code&gt;malformed&lt;/code&gt; flag is checked.  Missing pseudo-headers, invalid field characters, connection-specific headers → RST_STREAM PROTOCOL_ERROR before the request ever reaches a handler.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;HTTP2Actor._validate_stream_state(stream, frame_type)&lt;/code&gt; encodes the legal frame set for each state.  A HEADERS frame arriving on a stream that is already OPEN, for instance, is rejected — the RFC forbids a second HEADERS on an active stream.&lt;/p&gt;

&lt;h3&gt;
  
  
  CONTINUATION — assembling split header blocks
&lt;/h3&gt;

&lt;p&gt;When END_HEADERS=0, &lt;code&gt;HTTP2Actor._on_continuation_frame()&lt;/code&gt; accumulates &lt;code&gt;header_frame.raw_block&lt;/code&gt; across subsequent CONTINUATION frames.  Only when END_HEADERS=1 does it call &lt;code&gt;header_frame.parse_payload()&lt;/code&gt; + &lt;code&gt;parse_headers()&lt;/code&gt; once on the complete block — the same concurrency check and malformed check then run on the assembled HEADERS.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;CONTINUATION flood protection&lt;/strong&gt; is covered in the security sidebar in part 2.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What's next — part 2 covers the rest of the lifecycle
&lt;/h2&gt;

&lt;p&gt;Part 1 traced the connection from the 24-byte preface through the first request's arrival.  Part 2 follows what happens after the stream is OPEN: flow control and DATA delivery, stream death and cleanup, connection shutdown via GOAWAY, and three security guards every from-scratch implementer needs.  It also covers WebSocket over HTTP/2 (RFC 8441) — where the same &lt;code&gt;WebSocketActor&lt;/code&gt; runs unmodified over HTTP/2 DATA frames.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Want the full RFC→code map?&lt;/strong&gt; Every section of RFC 9113, every BlackBull method, with file references, lives at &lt;a href="https://TOKUJI.github.io/BlackBull/about/rfc9113-implementation/" rel="noopener noreferrer"&gt;&lt;code&gt;docs/about/rfc9113-implementation.md&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The source is &lt;a href="https://github.com/TOKUJI/BlackBull/blob/master/blackbull/server/http2_actor.py" rel="noopener noreferrer"&gt;&lt;code&gt;blackbull/server/http2_actor.py&lt;/code&gt;&lt;/a&gt; — 1200+ lines, nearly every block annotated with its RFC cite.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;BlackBull is a personal learning project — pure-Python HTTP/1.1, HTTP/2, and WebSocket, no C extensions in the protocol stack.  Issues and PRs welcome.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>python</category>
      <category>http</category>
      <category>learning</category>
    </item>
    <item>
      <title>Brewing Coffee Over HTTP: A Python Implementation of RFC 2324 and RFC 7168 (HTCPCP)</title>
      <dc:creator>TOKUJI</dc:creator>
      <pubDate>Thu, 18 Jun 2026 13:00:13 +0000</pubDate>
      <link>https://dev.to/tokuji_30/brewing-coffee-over-http-a-python-implementation-of-rfc-2324-and-rfc-7168-htcpcp-4hkc</link>
      <guid>https://dev.to/tokuji_30/brewing-coffee-over-http-a-python-implementation-of-rfc-2324-and-rfc-7168-htcpcp-4hkc</guid>
      <description>&lt;h2&gt;
  
  
  One terminal session. Copy, paste, watch it run.
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;blackbull-htcpcp

&lt;span class="c"&gt;# ---- Coffee mode: start the server ----&lt;/span&gt;
python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
from blackbull import BlackBull
from blackbull_htcpcp import HtcpcpExtension

app = BlackBull()
HtcpcpExtension(app=app, pot_type='coffee')
app.run(port=8000)
"&lt;/span&gt; &amp;amp;

&lt;span class="nb"&gt;sleep &lt;/span&gt;1  &lt;span class="c"&gt;# wait for the server to bind&lt;/span&gt;

&lt;span class="c"&gt;# ---- Brew coffee with additions ----&lt;/span&gt;
curl &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Accept-Additions: cream; sugar; vanilla'&lt;/span&gt; http://localhost:8000/pot

&lt;span class="c"&gt;# ---- Inspect the pot ----&lt;/span&gt;
curl &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; http://localhost:8000/pot

&lt;span class="c"&gt;# ---- Ask when it'll be ready ----&lt;/span&gt;
curl &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; http://localhost:8000/pot/when

&lt;span class="c"&gt;# ---- Teapot mode ----&lt;/span&gt;
&lt;span class="nb"&gt;kill&lt;/span&gt; %1 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;wait &lt;/span&gt;2&amp;gt;/dev/null
python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
from blackbull import BlackBull
from blackbull_htcpcp import HtcpcpExtension
app = BlackBull()
HtcpcpExtension(app=app, pot_type='teapot')
app.run(port=8000)
"&lt;/span&gt; &amp;amp;

&lt;span class="nb"&gt;sleep &lt;/span&gt;1  &lt;span class="c"&gt;# wait for the server to bind&lt;/span&gt;

&lt;span class="c"&gt;# Ask a teapot to brew coffee → 418&lt;/span&gt;
curl &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8000/pot

&lt;span class="c"&gt;# Ask a teapot to brew tea → 200&lt;/span&gt;
curl &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Accept-Additions: milk; honey'&lt;/span&gt; http://localhost:8000/pot

&lt;span class="nb"&gt;kill&lt;/span&gt; %1 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;wait &lt;/span&gt;2&amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll see five JSON responses, each with &lt;code&gt;Content-Type: message/coffeepot&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;What you did&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Key detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;POST with &lt;code&gt;cream; sugar; vanilla&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Additions echoed back in the response&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;GET the pot state&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;"state":"ready"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;GET when it's ready&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;"ready":true&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Teapot + no additions&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;418&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;"I'm a teapot"&lt;/code&gt; — RFC 7168 §2.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Teapot + &lt;code&gt;milk; honey&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Tea brewing succeeds — RFC 7168&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Native method support:&lt;/strong&gt; &lt;code&gt;BREW&lt;/code&gt;, &lt;code&gt;PROPFIND&lt;/code&gt;, and &lt;code&gt;WHEN&lt;/code&gt; are registered as first-class HTTP methods when the underlying BlackBull framework is ≥ 0.42.1. Before BlackBull 0.42.1, &lt;code&gt;POST&lt;/code&gt; served as the fallback for &lt;code&gt;BREW&lt;/code&gt; and &lt;code&gt;GET&lt;/code&gt; covered &lt;code&gt;PROPFIND&lt;/code&gt; / &lt;code&gt;WHEN&lt;/code&gt; — both paths produce identical wire behaviour.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://pypi.org/project/blackbull-htcpcp/" rel="noopener noreferrer"&gt;&lt;code&gt;blackbull-htcpcp&lt;/code&gt;&lt;/a&gt; is the first PyPI package to implement both &lt;a href="https://datatracker.ietf.org/doc/html/rfc2324" rel="noopener noreferrer"&gt;RFC 2324&lt;/a&gt; (the core HTCPCP spec) and &lt;a href="https://datatracker.ietf.org/doc/html/rfc7168" rel="noopener noreferrer"&gt;RFC 7168&lt;/a&gt; (its tea-pot extension). 53 tests. A documented threat model. And every feature is wired through the framework's public extension API — zero core modifications.&lt;/p&gt;




&lt;h2&gt;
  
  
  The real goal: stress-test an extension surface
&lt;/h2&gt;

&lt;p&gt;HTCPCP is a joke, but it is a joke with excellent test coverage of HTTP's extension points. A protocol that exercises &lt;strong&gt;all five&lt;/strong&gt; of these in 350 lines is a useful validation tool:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Extension point&lt;/th&gt;
&lt;th&gt;HTCPCP usage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Custom HTTP method&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;BREW&lt;/code&gt;, &lt;code&gt;PROPFIND&lt;/code&gt;, &lt;code&gt;WHEN&lt;/code&gt; — non-IANA verbs as first-class routes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom status code&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;418 I'm a teapot&lt;/code&gt; with application-level semantics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom content type&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;message/coffeepot&lt;/code&gt; on every &lt;code&gt;/pot&lt;/code&gt; response&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Application-defined header&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Accept-Additions: cream; sugar; whisky&lt;/code&gt; with its own grammar&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error semantics&lt;/td&gt;
&lt;td&gt;418 is not "server error" or "client error" — it means "wrong pot type"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you can ship all five purely on &lt;code&gt;app.route()&lt;/code&gt;, &lt;code&gt;app.on_error()&lt;/code&gt;, and &lt;code&gt;app.extensions&lt;/code&gt;, the extension surface is real. If you need to modify the framework core, it is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spoiler: no core modifications were needed.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The extension surface
&lt;/h2&gt;

&lt;p&gt;BlackBull's extension model is deliberately minimal — no plugin registry, no dependency injection, no auto-discovery:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app.route — accepts any RFC 9110 section 5.6.2 token since v0.42.1
&lt;/span&gt;&lt;span class="nd"&gt;@app.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/pot&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;methods&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;BREW&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HTTPMethod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;brew_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="c1"&gt;# app.on_error — custom handler for any HTTP status
&lt;/span&gt;&lt;span class="nd"&gt;@app.on_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HTTPStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IM_A_TEAPOT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;teapot_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="c1"&gt;# app.extensions — plain dict, stable key
&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;extensions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;htcpcp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire API surface the extension touches. Four &lt;code&gt;@app.route&lt;/code&gt; decorators, one &lt;code&gt;app.on_error(418)&lt;/code&gt;, and one dict assignment. The remaining 300 lines are the &lt;code&gt;Accept-Additions&lt;/code&gt; parser, the pot state machine (&lt;code&gt;idle -&amp;gt; brewing -&amp;gt; ready&lt;/code&gt;), and the teapot discrimination logic from RFC 7168 section 2.1.&lt;/p&gt;

&lt;h3&gt;
  
  
  What teapot discrimination looks like in code
&lt;/h3&gt;

&lt;p&gt;RFC 7168 adds nuance: a teapot does not &lt;em&gt;always&lt;/em&gt; return 418. Asked to brew tea (with explicit tea additions like &lt;code&gt;milk&lt;/code&gt;, &lt;code&gt;lemon&lt;/code&gt;, &lt;code&gt;honey&lt;/code&gt;), it returns 200. Asked to brew coffee, or asked with no additions at all, it returns 418:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_pot_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;teapot&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;has_coffee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;COFFEE_ADDITIONS&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;additions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;has_tea&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;TEA_ADDITIONS&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;additions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;has_tea&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;has_coffee&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_teapot_response&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;  &lt;span class="c1"&gt;# 418
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt;
&lt;span class="c1"&gt;# ...proceed to brew (200)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the kind of detail that separates "I made a 418 joke endpoint" from "I implemented the RFC." Prior Python implementations (HyperTextCoffeePot, 78 stars, Flask, archived 2015) handled 418 but not RFC 7168 teapot/coffee-pot discrimination.&lt;/p&gt;




&lt;h2&gt;
  
  
  Security: the threat model
&lt;/h2&gt;

&lt;p&gt;HTCPCP inherits HTTP's full attack surface. &lt;code&gt;Accept-Additions&lt;/code&gt; is a semicolon-delimited header parsed from untrusted input — it needs the same hardening as any other HTTP header parser. The test suite documents 15 security cases (S001–S015); the most illustrative are shown below:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Case&lt;/th&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Threat&lt;/th&gt;
&lt;th&gt;Mitigation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;S001–S003&lt;/td&gt;
&lt;td&gt;Header injection&lt;/td&gt;
&lt;td&gt;CRLF / LF / NULL byte in addition tokens&lt;/td&gt;
&lt;td&gt;Rejected at parse -&amp;gt; 400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S004–S005&lt;/td&gt;
&lt;td&gt;DoS&lt;/td&gt;
&lt;td&gt;Token &amp;gt; 256 chars, or &amp;gt; 64 tokens&lt;/td&gt;
&lt;td&gt;Rejected -&amp;gt; 400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S006–S007&lt;/td&gt;
&lt;td&gt;DoS&lt;/td&gt;
&lt;td&gt;Concurrent BREW, body &amp;gt; 1 MiB&lt;/td&gt;
&lt;td&gt;409 / 413&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S008&lt;/td&gt;
&lt;td&gt;Control chars&lt;/td&gt;
&lt;td&gt;Non-printable chars below 0x20 (except HTAB)&lt;/td&gt;
&lt;td&gt;Rejected -&amp;gt; 400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S010&lt;/td&gt;
&lt;td&gt;418 abuse&lt;/td&gt;
&lt;td&gt;Cache poisoning via 418 + &lt;code&gt;Cache-Control&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No permissive cache headers on 418&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S014&lt;/td&gt;
&lt;td&gt;Smuggling&lt;/td&gt;
&lt;td&gt;BREW + Content-Length / Transfer-Encoding games&lt;/td&gt;
&lt;td&gt;Body capped, not interpreted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S015&lt;/td&gt;
&lt;td&gt;Replay&lt;/td&gt;
&lt;td&gt;Race: two BREW requests simultaneously&lt;/td&gt;
&lt;td&gt;State machine guards brewing state&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every rejection — 400, 409, 413 — returns &lt;code&gt;Content-Type: message/coffeepot&lt;/code&gt;. The error format is consistent regardless of which security boundary was hit. The protocol is absurd; the parser is not.&lt;/p&gt;




&lt;h2&gt;
  
  
  Prior art
&lt;/h2&gt;

&lt;p&gt;Roughly 105 HTCPCP repos on GitHub across all languages. The top implementations by stars:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Implementation&lt;/th&gt;
&lt;th&gt;Language&lt;/th&gt;
&lt;th&gt;Stars&lt;/th&gt;
&lt;th&gt;Last updated&lt;/th&gt;
&lt;th&gt;RFC 7168&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/HyperTextCoffeePot/HyperTextCoffeePot" rel="noopener noreferrer"&gt;HyperTextCoffeePot&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Python (Flask)&lt;/td&gt;
&lt;td&gt;78&lt;/td&gt;
&lt;td&gt;2015&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/madmaze/HTCPCP" rel="noopener noreferrer"&gt;madmaze/HTCPCP&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;49&lt;/td&gt;
&lt;td&gt;2011&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/stephen/node-htcpcp" rel="noopener noreferrer"&gt;node-htcpcp&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Node.js&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;td&gt;2013&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/dkundel/htcpcp-delonghi" rel="noopener noreferrer"&gt;htcpcp-delonghi&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;JS (Tessel 2)&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;td&gt;2023&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;blackbull-htcpcp&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Python (BlackBull)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;—&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2026 (active)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;blackbull-htcpcp&lt;/code&gt; is the &lt;strong&gt;first Python package on PyPI&lt;/strong&gt; implementing HTCPCP, one of two known implementations covering both RFCs, the only ASGI-native one, and actively maintained with 53 automated tests.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why joke RFCs matter
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc1149" rel="noopener noreferrer"&gt;RFC 1149&lt;/a&gt; (IP over Avian Carriers, 1990) was implemented by the Bergen Linux User Group in 2001 — 9 packets over 5 km, 55% packet loss, mostly pigeon-related. The #save418 movement in 2017 kept status 418 in Python, Go, and Node.js. Google serves &lt;a href="https://www.google.com/teapot" rel="noopener noreferrer"&gt;google.com/teapot&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The cultural through-line is that &lt;strong&gt;implementing joke protocols is a form of protocol literacy&lt;/strong&gt;. HTCPCP is small enough to implement in an afternoon, but it touches custom methods, custom status codes, custom content types, header parsing, and error semantics — the same extension points a real application protocol would need.&lt;/p&gt;




&lt;h2&gt;
  
  
  How this was tested
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Every claim above was tested against the published packages before writing.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test suite:&lt;/strong&gt; 53/53 passing (&lt;code&gt;pytest tests/ -v&lt;/code&gt; in &lt;code&gt;blackbull-htcpcp&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HTTP method validation:&lt;/strong&gt; &lt;code&gt;BREW&lt;/code&gt;, &lt;code&gt;PROPFIND&lt;/code&gt;, &lt;code&gt;WHEN&lt;/code&gt; all pass BlackBull's RFC 9110 §5.6.2 tchar check, added in v0.42.1 specifically to unblock this extension (&lt;a href="https://github.com/TOKUJI/BlackBull/blob/master/CHANGELOG.md#0421--2026-06-18" rel="noopener noreferrer"&gt;changelog&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Framework surface audit:&lt;/strong&gt; The extension imports only from &lt;code&gt;blackbull&lt;/code&gt; (public top-level): &lt;code&gt;BlackBull&lt;/code&gt;, &lt;code&gt;Response&lt;/code&gt;, &lt;code&gt;read_body&lt;/code&gt;, &lt;code&gt;Headers&lt;/code&gt;. No &lt;code&gt;blackbull._*&lt;/code&gt;, no &lt;code&gt;blackbull.server.*&lt;/code&gt;, no monkey-patches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;End-to-end wire output:&lt;/strong&gt; All responses shown in the terminal session at the top of this article were verified against the published packages using &lt;code&gt;blackbull.testing.TestClient&lt;/code&gt;; the &lt;code&gt;curl&lt;/code&gt; commands shown produce identical output.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;code&gt;blackbull-htcpcp&lt;/code&gt; is on &lt;a href="https://pypi.org/project/blackbull-htcpcp/" rel="noopener noreferrer"&gt;PyPI&lt;/a&gt; and &lt;a href="https://github.com/TOKUJI/blackbull-htcpcp" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. &lt;a href="https://github.com/TOKUJI/BlackBull" rel="noopener noreferrer"&gt;BlackBull&lt;/a&gt; is a pure-Python ASGI server with HTTP/1.1, HTTP/2, and WebSocket at the protocol level.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>http</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
