From f514023e9952f183c59ecc5fa9af70691702090c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julio=20C=C3=A9sar=20Su=C3=A1stegui?= Date: Mon, 30 Mar 2026 03:02:25 -0600 Subject: [PATCH 1/5] fix: remove content-type from headers in _add_from_file to avoid RuntimeError The recorder stores content_type as a top-level YAML key but may also capture a 'content-type' entry inside the headers dict (since HTTP servers return Content-Type as a response header). When _add_from_file() calls add() with both content_type= and headers={'content-type': ...}, add() raises: RuntimeError: You cannot define both content_type and headers[Content-Type]. Fix: strip any 'content-type' key from headers before calling add() when content_type is also present in the loaded response dict. The content_type kwarg takes precedence, which is already documented as the recommended approach. Fixes #741 --- responses/__init__.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/responses/__init__.py b/responses/__init__.py index d719476d..4380ad7c 100644 --- a/responses/__init__.py +++ b/responses/__init__.py @@ -856,12 +856,25 @@ def _add_from_file(self, file_path: "Union[str, bytes, os.PathLike[Any]]") -> No for rsp in data["responses"]: rsp = rsp["response"] + headers = rsp["headers"] if "headers" in rsp else None + + # The recorder stores ``content_type`` as a top-level key AND may + # also capture a ``content-type`` / ``Content-Type`` entry inside + # ``headers``. Passing both to ``add()`` raises a RuntimeError. + # Resolve the conflict by removing the header entry so the + # dedicated ``content_type`` kwarg takes precedence, which is the + # behaviour recommended by the library. + if headers is not None and "content_type" in rsp: + headers = {k: v for k, v in headers.items() if k.lower() != "content-type"} + if not headers: + headers = None + self.add( method=rsp["method"], url=rsp["url"], body=rsp["body"], status=rsp["status"], - headers=rsp["headers"] if "headers" in rsp else None, + headers=headers, content_type=rsp["content_type"], auto_calculate_content_length=rsp["auto_calculate_content_length"], ) From 806fb9c832a11c08b722be23dba5462854a7632d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julio=20C=C3=A9sar=20Su=C3=A1stegui?= Date: Tue, 31 Mar 2026 20:34:39 -0600 Subject: [PATCH 2/5] test: add regression test for Content-Type conflict in _add_from_file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the case where a YAML fixture has Content-Type in both headers and content_type fields — the recorder captures both, causing RuntimeError when _add_from_file calls add(). Added test_add_from_file_content_type_in_headers to verify the fix works end-to-end with a YAML fixture. --- responses/__init__.py | 6 ---- responses/tests/test_recorder.py | 60 ++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/responses/__init__.py b/responses/__init__.py index 4380ad7c..13b77333 100644 --- a/responses/__init__.py +++ b/responses/__init__.py @@ -858,12 +858,6 @@ def _add_from_file(self, file_path: "Union[str, bytes, os.PathLike[Any]]") -> No rsp = rsp["response"] headers = rsp["headers"] if "headers" in rsp else None - # The recorder stores ``content_type`` as a top-level key AND may - # also capture a ``content-type`` / ``Content-Type`` entry inside - # ``headers``. Passing both to ``add()`` raises a RuntimeError. - # Resolve the conflict by removing the header entry so the - # dedicated ``content_type`` kwarg takes precedence, which is the - # behaviour recommended by the library. if headers is not None and "content_type" in rsp: headers = {k: v for k, v in headers.items() if k.lower() != "content-type"} if not headers: diff --git a/responses/tests/test_recorder.py b/responses/tests/test_recorder.py index fece12fc..38b16c0e 100644 --- a/responses/tests/test_recorder.py +++ b/responses/tests/test_recorder.py @@ -238,3 +238,63 @@ def _parse_resp_f(file_path): assert responses.registered()[3].content_type == "text/plain" run() + + def test_add_from_file_content_type_in_headers(self): + """Fixture files may contain Content-Type in both headers and content_type. + + The recorder captures ``Content-Type`` inside the ``headers`` dict *and* + as the dedicated ``content_type`` field. Passing both to ``add()`` + raises a ``RuntimeError`` because ``content_type`` and a ``Content-Type`` + header conflict. ``_add_from_file`` should strip the duplicate header + entry so that the dedicated ``content_type`` kwarg wins. + """ + data = { + "responses": [ + { + "response": { + "method": "GET", + "url": "http://example.com/api", + "body": '{"status": "ok"}', + "status": 200, + "headers": {"Content-Type": "application/json"}, + "content_type": "application/json", + "auto_calculate_content_length": False, + } + }, + { + "response": { + "method": "POST", + "url": "http://example.com/submit", + "body": "created", + "status": 201, + "headers": { + "Content-Type": "text/plain", + "X-Request-Id": "abc123", + }, + "content_type": "text/plain", + "auto_calculate_content_length": False, + } + }, + ] + } + + with open(self.out_file, "w") as f: + yaml.dump(data, f) + + @responses.activate + def run(): + responses._add_from_file(file_path=self.out_file) + + # Verify responses were registered without RuntimeError + assert len(responses.registered()) == 2 + + assert responses.registered()[0].url == "http://example.com/api" + assert responses.registered()[0].content_type == "application/json" + + assert responses.registered()[1].url == "http://example.com/submit" + assert responses.registered()[1].content_type == "text/plain" + # Non-content-type headers should be preserved + resp = requests.post("http://example.com/submit") + assert resp.headers["X-Request-Id"] == "abc123" + + run() From 9da6a2b9150485297a50a53a6416658f68a66eca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julio=20C=C3=A9sar=20Su=C3=A1stegui?= Date: Wed, 1 Apr 2026 20:34:40 -0600 Subject: [PATCH 3/5] test: use mismatched Content-Type values to verify content_type precedence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per markstory's review: using different values for headers['Content-Type'] ('text/html') and content_type ('application/json'/'text/plain') makes the assertion non-trivial — it now explicitly verifies that content_type wins over the conflicting header, not just that both happen to carry the same value. --- responses/tests/test_recorder.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/responses/tests/test_recorder.py b/responses/tests/test_recorder.py index 38b16c0e..5327756c 100644 --- a/responses/tests/test_recorder.py +++ b/responses/tests/test_recorder.py @@ -247,6 +247,10 @@ def test_add_from_file_content_type_in_headers(self): raises a ``RuntimeError`` because ``content_type`` and a ``Content-Type`` header conflict. ``_add_from_file`` should strip the duplicate header entry so that the dedicated ``content_type`` kwarg wins. + + Using mismatched values (``text/html`` in headers vs ``application/json`` + in ``content_type``) ensures the assertion is non-trivial and confirms + that ``content_type`` takes precedence over the header value. """ data = { "responses": [ @@ -256,7 +260,10 @@ def test_add_from_file_content_type_in_headers(self): "url": "http://example.com/api", "body": '{"status": "ok"}', "status": 200, - "headers": {"Content-Type": "application/json"}, + # headers has a *different* Content-Type than content_type + # to verify that content_type wins (not just that both happen + # to be the same value). + "headers": {"Content-Type": "text/html"}, "content_type": "application/json", "auto_calculate_content_length": False, } @@ -268,7 +275,7 @@ def test_add_from_file_content_type_in_headers(self): "body": "created", "status": 201, "headers": { - "Content-Type": "text/plain", + "Content-Type": "text/html", "X-Request-Id": "abc123", }, "content_type": "text/plain", @@ -288,6 +295,7 @@ def run(): # Verify responses were registered without RuntimeError assert len(responses.registered()) == 2 + # content_type must win over the conflicting Content-Type header assert responses.registered()[0].url == "http://example.com/api" assert responses.registered()[0].content_type == "application/json" From a9ecf8cfef616bb7189064afcb9cf337e0c47370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julio=20C=C3=A9sar=20Su=C3=A1stegui?= Date: Thu, 2 Apr 2026 15:04:49 -0600 Subject: [PATCH 4/5] fix(test): use .yaml extension so _add_from_file selects YAML loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As suggested by @markstory — the fixture file needs an explicit extension so that _add_from_file can detect the correct loader. Changed out_file to use a .yaml suffix for the content_type_in_headers test. --- responses/tests/test_recorder.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/responses/tests/test_recorder.py b/responses/tests/test_recorder.py index 5327756c..824588c8 100644 --- a/responses/tests/test_recorder.py +++ b/responses/tests/test_recorder.py @@ -251,6 +251,9 @@ def test_add_from_file_content_type_in_headers(self): Using mismatched values (``text/html`` in headers vs ``application/json`` in ``content_type``) ensures the assertion is non-trivial and confirms that ``content_type`` takes precedence over the header value. + + The fixture is saved as a ``.yaml`` file so that ``_add_from_file`` + selects the YAML loader by extension. """ data = { "responses": [ @@ -285,12 +288,13 @@ def test_add_from_file_content_type_in_headers(self): ] } - with open(self.out_file, "w") as f: + yaml_file = Path(str(self.out_file) + ".yaml") + with open(yaml_file, "w") as f: yaml.dump(data, f) @responses.activate def run(): - responses._add_from_file(file_path=self.out_file) + responses._add_from_file(file_path=yaml_file) # Verify responses were registered without RuntimeError assert len(responses.registered()) == 2 From da3edf3c6aedc33bee7854438e39e0f81f9c060b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julio=20C=C3=A9sar=20Su=C3=A1stegui?= Date: Thu, 2 Apr 2026 17:45:34 -0600 Subject: [PATCH 5/5] fix(test): clean up .yaml/.toml fixture variants in teardown_method The content_type test creates response_record.yaml but teardown_method only deleted response_record (no extension). Add cleanup for .yaml and .toml variants to avoid stale artifacts across test runs. --- responses/tests/test_recorder.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/responses/tests/test_recorder.py b/responses/tests/test_recorder.py index 824588c8..caa39e5e 100644 --- a/responses/tests/test_recorder.py +++ b/responses/tests/test_recorder.py @@ -190,6 +190,12 @@ def teardown_method(self): if self.out_file.exists(): self.out_file.unlink() + # Clean up any extension variants created by individual tests + for suffix in (".yaml", ".toml"): + p = Path(str(self.out_file) + suffix) + if p.exists(): + p.unlink() + assert not self.out_file.exists() @pytest.mark.parametrize("parser", (yaml, tomli_w))