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()