Skip to content

Commit 0c1c6d4

Browse files
Pijukatelclaude
andauthored
fix: decode header values per-ecosystem (Python/JS), expose httpx-style Response.headers (#492)
Fixes #479 (and revisits the decision made in #434). ## Context Response header values were decoded as ISO-8859-1 (`b as char`, introduced in #434 to stop non-ASCII header bytes from crashing Node / emptying Python, #430). That corrupts the common case of **UTF-8** header values (e.g. `Content-Disposition: filename="naïve.pdf"`) into mojibake (#479). The two positions genuinely conflict: a byte sequence can't be decoded as both ISO-8859-1 and UTF-8, and the "right" answer differs by ecosystem. ## How Each binding follows the reference client it emulates: - **Python** — `Response.headers` is now an **httpx-style `Headers` object** (adapted from httpx and kept in Python, like the existing `Cookies` class). It provides case-insensitive `str` access and a `.raw` property returning the exact wire bytes as `list[tuple[bytes, bytes]]`. The `Headers` object owns decoding — ascii → utf-8 → iso-8859-1, chosen once over the whole header set, exactly as httpx does — so UTF-8 header values decode correctly (fixes #479) while non-UTF-8 values fall back to ISO-8859-1 (keeps #434/#430). The Rust side just hands `Headers` the raw wire bytes. - **JavaScript** — keeps **strict ISO-8859-1** decoding (Fetch semantics). Because that mapping is a bijection, the string form stays byte-recoverable: `Buffer.from(value, 'latin1')` reproduces the exact bytes (and `.toString('utf8')` recovers a UTF-8 value). No raw-header accessor is added — it isn't part of the Fetch interface impit implements, and the latin-1 round-trip already covers the need. Header decoding now lives in exactly one place per binding (the Python `Headers` class; Node's inline latin-1). The former shared `decode_header_value` core helper is removed; body-charset detection reads the (ASCII, per RFC 9110) content-type via `String::from_utf8_lossy`. ### Net effect on #479 - **Python**: fully fixed — UTF-8 header values decode correctly, and `response.headers.raw` exposes exact bytes for signature/HMAC use. - **JavaScript**: string values remain ISO-8859-1 **by design** (Fetch parity), and are byte-recoverable via `Buffer.from(value, 'latin1')`. > Note: `response.headers` on the Python side is a read view — each access rebuilds it from the > response, so in-place mutations don't persist (documented on the accessor). ## Consistency with ecosystem Each binding matches the reference client it implements. ### Python — matches `httpx` (which impit-python implements) impit-python advertises the httpx interface ("drop-in replacement for `httpx.AsyncClient`"). This PR mirrors httpx's `Headers` directly: - httpx `Headers.encoding` tries `ascii`, then `utf-8`, then falls back to `iso-8859-1`: [`httpx/_models.py` @ v0.28.1](https://github.com/encode/httpx/blob/0.28.1/httpx/_models.py#L125-L145) — *"Header encoding is mandated as ascii, but we allow fallbacks to utf-8 or iso-8859-1."* - httpx exposes `Headers.raw: list[tuple[bytes, bytes]]`: [same file, `raw` property](https://github.com/encode/httpx/blob/0.28.1/httpx/_models.py#L152-L156). impit's `Response.headers` is that same `Headers` type with the same `.raw`. ### JavaScript — matches the Fetch API / undici (which impit-node implements) impit-node is "API-compatible with the Fetch API `Response`". In Fetch, header values are a [byte sequence](https://fetch.spec.whatwg.org/#concept-header-value) exposed to JS as a [`ByteString`](https://fetch.spec.whatwg.org/#headers-class), i.e. via [isomorphic decode](https://webidl.spec.whatwg.org/#idl-ByteString) — each byte `0x00–0xFF` maps to the code point of equal value (ISO-8859-1). This PR keeps impit-node on that behavior: - undici (Node's `fetch`) does the same: [nodejs/undici#1560](nodejs/undici#1560), [#1317](nodejs/undici#1317) (Latin-1 `ByteString`s). - Node's core `http` parser decodes header values as `latin1`/`binary` ([nodejs/node#17390](nodejs/node#17390), [#58240](nodejs/node#58240)); **axios** inherits this via its Node and XHR/Fetch adapters. Fetch has no raw-header accessor, and ISO-8859-1 is a bijection, so a JS raw accessor would be redundant surface — hence none is added. ## Tests - **Python:** unit tests for the `Headers` class (`.raw` byte-exactness, encoding selection ascii/utf-8/iso-8859-1, case-insensitive access, comma-joined duplicates, construction from bytes-pairs and str mappings); an integration test over a raw socket asserting a UTF-8 header decodes as UTF-8 (`encoding == 'utf-8'`) with exact `.raw` bytes; `response_test` uses `.headers.raw`. - **JavaScript:** existing latin-1 regression test kept; a new test asserts a UTF-8 header value round-trips via `Buffer.from(value, 'latin1').toString('utf8')`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ddaeb59 commit 0c1c6d4

10 files changed

Lines changed: 433 additions & 15 deletions

File tree

impit-node/src/response.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ impl<'env> ImpitResponse {
8989
.canonical_reason()
9090
.unwrap_or("")
9191
.to_string();
92+
// JS Fetch semantics: header values are decoded as ISO-8859-1 (each byte 0x00..=0xFF maps to
93+
// the code point U+0000..=U+00FF). Since that mapping is a bijection, the string stays
94+
// byte-recoverable via `Buffer.from(value, 'latin1')`.
9295
let mut headers_vec: Vec<(String, String)> = Vec::new();
9396
for (k, v) in response.headers().iter() {
9497
headers_vec.push((

impit-node/test/basics.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,18 @@ describe.each([
571571
t.expect(response.headers.get('x-non-ascii')).toBe(routes.nonAsciiHeader.headerValue);
572572
});
573573

574+
test('UTF-8 header values stay ISO-8859-1 and are byte-recoverable (Fetch-style)', async (t) => {
575+
const response = await impit.fetch(new URL(routes.utf8Header.path, "http://127.0.0.1:3001").href);
576+
577+
// Fetch semantics: the string form is ISO-8859-1, so a UTF-8 value reads back as mojibake.
578+
const latin1 = response.headers.get('x-utf8');
579+
t.expect(latin1).not.toBe(routes.utf8Header.headerValue);
580+
581+
// ISO-8859-1 is a bijection, so the exact wire bytes are recoverable, and re-decoding
582+
// as UTF-8 yields the real value — no dedicated raw-header accessor needed.
583+
t.expect(Buffer.from(latin1!, 'latin1').toString('utf8')).toBe(routes.utf8Header.headerValue);
584+
});
585+
574586
test('.json() method works', async (t) => {
575587
const response = await impit.fetch(getHttpBinUrl('/json'));
576588
const json = await response.json();

impit-node/test/mock.server.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ export const routes = {
2424
path: '/non-ascii-header',
2525
headerValue: 'Dienstag, 31. März 2026',
2626
},
27+
utf8Header: {
28+
path: '/utf8-header',
29+
headerValue: 'attachment; filename="naïve.pdf"',
30+
},
2731
}
2832

2933
function parseMultipart(body: Buffer, boundary: string): Record<string, string> {
@@ -117,6 +121,22 @@ export async function runServer(port: number): Promise<Server> {
117121
socket.end();
118122
});
119123

124+
app.get(routes.utf8Header.path, (req, res) => {
125+
const socket = res.socket!;
126+
socket.write('HTTP/1.1 200 OK\r\n');
127+
socket.write('Content-Type: text/plain\r\n');
128+
// Header value carrying UTF-8 bytes (the ï is 0xC3 0xAF).
129+
socket.write(Buffer.concat([
130+
Buffer.from('X-Utf8: '),
131+
Buffer.from(routes.utf8Header.headerValue, 'utf-8'),
132+
Buffer.from('\r\n'),
133+
]));
134+
socket.write('Content-Length: 2\r\n');
135+
socket.write('\r\n');
136+
socket.write('ok');
137+
socket.end();
138+
});
139+
120140
app.get('/socket', (req, res) => {
121141
const socket = req.socket;
122142
const clientAddress = socket.remoteAddress;

impit-python/python/impit/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from typing import Literal
33

44
from .cookies import Cookies
5+
from .headers import Headers
56
from .impit import (
67
USE_CLIENT_DEFAULT,
78
AsyncClient,
@@ -61,6 +62,7 @@
6162
'DecodingError',
6263
'HTTPError',
6364
'HTTPStatusError',
65+
'Headers',
6466
'InvalidURL',
6567
'LocalProtocolError',
6668
'NetworkError',
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
"""Copyright © 2019, [Encode OSS Ltd](https://www.encode.io/).
2+
3+
All rights reserved.
4+
5+
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
6+
7+
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
8+
9+
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
10+
11+
* Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
12+
13+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
14+
"""
15+
# ruff: noqa: D102, D105, E501, FBT001, FBT002, PERF203, PLW1641, SIM118, SLF001
16+
17+
## The Headers class below is a modification of the `httpx.Headers` class, licensed under the BSD 3-Clause "New" License.
18+
19+
from __future__ import annotations
20+
21+
import typing
22+
from collections.abc import Mapping
23+
24+
HeaderTypes = typing.Union[
25+
'Headers',
26+
typing.Mapping[str, str],
27+
typing.Mapping[bytes, bytes],
28+
typing.Sequence[tuple[str, str]],
29+
typing.Sequence[tuple[bytes, bytes]],
30+
]
31+
32+
SENSITIVE_HEADERS = {'authorization', 'proxy-authorization'}
33+
34+
35+
def _normalize_header_key(key: str | bytes, encoding: str | None = None) -> bytes:
36+
return key if isinstance(key, bytes) else key.encode(encoding or 'ascii')
37+
38+
39+
def _normalize_header_value(value: str | bytes, encoding: str | None = None) -> bytes:
40+
return value if isinstance(value, bytes) else value.encode(encoding or 'ascii')
41+
42+
43+
def _obfuscate_sensitive_headers(
44+
items: typing.Iterable[tuple[str, str]],
45+
) -> typing.Iterator[tuple[str, str]]:
46+
for k, v in items:
47+
yield k, ('[secure]' if k.lower() in SENSITIVE_HEADERS else v)
48+
49+
50+
class Headers(typing.MutableMapping[str, str]):
51+
"""HTTP headers, as a case-insensitive multi-dict.
52+
53+
Mirrors the `httpx.Headers` interface, including the `raw` property that exposes the exact
54+
header value bytes received on the wire.
55+
"""
56+
57+
def __init__(self, headers: HeaderTypes | None = None, encoding: str | None = None) -> None:
58+
self._list: list[tuple[bytes, bytes, bytes]] = []
59+
60+
if isinstance(headers, Headers):
61+
self._list = list(headers._list)
62+
elif isinstance(headers, Mapping):
63+
for k, v in headers.items():
64+
bytes_key = _normalize_header_key(k, encoding)
65+
bytes_value = _normalize_header_value(v, encoding)
66+
self._list.append((bytes_key, bytes_key.lower(), bytes_value))
67+
elif headers is not None:
68+
for k, v in headers:
69+
bytes_key = _normalize_header_key(k, encoding)
70+
bytes_value = _normalize_header_value(v, encoding)
71+
self._list.append((bytes_key, bytes_key.lower(), bytes_value))
72+
73+
self._encoding = encoding
74+
75+
@property
76+
def encoding(self) -> str:
77+
"""Header encoding is mandated as ascii, but we allow fallbacks to utf-8 or iso-8859-1."""
78+
if self._encoding is None:
79+
for encoding in ['ascii', 'utf-8']:
80+
for key, value in self.raw:
81+
try:
82+
key.decode(encoding)
83+
value.decode(encoding)
84+
except UnicodeDecodeError:
85+
break
86+
else:
87+
self._encoding = encoding
88+
break
89+
else:
90+
# ISO-8859-1 covers all 256 byte values, so it never raises decode errors.
91+
self._encoding = 'iso-8859-1'
92+
return self._encoding
93+
94+
@encoding.setter
95+
def encoding(self, value: str) -> None:
96+
self._encoding = value
97+
98+
@property
99+
def raw(self) -> list[tuple[bytes, bytes]]:
100+
"""Return the raw header items, as `(name, value)` byte pairs."""
101+
return [(raw_key, value) for raw_key, _, value in self._list]
102+
103+
def keys(self) -> typing.KeysView[str]:
104+
return {key.decode(self.encoding): None for _, key, value in self._list}.keys()
105+
106+
def values(self) -> typing.ValuesView[str]:
107+
values_dict: dict[str, str] = {}
108+
for _, key, value in self._list:
109+
str_key = key.decode(self.encoding)
110+
str_value = value.decode(self.encoding)
111+
if str_key in values_dict:
112+
values_dict[str_key] += f', {str_value}'
113+
else:
114+
values_dict[str_key] = str_value
115+
return values_dict.values()
116+
117+
def items(self) -> typing.ItemsView[str, str]:
118+
"""Return `(key, value)` items, concatenating repeated keys with commas."""
119+
values_dict: dict[str, str] = {}
120+
for _, key, value in self._list:
121+
str_key = key.decode(self.encoding)
122+
str_value = value.decode(self.encoding)
123+
if str_key in values_dict:
124+
values_dict[str_key] += f', {str_value}'
125+
else:
126+
values_dict[str_key] = str_value
127+
return values_dict.items()
128+
129+
def multi_items(self) -> list[tuple[str, str]]:
130+
"""Return `(key, value)` pairs without concatenating repeated keys."""
131+
return [(key.decode(self.encoding), value.decode(self.encoding)) for _, key, value in self._list]
132+
133+
def get(self, key: str, default: typing.Any = None) -> typing.Any:
134+
"""Return a header value, concatenating repeated occurrences with commas."""
135+
try:
136+
return self[key]
137+
except KeyError:
138+
return default
139+
140+
def get_list(self, key: str, split_commas: bool = False) -> list[str]:
141+
"""Return all header values for `key`, optionally splitting on commas."""
142+
get_header_key = key.lower().encode(self.encoding)
143+
values = [
144+
item_value.decode(self.encoding)
145+
for _, item_key, item_value in self._list
146+
if item_key.lower() == get_header_key
147+
]
148+
if not split_commas:
149+
return values
150+
split_values = []
151+
for value in values:
152+
split_values.extend([item.strip() for item in value.split(',')])
153+
return split_values
154+
155+
def update(self, headers: HeaderTypes | None = None) -> None: # type: ignore[override]
156+
headers = Headers(headers)
157+
for key in headers.keys():
158+
if key in self:
159+
self.pop(key)
160+
self._list.extend(headers._list)
161+
162+
def copy(self) -> Headers:
163+
return Headers(self, encoding=self.encoding)
164+
165+
def __getitem__(self, key: str) -> str:
166+
"""Return a single header value; repeated keys are joined with commas (RFC 7230 §3.2.2)."""
167+
normalized_key = key.lower().encode(self.encoding)
168+
items = [
169+
header_value.decode(self.encoding)
170+
for _, header_key, header_value in self._list
171+
if header_key == normalized_key
172+
]
173+
if items:
174+
return ', '.join(items)
175+
raise KeyError(key)
176+
177+
def __setitem__(self, key: str, value: str) -> None:
178+
"""Set `key` to `value`, removing duplicates and retaining insertion order."""
179+
set_key = key.encode(self._encoding or 'utf-8')
180+
set_value = value.encode(self._encoding or 'utf-8')
181+
lookup_key = set_key.lower()
182+
found_indexes = [idx for idx, (_, item_key, _) in enumerate(self._list) if item_key == lookup_key]
183+
for idx in reversed(found_indexes[1:]):
184+
del self._list[idx]
185+
if found_indexes:
186+
idx = found_indexes[0]
187+
self._list[idx] = (set_key, lookup_key, set_value)
188+
else:
189+
self._list.append((set_key, lookup_key, set_value))
190+
191+
def __delitem__(self, key: str) -> None:
192+
"""Remove the header `key`."""
193+
del_key = key.lower().encode(self.encoding)
194+
pop_indexes = [idx for idx, (_, item_key, _) in enumerate(self._list) if item_key.lower() == del_key]
195+
if not pop_indexes:
196+
raise KeyError(key)
197+
for idx in reversed(pop_indexes):
198+
del self._list[idx]
199+
200+
def __contains__(self, key: typing.Any) -> bool:
201+
header_key = key.lower().encode(self.encoding)
202+
return header_key in [key for _, key, _ in self._list]
203+
204+
def __iter__(self) -> typing.Iterator[typing.Any]:
205+
return iter(self.keys())
206+
207+
def __len__(self) -> int:
208+
return len(self._list)
209+
210+
def __eq__(self, other: object) -> bool:
211+
try:
212+
other_headers = Headers(other) # type: ignore[arg-type]
213+
except ValueError:
214+
return False
215+
self_list = [(key, value) for _, key, value in self._list]
216+
other_list = [(key, value) for _, key, value in other_headers._list]
217+
return sorted(self_list) == sorted(other_list)
218+
219+
def __repr__(self) -> str:
220+
class_name = self.__class__.__name__
221+
encoding_str = f', encoding={self.encoding!r}' if self.encoding != 'ascii' else ''
222+
as_list = list(_obfuscate_sensitive_headers(self.multi_items()))
223+
as_dict = dict(as_list)
224+
if len(as_dict) == len(as_list):
225+
return f'{class_name}({as_dict!r}{encoding_str})'
226+
return f'{class_name}({as_list!r}{encoding_str})'

impit-python/python/impit/impit.pyi

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22
from http.cookiejar import CookieJar
33
from .cookies import Cookies
4+
from .headers import Headers
45

56
from typing import Literal, Any
67
from collections.abc import Iterator, AsyncIterator
@@ -171,13 +172,20 @@ class Response:
171172
print(response.http_version) # 'HTTP/2'
172173
"""
173174

174-
headers: dict[str, str]
175-
"""Response headers as a Python dictionary.
175+
headers: Headers
176+
"""Response headers as an httpx-style :class:`Headers` object.
177+
178+
Provides case-insensitive ``str`` access, plus a ``.raw`` property exposing the exact header
179+
value bytes received on the wire (useful for UTF-8 header values or signature/HMAC checks).
180+
181+
This is a read view: each access rebuilds the object from the response, so in-place mutations
182+
(``response.headers['x'] = ...``) do not persist on the response.
176183
177184
.. code-block:: python
178185
179186
response = await client.get("https://crawlee.dev")
180-
print(response.headers) # {'content-type': 'text/html; charset=utf-8', ... }
187+
print(response.headers['content-type']) # 'text/html; charset=utf-8'
188+
print(response.headers.raw) # [(b'content-type', b'text/html; charset=utf-8'), ... ]
181189
"""
182190

183191
text: str

0 commit comments

Comments
 (0)