diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 12d1589..810c1ee 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,5 +29,6 @@ jobs: build_branch: build build_main: build artifact_path: dist - # No PyPI yet: publishing comes with the API provider, once Trusted - # Publishing is configured on pypi.org. + # PyPI is not published from here: publish.yml uploads this build's + # artifact after a push to main, because Trusted Publishing cannot + # name a reusable workflow from another repository. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..931226d --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,45 @@ +name: Publish + +# PyPI's Trusted Publishing names the workflow that uploads, and it cannot be a +# reusable workflow from another repository. So the build stays in +# tehw0lf/workflows and this workflow only uploads what that build produced, +# via OIDC, without a token. +on: + workflow_run: + workflows: [ Build ] + types: [ completed ] + branches: [ main ] + +jobs: + publish_to_pypi: + name: Publish to PyPI + runs-on: ubuntu-latest + # A pull request from a fork whose branch is also called main would pass + # the branches filter; only a push to this repository publishes. + if: >- + github.event.workflow_run.conclusion == 'success' + && github.event.workflow_run.event == 'push' + && github.event.workflow_run.head_repository.full_name == github.repository + timeout-minutes: 15 + environment: + name: pypi + url: https://pypi.org/p/yaft + permissions: + actions: read + id-token: write + steps: + - name: Download the build artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: build + path: dist + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + + # Fails if the version is already on PyPI, which never replaces a file: + # a merge without a version bump should be loud, not skipped. + - name: Publish to PyPI + # Named explicitly: uv build also leaves a .gitignore in dist. + run: uv publish --trusted-publishing always dist/*.whl dist/*.tar.gz diff --git a/CLAUDE.md b/CLAUDE.md index c95894f..9f30485 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,8 +15,14 @@ Java and Go ports are references for behaviour, not for API shape. the boolean shape. - `src/yaft/providers.py` — `FeatureProvider` protocol, local providers; `LocalFeatureProvider.load` is the all-or-nothing refresh (R30) +- `src/yaft/api.py` — `APIFeatureProvider` over `urllib` (R26, R30, R32): + hash first, group only on change, hash recorded only after the group + loaded; `refresh()` raises `RefreshError`, `refresh_quietly()` logs. No + redirects; a deadline checked between `read1` calls bounds a trickling body - `src/yaft/toggle.py` — `feature_toggle`, `set_provider`; functions per call, classes once, empty shell for an off class without fallback (R14–R19) +- `tests/backend.py` — a local stand-in backend; the refresh cases of the + suite run through `APIFeatureProvider` against it - `tests/conformance/` — the suite adapter; unknown case values and unknown case-file format versions must fail, never be skipped - `tests/test_*.py` — what the suite cannot see (generators, static/class @@ -26,6 +32,9 @@ Java and Go ports are references for behaviour, not for API shape. - No runtime dependencies. - `requires-python = ">=3.11"`; CI runs the tests on 3.11 and 3.14. +- PyPI: `.github/workflows/publish.yml` uploads the `build` artifact of a + push to `main` via Trusted Publishing (environment `pypi`). PyPI never + replaces a file, so a merge without a version bump fails that job. - Bump the patch version in `pyproject.toml` on every PR and run `uv lock`. - Mutation-check new rules: `PYTHONDONTWRITEBYTECODE=1`, or a same-length edit within the same second keeps running the stale `.pyc`. diff --git a/README.md b/README.md index 2c6a9a4..12cd00e 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,10 @@ typed, and has no runtime dependencies. Python 3.11 or later. ## Installation -Not on PyPI yet; that comes with the API provider. Until then: - ```bash -pip install git+https://github.com/tehw0lf/yaft-python +pip install yaft +# or +uv add yaft ``` ```python @@ -41,6 +41,7 @@ set_provider(LocalFeatureProvider({"newCheckout": Feature(key="newCheckout", val |---|---|---| | `LocalFeatureProvider` | full `Feature` records | yes: `active_at`, `disabled_at` | | `LocalBooleanProvider` | `{"myToggle": True}` | none, by design | +| `APIFeatureProvider` | one group of a YaFT backend | yes, evaluated locally | Both read a JSON file with `from_file(path)`. `LocalFeatureProvider` takes a YaFT backend response (`{"toggles": [...]}`, in either field spelling) or @@ -57,6 +58,49 @@ A missing or unreadable file raises instead of starting with everything off. nothing: a body that is not a toggle group raises `ValueError` and the old data stays. +### From a YaFT backend + +`APIFeatureProvider` loads one toggle group over HTTP, with `urllib` and +nothing else: + +```python +import threading +import time + +from yaft import APIFeatureProvider, set_provider + +provider = APIFeatureProvider("https://yaft.example.com", "896ea308-382f-46b0-bc59-d93a28013633") +provider.refresh() # raises RefreshError if the backend cannot be reached +set_provider(provider) + + +def refresh_every_minute() -> None: + while True: + time.sleep(60) + provider.refresh_quietly() # logs a failure instead of raising it + + +threading.Thread(target=refresh_every_minute, daemon=True).start() +``` + +Nothing is fetched until the first `refresh()`; until then every feature is +off. `refresh()` asks `/collectionHash/{uuid}` first and fetches +`/features/{uuid}` only when the group changed. It returns `True` for new data +and `False` for none, and raises `RefreshError` when the backend is down or +answers with something that is not a toggle group. The previous data then +stays: an outage does not switch everything off. An empty group does, because +that is what deleting its last toggle looks like. + +A toggle is found by its name (`"newCheckout"`) or its full key +(`"896ea308-…|newCheckout"`). Time bounds are evaluated locally, so a +scheduled toggle flips at its instant, not when the backend's cron job runs. +Keyword arguments: `timeout` (seconds per request, default 5), +`max_body_bytes` (default 1 MiB), `clock`, and `opener` for a custom +`urllib.request.OpenerDirector`, for example one with its own TLS context. +The default opener follows no redirects. + +### Your own provider + Any object with an `is_enabled(key: str) -> bool` method is a provider, for example one that reads environment variables. diff --git a/pyproject.toml b/pyproject.toml index 0084996..ce4d6c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "yaft" -version = "0.1.0" +version = "0.2.0" description = "Yet another Feature Toggle: decorators for functions, methods and classes" readme = "README.md" license = "MIT" diff --git a/src/yaft/__init__.py b/src/yaft/__init__.py index f3d68fa..8c2f568 100644 --- a/src/yaft/__init__.py +++ b/src/yaft/__init__.py @@ -14,6 +14,7 @@ def total(self, cart: list[int]) -> int: ... from importlib.metadata import PackageNotFoundError, version +from .api import APIFeatureProvider, RefreshError from .evaluate import Clock, evaluate, parse_timestamp, system_clock from .mapping import normalise_booleans, normalise_collection, normalise_feature, normalise_group from .model import Feature @@ -26,12 +27,14 @@ def total(self, cart: list[int]) -> int: ... __version__ = "0+unknown" __all__ = [ + "APIFeatureProvider", "Clock", "Feature", "FeatureProvider", "LocalBooleanProvider", "LocalFeatureProvider", "ProviderNotSetError", + "RefreshError", "__version__", "evaluate", "feature_toggle", diff --git a/src/yaft/api.py b/src/yaft/api.py new file mode 100644 index 0000000..5937a15 --- /dev/null +++ b/src/yaft/api.py @@ -0,0 +1,205 @@ +"""A provider that loads one toggle group from a YaFT backend (SPEC sections 5-7).""" + +import json +import logging +import threading +import time +import urllib.error +import urllib.request +import uuid +from collections.abc import Mapping +from importlib.metadata import PackageNotFoundError, version +from typing import Any +from urllib.parse import urlsplit + +from .evaluate import Clock, evaluate, system_clock +from .model import Feature +from .providers import LocalFeatureProvider + +log = logging.getLogger("yaft") + +# urllib's own "Python-urllib/3.x" is on the block lists of common bot +# filters: Cloudflare answers it with 403, so a backend behind Cloudflare +# looks like one that refuses every refresh. +try: + _USER_AGENT = f"yaft-python/{version('yaft')}" +except PackageNotFoundError: # pragma: no cover - only without an installed distribution + _USER_AGENT = "yaft-python" + + +class RefreshError(Exception): + """A refresh failed; the provider still holds the data it had before.""" + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + """Turns a redirect into an error instead of following it. + + The backend never redirects, so a redirect comes from something in front of + it -- a login page, a captive portal -- whose target is not a toggle group. + """ + + def redirect_request(self, *args: Any, **kwargs: Any) -> None: + return None + + +class APIFeatureProvider: + """A feature-shape provider over one toggle group of a YaFT backend. + + :meth:`refresh` asks ``GET /collectionHash/{uuid}`` whether the group + changed and only then fetches ``GET /features/{uuid}``. Evaluation is local, + against ``clock`` (R26): a scheduled toggle flips at its exact instant, not + when the backend's cron job gets to it. + + Nothing is fetched until the first refresh; until then every feature is + off. A failed refresh raises :class:`RefreshError` and keeps the previous + data, so a backend outage does not switch everything off (R30). Refreshing + on a schedule is left to the application, for example:: + + def refresh_every_minute() -> None: + while True: + time.sleep(60) + provider.refresh_quietly() + + threading.Thread(target=refresh_every_minute, daemon=True).start() + + Safe for use from several threads. + """ + + def __init__( + self, + base_url: str, + group: str, + *, + timeout: float = 5.0, + max_body_bytes: int = 1 << 20, + clock: Clock = system_clock, + opener: urllib.request.OpenerDirector | None = None, + ): + """Prepares a provider for ``group`` on the backend at ``base_url``. + + ``base_url`` must be http or https, without query or fragment. + ``timeout`` bounds each request, from connecting until the last byte of + the body; a read already waiting may overrun it by at most ``timeout``. + ``max_body_bytes`` keeps a misbehaving endpoint from exhausting memory. + ``opener`` replaces the default one, for example to add a TLS context; + the default follows no redirects and honours the proxy environment + variables, like everything built on :mod:`urllib.request`. + """ + parts = urlsplit(base_url) + if parts.scheme not in ("http", "https") or not parts.netloc: + raise ValueError(f"base URL must be http or https: {base_url!r}") + if parts.query or parts.fragment: + raise ValueError(f"base URL must not carry a query or fragment: {base_url!r}") + # The UUID goes into the request path, so only the canonical 8-4-4-4-12 + # form is accepted. uuid.UUID alone would also take braces, a "urn:" + # prefix or no hyphens. + try: + canonical = str(uuid.UUID(group)) + except ValueError: + canonical = None + if canonical is None or canonical != group.lower(): + raise ValueError(f"group is not a UUID: {group!r}") + if timeout <= 0 or max_body_bytes <= 0: + raise ValueError("timeout and max_body_bytes must be positive") + + root = base_url.rstrip("/") + self._features_url = f"{root}/features/{canonical}" + self._hash_url = f"{root}/collectionHash/{canonical}" + self._key_prefix = f"{canonical}|" + self._timeout = timeout + self._max_body_bytes = max_body_bytes + self._opener = opener or urllib.request.build_opener(_NoRedirect) + self._clock = clock + self._local = LocalFeatureProvider(clock=clock) + self._lock = threading.Lock() + self._hash = "" + + @property + def data(self) -> Mapping[str, Feature]: + """The features of the last successful refresh by key, read-only.""" + return self._local.data + + def refresh(self) -> bool: + """Fetches the group if it changed since the last successful refresh. + + Returns ``True`` if new data was loaded and ``False`` if the group is + unchanged. Raises :class:`RefreshError` if the backend cannot be reached + or answers with something that is not a toggle group (R32); the + previous data then stays in place. + """ + with self._lock: + hash_ = _hash_of(self._get(self._hash_url)) + if hash_ is None: + raise RefreshError(f"GET {self._hash_url} sent no collectionHash") + if hash_ == self._hash: + return False + try: + self._local.load(self._get(self._features_url)) + except ValueError as error: + raise RefreshError(f"GET {self._features_url}: {error}") from error + # Recorded only after the group loaded, so a failed fetch is + # retried (R30). + self._hash = hash_ + return True + + def refresh_quietly(self) -> bool: + """:meth:`refresh` for a scheduler: logs a failure instead of raising it.""" + try: + return self.refresh() + except RefreshError as error: + log.warning("refresh failed; keeping the previous data: %s", error) + return False + + def is_enabled(self, key: str) -> bool: + """Answers for a toggle of this group, by its name or its ``uuid|name`` key. + + The key is looked up as given first, then as a name within the group, + so a toggle can be named without knowing the group's UUID. + """ + data = self._local.data + feature = data.get(key) + if feature is None and not key.startswith(self._key_prefix): + feature = data.get(self._key_prefix + key) + return evaluate(feature, self._clock()) + + def _get(self, url: str) -> object: + request = urllib.request.Request( + url, headers={"Accept": "application/json", "User-Agent": _USER_AGENT} + ) + deadline = time.monotonic() + self._timeout + try: + with self._opener.open(request, timeout=self._timeout) as response: + if response.status != 200: + raise RefreshError(f"GET {url} answered {response.status}") + body = bytearray() + # read1 returns after one read from the socket, so the deadline + # is checked while a slow server trickles the body in; read(n) + # would wait for all n bytes. + while chunk := response.read1(64 * 1024): + body += chunk + if len(body) > self._max_body_bytes: + raise RefreshError(f"GET {url} sent more than {self._max_body_bytes} bytes") + if time.monotonic() > deadline: + raise RefreshError(f"GET {url} took longer than {self._timeout} s") + except urllib.error.HTTPError as error: + # Raised for 3xx (no redirects) and every 4xx and 5xx. + error.close() + raise RefreshError(f"GET {url} answered {error.code}") from error + except (OSError, ValueError) as error: + # URLError, timeouts, refused connections and malformed responses + # are all OSError; http.client raises some ValueErrors of its own. + raise RefreshError(f"GET {url}: {error}") from error + try: + return json.loads(body) + except (ValueError, RecursionError) as error: + # RecursionError: a body of nothing but "[" nests deeper than the + # parser recurses, well within max_body_bytes. + raise RefreshError(f"GET {url} sent a body that does not parse: {error}") from error + + +def _hash_of(response: object) -> str | None: + """Reads the hash by presence, as the other ports do: collectionHash, else value.""" + if not isinstance(response, Mapping): + return None + value = response["collectionHash"] if "collectionHash" in response else response.get("value") + return value if isinstance(value, str) and value != "" else None diff --git a/tests/backend.py b/tests/backend.py new file mode 100644 index 0000000..d29e5d7 --- /dev/null +++ b/tests/backend.py @@ -0,0 +1,70 @@ +"""A stand-in YaFT backend on a local port, for the API provider's tests.""" + +import json +import threading +from collections import Counter +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +GROUP = "896ea308-382f-46b0-bc59-d93a28013633" + +# Writes the answer to a request; the default ones are built by serve(). +Route = Callable[[BaseHTTPRequestHandler], None] + + +class Backend: + """Canned answers per path, and a count of the requests per path.""" + + def __init__(self, url: str): + self.url = url + self.routes: dict[str, Route] = {} + self.hits: Counter[str] = Counter() + self.user_agents: set[str] = set() + + def serve(self, hash_: str, features: object) -> None: + """Answers the group's hash with ``hash_`` and its features with ``features``.""" + self.routes[f"/collectionHash/{GROUP}"] = answer(200, {"collectionHash": hash_}) + self.routes[f"/features/{GROUP}"] = answer(200, features) + + +def answer(status: int, body: object, headers: dict[str, str] | None = None) -> Route: + raw = body if isinstance(body, bytes) else json.dumps(body).encode() + + def route(handler: BaseHTTPRequestHandler) -> None: + handler.send_response(status) + for name, value in {"Content-Type": "application/json", **(headers or {})}.items(): + handler.send_header(name, value) + handler.send_header("Content-Length", str(len(raw))) + handler.end_headers() + handler.wfile.write(raw) + + return route + + +@contextmanager +def running() -> Iterator[Backend]: + backend: Backend + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + backend.hits[self.path] += 1 + backend.user_agents.add(self.headers.get("User-Agent", "")) + route = backend.routes.get(self.path, answer(404, {"error": "not found"})) + route(self) + + def log_message(self, format: str, *args: object) -> None: + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + server.daemon_threads = True + backend = Backend(f"http://127.0.0.1:{server.server_port}") + # The default poll interval of 0.5 s would add that much to every shutdown. + thread = threading.Thread(target=server.serve_forever, args=(0.01,), daemon=True) + thread.start() + try: + yield backend + finally: + server.shutdown() + server.server_close() + thread.join() diff --git a/tests/conformance/test_mapping.py b/tests/conformance/test_mapping.py index 46e214c..df7225e 100644 --- a/tests/conformance/test_mapping.py +++ b/tests/conformance/test_mapping.py @@ -9,8 +9,15 @@ import pytest -from yaft import Feature, LocalBooleanProvider, LocalFeatureProvider, normalise_collection - +from yaft import ( + APIFeatureProvider, + Feature, + LocalBooleanProvider, + RefreshError, + normalise_collection, +) + +from ..backend import GROUP, Backend, running from .cases import Case, as_record, load, title, unsupported CASES = load("mapping") @@ -28,7 +35,8 @@ def test_loads_the_suite() -> None: def test_mapping(case: Case) -> None: match case["shape"]: case "feature" if "held" in case: - refresh(case) + with running() as backend: + refresh(case, backend) case "feature": assert records(normalise_collection(case["response"])) == case["expected"] case "boolean": @@ -43,31 +51,36 @@ def test_mapping(case: Case) -> None: unsupported("shape", other, case) -def refresh(case: Case) -> None: - """A refresh case (R30, R32): ``held`` is loaded, then ``response`` arrives. +def refresh(case: Case, backend: Backend) -> None: + """A refresh case (R30, R32) through the API provider, over HTTP. - This port has no API provider yet, so the refresh goes through - ``LocalFeatureProvider.load``, which the API provider will use as well. - ``load`` must raise exactly when the case says the response is rejected - (R32), and leave the data the case expects. ``retry`` checks that a - rejected refresh does not block the next one; without a collection hash - there is nothing to record, so here it only checks the data it loads. + ``held`` is loaded first, then the backend answers with a new hash and + ``response``. The refresh must raise exactly when the case says the + response is rejected (R32), and leave the data the case expects. ``retry`` + comes with the same hash as the rejected response: a provider that + recorded the hash of a body it rejected never fetches again (R30). """ rejected = case["rejected"] if not isinstance(rejected, bool): unsupported("rejected", rejected, case) - provider = LocalFeatureProvider.from_response({"toggles": list(case["held"].values())}) + backend.serve("held", {"toggles": list(case["held"].values())}) + provider = APIFeatureProvider(backend.url, GROUP) + assert provider.refresh() assert records(provider.data) == case["held"] + backend.serve("response", case["response"]) if rejected: - with pytest.raises(ValueError, match="not a toggle group"): - provider.load(case["response"]) + with pytest.raises(RefreshError): + provider.refresh() else: - provider.load(case["response"]) + provider.refresh() assert records(provider.data) == case["expected"] retry = case.get("retry") if retry is not None: - provider.load(retry["response"]) + if not isinstance(retry, dict): + unsupported("retry", retry, case) + backend.serve("response", retry["response"]) + provider.refresh() assert records(provider.data) == retry["expected"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b0bdc68 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,11 @@ +from collections.abc import Iterator + +import pytest + +from .backend import Backend, running + + +@pytest.fixture +def backend() -> Iterator[Backend]: + with running() as backend: + yield backend diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..b3470c2 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,202 @@ +"""The API provider: what the conformance suite cannot see over HTTP.""" + +import logging +import socket +import time +from datetime import UTC, datetime +from http.server import BaseHTTPRequestHandler + +import pytest + +from yaft import APIFeatureProvider, FeatureProvider, RefreshError, __version__ + +from .backend import GROUP, Backend, Route, answer + +FEATURES = f"/features/{GROUP}" +HASH = f"/collectionHash/{GROUP}" + + +def toggle(name: str, value: str = "true", **fields: str) -> dict[str, str]: + return {"key": f"{GROUP}|{name}", "value": value, **fields} + + +def test_is_a_feature_provider() -> None: + assert isinstance(APIFeatureProvider("http://localhost", GROUP), FeatureProvider) + + +@pytest.mark.parametrize( + "base_url", + ["ftp://host", "localhost:8080", "http://", "http://host/?a=b", "http://host/#top"], +) +def test_rejects_a_base_url_it_cannot_use(base_url: str) -> None: + with pytest.raises(ValueError, match="base URL"): + APIFeatureProvider(base_url, GROUP) + + +@pytest.mark.parametrize( + "group", + [ + "", + "not-a-uuid", + GROUP.replace("-", ""), + "{" + GROUP + "}", + "urn:uuid:" + GROUP, + GROUP + "/../x", + ], +) +def test_accepts_only_the_canonical_uuid_form(group: str) -> None: + # The UUID goes into the request path. + with pytest.raises(ValueError, match="not a UUID"): + APIFeatureProvider("http://localhost", group) + + +def test_rejects_limits_that_are_not_positive() -> None: + with pytest.raises(ValueError, match="positive"): + APIFeatureProvider("http://localhost", GROUP, timeout=0) + with pytest.raises(ValueError, match="positive"): + APIFeatureProvider("http://localhost", GROUP, max_body_bytes=0) + + +def test_fetches_nothing_before_the_first_refresh(backend: Backend) -> None: + backend.serve("h1", {"toggles": [toggle("a")]}) + provider = APIFeatureProvider(backend.url, GROUP) + assert not provider.is_enabled("a") + assert not backend.hits + + +def test_fetches_the_group_only_when_its_hash_changed(backend: Backend) -> None: + backend.serve("h1", {"toggles": [toggle("a")]}) + provider = APIFeatureProvider(backend.url + "/", GROUP.upper()) + assert provider.refresh() + assert not provider.refresh() + assert backend.hits == {HASH: 2, FEATURES: 1} + + backend.serve("h2", {"toggles": [toggle("a", "false")]}) + assert provider.refresh() + assert not provider.is_enabled("a") + + +def test_names_itself_in_the_user_agent(backend: Backend) -> None: + # Cloudflare answers urllib's default "Python-urllib/3.x" with 403. + backend.serve("h1", {"toggles": [toggle("a")]}) + APIFeatureProvider(backend.url, GROUP).refresh() + assert backend.user_agents == {f"yaft-python/{__version__}"} + + +def test_answers_by_name_or_by_full_key(backend: Backend) -> None: + backend.serve("h1", {"toggles": [toggle("a"), {"key": "plain", "value": "true"}]}) + provider = APIFeatureProvider(backend.url, GROUP) + provider.refresh() + assert provider.is_enabled("a") + assert provider.is_enabled(f"{GROUP}|a") + # A key as given wins, and a full key is not looked up a second time. + assert provider.is_enabled("plain") + assert not provider.is_enabled(f"{GROUP}|missing") + assert not provider.is_enabled("missing") + + +def test_evaluates_against_its_clock(backend: Backend) -> None: + now = datetime(2026, 9, 30, 11, 59, tzinfo=UTC) + backend.serve("h1", {"toggles": [toggle("a", activeAt="2026-09-30T12:00:00Z")]}) + provider = APIFeatureProvider(backend.url, GROUP, clock=lambda: now) + provider.refresh() + assert not provider.is_enabled("a") + now = datetime(2026, 9, 30, 12, 0, tzinfo=UTC) + assert provider.is_enabled("a") + + +@pytest.mark.parametrize( + ("route", "message"), + [ + (answer(404, {"error": "not found"}), "answered 404"), + (answer(500, b"oops"), "answered 500"), + (answer(204, b""), "answered 204"), + # Not followed: the target of a redirect is not the backend. + (answer(302, b"", {"Location": "/elsewhere"}), "answered 302"), + (answer(200, b"login"), "does not parse"), + (answer(200, b"\xff\xfe"), "does not parse"), + (answer(200, b"[" * 100_000), "does not parse"), + (answer(200, b"x" * 300_000), "more than 200000 bytes"), + ], +) +def test_a_failed_fetch_raises_and_keeps_the_data( + backend: Backend, route: Route, message: str +) -> None: + backend.serve("h1", {"toggles": [toggle("a")]}) + provider = APIFeatureProvider(backend.url, GROUP, max_body_bytes=200_000) + provider.refresh() + + backend.serve("h2", None) + backend.routes[FEATURES] = route + with pytest.raises(RefreshError, match=message): + provider.refresh() + assert provider.is_enabled("a") + + +@pytest.mark.parametrize( + "body", + [ + {}, + {"collectionHash": ""}, + {"collectionHash": 7}, + # By presence, like every field (R23): an empty collectionHash does not + # fall through to value. + {"collectionHash": "", "value": "h1"}, + None, + [], + ], +) +def test_a_missing_hash_fails_the_refresh(backend: Backend, body: object) -> None: + backend.routes[HASH] = answer(200, body) + provider = APIFeatureProvider(backend.url, GROUP) + with pytest.raises(RefreshError, match="no collectionHash"): + provider.refresh() + assert FEATURES not in backend.hits + + +def test_reads_the_hash_from_value_when_collection_hash_is_absent(backend: Backend) -> None: + backend.serve("unused", {"toggles": [toggle("a")]}) + backend.routes[HASH] = answer(200, {"value": "h1"}) + provider = APIFeatureProvider(backend.url, GROUP) + assert provider.refresh() + assert not provider.refresh() + + +def test_an_unreachable_backend_raises() -> None: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + provider = APIFeatureProvider(f"http://127.0.0.1:{port}", GROUP) + with pytest.raises(RefreshError, match=r"GET http://127\.0\.0\.1"): + provider.refresh() + + +def test_the_timeout_covers_a_body_that_trickles_in(backend: Backend) -> None: + def trickle(handler: BaseHTTPRequestHandler) -> None: + handler.send_response(200) + handler.send_header("Content-Length", "100") + handler.end_headers() + for _ in range(100): + handler.wfile.write(b" ") + handler.wfile.flush() + time.sleep(0.05) + + backend.routes[HASH] = trickle + provider = APIFeatureProvider(backend.url, GROUP, timeout=0.5) + started = time.monotonic() + with pytest.raises(RefreshError, match="took longer"): + provider.refresh() + assert time.monotonic() - started < 2 + + +def test_refresh_quietly_logs_instead_of_raising( + backend: Backend, caplog: pytest.LogCaptureFixture +) -> None: + provider = APIFeatureProvider(backend.url, GROUP) + with caplog.at_level(logging.WARNING, logger="yaft"): + assert not provider.refresh_quietly() + assert "answered 404" in caplog.text + + backend.serve("h1", {"toggles": [toggle("a")]}) + assert provider.refresh_quietly() + assert provider.is_enabled("a") diff --git a/uv.lock b/uv.lock index 0b34f57..c390d49 100644 --- a/uv.lock +++ b/uv.lock @@ -361,7 +361,7 @@ wheels = [ [[package]] name = "yaft" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } [package.dev-dependencies]