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 (6)

Collapse
 
sinarezaei profile image
Sina Rezaei •

The interesting part here is that the same input data goes through two completely different encoding paths depending on whether a file is attached. That's a pretty nasty boundary to leave untested.

I've seen a related issue in FastAPI's GitHub discussions (#6151), where combining an uploaded file with a dictionary declared as Body(...) resulted in a 422 because the request switched from JSON to multipart/form-data. The fix was to move the structured fields into Form().

What connects both cases is that adding a file isn't just adding another field. It changes the serialization contract of the entire request.

I'd also test this at the HTTP boundary, not just against the internal encoder: inspect the prepared Content-Type, generated multipart field names, and actual payload, then run the same parameterized cases with and without files. That would catch regressions that unit tests around the dictionary itself could easily miss.

Really like this series. These are exactly the kinds of bugs that survive for months because the test data never crosses the boundary that triggers them.

Collapse
 
dafili profile image
Renhua Wei •

Yes to the boundary, with the caveat this bug forces: with files attached the raise happens inside prepare(), before a socket exists, so the test never reaches the point where you can read Content-Type or the payload. The assertion has to sit on the prepare() call itself.

@pytest.mark.parametrize("key", [1, 3.5, b"k1", "k1", None])
@pytest.mark.parametrize("files", [None, {"f": ("a.txt", b"x")}])
def test_prepare_same_dict(key, files):
    req = requests.Request("POST", "https://example.invalid/", data={key: "a"}, files=files)
    p = req.prepare()   # AttributeError for int/float keys only when files is set
    ct = p.headers["Content-Type"].split(";")[0]
    assert ct == ("multipart/form-data" if files else "application/x-www-form-urlencoded")
Enter fullscreen mode Exit fullscreen mode

I ran that matrix at 5 key types x 2 value types x 2 file states - 20 cells, requests 2.34.2 / urllib3 2.8.0 / CPython 3.11.18: 4 cells fail, all non-string keys, all with files.

The part that surprised me is that a body-level check can't see any of it. b'k1' and 'k1' emit identical multipart bytes, because urllib3 decodes bytes keys before translate() - the legality differs, the wire output doesn't. So byte comparison passes on a dict that is one edit away from raising, and your parameterized run has to assert on the key type, not on the payload.

Your FastAPI case is the same shape one layer up: attaching a file doesn't add a field, it swaps the serialization contract, and the failure lands in a branch no file-free test ever executes. Only the FastAPI one told you which parameter was wrong; this one names a Python type you never passed.

Collapse
 
sinarezaei profile image
Sina Rezaei •

That's a good catch. You're right, I was placing the assertion too late. In this case, prepare() is the actual boundary where the failure needs to be tested.

One thing your bytes vs str observation makes me think about is normalization. If both keys can produce the same multipart field name, what happens when a dictionary contains both b"k1" and "k1"? They can coexist as Python dictionary keys but end up representing the same field on the wire.

I'd be inclined to validate field names before choosing the encoding path, rather than letting each encoder enforce a different set of rules. If bytes keys are supported, collision detection after normalization would also be worth considering. Otherwise, rejecting non-string keys consistently with a clear TypeError seems safer than silently coercing them.

The interesting part here is that the bug isn't just about multipart encoding failing. It's about two serialization paths accepting different input contracts for what looks like the same API.

Thread Thread
 
dafili profile image
Renhua Wei •

Ran both paths on requests 2.34.2 / urllib3 2.8.0: neither rejects bytes keys — {b"k1": "a"} sends k1=a without a file, and Content-Disposition: form-data; name="k1" with one, so a TypeError guard inside either encoder is already too late.

The collision is the worse half. {b"k1": "a", "k1": "b"} plus a file emits two parts both named k1 and the server keeps one, while the urlencoded path sends k1=a&k1=b and a MultiDict keeps both — same dict, two different success semantics, no exception on either.

So validate before the branch and assert on the parsed result (exactly one field named k1, otherwise raise), not on body bytes: byte-level assertions go green on both paths while the field is already gone.

Thread Thread
 
sinarezaei profile image
Sina Rezaei •

That's an interesting distinction. I'd take the collision test one step further and separate what the encoder emits from what the server actually exposes to the application.

There's a detail in RFC 7578 (section 5.2) worth considering here: multipart processors shouldn't coalesce parts with identical field names. So the two k1 parts aren't necessarily lost during parsing. They can survive in a MultiDict while ordinary dictionary-style access silently returns just one value. Werkzeug is a good example of this behavior with getlist() versus regular key access.

I'd test this with a field that is expected to be scalar, not just compare the serialized bodies. For example, if tenant_id arrives twice after normalization, the application shouldn't silently accept whichever value its parser happens to return.

Something like this:

  • Normalize field names before selecting the encoder.
  • Detect collisions after normalization.
  • Preserve repeated values at the parsing boundary.
  • Reject duplicate values when the API schema expects exactly one.

That last part matters because the same request can look perfectly valid at the transport level while violating the application's data contract.

I think there's a useful distinction here: the encoder should guarantee a consistent representation, but the application boundary should guarantee unambiguous meaning. Fixing the first without testing the second could leave the silent failure intact.

Thread Thread
 
dafili profile image
Renhua Wei •

You're right and my wording was wrong on one point: the server doesn't keep one. Reran it just now — the body ships two parts both named k1, and werkzeug's MultiPartParser returns getlist("k1") == ["a", "b"] with nothing coalesced, which is your point.

The value is lost one line later, at dict-style access: form["k1"] == "a". Order decides the winner, not the parser — swap the two dict entries and form["k1"] becomes "b" instead. So the assertion belongs on the parsed mapping, per key, with a count:

p = requests.Request("POST", "https://example.invalid/", data={b"tenant_id": "1", "tenant_id": "2"}, files={"f": ("a.txt", b"x")}).prepare()
form, _ = MultiPartParser().parse(BytesIO(p.body), p.headers["Content-Type"].split("boundary=")[1].encode(), len(p.body))
vals = form.getlist("tenant_id")
assert len(vals) == 1, f"duplicate tenant_id after normalization: {vals}"
Enter fullscreen mode Exit fullscreen mode

Measured on requests 2.34.2 / urllib3 2.8.0 / werkzeug 3.2.0.dev0, both dict orders, file attached and not attached.

Unmeasured: whether starlette or DRF coalesce or reorder before your handler sees the object — I only ran werkzeug's parser, so I wouldn't ship that half as a test I hadn't run.