Repository: twisted/twisted

Issue:
Speed up HTTP header processing even more
Currently every get and set of headers re-encodes the header name, and handles both strings and bytes. This is good for user code, but adds overhead. For built-in Twisted interactions with fixed header names, we can do better.

Discussion:
codspeed-hq[bot]: ## [CodSpeed Performance Report](https://codspeed.io/twisted/twisted/branches/12191-faster-http-headers-maybe)

### Merging #12264 will **degrade performances by 2.37%**

<sub>Comparing <code>12191-faster-http-headers-maybe</code> (18a3f02) with <code>12257-more-http-server-benchmarks</code> (d077674)</sub>



### Summary

`❌ 1` regressions
`✅ 15` untouched benchmarks

`⁉️ 4` dropped benchmarks


> :warning: _Please fix the performance issues or [acknowledge them on CodSpeed](https://codspeed.io/twisted/twisted/branches/12191-faster-http-headers-maybe)._

### Benchmarks breakdown

|     | Benchmark | `12257-more-http-server-benchmarks` | `12191-faster-http-headers-maybe` | Change |
| --- | --------- | ----------------------- | ------------------- | ------ |
| ❌ | `test_http_client_small_response` | 325.2 µs | 333.1 µs | -2.37% |
| ⁉️ | `test_bit_more_complex_response` | 426.1 µs | N/A | N/A |
| ⁉️ | `test_http11_server_chunked_request` | 1.8 ms | N/A | N/A |
| ⁉️ | `test_http11_server_chunked_response` | 854.9 µs | N/A | N/A |
| ⁉️ | `test_http11_server_many_headers` | 1.2 ms | N/A | N/A |

twm: This is *not* ready for review; I am trying to get CodSpeed to re-run.
twm: This is ready; please review.

Patch:
diff --git a/src/twisted/web/_abnf.py b/src/twisted/web/_abnf.py
new file mode 100644
index 0000000000..a9fd5a48de
--- /dev/null
+++ b/src/twisted/web/_abnf.py
@@ -0,0 +1,68 @@
+# -*- test-case-name: twisted.web.test.test_abnf -*-
+# Copyright (c) Twisted Matrix Laboratories.
+# See LICENSE for details.
+
+"""
+Tools for pedantically processing the HTTP protocol.
+"""
+
+
+def _istoken(b: bytes) -> bool:
+    """
+    Is the string a token per RFC 9110 section 5.6.2?
+    """
+    for c in b:
+        if c not in (
+            b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"  # ALPHA
+            b"0123456789"  # DIGIT
+            b"!#$%^'*+-.^_`|~"
+        ):
+            return False
+    return b != b""
+
+
+def _decint(data: bytes) -> int:
+    """
+    Parse a decimal integer of the form C{1*DIGIT}, i.e. consisting only of
+    decimal digits. The integer may be embedded in whitespace (space and
+    horizontal tab). This differs from the built-in L{int()} function by
+    disallowing a leading C{+} character and various forms of whitespace
+    (note that we sanitize linear whitespace in header values in
+    L{twisted.web.http_headers.Headers}).
+
+    @param data: Value to parse.
+
+    @returns: A non-negative integer.
+
+    @raises ValueError: When I{value} contains non-decimal characters.
+    """
+    data = data.strip(b" \t")
+    if not data.isdigit():
+        raise ValueError(f"Value contains non-decimal digits: {data!r}")
+    return int(data)
+
+
+def _ishexdigits(b: bytes) -> bool:
+    """
+    Is the string case-insensitively hexidecimal?
+
+    It must be composed of one or more characters in the ranges a-f, A-F
+    and 0-9.
+    """
+    for c in b:
+        if c not in b"0123456789abcdefABCDEF":
+            return False
+    return b != b""
+
+
+def _hexint(b: bytes) -> int:
+    """
+    Decode a hexadecimal integer.
+
+    Unlike L{int(b, 16)}, this raises L{ValueError} when the integer has
+    a prefix like C{b'0x'}, C{b'+'}, or C{b'-'}, which is desirable when
+    parsing network protocols.
+    """
+    if not _ishexdigits(b):
+        raise ValueError(b)
+    return int(b, 16)
diff --git a/src/twisted/web/_http2.py b/src/twisted/web/_http2.py
index f048c7335e..93c32723b5 100644
--- a/src/twisted/web/_http2.py
+++ b/src/twisted/web/_http2.py
@@ -14,7 +14,6 @@ This API is currently considered private because it's in early draft form. When
 it has stabilised, it'll be made public.
 """
 
-
 import io
 from collections import deque
 from typing import List
@@ -972,7 +971,7 @@ class H2Stream:
                 self._request.gotLength(None)
 
         self._request.parseCookies()
-        expectContinue = self._request.requestHeaders.getRawHeaders(b"expect")
+        expectContinue = self._request.requestHeaders.getRawHeaders(b"Expect")
         if expectContinue and expectContinue[0].lower() == b"100-continue":
             self._send100Continue()
 
diff --git a/src/twisted/web/_newclient.py b/src/twisted/web/_newclient.py
index a151bdae05..fa22c99d17 100644
--- a/src/twisted/web/_newclient.py
+++ b/src/twisted/web/_newclient.py
@@ -42,6 +42,7 @@ from twisted.protocols.basic import LineReceiver
 from twisted.python.compat import networkString
 from twisted.python.components import proxyForInterface
 from twisted.python.failure import Failure
+from twisted.web._abnf import _decint, _istoken
 from twisted.web.http import (
     NO_CONTENT,
     NOT_MODIFIED,
@@ -478,7 +479,7 @@ class HTTPClientParser(HTTPParser):
             self.response._bodyDataFinished()
         else:
             transferEncodingHeaders = self.connHeaders.getRawHeaders(
-                b"transfer-encoding"
+                b"Transfer-Encoding"
             )
             if transferEncodingHeaders:
                 # This could be a KeyError.  However, that would mean we do not
@@ -556,35 +557,6 @@ class HTTPClientParser(HTTPParser):
             del self._responseDeferred
 
 
-_VALID_METHOD = re.compile(
-    rb"\A[%s]+\Z"
-    % (
-        bytes().join(
-            (
-                b"!",
-                b"#",
-                b"$",
-                b"%",
-                b"&",
-                b"'",
-                b"*",
-                b"+",
-                b"-",
-                b".",
-                b"^",
-                b"_",
-                b"`",
-                b"|",
-                b"~",
-                b"\x30-\x39",
-                b"\x41-\x5a",
-                b"\x61-\x7a",
-            ),
-        ),
-    ),
-)
-
-
 def _ensureValidMethod(method):
     """
     An HTTP method is an HTTP token, which consists of any visible
@@ -603,7 +575,7 @@ def _ensureValidMethod(method):
         U{https://tools.ietf.org/html/rfc7230#section-3.2.6},
         U{https://tools.ietf.org/html/rfc5234#appendix-B.1}
     """
-    if _VALID_METHOD.match(method):
+    if _istoken(method):
         return method
     raise ValueError(f"Invalid method {method!r}")
 
@@ -634,27 +606,6 @@ def _ensureValidURI(uri):
     raise ValueError(f"Invalid URI {uri!r}")
 
 
-def _decint(data: bytes) -> int:
-    """
-    Parse a decimal integer of the form C{1*DIGIT}, i.e. consisting only of
-    decimal digits. The integer may be embedded in whitespace (space and
-    horizontal tab). This differs from the built-in L{int()} function by
-    disallowing a leading C{+} character and various forms of whitespace
-    (note that we sanitize linear whitespace in header values in
-    L{twisted.web.http_headers.Headers}).
-
-    @param data: Value to parse.
-
-    @returns: A non-negative integer.
-
-    @raises ValueError: When I{value} contains non-decimal characters.
-    """
-    data = data.strip(b" \t")
-    if not data.isdigit():
-        raise ValueError(f"Value contains non-decimal digits: {data!r}")
-    return int(data)
-
-
 def _contentLength(connHeaders: Headers) -> Optional[int]:
     """
     Parse the I{Content-Length} connection header.
@@ -681,7 +632,7 @@ def _contentLength(connHeaders: Headers) -> Optional[int]:
 
     @see: U{https://datatracker.ietf.org/doc/html/rfc9110#section-8.6}
     """
-    headers = connHeaders.getRawHeaders(b"content-length")
+    headers = connHeaders.getRawHeaders(b"Content-Length")
     if headers is None:
         return None
 
@@ -781,7 +732,7 @@ class Request:
         return getattr(self._parsedURI, "toBytes", lambda: None)()
 
     def _writeHeaders(self, transport, TEorCL):
-        hosts = self.headers.getRawHeaders(b"host", ())
+        hosts = self.headers.getRawHeaders(b"Host", ())
         if len(hosts) != 1:
             raise BadHeaders("Exactly one Host header required")
 
@@ -1661,7 +1612,7 @@ class HTTP11ClientProtocol(Protocol):
             return
 
         reason = ConnectionDone("synthetic!")
-        connHeaders = self._parser.connHeaders.getRawHeaders(b"connection", ())
+        connHeaders = self._parser.connHeaders.getRawHeaders(b"Connection", ())
         if (
             (b"close" in connHeaders)
             or self._state != "QUIESCENT"
diff --git a/src/twisted/web/client.py b/src/twisted/web/client.py
index b06f1bef28..cfd945d5d4 100644
--- a/src/twisted/web/client.py
+++ b/src/twisted/web/client.py
@@ -37,7 +37,7 @@ from twisted.python.deprecate import (
 from twisted.python.failure import Failure
 from twisted.web import error, http
 from twisted.web._newclient import _ensureValidMethod, _ensureValidURI
-from twisted.web.http_headers import Headers
+from twisted.web.http_headers import Headers, _nameEncoder
 from twisted.web.iweb import (
     UNKNOWN_LENGTH,
     IAgent,
@@ -924,10 +924,10 @@ class _AgentBase:
         # Create minimal headers, if necessary:
         if headers is None:
             headers = Headers()
-        if not headers.hasHeader(b"host"):
+        if not headers.hasHeader(b"Host"):
             headers = headers.copy()
             headers.addRawHeader(
-                b"host",
+                b"Host",
                 self._computeHostValue(
                     parsedURI.scheme, parsedURI.host, parsedURI.port
                 ),
@@ -1367,12 +1367,12 @@ class CookieAgent:
         lastRequest = _FakeStdlibRequest(uri)
         # Setting a cookie header explicitly will disable automatic request
         # cookies.
-        if not actualHeaders.hasHeader(b"cookie"):
+        if not actualHeaders.hasHeader(b"Cookie"):
             self.cookieJar.add_cookie_header(lastRequest)
             cookieHeader = lastRequest.get_header("Cookie", None)
             if cookieHeader is not None:
                 actualHeaders = actualHeaders.copy()
-                actualHeaders.addRawHeader(b"cookie", networkString(cookieHeader))
+                actualHeaders.addRawHeader(b"Cookie", networkString(cookieHeader))
 
         return self._agent.request(
             method, uri, actualHeaders, bodyProducer
@@ -1502,7 +1502,7 @@ class ContentDecoderAgent:
             headers = Headers()
         else:
             headers = headers.copy()
-        headers.addRawHeader(b"accept-encoding", self._supported)
+        headers.addRawHeader(b"Accept-Encoding", self._supported)
         deferred = self._agent.request(method, uri, headers, bodyProducer)
         return deferred.addCallback(self._handleResponse)
 
@@ -1510,7 +1510,7 @@ class ContentDecoderAgent:
         """
         Check if the response is encoded, and wrap it to handle decompression.
         """
-        contentEncodingHeaders = response.headers.getRawHeaders(b"content-encoding", [])
+        contentEncodingHeaders = response.headers.getRawHeaders(b"Content-Encoding", [])
         contentEncodingHeaders = b",".join(contentEncodingHeaders).split(b",")
         while contentEncodingHeaders:
             name = contentEncodingHeaders.pop().strip()
@@ -1523,14 +1523,14 @@ class ContentDecoderAgent:
                 break
         if contentEncodingHeaders:
             response.headers.setRawHeaders(
-                b"content-encoding", [b",".join(contentEncodingHeaders)]
+                b"Content-Encoding", [b",".join(contentEncodingHeaders)]
             )
         else:
-            response.headers.removeHeader(b"content-encoding")
+            response.headers.removeHeader(b"Content-Encoding")
         return response
 
 
-_canonicalHeaderName = Headers()._encodeName
+_canonicalHeaderName = _nameEncoder.encode
 _defaultSensitiveHeaders = frozenset(
     [
         b"Authorization",
diff --git a/src/twisted/web/http.py b/src/twisted/web/http.py
index 80650fdd6e..18710f51dc 100644
--- a/src/twisted/web/http.py
+++ b/src/twisted/web/http.py
@@ -143,6 +143,7 @@ from twisted.python.compat import nativeString, networkString
 from twisted.python.components import proxyForInterface
 from twisted.python.deprecate import deprecated, deprecatedModuleAttribute
 from twisted.python.failure import Failure
+from twisted.web._abnf import _hexint, _istoken
 from twisted.web._responses import (
     ACCEPTED,
     BAD_GATEWAY,
@@ -190,7 +191,12 @@ from twisted.web._responses import (
     UNSUPPORTED_MEDIA_TYPE,
     USE_PROXY,
 )
-from twisted.web.http_headers import Headers, _sanitizeLinearWhitespace
+from twisted.web.http_headers import (
+    Headers,
+    InvalidHeaderName,
+    _nameEncoder,
+    _sanitizeLinearWhitespace,
+)
 from twisted.web.iweb import IAccessLogFormatter, INonQueuedRequestFactory, IRequest
 
 try:
@@ -507,46 +513,6 @@ def toChunk(data):
     return (networkString(f"{len(data):x}"), b"\r\n", data, b"\r\n")
 
 
-def _istoken(b: bytes) -> bool:
-    """
-    Is the string a token per RFC 9110 section 5.6.2?
-    """
-    for c in b:
-        if c not in (
-            b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"  # ALPHA
-            b"0123456789"  # DIGIT
-            b"!#$%^'*+-.^_`|~"
-        ):
-            return False
-    return b != b""
-
-
-def _ishexdigits(b: bytes) -> bool:
-    """
-    Is the string case-insensitively hexidecimal?
-
-    It must be composed of one or more characters in the ranges a-f, A-F
-    and 0-9.
-    """
-    for c in b:
-        if c not in b"0123456789abcdefABCDEF":
-            return False
-    return b != b""
-
-
-def _hexint(b: bytes) -> int:
-    """
-    Decode a hexadecimal integer.
-
-    Unlike L{int(b, 16)}, this raises L{ValueError} when the integer has
-    a prefix like C{b'0x'}, C{b'+'}, or C{b'-'}, which is desirable when
-    parsing network protocols.
-    """
-    if not _ishexdigits(b):
-        raise ValueError(b)
-    return int(b, 16)
-
-
 def fromChunk(data: bytes) -> Tuple[bytes, bytes]:
     """
     Convert chunk to string.
@@ -1031,7 +997,7 @@ class Request:
 
         This method is not intended for users.
         """
-        cookieheaders = self.requestHeaders.getRawHeaders(b"cookie")
+        cookieheaders = self.requestHeaders.getRawHeaders(b"Cookie")
 
         if cookieheaders is None:
             return
@@ -1086,7 +1052,7 @@ class Request:
 
         # Argument processing
         args = self.args
-        ctype = self.requestHeaders.getRawHeaders(b"content-type")
+        ctype = self.requestHeaders.getRawHeaders(b"Content-Type")
         if ctype is not None:
             ctype = ctype[0]
 
@@ -1270,7 +1236,7 @@ class Request:
         """
         if self.finished:
             raise RuntimeError(
-                "Request.write called on a request after " "Request.finish was called."
+                "Request.write called on a request after Request.finish was called."
             )
 
         if self._disconnected:
@@ -1290,7 +1256,7 @@ class Request:
             # persistent connections.
             if (
                 (version == b"HTTP/1.1")
-                and (self.responseHeaders.getRawHeaders(b"content-length") is None)
+                and (self.responseHeaders.getRawHeaders(b"Content-Length") is None)
                 and self.method != b"HEAD"
                 and self.code not in NO_BODY_CODES
             ):
@@ -1298,14 +1264,14 @@ class Request:
                 self.chunked = 1
 
             if self.lastModified is not None:
-                if self.responseHeaders.hasHeader(b"last-modified"):
+                if self.responseHeaders.hasHeader(b"Last-Modified"):
                     self._log.info(
                         "Warning: last-modified specified both in"
                         " header list and lastModified attribute."
                     )
                 else:
                     self.responseHeaders.setRawHeaders(
-                        b"last-modified", [datetimeToString(self.lastModified)]
+                        b"Last-Modified", [datetimeToString(self.lastModified)]
                     )
 
             if self.etag is not None:
@@ -1483,7 +1449,7 @@ class Request:
         @type url: L{bytes} or L{str}
         """
         self.setResponseCode(FOUND)
-        self.setHeader(b"location", url)
+        self.setHeader(b"Location", url)
 
     def setLastModified(self, when):
         """
@@ -1510,7 +1476,7 @@ class Request:
         if (not self.lastModified) or (self.lastModified < when):
             self.lastModified = when
 
-        modifiedSince = self.getHeader(b"if-modified-since")
+        modifiedSince = self.getHeader(b"If-Modified-Since")
         if modifiedSince:
             firstPart = modifiedSince.split(b";", 1)[0]
             try:
@@ -1544,7 +1510,7 @@ class Request:
         if etag:
             self.etag = etag
 
-        tags = self.getHeader(b"if-none-match")
+        tags = self.getHeader(b"If-None-Match")
         if tags:
             tags = tags.split()
             if (etag in tags) or (b"*" in tags):
@@ -1578,7 +1544,7 @@ class Request:
 
         @rtype: C{bytes}
         """
-        host = self.getHeader(b"host")
+        host = self.getHeader(b"Host")
         if host is not None:
             match = _hostHeaderExpression.match(host)
             if match is not None:
@@ -1626,7 +1592,7 @@ class Request:
             hostHeader = host
         else:
             hostHeader = b"%b:%d" % (host, port)
-        self.requestHeaders.setRawHeaders(b"host", [hostHeader])
+        self.requestHeaders.setRawHeaders(b"Host", [hostHeader])
         self.host = address.IPv4Address("TCP", host, port)
 
     @deprecated(Version("Twisted", 18, 4, 0), replacement="getClientAddress")
@@ -2445,7 +2411,7 @@ class HTTPChannel(basic.LineReceiver, policies.TimeoutMixin):
             return False
 
         # Can this header determine the length?
-        if header == b"content-length":
+        if header == b"Content-Length":
             if not data.isdigit():
                 return fail()
             try:
@@ -2455,7 +2421,7 @@ class HTTPChannel(basic.LineReceiver, policies.TimeoutMixin):
             newTransferDecoder = _IdentityTransferDecoder(
                 length, self.requests[-1].handleContentChunk, self._finishRequestBody
             )
-        elif header == b"transfer-encoding":
+        elif header == b"Transfer-Encoding":
             # XXX Rather poorly tested code block, apparently only exercised by
             # test_chunkedEncoding
             if data.lower() == b"chunked":
@@ -2480,7 +2446,7 @@ class HTTPChannel(basic.LineReceiver, policies.TimeoutMixin):
 
     def headerReceived(self, line):
         """
-        Do pre-processing (for content-length) and store this header away.
+        Do pre-processing (for Content-Length) and store this header away.
         Enforce the per-request header limit.
 
         @type line: C{bytes}
@@ -2496,12 +2462,13 @@ class HTTPChannel(basic.LineReceiver, policies.TimeoutMixin):
             self._respondToBadRequestAndDisconnect()
             return False
 
-        # Header names must be tokens, per RFC 9110 section 5.1.
-        if not _istoken(header):
+        # Canonicalize the header name.
+        try:
+            header = _nameEncoder.encode(header)
+        except InvalidHeaderName:
             self._respondToBadRequestAndDisconnect()
             return False
 
-        header = header.lower()
         data = data.strip(b" \t")
         if b"\x00" in data:
             self._respondToBadRequestAndDisconnect()
@@ -2595,7 +2562,7 @@ class HTTPChannel(basic.LineReceiver, policies.TimeoutMixin):
         req.gotLength(self.length)
         # Handle 'Expect: 100-continue' with automated 100 response code,
         # a simplistic implementation of RFC 2686 8.2.3:
-        expectContinue = req.requestHeaders.getRawHeaders(b"expect")
+        expectContinue = req.requestHeaders.getRawHeaders(b"Expect")
         if (
             expectContinue
             and expectContinue[0].lower() == b"100-continue"
@@ -2620,7 +2587,7 @@ class HTTPChannel(basic.LineReceiver, policies.TimeoutMixin):
             must be closed in order to indicate the completion of the response
             to C{request}.
         """
-        connection = request.requestHeaders.getRawHeaders(b"connection")
+        connection = request.requestHeaders.getRawHeaders(b"Connection")
         if connection:
             tokens = [t.lower() for t in connection[0].split(b" ")]
         else:
@@ -2639,7 +2606,7 @@ class HTTPChannel(basic.LineReceiver, policies.TimeoutMixin):
 
         if version == b"HTTP/1.1":
             if b"close" in tokens:
-                request.responseHeaders.setRawHeaders(b"connection", [b"close"])
+                request.responseHeaders.setRawHeaders(b"Connection", [b"close"])
                 return False
             else:
                 return True
@@ -3037,7 +3004,7 @@ class _XForwardedForRequest(proxyForInterface(IRequest, "_request")):  # type: i
             expected by L{combinedLogFormatter}.
         """
         host = (
-            self._request.requestHeaders.getRawHeaders(b"x-forwarded-for", [b"-"])[0]
+            self._request.requestHeaders.getRawHeaders(b"X-Forwarded-For", [b"-"])[0]
             .split(b",")[0]
             .strip()
         )
diff --git a/src/twisted/web/http_headers.py b/src/twisted/web/http_headers.py
index 8b1d41adb6..88b653439b 100644
--- a/src/twisted/web/http_headers.py
+++ b/src/twisted/web/http_headers.py
@@ -22,18 +22,26 @@ from typing import (
 )
 
 from twisted.python.compat import cmp, comparable
+from twisted.web._abnf import _istoken
+
+
+class InvalidHeaderName(ValueError):
+    """
+    HTTP header names must be tokens, per RFC 9110 section 5.1.
+    """
+
 
 _T = TypeVar("_T")
 
 
 def _sanitizeLinearWhitespace(headerComponent: bytes) -> bytes:
     r"""
-    Replace linear whitespace (C{\n}, C{\r\n}, C{\r}) in a header key
-    or value with a single space.
+    Replace linear whitespace (C{\n}, C{\r\n}, C{\r}) in a header
+    value with a single space.
 
-    @param headerComponent: The header key or value to sanitize.
+    @param headerComponent: The header value to sanitize.
 
-    @return: The sanitized header key or value.
+    @return: The sanitized header value.
     """
     return b" ".join(headerComponent.splitlines())
 
@@ -53,31 +61,10 @@ class Headers:
     ensure no decoding or encoding is done, and L{Headers} will treat the keys
     and values as opaque byte strings.
 
-    @cvar _caseMappings: A L{dict} that maps lowercase header names
-        to their canonicalized representation, for headers with unconventional
-        capitalization.
-
-    @cvar _canonicalHeaderCache: A L{dict} that maps header names to their
-        canonicalized representation.
-
     @ivar _rawHeaders: A L{dict} mapping header names as L{bytes} to L{list}s of
         header values as L{bytes}.
     """
 
-    _caseMappings: ClassVar[Dict[bytes, bytes]] = {
-        b"content-md5": b"Content-MD5",
-        b"dnt": b"DNT",
-        b"etag": b"ETag",
-        b"p3p": b"P3P",
-        b"te": b"TE",
-        b"www-authenticate": b"WWW-Authenticate",
-        b"x-xss-protection": b"X-XSS-Protection",
-    }
-
-    _canonicalHeaderCache: ClassVar[Dict[Union[bytes, str], bytes]] = {}
-
-    _MAX_CACHED_HEADERS: ClassVar[int] = 10_000
-
     __slots__ = ["_rawHeaders"]
 
     def __init__(
@@ -109,39 +96,6 @@ class Headers:
             )
         return NotImplemented
 
-    def _encodeName(self, name: Union[str, bytes]) -> bytes:
-        """
-        Encode the name of a header (eg 'Content-Type') to an ISO-8859-1
-        encoded bytestring if required.  It will be canonicalized and
-        whitespace-sanitized.
-
-        @param name: A HTTP header name
-
-        @return: C{name}, encoded if required, lowercased
-        """
-        if canonicalName := self._canonicalHeaderCache.get(name, None):
-            return canonicalName
-
-        bytes_name = name.encode("iso-8859-1") if isinstance(name, str) else name
-
-        if bytes_name.lower() in self._caseMappings:
-            # Some headers have special capitalization:
-            result = self._caseMappings[bytes_name.lower()]
-        else:
-            result = _sanitizeLinearWhitespace(
-                b"-".join([word.capitalize() for word in bytes_name.split(b"-")])
-            )
-
-        # In general, we should only see a very small number of header
-        # variations in the real world, so caching them is fine. However, an
-        # attacker could generate infinite header variations to fill up RAM, so
-        # we cap how many we cache. The performance degradation from lack of
-        # caching won't be that bad, and legit traffic won't hit it.
-        if len(self._canonicalHeaderCache) < self._MAX_CACHED_HEADERS:
-            self._canonicalHeaderCache[name] = result
-
-        return result
-
     def copy(self):
         """
         Return a copy of itself with the same headers set.
@@ -158,7 +112,7 @@ class Headers:
 
         @return: C{True} if the header exists, otherwise C{False}.
         """
-        return self._encodeName(name) in self._rawHeaders
+        return _nameEncoder.encode(name) in self._rawHeaders
 
     def removeHeader(self, name: AnyStr) -> None:
         """
@@ -168,7 +122,7 @@ class Headers:
 
         @return: L{None}
         """
-        self._rawHeaders.pop(self._encodeName(name), None)
+        self._rawHeaders.pop(_nameEncoder.encode(name), None)
 
     def setRawHeaders(
         self, name: Union[str, bytes], values: Sequence[Union[str, bytes]]
@@ -186,7 +140,7 @@ class Headers:
 
         @return: L{None}
         """
-        _name = self._encodeName(name)
+        _name = _nameEncoder.encode(name)
         encodedValues: List[bytes] = []
         for v in values:
             if isinstance(v, str):
@@ -205,7 +159,7 @@ class Headers:
 
         @param value: The value to set for the named header.
         """
-        self._rawHeaders.setdefault(self._encodeName(name), []).append(
+        self._rawHeaders.setdefault(_nameEncoder.encode(name), []).append(
             _sanitizeLinearWhitespace(
                 value.encode("utf8") if isinstance(value, str) else value
             )
@@ -234,7 +188,7 @@ class Headers:
         @return: If the named header is present, a sequence of its
             values.  Otherwise, C{default}.
         """
-        encodedName = self._encodeName(name)
+        encodedName = _nameEncoder.encode(name)
         values = self._rawHeaders.get(encodedName, [])
         if not values:
             return default
@@ -252,4 +206,79 @@ class Headers:
         return iter(self._rawHeaders.items())
 
 
+class _NameEncoder:
+    """
+    C{_NameEncoder} converts HTTP header names to L{bytes} and canonicalizies
+    their capitalization.
+
+    @cvar _caseMappings: A L{dict} that maps conventionally-capitalized
+        header names to their canonicalized representation, for headers with
+        unconventional capitalization.
+
+    @cvar _canonicalHeaderCache: A L{dict} that maps header names to their
+        canonicalized representation.
+    """
+
+    __slots__ = ("_canonicalHeaderCache",)
+    _canonicalHeaderCache: Dict[Union[bytes, str], bytes]
+
+    _caseMappings: ClassVar[Dict[bytes, bytes]] = {
+        b"Content-Md5": b"Content-MD5",
+        b"Dnt": b"DNT",
+        b"Etag": b"ETag",
+        b"P3p": b"P3P",
+        b"Te": b"TE",
+        b"Www-Authenticate": b"WWW-Authenticate",
+        b"X-Xss-Protection": b"X-XSS-Protection",
+    }
+
+    _MAX_CACHED_HEADERS: ClassVar[int] = 10_000
+
+    def __init__(self):
+        self._canonicalHeaderCache = {}
+
+    def encode(self, name: Union[str, bytes]) -> bytes:
+        """
+        Encode the name of a header (eg 'Content-Type') to an ISO-8859-1
+        bytestring if required. It will be canonicalized to Http-Header-Case.
+
+        @raises InvalidHeaderName:
+            If the header name contains invalid characters like whitespace
+            or NUL.
+
+        @param name: An HTTP header name
+
+        @return: C{name}, encoded if required, in Header-Case
+        """
+        if canonicalName := self._canonicalHeaderCache.get(name):
+            return canonicalName
+
+        bytes_name = name.encode("iso-8859-1") if isinstance(name, str) else name
+
+        if not _istoken(bytes_name):
+            raise InvalidHeaderName(bytes_name)
+
+        result = b"-".join([word.capitalize() for word in bytes_name.split(b"-")])
+
+        # Some headers have special capitalization:
+        if result in self._caseMappings:
+            result = self._caseMappings[result]
+
+        # In general, we should only see a very small number of header
+        # variations in the real world, so caching them is fine. However, an
+        # attacker could generate infinite header variations to fill up RAM, so
+        # we cap how many we cache. The performance degradation from lack of
+        # caching won't be that bad, and legit traffic won't hit it.
+        if len(self._canonicalHeaderCache) < self._MAX_CACHED_HEADERS:
+            self._canonicalHeaderCache[name] = result
+
+        return result
+
+
+_nameEncoder = _NameEncoder()
+"""
+The global name encoder.
+"""
+
+
 __all__ = ["Headers"]
diff --git a/src/twisted/web/newsfragments/12191.feature b/src/twisted/web/newsfragments/12191.feature
new file mode 100644
index 0000000000..035347e9f5
--- /dev/null
+++ b/src/twisted/web/newsfragments/12191.feature
@@ -0,0 +1,1 @@
+The twisted.web HTTP server and client now reject HTTP header names containing whitespace or other invalid characters by raising twisted.web.http_headers.InvalidHeaderName, improving compliance with RFC 9110. As a side effect, the server is slightly faster.
diff --git a/src/twisted/web/newsfragments/9743.misc b/src/twisted/web/newsfragments/9743.misc
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/twisted/web/server.py b/src/twisted/web/server.py
index cfcefad7f3..1a4318022b 100644
--- a/src/twisted/web/server.py
+++ b/src/twisted/web/server.py
@@ -13,7 +13,6 @@ This is a web server which integrates with the twisted.internet infrastructure.
     value.
 """
 
-
 import copy
 import os
 import re
@@ -192,8 +191,8 @@ class Request(Copyable, http.Request, components.Componentized):
         self.site = self.channel.site
 
         # set various default headers
-        self.setHeader(b"server", version)
-        self.setHeader(b"date", datetimeToString())
+        self.setHeader(b"Server", version)
+        self.setHeader(b"Date", datetimeToString())
 
         # Resource Identification
         self.prepath = []
@@ -228,8 +227,8 @@ class Request(Copyable, http.Request, components.Componentized):
             # is a Content-Length header set to 0, as empty bodies don't need
             # a content-type.
             needsCT = self.code not in (NOT_MODIFIED, NO_CONTENT)
-            contentType = self.responseHeaders.getRawHeaders(b"content-type")
-            contentLength = self.responseHeaders.getRawHeaders(b"content-length")
+            contentType = self.responseHeaders.getRawHeaders(b"Content-Type")
+            contentLength = self.responseHeaders.getRawHeaders(b"Content-Length")
             contentLengthZero = contentLength and (contentLength[0] == b"0")
 
             if (
@@ -239,7 +238,7 @@ class Request(Copyable, http.Request, components.Componentized):
                 and not contentLengthZero
             ):
                 self.responseHeaders.setRawHeaders(
-                    b"content-type", [self.defaultContentType]
+                    b"Content-Type", [self.defaultContentType]
                 )
 
         # Only let the write happen if we're not generating a HEAD response by
@@ -298,7 +297,7 @@ class Request(Copyable, http.Request, components.Componentized):
                     )
                     # Oh well, I guess we won't include the content length.
                 else:
-                    self.setHeader(b"content-length", b"%d" % (len(body),))
+                    self.setHeader(b"Content-Length", b"%d" % (len(body),))
 
                 self._inFakeHead = False
                 self.method = b"HEAD"
@@ -361,10 +360,10 @@ class Request(Copyable, http.Request, components.Componentized):
                     slf=self,
                     resrc=resrc,
                 )
-                self.setHeader(b"content-length", b"%d" % (len(body),))
+                self.setHeader(b"Content-Length", b"%d" % (len(body),))
             self.write(b"")
         else:
-            self.setHeader(b"content-length", b"%d" % (len(body),))
+            self.setHeader(b"Content-Length", b"%d" % (len(body),))
             self.write(body)
         self.finish()
 
@@ -397,8 +396,8 @@ class Request(Copyable, http.Request, components.Componentized):
             )
 
         self.setResponseCode(http.INTERNAL_SERVER_ERROR)
-        self.setHeader(b"content-type", b"text/html")
-        self.setHeader(b"content-length", b"%d" % (len(body),))
+        self.setHeader(b"Content-Type", b"text/html")
+        self.setHeader(b"Content-Length", b"%d" % (len(body),))
         self.write(body)
         self.finish()
         return reason
@@ -605,16 +604,16 @@ class GzipEncoderFactory:
         request if so.
         """
         acceptHeaders = b",".join(
-            request.requestHeaders.getRawHeaders(b"accept-encoding", [])
+            request.requestHeaders.getRawHeaders(b"Accept-Encoding", [])
         )
         if self._gzipCheckRegex.search(acceptHeaders):
-            encoding = request.responseHeaders.getRawHeaders(b"content-encoding")
+            encoding = request.responseHeaders.getRawHeaders(b"Content-Encoding")
             if encoding:
                 encoding = b",".join(encoding + [b"gzip"])
             else:
                 encoding = b"gzip"
 
-            request.responseHeaders.setRawHeaders(b"content-encoding", [encoding])
+            request.responseHeaders.setRawHeaders(b"Content-Encoding", [encoding])
             return _GzipEncoder(self.compressLevel, request)
 
 
@@ -646,7 +645,7 @@ class _GzipEncoder:
         if not self._request.startedWriting:
             # Remove the content-length header, we can't honor it
             # because we compress on the fly.
-            self._request.responseHeaders.removeHeader(b"content-length")
+            self._request.responseHeaders.removeHeader(b"Content-Length")
         return self._zlibCompressor.compress(data)
 
     def finish(self):


Test patch:
diff --git a/src/twisted/web/test/test_abnf.py b/src/twisted/web/test/test_abnf.py
new file mode 100644
index 0000000000..68a3403f5b
--- /dev/null
+++ b/src/twisted/web/test/test_abnf.py
@@ -0,0 +1,113 @@
+# Copyright (c) Twisted Matrix Laboratories.
+# See LICENSE for details.
+
+"""
+Tests for L{twisted.web._abnf}.
+"""
+
+from twisted.trial import unittest
+from twisted.web._abnf import _decint, _hexint, _ishexdigits, _istoken
+
+
+class IsTokenTests(unittest.SynchronousTestCase):
+    """
+    Test the L{twisted.web._abnf._istoken} function.
+    """
+
+    def test_ok(self) -> None:
+        for b in (
+            b"GET",
+            b"Cache-Control",
+        ):
+            self.assertTrue(_istoken(b))
+
+    def test_bad(self) -> None:
+        for b in (
+            b"",
+            b" ",
+            b"a b",
+        ):
+            self.assertFalse(_istoken(b))
+
+
+class DecintTests(unittest.SynchronousTestCase):
+    """
+    Test the L{twisted.web._abnf._decint} function.
+    """
+
+    def test_valid(self) -> None:
+        """
+        Given a decimal digits, L{_decint} return an L{int}.
+        """
+        self.assertEqual(1, _decint(b"1"))
+        self.assertEqual(10, _decint(b"10"))
+        self.assertEqual(9000, _decint(b"9000"))
+        self.assertEqual(9000, _decint(b"0009000"))
+
+    def test_validWhitespace(self) -> None:
+        """
+        L{_decint} decodes integers embedded in linear whitespace.
+        """
+        self.assertEqual(123, _decint(b" 123"))
+        self.assertEqual(123, _decint(b"123\t\t"))
+        self.assertEqual(123, _decint(b" \t 123   \t  "))
+
+    def test_invalidPlus(self) -> None:
+        """
+        L{_decint} rejects a number with a leading C{+} character.
+        """
+        self.assertRaises(ValueError, _decint, b"+1")
+
+    def test_invalidMinus(self) -> None:
+        """
+        L{_decint} rejects a number with a leading C{-} character.
+        """
+        self.assertRaises(ValueError, _decint, b"-1")
+
+    def test_invalidWhitespace(self) -> None:
+        """
+        L{_decint} rejects a number embedded in non-linear whitespace.
+        """
+        self.assertRaises(ValueError, _decint, b"\v1")
+        self.assertRaises(ValueError, _decint, b"\x1c1")
+        self.assertRaises(ValueError, _decint, b"1\x1e")
+
+
+class HexHelperTests(unittest.SynchronousTestCase):
+    """
+    Test the L{twisted.web._abnf._hexint} and L{_ishexdigits} helper functions.
+    """
+
+    badStrings = (b"", b"0x1234", b"feds", b"-123" b"+123")
+
+    def test_isHex(self) -> None:
+        """
+        L{_ishexdigits()} returns L{True} for nonempy bytestrings containing
+        hexadecimal digits.
+        """
+        for s in (b"10", b"abcdef", b"AB1234", b"fed", b"123467890"):
+            self.assertIs(True, _ishexdigits(s))
+
+    def test_decodes(self) -> None:
+        """
+        L{_hexint()} returns the integer equivalent of the input.
+        """
+        self.assertEqual(10, _hexint(b"a"))
+        self.assertEqual(0x10, _hexint(b"10"))
+        self.assertEqual(0xABCD123, _hexint(b"abCD123"))
+
+    def test_isNotHex(self) -> None:
+        """
+        L{_ishexdigits()} returns L{False} for bytestrings that don't contain
+        hexadecimal digits, including the empty string.
+        """
+        for s in self.badStrings:
+            self.assertIs(False, _ishexdigits(s))
+
+    def test_decodeNotHex(self) -> None:
+        """
+        L{_hexint()} raises L{ValueError} for bytestrings that can't
+        be decoded.
+        """
+        for s in self.badStrings:
+            self.assertRaises(ValueError, _hexint, s)
diff --git a/src/twisted/web/test/test_http.py b/src/twisted/web/test/test_http.py
index ad27e29c5b..95f22871ce 100644
--- a/src/twisted/web/test/test_http.py
+++ b/src/twisted/web/test/test_http.py
@@ -2188,6 +2188,7 @@ class ParsingTests(unittest.TestCase):
             b"foo\x00bar: baz",  # NUL byte
             b"foo\x1bbar: baz",  # ESC byte
             b"Foo\vBar: baz",  # exotic whitespace
+            b"Foo Bar: baz",  # banal whitespace
             b"foo\xe2\x80\xbdbar: baz",  # non-ASCII bytes
         ]:
             self.assertRequestRejected(
@@ -4653,18 +4654,10 @@ class HTTPChannelSanitizationTests(unittest.SynchronousTestCase):
                 version=b"HTTP/1.1",
                 code=b"200",
                 reason=b"OK",
-                headers=[(component, component)],
+                headers=[(b"Foo", component)],
             )
 
-            sanitizedHeaderLine = (
-                b": ".join(
-                    [
-                        sanitizedBytes,
-                        sanitizedBytes,
-                    ]
-                )
-                + b"\r\n"
-            )
+            sanitizedHeaderLine = b"Foo: " + sanitizedBytes + b"\r\n"
 
             self.assertEqual(
                 transport.value(),
@@ -4713,43 +4706,3 @@ class HTTPClientSanitizationTests(unittest.SynchronousTestCase):
                 transport.value().splitlines(),
                 [b": ".join([sanitizedBytes, sanitizedBytes])],
             )
-
-
-class HexHelperTests(unittest.SynchronousTestCase):
-    """
-    Test the L{http._hexint} and L{http._ishexdigits} helper functions.
-    """
-
-    badStrings = (b"", b"0x1234", b"feds", b"-123" b"+123")
-
-    def test_isHex(self):
-        """
-        L{_ishexdigits()} returns L{True} for nonempy bytestrings containing
-        hexadecimal digits.
-        """
-        for s in (b"10", b"abcdef", b"AB1234", b"fed", b"123467890"):
-            self.assertIs(True, http._ishexdigits(s))
-
-    def test_decodes(self):
-        """
-        L{_hexint()} returns the integer equivalent of the input.
-        """
-        self.assertEqual(10, http._hexint(b"a"))
-        self.assertEqual(0x10, http._hexint(b"10"))
-        self.assertEqual(0xABCD123, http._hexint(b"abCD123"))
-
-    def test_isNotHex(self):
-        """
-        L{_ishexdigits()} returns L{False} for bytestrings that don't contain
-        hexadecimal digits, including the empty string.
-        """
-        for s in self.badStrings:
-            self.assertIs(False, http._ishexdigits(s))
-
-    def test_decodeNotHex(self):
-        """
-        L{_hexint()} raises L{ValueError} for bytestrings that can't
-        be decoded.
-        """
-        for s in self.badStrings:
-            self.assertRaises(ValueError, http._hexint, s)
diff --git a/src/twisted/web/test/test_http_headers.py b/src/twisted/web/test/test_http_headers.py
index 61dfd0bc33..61b7f852b7 100644
--- a/src/twisted/web/test/test_http_headers.py
+++ b/src/twisted/web/test/test_http_headers.py
@@ -4,12 +4,13 @@
 """
 Tests for L{twisted.web.http_headers}.
 """
+
 from __future__ import annotations
 
 from typing import Sequence
 
-from twisted.trial.unittest import TestCase
-from twisted.web.http_headers import Headers
+from twisted.trial.unittest import SynchronousTestCase, TestCase
+from twisted.web.http_headers import Headers, InvalidHeaderName, _nameEncoder
 from twisted.web.test.requesthelper import (
     bytesLinearWhitespaceComponents,
     sanitizedBytes,
@@ -17,41 +18,90 @@ from twisted.web.test.requesthelper import (
 )
 
 
+class NameEncoderTests(SynchronousTestCase):
+    """
+    Test L{twisted.web.http_headers._NameEncoder}
+    """
+
+    def test_encodeName(self) -> None:
+        """
+        L{_NameEncoder.encode} returns the canonical capitalization for
+        the given header.
+        """
+        self.assertEqual(_nameEncoder.encode(b"test"), b"Test")
+        self.assertEqual(_nameEncoder.encode(b"test-stuff"), b"Test-Stuff")
+        self.assertEqual(_nameEncoder.encode(b"content-md5"), b"Content-MD5")
+        self.assertEqual(_nameEncoder.encode(b"dnt"), b"DNT")
+        self.assertEqual(_nameEncoder.encode(b"etag"), b"ETag")
+        self.assertEqual(_nameEncoder.encode(b"p3p"), b"P3P")
+        self.assertEqual(_nameEncoder.encode(b"te"), b"TE")
+        self.assertEqual(_nameEncoder.encode(b"www-authenticate"), b"WWW-Authenticate")
+        self.assertEqual(_nameEncoder.encode(b"WWW-authenticate"), b"WWW-Authenticate")
+        self.assertEqual(_nameEncoder.encode(b"Www-Authenticate"), b"WWW-Authenticate")
+        self.assertEqual(_nameEncoder.encode(b"x-xss-protection"), b"X-XSS-Protection")
+
+    def test_encodeNameStr(self) -> None:
+        """
+        L{_NameEncoder.encode} returns the canonical capitalization for
+        a header name given as a L{str}.
+        """
+        self.assertEqual(_nameEncoder.encode("test"), b"Test")
+        self.assertEqual(_nameEncoder.encode("test-stuff"), b"Test-Stuff")
+        self.assertEqual(_nameEncoder.encode("content-md5"), b"Content-MD5")
+        self.assertEqual(_nameEncoder.encode("dnt"), b"DNT")
+        self.assertEqual(_nameEncoder.encode("etag"), b"ETag")
+        self.assertEqual(_nameEncoder.encode("p3p"), b"P3P")
+        self.assertEqual(_nameEncoder.encode("te"), b"TE")
+        self.assertEqual(_nameEncoder.encode("www-authenticate"), b"WWW-Authenticate")
+        self.assertEqual(_nameEncoder.encode("WWW-authenticate"), b"WWW-Authenticate")
+        self.assertEqual(_nameEncoder.encode("Www-Authenticate"), b"WWW-Authenticate")
+        self.assertEqual(_nameEncoder.encode("x-xss-protection"), b"X-XSS-Protection")
+
+    def test_maxCachedHeaders(self) -> None:
+        """
+        Only a limited number of HTTP header names get cached.
+        """
+        headers = Headers()
+        for i in range(_nameEncoder._MAX_CACHED_HEADERS + 200):
+            headers.addRawHeader(f"hello-{i}", "value")
+        self.assertEqual(
+            len(_nameEncoder._canonicalHeaderCache), _nameEncoder._MAX_CACHED_HEADERS
+        )
+
+
 def assertSanitized(
     testCase: TestCase, components: Sequence[bytes] | Sequence[str], expected: bytes
 ) -> None:
     """
     Assert that the components are sanitized to the expected value as
-    both a header name and value, across all of L{Header}'s setters
-    and getters.
+    both a header value, across all of L{Header}'s setters and getters.
 
     @param testCase: A test case.
 
     @param components: A sequence of values that contain linear
-        whitespace to use as header names and values; see
+        whitespace to use as header values; see
         C{textLinearWhitespaceComponents} and
         C{bytesLinearWhitespaceComponents}
 
-    @param expected: The expected sanitized form of the component for
-        both headers names and their values.
+    @param expected: The expected sanitized form of the component as
+        a header value.
     """
+    name = b"Name"
     for component in components:
         headers = []
-        headers.append(Headers({component: [component]}))  # type: ignore[type-var]
+        headers.append(Headers({name: [component]}))  # type: ignore[misc]
 
         added = Headers()
-        added.addRawHeader(component, component)
+        added.addRawHeader(name, component)
         headers.append(added)
 
         setHeader = Headers()
-        setHeader.setRawHeaders(component, [component])
+        setHeader.setRawHeaders(name, [component])
         headers.append(setHeader)
 
         for header in headers:
-            testCase.assertEqual(
-                list(header.getAllRawHeaders()), [(expected, [expected])]
-            )
-            testCase.assertEqual(header.getRawHeaders(expected), [expected])
+            testCase.assertEqual(list(header.getAllRawHeaders()), [(name, [expected])])
+            testCase.assertEqual(header.getRawHeaders(name), [expected])
 
 
 class BytesHeadersTests(TestCase):
@@ -176,24 +226,6 @@ class BytesHeadersTests(TestCase):
         h.removeHeader(b"test")
         self.assertEqual(list(h.getAllRawHeaders()), [])
 
-    def test_encodeName(self) -> None:
-        """
-        L{Headers._encodeName} returns the canonical capitalization for
-        the given header.
-        """
-        h = Headers()
-        self.assertEqual(h._encodeName(b"test"), b"Test")
-        self.assertEqual(h._encodeName(b"test-stuff"), b"Test-Stuff")
-        self.assertEqual(h._encodeName(b"content-md5"), b"Content-MD5")
-        self.assertEqual(h._encodeName(b"dnt"), b"DNT")
-        self.assertEqual(h._encodeName(b"etag"), b"ETag")
-        self.assertEqual(h._encodeName(b"p3p"), b"P3P")
-        self.assertEqual(h._encodeName(b"te"), b"TE")
-        self.assertEqual(h._encodeName(b"www-authenticate"), b"WWW-Authenticate")
-        self.assertEqual(h._encodeName(b"WWW-authenticate"), b"WWW-Authenticate")
-        self.assertEqual(h._encodeName(b"Www-Authenticate"), b"WWW-Authenticate")
-        self.assertEqual(h._encodeName(b"x-xss-protection"), b"X-XSS-Protection")
-
     def test_getAllRawHeaders(self) -> None:
         """
         L{Headers.getAllRawHeaders} returns an iterable of (k, v) pairs, where
@@ -298,17 +330,6 @@ class BytesHeadersTests(TestCase):
         i.addRawHeader(b"test", b"baz")
         self.assertEqual(h.getRawHeaders(b"test"), [b"foo", b"bar"])
 
-    def test_max_cached_headers(self) -> None:
-        """
-        Only a limited number of HTTP header names get cached.
-        """
-        headers = Headers()
-        for i in range(Headers._MAX_CACHED_HEADERS + 200):
-            headers.addRawHeader(f"hello-{i}", "value")
-        self.assertEqual(
-            len(Headers._canonicalHeaderCache), Headers._MAX_CACHED_HEADERS
-        )
-
 
 class UnicodeHeadersTests(TestCase):
     """
@@ -365,6 +386,22 @@ class UnicodeHeadersTests(TestCase):
         with self.assertRaises(UnicodeEncodeError):
             h.hasHeader("\u2603")
 
+    def test_nameNotToken(self) -> None:
+        """
+        HTTP header names must be tokens, so any names containing non-token
+        characters raises L{InvalidHeaderName}
+        """
+        h = Headers()
+
+        # A non-token character within ISO-8851-1
+        self.assertRaises(InvalidHeaderName, h.setRawHeaders, b"\xe1", [b"val"])
+        self.assertRaises(InvalidHeaderName, h.setRawHeaders, "\u00e1", [b"val"])
+
+        # Whitespace
+        self.assertRaises(InvalidHeaderName, h.setRawHeaders, b"a b", [b"val"])
+        self.assertRaises(InvalidHeaderName, h.setRawHeaders, "c\nd", [b"val"])
+        self.assertRaises(InvalidHeaderName, h.setRawHeaders, "c\td", [b"val"])
+
     def test_nameEncoding(self) -> None:
         """
         Passing L{str} to any function that takes a header name will encode
@@ -373,14 +410,14 @@ class UnicodeHeadersTests(TestCase):
         h = Headers()
 
         # We set it using a Unicode string.
-        h.setRawHeaders("\u00E1", [b"foo"])
+        h.setRawHeaders("bar", [b"foo"])
 
         # It's encoded to the ISO-8859-1 value, which we can use to access it
-        self.assertTrue(h.hasHeader(b"\xe1"))
-        self.assertEqual(h.getRawHeaders(b"\xe1"), [b"foo"])
+        self.assertTrue(h.hasHeader(b"bar"))
+        self.assertEqual(h.getRawHeaders(b"bar"), [b"foo"])
 
         # We can still access it using the Unicode string..
-        self.assertTrue(h.hasHeader("\u00E1"))
+        self.assertTrue(h.hasHeader("bar"))
 
     def test_rawHeadersValueEncoding(self) -> None:
         """
@@ -388,9 +425,9 @@ class UnicodeHeadersTests(TestCase):
         ISO-8859-1 and values as UTF-8.
         """
         h = Headers()
-        h.setRawHeaders("\u00E1", ["\u2603", b"foo"])
-        self.assertTrue(h.hasHeader(b"\xe1"))
-        self.assertEqual(h.getRawHeaders(b"\xe1"), [b"\xe2\x98\x83", b"foo"])
+        h.setRawHeaders("x", ["\u2603", b"foo"])
+        self.assertTrue(h.hasHeader(b"x"))
+        self.assertEqual(h.getRawHeaders(b"x"), [b"\xe2\x98\x83", b"foo"])
 
     def test_addRawHeader(self) -> None:
         """
@@ -443,11 +480,11 @@ class UnicodeHeadersTests(TestCase):
         given header.
         """
         h = Headers()
-        h.setRawHeaders("test\u00E1", ["lemur"])
-        self.assertEqual(h.getRawHeaders("test\u00E1"), ["lemur"])
-        self.assertEqual(h.getRawHeaders("Test\u00E1"), ["lemur"])
-        self.assertEqual(h.getRawHeaders(b"test\xe1"), [b"lemur"])
-        self.assertEqual(h.getRawHeaders(b"Test\xe1"), [b"lemur"])
+        h.setRawHeaders("test", ["lemur"])
+        self.assertEqual(h.getRawHeaders("test"), ["lemur"])
+        self.assertEqual(h.getRawHeaders("Test"), ["lemur"])
+        self.assertEqual(h.getRawHeaders(b"test"), [b"lemur"])
+        self.assertEqual(h.getRawHeaders(b"Test"), [b"lemur"])
 
     def test_hasHeaderTrue(self) -> None:
         """
@@ -455,18 +492,18 @@ class UnicodeHeadersTests(TestCase):
         is found.
         """
         h = Headers()
-        h.setRawHeaders("test\u00E1", ["lemur"])
-        self.assertTrue(h.hasHeader("test\u00E1"))
-        self.assertTrue(h.hasHeader("Test\u00E1"))
-        self.assertTrue(h.hasHeader(b"test\xe1"))
-        self.assertTrue(h.hasHeader(b"Test\xe1"))
+        h.setRawHeaders("test", ["lemur"])
+        self.assertTrue(h.hasHeader("test"))
+        self.assertTrue(h.hasHeader("Test"))
+        self.assertTrue(h.hasHeader(b"test"))
+        self.assertTrue(h.hasHeader(b"Test"))
 
     def test_hasHeaderFalse(self) -> None:
         """
         L{Headers.hasHeader} returns C{False} when the given header is not
         found.
         """
-        self.assertFalse(Headers().hasHeader("test\u00E1"))
+        self.assertFalse(Headers().hasHeader("test"))
 
     def test_removeHeader(self) -> None:
         """
@@ -502,7 +539,7 @@ class UnicodeHeadersTests(TestCase):
         is a sequence of values.
         """
         h = Headers()
-        h.setRawHeaders("test\u00E1", ["lemurs"])
+        h.setRawHeaders("test", ["lemurs"])
         h.setRawHeaders("www-authenticate", ["basic aksljdlk="])
         h.setRawHeaders("content-md5", ["kjdfdfgdfgnsd"])
 
@@ -513,7 +550,7 @@ class UnicodeHeadersTests(TestCase):
             {
                 (b"WWW-Authenticate", (b"basic aksljdlk=",)),
                 (b"Content-MD5", (b"kjdfdfgdfgnsd",)),
-                (b"Test\xe1", (b"lemurs",)),
+                (b"Test", (b"lemurs",)),
             },
         )
 
@@ -523,11 +560,11 @@ class UnicodeHeadersTests(TestCase):
         L{Headers} instance with the same values.
         """
         first = Headers()
-        first.setRawHeaders("foo\u00E1", ["panda"])
+        first.setRawHeaders("foo", ["panda"])
         second = Headers()
-        second.setRawHeaders("foo\u00E1", ["panda"])
+        second.setRawHeaders("foo", ["panda"])
         third = Headers()
-        third.setRawHeaders("foo\u00E1", ["lemur", "panda"])
+        third.setRawHeaders("foo", ["lemur", "panda"])
 
         self.assertEqual(first, first)
         self.assertEqual(first, second)
@@ -535,11 +572,11 @@ class UnicodeHeadersTests(TestCase):
 
         # Headers instantiated with bytes equivs are also the same
         firstBytes = Headers()
-        firstBytes.setRawHeaders(b"foo\xe1", [b"panda"])
+        firstBytes.setRawHeaders(b"foo", [b"panda"])
         secondBytes = Headers()
-        secondBytes.setRawHeaders(b"foo\xe1", [b"panda"])
+        secondBytes.setRawHeaders(b"foo", [b"panda"])
         thirdBytes = Headers()
-        thirdBytes.setRawHeaders(b"foo\xe1", [b"lemur", "panda"])
+        thirdBytes.setRawHeaders(b"foo", [b"lemur", "panda"])
 
         self.assertEqual(first, firstBytes)
         self.assertEqual(second, secondBytes)
@@ -561,10 +598,10 @@ class UnicodeHeadersTests(TestCase):
         the headers it contains. This shows only reprs of bytes values, as
         undecodable headers may cause an exception.
         """
-        foo = "foo\u00E1"
+        foo = "foo"
         bar = "bar\u2603"
         baz = "baz"
-        fooEncoded = "'Foo\\xe1'"
+        fooEncoded = "'Foo'"
         barEncoded = "'bar\\xe2\\x98\\x83'"
         fooEncoded = "b" + fooEncoded
         barEncoded = "b" + barEncoded
@@ -580,10 +617,10 @@ class UnicodeHeadersTests(TestCase):
         The L{repr} of an instance of a subclass of L{Headers} uses the name
         of the subclass instead of the string C{"Headers"}.
         """
-        foo = "foo\u00E1"
+        foo = "foo"
         bar = "bar\u2603"
         baz = "baz"
-        fooEncoded = "b'Foo\\xe1'"
+        fooEncoded = "b'Foo'"
         barEncoded = "b'bar\\xe2\\x98\\x83'"
 
         class FunnyHeaders(Headers):
@@ -602,26 +639,26 @@ class UnicodeHeadersTests(TestCase):
         between the copies.
         """
         h = Headers()
-        h.setRawHeaders("test\u00E1", ["foo\u2603"])
+        h.setRawHeaders("test", ["foo\u2603"])
         i = h.copy()
 
         # The copy contains the same value as the original
-        self.assertEqual(i.getRawHeaders("test\u00E1"), ["foo\u2603"])
-        self.assertEqual(i.getRawHeaders(b"test\xe1"), [b"foo\xe2\x98\x83"])
+        self.assertEqual(i.getRawHeaders("test"), ["foo\u2603"])
+        self.assertEqual(i.getRawHeaders(b"test"), [b"foo\xe2\x98\x83"])
 
         # Add a header to the original
-        h.addRawHeader("test\u00E1", "bar")
+        h.addRawHeader("test", "bar")
 
         # Verify that the copy has not changed
-        self.assertEqual(i.getRawHeaders("test\u00E1"), ["foo\u2603"])
-        self.assertEqual(i.getRawHeaders(b"test\xe1"), [b"foo\xe2\x98\x83"])
+        self.assertEqual(i.getRawHeaders("test"), ["foo\u2603"])
+        self.assertEqual(i.getRawHeaders(b"test"), [b"foo\xe2\x98\x83"])
 
         # Add a header to the copy
-        i.addRawHeader("test\u00E1", b"baz")
+        i.addRawHeader("Test", b"baz")
 
         # Verify that the orignal does not have it
-        self.assertEqual(h.getRawHeaders("test\u00E1"), ["foo\u2603", "bar"])
-        self.assertEqual(h.getRawHeaders(b"test\xe1"), [b"foo\xe2\x98\x83", b"bar"])
+        self.assertEqual(h.getRawHeaders("test"), ["foo\u2603", "bar"])
+        self.assertEqual(h.getRawHeaders(b"test"), [b"foo\xe2\x98\x83", b"bar"])
 
 
 class MixedHeadersTests(TestCase):
diff --git a/src/twisted/web/test/test_newclient.py b/src/twisted/web/test/test_newclient.py
index a958545b0c..4b0ffc0d0d 100644
--- a/src/twisted/web/test/test_newclient.py
+++ b/src/twisted/web/test/test_newclient.py
@@ -2042,11 +2042,11 @@ class RequestTests(TestCase):
 
     def test_sanitizeLinearWhitespaceInRequestHeaders(self):
         """
-        Linear whitespace in request headers is replaced with a single
-        space.
+        Linear whitespace in request header values is replaced with a
+        single space.
         """
         for component in bytesLinearWhitespaceComponents:
-            headers = Headers({component: [component], b"host": [b"example.invalid"]})
+            headers = Headers({b"x-foo": [component], b"host": [b"example.invalid"]})
             transport = StringTransport()
             Request(b"GET", b"/foo", headers, None).writeTo(transport)
             lines = transport.value().split(b"\r\n")
@@ -2055,8 +2055,7 @@ class RequestTests(TestCase):
             del lines[0], lines[-2:]
             lines.remove(b"Connection: close")
             lines.remove(b"Host: example.invalid")
-            sanitizedHeaderLine = b": ".join([sanitizedBytes, sanitizedBytes])
-            self.assertEqual(lines, [sanitizedHeaderLine])
+            self.assertEqual(lines, [b"X-Foo: " + sanitizedBytes])
 
     def test_sendChunkedRequestBody(self):
         """
diff --git a/src/twisted/web/test/test_web.py b/src/twisted/web/test/test_web.py
index a95973d413..d872683940 100644
--- a/src/twisted/web/test/test_web.py
+++ b/src/twisted/web/test/test_web.py
@@ -1157,9 +1157,10 @@ class NewRenderResource(resource.Resource):
 
 
 @implementer(resource.IResource)
-class HeadlessResource:
+class HeadlessWriter:
     """
-    A resource that implements GET but not HEAD.
+    A resource that implements GET but not HEAD, and
+    calls C{request.write()} when rendering GET.
     """
 
     allowedMethods = [b"GET"]
@@ -1175,21 +1176,46 @@ class HeadlessResource:
         return server.NOT_DONE_YET
 
     def isLeaf(self):
-        """
         # IResource.isLeaf
-        """
         raise NotImplementedError()
 
     def getChildWithDefault(self, name, request):
-        """
         # IResource.getChildWithDefault
-        """
         raise NotImplementedError()
 
     def putChild(self, path, child):
-        """
         # IResource.putChild
+        raise NotImplementedError()
+
+
+@implementer(resource.IResource)
+class HeadlessReturner:
+    """
+    A resource that implements GET but not HEAD, and
+    returns C{bytes} when rendering GET.
+    """
+
+    allowedMethods = [b"GET"]
+
+    def render(self, request):
         """
+        Leave the request open for future writes.
+        """
+        self.request = request
+        if request.method not in self.allowedMethods:
+            raise error.UnsupportedMethod(self.allowedMethods)
+        return b"some data"
+
+    def isLeaf(self):
+        # IResource.isLeaf
+        raise NotImplementedError()
+
+    def getChildWithDefault(self, name, request):
+        # IResource.getChildWithDefault
+        raise NotImplementedError()
+
+    def putChild(self, path, child):
+        # IResource.putChild
         raise NotImplementedError()
 
 
@@ -1257,21 +1283,44 @@ class NewRenderTests(unittest.TestCase):
         event = logObserver[0]
         self.assertEquals(event["log_level"], LogLevel.info)
 
-    def test_unsupportedHead(self):
+    def test_unsupportedHeadWrite(self):
         """
         HEAD requests against resource that only claim support for GET
-        should not include a body in the response.
+        should not include a body in the response. When the resource
+        returns C{NOT_DONE_YET} a cryptic message is logged and no
+        Content-Length header is synthesized.
         """
-        logObserver = EventLoggingObserver.createWithCleanup(self, globalLogPublisher)
+        logs = EventLoggingObserver.createWithCleanup(self, globalLogPublisher)
 
-        resource = HeadlessResource()
+        resource = HeadlessWriter()
         req = self._getReq(resource)
         req.requestReceived(b"HEAD", b"/newrender", b"HTTP/1.0")
         headers, body = req.transport.written.getvalue().split(b"\r\n\r\n")
+
         self.assertEqual(req.code, 200)
         self.assertEqual(body, b"")
+        self.assertNotIn(b"Content-Length:", headers)
+        [faking, cryptic] = logs
+        self.assertIn("Using GET to fake a HEAD request", faking["log_format"])
+        self.assertIn("it got away from me", cryptic["log_format"])
 
-        self.assertEquals(2, len(logObserver))
+    def test_unsupportedHeadReturn(self):
+        """
+        HEAD requests against resource that only claim support for GET
+        should not include a body in the response.
+        """
+        logs = EventLoggingObserver.createWithCleanup(self, globalLogPublisher)
+
+        resource = HeadlessReturner()
+        req = self._getReq(resource)
+        req.requestReceived(b"HEAD", b"/newrender", b"HTTP/1.0")
+        headers, body = req.transport.written.getvalue().split(b"\r\n\r\n")
+
+        self.assertEqual(req.code, 200)
+        self.assertEqual(body, b"")
+        self.assertIn(b"Content-Length:", headers)
+        [faking] = logs
+        self.assertIn("Using GET to fake a HEAD request", faking["log_format"])
 
     def test_noBytesResult(self):
         """