From 80fed5f228f5daa1efea346d4194f3b5424d7528 Mon Sep 17 00:00:00 2001 From: Thomas Howe Date: Thu, 16 Jul 2026 14:11:00 +0200 Subject: [PATCH] Guard tag link against missing vCon (CON-617) VconRedis.get_vcon returns None on a Redis/storage miss (documented "halt the chain" contract). The tag link called .add_tag on the result without a None check, so any evicted/expired vCon crashed with 'NoneType' object has no attribute 'add_tag' and was dropped to the DLQ. ~376k such errors on BDS 2026-06-07..09 during a chain-latency incident. Halt the chain on None, matching the existing tag_router link. Adds a regression test mocking get_vcon -> None. Co-Authored-By: Claude Opus 4.8 --- conserver/links/tag/__init__.py | 6 ++++++ conserver/links/tag/test_tag.py | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/conserver/links/tag/__init__.py b/conserver/links/tag/__init__.py index 824cc6e..edce5e8 100644 --- a/conserver/links/tag/__init__.py +++ b/conserver/links/tag/__init__.py @@ -16,6 +16,12 @@ def run( vcon_redis = VconRedis() vCon = vcon_redis.get_vcon(vcon_uuid) + if vCon is None: + # get_vcon returns None when the vCon is missing from Redis and every + # storage backend (evicted/expired under chain latency). None is the + # documented "halt the chain" contract, so stop rather than crash. + logger.warning(f"tag: vCon {vcon_uuid} not found, halting chain") + return None for tag in opts.get("tags", []): vCon.add_tag(tag_name=tag, tag_value=tag) vcon_redis.store_vcon(vCon) diff --git a/conserver/links/tag/test_tag.py b/conserver/links/tag/test_tag.py index ffff22f..4eb6cee 100644 --- a/conserver/links/tag/test_tag.py +++ b/conserver/links/tag/test_tag.py @@ -33,6 +33,19 @@ def test_run_respects_custom_tags(mock_vcon_redis): mock_instance.store_vcon.assert_called_once_with(vcon) +@patch("links.tag.VconRedis") +def test_run_halts_chain_when_vcon_missing(mock_vcon_redis): + # Regression for CON-617: get_vcon returns None on a Redis/storage miss. + # The link must halt the chain, not crash with 'NoneType' has no add_tag. + mock_instance = mock_vcon_redis.return_value + mock_instance.get_vcon.return_value = None + + result = run("missing-uuid", "tag") + + assert result is None + mock_instance.store_vcon.assert_not_called() + + @patch("links.tag.VconRedis") def test_run_handles_empty_tag_list(mock_vcon_redis): vcon = Vcon.build_new()