Skip to content

refactor: extract API error handling into decorator#352

Closed
sorlen008 wants to merge 2 commits intocyberjunky:masterfrom
sorlen008:refactor/error-handler-decorator
Closed

refactor: extract API error handling into decorator#352
sorlen008 wants to merge 2 commits intocyberjunky:masterfrom
sorlen008:refactor/error-handler-decorator

Conversation

@sorlen008
Copy link
Copy Markdown
Contributor

@sorlen008 sorlen008 commented Apr 15, 2026

Noticed that connectapi(), connectwebproxy(), and download() have ~50 lines of nearly identical error-handling code — each one catches HTTPError / GarminConnectConnectionError, pulls the status code off the response, logs, and then maps 401 -> auth error, 429 -> rate limit, 4xx -> client error, rest -> HTTP error, plus a generic Exception -> connection error catch-all.

This PR pulls that logic out into a single _handle_api_errors(label) decorator factory so each of the three methods collapses to a one-liner.

Before

def connectapi(self, path: str, **kwargs: Any) -> Any:
    """Wrapper for native connectapi with error handling."""
    try:
        return self.client.connectapi(path, **kwargs)
    except (HTTPError, GarminConnectConnectionError) as e:
        if isinstance(e, GarminConnectConnectionError):
            status = getattr(getattr(e, "response", None), "status_code", None)
        else:
            status = getattr(getattr(e, "response", None), "status_code", None)
        logger.exception(
            "API call failed for path '%s': %s (status=%s)", path, e, status
        )
        if status == 401:
            raise GarminConnectAuthenticationError(f"Authentication failed: {e}") from e
        if status == 429:
            raise GarminConnectTooManyRequestsError(f"Rate limit exceeded: {e}") from e
        if status and 400 <= status < 500:
            raise GarminConnectConnectionError(f"API client error ({status}): {e}") from e
        raise GarminConnectConnectionError(f"HTTP error: {e}") from e
    except Exception as e:
        logger.exception("Connection error during connectapi path=%s", path)
        raise GarminConnectConnectionError(f"Connection error: {e}") from e

# ...and ~50 more lines of the same thing in connectwebproxy() and download()

After

@_handle_api_errors("API call")
def connectapi(self, path: str, **kwargs: Any) -> Any:
    """Wrapper for native connectapi with error handling."""
    return self.client.connectapi(path, **kwargs)

@_handle_api_errors("Web proxy call")
def connectwebproxy(self, path: str, **kwargs: Any) -> Any:
    """Wrapper for web proxy requests to connect.garmin.com with error handling."""
    return self.client.request("GET", "connect", path, **kwargs).json()

@_handle_api_errors("Download")
def download(self, path: str, **kwargs: Any) -> Any:
    """Wrapper for native download with error handling."""
    return self.client.download(path, **kwargs)

The decorator itself lives above the Garmin class and looks like this:

def _handle_api_errors(label: str) -> Callable:
    def decorator(func: Callable) -> Callable:
        @functools.wraps(func)
        def wrapper(self, path, *args, **kwargs):
            try:
                return func(self, path, *args, **kwargs)
            except (HTTPError, GarminConnectConnectionError) as e:
                status = getattr(getattr(e, "response", None), "status_code", None)
                logger.exception("%s failed for path '%s' (status=%s)", label, path, status)
                if status == 401:
                    raise GarminConnectAuthenticationError(f"Authentication failed: {e}") from e
                if status == 429:
                    raise GarminConnectTooManyRequestsError(f"Rate limit exceeded: {e}") from e
                if status and 400 <= status < 500:
                    raise GarminConnectConnectionError(f"{label} client error ({status}): {e}") from e
                raise GarminConnectConnectionError(f"{label} HTTP error: {e}") from e
            except Exception as e:
                logger.exception("Connection error during %s path=%s", label, path)
                raise GarminConnectConnectionError(f"Connection error: {e}") from e
        return wrapper
    return decorator

Zero behavior change

  • Same exceptions raised in the same conditions.
    • 401 -> GarminConnectAuthenticationError
    • 429 -> GarminConnectTooManyRequestsError
    • 400-499 -> GarminConnectConnectionError ("{label} client error")
    • other HTTP -> GarminConnectConnectionError ("{label} HTTP error")
    • anything else -> GarminConnectConnectionError ("Connection error")
  • logger.exception still fires on every failure path, keyed by the same label strings.
  • Exception message wording has minor tweaks (e.g. "Download client error (400): ..." instead of "Download error: ..." on the 4xx branch — which I think is actually more informative — but the exception classes are identical).

Net change: +54 / -78 lines in garminconnect/__init__.py.

Why

Beyond just deduping, the main payoff is that any future cross-cutting concern — retries with backoff, structured logging, metrics, circuit breakers — now has exactly one place to live instead of three. E.g. adding retry on 429 becomes a small change inside the decorator instead of three parallel edits.

Test plan

  • Full test suite passes (python -m pytest tests/ -v): 19 passed, 2 skipped — the two skips are pre-existing conditional skips inside test_download_activity and test_upload.
  • Manually exercised the decorator with a mocked client raising HTTPError(status=401/429/404/500) and a generic ValueError — all five branches map to the expected exception types and messages.
  • Success pass-through verified (return_value flows back through the wrapper unchanged).

Summary by CodeRabbit

  • Refactor
    • Centralized API error handling for more consistent and reliable mapping of service errors to user-facing error types.
  • Tests
    • Added regression tests to verify error mappings, preservation of specific domain errors, and that successful responses remain unchanged.

connectapi(), connectwebproxy(), and download() each contained nearly
identical error-handling blocks — roughly 50 lines of duplicated logic
mapping HTTPError / GarminConnectConnectionError to domain exceptions
based on HTTP status.

Extracted that into a single _handle_api_errors(label) decorator factory.
Each wrapped method now holds just the one-line call to self.client.
Exception types and status mapping are unchanged; log messages keep the
per-method label ('API call', 'Web proxy call', 'Download').

Net change: -78 lines, +54 lines. Makes it easier to add retry or other
cross-cutting concerns later without touching each method.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 15, 2026

Walkthrough

Introduces a reusable _handle_api_errors(label) decorator that centralizes Garmin API error mapping and replaces local try/except blocks in several methods, delegating HTTP error-to-domain-exception translation to the decorator while preserving normal return values. (50 words)

Changes

Cohort / File(s) Summary
Core error-handling changes
garminconnect/__init__.py
Added _handle_api_errors(label) decorator using ParamSpec/TypeVar and functools.wraps. Mapped HTTP errors to domain exceptions (401 → GarminConnectAuthenticationError, 429 → GarminConnectTooManyRequestsError, other 4xx → GarminConnectConnectionError, non-4xx/unknown → GarminConnectConnectionError). Removed local try/except blocks from Garmin.connectapi, Garmin.connectwebproxy, and Garmin.download and applied the decorator.
Tests
tests/test_error_handler_decorator.py
Added regression tests exercising the decorator: verifies propagation of existing domain exceptions, mapping of HTTP status codes to domain exceptions (401, 429, 404, 500), wrapping behavior for pre-existing GarminConnectConnectionError and generic exceptions, and correct passthrough of successful returns.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 32.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'refactor: extract API error handling into decorator' accurately summarizes the main change: extracting duplicated error-handling logic from three methods into a centralized decorator.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@garminconnect/__init__.py`:
- Around line 102-118: The decorator _handle_api_errors currently types
everything as Callable which loses the wrapped function signature; update its
typing to use typing.ParamSpec and a TypeVar for the return type so the
decorator preserves parameter and return info: add "P = ParamSpec('P')" and "R =
TypeVar('R')" to imports, change the decorator signature to "def
_handle_api_errors(label: str) -> Callable[[Callable[P, R]], Callable[P, R]]"
(or use def decorator(func: Callable[P, R]) -> Callable[P, R]) and type the
inner functions accordingly (reference symbols: _handle_api_errors, decorator,
func) so IDEs and static checkers retain exact call signatures.
- Around line 140-142: The generic except Exception in the error handling around
the request (in the block that logs "Connection error during %s path=%s" and
raises GarminConnectConnectionError) is catching and wrapping domain-specific
exceptions from _run_request (notably GarminConnectTooManyRequestsError and
GarminConnectAuthenticationError), breaking callers' ability to handle them.
Change the handler to only catch actual connection/HTTP-related exceptions
(e.g., requests.exceptions.RequestException or HTTPError) or explicitly re-raise
domain exceptions from GarminConnectTooManyRequestsError and
GarminConnectAuthenticationError so they propagate unchanged; update the except
clause around logger.exception/raise GarminConnectConnectionError in __init__.py
accordingly and reference _run_request in client.py to ensure domain errors are
not wrapped.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d9223405-0b22-4c08-b1af-ed29d682b757

📥 Commits

Reviewing files that changed from the base of the PR and between b1dfb0d and ab175da.

📒 Files selected for processing (1)
  • garminconnect/__init__.py

Comment thread garminconnect/__init__.py Outdated
Comment thread garminconnect/__init__.py
The previous `_handle_api_errors` decorator had a trailing
`except Exception` clause that swallowed the domain-specific
`GarminConnectAuthenticationError` and `GarminConnectTooManyRequestsError`
exceptions raised directly by `client._run_request` and the login flow,
re-wrapping them as generic `GarminConnectConnectionError` and losing
the specific error type.

Catch these two Garmin-specific exceptions first and re-raise them
unchanged so callers can still distinguish 401/429 from generic
connection errors. HTTPError / GarminConnectConnectionError handling
(including status-code mapping) is unchanged.

Also type the decorator with `ParamSpec` + `TypeVar` so wrapped methods
keep their exact signature for type-checkers, and add
`tests/test_error_handler_decorator.py` with 9 regression tests
covering the passthrough case, status-code mapping, and generic
exception wrapping.

Addresses CodeRabbit review on PR cyberjunky#352.
@sorlen008
Copy link
Copy Markdown
Contributor Author

Thanks @coderabbitai for catching the critical issue with exception handling — you were absolutely right.

The bug

The decorator's trailing except Exception clause was swallowing the domain-specific GarminConnectAuthenticationError and GarminConnectTooManyRequestsError exceptions that client._run_request (and the login flow in client.py) raise directly. Since those two exceptions subclass Exceptionnot HTTPError or GarminConnectConnectionError — they were being re-wrapped as generic GarminConnectConnectionError, silently losing the 401/429 signal that callers rely on to drive retry/backoff and re-auth logic.

The fix (commit e32fb1c)

Catch the two Garmin-specific exceptions first and re-raise them unchanged:

def decorator(func: Callable[P, R]) -> Callable[P, R]:
    @functools.wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        path = args[1] if len(args) > 1 else kwargs.get("path", "<unknown>")
        try:
            return func(*args, **kwargs)
        except (
            GarminConnectAuthenticationError,
            GarminConnectTooManyRequestsError,
        ):
            # Already domain-specific — let them propagate unchanged.
            raise
        except (HTTPError, GarminConnectConnectionError) as e:
            status = getattr(getattr(e, "response", None), "status_code", None)
            ...  # unchanged 401/429/4xx/other mapping
        except Exception as e:
            ...  # unchanged generic wrap

I also applied the ParamSpec + TypeVar nitpick since it was a clean one-liner — wrapped methods now keep their exact signature for type-checkers.

Verification

Added tests/test_error_handler_decorator.py with 9 regression tests covering every branch:

  • test_propagates_too_many_requests_unchanged — the exact bug you flagged
  • test_propagates_authentication_error_unchanged
  • test_http_401_maps_to_authentication_error
  • test_http_429_maps_to_too_many_requests
  • test_http_404_maps_to_client_error
  • test_http_500_maps_to_generic_http_error
  • test_existing_connection_error_without_status_still_wrapped
  • test_generic_exception_wrapped_as_connection_error
  • test_successful_call_returns_value_unchanged

Full suite: 28 passed, 2 skipped (the two skips are the pre-existing live Garmin VCR tests). ruff check and mypy both clean on the edited file.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 15, 2026

Tip

For best results, initiate chat on the files or code changes.

@sorlen008 — excellent work! The fix is exactly right: placing the two domain-specific exception types at the top of the except chain and re-raising them unchanged is the cleanest, most readable way to solve this, and it makes the intent immediately obvious to future readers.

The test suite additions are particularly thorough — test_propagates_too_many_requests_unchanged and test_propagates_authentication_error_unchanged as explicit regression tests for this exact bug are a great addition. Having all nine branches documented as test cases also means any future refactoring of this decorator will be well-guarded.

The ParamSpec + TypeVar improvement is a nice bonus — callers and type-checkers now see the wrapped methods' exact signatures rather than a generic Callable.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/test_error_handler_decorator.py`:
- Around line 36-133: Remove the return type hints ("-> None") from all test
function definitions in this file (e.g.
test_propagates_too_many_requests_unchanged,
test_propagates_authentication_error_unchanged,
test_http_401_maps_to_authentication_error,
test_http_429_maps_to_too_many_requests, test_http_404_maps_to_client_error,
test_http_500_maps_to_generic_http_error,
test_existing_connection_error_without_status_still_wrapped,
test_generic_exception_wrapped_as_connection_error,
test_successful_call_returns_value_unchanged) so they follow the tests/**
guideline; update each def signature to drop the "-> None" return annotation
while leaving the decorator `@_handle_api_errors`, parameters, and function body
unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5b9764f0-609b-4f1f-a7f7-4f67b2e8150a

📥 Commits

Reviewing files that changed from the base of the PR and between ab175da and e32fb1c.

📒 Files selected for processing (2)
  • garminconnect/__init__.py
  • tests/test_error_handler_decorator.py

Comment on lines +36 to +133
def test_propagates_too_many_requests_unchanged() -> None:
"""A ``GarminConnectTooManyRequestsError`` raised from inside the wrapped
function must propagate unchanged — not be wrapped as a generic
connection error. This is the exact bug from CodeRabbit on PR #352."""

@_handle_api_errors("API call")
def inner(self: object, path: str) -> None:
raise GarminConnectTooManyRequestsError("rate limited directly")

with pytest.raises(GarminConnectTooManyRequestsError) as excinfo:
inner(_Dummy(), "/foo")
assert "rate limited directly" in str(excinfo.value)


def test_propagates_authentication_error_unchanged() -> None:
"""A ``GarminConnectAuthenticationError`` raised from inside the wrapped
function must propagate unchanged."""

@_handle_api_errors("API call")
def inner(self: object, path: str) -> None:
raise GarminConnectAuthenticationError("auth failed directly")

with pytest.raises(GarminConnectAuthenticationError) as excinfo:
inner(_Dummy(), "/foo")
assert "auth failed directly" in str(excinfo.value)


def test_http_401_maps_to_authentication_error() -> None:
@_handle_api_errors("API call")
def inner(self: object, path: str) -> None:
raise _http_error(401, "unauthorized")

with pytest.raises(GarminConnectAuthenticationError) as excinfo:
inner(_Dummy(), "/foo")
assert "Authentication failed" in str(excinfo.value)


def test_http_429_maps_to_too_many_requests() -> None:
@_handle_api_errors("API call")
def inner(self: object, path: str) -> None:
raise _http_error(429, "slow down")

with pytest.raises(GarminConnectTooManyRequestsError) as excinfo:
inner(_Dummy(), "/foo")
assert "Rate limit exceeded" in str(excinfo.value)


def test_http_404_maps_to_client_error() -> None:
@_handle_api_errors("Download")
def inner(self: object, path: str) -> None:
raise _http_error(404, "nope")

with pytest.raises(GarminConnectConnectionError) as excinfo:
inner(_Dummy(), "/foo")
assert "Download client error (404)" in str(excinfo.value)


def test_http_500_maps_to_generic_http_error() -> None:
@_handle_api_errors("API call")
def inner(self: object, path: str) -> None:
raise _http_error(500, "server")

with pytest.raises(GarminConnectConnectionError) as excinfo:
inner(_Dummy(), "/foo")
assert "HTTP error" in str(excinfo.value)


def test_existing_connection_error_without_status_still_wrapped() -> None:
"""A plain ``GarminConnectConnectionError`` with no ``.response`` still
falls through to the HTTP-error wrap branch."""

@_handle_api_errors("API call")
def inner(self: object, path: str) -> None:
raise GarminConnectConnectionError("original message")

with pytest.raises(GarminConnectConnectionError) as excinfo:
inner(_Dummy(), "/foo")
assert "original message" in str(excinfo.value)


def test_generic_exception_wrapped_as_connection_error() -> None:
@_handle_api_errors("API call")
def inner(self: object, path: str) -> None:
raise ValueError("something unexpected")

with pytest.raises(GarminConnectConnectionError) as excinfo:
inner(_Dummy(), "/foo")
assert "Connection error" in str(excinfo.value)
assert "something unexpected" in str(excinfo.value)


def test_successful_call_returns_value_unchanged() -> None:
@_handle_api_errors("API call")
def inner(self: object, path: str, extra: int = 0) -> dict[str, int]:
return {"path": len(path), "extra": extra}

result = inner(_Dummy(), "/foo", extra=5)
assert result == {"path": 4, "extra": 5}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Remove return type hints from test functions.

Per coding guidelines for tests/**, test functions shouldn't have a return type hint. All test functions currently include -> None.

♻️ Proposed fix
-def test_propagates_too_many_requests_unchanged() -> None:
+def test_propagates_too_many_requests_unchanged():
     """A ``GarminConnectTooManyRequestsError`` raised from inside the wrapped
     ...

-def test_propagates_authentication_error_unchanged() -> None:
+def test_propagates_authentication_error_unchanged():
     """A ``GarminConnectAuthenticationError`` raised from inside the wrapped
     ...

-def test_http_401_maps_to_authentication_error() -> None:
+def test_http_401_maps_to_authentication_error():
     `@_handle_api_errors`("API call")
     ...

-def test_http_429_maps_to_too_many_requests() -> None:
+def test_http_429_maps_to_too_many_requests():
     `@_handle_api_errors`("API call")
     ...

-def test_http_404_maps_to_client_error() -> None:
+def test_http_404_maps_to_client_error():
     `@_handle_api_errors`("Download")
     ...

-def test_http_500_maps_to_generic_http_error() -> None:
+def test_http_500_maps_to_generic_http_error():
     `@_handle_api_errors`("API call")
     ...

-def test_existing_connection_error_without_status_still_wrapped() -> None:
+def test_existing_connection_error_without_status_still_wrapped():
     """A plain ``GarminConnectConnectionError`` with no ``.response`` still
     ...

-def test_generic_exception_wrapped_as_connection_error() -> None:
+def test_generic_exception_wrapped_as_connection_error():
     `@_handle_api_errors`("API call")
     ...

-def test_successful_call_returns_value_unchanged() -> None:
+def test_successful_call_returns_value_unchanged():
     `@_handle_api_errors`("API call")
     ...
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_propagates_too_many_requests_unchanged() -> None:
"""A ``GarminConnectTooManyRequestsError`` raised from inside the wrapped
function must propagate unchangednot be wrapped as a generic
connection error. This is the exact bug from CodeRabbit on PR #352."""
@_handle_api_errors("API call")
def inner(self: object, path: str) -> None:
raise GarminConnectTooManyRequestsError("rate limited directly")
with pytest.raises(GarminConnectTooManyRequestsError) as excinfo:
inner(_Dummy(), "/foo")
assert "rate limited directly" in str(excinfo.value)
def test_propagates_authentication_error_unchanged() -> None:
"""A ``GarminConnectAuthenticationError`` raised from inside the wrapped
function must propagate unchanged."""
@_handle_api_errors("API call")
def inner(self: object, path: str) -> None:
raise GarminConnectAuthenticationError("auth failed directly")
with pytest.raises(GarminConnectAuthenticationError) as excinfo:
inner(_Dummy(), "/foo")
assert "auth failed directly" in str(excinfo.value)
def test_http_401_maps_to_authentication_error() -> None:
@_handle_api_errors("API call")
def inner(self: object, path: str) -> None:
raise _http_error(401, "unauthorized")
with pytest.raises(GarminConnectAuthenticationError) as excinfo:
inner(_Dummy(), "/foo")
assert "Authentication failed" in str(excinfo.value)
def test_http_429_maps_to_too_many_requests() -> None:
@_handle_api_errors("API call")
def inner(self: object, path: str) -> None:
raise _http_error(429, "slow down")
with pytest.raises(GarminConnectTooManyRequestsError) as excinfo:
inner(_Dummy(), "/foo")
assert "Rate limit exceeded" in str(excinfo.value)
def test_http_404_maps_to_client_error() -> None:
@_handle_api_errors("Download")
def inner(self: object, path: str) -> None:
raise _http_error(404, "nope")
with pytest.raises(GarminConnectConnectionError) as excinfo:
inner(_Dummy(), "/foo")
assert "Download client error (404)" in str(excinfo.value)
def test_http_500_maps_to_generic_http_error() -> None:
@_handle_api_errors("API call")
def inner(self: object, path: str) -> None:
raise _http_error(500, "server")
with pytest.raises(GarminConnectConnectionError) as excinfo:
inner(_Dummy(), "/foo")
assert "HTTP error" in str(excinfo.value)
def test_existing_connection_error_without_status_still_wrapped() -> None:
"""A plain ``GarminConnectConnectionError`` with no ``.response`` still
falls through to the HTTP-error wrap branch."""
@_handle_api_errors("API call")
def inner(self: object, path: str) -> None:
raise GarminConnectConnectionError("original message")
with pytest.raises(GarminConnectConnectionError) as excinfo:
inner(_Dummy(), "/foo")
assert "original message" in str(excinfo.value)
def test_generic_exception_wrapped_as_connection_error() -> None:
@_handle_api_errors("API call")
def inner(self: object, path: str) -> None:
raise ValueError("something unexpected")
with pytest.raises(GarminConnectConnectionError) as excinfo:
inner(_Dummy(), "/foo")
assert "Connection error" in str(excinfo.value)
assert "something unexpected" in str(excinfo.value)
def test_successful_call_returns_value_unchanged() -> None:
@_handle_api_errors("API call")
def inner(self: object, path: str, extra: int = 0) -> dict[str, int]:
return {"path": len(path), "extra": extra}
result = inner(_Dummy(), "/foo", extra=5)
assert result == {"path": 4, "extra": 5}
def test_propagates_too_many_requests_unchanged():
"""A ``GarminConnectTooManyRequestsError`` raised from inside the wrapped
function must propagate unchangednot be wrapped as a generic
connection error. This is the exact bug from CodeRabbit on PR `#352`."""
`@_handle_api_errors`("API call")
def inner(self: object, path: str) -> None:
raise GarminConnectTooManyRequestsError("rate limited directly")
with pytest.raises(GarminConnectTooManyRequestsError) as excinfo:
inner(_Dummy(), "/foo")
assert "rate limited directly" in str(excinfo.value)
def test_propagates_authentication_error_unchanged():
"""A ``GarminConnectAuthenticationError`` raised from inside the wrapped
function must propagate unchanged."""
`@_handle_api_errors`("API call")
def inner(self: object, path: str) -> None:
raise GarminConnectAuthenticationError("auth failed directly")
with pytest.raises(GarminConnectAuthenticationError) as excinfo:
inner(_Dummy(), "/foo")
assert "auth failed directly" in str(excinfo.value)
def test_http_401_maps_to_authentication_error():
`@_handle_api_errors`("API call")
def inner(self: object, path: str) -> None:
raise _http_error(401, "unauthorized")
with pytest.raises(GarminConnectAuthenticationError) as excinfo:
inner(_Dummy(), "/foo")
assert "Authentication failed" in str(excinfo.value)
def test_http_429_maps_to_too_many_requests():
`@_handle_api_errors`("API call")
def inner(self: object, path: str) -> None:
raise _http_error(429, "slow down")
with pytest.raises(GarminConnectTooManyRequestsError) as excinfo:
inner(_Dummy(), "/foo")
assert "Rate limit exceeded" in str(excinfo.value)
def test_http_404_maps_to_client_error():
`@_handle_api_errors`("Download")
def inner(self: object, path: str) -> None:
raise _http_error(404, "nope")
with pytest.raises(GarminConnectConnectionError) as excinfo:
inner(_Dummy(), "/foo")
assert "Download client error (404)" in str(excinfo.value)
def test_http_500_maps_to_generic_http_error():
`@_handle_api_errors`("API call")
def inner(self: object, path: str) -> None:
raise _http_error(500, "server")
with pytest.raises(GarminConnectConnectionError) as excinfo:
inner(_Dummy(), "/foo")
assert "HTTP error" in str(excinfo.value)
def test_existing_connection_error_without_status_still_wrapped():
"""A plain ``GarminConnectConnectionError`` with no ``.response`` still
falls through to the HTTP-error wrap branch."""
`@_handle_api_errors`("API call")
def inner(self: object, path: str) -> None:
raise GarminConnectConnectionError("original message")
with pytest.raises(GarminConnectConnectionError) as excinfo:
inner(_Dummy(), "/foo")
assert "original message" in str(excinfo.value)
def test_generic_exception_wrapped_as_connection_error():
`@_handle_api_errors`("API call")
def inner(self: object, path: str) -> None:
raise ValueError("something unexpected")
with pytest.raises(GarminConnectConnectionError) as excinfo:
inner(_Dummy(), "/foo")
assert "Connection error" in str(excinfo.value)
assert "something unexpected" in str(excinfo.value)
def test_successful_call_returns_value_unchanged():
`@_handle_api_errors`("API call")
def inner(self: object, path: str, extra: int = 0) -> dict[str, int]:
return {"path": len(path), "extra": extra}
result = inner(_Dummy(), "/foo", extra=5)
assert result == {"path": 4, "extra": 5}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_error_handler_decorator.py` around lines 36 - 133, Remove the
return type hints ("-> None") from all test function definitions in this file
(e.g. test_propagates_too_many_requests_unchanged,
test_propagates_authentication_error_unchanged,
test_http_401_maps_to_authentication_error,
test_http_429_maps_to_too_many_requests, test_http_404_maps_to_client_error,
test_http_500_maps_to_generic_http_error,
test_existing_connection_error_without_status_still_wrapped,
test_generic_exception_wrapped_as_connection_error,
test_successful_call_returns_value_unchanged) so they follow the tests/**
guideline; update each def signature to drop the "-> None" return annotation
while leaving the decorator `@_handle_api_errors`, parameters, and function body
unchanged.

@cyberjunky
Copy link
Copy Markdown
Owner

@copilot resolve the merge conflicts in this pull request

@cyberjunky
Copy link
Copy Markdown
Owner

I have combined this with your other two PR's which had massive overlap.

@cyberjunky cyberjunky closed this Apr 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants