Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://www.strava.com/settings/api>.
2. Set **Authorization Callback Domain** to `localhost`.
Expand Down
2 changes: 1 addition & 1 deletion eufy_sync/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
37 changes: 19 additions & 18 deletions eufy_sync/garmin_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"


Expand All @@ -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)
Expand Down Expand Up @@ -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():
Expand Down
19 changes: 18 additions & 1 deletion eufy_sync/strava_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
]
Expand Down
17 changes: 17 additions & 0 deletions tests/test_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 7 additions & 6 deletions tests/test_garmin_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -614,8 +614,9 @@ def test_old_session_still_serves_calls_after_a_failed_relogin():
b"<body><script src='/cdn-cgi/challenge-platform/h/b/orchestrate'></script></body></html>",
)
CF_MITIGATED = (403, {"Content-Type": "text/html", "cf-mitigated": "challenge"}, b"<html></html>")
# 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"},
Expand Down Expand Up @@ -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}]})])
Expand Down
41 changes: 41 additions & 0 deletions tests/test_strava_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading