Skip to content

Commit 75fac08

Browse files
zeevdrclaude
andcommitted
fix(watcher): graceful type-flip degradation + regression tests
_apply_raw now catches TypeMismatchError, logs a warning, and falls back to the field's default value (matching Go SDK behavior). Previously a type mismatch from the server would propagate uncaught and crash the background subscribe thread/task. Adds regression tests for both sync (WatchedField, ConfigWatcher) and async (AsyncWatchedField, AsyncConfigWatcher) paths: _load_initial type-flip falls back to default; _update type-flip falls back to default; _process_change type-flip continues the stream. Refs #563 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 92e944b commit 75fac08

3 files changed

Lines changed: 117 additions & 2 deletions

File tree

sdk/src/opendecree/_watcher_base.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Generic, TypeVar
88

99
from opendecree._convert import convert_value
10+
from opendecree.errors import TypeMismatchError
1011

1112
T = TypeVar("T")
1213

@@ -57,8 +58,18 @@ def _apply_raw(self, raw_value: str | None) -> tuple[T, T]:
5758
"""Set _value/_is_set from a raw string. Returns (old, new). Caller must lock if needed."""
5859
old = self._value
5960
if raw_value is not None:
60-
self._value = convert_value(raw_value, self._type) # type: ignore[assignment]
61-
self._is_set = True
61+
try:
62+
self._value = convert_value(raw_value, self._type) # type: ignore[assignment]
63+
self._is_set = True
64+
except TypeMismatchError:
65+
_logger.warning(
66+
"Type mismatch for field %r: cannot convert %r to %s, using default",
67+
self._path,
68+
raw_value,
69+
self._type.__name__,
70+
)
71+
self._value = self._default
72+
self._is_set = False
6273
else:
6374
self._value = self._default
6475
self._is_set = False

sdk/tests/test_async_watcher.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,26 @@ def test_repr(self):
9191
f = AsyncWatchedField("payments.fee", float, 0.01)
9292
assert "payments.fee" in repr(f)
9393

94+
def test_load_initial_type_flip_falls_back_to_default(self):
95+
f = AsyncWatchedField("payments.fee", float, 0.01)
96+
f._load_initial("not-a-number")
97+
assert f.value == pytest.approx(0.01)
98+
assert not f._is_set
99+
100+
def test_update_type_flip_falls_back_to_default(self):
101+
f = AsyncWatchedField("payments.fee", float, 0.01)
102+
f._load_initial("0.025")
103+
assert f.value == pytest.approx(0.025)
104+
105+
change = Change(
106+
field_path="payments.fee", old_value="0.025", new_value="not-a-number", version=2
107+
)
108+
f._update("not-a-number", change)
109+
110+
# Falls back to default on type mismatch, does not crash.
111+
assert f.value == pytest.approx(0.01)
112+
assert not f._is_set
113+
94114
def test_callback_exception_is_logged(self):
95115
f = AsyncWatchedField("x", int, 0)
96116
f._load_initial("1")
@@ -348,6 +368,36 @@ def test_process_change(self):
348368
w._process_change(change)
349369
assert fee.value == 2.0
350370

371+
def test_type_flip_mid_stream_uses_default_and_continues(self):
372+
"""Type-flip mid-stream falls back to default and keeps the stream alive."""
373+
from opendecree._generated.centralconfig.v1 import types_pb2
374+
375+
w = self._make_watcher()
376+
fee = w.field("fee", float, default=0.01)
377+
fee._load_initial("0.025")
378+
379+
bad_change = MagicMock()
380+
bad_change.field_path = "fee"
381+
bad_change.HasField.side_effect = lambda name: name in ("old_value", "new_value")
382+
bad_change.old_value = types_pb2.TypedValue(string_value="0.025")
383+
bad_change.new_value = types_pb2.TypedValue(string_value="not-a-number")
384+
bad_change.version = 2
385+
bad_change.changed_by = ""
386+
387+
w._process_change(bad_change)
388+
assert fee.value == pytest.approx(0.01)
389+
390+
good_change = MagicMock()
391+
good_change.field_path = "fee"
392+
good_change.HasField.side_effect = lambda name: name in ("old_value", "new_value")
393+
good_change.old_value = types_pb2.TypedValue(string_value="not-a-number")
394+
good_change.new_value = types_pb2.TypedValue(string_value="0.1")
395+
good_change.version = 3
396+
good_change.changed_by = ""
397+
398+
w._process_change(good_change)
399+
assert fee.value == pytest.approx(0.1)
400+
351401
def test_process_change_unknown_field_ignored(self):
352402
w = self._make_watcher()
353403
w.field("known", str, default="")

sdk/tests/test_watcher.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,28 @@ def test_repr(self):
107107
assert "payments.fee" in repr(f)
108108
assert "0.01" in repr(f)
109109

110+
def test_load_initial_type_flip_falls_back_to_default(self):
111+
f = WatchedField("payments.fee", float, 0.01)
112+
f._load_initial("not-a-number")
113+
assert f.value == pytest.approx(0.01)
114+
assert not f._is_set
115+
116+
def test_update_type_flip_falls_back_to_default(self):
117+
f = WatchedField("payments.fee", float, 0.01)
118+
f._load_initial("0.025")
119+
assert f.value == pytest.approx(0.025)
120+
121+
from opendecree.types import Change
122+
123+
change = Change(
124+
field_path="payments.fee", old_value="0.025", new_value="not-a-number", version=2
125+
)
126+
f._update("not-a-number", change)
127+
128+
# Falls back to default on type mismatch, does not crash.
129+
assert f.value == pytest.approx(0.01)
130+
assert not f._is_set
131+
110132
def test_callback_exception_is_logged(self):
111133
f = WatchedField("x", int, 0)
112134
f._load_initial("1")
@@ -373,6 +395,38 @@ def test_process_change(self):
373395
w._process_change(change)
374396
assert fee.value == 2.0
375397

398+
def test_type_flip_mid_stream_uses_default_and_continues(self):
399+
"""Type-flip mid-stream falls back to default and keeps the stream alive."""
400+
from opendecree._generated.centralconfig.v1 import types_pb2
401+
402+
w = self._make_watcher()
403+
fee = w.field("fee", float, default=0.01)
404+
fee._load_initial("0.025")
405+
406+
# Type-flip: string value for a float field.
407+
bad_change = MagicMock()
408+
bad_change.field_path = "fee"
409+
bad_change.HasField.side_effect = lambda name: name in ("old_value", "new_value")
410+
bad_change.old_value = types_pb2.TypedValue(string_value="0.025")
411+
bad_change.new_value = types_pb2.TypedValue(string_value="not-a-number")
412+
bad_change.version = 2
413+
bad_change.changed_by = ""
414+
415+
w._process_change(bad_change)
416+
assert fee.value == pytest.approx(0.01)
417+
418+
# Subsequent valid change must still apply.
419+
good_change = MagicMock()
420+
good_change.field_path = "fee"
421+
good_change.HasField.side_effect = lambda name: name in ("old_value", "new_value")
422+
good_change.old_value = types_pb2.TypedValue(string_value="not-a-number")
423+
good_change.new_value = types_pb2.TypedValue(string_value="0.1")
424+
good_change.version = 3
425+
good_change.changed_by = ""
426+
427+
w._process_change(good_change)
428+
assert fee.value == pytest.approx(0.1)
429+
376430
def test_process_change_unknown_field_ignored(self):
377431
w = self._make_watcher()
378432
w.field("known", str, default="")

0 commit comments

Comments
 (0)