From 300adc181377f585df2ccfa653ef2b233871ca66 Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Mon, 31 Aug 2026 15:56:47 -0500 Subject: [PATCH 1/2] Demonstrate a `RecursionError` when preparing POST data --- .../test_representation_providers.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 tests/unit/transport/test_representation_providers.py diff --git a/tests/unit/transport/test_representation_providers.py b/tests/unit/transport/test_representation_providers.py new file mode 100644 index 000000000..7245b2975 --- /dev/null +++ b/tests/unit/transport/test_representation_providers.py @@ -0,0 +1,32 @@ +from globus_sdk import MISSING +from globus_sdk.transport.representation_providers import RequestsRepresentationProvider + + +def test_deeply_nested_body(): + """Verify that `RecursionError` doesn't manifest with a deeply-nested POST body.""" + + deepest = { + "value": MISSING, + "list": [MISSING], + "dict": {"missing": MISSING}, + } + + deep = deepest + for _ in range(1_000): + deep = { + "deep": [deep], + "missing": MISSING, + } + pass + + provider = RequestsRepresentationProvider() + result = provider._prepare_data(deep) + + # * The original `deepest` variable must not have been modified. + assert "value" in deepest + assert deepest["list"] == [MISSING] + assert deepest["dict"] == {"missing": MISSING} + + # * "missing" must not appear in the result value. + assert "missing" not in result + assert "missing" not in result["deep"][0]["deep"][0]["deep"][0] From 315384925bc295c381e36c08b887fc2353531736 Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Mon, 31 Aug 2026 16:00:08 -0500 Subject: [PATCH 2/2] Fix a `RecursionError` caused by deeply-nested request bodies --- ..._155548_kurtmckee_fix_recursion_errors.rst | 4 ++ .../transport/representation_providers.py | 67 ++++++++++++++++--- 2 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 changelog.d/20260831_155548_kurtmckee_fix_recursion_errors.rst diff --git a/changelog.d/20260831_155548_kurtmckee_fix_recursion_errors.rst b/changelog.d/20260831_155548_kurtmckee_fix_recursion_errors.rst new file mode 100644 index 000000000..b99915548 --- /dev/null +++ b/changelog.d/20260831_155548_kurtmckee_fix_recursion_errors.rst @@ -0,0 +1,4 @@ +Fixed +----- + +- Fix a ``RecursionError`` that occurs with deeply-nested request bodies. (:pr:`NUMBER`) diff --git a/src/globus_sdk/transport/representation_providers.py b/src/globus_sdk/transport/representation_providers.py index 074f1c28c..a69d2a208 100644 --- a/src/globus_sdk/transport/representation_providers.py +++ b/src/globus_sdk/transport/representation_providers.py @@ -3,6 +3,7 @@ import enum import typing as t import uuid +from collections import deque from globus_sdk._internal import orjson_compat from globus_sdk._missing import MISSING, filter_missing @@ -106,18 +107,66 @@ def _prepare_data(self, data: t.Any) -> t.Any: """ Prepare the data (body) for a request. - If the body is a dict, list, or tuple, it will be recursively processed to - filter out MISSING and format primitives. - - Otherwise, it is returned as-is. + MISSING values are filtered out of dicts, lists, and tuples. + Scalar elements, like UUIDs and enums, and converted to strings. """ - if isinstance(data, dict): - return filter_missing({k: self._prepare_data(v) for k, v in data.items()}) - elif isinstance(data, (list, tuple)): - return [self._prepare_data(x) for x in data if x is not MISSING] - else: + + if not isinstance(data, (dict, list, tuple)): return self._format_primitive(data) + # Strategy: Rely on object mutability to iteratively prepare data + # after it has already been appended to a list / assigned in a dict. + data = self._create_mutable_copy(data) + unprocessed_data: deque[dict[str, t.Any] | list[t.Any]] = deque((data,)) + + while unprocessed_data: + current_data = unprocessed_data.popleft() + + if isinstance(current_data, dict): + prepared_dict: dict[str, t.Any] = { + k: self._prepare_element(v, unprocessed_data) + for k, v in current_data.items() + if v is not MISSING + } + current_data.clear() + current_data.update(prepared_dict) + + else: # isinstance(current_data, list) + prepared_list: list[t.Any] = [ + self._prepare_element(v, unprocessed_data) + for v in current_data + if v is not MISSING + ] + current_data.clear() + current_data.extend(prepared_list) + + return data + + def _prepare_element( + self, element: t.Any, unprocessed_data: deque[dict[str, t.Any] | list[t.Any]] + ) -> t.Any: + if not isinstance(element, (dict, list, tuple)): + return self._format_primitive(element) + element = self._create_mutable_copy(element) + unprocessed_data.append(element) + return element + + @staticmethod + def _create_mutable_copy( + data: dict[str, t.Any] | list[t.Any] | tuple[t.Any, ...], + ) -> dict[str, t.Any] | list[t.Any]: + """ + Make a mutable copy of a non-scalar value. + + Tuples will be converted to lists. + """ + + if isinstance(data, tuple): + return list(data) + if isinstance(data, list): + return data.copy() + return data.copy() + class RequestsPlainTextProvider(RequestsRepresentationProvider): """The plain-text provider ensures that the body is text."""