1. 先搞清楚 h11 在整条链路里的位置写网络程序写久了会有一种错觉发个 HTTP 请求不就是拼几行字符串、然后从 socket 里读回来吗。真按这个思路写过一次你就会撞上一堆莫名其妙的问题——响应体没读完、连接被对面提前关掉、Transfer-Encoding: chunked的 16 进制长度行看着像乱码、Content-Length和实际字节数差了一个、想复用连接却发现状态对不上。这些问题里没有一个是业务逻辑问题全部出在协议层。h11就是专门用来干掉这一层的——它是一个纯 Python 实现的HTTP/1.1协议状态机不碰 socket、不碰线程、不碰事件循环只负责一件事吃进字节流或者事件吐出正确的事件或者字节流并且在这个过程中严格守住协议规范。很多人第一次听说它会觉得抽象但把它当成一个协议翻译器就很好理解了。你在上面写自己的 IO 层同步 socket、异步协程、内存管道都行往下把字节丢给它它帮你把GET /a HTTP/1.1这样的原始报文解析成h11.Request事件把一堆分块传输的数据解成h11.Data事件把结束标记解成h11.EndOfMessage。反过来你要发东西就把h11.Response、h11.Data这些事件交给它它帮你拼成合法的字节序列顺手把Content-Length、Transfer-Encoding、连接是否该关闭这些细节算清楚。这套东西解决的问题非常具体你有没有勇气把 HTTP 报文解析这一层自己写一遍并且保证它不出安全漏洞。自己手搓过解析器的人都知道请求行怎么切、头部有没有大小写陷阱、Content-Length被伪造两个怎么办、分块长度行带扩展参数怎么处理——每一个细节都是一次安全性事故的入口。h11 把这些问题收在一个两千行左右、有完整状态校验的库里让你在上面只关心我要收什么、我要发什么。它适合的人群也很明确正在写 HTTP 客户端、代理、网关、测试桩、抓包分析工具、需要精细控制协议的中间件开发者如果你的需求只是发一个请求拿个 JSON那用成熟的高层库就行不必自己往下钻。2. 核心设计拆解状态机加事件流为什么是正解2.1 状态机是协议唯一不会出错的本体HTTP/1.1 本质是一个有状态的双向对话。一次连接上客户端和服务端各自有自己的一套状态还没开始、正在发请求、正在发请求体、请求发完了、该收响应了、收完了、连接应该关闭、已关闭、出错了。这些状态之间的迁移是被规范钉死的不是随便走的——你不能在对方还没发完请求的时候就回响应也不能在响应体没发完的时候突然开始下一轮请求。用一堆if/else和布尔变量来表达这种约束写过的人都知道结局三个月后你自己都不敢改。h11 的做法是把状态显式建模成两个枚举——conn.our_state和conn.their_state取值是IDLE、SEND_RESPONSE、SEND_BODY、DONE、MUST_CLOSE、CLOSED、ERROR这几个。每次你send()或者next_event()它先检查当前状态允许不允许这个动作不允许就直接抛LocalProtocolError或者RemoteProtocolError把错误拦在造成混乱之前。这个设计带来的最大好处是错误提前暴露。举个真实场景你在写一个代理转发上游响应给下游客户端如果上游把Content-Length声明的字节数发少了就断了很多手写实现会默默把已经收到的内容吐给客户端客户端以为数据完整结果拿到半截 JSON。h11 会直接告诉你对端在消息体未完成时关闭了连接抛RemoteProtocolError你在转发层就能中断并返回一个正确的错误状态。这种宁可直接炸也不装没事的取向正是协议库该有的性格。2.2 三种特殊返回值NEED_DATA、PAUSED 与正常事件conn.next_event()的返回值其实有三类这是 h11 最容易踩坑的地方我拆开讲。第一类是真正的事件对象比如h11.Request、h11.Response、h11.Data、h11.EndOfMessage、h11.ConnectionClosed。这些是你业务逻辑要处理的。第二类是h11.NEED_DATA这个单例。它出现的含义是缓冲区里的字节不够解析出下一个完整事件了你得再喂点数据进来。注意它是单例判断要用is而不是这是作者刻意为之的。第三类是h11.PAUSED也是最容易被误解的一个。它表示我暂时没法继续了但不是因为缺数据。典型场景是服务端请求已经完整收下来了现在轮到我们发响应所以解析方向要暂停又比如客户端在Connection: close的模式下读完响应后连接就该关了。看到 PAUSED 不要继续读应该切换到写模式。这个三态设计其实是 h11 零 IO 的关键因为它不能主动去读 socket所以必须通过返回值告诉你现在该你干活了。理解了这一点整个 API 的手感就顺了。2.3 客户端与服务端共用一套事件词汇这是个很聪明的对称设计。无论你是h11.Connection(h11.CLIENT)还是h11.Connection(h11.SERVER)事件类型都是同一套Request、Response、Data、EndOfMessage。变化的只是方向客户端调send()时接受Request、Data、EndOfMessagenext_event()吐出Response、Data、EndOfMessage服务端正好相反。这个设计对写代理的人是巨大福利。因为你可以把从上游next_event()拿到的事件几乎原封不动地send()给下游——事件本身是方向无关的只有Connection知道当前该往哪边走。我做转发逻辑的时候核心代码就是一句downstream.send(event)Request转RequestResponse转Response只有InformationalResponse1xx 响应需要特殊处理一下因为它可以出现多次。顺带说一句InformationalResponse的存在感。很多手写实现直接忽略 1xx觉得反正没见过。但对101 Switching Protocols这种要切换协议的场景忽略它就意味着后面的连接会被当成普通 HTTP 数据乱解析。h11 把 1xx 单独建模成InformationalResponse是认真的。2.4 严格到不近人情的校验是有意为之用 h11 的第一感受往往是怎么这么爱报错。头部的名字不符合规范报错Content-Length和Transfer-Encoding同时出现报错消息体没发完就想开始下一轮报错收到空字节对端关闭时消息体不完整也报错。这些严格性不是作者较真而是在协议层做安全边界。HTTP 请求走私request smuggling这类问题根子就是不同实现对接头部的宽容度不一致——一个实现相信Content-Length另一个相信Transfer-Encoding中间夹一个代理两边理解出两条不同的请求流。h11 的选择是遇到这种歧义直接拒绝绝不猜一个合理的解释。代价是你必须在应用层兜住这些异常。我的习惯是LocalProtocolError属于自己写错了应该修代码不该在生产环境静默RemoteProtocolError属于对端行为异常应该降级成一个 400 或者 502 回应并记录日志。把这两类的处理策略分开能省掉大量排查时间。3. 客户端实操从零拼出一个能跑的请求3.1 环境准备与最小骨架依赖只有一个而且是纯 Python 的不需要编译扩展跨平台没有负担pip install h11搭一个最小客户端的骨架其实只有四行逻辑但每一步都有讲究import h11 conn h11.Connection(h11.CLIENT) raw conn.send(h11.Request( methodGET, target/api/health, headers[(Host, example.internal), (User-Agent, probe/0.1)], )) print(raw)跑一下你会看到它输出的就是标准报文bGET /api/health HTTP/1.1\r\nHost: example.internal\r\nUser-Agent: probe/0.1\r\n\r\n这里有个新手常犯的错忘了写Host头。HTTP/1.1 规定这个头是必须的h11 在客户端模式下会检查缺了直接抛LocalProtocolError。我在写探测工具时被这个拦过一次后来就养成习惯把Host放进构造参数而不是散在headers列表里随手拼。另外注意target只能是路径部分/api/health不能是完整 URL。想发绝对形式的话得显式指定但绝大多数场景用不到别自找麻烦。3.2 发送一个 GET 并正确收完响应发送动作分两步先send(Request)再send(EndOfMessage())。第二步很多人会漏尤其是没有请求体的 GET。漏了的后果是对端一直在等请求体结束你这边看着像卡住了。EndOfMessage()就是为了明确告诉对方我发完了。接收侧的循环是这套 API 的核心模板我建议你直接背下来while True: event conn.next_event() if event is h11.NEED_DATA: conn.receive_data(sock.recv(65536)) continue if event is h11.PAUSED: break if isinstance(event, h11.InformationalResponse): continue if isinstance(event, h11.Response): status event.status_code headers dict(event.headers) continue if isinstance(event, h11.Data): body.extend(event.data) continue if isinstance(event, h11.EndOfMessage): break if isinstance(event, h11.ConnectionClosed): break这个循环的骨架值得逐行理解。NEED_DATA时去读 socket读到的字节喂回receive_data()然后继续循环——而不是直接处理返回值因为一次喂进去可能解析出多个事件。recv()返回空字节时也要喂进去这是关键细节下一节细说。PAUSED直接退出因为此时该干别的了。3.3 处理 chunked 与连接复用Transfer-Encoding: chunked是手写实现翻车率最高的地方。分块格式长这样1a\r\n26字节数据\r\n0\r\n\r\n长度是十六进制还可能带;extval这样的扩展参数。自己解析要处理大小写、前导零、超长长度行、末尾 trailer 头。h11 把这层完全吃掉了。你看到的永远是一个个干净的h11.Data事件里面是解好的真实字节0\r\n\r\n这个结束块被翻译成EndOfMessage事件。你完全不需要知道底层是分块的还是定长的。这个抽象非常值钱意味着你写业务逻辑时不用维护两套代码路径。连接复用也有对应机制。响应处理完、EndOfMessage之后如果双方都没要求关闭conn.our_state和conn.their_state会落到DONE。这时候调用conn.start_next_cycle()就能在同一连接上发第二个请求h11 会帮你重置状态。但如果之前收到了Connection: close状态会是MUST_CLOSE这时候强行start_next_cycle()会抛异常——这个检查其实是在保护你避免往一个注定要断的连接上追加请求。3.4 一段可以直接抄的完整客户端把上面拼起来给一个能跑完整流程的版本import socket import h11 def fetch(host: str, port: int, target: str, timeout: float 10.0): 返回 (状态码, 头部字典, 响应体 bytes)。仅演示语义生产需补异常处理。 sock socket.create_connection((host, port), timeouttimeout) conn h11.Connection(h11.CLIENT) status None headers {} body bytearray() try: sock.sendall(conn.send(h11.Request( methodGET, targettarget, headers[ (Host, host), (User-Agent, h11-client/0.1), (Accept, */*), (Connection, close), ], ))) sock.sendall(conn.send(h11.EndOfMessage())) while True: event conn.next_event() if event is h11.NEED_DATA: conn.receive_data(sock.recv(65536)) continue if event is h11.PAUSED: break if isinstance(event, h11.InformationalResponse): continue if isinstance(event, h11.Response): status event.status_code headers { k.decode(latin-1).lower(): v.decode(latin-1) for k, v in event.headers } continue if isinstance(event, h11.Data): body.extend(event.data) continue if isinstance(event, h11.EndOfMessage): break if isinstance(event, h11.ConnectionClosed): break finally: sock.close() return status, headers, bytes(body)有两个细节值得单独说。第一头部是bytes列表我在这里统一用latin-1解码成文本。别用utf-8HTTP 头部历史上只保证 ASCII 兼容字节latin-1是字节到字符的无损映射用utf-8遇到非 ASCII 字节会直接抛异常。第二这段代码里显式加了Connection: close是为了让连接生命周期简单化。如果你想测试复用逻辑把它去掉换成在一个 socket 上循环start_next_cycle()。4. 服务端实操把字节流变成可处理的请求对象4.1 请求接收循环的关键判断服务端方向的循环结构和客户端相似但断点不一样——服务端的读循环以PAUSED结束因为请求收完之后h11 会暂停解析等你发响应。conn h11.Connection(h11.SERVER) request None body bytearray() while True: event conn.next_event() if event is h11.NEED_DATA: chunk client.recv(65536) conn.receive_data(chunk) continue if event is h11.PAUSED: break if isinstance(event, h11.Request): request event continue if isinstance(event, h11.Data): body.extend(event.data) continue if isinstance(event, h11.EndOfMessage): break if isinstance(event, h11.ConnectionClosed): breakevent.method、event.target、event.headers都是原始字节method是bytestarget也是bytes这个设计上有点反直觉但方向是对的——协议层不该替你做字符集假设。你在业务层按需要解码就行。一个必须处理的细节recv()返回b时不能直接 break要把空字节喂给receive_data()。h11 用收到零长度数据作为对端关闭连接的信号它才能正确判断出消息体还没收完对面就断了并抛出RemoteProtocolError。如果你看到b就自己退出这个错误就被吞了你会以为请求正常结束然后处理一个残缺的 body。这是个非常隐蔽的坑。4.2 响应的三种收尾方式与 Content-Length 的取舍发响应的时候h11 对消息体的边界处理有一套规则了解它能少踩很多坑。第一种有明确长度头部带Content-Length: N然后发Data事件最后EndOfMessage。body ok.encode() client.sendall(conn.send(h11.Response( status_code200, headers[ (Content-Type, text/plain; charsetutf-8), (Content-Length, str(len(body))), ], ))) client.sendall(conn.send(h11.Data(databody))) client.sendall(conn.send(h11.EndOfMessage()))第二种让 h11 自己选如果你不给Content-Length也不给Transfer-Encodingh11 会自动帮你加上Transfer-Encoding: chunked并把Data事件编码成分块传输。这样你就不用提前知道总长度。第三种靠连接关闭界定发Connection: close头不给长度发完直接关 socket。这是 HTTP/1.0 的老办法只在特殊场景用。三者的取舍很清晰响应体大小能提前算出来就用第一种客户端不需要额外缓冲最好调流式输出比如边生成边推用第二种但要记得分块编码本身有轻微开销而且中间若有代理可能不支持第三种尽量别用它意味着连接不能复用性能会掉一截。有个坑我必须提醒h11.Data事件的data参数必须是bytes传str会抛TypeError。而且EndOfMessage()如果带了headers参数那就是发 trailer 头——普通场景留空就行别乱传。4.3 100-continue 与 Upgrade 的处理Expect: 100-continue这个机制很多人只知道名字不知道用法。客户端发一个大请求体之前先带这个头发个请求头过去问服务端你收不收服务端如果愿意就回一个100 Continue客户端再发体内内容。这样避免了服务端根本不想收客户端却已经把 200MB 传完了的浪费。h11 在这方面提供了支持。服务端拿到Request事件后可以检查conn.they_are_waiting_for_100_continue属性为真就先发一个InformationalResponse(status_code100)再发正式响应。客户端侧 h11 也会正确处理收到 1xx 后继续发体的流程。如果你不做这个判断直接发200加体协议上也能工作但就浪费了这次协商的机会。Upgrade和CONNECT属于另一类场景发个101或者200之后连接就脱离 HTTP 语义了变成裸的字节通道。h11 在收到或发出这类特殊响应后会进入一个旁路状态之后的字节不会再用 HTTP 规则解析。如果你在做 WebSocket 握手、隧道代理这类东西这块要看清楚文档再动手不然很容易把后续的原始字节当成 HTTP 消息体吃掉。5. 问题排查实录那些报错到底在说什么5.1 状态相关异常的对照表h11 的异常信息其实写得挺清楚但第一次见到LocalProtocolError: cant send Data when in state DONE还是会愣一下。我把高频的几类整理成表遇到直接对号入座。报错关键词触发原因处理方式cant send Request/cant send Data当前状态不允许这类事件检查conn.our_state确认是否漏了EndOfMessage或提前发了事件Receive buffer too long单次事件累积超过max_incomplete_event_size提高上限或排查对端是否在发垃圾数据peer closed connection without sending complete message body对端在消息体未完成时断开应用层降级处理返回错误状态给上层Got both Content-Length and Transfer-Encoding头部同时出现两个长度界定按协议直接拒绝不要试图选一个illegal header name头部名字含非法字符检查是否用了非 ASCII 或空格event sequence out of order客户端/服务端角色搞反确认h11.Connection()的参数RemoteProtocolError和LocalProtocolError的区别要记牢前者是对方或网络的问题可以降级后者基本是自己代码逻辑有 bug需要修。把两者混在一起except Exception兜住等于把 bug 藏起来。5.2 缓冲区与 Paused 的典型误用有一个非常常见的错误模式看到PAUSED之后继续调next_event()想再挤一个事件出来。结果要么一直拿到PAUSED死循环要么拿到状态异常。PAUSED的语义就是停方向要切了必须跳出当前循环去发东西或者关闭连接。另一个高频坑是max_incomplete_event_size的默认值。h11 默认给单个未完成事件留一个有限缓冲十几 KB 量级这个值设计意图是防对端一直发不带换行的垃圾数据把内存吃干。如果你的应用场景里单条头部特别大比如某些携带大 cookie 或代理链头的请求就会撞上Receive buffer too long。正确做法是构造Connection时显式调大conn h11.Connection(h11.SERVER, max_incomplete_event_size2 ** 20)但别一拍脑袋调到几 MB那等于把这个防护拆了。按你实际业务里最大的头部合法尺寸留个两三倍余量就够了。5.3 头部与请求行的边界情况头部相关的坑集中在几个地方。头部数量上限。h11 内部对单条消息的头部数量有上限保护超过会报错。如果你的场景是处理带几十条自定义头的内部调用一般碰不到但如果是做通用的反向代理要考虑对端可能发一堆重复头的情况。大小写处理。HTTP 头部名字大小写不敏感但 h11 返回的是原始字节不会帮你归一化。所以我强烈建议在事件处理的第一步就自己统一转小写别在业务逻辑里到处if name bContent-Length or name bcontent-length。重复头部。像Set-Cookie这种天然可以出现多次的event.headers里就是多个元组不要用dict()一把梭直接转字典会丢数据。要合并的字段比如接收端的Accept自己按逗号拼。请求行里的 target 形式。GET http://a/b HTTP/1.1和GET /b HTTP/1.1都合法前者出现在代理场景。如果你的服务只接后者要做显式判断而不是假设它一定以/开头。6. 工程化落地集成、性能与测试6.1 与异步事件循环的粘合方式h11 零 IO 的设计让它天生适配异步。在协程框架里你只需要把sock.recv()换成await reader.read(n)把sock.sendall()换成await writer.write(data)整个循环结构一行都不用改。async def handle(reader, writer): conn h11.Connection(h11.SERVER) while True: event conn.next_event() if event is h11.NEED_DATA: conn.receive_data(await reader.read(65536)) continue if event is h11.PAUSED: break if isinstance(event, h11.EndOfMessage): break # 这里开始发响应连接资源全部由外层管这种解耦的价值在于你可以对它做单元测试。因为Connection不需要真实 socket你完全可以在测试里模拟分三次收到数据甚至每次只喂一个字节验证解析逻辑在各种切分下都正确。这一点比任何性能优势都实用——TCP 分包的边界是不可控的能对着字节切片做测试的协议库才敢放进生产。6.2 内存与吞吐的实际取舍有几个性能相关的判断值得说清楚。不要一次读太大。recv(65536)是常见起点但如果你要做流式转发比如大文件下载应该边收边往外吐Data事件而不是把整个 body 攒进bytearray再一次性处理。h11 的Data事件是能持续吐的攒起来纯粹是浪费内存。事件构造要复用。h11.Data(datachunk)每次都会创建对象热路径上这是可观的分配量。实践中我一般控制在 16~64KB 一个块太小了事件对象创建的开销占比会上去太大了内存峰值又不好看。状态检查是廉价的。有人担心每步都做状态校验会拖慢速度实测下来这部分开销在整体网络 IO 面前基本可以忽略完全没必要为了省这点去做什么跳过校验的骚操作。头部解析是热点之一。如果你的服务是典型的小请求高 QPS 场景头部解析的 CPU 占比会比想象中高。这时候值得做的是减少不必要的中间拷贝把event.headers直接在自己的结构里复用而不是先转字典再转对象转两遍。6.3 用性质测试喂给它畸形输入协议实现的正确性没法靠写几个正常的用例保证得靠畸形输入。我自己在维护协议相关代码时的习惯是准备一批怪物报文当回归测试头部行只给\n不给\r\nContent-Length: 5但实际只发 3 字节就断开分块长度行写成0x1a带0x前缀或者超长数字同时给Content-Length和Transfer-Encoding头部字段名里混入空字节或空格请求行缺版本号、多一个空格头部总数上千条把这些逐条喂给receive_data()验证要么正确解析、要么抛出RemoteProtocolError绝不能出现解析成功但结果不对。最后这一类才是最危险的因为它不会报错只会在业务层造成数据错乱。h11 在这方面表现是可靠的但前提是你真的用异常处理把它兜住了——很多项目在这一步挂了except Exception: pass等于白搭。还有一个实践技巧把Connection的状态机当成测试断言的一部分。在关键节点检查conn.our_state、conn.their_state是不是符合预期比检查输出字节更能定位问题在哪。7. 几句个人体会用 h11 这类库最需要调整的心态是它不帮你做事它帮你不出错。它不会替你去连接、不会替你重试、不会替你解压你要的这些它一概不管但只要你把字节喂对了、把事件接住了它保证底下的协议层不会给你埋雷。我在实际使用中最大的收获是它逼着我把协议层和业务层的边界划清楚了。以前写网络代码解析、路由、业务逻辑混在一个函数里出问题只能靠打日志一段段猜现在边上是一个纯粹的事件循环中间是一条明确的异常边界LocalProtocolError出现就是自己写错RemoteProtocolError出现就是对面不老实处理策略一刀切开排查效率差了不止一个量级。如果你的项目正在自己拼 HTTP 报文或者打算写一个需要精细控制协议行为的东西——代理、测试桩、协议分析工具——花两个小时把 h11 的事件模型吃透比在\r\n和Content-Length上反复翻车要划算得多。真要给一条最实在的建议先把喂数据 → 取事件 → 判断 NEED_DATA/PAUSED → 切换方向这个循环模板抄熟剩下的都是在它外面套业务。这个模板写顺了后面所有场景都是它的变体。
阅读完成 · 觉得有帮助?