Skip to content

Commit 2761842

Browse files
authored
Fix double gzip decompression error in aiohttp transport (#135)
1 parent f99f324 commit 2761842

File tree

2 files changed

+152
-67
lines changed

2 files changed

+152
-67
lines changed

onvif/zeep_aiohttp.py

Lines changed: 42 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@
1010
from zeep.transports import Transport
1111
from zeep.utils import get_version
1212
from zeep.wsdl.utils import etree_to_string
13-
13+
from multidict import CIMultiDict
1414
import httpx
15-
from aiohttp import ClientResponse, ClientSession
15+
from aiohttp import ClientResponse, ClientSession, hdrs
1616
from requests import Response
17+
from requests.structures import CaseInsensitiveDict
1718

1819
if TYPE_CHECKING:
1920
from lxml.etree import _Element
@@ -65,14 +66,23 @@ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
6566
async def aclose(self) -> None:
6667
"""Close the transport session."""
6768

69+
def _filter_headers(self, headers: CIMultiDict[str]) -> list[tuple[str, str]]:
70+
"""Filter out Content-Encoding header.
71+
72+
Since aiohttp has already decompressed the content, we need to
73+
remove the Content-Encoding header to prevent zeep from trying
74+
to decompress it again, which would cause a zlib error.
75+
"""
76+
return [(k, v) for k, v in headers.items() if k != hdrs.CONTENT_ENCODING]
77+
6878
def _aiohttp_to_httpx_response(
6979
self, aiohttp_response: ClientResponse, content: bytes
7080
) -> httpx.Response:
7181
"""Convert aiohttp ClientResponse to httpx Response."""
7282
# Create httpx Response with the content
7383
httpx_response = httpx.Response(
7484
status_code=aiohttp_response.status,
75-
headers=httpx.Headers(aiohttp_response.headers),
85+
headers=httpx.Headers(self._filter_headers(aiohttp_response.headers)),
7686
content=content,
7787
request=httpx.Request(
7888
method=aiohttp_response.method,
@@ -104,7 +114,10 @@ def _aiohttp_to_requests_response(
104114
new = Response()
105115
new._content = content
106116
new.status_code = aiohttp_response.status
107-
new.headers = dict(aiohttp_response.headers)
117+
# Use dict comprehension for requests.Response headers
118+
new.headers = CaseInsensitiveDict(
119+
self._filter_headers(aiohttp_response.headers)
120+
)
108121
# Convert aiohttp cookies to requests format
109122
if aiohttp_response.cookies:
110123
for name, cookie in aiohttp_response.cookies.items():
@@ -117,27 +130,10 @@ def _aiohttp_to_requests_response(
117130
new.encoding = aiohttp_response.charset
118131
return new
119132

120-
async def post(
121-
self, address: str, message: str, headers: dict[str, str]
122-
) -> httpx.Response:
123-
"""
124-
Perform async POST request.
125-
126-
Args:
127-
address: The URL to send the request to
128-
message: The message to send
129-
headers: HTTP headers to include
130-
131-
Returns:
132-
The httpx response object
133-
134-
"""
135-
return await self._post(address, message, headers)
136-
137-
async def _post(
138-
self, address: str, message: str, headers: dict[str, str]
139-
) -> httpx.Response:
140-
"""Internal POST implementation."""
133+
async def _post_internal(
134+
self, address: str, message: str | bytes, headers: dict[str, str]
135+
) -> tuple[ClientResponse, bytes]:
136+
"""Internal POST implementation that returns aiohttp response and content."""
141137
_LOGGER.debug("HTTP Post to %s:\n%s", address, message)
142138

143139
# Set default headers
@@ -169,15 +165,31 @@ async def _post(
169165
content,
170166
)
171167

172-
# Convert to httpx Response
173-
return self._aiohttp_to_httpx_response(response, content)
168+
return response, content
174169
except RuntimeError as exc:
175170
# Handle RuntimeError which may occur if the session is closed
176171
raise RuntimeError(f"Failed to post to {address}: {exc}") from exc
177-
178172
except TimeoutError as exc:
179173
raise TimeoutError(f"Request to {address} timed out") from exc
180174

175+
async def post(
176+
self, address: str, message: str, headers: dict[str, str]
177+
) -> httpx.Response:
178+
"""
179+
Perform async POST request.
180+
181+
Args:
182+
address: The URL to send the request to
183+
message: The message to send
184+
headers: HTTP headers to include
185+
186+
Returns:
187+
The httpx response object
188+
189+
"""
190+
response, content = await self._post_internal(address, message, headers)
191+
return self._aiohttp_to_httpx_response(response, content)
192+
181193
async def post_xml(
182194
self, address: str, envelope: _Element, headers: dict[str, str]
183195
) -> Response:
@@ -194,8 +206,8 @@ async def post_xml(
194206
195207
"""
196208
message = etree_to_string(envelope)
197-
response = await self.post(address, message, headers)
198-
return self._httpx_to_requests_response(response)
209+
response, content = await self._post_internal(address, message, headers)
210+
return self._aiohttp_to_requests_response(response, content)
199211

200212
async def get(
201213
self,
@@ -215,15 +227,6 @@ async def get(
215227
A Response object compatible with zeep
216228
217229
"""
218-
return await self._get(address, params, headers)
219-
220-
async def _get(
221-
self,
222-
address: str,
223-
params: dict[str, Any] | None = None,
224-
headers: dict[str, str] | None = None,
225-
) -> Response:
226-
"""Internal GET implementation."""
227230
_LOGGER.debug("HTTP Get from %s", address)
228231

229232
# Set default headers
@@ -258,18 +261,6 @@ async def _get(
258261
except TimeoutError as exc:
259262
raise TimeoutError(f"Request to {address} timed out") from exc
260263

261-
def _httpx_to_requests_response(self, response: httpx.Response) -> Response:
262-
"""Convert an httpx.Response object to a requests.Response object"""
263-
body = response.read()
264-
265-
new = Response()
266-
new._content = body
267-
new.status_code = response.status_code
268-
new.headers = response.headers
269-
new.cookies = response.cookies
270-
new.encoding = response.encoding
271-
return new
272-
273264
def load(self, url: str) -> bytes:
274265
"""
275266
Load content from URL synchronously.

tests/test_zeep_transport.py

Lines changed: 110 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
"""Tests for AIOHTTPTransport to ensure compatibility with zeep's AsyncTransport."""
22

3+
from http.cookies import SimpleCookie
34
from unittest.mock import AsyncMock, Mock, patch
45

56
import aiohttp
67
import httpx
78
import pytest
89
from lxml import etree
10+
from multidict import CIMultiDict
911
from onvif.zeep_aiohttp import AIOHTTPTransport
1012
from requests import Response as RequestsResponse
1113

@@ -460,8 +462,6 @@ async def test_cookies_in_requests_response():
460462
transport = AIOHTTPTransport(session=mock_session)
461463

462464
# Mock cookies using SimpleCookie format
463-
from http.cookies import SimpleCookie
464-
465465
mock_cookies = SimpleCookie()
466466
mock_cookies["session"] = "abc123"
467467

@@ -606,8 +606,6 @@ async def test_cookie_conversion_httpx_basic():
606606
transport = AIOHTTPTransport(session=mock_session)
607607

608608
# Create aiohttp cookies
609-
from http.cookies import SimpleCookie
610-
611609
cookies = SimpleCookie()
612610
cookies["session"] = "abc123"
613611
cookies["session"]["domain"] = ".example.com"
@@ -652,8 +650,6 @@ async def test_cookie_conversion_requests_basic():
652650
transport = AIOHTTPTransport(session=mock_session)
653651

654652
# Create aiohttp cookies
655-
from http.cookies import SimpleCookie
656-
657653
cookies = SimpleCookie()
658654
cookies["token"] = "xyz789"
659655
cookies["token"]["domain"] = ".api.example.com"
@@ -688,8 +684,6 @@ async def test_cookie_attributes_httpx():
688684
transport = AIOHTTPTransport(session=mock_session)
689685

690686
# Create cookie with all attributes
691-
from http.cookies import SimpleCookie
692-
693687
cookies = SimpleCookie()
694688
cookies["auth"] = "secret123"
695689
cookies["auth"]["domain"] = ".secure.com"
@@ -732,8 +726,6 @@ async def test_multiple_cookies():
732726
transport = AIOHTTPTransport(session=mock_session)
733727

734728
# Create multiple cookies
735-
from http.cookies import SimpleCookie
736-
737729
cookies = SimpleCookie()
738730
for i in range(5):
739731
cookie_name = f"cookie{i}"
@@ -773,8 +765,6 @@ async def test_empty_cookies():
773765
transport = AIOHTTPTransport(session=mock_session)
774766

775767
# Mock response without cookies
776-
from http.cookies import SimpleCookie
777-
778768
mock_response = Mock(spec=aiohttp.ClientResponse)
779769
mock_response.status = 200
780770
mock_response.headers = {}
@@ -803,8 +793,6 @@ async def test_cookie_encoding():
803793
transport = AIOHTTPTransport(session=mock_session)
804794

805795
# Create cookies with special chars
806-
from http.cookies import SimpleCookie
807-
808796
cookies = SimpleCookie()
809797
cookies["data"] = "hello%20world%21" # URL encoded
810798
cookies["unicode"] = "café"
@@ -838,8 +826,6 @@ async def test_cookie_jar_type():
838826
mock_session = create_mock_session()
839827
transport = AIOHTTPTransport(session=mock_session)
840828

841-
from http.cookies import SimpleCookie
842-
843829
cookies = SimpleCookie()
844830
cookies["test"] = "value"
845831

@@ -870,6 +856,114 @@ async def test_cookie_jar_type():
870856
assert "test" in requests_result.cookies
871857

872858

859+
@pytest.mark.asyncio
860+
async def test_gzip_content_encoding_header_removed():
861+
"""Test that Content-Encoding: gzip header is removed after aiohttp decompresses.
862+
863+
This fixes the issue where aiohttp automatically decompresses gzip content
864+
but the Content-Encoding header was still passed to zeep, causing it to
865+
attempt decompression again on already-decompressed content, resulting in
866+
zlib errors.
867+
"""
868+
mock_session = create_mock_session()
869+
transport = AIOHTTPTransport(session=mock_session)
870+
871+
# Mock response with Content-Encoding: gzip
872+
# aiohttp will have already decompressed the content
873+
mock_aiohttp_response = Mock(spec=aiohttp.ClientResponse)
874+
mock_aiohttp_response.status = 200
875+
# Simulate headers with Content-Encoding: gzip
876+
headers = CIMultiDict()
877+
headers["Content-Type"] = "application/soap+xml; charset=utf-8"
878+
headers["Content-Encoding"] = "gzip"
879+
headers["Server"] = "PelcoOnvifNvt"
880+
mock_aiohttp_response.headers = headers
881+
mock_aiohttp_response.method = "POST"
882+
mock_aiohttp_response.url = "http://camera.local/onvif/device_service"
883+
mock_aiohttp_response.charset = "utf-8"
884+
mock_aiohttp_response.cookies = {}
885+
886+
# Content is already decompressed by aiohttp
887+
decompressed_content = b'<?xml version="1.0"?><soap:Envelope>test</soap:Envelope>'
888+
mock_aiohttp_response.read = AsyncMock(return_value=decompressed_content)
889+
890+
mock_session = Mock(spec=aiohttp.ClientSession)
891+
mock_session.post = AsyncMock(return_value=mock_aiohttp_response)
892+
transport.session = mock_session
893+
894+
# Test httpx response (from post)
895+
httpx_result = await transport.post(
896+
"http://camera.local/onvif/device_service", "<request/>", {}
897+
)
898+
899+
# Verify Content-Encoding header was removed
900+
assert "content-encoding" not in httpx_result.headers
901+
assert "Content-Encoding" not in httpx_result.headers
902+
# Other headers should still be present
903+
assert httpx_result.headers["content-type"] == "application/soap+xml; charset=utf-8"
904+
assert httpx_result.headers["server"] == "PelcoOnvifNvt"
905+
# Content should be the decompressed data
906+
assert httpx_result.read() == decompressed_content
907+
908+
# Test requests response (from get)
909+
mock_session.get = AsyncMock(return_value=mock_aiohttp_response)
910+
requests_result = await transport.get("http://camera.local/onvif/device_service")
911+
912+
# Verify Content-Encoding header was removed from requests response too
913+
assert "content-encoding" not in requests_result.headers
914+
assert "Content-Encoding" not in requests_result.headers
915+
# Other headers should still be present
916+
assert (
917+
requests_result.headers["content-type"] == "application/soap+xml; charset=utf-8"
918+
)
919+
assert requests_result.headers["server"] == "PelcoOnvifNvt"
920+
# Content should be the decompressed data
921+
assert requests_result.content == decompressed_content
922+
923+
924+
@pytest.mark.asyncio
925+
async def test_multiple_duplicate_headers_preserved():
926+
"""Test that duplicate headers (except Content-Encoding) are preserved."""
927+
mock_session = create_mock_session()
928+
transport = AIOHTTPTransport(session=mock_session)
929+
930+
# Mock response with duplicate headers
931+
mock_aiohttp_response = Mock(spec=aiohttp.ClientResponse)
932+
mock_aiohttp_response.status = 200
933+
934+
# Create headers with duplicates (like multiple Set-Cookie headers)
935+
headers = CIMultiDict()
936+
headers.add("Set-Cookie", "session=abc123; Path=/")
937+
headers.add("Set-Cookie", "user=john; Path=/api")
938+
headers.add("Set-Cookie", "token=xyz789; Secure")
939+
headers.add("Content-Type", "text/xml")
940+
headers.add("Content-Encoding", "gzip") # This should be removed
941+
942+
mock_aiohttp_response.headers = headers
943+
mock_aiohttp_response.method = "POST"
944+
mock_aiohttp_response.url = "http://example.com"
945+
mock_aiohttp_response.charset = "utf-8"
946+
mock_aiohttp_response.cookies = {}
947+
mock_aiohttp_response.read = AsyncMock(return_value=b"test")
948+
949+
mock_session = Mock(spec=aiohttp.ClientSession)
950+
mock_session.post = AsyncMock(return_value=mock_aiohttp_response)
951+
transport.session = mock_session
952+
953+
# Test httpx response
954+
httpx_result = await transport.post("http://example.com", "test", {})
955+
956+
# Content-Encoding should be removed
957+
assert "content-encoding" not in httpx_result.headers
958+
959+
# All Set-Cookie headers should be preserved
960+
set_cookie_values = httpx_result.headers.get_list("set-cookie")
961+
assert len(set_cookie_values) == 3
962+
assert "session=abc123; Path=/" in set_cookie_values
963+
assert "user=john; Path=/api" in set_cookie_values
964+
assert "token=xyz789; Secure" in set_cookie_values
965+
966+
873967
@pytest.mark.asyncio
874968
async def test_http_error_responses_no_exception():
875969
"""Test that HTTP error responses (401, 500, etc.) don't raise exceptions."""

0 commit comments

Comments
 (0)