From 85de63bbd70027acfbc1a41afcb52ed905f5ae34 Mon Sep 17 00:00:00 2001 From: Karthik Vinayan Date: Thu, 5 Feb 2026 15:27:38 +0530 Subject: [PATCH] Fix false "Incomplete download" error with Content-Encoding When servers send Content-Encoding: gzip, Content-Length reflects compressed size, but requests auto-decompresses. This caused downloaded bytes to exceed Content-Length, triggering false errors. Changed interrupted check from `!=` to `<` so receiving more bytes than expected is correctly treated as complete. Fixes #1642 See also #423 --- CHANGELOG.md | 4 ++++ httpie/downloads.py | 15 ++++++++++++--- tests/test_downloads.py | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0497ac3508..ff3d459ccc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ This document records all notable changes to [HTTPie](https://httpie.io). This project adheres to [Semantic Versioning](https://semver.org/). +## [Unreleased] + +- Fix false "Incomplete download" error when downloaded bytes exceed `Content-Length`, which occurs when servers send `Content-Encoding: gzip` and `requests` auto-decompresses. ([#1642](https://github.com/httpie/cli/issues/1642), [#423](https://github.com/httpie/cli/issues/423)) + ## [3.2.4](https://github.com/httpie/cli/compare/3.2.3...3.2.4) (2024-11-01) - Fix default certs loading and unpin `requests`. ([#1596](https://github.com/httpie/cli/issues/1596)) diff --git a/httpie/downloads.py b/httpie/downloads.py index 9c4b895e6f..55896a0ab2 100644 --- a/httpie/downloads.py +++ b/httpie/downloads.py @@ -216,8 +216,6 @@ def start( """ assert not self.status.time_started - # FIXME: some servers still might sent Content-Encoding: gzip - # try: total_size = int(final_response.headers['Content-Length']) except (KeyError, ValueError, TypeError): @@ -269,10 +267,21 @@ def failed(self): @property def interrupted(self) -> bool: + """ + Download is interrupted if we received fewer bytes than expected. + + Uses `<` instead of `!=` to handle Content-Encoding (e.g., gzip): + when servers send compressed responses, Content-Length reflects + compressed size, but `requests` auto-decompresses, so downloaded + bytes exceed Content-Length. This is not an interruption. + + See https://github.com/httpie/cli/issues/423 + See https://github.com/httpie/cli/issues/1642 + """ return ( self.finished and self.status.total_size - and self.status.total_size != self.status.downloaded + and self.status.downloaded < self.status.total_size ) def chunk_downloaded(self, chunk: bytes): diff --git a/tests/test_downloads.py b/tests/test_downloads.py index b646a0e6a5..5a2d296f3e 100644 --- a/tests/test_downloads.py +++ b/tests/test_downloads.py @@ -201,6 +201,39 @@ def test_download_interrupted(self, mock_env, httpbin_both): downloader.finish() assert downloader.interrupted + def test_download_not_interrupted_when_bytes_exceed_content_length(self, mock_env, httpbin_both): + """ + When downloaded bytes exceed Content-Length, this is NOT an interrupted + download. This happens when servers send Content-Encoding (e.g., gzip): + Content-Length reflects compressed size, but requests auto-decompresses, + so we receive more bytes than Content-Length indicates. + + The fix uses `downloaded < total_size` instead of `!=` to detect + interruptions, so receiving MORE bytes than expected is considered complete. + + Regression test for https://github.com/httpie/cli/issues/1642 + See also https://github.com/httpie/cli/issues/423 + """ + 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': 100, + } + ) + ) + # Simulate receiving MORE bytes than Content-Length + downloader.chunk_downloaded(b'x' * 150) + downloader.finish() + # Should NOT be marked as interrupted (got more than expected) + assert not downloader.interrupted + # Progress tracking should still work (total_size preserved) + assert downloader.status.total_size == 100 + assert downloader.status.downloaded == 150 + def test_download_resumed(self, mock_env, httpbin_both): with tempfile.TemporaryDirectory() as tmp_dirname: file = os.path.join(tmp_dirname, 'file.bin')