DEV Community

Cover image for Silent Failure #1: The Dict Key That Only Breaks When You Attach a File
Renhua Wei
Renhua Wei

Posted on

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 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'
Enter fullscreen mode Exit fullscreen mode

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,
        ))
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

and inside urllib3/fields.py:

if isinstance(value, bytes):
    value = value.decode("utf-8")
value = value.translate({10: "%0A", 13: "%0D", 34: "%22"})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Both encoders in the same test file, one case each, so a future refactor cannot fix one branch and quietly change the other.
  2. Assert on the message, not the type: a bare pytest.raises(TypeError) passes when the text says unsupported operand.
  3. 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.

Top comments (0)