Skip to content

Commit 7f60324

Browse files
oasaphoasaph
andauthored
🔖 Bump version to 0.3.0 (#13)
* 🥅 fix(flag): improve error handling and logging in flag operations * 🔖 Bump version to 0.3.0 --------- Co-authored-by: oasaph <contato@asaph.dev>
1 parent ebbe40c commit 7f60324

7 files changed

Lines changed: 129 additions & 24 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file
237237
- [ ] **Advanced Rollout Strategies:** Support for percentage rollouts, user targeting, and A/B testing.
238238
- [ ] **Async Support:** Add async/await support for non-blocking flag fetching and updates.
239239
- [ ] **Type Annotations & Validation:** Improve type safety and validation for flag values and operations.
240-
- [ ] **Better Error Handling & Logging:** More granular error reporting and logging options.
240+
- [x] **Better Error Handling & Logging:** More granular error reporting and logging options.
241241
- [x] **Extensive Documentation & Examples:** Expand documentation with more real-world usage patterns and advanced scenarios.
242242

243243
Contributions and suggestions are welcome! Please open an issue or pull request if you have ideas for improvements.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "python_flaggle"
3-
version = "0.2.1"
3+
version = "0.3.0"
44
description = ""
55
authors = [
66
{name = "Asaph Diniz", email = "contato@asaph.dev.br"}

python_flaggle/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,6 @@
1313
from python_flaggle.flag import Flag, FlagOperation, FlagType
1414

1515
__all__ = ["FlagType", "FlagOperation", "Flag", "Flaggle"]
16-
__version__ = "0.2.1"
16+
__version__ = "0.3.0"
1717
__author__ = "Asaph Diniz"
1818
__email__ = "contato@asaph.dev.br"

python_flaggle/flag.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,13 +113,15 @@ def from_string(cls, operation: str) -> "FlagOperation":
113113
ValueError: If the operation name is invalid.
114114
"""
115115
operation = operation.upper()
116-
117116
try:
118117
return cls.__dict__[operation]
119118
except KeyError as exc:
120119
logger.error("Invalid Operation '%s'", operation)
121120
logger.debug(format_exc())
122121
raise ValueError(f"Invalid Operation '{operation}'") from exc
122+
except Exception as exc:
123+
logger.critical("Unexpected error in FlagOperation.from_string: %s", exc, exc_info=True)
124+
raise
123125

124126

125127
class Flag:
@@ -247,13 +249,14 @@ def from_json(cls: "Flag", data: dict) -> dict[str, "Flag"]:
247249
try:
248250
flags_data = data.get("flags")
249251
if flags_data is None or not isinstance(flags_data, list):
252+
logger.error("No flags in the provided JSON data: %r", data)
250253
raise ValueError("No flags in the provided JSON data")
251254

252255
result = {}
253256
for flag_data in flags_data:
254257
name = flag_data.get("name")
255258
if not name:
256-
logger.warning("Found flag without name, skipping")
259+
logger.warning("Found flag without name, skipping: %r", flag_data)
257260
continue
258261

259262
value = flag_data.get("value")
@@ -262,11 +265,19 @@ def from_json(cls: "Flag", data: dict) -> dict[str, "Flag"]:
262265
operation_str = flag_data.get("operation")
263266
operation = None
264267
if operation_str:
265-
operation = FlagOperation.from_string(operation_str)
268+
try:
269+
operation = FlagOperation.from_string(operation_str)
270+
except Exception as exc:
271+
logger.error("Invalid operation '%s' for flag '%s': %s", operation_str, name, exc, exc_info=True)
272+
raise ValueError("Invalid JSON data: invalid operation") from exc
266273

267274
result[name] = cls(name, value, description, operation)
268275

269276
return result
270277

271278
except (KeyError, AttributeError) as exc:
279+
logger.error("Invalid JSON data: %s", exc, exc_info=True)
280+
raise ValueError(f"Invalid JSON data: {exc}") from exc
281+
except Exception as exc:
282+
logger.critical("Unexpected error in Flag.from_json: %s", exc, exc_info=True)
272283
raise ValueError(f"Invalid JSON data: {exc}") from exc

python_flaggle/flaggle.py

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -150,44 +150,61 @@ def _fetch_flags(self) -> dict[str, list[dict[str, str]]]:
150150
logger.info("Fetching flags from %s", self._url)
151151
response = get(self._url, timeout=self._timeout, verify=self._verify_ssl)
152152
response.raise_for_status()
153-
154-
logger.info("Flags fetched successfully")
153+
logger.info("Flags fetched successfully from %s", self._url)
155154
logger.debug("Response content: %s", response.text)
156-
logger.warning("Response[%i]: %r", response.status_code, response.json())
157-
155+
logger.debug("Response[%i]: %r", response.status_code, response.json())
158156
return Flag.from_json(response.json())
159157
except RequestException as e:
160-
print(f"Error fetching flags: {e}")
158+
logger.error("Error fetching flags from %s: %s", self._url, e, exc_info=True)
159+
return {}
160+
except ValueError as e:
161+
logger.error("Invalid response format from %s: %s", self._url, e, exc_info=True)
161162
return {}
162-
except (KeyError, ValueError):
163-
logger.error("Invalid response format: 'flags' key not found")
163+
except Exception as e:
164+
logger.critical("Unexpected error during flag fetch: %s", e, exc_info=True)
164165
return {}
165166

166167
def _update(self) -> None:
167168
"""
168169
Update the internal flag dictionary by fetching the latest flags.
169170
"""
170-
flags_data = self._fetch_flags()
171-
if flags_data:
172-
self._flags = flags_data
173-
self._last_update = datetime.now(timezone.utc)
174-
logger.info("Flags updated successfully")
175-
logger.debug("Current flags: %s", self._flags)
171+
try:
172+
flags_data = self._fetch_flags()
173+
if flags_data:
174+
self._flags = flags_data
175+
self._last_update = datetime.now(timezone.utc)
176+
logger.info("Flags updated successfully at %s", self._last_update)
177+
logger.debug("Current flags: %s", self._flags)
178+
else:
179+
logger.warning("No flags data received; keeping previous flags.")
180+
except Exception as e:
181+
logger.critical("Unexpected error during flag update: %s", e, exc_info=True)
176182

177183
def _schedule_update(self) -> None:
178184
"""
179185
Start the background scheduler for periodic flag updates.
180186
"""
181187
def run_scheduler():
182-
self._scheduler.enter(self._interval, 1, self.recurring_update)
183-
self._scheduler.run()
188+
try:
189+
self._scheduler.enter(self._interval, 1, self.recurring_update)
190+
self._scheduler.run()
191+
except Exception as e:
192+
logger.critical("Scheduler thread encountered an error: %s", e, exc_info=True)
184193

185194
self._scheduler_thread = Thread(target=run_scheduler, daemon=True)
186195
self._scheduler_thread.start()
196+
logger.info("Flag update scheduler started (interval=%s seconds)", self._interval)
187197

188198
def recurring_update(self) -> None:
189199
"""
190200
Periodically update flags at the configured interval.
191201
"""
192-
self._update()
193-
self._scheduler.enter(self._interval, 1, self.recurring_update)
202+
try:
203+
self._update()
204+
except Exception as e:
205+
logger.error("Error during recurring flag update: %s", e, exc_info=True)
206+
finally:
207+
try:
208+
self._scheduler.enter(self._interval, 1, self.recurring_update)
209+
except Exception as e:
210+
logger.critical("Failed to reschedule recurring update: %s", e, exc_info=True)

tests/test_flag.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,10 @@ def test_from_json_no_flag_name(self):
314314
with patch("python_flaggle.flag.logger.warning") as mock_warning:
315315
flag = Flag.from_json(json_data)
316316
assert flag == {}
317-
mock_warning.assert_called_once_with("Found flag without name, skipping")
317+
mock_warning.assert_called_once_with(
318+
"Found flag without name, skipping: %r",
319+
{"description": "a test", "value": "testflag", "operation": "eq"},
320+
)
318321

319322
def test_from_json_invalid_json_data(self):
320323
json_data = {

tests/test_flaggle_extra.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
import logging
12
import threading
3+
import types
24
from unittest.mock import MagicMock, patch
35

6+
import pytest
7+
48
from python_flaggle import Flag, Flaggle
59

610

@@ -114,3 +118,73 @@ def test_flaggle_properties(monkeypatch):
114118
assert f.verify_ssl is False
115119
assert f.flags == f._flags
116120
assert f.last_update == f._last_update
121+
122+
123+
def test_flaggle_fetch_flags_unexpected_exception(monkeypatch, caplog):
124+
"""Test that _fetch_flags handles unexpected exceptions and logs critical."""
125+
126+
class DummyFlaggle(Flaggle):
127+
pass
128+
129+
def raise_exc(*a, **k):
130+
raise RuntimeError("unexpected")
131+
132+
monkeypatch.setattr("python_flaggle.flaggle.get", raise_exc)
133+
f = DummyFlaggle("http://x", interval=1, default_flags={"f": Flag("f", True)})
134+
with caplog.at_level(logging.CRITICAL):
135+
result = f._fetch_flags()
136+
assert result == {}
137+
assert any(
138+
"Unexpected error during flag fetch" in r.message for r in caplog.records
139+
)
140+
141+
142+
def test_flaggle_update_handles_exception(monkeypatch, caplog):
143+
"""Test that _update logs critical on unexpected exception."""
144+
145+
class DummyFlaggle(Flaggle):
146+
pass
147+
148+
f = DummyFlaggle("http://x", interval=1, default_flags={"f": Flag("f", True)})
149+
150+
def raise_exc():
151+
raise RuntimeError("fail update")
152+
153+
f._fetch_flags = raise_exc
154+
with caplog.at_level(logging.CRITICAL):
155+
f._update()
156+
assert any(
157+
"Unexpected error during flag update" in r.message for r in caplog.records
158+
)
159+
160+
161+
def test_flaggle_recurring_update_reschedule_exception(monkeypatch, caplog):
162+
"""Test that recurring_update logs critical if rescheduling fails."""
163+
f = Flaggle("http://x", interval=1, default_flags={"f": Flag("f", True)})
164+
165+
def raise_enter(*a, **k):
166+
raise RuntimeError("fail reschedule")
167+
168+
f._scheduler.enter = raise_enter
169+
with caplog.at_level(logging.CRITICAL):
170+
f.recurring_update()
171+
assert any(
172+
"Failed to reschedule recurring update" in r.message for r in caplog.records
173+
)
174+
175+
176+
def test_flaggle_recurring_update_update_exception(monkeypatch, caplog):
177+
"""Test that recurring_update logs error if _update fails."""
178+
f = Flaggle("http://x", interval=1, default_flags={"f": Flag("f", True)})
179+
180+
def raise_update():
181+
raise RuntimeError("fail update")
182+
183+
f._update = raise_update
184+
# Patch _scheduler.enter to a no-op to avoid further errors
185+
f._scheduler.enter = lambda *a, **k: None
186+
with caplog.at_level(logging.ERROR):
187+
f.recurring_update()
188+
assert any(
189+
"Error during recurring flag update" in r.message for r in caplog.records
190+
)

0 commit comments

Comments
 (0)