From f44b49b33dfaf2c93628c2aa4498d1f2b87f42c0 Mon Sep 17 00:00:00 2001 From: William FH <13333726+hinthornw@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:13:03 -0700 Subject: [PATCH] chore: dedup warnings (#7257) Co-authored-by: Will Fu-Hinthorn --- .../langgraph/checkpoint/serde/jsonplus.py | 25 +++++++++++++++++-- libs/checkpoint/tests/test_encrypted.py | 9 +++++++ libs/checkpoint/tests/test_jsonplus.py | 18 +++++++++++-- libs/checkpoint/tests/test_memory.py | 14 ++++++++++- 4 files changed, 61 insertions(+), 5 deletions(-) diff --git a/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py b/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py index 4ee654df8..c4adcb484 100644 --- a/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py +++ b/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py @@ -46,6 +46,23 @@ LC_REVIVER = Reviver() EMPTY_BYTES = b"" logger = logging.getLogger(__name__) +# Dedup log warnings across process lifetime; cap bounds state if types are +# dynamically generated (also acts as a circuit breaker on warning volume). +# Dedup is best-effort: racing threads may each emit once for the same key, +# and warnings are silently dropped once _MAX_WARNED_TYPES is reached. +_MAX_WARNED_TYPES = 1000 +_warned_unregistered_types: set[tuple[str, str]] = set() +_warned_blocked_types: set[tuple[str, str]] = set() + + +def _warn_once( + seen: set[tuple[str, str]], key: tuple[str, str], msg: str, *args: object +) -> None: + if key in seen or len(seen) >= _MAX_WARNED_TYPES: + return + seen.add(key) + logger.warning(msg, *args) + class JsonPlusSerializer(SerializerProtocol): """Serializer that uses ormsgpack, with optional fallbacks. @@ -534,7 +551,9 @@ def _create_msgpack_ext_hook( "name": name, } ) - logger.warning( + _warn_once( + _warned_unregistered_types, + key, "Deserializing unregistered type %s.%s from checkpoint. " "This will be blocked in a future version. " "Set LANGGRAPH_STRICT_MSGPACK=true to block now, or add " @@ -556,7 +575,9 @@ def _create_msgpack_ext_hook( "name": name, } ) - logger.warning( + _warn_once( + _warned_blocked_types, + key, "Blocked deserialization of %s.%s - not in allowed_msgpack_modules. " "Add to allowed_msgpack_modules to allow: [(%r, %r)]", module, diff --git a/libs/checkpoint/tests/test_encrypted.py b/libs/checkpoint/tests/test_encrypted.py index 696b93d32..f1fc14e94 100644 --- a/libs/checkpoint/tests/test_encrypted.py +++ b/libs/checkpoint/tests/test_encrypted.py @@ -29,6 +29,8 @@ from langgraph.checkpoint.serde.jsonplus import ( EXT_METHOD_SINGLE_ARG, JsonPlusSerializer, _msgpack_enc, + _warned_blocked_types, + _warned_unregistered_types, ) @@ -102,6 +104,13 @@ def test_msgpack_method_pathlib_blocked_encrypted_strict( class TestEncryptedSerializerMsgpackAllowlist: """Test msgpack allowlist behavior through EncryptedSerializer.""" + @pytest.fixture(autouse=True) + def _reset_warned_types(self) -> None: + # Warning dedup state is process-global; reset per-test so each case + # sees a fresh slate and assertions about warning emission are stable. + _warned_unregistered_types.clear() + _warned_blocked_types.clear() + def test_safe_types_no_warning(self, caplog: pytest.LogCaptureFixture) -> None: """Test safe types deserialize without warnings through encryption.""" serde = _make_encrypted_serde() diff --git a/libs/checkpoint/tests/test_jsonplus.py b/libs/checkpoint/tests/test_jsonplus.py index 9242c59bf..37200b964 100644 --- a/libs/checkpoint/tests/test_jsonplus.py +++ b/libs/checkpoint/tests/test_jsonplus.py @@ -35,6 +35,8 @@ from langgraph.checkpoint.serde.jsonplus import ( JsonPlusSerializer, _msgpack_enc, _msgpack_ext_hook_to_json, + _warned_blocked_types, + _warned_unregistered_types, ) from langgraph.store.base import Item @@ -580,6 +582,14 @@ def test_msgpack_safe_types_no_warning(caplog: pytest.LogCaptureFixture) -> None assert result is not None +@pytest.fixture(autouse=True) +def _reset_warned_types() -> None: + # Warning dedup state is process-global; reset per-test so each case sees + # a fresh slate and assertions about warning emission are stable. + _warned_unregistered_types.clear() + _warned_blocked_types.clear() + + def test_msgpack_pydantic_warns_by_default(caplog: pytest.LogCaptureFixture) -> None: """Pydantic models not in allowlist should log warning but still deserialize.""" current = _lg_msgpack.STRICT_MSGPACK_ENABLED @@ -595,6 +605,12 @@ def test_msgpack_pydantic_warns_by_default(caplog: pytest.LogCaptureFixture) -> assert "unregistered type" in caplog.text.lower() assert "allowed_msgpack_modules" in caplog.text assert result == obj + + # Second deserialization of the same type should NOT produce another warning + caplog.clear() + result2 = serde.loads_typed(dumped) + assert "unregistered type" not in caplog.text.lower() + assert result2 == obj _lg_msgpack.STRICT_MSGPACK_ENABLED = current @@ -639,7 +655,6 @@ def test_msgpack_allowlist_silences_warning(caplog: pytest.LogCaptureFixture) -> def test_msgpack_none_blocks_unregistered(caplog: pytest.LogCaptureFixture) -> None: """allowed_msgpack_modules=None should block unregistered types.""" - serde = JsonPlusSerializer(allowed_msgpack_modules=None) obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world")) @@ -657,7 +672,6 @@ def test_msgpack_allowlist_blocks_non_listed( caplog: pytest.LogCaptureFixture, ) -> None: """Allowlists should block unregistered types even if msgpack is enabled.""" - serde = JsonPlusSerializer( allowed_msgpack_modules=[("tests.test_jsonplus", "MyPydantic")] ) diff --git a/libs/checkpoint/tests/test_memory.py b/libs/checkpoint/tests/test_memory.py index a68a23d90..4198f600c 100644 --- a/libs/checkpoint/tests/test_memory.py +++ b/libs/checkpoint/tests/test_memory.py @@ -12,13 +12,25 @@ from langgraph.checkpoint.base import ( empty_checkpoint, ) from langgraph.checkpoint.memory import InMemorySaver -from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer +from langgraph.checkpoint.serde.jsonplus import ( + JsonPlusSerializer, + _warned_blocked_types, + _warned_unregistered_types, +) class MemoryPydantic(BaseModel): foo: str +@pytest.fixture(autouse=True) +def _reset_warned_types() -> None: + # Warning dedup state is process-global; reset per-test so each case sees + # a fresh slate and assertions about warning emission are stable. + _warned_unregistered_types.clear() + _warned_blocked_types.clear() + + class TestMemorySaver: @pytest.fixture(autouse=True) def setup(self) -> None: