#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 用量采集的公共骨架 —— 本地缓存 + 有效期(TTL)+ 失败退避(Claude / Codex 两个采集器共用)。 为什么需要:launchd 每 5 分钟起两个独立进程(小屏渲染 + 上报),且用户可能手动连跑几次; 账户级用量接口对突发调用敏感(Anthropic 的 /api/oauth/usage 一分钟内几十次即 429 并封约 24 小时, 连带 Claude Code `/usage` 与 claude.ai 一起看不到用量)。故: - 距上次成功拉取不足 TTL 秒(CLAUDE_USAGE_TTL / CODEX_USAGE_TTL,语义同 WEATHER_TTL/MAO_TTL/BTC_TTL,0=每次实拉)→ 直接用缓存,不发请求; - 拉取失败 → 沿用旧缓存(标 stale),并按 BACKOFF 秒退避,退避期内不再尝试;异常对象带 `backoff` 属性时按它退避 (claude_usage.TokenUnavailable = 一个 TTL:token 过期是本机状态、Claude Code 随时可能刷新,不必等满 BACKOFF); - 从未成功且本次失败 → 抛出,由上层回退占位 "--"。 缓存文件只存快照与时间戳,**不存任何凭证**。 """ import json import os import time def cached_snapshot(path, ttl, backoff, fetch, force=False): """返回 (snapshot, status)。status ∈ {"fetched", "cache", "stale", "backoff"}。 fetch() 需返回可 JSON 序列化的快照 dict(含 updated_at)。""" state = _load(path) now = time.time() snap = state.get("snapshot") if not force and snap and now - state.get("fetched_at", 0) < ttl: return snap, "cache" if not force and state.get("backoff_until", 0) > now: if snap: return snap, "backoff" raise RuntimeError(f"退避期内(至 {time.strftime('%H:%M:%S', time.localtime(state['backoff_until']))}),且无旧快照") try: snap_new = fetch() except Exception as e: state["backoff_until"] = now + getattr(e, "backoff", backoff) # 异常可自带更短退避(如本机 token 过期,只需等一个 TTL) state["last_error"] = f"{type(e).__name__}: {e}"[:300] _save(path, state) if snap: print(f"[用量] 拉取失败,沿用旧快照:{e}") return snap, "stale" raise state = {"fetched_at": now, "snapshot": snap_new} _save(path, state) return snap_new, "fetched" def _load(path): try: with open(path, encoding="utf-8") as f: return json.load(f) except Exception: return {} def _save(path, state): tmp = path + ".tmp" try: with open(tmp, "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False) os.replace(tmp, path) except Exception as e: # 缓存写不进去不算致命 print(f"[用量] 缓存写入失败:{e}")