fix(safe_json_dumps): snapshot dict items before iterating

safe_dumps runs on request_data and logging_obj.model_call_details, which
async guardrail/logging hooks can mutate while serialization is in flight.
_serialize iterated obj.items() over the live dict, so a concurrent insert
raised "RuntimeError: dictionary changed size during iteration" and killed
the request.

Wrap items() in list() to take a snapshot before iterating. No behavior
change for non-concurrent callers.
This commit is contained in:
Brendan Smith-Elion 2026-06-04 07:54:36 -04:00
parent d45e9e4d56
commit 6abc171f9a
2 changed files with 26 additions and 1 deletions

View file

@ -34,7 +34,9 @@ def safe_dumps(data: Any, max_depth: int = DEFAULT_MAX_RECURSE_DEPTH) -> str:
result: Union[dict, list, tuple, set, str]
if isinstance(obj, dict):
result = {}
for k, v in obj.items():
# Snapshot items() so a concurrent async hook mutating the same
# dict can't raise "dictionary changed size during iteration".
for k, v in list(obj.items()):
if isinstance(k, (str)):
result[strip_null_bytes(k)] = _serialize(v, seen, depth + 1)
seen.remove(id(obj))

View file

@ -206,3 +206,26 @@ def test_pydantic_base_model():
assert len(result["healthy_endpoints"]) == 2
assert result["healthy_endpoints"][0]["name"] == "test"
assert result["healthy_endpoints"][1] == {"value": 1, "label": "one"}
def test_dict_mutated_during_serialization_does_not_raise():
# Regression: safe_dumps runs on request_data / model_call_details, which a
# concurrent async hook can mutate while we iterate. Serializing one value
# here inserts a new key into the same dict (standing in for that hook). On
# the pre-fix code this raised "RuntimeError: dictionary changed size during
# iteration"; the list() snapshot must keep iteration stable.
class _MutateOnStr:
def __init__(self, target):
self._target = target
def __str__(self):
self._target["injected_by_concurrent_hook"] = "value"
return "mutated"
data = {}
data["trigger"] = _MutateOnStr(data)
data["after"] = 1
result = json.loads(safe_dumps(data))
assert result["trigger"] == "mutated"
assert result["after"] == 1