From 564d3930fdc02e27c1bf2804692e561a7d924ea6 Mon Sep 17 00:00:00 2001 From: thodson-usgs Date: Thu, 25 Jun 2026 09:01:09 -0500 Subject: [PATCH] fix(utils): query() must not mutate the caller's payload query() stringified each value in place (`payload[key] = to_str(...)`), so a caller reusing the same dict carried delimiter-joined values into its next call. Build a fresh `params` dict from the payload instead and pass that to the request; the caller's dict is left untouched. Adds a regression test asserting the payload is unchanged after the call. The branch's original 4xx/5xx-handling commits are dropped: that half is already superseded on main by the httpx migration (#289) and the typed error taxonomy (`_raise_for_status` / `error_for_status`, #313/#319), which raise on every >=400 status. Only the payload-mutation fix remained unmerged, re-authored here onto main's httpx `query`. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Sjb14HkwuCydKSKMsaXsgd --- dataretrieval/utils.py | 10 +++++----- tests/utils_test.py | 12 ++++++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/dataretrieval/utils.py b/dataretrieval/utils.py index 8d28ee86..a32512fa 100644 --- a/dataretrieval/utils.py +++ b/dataretrieval/utils.py @@ -419,7 +419,7 @@ def query( url: string URL to query payload: dict - query parameters passed to ``httpx.get`` + query parameters passed to ``httpx.get``. Not mutated. delimiter: string delimiter to use with lists ssl_check: bool @@ -442,18 +442,18 @@ def query( ``httpx`` exception on ``__cause__``. """ - for key, value in payload.items(): - payload[key] = to_str(value, delimiter) + # Build a fresh params dict; never mutate the caller's payload. + params = {key: to_str(value, delimiter) for key, value in payload.items()} # httpx serializes None params as ``foo=``; USGS rejects with 400. # Drop them. (``to_str`` returns None for non-iterable scalars like bools.) - payload = {k: v for k, v in payload.items() if v is not None} + params = {k: v for k, v in params.items() if v is not None} user_agent = {"user-agent": f"python-dataretrieval/{dataretrieval.__version__}"} try: response = _get( url, - params=payload, + params=params, headers=user_agent, verify=ssl_check, **HTTPX_DEFAULTS, diff --git a/tests/utils_test.py b/tests/utils_test.py index b3201419..ae32f569 100644 --- a/tests/utils_test.py +++ b/tests/utils_test.py @@ -1,5 +1,6 @@ """Unit tests for functions in utils.py""" +import copy from unittest import mock import pandas as pd @@ -33,6 +34,17 @@ def test_header(self, httpx_mock): assert response.status_code == 200 # GET was successful assert "user-agent" in response.request.headers + def test_does_not_mutate_caller_payload(self, httpx_mock): + """query() builds its own params dict and must not mutate the caller's + payload, which it previously stringified in place (so a reused dict + carried delimiter-joined values into the next call).""" + httpx_mock.add_response(method="GET", json={"value": {}}) + url = "https://waterservices.usgs.gov/nwis/dv" + payload = {"sites": ["01646500", "01646501"], "format": "json"} + original = copy.deepcopy(payload) + utils.query(url, payload) + assert payload == original + class Test_error_taxonomy: """The unified request-error hierarchy.