Spaces:
Build error
Build error
pythoninthegrass Claude Sonnet 4.5 commited on
Commit ·
876e860
1
Parent(s): 8599bf7
Add tests for compression header removal fix
Browse filesTest cases verify:
- Content-Encoding and Content-Length headers are correctly removed
- Response bodies are already decompressed by httpx
- Keeping compression headers causes length mismatch issues
- The fix doesn't break uncompressed responses
These tests will catch regressions of the ZlibError bug.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
tests/test_proxy_compression_headers.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for compression header handling in the proxy server.
|
| 2 |
+
|
| 3 |
+
These tests verify that the proxy correctly removes Content-Encoding headers
|
| 4 |
+
from responses after httpx automatically decompresses them, preventing
|
| 5 |
+
double-decompression errors (ZlibError) in clients.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import gzip
|
| 9 |
+
import json
|
| 10 |
+
|
| 11 |
+
import pytest
|
| 12 |
+
|
| 13 |
+
from headroom.proxy.server import ProxyConfig
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@pytest.fixture
|
| 17 |
+
def mock_anthropic_response_with_compression_headers():
|
| 18 |
+
"""Create a mock response that simulates httpx behavior.
|
| 19 |
+
|
| 20 |
+
httpx automatically decompresses responses but leaves compression headers.
|
| 21 |
+
This is what causes the ZlibError bug we're testing for.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
class MockResponse:
|
| 25 |
+
"""Mock httpx response with compression headers."""
|
| 26 |
+
|
| 27 |
+
def __init__(self):
|
| 28 |
+
self.response_data = {
|
| 29 |
+
"id": "msg_test123",
|
| 30 |
+
"type": "message",
|
| 31 |
+
"role": "assistant",
|
| 32 |
+
"content": [{"type": "text", "text": "Hello!"}],
|
| 33 |
+
"model": "claude-3-5-sonnet-20241022",
|
| 34 |
+
"stop_reason": "end_turn",
|
| 35 |
+
"usage": {"input_tokens": 10, "output_tokens": 5},
|
| 36 |
+
}
|
| 37 |
+
# Body is already decompressed (httpx does this automatically)
|
| 38 |
+
self.content = json.dumps(self.response_data).encode("utf-8")
|
| 39 |
+
self.status_code = 200
|
| 40 |
+
|
| 41 |
+
# Headers still contain compression info (this is the bug!)
|
| 42 |
+
self.headers = {
|
| 43 |
+
"content-type": "application/json",
|
| 44 |
+
"content-encoding": "gzip", # Should be removed!
|
| 45 |
+
"content-length": str(len(gzip.compress(self.content))), # Wrong!
|
| 46 |
+
"x-request-id": "test-request-id",
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
return MockResponse()
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class TestCompressionHeaderRemoval:
|
| 53 |
+
"""Tests for Content-Encoding header removal logic."""
|
| 54 |
+
|
| 55 |
+
def test_compression_headers_are_removed_from_dict(
|
| 56 |
+
self, mock_anthropic_response_with_compression_headers
|
| 57 |
+
):
|
| 58 |
+
"""Test that our fix removes compression headers from response headers."""
|
| 59 |
+
mock_response = mock_anthropic_response_with_compression_headers
|
| 60 |
+
|
| 61 |
+
# Simulate what the fixed code does
|
| 62 |
+
response_headers = dict(mock_response.headers)
|
| 63 |
+
response_headers.pop("content-encoding", None)
|
| 64 |
+
response_headers.pop("content-length", None)
|
| 65 |
+
|
| 66 |
+
# Verify compression headers are removed
|
| 67 |
+
assert "content-encoding" not in response_headers
|
| 68 |
+
assert "content-length" not in response_headers
|
| 69 |
+
|
| 70 |
+
# Verify other headers are preserved
|
| 71 |
+
assert response_headers["content-type"] == "application/json"
|
| 72 |
+
assert response_headers["x-request-id"] == "test-request-id"
|
| 73 |
+
|
| 74 |
+
def test_response_body_is_decompressed_not_compressed(
|
| 75 |
+
self, mock_anthropic_response_with_compression_headers
|
| 76 |
+
):
|
| 77 |
+
"""Verify the response content is already decompressed (httpx behavior)."""
|
| 78 |
+
mock_response = mock_anthropic_response_with_compression_headers
|
| 79 |
+
|
| 80 |
+
# The content should be valid JSON (decompressed)
|
| 81 |
+
response_data = json.loads(mock_response.content)
|
| 82 |
+
assert response_data["id"] == "msg_test123"
|
| 83 |
+
|
| 84 |
+
# Trying to decompress it again should fail (proving it's not compressed)
|
| 85 |
+
with pytest.raises((gzip.BadGzipFile, OSError, Exception)):
|
| 86 |
+
gzip.decompress(mock_response.content)
|
| 87 |
+
|
| 88 |
+
def test_headers_with_wrong_content_length_cause_issues(
|
| 89 |
+
self, mock_anthropic_response_with_compression_headers
|
| 90 |
+
):
|
| 91 |
+
"""Demonstrate that keeping compression headers causes length mismatch."""
|
| 92 |
+
mock_response = mock_anthropic_response_with_compression_headers
|
| 93 |
+
|
| 94 |
+
# The content-length header says the body is compressed size
|
| 95 |
+
claimed_length = int(mock_response.headers["content-length"])
|
| 96 |
+
|
| 97 |
+
# But the actual content is decompressed size
|
| 98 |
+
actual_length = len(mock_response.content)
|
| 99 |
+
|
| 100 |
+
# They don't match! This can cause client issues
|
| 101 |
+
assert claimed_length != actual_length
|
| 102 |
+
assert claimed_length < actual_length # Compressed is smaller
|
| 103 |
+
|
| 104 |
+
def test_removing_headers_fixes_length_mismatch(
|
| 105 |
+
self, mock_anthropic_response_with_compression_headers
|
| 106 |
+
):
|
| 107 |
+
"""Show that removing compression headers allows proper content-length."""
|
| 108 |
+
mock_response = mock_anthropic_response_with_compression_headers
|
| 109 |
+
|
| 110 |
+
# Apply the fix
|
| 111 |
+
response_headers = dict(mock_response.headers)
|
| 112 |
+
response_headers.pop("content-encoding", None)
|
| 113 |
+
response_headers.pop("content-length", None)
|
| 114 |
+
|
| 115 |
+
# Now we can set correct content-length
|
| 116 |
+
response_headers["content-length"] = str(len(mock_response.content))
|
| 117 |
+
|
| 118 |
+
# Verify it matches actual content
|
| 119 |
+
assert int(response_headers["content-length"]) == len(mock_response.content)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class TestNoRegressionForUncompressedResponses:
|
| 123 |
+
"""Ensure the fix doesn't break responses that were never compressed."""
|
| 124 |
+
|
| 125 |
+
def test_pop_on_missing_keys_is_safe(self):
|
| 126 |
+
"""Verify that .pop() on non-existent keys doesn't cause errors."""
|
| 127 |
+
headers = {
|
| 128 |
+
"content-type": "application/json",
|
| 129 |
+
# No compression headers
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
# This should not raise KeyError
|
| 133 |
+
headers.pop("content-encoding", None)
|
| 134 |
+
headers.pop("content-length", None)
|
| 135 |
+
|
| 136 |
+
# Headers should be unchanged
|
| 137 |
+
assert headers == {"content-type": "application/json"}
|
| 138 |
+
|
| 139 |
+
def test_dict_conversion_preserves_headers(self):
|
| 140 |
+
"""Verify dict() conversion doesn't lose headers."""
|
| 141 |
+
original_headers = {
|
| 142 |
+
"content-type": "application/json",
|
| 143 |
+
"x-custom-header": "value",
|
| 144 |
+
"authorization": "Bearer token",
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
# Convert to dict (as the fix does)
|
| 148 |
+
converted = dict(original_headers)
|
| 149 |
+
|
| 150 |
+
# All headers preserved
|
| 151 |
+
assert converted == original_headers
|
| 152 |
+
assert converted is not original_headers # New object
|