Skip to content

Retry preauthorized on 401/429 with backoff#214

Closed
matin wants to merge 8 commits intomainfrom
retry-preauthorized
Closed

Retry preauthorized on 401/429 with backoff#214
matin wants to merge 8 commits intomainfrom
retry-preauthorized

Conversation

@matin
Copy link
Copy Markdown
Owner

@matin matin commented Mar 18, 2026

Summary

The preauthorized endpoint intermittently returns 401 or 429 even with correct Android SSO constants. Retry up to 3 times with linear backoff (1s, 2s) before failing.

Test plan

  • 163 tests pass

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved OAuth token retrieval with automatic retries and backoff on transient authentication failures, increasing reliability.
  • New Features

    • Telemetry now includes a hashed public IP (with safe fallback) computed once per session to avoid repeated network calls.
  • Tests

    • Added tests validating OAuth retry behavior and error handling.
  • Chores

    • Bumped package version to 0.7.10.

The preauthorized endpoint intermittently returns 401 or 429. Retry
up to 3 times with linear backoff (1s, 2s) before failing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 18, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds retry-with-backoff to get_oauth1_token(ticket, client, retries=3) for transient HTTP failures (401, 429); introduces Telemetry.ip_hash (lazy SHA-256 of public IP, cached) and _fetch_ip_hash(); bumps package version to 0.7.10; adds tests for retry success and retry-failure scenarios.

Changes

Cohort / File(s) Summary
OAuth1 Token Retry Logic
src/garth/sso.py
Updated get_oauth1_token(ticket, client, retries: int = 3) -> OAuth1Token. Adds retry loop (normalizes retries ≥1) with incremental backoff (time.sleep) for transient responses (401, 429); raises on final failure; retains token parsing on success.
Telemetry: IP hashing
src/garth/telemetry.py
Added cached self._ip_hash and public ip_hash property; added @staticmethod _fetch_ip_hash() that GETs https://api.ipify.org (3s timeout), SHA-256-hashes the IP and returns first 16 hex chars, falling back to "unknown" on error. _response_hook now includes ip_hash in telemetry payload.
Version Bump
src/garth/version.py
Incremented __version__ from "0.7.9" to "0.7.10".
Tests: SSO & Telemetry
tests/test_sso.py, tests/test_telemetry.py
Added SSO retry tests: test_get_oauth1_token_retries_on_401 (two 401s then 200) and test_get_oauth1_token_raises_after_retries (persistent 401); tests patch session get and time.sleep. Minor telemetry test sets authed_client.telemetry._ip_hash = "test".

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested reviewers

  • felipao-mx
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.08% 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 accurately describes the main change: implementing retry logic for the OAuth1 token retrieval function when receiving 401/429 responses with backoff intervals.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch retry-preauthorized
📝 Coding Plan
  • Generate coding plan for human review comments

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.

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 18, 2026

Codecov Report

❌ Patch coverage is 87.50000% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 99.86%. Comparing base (a3bac03) to head (e7b56ba).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
src/garth/telemetry.py 85.71% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #214      +/-   ##
==========================================
- Coverage   99.91%   99.86%   -0.06%     
==========================================
  Files          68       68              
  Lines        3566     3581      +15     
==========================================
+ Hits         3563     3576      +13     
- Misses          3        5       +2     
Flag Coverage Δ
unittests 99.86% <87.50%> (-0.06%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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 `@src/garth/sso.py`:
- Around line 148-150: The get_oauth1_token function can leave resp undefined
when retries <= 0; add an upfront guard in get_oauth1_token that validates the
retries parameter (e.g., ensure retries is an int >= 1 or raise a ValueError)
before entering the retry loop so resp is always assigned; alternatively clamp
retries to a minimum of 1 at function start and proceed—this prevents the
UnboundLocalError when later referencing resp.
🪄 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: CHILL

Plan: Pro

Run ID: 4f47eee5-c735-43e9-acf9-522229d4ed6b

📥 Commits

Reviewing files that changed from the base of the PR and between a3bac03 and 49c4f1d.

📒 Files selected for processing (2)
  • src/garth/sso.py
  • src/garth/version.py

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 `@tests/test_sso.py`:
- Around line 202-224: Update the test_get_oauth1_token_raises_after_retries
test to verify that retries actually occurred: instrument the mocked
GarminOAuth1Session.get (or wrap mock_resp) with a call counter (e.g., use a
MagicMock for get or attach side_effect that increments) and patch time.sleep so
you can assert it was called the expected number of times; after invoking
sso.get_oauth1_token("ticket", client) inside the pytest.raises block, assert
the mocked get call count and the patched time.sleep call count reflect the
retry attempts (reference GarminOAuth1Session.get, sso.get_oauth1_token, and
time.sleep) so the test fails if the function raises on the first 401.
- Around line 170-200: Extend the test for get_oauth1_token to also simulate a
429 response and assert the linear backoff sleeps: create a mock_resp_429
(ok=False, status_code=429) and adjust mock_get to return 429 on the first call,
401 on the second, and the successful 200 on the third; patch
sso.GarminOAuth1Session.get to use this sequence, patch time.sleep (e.g., with a
MagicMock) and after calling sso.get_oauth1_token assert that time.sleep was
called with the expected linear delays (for two retries, e.g., 1 then 2) and
that call_count reached 3 and the returned token is correct.
🪄 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: CHILL

Plan: Pro

Run ID: 9e64b483-bfaa-4e51-8310-a18bed63691a

📥 Commits

Reviewing files that changed from the base of the PR and between 49c4f1d and 8555845.

📒 Files selected for processing (1)
  • tests/test_sso.py

matin and others added 2 commits March 18, 2026 05:32
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fetch public IP once at session start via ipify.org and include it
in every span. Helps diagnose IP-based issues with Garmin's SSO
(per cyberjunky/python-garminconnect#332). Skipped when telemetry
is disabled.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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: 3

🧹 Nitpick comments (1)
src/garth/telemetry.py (1)

149-184: Split _response_hook into smaller helpers.

This method is doing extraction, sanitization, payload assembly, and dispatch/error isolation in one block; please split into helper methods to keep it within the file’s readability rule.

As per coding guidelines, **/*.py: “No function should require scrolling to read”.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/garth/telemetry.py` around lines 149 - 184, The _response_hook is too
large; split its responsibilities into small helpers: implement a helper like
_extract_request_response(response) that returns a raw dict with keys method,
url, status_code, request, response, headers, body and uses response.request,
and another helper _sanitize_payload(raw_dict) that calls sanitize_headers,
sanitize and decodes bytes to produce the final payload including session_id,
__version__, and _public_ip, and a _dispatch_telemetry(payload) that invokes
self.callback or self._default_callback; then reduce _response_hook to calling
these three helpers inside a minimal try/except so telemetry errors don’t bubble
up. Ensure you reference existing symbols _default_callback, sanitize,
sanitize_headers, callback, session_id, __version__, and _public_ip when
building helpers so behavior remains identical.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/garth/telemetry.py`:
- Around line 126-128: Telemetry currently performs a blocking network call
during construction by setting self._public_ip = self._fetch_public_ip() in
Telemetry.__init__, which adds startup latency; remove that eager call and
instead initialize self._public_ip to a sentinel (e.g., None or "disabled") and
lazily call and cache _fetch_public_ip() the first time a telemetry event is
prepared/sent (provide a helper like _get_public_ip() or call _fetch_public_ip()
inside the existing event creation path), ensuring you catch exceptions and
apply a short timeout so failures don’t block the caller; update references to
self._public_ip accordingly so initialization is non-blocking and the actual
fetch happens only on first event send.
- Around line 130-139: Change the broad except in _fetch_public_ip to only catch
network-related errors: import requests (or keep the in-try import but add a
separate except ImportError) and replace "except Exception" with "except
_requests.RequestException" so only request/network failures are swallowed and
other bugs surface; ensure the name _requests is available in the except by
importing requests before the try or by handling ImportError separately, and
keep returning "unknown" on request failures.
- Line 161: The telemetry payload currently includes raw public IP via
self._public_ip; change the default behavior to avoid emitting raw IP by either
removing self._public_ip from the default payload and replacing it with an
anonymized value (e.g., salted hash or truncated form) or require explicit
opt-in before emitting the raw IP; update the code that builds/sends the payload
(look for the Telemetry class and the method that constructs the payload
containing "public_ip") to emit only the anonymized token by default and ensure
config defaults (enabled=True/send_to_logfire=True) do not cause raw IP export.

---

Nitpick comments:
In `@src/garth/telemetry.py`:
- Around line 149-184: The _response_hook is too large; split its
responsibilities into small helpers: implement a helper like
_extract_request_response(response) that returns a raw dict with keys method,
url, status_code, request, response, headers, body and uses response.request,
and another helper _sanitize_payload(raw_dict) that calls sanitize_headers,
sanitize and decodes bytes to produce the final payload including session_id,
__version__, and _public_ip, and a _dispatch_telemetry(payload) that invokes
self.callback or self._default_callback; then reduce _response_hook to calling
these three helpers inside a minimal try/except so telemetry errors don’t bubble
up. Ensure you reference existing symbols _default_callback, sanitize,
sanitize_headers, callback, session_id, __version__, and _public_ip when
building helpers so behavior remains identical.
🪄 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: CHILL

Plan: Pro

Run ID: 0695b246-0cb8-4a31-a7bc-4f9d502bc5df

📥 Commits

Reviewing files that changed from the base of the PR and between 6e3c7bf and e9c40cf.

📒 Files selected for processing (1)
  • src/garth/telemetry.py

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

♻️ Duplicate comments (1)
src/garth/telemetry.py (1)

141-142: ⚠️ Potential issue | 🟠 Major

Follow-up: hashing the full IP is still reversible for IPv4.

This is still a stable pseudonymous identifier, not anonymous telemetry; truncating SHA-256 to 16 hex chars does not materially change that. If this must stay anonymous by default, hash a coarser network bucket or make the field opt-in. As per coding guidelines, src/garth/telemetry.py requires DEFAULT_TOKEN telemetry to be "sanitized, anonymous trace data".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/garth/telemetry.py` around lines 141 - 142, The current code fetches the
full public IP via _req.get("https://api.ipify.org") and hashes it (then
truncates) which still yields a stable pseudonymous identifier; instead, in
src/garth/telemetry.py change the logic around the IP collection in the same
function/section to derive a coarse network bucket (e.g., mask IPv4 to /24 or
IPv6 to /64) before hashing, or remove automatic collection and make the
telemetry field opt-in by default (tied to DEFAULT_TOKEN behavior); ensure you
update the code that reads ip, the hashing path, and any DEFAULT_TOKEN default
so the stored value is sanitized and non-identifying.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/garth/telemetry.py`:
- Around line 163-166: The telemetry code eagerly evaluates self.ip_hash while
building the data dict, causing an unnecessary network call; change it so
ip_hash is only resolved when needed by the chosen send path: either compute
ip_hash lazily (turn self.ip_hash into a property that caches on first access)
or move the lookup into the branches that actually send to Logfire (check
send_to_logfire flag and the selected callback) and only add "ip_hash" to the
data dict in those branches; reference the existing self.ip_hash access and the
send logic (send_to_logfire / logfire callback / the telemetry send function)
and ensure any custom callback path can opt out without triggering the lookup.

---

Duplicate comments:
In `@src/garth/telemetry.py`:
- Around line 141-142: The current code fetches the full public IP via
_req.get("https://api.ipify.org") and hashes it (then truncates) which still
yields a stable pseudonymous identifier; instead, in src/garth/telemetry.py
change the logic around the IP collection in the same function/section to derive
a coarse network bucket (e.g., mask IPv4 to /24 or IPv6 to /64) before hashing,
or remove automatic collection and make the telemetry field opt-in by default
(tied to DEFAULT_TOKEN behavior); ensure you update the code that reads ip, the
hashing path, and any DEFAULT_TOKEN default so the stored value is sanitized and
non-identifying.
🪄 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: CHILL

Plan: Pro

Run ID: 6553981b-68e3-445d-b8fd-c7535481648f

📥 Commits

Reviewing files that changed from the base of the PR and between e9c40cf and 281ba8a.

📒 Files selected for processing (2)
  • src/garth/telemetry.py
  • tests/test_telemetry.py
✅ Files skipped from review due to trivial changes (1)
  • tests/test_telemetry.py

- SHA256 hash (first 16 chars) instead of raw IP for privacy
- Lazy fetch on first telemetry event (not at construction)
- Catch RequestException only, not all exceptions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@matin matin force-pushed the retry-preauthorized branch from 281ba8a to 270cbfb Compare March 18, 2026 13:23
matin and others added 3 commits March 18, 2026 06:32
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Data shows retries never succeed on preauthorized — 0/12 for 401,
0/0 for 429. Remove the manual retry loop and revert status_forcelist.

Keep: public IP and OS in telemetry spans for diagnosing IP-based
Garmin SSO issues.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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.

1 participant