From 4bd46a45e7dc0aabbc92161a4d659ea11c758a60 Mon Sep 17 00:00:00 2001 From: b84330808 Date: Sun, 1 Nov 2026 20:58:20 +0900 Subject: [PATCH] gh-72756: Skip all 1xx interim responses in http.client HTTPResponse.begin() only skipped "100 Continue", so any other 1xx response, such as "102 Processing" or "103 Early Hints", was returned as the final response and the real response was left unread on the connection. RFC 9110 requires clients to parse 1xx responses received before the final response, and allows them to ignore unexpected ones. Skip every 1xx response except "101 Switching Protocols", which is still returned to the caller. Co-Authored-By: Claude Opus 5.5 --- Lib/http/client.py | 9 +++-- Lib/test/test_httplib.py | 40 +++++++++++++++++++ Lib/test/test_httpservers.py | 19 ++++++++- ...6-11-01-11-58-06.gh-issue-72756.6qU4Wg.rst | 3 ++ 4 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-11-01-11-58-06.gh-issue-72756.6qU4Wg.rst diff --git a/Lib/http/client.py b/Lib/http/client.py index fe67d0f7963f78..201123930da00f 100644 --- a/Lib/http/client.py +++ b/Lib/http/client.py @@ -351,12 +351,15 @@ def begin(self, *, _max_headers=None): # for _read_and_discard_trailer() to enforce. self._max_headers = _max_headers - # read until we get a non-100 response + # read until we get a final response, skipping 1xx (informational) + # responses such as "100 Continue" or "103 Early Hints". + # "101 Switching Protocols" is returned to the caller since it ends + # the HTTP/1.1 exchange on this connection. for _ in range(_MAXINTERIMRESPONSES): version, status, reason = self._read_status() - if status != CONTINUE: + if not 100 <= status < 200 or status == SWITCHING_PROTOCOLS: break - # skip the header from the 100 response + # skip the header from the 1xx response skipped_headers = _read_headers(self.fp, _max_headers) if self.debuglevel > 0: print("headers:", skipped_headers) diff --git a/Lib/test/test_httplib.py b/Lib/test/test_httplib.py index 1e5ec0d4e43c27..0964a6b08fa47f 100644 --- a/Lib/test/test_httplib.py +++ b/Lib/test/test_httplib.py @@ -1479,6 +1479,46 @@ def test_multiple_interim_responses(self): self.assertEqual(resp.read(), b'hello') resp.close() + def test_informational_responses(self): + # gh-72756: every 1xx response other than "101 Switching Protocols" + # is an interim response and must be skipped, like "100 Continue". + for interim in ('102 Processing', '103 Early Hints', '199 Unknown', + '103 Early Hints\r\n\r\nHTTP/1.1 100 Continue'): + with self.subTest(interim=interim): + body = ( + f'HTTP/1.1 {interim}\r\n' + 'Link: ; rel=preload; as=style\r\n' + '\r\n' + 'HTTP/1.1 200 OK\r\n' + 'Content-Length: 5\r\n' + '\r\n' + 'hello' + ) + resp = client.HTTPResponse(FakeSocket(body), method="GET") + resp.begin() + self.assertEqual(resp.status, 200) + self.assertEqual(resp.reason, 'OK') + self.assertIsNone(resp.getheader('Link')) + self.assertEqual(resp.read(), b'hello') + resp.close() + + def test_switching_protocols_response(self): + # "101 Switching Protocols" ends the HTTP/1.1 exchange, so it is + # returned to the caller instead of being skipped. + body = ( + 'HTTP/1.1 101 Switching Protocols\r\n' + 'Upgrade: websocket\r\n' + 'Connection: Upgrade\r\n' + '\r\n' + 'not HTTP' + ) + resp = client.HTTPResponse(FakeSocket(body), method="GET") + resp.begin() + self.assertEqual(resp.status, 101) + self.assertEqual(resp.getheader('Upgrade'), 'websocket') + self.assertEqual(resp.read(), b'') + resp.close() + def test_overflowing_chunked_line(self): body = ( 'HTTP/1.1 200 OK\r\n' diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index c82084ce299d77..3c5e08a3821321 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -329,7 +329,7 @@ def test_send_error(self): allow_transfer_encoding_codes = (HTTPStatus.NOT_MODIFIED, HTTPStatus.RESET_CONTENT) for code in (HTTPStatus.NO_CONTENT, HTTPStatus.NOT_MODIFIED, - HTTPStatus.PROCESSING, HTTPStatus.RESET_CONTENT, + HTTPStatus.RESET_CONTENT, HTTPStatus.SWITCHING_PROTOCOLS): self.con.request('SEND_ERROR', '/{}'.format(code)) res = self.con.getresponse() @@ -342,6 +342,23 @@ def test_send_error(self): data = res.read() self.assertEqual(b'', data) + def test_send_error_informational(self): + # http.client skips 1xx (informational) responses other than + # "101 Switching Protocols", so read this one from the socket. + self.con.close() + with socket.create_connection((self.HOST, self.PORT)) as sock: + sock.sendall(b'SEND_ERROR /102 HTTP/1.1\r\n\r\n') + response = b''.join(iter(lambda: sock.recv(1024), b'')) + head, sep, data = response.partition(b'\r\n\r\n') + self.assertEqual(sep, b'\r\n\r\n') + status_line, *header_lines = head.split(b'\r\n') + self.assertStartsWith(status_line, b'HTTP/1.1 102 ') + names = [line.partition(b':')[0].lower() for line in header_lines] + self.assertNotIn(b'content-length', names) + self.assertNotIn(b'content-type', names) + self.assertNotIn(b'transfer-encoding', names) + self.assertEqual(b'', data) + def test_head_via_send_error(self): allow_transfer_encoding_codes = (HTTPStatus.NOT_MODIFIED, HTTPStatus.RESET_CONTENT) diff --git a/Misc/NEWS.d/next/Library/2026-11-01-11-58-06.gh-issue-72756.6qU4Wg.rst b/Misc/NEWS.d/next/Library/2026-11-01-11-58-06.gh-issue-72756.6qU4Wg.rst new file mode 100644 index 00000000000000..706ec59278a0d4 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-11-01-11-58-06.gh-issue-72756.6qU4Wg.rst @@ -0,0 +1,3 @@ +Fix :mod:`http.client` returning a 1xx informational response such as +``103 Early Hints`` as the final response. All 1xx responses except +``101 Switching Protocols`` are now skipped, like ``100 Continue``.