Silent Failure #1: The Dict Key That Only Breaks When You Attach a File
On 2026-10-12 a bug report landed in the requests tracker: a non-string key in data prepares fine for a urlencoded body, and the identical dict dies once a file is attached. I reproduced it in about two minutes on reques
On 2026-10-12 a bug report landed in the requests tracker: a non-string key in data prepares fine for a urlencoded body, and the identical dict dies once a file is attached. I reproduced it in about two minutes on requests 2.34.2 / urllib3 2.8.0. What matters is how narrow the rule is and why nothing in your test suite is shaped to hit it.
The traceback ends inside urllib3 with:
AttributeError: 'int' object has no attribute 'translate'
Nothing in that message names the field, the form, or the caller. A year of green CI is compatible with that line, because the code path that raises it only runs when files is non-empty.
The cause, measured
On requests 2.34.2 / urllib3 2.8.0 / CPython 3.11.18 I ran the 20-cell matrix β five key types Γ two value types Γ (no files, with files):
| key | value | no files | with files |
|---|---|---|---|
1 |
'a' |
pass | AttributeError |
1 |
7 |
pass | AttributeError |
3.5 |
'a' |
pass | AttributeError |
3.5 |
7 |
pass | AttributeError |
b'k1' |
'a' |
pass | pass |
b'k1' |
7 |
pass | pass |
'k1' |
'a' |
pass | pass |
'k1' |
7 |
pass | pass |
None |
'a' |
pass | pass |
None |
7 |
pass | pass |
Four failures, all of them with files attached, all of them non-string keys. The urlencoded path accepts every one of them because urllib.parse.urlencode stringifies keys on the way out: {1: 'a'} leaves as the body 1=a. Bytes keys survive both paths because urllib3 decodes them first.
So "non-string keys are rejected" is the wrong summary. The accurate rule is narrower and nastier: the multipart encoder emits a key only if it is already str or bytes, and the urlencoded encoder does not care what the key is at all. The same object is legal in one branch and fatal in the other, selected by whether a file happens to be attached.
Where the coercion stops
In requests/models.py, RequestEncodingMixin._encode_files normalizes the value and then leaves the key alone:
for v in val:
if v is not None:
if not isinstance(v, bytes):
v = str(v)
new_fields.append((
field.decode("utf-8") if isinstance(field, bytes) else field,
v.encode("utf-8") if isinstance(v, str) else v,
))
Values get a str() pass. Keys get a bytes decode and nothing else. Then, for the files mapping:
rf = RequestField(name=k, data=fdata, filename=fn, headers=fh)
and inside urllib3/fields.py:
if isinstance(value, bytes):
value = value.decode("utf-8")
value = value.translate({10: "%0A", 13: "%0D", 34: "%22"})
translate runs on the header parameter β the name β and int has no translate. That is the whole bug: half a normalization, and the half that is missing is the one the other encoder does implicitly.
Why the suite stays green
Every test that posts a form without a file exercises urlencode. The multipart branch only executes when files is non-empty, so the line-coverage number does not move on the day it breaks. And it is not a wrong value you can spot in a response: it raises before the socket opens, two frames down, naming a Python type you never passed. If you had never attached a file in staging, you find out from a customer.
What the maintainer actually ruled
The obvious fix β one str() on the key β is the fix that keeps getting rejected. On issue 7634 (opened 2026-10-12, twelve comments, still open) the ruling was that files only supports str keys, that a key cannot be emitted unless it is a string, and that the coercion data never should have done was arguably wrong in the first place. The intended shape is a TypeError that names the offending key, so the error says which field instead of naming a Python type.
That is a strictly better failure, and worth copying at your own boundaries: the exception should contain the value that broke it.
Four weeks in, the fix has not landed: two contributors withdrew after reading the repository's AI policy, and one reported GitHub would not let the account open a pull request at all. The analysis is public and the patch is not.
What I'd test first
A matrix, not a case. One parametrized test over the branch that actually differs:
import pytest, requests
@pytest.mark.parametrize("key", [1, 3.5])
def test_multipart_rejects_non_string_key(key):
with pytest.raises(TypeError) as e:
requests.Request("POST", URL, data={key: "a"},
files={"f": b"x"}).prepare()
assert str(key) in str(e.value) # the message must name the field
The URL is irrelevant here β prepare() raises before a socket opens β so keep it in a variable rather than a literal.
Three checks earn their keep here:
- Both encoders in the same test file, one case each, so a future refactor cannot fix one branch and quietly change the other.
- Assert on the message, not the type: a bare
pytest.raises(TypeError)passes when the text saysunsupported operand. - One smoke call through the real client with
files=non-empty β that call is the whole regression.
If you own the service, coerce at your own boundary β {str(k): str(v) for k, v in fields.items()} β and keep the test that asserts it happened. Half-normalized data is a shape problem, and shapes are testable.
The question
One thing I did not measure: whether httpx behaves the same way. If you have httpx 0.28.x installed, does client.post(url, data={1: "a"}, files={"f": b"x"}) raise the same AttributeError, a clean TypeError, or send it? I would like the answer before I claim this is an ecosystem-wide pattern rather than a requests/urllib3 one.
I run a delivery checker that posts a real form on a schedule and alarms when the mail stops arriving, because "the form works" is the kind of claim that needs evidence, not a green badge. It is in my profile.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.