diff --git a/src/glitchup_bot/glitchtip_client/client.py b/src/glitchup_bot/glitchtip_client/client.py index 694b476..d6a43b2 100644 --- a/src/glitchup_bot/glitchtip_client/client.py +++ b/src/glitchup_bot/glitchtip_client/client.py @@ -52,6 +52,36 @@ class GlitchTipClient: return results + async def _get_paginated_with_fallbacks( + self, + path: str, + param_candidates: list[dict], + *, + fallback_filter=None, + ) -> list: + last_error: Exception | None = None + + for index, candidate in enumerate(param_candidates): + try: + results = await self._get_paginated(path, params=candidate) + if fallback_filter is not None: + results = [item for item in results if fallback_filter(item)] + return results + except httpx.HTTPStatusError as exc: + last_error = exc + if exc.response.status_code != 422 or index == len(param_candidates) - 1: + raise + + logger.warning( + "GlitchTip rejected issue query params for %s with 422; " + "retrying with a simpler request", + path, + ) + + if last_error is not None: + raise last_error + return [] + @staticmethod def _parse_next_cursor(link_header: str) -> str | None: for part in link_header.split(","): @@ -72,9 +102,17 @@ class GlitchTipClient: async def list_issues( self, project_slug: str, query: str = "is:unresolved", sort: str = "date" ) -> list[dict]: - return await self._get_paginated( - f"/api/0/projects/{settings.glitchtip_org_slug}/{project_slug}/issues/", - params={"query": query, "sort": sort}, + path = f"/api/0/projects/{settings.glitchtip_org_slug}/{project_slug}/issues/" + return await self._get_paginated_with_fallbacks( + path, + [ + {"query": query, "sort": sort}, + {"query": query}, + {}, + ], + fallback_filter=( + lambda issue: (issue.get("status") or "unresolved").lower() == "unresolved" + ), ) async def get_issue(self, issue_id: int) -> dict: diff --git a/tests/test_glitchtip_client.py b/tests/test_glitchtip_client.py new file mode 100644 index 0000000..7f0d22e --- /dev/null +++ b/tests/test_glitchtip_client.py @@ -0,0 +1,63 @@ +import httpx +import pytest + +from glitchup_bot.glitchtip_client.client import GlitchTipClient + + +@pytest.mark.asyncio +async def test_list_issues_retries_without_query_params_on_422() -> None: + attempts: list[dict[str, str | int]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + attempts.append(dict(request.url.params)) + + if len(attempts) < 3: + return httpx.Response(422, request=request, json={"detail": "bad query"}) + + return httpx.Response( + 200, + request=request, + json=[ + {"id": "1", "status": "unresolved", "title": "keep"}, + {"id": "2", "status": "resolved", "title": "drop"}, + ], + headers={"link": ""}, + ) + + client = GlitchTipClient() + client._client = httpx.AsyncClient( + base_url=client.base_url, + headers=client.headers, + transport=httpx.MockTransport(handler), + ) + + try: + issues = await client.list_issues("backend-production") + finally: + await client.close() + + assert [issue["id"] for issue in issues] == ["1"] + assert attempts == [ + {"query": "is:unresolved", "sort": "date", "limit": "100"}, + {"query": "is:unresolved", "limit": "100"}, + {"limit": "100"}, + ] + + +@pytest.mark.asyncio +async def test_list_issues_preserves_non_422_errors() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(500, request=request, json={"detail": "server error"}) + + client = GlitchTipClient() + client._client = httpx.AsyncClient( + base_url=client.base_url, + headers=client.headers, + transport=httpx.MockTransport(handler), + ) + + try: + with pytest.raises(httpx.HTTPStatusError): + await client.list_issues("backend-production") + finally: + await client.close()