Skip to content

httptools: Connection: Close does not close the connection #3074

Description

@8dazo

Discussed in #3071

Originally posted by 8dazo August 17, 2026

Initial Checks

  • I confirm this was discussed, and the maintainers suggest I open an issue.
  • I'm aware that if I created this issue without a discussion, it may be closed without a response.

Discussion Link

#3071

Description

On the httptools HTTP implementation (what --http auto selects when httptools is installed), Uvicorn only honors a request Connection: close when the header value is exactly lowercase close.

Connection: Close and Connection: keep-alive, close leave the TCP connection open and do not echo Connection: close on the response.

This is a bug, not intentional:

  • docs/server-behavior.md says a Connection: Close header must close the connection, and that HTTP headers are case-insensitive.
  • RFC 9112 §9.6: a server that receives a close connection option MUST close after the final response, and SHOULD send Connection: close on that response. Tokens are case-insensitive (RFC 9110 §7.6.1).
  • httptools already treats the response Connection value as case-insensitive (value.lower() == b"close"). The request check is an exact tuple match on (b"connection", b"close"). Existing tests only send lowercase close.

As noted in the discussion, the exact-match is not local to httptools. CLOSE_HEADER in flow_control.py is used the same way in httptools_impl.py, h11_impl.py, and zttp_impl.py. h11 and zttp still close via their own state machines, but they also fail to echo Connection: close for mixed-case / multi-token values (RFC SHOULD). Patching only httptools would leave that echo gap.

Lowercasing the whole header value is not enough for keep-alive, close. Connection is a comma-separated token list. The same tokenisation already exists in _get_upgrade() (split, strip, lower, membership test). A small helper next to CLOSE_HEADER (split / strip / lowercase each token) should be used for the echo in all implementations, and for httptools' keep-alive decision.

Actual (0.52.3 / main):

h11        close | Close | keep-alive, close   closing=True  echo=True for lowercase only
httptools  close                               closing=True  echo=True
httptools  Close                               closing=False echo=False
httptools  keep-alive, close                   closing=False echo=False
zttp       close                               closing=True  echo=True
zttp       Close / keep-alive, close           closing=True  echo=False

Expected: all three implementations close after the response and echo Connection: close whenever the request Connection header contains a close token, regardless of case, surrounding whitespace, or sibling tokens.

Example Code

import asyncio

from tests.protocols.test_http import get_connected_protocol
from tests.response import Response
from uvicorn.protocols.http.h11_impl import H11Protocol
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
from uvicorn.protocols.http.zttp_impl import ZttpProtocol

REQUESTS = {
    "lower": b"GET / HTTP/1.1\r\nHost: example.org\r\nConnection: close\r\n\r\n",
    "upper": b"GET / HTTP/1.1\r\nHost: example.org\r\nConnection: Close\r\n\r\n",
    "mixed": b"GET / HTTP/1.1\r\nHost: example.org\r\nConnection: keep-alive, close\r\n\r\n",
}

async def main():
    for name, cls in [
        ("h11", H11Protocol),
        ("httptools", HttpToolsProtocol),
        ("zttp", ZttpProtocol),
    ]:
        for key, req in REQUESTS.items():
            protocol = get_connected_protocol(
                Response("Hello, world", media_type="text/plain"),
                cls,
                access_log=False,
            )
            protocol.data_received(req)
            await protocol.loop.run_one()
            buf = protocol.transport.buffer.lower()
            print(
                name,
                key,
                "closing=",
                protocol.transport.is_closing(),
                "echo=",
                b"connection: close" in buf,
            )

asyncio.run(main())

Python, Uvicorn & OS Version

Running uvicorn 0.52.3 with CPython 3.12.13 on Darwin

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions