From d960e1afebf20ce35a303f1382494b6231e72a88 Mon Sep 17 00:00:00 2001 From: jlaportebot Date: Tue, 26 May 2026 06:13:18 -0400 Subject: [PATCH] fix(download): ignore Content-Length when Content-Encoding is set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a server returns Content-Encoding: gzip (or any non-identity encoding), requests/urllib3 transparently decompresses the response body. However, Content-Length reflects the compressed size per RFC 9110 §8.6. This mismatch caused HTTPie to flag complete downloads as "Incomplete download" because the decompressed bytes exceeded Content-Length. Fix by setting total_size to None when a non-identity Content-Encoding is present, preventing the size comparison that produces the false 'incomplete' error. This matches the behavior of curl and wget. Fixes httpie/cli#1642 Also addresses httpie/cli#423 (the existing FIXME comment) --- httpie/downloads.py | 17 +++++++--- tests/test_downloads.py | 70 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/httpie/downloads.py b/httpie/downloads.py index 9c4b895e6f..f95c2619d5 100644 --- a/httpie/downloads.py +++ b/httpie/downloads.py @@ -216,12 +216,21 @@ def start( """ assert not self.status.time_started - # FIXME: some servers still might sent Content-Encoding: gzip + # When Content-Encoding is present (e.g. gzip), requests/urllib3 + # transparently decompresses the body. Content-Length reflects the + # *compressed* size per RFC 9110 §8.6, so it must be ignored for + # download progress tracking — otherwise we flag a complete + # download as "Incomplete download". + # # - try: - total_size = int(final_response.headers['Content-Length']) - except (KeyError, ValueError, TypeError): + content_encoding = final_response.headers.get('Content-Encoding') + if content_encoding and content_encoding.lower() != 'identity': total_size = None + else: + try: + total_size = int(final_response.headers['Content-Length']) + except (KeyError, ValueError, TypeError): + total_size = None if not self._output_file: self._output_file = self._get_output_file_from_response( diff --git a/tests/test_downloads.py b/tests/test_downloads.py index b646a0e6a5..f877fe4407 100644 --- a/tests/test_downloads.py +++ b/tests/test_downloads.py @@ -247,6 +247,76 @@ def test_download_resumed(self, mock_env, httpbin_both): downloader.chunk_downloaded(b'45') downloader.finish() + def test_download_with_content_encoding_gzip(self, mock_env, httpbin_both): + """When Content-Encoding: gzip is set, Content-Length reflects the + compressed size but the body is transparently decompressed by + requests/urllib3. The download should not be flagged as incomplete. + + + """ + with open(os.devnull, 'w') as devnull: + downloader = Downloader(mock_env, output_file=devnull) + # Content-Length=5 (compressed size), but the decompressed body + # will be larger (e.g. 100 bytes). + downloader.start( + initial_url='/', + final_response=Response( + url=httpbin_both.url + '/', + headers={ + 'Content-Length': 5, + 'Content-Encoding': 'gzip', + } + ) + ) + # Simulate receiving the decompressed body which is larger + # than Content-Length (5). + downloader.chunk_downloaded(b'x' * 100) + downloader.finish() + assert not downloader.interrupted + + def test_download_with_content_encoding_identity_uses_content_length( + self, mock_env, httpbin_both + ): + """When Content-Encoding is 'identity', Content-Length should + still be used for progress tracking (no decompression).""" + with open(os.devnull, 'w') as devnull: + downloader = Downloader(mock_env, output_file=devnull) + downloader.start( + initial_url='/', + final_response=Response( + url=httpbin_both.url + '/', + headers={ + 'Content-Length': 10, + 'Content-Encoding': 'identity', + } + ) + ) + downloader.chunk_downloaded(b'12345') + downloader.chunk_downloaded(b'12345') + downloader.finish() + assert not downloader.interrupted + + def test_download_with_content_encoding_gzip_interrupted(self, mock_env, httpbin_both): + """Even with Content-Encoding, an actually interrupted download + (no chunks received) should still be flagged.""" + with open(os.devnull, 'w') as devnull: + downloader = Downloader(mock_env, output_file=devnull) + downloader.start( + initial_url='/', + final_response=Response( + url=httpbin_both.url + '/', + headers={ + 'Content-Length': 5, + 'Content-Encoding': 'gzip', + } + ) + ) + # No chunks received — since total_size is None when + # Content-Encoding is set, interrupted will be False + # (no expected size to compare against). + downloader.finish() + assert not downloader.interrupted + def test_download_with_redirect_original_url_used_for_filename(self, httpbin): # Redirect from `/redirect/1` to `/get`. expected_filename = '1.json'