diff --git a/README.md b/README.md index e08f44c..bbc0391 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ On macOS and Windows, setup offers automatic sync every four hours after a succe ## Adding Strava -Strava requires an active subscription to create a new API application. This is [Strava's requirement](https://developers.strava.com/docs/getting-started/), and Garmin sync works without Strava. +Strava requires an active subscription to create an API application and keep it working. If the subscription lapses, Strava refuses the app with HTTP 403, and weight updates stop until you renew it. This is [Strava's requirement](https://developers.strava.com/docs/getting-started/), and Garmin sync works without Strava. 1. Create a Strava API app at . 2. Set **Authorization Callback Domain** to `localhost`. diff --git a/eufy_sync/__init__.py b/eufy_sync/__init__.py index 7f7bf67..ebc3551 100644 --- a/eufy_sync/__init__.py +++ b/eufy_sync/__init__.py @@ -1,6 +1,6 @@ """Sync Eufy smart scale body composition data to Garmin Connect and Strava.""" -__version__ = "1.15.1" +__version__ = "1.15.2" # Public API for programmatic use from eufy_sync.eufy_client import EufyClient, EufyMeasurement diff --git a/eufy_sync/garmin_client.py b/eufy_sync/garmin_client.py index 0f29dc0..4dd89ad 100644 --- a/eufy_sync/garmin_client.py +++ b/eufy_sync/garmin_client.py @@ -2,11 +2,13 @@ python-garminconnect; keeps a same-date duplicate check. The library logs in through curl_cffi but sends every data call through plain -requests, and on some networks (VPNs, datacenter IPs) Cloudflare refuses that -TLS fingerprint with a 403 even though the token is fine (upstream issue #444). -A call refused that way is sent once more through curl_cffi with a browser -fingerprint, reusing the session's token, before anything here treats the -session as dead. +requests. A data call refused with a 403 or a Cloudflare challenge is sent once +more through curl_cffi with a browser fingerprint, reusing the session's token, +before anything here treats the session as dead. That retry settles two causes +a fresh login cannot fix: a network that refuses the plain requests client, and +a `default` entry in ~/.netrc, which requests sends to every host in place of +the Bearer token (curl_cffi does not read netrc). The second is what upstream +issue #444 turned out to be. """ from __future__ import annotations @@ -130,9 +132,7 @@ def _timed_entries_at(entries: list[dict], uploaded_at: datetime) -> list[dict]: # or origin failure, where the request may well have reached Garmin. _CHALLENGE_STATUSES = (403, 429, 503) -# The browser fingerprint for the fallback. Issue #444's reporter got 200s -# with this one, the library's native headers, and the same token that plain -# requests could not use. +# The browser fingerprint the fallback presents. _IMPERSONATE = "chrome" @@ -150,12 +150,12 @@ def _is_garmin_auth_failure(exc: Exception) -> bool: the dedicated auth error and the 401/403 that the library reports as a generic connection error ("API Error 401 - ..."). - A 403 stays ambiguous even with the response in hand: the network block - in issue #444 answers with the same JSON ForbiddenException a refused - token gets. By the time a 403 reaches the relogin, the browser-fingerprint - retry has already failed, and a relogin keeps the stored token until the - new login succeeds, so a 403 that was only a passing block costs one - login attempt, not the session.""" + A 403 stays ambiguous even with the response in hand: a network block, or + a ~/.netrc entry that replaces the token, gets the same JSON + ForbiddenException a refused token gets. By the time a 403 reaches the + relogin, the browser-fingerprint retry has already failed, and a relogin + keeps the stored token until the new login succeeds, so a 403 that was + only a passing block costs one login attempt, not the session.""" if isinstance(exc, GarminConnectAuthenticationError): return True return isinstance(exc, GarminConnectConnectionError) and _status_code(exc) in (401, 403) @@ -386,10 +386,11 @@ def _refused_with_403(self, exc: Exception) -> bool: def _call_with_fallback(self, call): """Run call; when the network refused it, run it once more through a browser fingerprint. A Cloudflare challenge or block qualifies, and so - does any 403, because the #444 block answers with a JSON 403 that - looks exactly like a refused token. Nothing else does: a 5xx or a - network failure is never replayed here, since the request may have - reached Garmin and an upload would be sent twice. Trying the fingerprint first costs one + does any 403, because a network block or a ~/.netrc entry that + replaces the token answers with a JSON 403 that looks exactly like a + refused token. Nothing else does: a 5xx or a network failure is never + replayed here, since the request may have reached Garmin and an + upload would be sent twice. Trying the fingerprint first costs one request; a relogin costs a login and risks a 429, and on a blocked network its own token check fails the same way.""" if self._impersonate_always and self._can_impersonate(): diff --git a/eufy_sync/strava_client.py b/eufy_sync/strava_client.py index cbf6f72..341ef99 100644 --- a/eufy_sync/strava_client.py +++ b/eufy_sync/strava_client.py @@ -257,9 +257,19 @@ def _refresh_access_token(self) -> None: def check_connection(self) -> None: """Verify the saved session with a read-only athlete request.""" resp = self._api_request("GET", "/athlete") - if resp.status_code in (401, 403): + if resp.status_code == 401: from eufy_sync.sync import PermanentSyncError raise PermanentSyncError("Strava rejected the session. Run: eufy-sync --setup-strava") + if resp.status_code == 403: + # Strava blocks an API app whose owner has no subscription. The + # message must not name --setup-strava: doctor would offer it as + # the fix, and signing in again cannot help. + from eufy_sync.sync import PermanentSyncError + raise PermanentSyncError( + "Strava returned HTTP 403 because the account that owns your Strava API app " + "likely has no active subscription; Strava has required one for API apps " + "since June 2026. Signing in again won't fix it." + ) resp.raise_for_status() def update_weight(self, weight_kg: float) -> dict: @@ -277,6 +287,13 @@ def update_weight(self, weight_kg: float) -> dict: if resp.status_code != 200: logger.debug("Strava weight update failed: %d %s", resp.status_code, resp.text) # 4xx (except 429) won't recover by retrying; signal that to _retry. + if resp.status_code == 403: + from eufy_sync.sync import PermanentSyncError + raise PermanentSyncError( + "Failed to update Strava weight (HTTP 403). The account that owns the " + "API app may need its Strava subscription renewed. If the subscription " + "is active, run eufy-sync --setup-strava and allow profile edits." + ) if 400 <= resp.status_code < 500 and resp.status_code != 429: from eufy_sync.sync import PermanentSyncError raise PermanentSyncError( diff --git a/pyproject.toml b/pyproject.toml index 9b11912..07f5405 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "eufy-sync" -version = "1.15.1" +version = "1.15.2" description = "Sync Eufy smart scale data to Garmin Connect, Strava, and Zwift" readme = "README.md" license = "MIT" @@ -36,7 +36,7 @@ classifiers = [ dependencies = [ "httpx>=0.27.0", "keyring>=25.0.0", - "garminconnect>=0.3.10", + "garminconnect>=0.3.20", "curl_cffi>=0.17.0", "pyyaml>=6.0", ] diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 9a94245..eda7113 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -529,3 +529,20 @@ def test_garmin_and_strava_skipped_when_not_configured(tmp_path, monkeypatch, ca assert "garmin session" not in out assert "strava token" not in out assert code == 0 + + +def test_strava_403_fails_without_offering_a_new_sign_in(tmp_path, monkeypatch, capsys): + config_path, db_path = _patch_all_pass(tmp_path, monkeypatch) + doctor.StravaClient.return_value.check_connection.side_effect = RuntimeError( + "Strava returned HTTP 403 because the account that owns your Strava API app " + "likely has no active subscription; Strava has required one for API apps " + "since June 2026. Signing in again won't fix it." + ) + + code = doctor._run_doctor(config_path, db_path) + + out = capsys.readouterr().out + assert code == 1 + assert "strava token" in out + assert "subscription" in out + assert "fix: eufy-sync --setup-strava" not in out diff --git a/tests/test_garmin_client.py b/tests/test_garmin_client.py index 4cbd373..e0cf46e 100644 --- a/tests/test_garmin_client.py +++ b/tests/test_garmin_client.py @@ -599,7 +599,7 @@ def test_old_session_still_serves_calls_after_a_failed_relogin(): # --------------------------------------------------------------------------- -# Cloudflare blocks and the browser-fingerprint fallback (upstream issue #444) +# Cloudflare blocks, JSON 403s, and the browser-fingerprint fallback # # These run the real library against fake transports: a requests adapter for # the library's own API session and a fake curl_cffi session for the fallback. @@ -614,8 +614,9 @@ def test_old_session_still_serves_calls_after_a_failed_relogin(): b"", ) CF_MITIGATED = (403, {"Content-Type": "text/html", "cf-mitigated": "challenge"}, b"") -# Issue #444's block: the same JSON a refused token gets, behind the same -# Cloudflare headers every API answer carries. +# A network block, or a ~/.netrc entry that replaces the token (upstream issue +# #444): the same JSON a refused token gets, behind the same Cloudflare headers +# every API answer carries. JSON_403 = ( 403, {"Content-Type": "application/json", "Server": "cloudflare", "CF-RAY": "8f1-EWR"}, @@ -734,9 +735,9 @@ def test_cloudflare_page_on_upload_retries_through_curl_cffi_without_relogin(): def test_json_403_on_a_read_tries_the_fingerprint_before_any_relogin(): - # Issue #444: the block is a JSON 403. The fingerprint retry is one - # request; a relogin is a login, a 429 risk, and on that network its own - # token check fails the same way. + # A block or a ~/.netrc entry answers with a JSON 403. The fingerprint + # retry is one request; a relogin is a login, a 429 risk, and in either + # case its own token check fails the same way. garmin, _ = _real_garmin([JSON_403]) client = _client_on(garmin) cffi = _FakeCffi([_ok({"dateWeightList": [{"weight": 86000}]})]) diff --git a/tests/test_strava_client.py b/tests/test_strava_client.py index 01c8013..51b18ee 100644 --- a/tests/test_strava_client.py +++ b/tests/test_strava_client.py @@ -389,3 +389,44 @@ def test_oauth_urls_stay_on_www_strava_com_after_the_move(): from eufy_sync.strava_client import STRAVA_AUTH_URL, STRAVA_TOKEN_URL assert STRAVA_AUTH_URL == "https://www.strava.com/oauth/authorize" assert STRAVA_TOKEN_URL == "https://www.strava.com/oauth/token" + + +def test_check_connection_401_asks_for_a_new_sign_in(): + from eufy_sync.sync import PermanentSyncError + client = _client_on(date(2026, 11, 2)) + with patch.object(client._client, "request", return_value=_status(401)): + with pytest.raises(PermanentSyncError, match="--setup-strava"): + client.check_connection() + client.close() + + +def test_check_connection_403_points_at_the_subscription_not_a_sign_in(): + # doctor offers any --setup-strava it finds in the message as the fix, and + # signing in again cannot unblock an app whose owner has no subscription. + from eufy_sync.sync import PermanentSyncError + client = _client_on(date(2026, 11, 2)) + with patch.object(client._client, "request", return_value=_status(403)): + with pytest.raises(PermanentSyncError) as excinfo: + client.check_connection() + message = str(excinfo.value) + assert "HTTP 403" in message + assert "subscription" in message + assert "--setup-strava" not in message + client.close() + + +@patch("eufy_sync.strava_client._save_tokens") +@patch("eufy_sync.strava_client._load_tokens") +def test_update_weight_403_names_both_causes(mock_load, mock_save): + from eufy_sync.sync import PermanentSyncError + mock_load.return_value = _make_tokens() + client = StravaClient(_make_config()) + client.authenticate() + with patch.object(client._client, "request", return_value=_status(403)): + with pytest.raises(PermanentSyncError) as excinfo: + client.update_weight(86.2) + message = str(excinfo.value) + assert message.startswith("Failed to update Strava weight (HTTP 403).") + assert "subscription" in message + assert "eufy-sync --setup-strava" in message + client.close()