usage-cli 0.29.32__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- adapters/__init__.py +5 -0
- adapters/agy.py +68 -0
- adapters/claude.py +215 -0
- adapters/codex.py +209 -0
- adapters/rate_limits.py +76 -0
- adapters/registry.py +17 -0
- adapters/types.py +139 -0
- agy_disk_cache.py +135 -0
- agy_loader.py +416 -0
- agy_quota_probe.py +748 -0
- agy_window_keeper.py +185 -0
- analyzer/__init__.py +5 -0
- analyzer/aggregator.py +139 -0
- analyzer/blocks.py +80 -0
- analyzer/diagnoser.py +638 -0
- analyzer/insights.py +277 -0
- analyzer/persona_loader.py +199 -0
- analyzer/reporter.py +989 -0
- analyzer/subscription.py +108 -0
- burn_rate.py +75 -0
- cache_quarantine.py +50 -0
- codex_disk_cache.py +227 -0
- codex_events.py +136 -0
- codex_fork_replay.py +111 -0
- codex_loader.py +1426 -0
- codex_paths.py +20 -0
- critter_frames.py +26 -0
- discussion_bridge.py +1196 -0
- discussion_cli.py +844 -0
- discussion_session.py +622 -0
- discussion_usage.py +13 -0
- discussion_window.py +955 -0
- disk_cache_common.py +132 -0
- disk_cache_lifecycle.py +39 -0
- doctor.py +452 -0
- fsevents_watch.py +207 -0
- history_disk_cache.py +110 -0
- history_loader.py +416 -0
- i18n.py +88 -0
- jsonl_limits.py +17 -0
- jsonl_utils.py +40 -0
- login_item.py +154 -0
- main.py +387 -0
- menubar.py +1201 -0
- menubar_actions.py +204 -0
- menubar_agy.py +193 -0
- menubar_chrome.py +156 -0
- menubar_menu.py +169 -0
- menubar_notify.py +102 -0
- menubar_popover.py +233 -0
- menubar_prefs.py +118 -0
- menubar_refresh.py +285 -0
- menubar_state.py +1200 -0
- menubar_title.py +157 -0
- menubar_update.py +123 -0
- panel_window.py +78 -0
- panel_window_state.py +159 -0
- panels/__init__.py +186 -0
- panels/base.py +83 -0
- panels/dynamic_height.py +140 -0
- panels/payload.py +178 -0
- panels/web_panel.py +513 -0
- panels/window_drag.py +56 -0
- prefs.py +44 -0
- pricing.py +452 -0
- project_resolver.py +112 -0
- service_status.py +383 -0
- session_hooks.py +1154 -0
- setup_app.py +171 -0
- setup_hook.py +1011 -0
- statusline_settings.py +160 -0
- talent_market_bridge.py +243 -0
- time_utils.py +24 -0
- tui.py +288 -0
- tui_sprite.py +206 -0
- ui/__init__.py +5 -0
- ui/html_report.py +923 -0
- ui/report_scripts.py +251 -0
- ui/report_styles.py +370 -0
- ui/tables.py +888 -0
- update_checker.py +156 -0
- update_gate.py +66 -0
- update_release_notes.py +49 -0
- usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
- usage_cli-0.29.32.dist-info/METADATA +223 -0
- usage_cli-0.29.32.dist-info/RECORD +109 -0
- usage_cli-0.29.32.dist-info/WHEEL +5 -0
- usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
- usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
- usage_cli-0.29.32.dist-info/top_level.txt +80 -0
- usage_cli.py +827 -0
- usage_client.py +487 -0
- usage_diagnosis_snapshot.py +143 -0
- usage_dir_sweeper.py +100 -0
- usage_lang.py +79 -0
- usage_logging.py +75 -0
- usage_notifications.py +96 -0
- usage_rate.py +97 -0
- usage_session_resume.py +913 -0
- usage_statusline.py +810 -0
- usage_statusline_agy.py +397 -0
- usage_statusline_forwarder.py +88 -0
- usage_terse_mode.py +223 -0
- usage_terse_reminder.py +151 -0
- win_login_item.py +53 -0
- window_keeper.py +264 -0
- windows_watch.py +443 -0
- wintray.py +2014 -0
- wintray_menu.py +136 -0
service_status.py
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
|
+
|
|
7
|
+
"""Read public service-status feeds, never LLM usage APIs.
|
|
8
|
+
|
|
9
|
+
This module downloads only public Statuspage JSON. It does not call an LLM
|
|
10
|
+
usage API and therefore does not collect or infer account usage.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import tempfile
|
|
20
|
+
import time
|
|
21
|
+
import urllib.request
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from datetime import UTC, datetime
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Literal
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
CACHE_TTL_SECONDS = 300
|
|
30
|
+
FAILURE_RETRY_SECONDS = 60
|
|
31
|
+
MONITORING_SETTLED_SECONDS = 4 * 3600
|
|
32
|
+
OBSERVED_STALE_SECONDS = 24 * 3600
|
|
33
|
+
SUPPRESSIBLE_STATUSES = ("degraded_performance",)
|
|
34
|
+
_CLOSED_INCIDENTS = frozenset({"resolved", "postmortem"})
|
|
35
|
+
USER_AGENT = "usage/0.9"
|
|
36
|
+
# Statuspage summaries are tens of KB; cap the read so a broken endpoint cannot
|
|
37
|
+
# make us buffer an unbounded response.
|
|
38
|
+
MAX_RESPONSE_BYTES = 4 * 1024 * 1024
|
|
39
|
+
ALERT_STATE_PATH = Path(os.path.expanduser("~/.usage/service_alert_state.json"))
|
|
40
|
+
|
|
41
|
+
StatusSource = Literal["fetched", "cache", "stale", "fallback"]
|
|
42
|
+
_SEVERITY = {
|
|
43
|
+
"operational": 0,
|
|
44
|
+
"degraded_performance": 1,
|
|
45
|
+
"partial_outage": 2,
|
|
46
|
+
"major_outage": 3,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class ServiceStatusConfig:
|
|
52
|
+
"""The public status-page details for one tool."""
|
|
53
|
+
|
|
54
|
+
service_name: str
|
|
55
|
+
status_url: str
|
|
56
|
+
incidents_url: str
|
|
57
|
+
component_names: tuple[str, ...]
|
|
58
|
+
cache_path: Path
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Both feeds use components.json, not summary.json. Statuspage's summary payload
|
|
62
|
+
# is truncated to the first 25 components by position: OpenAI publishes 34, so
|
|
63
|
+
# "Codex API" (position 27) is silently absent from it and _build_status() can
|
|
64
|
+
# only ever report "unknown". components.json returns the full list and carries
|
|
65
|
+
# the same top-level "components" key, so the parser is unchanged. Anthropic
|
|
66
|
+
# publishes 6 today and is unaffected, but it uses the same endpoint so a future
|
|
67
|
+
# component of theirs cannot quietly fall off the end either.
|
|
68
|
+
CLAUDE_STATUS = ServiceStatusConfig(
|
|
69
|
+
service_name="Claude",
|
|
70
|
+
status_url="https://status.claude.com/api/v2/components.json",
|
|
71
|
+
incidents_url="https://status.claude.com/api/v2/incidents.json",
|
|
72
|
+
component_names=("Claude Code", "Claude API (api.anthropic.com)"),
|
|
73
|
+
cache_path=Path(os.path.expanduser("~/.usage/anthropic_status_cache.json")),
|
|
74
|
+
)
|
|
75
|
+
CODEX_STATUS = ServiceStatusConfig(
|
|
76
|
+
service_name="Codex",
|
|
77
|
+
status_url="https://status.openai.com/api/v2/components.json",
|
|
78
|
+
incidents_url="https://status.openai.com/api/v2/incidents.json",
|
|
79
|
+
# Do not include shared OpenAI API components (for example Responses): they
|
|
80
|
+
# affect all API users and do not necessarily affect the Codex CLI. Nor the
|
|
81
|
+
# "Codex Web" / "Codex in ChatGPT Desktop" components, which track surfaces
|
|
82
|
+
# the CLI does not use.
|
|
83
|
+
component_names=("Codex API",),
|
|
84
|
+
cache_path=Path(os.path.expanduser("~/.usage/openai_status_cache.json")),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class ServiceStatus:
|
|
90
|
+
"""Service state relevant to one supported tool."""
|
|
91
|
+
|
|
92
|
+
service_name: str
|
|
93
|
+
is_abnormal: bool
|
|
94
|
+
status: str
|
|
95
|
+
description: str
|
|
96
|
+
source: StatusSource
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_last_failure_at: dict[str, float] = {}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_service_status(config: ServiceStatusConfig) -> ServiceStatus:
|
|
103
|
+
"""Return the worst status among this tool's relevant components."""
|
|
104
|
+
cached = _read_cache(config)
|
|
105
|
+
if cached is not None:
|
|
106
|
+
return _build_status(config, cached, "cache")
|
|
107
|
+
|
|
108
|
+
stale_cached = _read_cache(config, allow_stale=True)
|
|
109
|
+
if _retry_is_delayed(config):
|
|
110
|
+
return _status_from_stale_or_fallback(config, stale_cached)
|
|
111
|
+
|
|
112
|
+
payload = _fetch_status(config)
|
|
113
|
+
if payload is not None:
|
|
114
|
+
status = _build_component_status(config, payload, "fetched")
|
|
115
|
+
if status.is_abnormal and status.status in SUPPRESSIBLE_STATUSES:
|
|
116
|
+
incidents = _fetch_incidents(config)
|
|
117
|
+
if incidents is not None:
|
|
118
|
+
payload["incidents"] = incidents
|
|
119
|
+
_write_cache(config, payload)
|
|
120
|
+
_clear_failure_retry(config)
|
|
121
|
+
return _apply_alert_suppression(config, payload, status)
|
|
122
|
+
|
|
123
|
+
_record_failure(config)
|
|
124
|
+
return _status_from_stale_or_fallback(config, stale_cached)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _status_from_stale_or_fallback(
|
|
128
|
+
config: ServiceStatusConfig, payload: dict[str, Any] | None
|
|
129
|
+
) -> ServiceStatus:
|
|
130
|
+
if payload is not None:
|
|
131
|
+
return _build_status(config, payload, "stale")
|
|
132
|
+
return ServiceStatus(config.service_name, False, "unknown", "Status unavailable.", "fallback")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _build_status(
|
|
136
|
+
config: ServiceStatusConfig, payload: dict[str, Any], source: StatusSource
|
|
137
|
+
) -> ServiceStatus:
|
|
138
|
+
status = _build_component_status(config, payload, source)
|
|
139
|
+
return _apply_alert_suppression(config, payload, status)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _build_component_status(
|
|
143
|
+
config: ServiceStatusConfig, payload: dict[str, Any], source: StatusSource
|
|
144
|
+
) -> ServiceStatus:
|
|
145
|
+
components = payload.get("components")
|
|
146
|
+
if not isinstance(components, list):
|
|
147
|
+
return ServiceStatus(config.service_name, False, "unknown", "Status unavailable.", source)
|
|
148
|
+
|
|
149
|
+
component_statuses = {
|
|
150
|
+
component.get("name"): component.get("status")
|
|
151
|
+
for component in components
|
|
152
|
+
if isinstance(component, dict)
|
|
153
|
+
and isinstance(component.get("name"), str)
|
|
154
|
+
and isinstance(component.get("status"), str)
|
|
155
|
+
}
|
|
156
|
+
statuses = tuple(component_statuses.get(name) for name in config.component_names)
|
|
157
|
+
valid_statuses = tuple(
|
|
158
|
+
status for status in statuses if isinstance(status, str) and status in _SEVERITY
|
|
159
|
+
)
|
|
160
|
+
if len(valid_statuses) != len(config.component_names):
|
|
161
|
+
return ServiceStatus(config.service_name, False, "unknown", "Status unavailable.", source)
|
|
162
|
+
|
|
163
|
+
worst_status = max(valid_statuses, key=lambda status: _SEVERITY[status])
|
|
164
|
+
affected = [
|
|
165
|
+
name
|
|
166
|
+
for name, status in zip(config.component_names, valid_statuses, strict=True)
|
|
167
|
+
if status != "operational"
|
|
168
|
+
]
|
|
169
|
+
if not affected:
|
|
170
|
+
status = ServiceStatus(
|
|
171
|
+
config.service_name, False, worst_status, "Relevant components are operational.", source
|
|
172
|
+
)
|
|
173
|
+
else:
|
|
174
|
+
status = ServiceStatus(
|
|
175
|
+
config.service_name,
|
|
176
|
+
True,
|
|
177
|
+
worst_status,
|
|
178
|
+
f"{', '.join(affected)}: {worst_status}.",
|
|
179
|
+
source,
|
|
180
|
+
)
|
|
181
|
+
return status
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _apply_alert_suppression(
|
|
185
|
+
config: ServiceStatusConfig, payload: dict[str, Any], status: ServiceStatus
|
|
186
|
+
) -> ServiceStatus:
|
|
187
|
+
observed_stale = _observe_status(config.service_name, status.status)
|
|
188
|
+
if not status.is_abnormal or status.status not in SUPPRESSIBLE_STATUSES:
|
|
189
|
+
return status
|
|
190
|
+
|
|
191
|
+
incidents = payload.get("incidents")
|
|
192
|
+
if isinstance(incidents, list):
|
|
193
|
+
incident_statuses = [
|
|
194
|
+
incident.get("status") for incident in incidents if isinstance(incident, dict)
|
|
195
|
+
]
|
|
196
|
+
if any(value in {"investigating", "identified"} for value in incident_statuses):
|
|
197
|
+
return status
|
|
198
|
+
|
|
199
|
+
if incidents and len(incident_statuses) == len(incidents) and all(
|
|
200
|
+
value == "monitoring" for value in incident_statuses
|
|
201
|
+
):
|
|
202
|
+
updated_times: list[datetime] = []
|
|
203
|
+
for incident in incidents:
|
|
204
|
+
updated_at = incident.get("updated_at")
|
|
205
|
+
if not isinstance(updated_at, str):
|
|
206
|
+
break
|
|
207
|
+
try:
|
|
208
|
+
updated_time = datetime.fromisoformat(updated_at)
|
|
209
|
+
except ValueError:
|
|
210
|
+
break
|
|
211
|
+
if updated_time.tzinfo is None:
|
|
212
|
+
break
|
|
213
|
+
updated_times.append(updated_time)
|
|
214
|
+
if len(updated_times) == len(incidents):
|
|
215
|
+
latest_update = max(updated_times)
|
|
216
|
+
age_seconds = (datetime.now(UTC) - latest_update).total_seconds()
|
|
217
|
+
if age_seconds > MONITORING_SETTLED_SECONDS:
|
|
218
|
+
return ServiceStatus(
|
|
219
|
+
status.service_name,
|
|
220
|
+
False,
|
|
221
|
+
status.status,
|
|
222
|
+
"Alert suppressed: all incidents have remained in monitoring "
|
|
223
|
+
"for more than 4 hours.",
|
|
224
|
+
status.source,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
if observed_stale:
|
|
228
|
+
return ServiceStatus(
|
|
229
|
+
status.service_name,
|
|
230
|
+
False,
|
|
231
|
+
status.status,
|
|
232
|
+
"Alert suppressed: this status has been observed unchanged for more than 24 hours.",
|
|
233
|
+
status.source,
|
|
234
|
+
)
|
|
235
|
+
return status
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _observe_status(service_name: str, status: str) -> bool:
|
|
239
|
+
state = _read_alert_state()
|
|
240
|
+
now = time.time()
|
|
241
|
+
previous = state.get(service_name)
|
|
242
|
+
if (
|
|
243
|
+
isinstance(previous, dict)
|
|
244
|
+
and previous.get("status") == status
|
|
245
|
+
and isinstance(previous.get("first_seen_at"), int | float)
|
|
246
|
+
and not isinstance(previous.get("first_seen_at"), bool)
|
|
247
|
+
):
|
|
248
|
+
first_seen_at = float(previous["first_seen_at"])
|
|
249
|
+
return now - first_seen_at > OBSERVED_STALE_SECONDS
|
|
250
|
+
|
|
251
|
+
state[service_name] = {"status": status, "first_seen_at": now}
|
|
252
|
+
_write_alert_state(state)
|
|
253
|
+
return False
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _read_alert_state() -> dict[str, Any]:
|
|
257
|
+
try:
|
|
258
|
+
with ALERT_STATE_PATH.open(encoding="utf-8") as file:
|
|
259
|
+
state = json.load(file)
|
|
260
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
261
|
+
logger.debug("failed to read service alert state: %s", exc)
|
|
262
|
+
return {}
|
|
263
|
+
return state if isinstance(state, dict) else {}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _write_alert_state(state: dict[str, Any]) -> None:
|
|
267
|
+
tmp_path: str | None = None
|
|
268
|
+
try:
|
|
269
|
+
ALERT_STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
270
|
+
fd, tmp_path = tempfile.mkstemp(dir=ALERT_STATE_PATH.parent, suffix=".tmp")
|
|
271
|
+
with os.fdopen(fd, "w", encoding="utf-8") as file:
|
|
272
|
+
json.dump(state, file, ensure_ascii=False, indent=2, sort_keys=True)
|
|
273
|
+
os.replace(tmp_path, ALERT_STATE_PATH)
|
|
274
|
+
tmp_path = None
|
|
275
|
+
except OSError as exc:
|
|
276
|
+
logger.warning("failed to write service alert state: %s", exc)
|
|
277
|
+
finally:
|
|
278
|
+
if tmp_path is not None:
|
|
279
|
+
with contextlib.suppress(OSError):
|
|
280
|
+
os.unlink(tmp_path)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _read_cache(
|
|
284
|
+
config: ServiceStatusConfig, *, allow_stale: bool = False
|
|
285
|
+
) -> dict[str, Any] | None:
|
|
286
|
+
try:
|
|
287
|
+
if not allow_stale and (
|
|
288
|
+
time.time() - config.cache_path.stat().st_mtime
|
|
289
|
+
) > CACHE_TTL_SECONDS:
|
|
290
|
+
return None
|
|
291
|
+
with config.cache_path.open(encoding="utf-8") as file:
|
|
292
|
+
payload = json.load(file)
|
|
293
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
294
|
+
logger.debug("failed to read %s status cache: %s", config.service_name, exc)
|
|
295
|
+
return None
|
|
296
|
+
return payload if isinstance(payload, dict) else None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _fetch_status(config: ServiceStatusConfig) -> dict[str, Any] | None:
|
|
300
|
+
request = urllib.request.Request(config.status_url, headers={"User-Agent": USER_AGENT})
|
|
301
|
+
try:
|
|
302
|
+
with urllib.request.urlopen(request, timeout=10) as response:
|
|
303
|
+
raw = response.read(MAX_RESPONSE_BYTES + 1)
|
|
304
|
+
if len(raw) > MAX_RESPONSE_BYTES:
|
|
305
|
+
raise ValueError("status response exceeds the size limit")
|
|
306
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
307
|
+
except (OSError, TimeoutError, UnicodeDecodeError, ValueError) as exc:
|
|
308
|
+
logger.warning(
|
|
309
|
+
"failed to fetch %s status from %s: %s",
|
|
310
|
+
config.service_name,
|
|
311
|
+
config.status_url,
|
|
312
|
+
exc,
|
|
313
|
+
)
|
|
314
|
+
return None
|
|
315
|
+
return payload if isinstance(payload, dict) else None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# Both feeds use incidents.json, not incidents/unresolved.json: OpenAI's status
|
|
319
|
+
# page answers 404 for the unresolved endpoint (verified 2026-08-13), and its
|
|
320
|
+
# summary.json has no "incidents" key at all, so this is the only incident
|
|
321
|
+
# source that works for Codex. Anthropic serves both; using one endpoint for
|
|
322
|
+
# both keeps a single payload shape.
|
|
323
|
+
def _fetch_incidents(config: ServiceStatusConfig) -> list[Any] | None:
|
|
324
|
+
"""Fetch unresolved incidents without affecting component-status availability."""
|
|
325
|
+
request = urllib.request.Request(config.incidents_url, headers={"User-Agent": USER_AGENT})
|
|
326
|
+
try:
|
|
327
|
+
with urllib.request.urlopen(request, timeout=10) as response:
|
|
328
|
+
raw = response.read(MAX_RESPONSE_BYTES + 1)
|
|
329
|
+
if len(raw) > MAX_RESPONSE_BYTES:
|
|
330
|
+
raise ValueError("incidents response exceeds the size limit")
|
|
331
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
332
|
+
except (OSError, TimeoutError, UnicodeDecodeError, ValueError) as exc:
|
|
333
|
+
logger.debug(
|
|
334
|
+
"failed to fetch %s unresolved incidents from %s: %s",
|
|
335
|
+
config.service_name,
|
|
336
|
+
config.incidents_url,
|
|
337
|
+
exc,
|
|
338
|
+
)
|
|
339
|
+
return None
|
|
340
|
+
if not isinstance(payload, dict):
|
|
341
|
+
return None
|
|
342
|
+
incidents = payload.get("incidents")
|
|
343
|
+
if not isinstance(incidents, list):
|
|
344
|
+
return None
|
|
345
|
+
# incidents.json carries resolved history too. Drop the closed ones by
|
|
346
|
+
# denylist rather than keeping an allowlist of open states: an unfamiliar
|
|
347
|
+
# status then survives the filter and blocks suppression, which errs
|
|
348
|
+
# towards showing the banner.
|
|
349
|
+
return [
|
|
350
|
+
incident
|
|
351
|
+
for incident in incidents
|
|
352
|
+
if not (isinstance(incident, dict) and incident.get("status") in _CLOSED_INCIDENTS)
|
|
353
|
+
]
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _write_cache(config: ServiceStatusConfig, payload: dict[str, Any]) -> None:
|
|
357
|
+
tmp_path: str | None = None
|
|
358
|
+
try:
|
|
359
|
+
config.cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
360
|
+
fd, tmp_path = tempfile.mkstemp(dir=config.cache_path.parent, suffix=".tmp")
|
|
361
|
+
with os.fdopen(fd, "w", encoding="utf-8") as file:
|
|
362
|
+
json.dump(payload, file, ensure_ascii=False, indent=2, sort_keys=True)
|
|
363
|
+
os.replace(tmp_path, config.cache_path)
|
|
364
|
+
tmp_path = None
|
|
365
|
+
except OSError as exc:
|
|
366
|
+
logger.warning("failed to write %s status cache: %s", config.service_name, exc)
|
|
367
|
+
finally:
|
|
368
|
+
if tmp_path is not None:
|
|
369
|
+
with contextlib.suppress(OSError):
|
|
370
|
+
os.unlink(tmp_path)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _retry_is_delayed(config: ServiceStatusConfig) -> bool:
|
|
374
|
+
failed_at = _last_failure_at.get(config.service_name)
|
|
375
|
+
return failed_at is not None and time.monotonic() - failed_at < FAILURE_RETRY_SECONDS
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _record_failure(config: ServiceStatusConfig) -> None:
|
|
379
|
+
_last_failure_at[config.service_name] = time.monotonic()
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _clear_failure_retry(config: ServiceStatusConfig) -> None:
|
|
383
|
+
_last_failure_at.pop(config.service_name, None)
|