From 1fe0a7e24910171d0d18490a03a598423ee3b329 Mon Sep 17 00:00:00 2001 From: oasaph Date: Wed, 4 Jun 2025 11:06:49 -0300 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A5=85=20fix(flag):=20improve=20error?= =?UTF-8?q?=20handling=20and=20logging=20in=20flag=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- python_flaggle/flag.py | 17 +++++++-- python_flaggle/flaggle.py | 51 ++++++++++++++++--------- tests/test_flag.py | 5 ++- tests/test_flaggle_extra.py | 74 +++++++++++++++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 5dfe370..414c836 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file - [ ] **Advanced Rollout Strategies:** Support for percentage rollouts, user targeting, and A/B testing. - [ ] **Async Support:** Add async/await support for non-blocking flag fetching and updates. - [ ] **Type Annotations & Validation:** Improve type safety and validation for flag values and operations. -- [ ] **Better Error Handling & Logging:** More granular error reporting and logging options. +- [x] **Better Error Handling & Logging:** More granular error reporting and logging options. - [x] **Extensive Documentation & Examples:** Expand documentation with more real-world usage patterns and advanced scenarios. Contributions and suggestions are welcome! Please open an issue or pull request if you have ideas for improvements. \ No newline at end of file diff --git a/python_flaggle/flag.py b/python_flaggle/flag.py index 30f5b13..2ad4a3c 100644 --- a/python_flaggle/flag.py +++ b/python_flaggle/flag.py @@ -113,13 +113,15 @@ def from_string(cls, operation: str) -> "FlagOperation": ValueError: If the operation name is invalid. """ operation = operation.upper() - try: return cls.__dict__[operation] except KeyError as exc: logger.error("Invalid Operation '%s'", operation) logger.debug(format_exc()) raise ValueError(f"Invalid Operation '{operation}'") from exc + except Exception as exc: + logger.critical("Unexpected error in FlagOperation.from_string: %s", exc, exc_info=True) + raise class Flag: @@ -247,13 +249,14 @@ def from_json(cls: "Flag", data: dict) -> dict[str, "Flag"]: try: flags_data = data.get("flags") if flags_data is None or not isinstance(flags_data, list): + logger.error("No flags in the provided JSON data: %r", data) raise ValueError("No flags in the provided JSON data") result = {} for flag_data in flags_data: name = flag_data.get("name") if not name: - logger.warning("Found flag without name, skipping") + logger.warning("Found flag without name, skipping: %r", flag_data) continue value = flag_data.get("value") @@ -262,11 +265,19 @@ def from_json(cls: "Flag", data: dict) -> dict[str, "Flag"]: operation_str = flag_data.get("operation") operation = None if operation_str: - operation = FlagOperation.from_string(operation_str) + try: + operation = FlagOperation.from_string(operation_str) + except Exception as exc: + logger.error("Invalid operation '%s' for flag '%s': %s", operation_str, name, exc, exc_info=True) + raise ValueError("Invalid JSON data: invalid operation") from exc result[name] = cls(name, value, description, operation) return result except (KeyError, AttributeError) as exc: + logger.error("Invalid JSON data: %s", exc, exc_info=True) + raise ValueError(f"Invalid JSON data: {exc}") from exc + except Exception as exc: + logger.critical("Unexpected error in Flag.from_json: %s", exc, exc_info=True) raise ValueError(f"Invalid JSON data: {exc}") from exc diff --git a/python_flaggle/flaggle.py b/python_flaggle/flaggle.py index 8113d3a..0975e04 100644 --- a/python_flaggle/flaggle.py +++ b/python_flaggle/flaggle.py @@ -150,44 +150,61 @@ def _fetch_flags(self) -> dict[str, list[dict[str, str]]]: logger.info("Fetching flags from %s", self._url) response = get(self._url, timeout=self._timeout, verify=self._verify_ssl) response.raise_for_status() - - logger.info("Flags fetched successfully") + logger.info("Flags fetched successfully from %s", self._url) logger.debug("Response content: %s", response.text) - logger.warning("Response[%i]: %r", response.status_code, response.json()) - + logger.debug("Response[%i]: %r", response.status_code, response.json()) return Flag.from_json(response.json()) except RequestException as e: - print(f"Error fetching flags: {e}") + logger.error("Error fetching flags from %s: %s", self._url, e, exc_info=True) + return {} + except ValueError as e: + logger.error("Invalid response format from %s: %s", self._url, e, exc_info=True) return {} - except (KeyError, ValueError): - logger.error("Invalid response format: 'flags' key not found") + except Exception as e: + logger.critical("Unexpected error during flag fetch: %s", e, exc_info=True) return {} def _update(self) -> None: """ Update the internal flag dictionary by fetching the latest flags. """ - flags_data = self._fetch_flags() - if flags_data: - self._flags = flags_data - self._last_update = datetime.now(timezone.utc) - logger.info("Flags updated successfully") - logger.debug("Current flags: %s", self._flags) + try: + flags_data = self._fetch_flags() + if flags_data: + self._flags = flags_data + self._last_update = datetime.now(timezone.utc) + logger.info("Flags updated successfully at %s", self._last_update) + logger.debug("Current flags: %s", self._flags) + else: + logger.warning("No flags data received; keeping previous flags.") + except Exception as e: + logger.critical("Unexpected error during flag update: %s", e, exc_info=True) def _schedule_update(self) -> None: """ Start the background scheduler for periodic flag updates. """ def run_scheduler(): - self._scheduler.enter(self._interval, 1, self.recurring_update) - self._scheduler.run() + try: + self._scheduler.enter(self._interval, 1, self.recurring_update) + self._scheduler.run() + except Exception as e: + logger.critical("Scheduler thread encountered an error: %s", e, exc_info=True) self._scheduler_thread = Thread(target=run_scheduler, daemon=True) self._scheduler_thread.start() + logger.info("Flag update scheduler started (interval=%s seconds)", self._interval) def recurring_update(self) -> None: """ Periodically update flags at the configured interval. """ - self._update() - self._scheduler.enter(self._interval, 1, self.recurring_update) + try: + self._update() + except Exception as e: + logger.error("Error during recurring flag update: %s", e, exc_info=True) + finally: + try: + self._scheduler.enter(self._interval, 1, self.recurring_update) + except Exception as e: + logger.critical("Failed to reschedule recurring update: %s", e, exc_info=True) diff --git a/tests/test_flag.py b/tests/test_flag.py index 7e0d37e..5188870 100644 --- a/tests/test_flag.py +++ b/tests/test_flag.py @@ -314,7 +314,10 @@ def test_from_json_no_flag_name(self): with patch("python_flaggle.flag.logger.warning") as mock_warning: flag = Flag.from_json(json_data) assert flag == {} - mock_warning.assert_called_once_with("Found flag without name, skipping") + mock_warning.assert_called_once_with( + "Found flag without name, skipping: %r", + {"description": "a test", "value": "testflag", "operation": "eq"}, + ) def test_from_json_invalid_json_data(self): json_data = { diff --git a/tests/test_flaggle_extra.py b/tests/test_flaggle_extra.py index d7c07d7..ca96ce9 100644 --- a/tests/test_flaggle_extra.py +++ b/tests/test_flaggle_extra.py @@ -1,6 +1,10 @@ +import logging import threading +import types from unittest.mock import MagicMock, patch +import pytest + from python_flaggle import Flag, Flaggle @@ -114,3 +118,73 @@ def test_flaggle_properties(monkeypatch): assert f.verify_ssl is False assert f.flags == f._flags assert f.last_update == f._last_update + + +def test_flaggle_fetch_flags_unexpected_exception(monkeypatch, caplog): + """Test that _fetch_flags handles unexpected exceptions and logs critical.""" + + class DummyFlaggle(Flaggle): + pass + + def raise_exc(*a, **k): + raise RuntimeError("unexpected") + + monkeypatch.setattr("python_flaggle.flaggle.get", raise_exc) + f = DummyFlaggle("http://x", interval=1, default_flags={"f": Flag("f", True)}) + with caplog.at_level(logging.CRITICAL): + result = f._fetch_flags() + assert result == {} + assert any( + "Unexpected error during flag fetch" in r.message for r in caplog.records + ) + + +def test_flaggle_update_handles_exception(monkeypatch, caplog): + """Test that _update logs critical on unexpected exception.""" + + class DummyFlaggle(Flaggle): + pass + + f = DummyFlaggle("http://x", interval=1, default_flags={"f": Flag("f", True)}) + + def raise_exc(): + raise RuntimeError("fail update") + + f._fetch_flags = raise_exc + with caplog.at_level(logging.CRITICAL): + f._update() + assert any( + "Unexpected error during flag update" in r.message for r in caplog.records + ) + + +def test_flaggle_recurring_update_reschedule_exception(monkeypatch, caplog): + """Test that recurring_update logs critical if rescheduling fails.""" + f = Flaggle("http://x", interval=1, default_flags={"f": Flag("f", True)}) + + def raise_enter(*a, **k): + raise RuntimeError("fail reschedule") + + f._scheduler.enter = raise_enter + with caplog.at_level(logging.CRITICAL): + f.recurring_update() + assert any( + "Failed to reschedule recurring update" in r.message for r in caplog.records + ) + + +def test_flaggle_recurring_update_update_exception(monkeypatch, caplog): + """Test that recurring_update logs error if _update fails.""" + f = Flaggle("http://x", interval=1, default_flags={"f": Flag("f", True)}) + + def raise_update(): + raise RuntimeError("fail update") + + f._update = raise_update + # Patch _scheduler.enter to a no-op to avoid further errors + f._scheduler.enter = lambda *a, **k: None + with caplog.at_level(logging.ERROR): + f.recurring_update() + assert any( + "Error during recurring flag update" in r.message for r in caplog.records + ) From cc61f45f3ae9206406754f06ecd30f870ce09799 Mon Sep 17 00:00:00 2001 From: oasaph Date: Wed, 4 Jun 2025 11:19:01 -0300 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=94=96=20Bump=20version=20to=200.3.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- python_flaggle/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0840fc0..5980dfb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python_flaggle" -version = "0.2.1" +version = "0.3.0" description = "" authors = [ {name = "Asaph Diniz", email = "contato@asaph.dev.br"} diff --git a/python_flaggle/__init__.py b/python_flaggle/__init__.py index 2b6f215..499b3f3 100644 --- a/python_flaggle/__init__.py +++ b/python_flaggle/__init__.py @@ -13,6 +13,6 @@ from python_flaggle.flag import Flag, FlagOperation, FlagType __all__ = ["FlagType", "FlagOperation", "Flag", "Flaggle"] -__version__ = "0.2.1" +__version__ = "0.3.0" __author__ = "Asaph Diniz" __email__ = "contato@asaph.dev.br"