Skip to content

fix: __get_headers mutates caller's config dict on multipart requests#83

Open
SiddarthAA wants to merge 1 commit intoJigsawStack:mainfrom
SiddarthAA:fix/headers-config-mutation
Open

fix: __get_headers mutates caller's config dict on multipart requests#83
SiddarthAA wants to merge 1 commit intoJigsawStack:mainfrom
SiddarthAA:fix/headers-config-mutation

Conversation

@SiddarthAA
Copy link
Copy Markdown

@SiddarthAA SiddarthAA commented Apr 24, 2026

Problem

So every service class (Audio, Translate, Vision, etc.) creates a single RequestConfig / AsyncRequestConfig in its __init__ and reuses it for every method call on that instance.

The bug was in how Request.__init__ (and the async version) was setting headers:

self.headers = config.get("headers", None) or {"Content-Type": "application/json"}

This isn't copying anything — it's just storing a reference to the same dict object that the service class holds onto. So when __get_headers() does:

self.headers.pop("Content-Type")

...during a multipart upload, it's actually wiping Content-Type out of the shared config for good. Every call after that — JSON POSTs, GETs, whatever — goes out without Content-Type: application/json and you start getting silent 400/422s from the server.

The really annoying part is it only breaks on the second call, so it's easy to miss in basic tests and a pain to track down.

Same issue in both request.py and async_request.py.

Fix

I went with a two-level defence in both files so there's no way either the config or self.headers gets touched:

  1. __init__ — own the dict

    raw_headers = config.get("headers", None) or {"Content-Type": "application/json"}
    self.headers: Dict[str, str] = dict(raw_headers)

    Now self.headers is its own copy — whatever happens to it later, the caller's config is safe.

  2. __get_headers() — throwaway per-call copy

    caller_headers = dict(self.headers)
    if self.files:
        caller_headers.pop("Content-Type", None)
    h.update(caller_headers)

    .pop() only touches a local copy made fresh each call, so self.headers stays intact.

Also cleaned up the leftover headers.pop("Content-Type", None) in make_request() in both files — __get_headers() already handles that, so it was just dead code sitting there.

Tests

Added test_headers_mutation.py — 12 tests, no API key or network needed:

Test Covers
test_multipart_does_not_mutate_config_headers Config dict is untouched after a multipart call
test_repeated_multipart_calls_do_not_degrade Calling __get_headers() twice returns identical results
test_multipart_output_omits_content_type Outgoing multipart headers correctly omit Content-Type so the HTTP library can set the boundary
test_json_request_includes_content_type Plain JSON requests still carry Content-Type: application/json
test_custom_headers_propagated Caller-supplied extra headers reach the outgoing dict
test_second_request_on_same_config_unaffected A second Request built from the same config after a multipart call has correct headers — the original regression scenario

All 6 run for both Request (sync) and AsyncRequest (async) — 12/12 pass.

Files changed

  • request.py
  • async_request.py
  • test_headers_mutation.py (new)

Contact

Thanks for reviewing — happy to fix something / reiterate if needed :D
siddartha_ay@protonmail.com | (everything about me at siddartha-ay.xyz )

Both Request and AsyncRequest stored a direct reference to the headers dict
from the caller's config TypedDict:

    self.headers = config.get("headers", None) or {...}

During multipart file-upload requests __get_headers() then called:

    self.headers.pop("Content-Type")

Because self.headers was the same object as config["headers"], this pop
permanently removed Content-Type from the shared config. Every subsequent
request reusing the same client config (which is what all service classes do)
would silently go out without Content-Type.

Fix:
- __init__ now copies the incoming headers with dict() so self.headers is
  always an owned instance, never an alias into the config.
- __get_headers() makes a second local copy (caller_headers) before calling
  .pop(), so neither self.headers nor the config are ever touched.

Adds tests/test_headers_mutation.py with 12 focused unit tests covering both
the sync and async clients. No API key or network access is required to run
them.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant