fix: 本地限流估算不再拦截同步——一律真实请求 Garmin,仅真实 429 记录冷却并退避

此前 sync_data/start_sync/_connect/scheduler 四处会在请求前按本地
rate_limited_until 估算直接拒绝同步,用户看到'立即同步→被限流'实际是
本地拦截、零请求。若 Garmin 已恢复,陈旧估算会让账号一直闲置。

2026-09-02 起冷却只是信息不是闸门:
- start_sync / sync_data 开头移除本地拒绝,_connect 移除冷却提前抛错,
  scheduler 不再跳过冷却中的账号
- 真实 429(refresh_oauth2 / 拉取中途)仍 _note_rate_limit 写 24h 冷却
  并 stand down,中途退避分支保留用冷却算恢复时间
- 成功收尾 _clear_rate_limit 退休陈旧冷却,避免误导后续诊断
- 测试:blocked→仍会真实请求;stale cooldown→healthy connect 放行;
  真实 429 mid-run 仍记 rate_limited;成功清除冷却(596 passed)
This commit is contained in:
ericwyuan
2026-09-03 07:06:12 +08:00
parent 7e8e376a6c
commit e0e7b3cf53
3 changed files with 110 additions and 76 deletions

View File

@@ -185,11 +185,15 @@ class RateLimited(RuntimeError):
"""
# Retrying while rate limited is what deepens the limit, so once Garmin says
# 429 the whole process stands down until this passes. The account was stuck for
# days because the backoff kept expiring before Garmin's own (multi-hour) window
# closed, so every tick re-hit it and the limit never lifted. A 24h stand-down
# is what actually outlasts the throttle and lets the window close for good.
# Garmin answering 429 is what deepens the limit: the account once stayed
# stuck for days because every retry re-hit the throttle before its own
# (multi-hour) window closed. We therefore record a 24h cooldown when a 429
# actually arrives, so the UI and the sync history can say when to expect
# recovery. The cooldown is *information*, not a gate: since 2026-09-02 the
# local estimate is no longer used to refuse a sync before it starts — every
# entry point (manual, 立即同步 and the automatic scheduler alike) issues a
# real request and trusts Garmin's live answer. Only a real 429 writes a new
# cooldown; a stale local estimate must never keep a healthy account idle.
RATE_LIMIT_BACKOFF = datetime.timedelta(hours=24)
# In-process cache of the cooldown, kept in sync with the DB copy below and
# still the lever the tests reach for via _rate_limited_until.clear().
@@ -263,11 +267,29 @@ def _note_rate_limit(user_id):
)
def _rate_limit_block(user_id):
"""If the account is cooling down, return (until, message); else (None, None).
def _clear_rate_limit(user_id):
"""Drop a recorded cooldown after a sync that actually succeeded.
Central guard used by both the manual and scheduled sync entry points, so a
blocked account issues zero Garmin requests until the window closes.
A live success is proof Garmin stopped throttling, so the estimate has
served its purpose; leaving a future deadline behind would just mislead
the next diagnosis.
"""
_rate_limited_until.pop(user_id, None)
try:
execute(
"UPDATE sync_status SET rate_limited_until = NULL "
"WHERE user_id = ?", [user_id],
)
except Exception: # noqa: BLE001 - clearing is best-effort
pass
def _rate_limit_block(user_id):
"""Human-readable recovery estimate from the *recorded* cooldown.
Only meaningful right after a real 429 wrote a fresh cooldown (mid-run
stand-down). It is not a pre-request gate — nothing consults the cooldown
before calling Garmin any more.
"""
until = rate_limited_until(user_id)
if not until or datetime.datetime.utcnow() >= until:
@@ -457,13 +479,6 @@ def _connect(creds, user_id=None):
client.garth.loads(token)
_use_api_user_agent(client)
blocked = rate_limited_until(user_id)
if blocked and datetime.datetime.utcnow() < blocked:
raise RateLimited(
"Garmin 暂时限制了请求频率,稍后会自动恢复(约 "
f"{max(1, int((blocked - datetime.datetime.utcnow()).total_seconds() // 60))} 分钟)。"
)
# Only when it has actually expired. Refreshing on every connect spends
# quota for nothing, and that is what walked the account into a 429.
oauth2 = getattr(client.garth, "oauth2_token", None)
@@ -1047,33 +1062,13 @@ def start_sync(user_id, creds, days=None):
Progress lands in sync_status, which the UI polls; a full backfill runs
far longer than any sensible HTTP timeout.
No local rate-limit estimate is consulted here: every manual start issues
a real request and trusts Garmin's live answer. A real 429 writes the
cooldown and the run stands down inside sync_data.
"""
days = DEFAULT_SYNC_DAYS if days is None else days
now = datetime.datetime.utcnow().isoformat(timespec="seconds")
until, msg = _rate_limit_block(user_id)
if until:
# A refused start synced nothing — keep the previous last_sync_time so
# the UI's 上次同步 keeps telling the truth (the data is stale since
# the last *successful* sync, and that date is what the user needs).
row = query_one(
"SELECT last_sync_time FROM sync_status WHERE user_id = ?", [user_id]
)
_set_sync_status(
user_id, "rate_limited", now,
last_sync_time=row["last_sync_time"] if row else None,
records_synced=0, progress_current=0, progress_total=days,
started_at=now, last_error=msg,
)
_log_sync_history(user_id, "manual", days, now, {
"status": "rate_limited",
"recordsSynced": 0,
"message": msg,
})
return {
"status": "rate_limited",
"message": msg,
"retryAfterSeconds": int((until - datetime.datetime.utcnow()).total_seconds()),
}
_set_sync_status(
user_id, "syncing", now,
records_synced=0, progress_current=0, progress_total=days,
@@ -1104,25 +1099,10 @@ def sync_data(user_id, creds, days=None, client=None, trigger="manual"):
_log_sync_history(user_id, trigger, days, started, result)
return result
until, msg = _rate_limit_block(user_id)
if until:
# A blocked account must issue zero Garmin requests — that is the whole
# point. Return immediately without touching the network, and keep the
# previous last_sync_time (this was not a sync).
row = query_one(
"SELECT last_sync_time FROM sync_status WHERE user_id = ?", [user_id]
)
_set_sync_status(
user_id, "rate_limited", now,
last_sync_time=row["last_sync_time"] if row else None,
records_synced=0, last_error=msg,
)
return finish({
"status": "rate_limited",
"recordsSynced": 0,
"message": msg,
"retryAfterSeconds": int((until - datetime.datetime.utcnow()).total_seconds()),
})
# No local cooldown gate here (2026-09-02): the recorded rate_limited_until
# is an estimate, and refusing on it kept accounts idle after Garmin had
# already recovered. Every run issues a real request; only a real 429
# stands the run down (mid-run, below) and writes a fresh cooldown.
_set_sync_status(
user_id, "syncing", now,
records_synced=0, progress_current=0, progress_total=days,
@@ -1331,6 +1311,9 @@ def sync_data(user_id, creds, days=None, client=None, trigger="manual"):
progress_current=days, progress_total=days, stage=None,
last_error="; ".join(day_errors[:3]) if day_errors else None,
)
# A live success proves Garmin stopped throttling — retire any recorded
# cooldown so it cannot mislead a later diagnosis.
_clear_rate_limit(user_id)
message = (
f"同步完成,更新 {days_synced} 天数据、{activities_synced} 条运动记录"
f"(含 {details_synced} 条详情)、"