Files
hermes-agent/hermes_cli/observability/shared_metrics_sender.py
teknium1 398234748f refactor(agent): one Retry-After parser and one reset-grammar table feed every retry wait
Seven sites hand-rolled `float(headers.get("Retry-After"))` (anon_auth,
shared_metrics_sender, gemini_native_adapter, extract_api_error_context,
nous_rate_guard, skills_hub_github, skills_hub_clawhub x2) and silently
dropped RFC 7231 HTTP-date values that the conversation loop already honours
via agent/retry_utils.py::parse_retry_after_seconds. They now call it; per-site
caps/floors stay at the call site.

The free-text "resets in / quotaResetDelay / retry after N s" regexes lived in
two tables (agent_runtime_helpers vs credential_pool) whose "resets in"
grammars diverged: the pool accepted only integer `Nhr Nmin` while the error
context accepted h/hr/hours + m/min/minutes + s/seconds with decimals. One table
(agent/retry_utils.py::RETRY_DELAY_PATTERNS / reset_delay_from_message) using
the wider grammar, so a pooled credential's cooldown and the UI's reset time
now agree.
2026-09-13 05:09:43 -07:00

527 lines
21 KiB
Python

"""Transmit exported shared-metrics packages to the Nous telemetry service.
Sender side of the ingest contract (telemetry repo ``CONTRACT.md``): ``202`` durably stored,
mark sent; ``400`` permanently malformed, never retry; ``429`` keep, retry after
``Retry-After``; ``5xx`` / timeout / connection error keep, retry with backoff.
"""
from __future__ import annotations
import gzip
import json
import logging
import random
import sqlite3
import time
import urllib.error
import urllib.request
import uuid
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from agent.retry_utils import parse_retry_after_seconds
from hermes_cli.sqlite_util import write_txn
from .shared_metrics import _isoformat, _utc_now
logger = logging.getLogger(__name__)
#: Contract recommends timing out at 30s and treating a timeout as retryable.
REQUEST_TIMEOUT_SECONDS = 30
#: In-process attempts per package per pass, then the package waits for a
#: later pass. Backoff is 1s/5s/25s with full jitter.
MAX_ATTEMPTS = 3
_BACKOFF_BASE_SECONDS = 1
_BACKOFF_FACTOR = 5
#: Contract recommends gzip above roughly this size.
GZIP_THRESHOLD_BYTES = 4096
#: Packages per pass. Bounds work on an interactive hook even after an outage.
MAX_PACKAGES_PER_PASS = 20
#: Claim lease, written INTO THE FUTURE as next_attempt_at so no other process selects
#: the row meanwhile. Must exceed one package's worst case (3x30s timeouts + 1s+5s
#: backoff, ~96s) — hence packages are claimed one at a time right before sending; a
#: batch of 20 under one lease can run ~1900s and get re-sent by another process.
_CLAIM_LEASE_SECONDS = 300
#: Floor applied after a pass fails to deliver, so a hard-down service is not
#: retried on every task completion.
_FAILURE_BACKOFF_SECONDS = 15 * 60
#: Permanent statuses per the ingest contract, deliberately narrow: 400 never validates,
#: 413 (over the 1 MiB cap) cannot shrink on retry. Everything else — including 403 from
#: the origin guard and 404 from a bad path — is deployment/edge misconfiguration that
#: resolves without the package changing, so it is retried.
_PERMANENT_STATUSES = frozenset({400, 413})
#: Attempts after which a package is abandoned; otherwise a poisoned row is retried until
#: 30-day retention deletes it (~160 requests) and pins the head of the queue.
MAX_SEND_ATTEMPTS = 25
#: Max distance one reconcile can advance the 'obs' mark. Never binds for honest
#: heartbeats (hours apart); bounds FORWARD clock poison, where one glitched sample (NTP
#: flap reading 2099) would drag every window open and confirmation horizon decades ahead.
MAX_OBS_ADVANCE_SECONDS = 30 * 24 * 3600
def _parse_stamp(value: str) -> datetime:
"""Parse a stamp this module itself wrote (Z-suffixed ISO-8601, UTC)."""
return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc)
@dataclass
class SendOutcome:
"""What one pass did. Returned for tests and diagnostics."""
sent: int = 0
rejected: int = 0
deferred: int = 0
@dataclass(slots=True)
class _Response:
status: int
retry_after: str | None
body: str
def _post(endpoint: str, payload: bytes, *, timeout: int) -> _Response:
"""POST one package. Raises on transport failure; never on HTTP status."""
headers = {
"Content-Type": "application/json",
"User-Agent": "hermes-agent-shared-metrics/1",
}
body = payload
if len(payload) > GZIP_THRESHOLD_BYTES:
# mtime=0 keeps two sends of one package byte-identical on the wire too.
body = gzip.compress(payload, mtime=0)
headers["Content-Encoding"] = "gzip"
request = urllib.request.Request(endpoint, data=body, headers=headers, method="POST")
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return _Response(
response.status,
response.headers.get("Retry-After"),
response.read(2048).decode("utf-8", "replace"),
)
except urllib.error.HTTPError as exc:
# An HTTP error status is a normal contract outcome, not a failure.
return _Response(
exc.code,
exc.headers.get("Retry-After") if exc.headers else None,
exc.read(2048).decode("utf-8", "replace") if exc.fp else "",
)
def _retry_after_seconds(value: str | None, default: int) -> int:
seconds = parse_retry_after_seconds(value)
if seconds is None:
return default
# Clamp so a bogus value cannot park a package for years, and never go below one second.
return max(1, min(int(seconds), 86_400))
def reconcile_send_consent(
connection: sqlite3.Connection,
send_enabled: bool,
*,
now: datetime | None = None,
) -> None:
"""Reconcile the consent-window table with the observed config state.
THE ONLY writer of consent state; must run inside a write transaction. Idempotent in
(config, now, store): call it from anywhere, any number of times, in any order.
"""
stamp = _isoformat(now or _utc_now())
raw_stamp = stamp # pre-cap observation time, used to clamp closes
previous_obs = connection.execute(
"SELECT stamp FROM consent_marks WHERE name = 'obs'"
).fetchone()
if previous_obs is not None:
ceiling = _isoformat(
_parse_stamp(str(previous_obs[0])) + timedelta(seconds=MAX_OBS_ADVANCE_SECONDS)
)
stamp = min(stamp, ceiling)
connection.execute(
"""
INSERT INTO consent_marks(name, stamp) VALUES ('obs', ?)
ON CONFLICT(name) DO UPDATE SET stamp = MAX(stamp, excluded.stamp)
""",
(stamp,),
)
marks = dict(connection.execute("SELECT name, stamp FROM consent_marks").fetchall())
obs = marks["obs"] # >= stamp; immune to clock rollback
data = marks.get("data")
open_row = connection.execute(
"SELECT rowid FROM send_consent_windows WHERE closed_at IS NULL"
).fetchone()
if send_enabled:
if open_row is None:
opened = max(x for x in (obs, data) if x is not None)
connection.execute(
"INSERT INTO send_consent_windows(opened_at, last_confirmed_at)"
" VALUES (?, ?)",
(opened, opened),
)
else:
connection.execute(
"UPDATE send_consent_windows"
" SET last_confirmed_at = MAX(last_confirmed_at, ?)"
" WHERE rowid = ?",
(obs, open_row[0]),
)
elif open_row is not None:
# Close at the last CONFIRMED moment, never after the closing observation's RAW
# stamp. Both clamps are load-bearing: last_confirmed_at means an unobserved gap
# (machine off, hand-edited config) is never asserted as consented; the raw
# (pre-cap) stamp pulls a glitched-forward last_confirmed_at back to the true
# revoke moment. A rolled-back clock only closes EARLIER — fail-closed.
connection.execute(
"UPDATE send_consent_windows"
" SET closed_at = MIN(last_confirmed_at, ?)"
" WHERE rowid = ?",
(raw_stamp, open_row[0]),
)
#: Claim-time consent predicate: the package's period must fall entirely inside SOME
#: recorded window. An open window vouches only up to its last confirmed moment, so a
#: package running past it waits for the next reconcile heartbeat (fail-closed).
CONSENT_GATE_SQL = """EXISTS (
SELECT 1 FROM send_consent_windows w
WHERE package_outbox.period_start >= w.opened_at
AND package_outbox.period_end <=
CASE WHEN w.closed_at IS NULL THEN w.last_confirmed_at
ELSE w.closed_at END
)"""
class SharedMetricsSender:
"""Sends exported packages, one bounded pass at a time."""
def __init__(
self,
store,
endpoint: str,
*,
post=_post,
sleep=time.sleep,
now=_utc_now,
max_attempts: int = MAX_ATTEMPTS,
consent_check=None,
) -> None:
self._store = store
self._endpoint = endpoint
self._post = post
self._sleep = sleep
self._now = now
self._max_attempts = max_attempts
# Called before every package. None disables the check for callers that have
# already established consent out of band (tests, E2E).
self._consent_check = consent_check
@contextmanager
def _write(self):
"""One store connection inside a write transaction."""
with self._store._connection() as connection:
with write_txn(connection):
yield connection
# -- selection ---------------------------------------------------------
def _claim_next(self, now: datetime, seen: set[str]) -> dict | None:
"""Claim exactly ONE package, immediately before it is sent (see _CLAIM_LEASE_SECONDS).
``seen`` (packages this pass finished with) is excluded IN SQL: with LIMIT 1,
returning None for a seen row would look like an empty queue and abandon everything
behind it, and rows can legitimately become eligible again mid-pass.
"""
with self._write() as connection:
stamp = _isoformat(now)
lease_until = now + timedelta(seconds=_CLAIM_LEASE_SECONDS)
placeholders = ",".join("?" for _ in seen)
exclusion = f" AND package_id NOT IN ({placeholders})" if seen else ""
# Consent is a READ here: the claim must never mutate the window table.
row = connection.execute(
f"""
SELECT package_id, payload_json, sent_install_id
FROM package_outbox
WHERE exported_at IS NOT NULL
AND (send_state IS NULL OR send_state = 'pending')
AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
AND {CONSENT_GATE_SQL}
AND send_attempts < ?
{exclusion}
ORDER BY created_at, package_id
LIMIT 1
""",
(stamp, MAX_SEND_ATTEMPTS, *sorted(seen)),
).fetchone()
if row is None:
return None
package_id = str(row[0])
derived = row[2] or self._freeze_identity(connection, package_id, row[1])
if derived is None:
# Unusable row, already marked rejected. Tell the caller to continue.
return {"package_id": package_id, "skip": True}
token = str(uuid.uuid4())
connection.execute(
"""
UPDATE package_outbox
SET send_state = 'pending',
send_attempts = send_attempts + 1,
next_attempt_at = ?,
claim_token = ?
WHERE package_id = ?
""",
# The token is this claim's identity: a reclaim after expiry mints a new one
# and every later write by THIS claimant is compare-and-set against it.
(_isoformat(lease_until), token, package_id),
)
return {
"package_id": package_id, "payload_json": str(row[1]), "derived": str(derived),
"claim_token": token, "skip": False,
}
@staticmethod
def _freeze_identity(
connection: sqlite3.Connection, package_id: str, payload_json
) -> str | None:
"""Record the transmitted install_id on the row, or reject an unusable one.
Rejecting rather than raising matters: an exception would roll back the claim
transaction and block every healthy package behind this one.
"""
install_id = None
try:
payload = json.loads(payload_json)
except (TypeError, ValueError):
reason = "unreadable payload"
else:
# Valid JSON is not enough: a top-level array/string/number parses cleanly.
install_id = payload.get("install_id") if isinstance(payload, dict) else None
if not isinstance(payload, dict):
reason = f"payload is {type(payload).__name__}, expected object"
elif not isinstance(install_id, str) or not install_id.strip():
reason = "payload has no usable install_id"
else:
reason = None
if reason is not None:
logger.warning("Shared-metrics package %s cannot be sent (%s)", package_id, reason)
connection.execute(
"""
UPDATE package_outbox
SET send_state = 'rejected', last_error = ?
WHERE package_id = ?
""",
(reason, package_id),
)
return None
connection.execute(
"UPDATE package_outbox SET sent_install_id = ? WHERE package_id = ?",
(install_id, package_id),
)
return str(install_id)
# -- transmission ------------------------------------------------------
@staticmethod
def _body(payload_json: str, transmitted_id: str) -> bytes:
"""Rebuild the exact bytes to send: deterministic json.dumps of the stored package
with install_id from the frozen ``sent_install_id`` column (resends are byte-identical)."""
payload = dict(json.loads(payload_json))
payload["install_id"] = transmitted_id
return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
def _mark(self, package_id: str, *, token: str | None = None, **columns) -> None:
"""Write send state for one package.
Guarded on send_state so a lapsed pass cannot resurrect a row another process already
finished. With ``token`` the write is also compare-and-set on claim_token: a
superseded claimant writes zero rows.
"""
assignments = ", ".join(f"{name} = ?" for name in columns)
predicate = " AND (send_state IS NULL OR send_state = 'pending')"
params: list = [*columns.values(), package_id]
if token is not None:
predicate += " AND claim_token = ?"
params.append(token)
with self._write() as connection:
connection.execute(
f"UPDATE package_outbox SET {assignments} WHERE package_id = ?{predicate}",
params,
)
def _renew_claim(self, package_id: str, token: str | None) -> bool:
"""Atomically re-assert ownership and extend the lease (CAS, one row; rowcount == 1
is the only grant).
A read-only check is not enough: a claimant whose lease expired while suspended still
has its token in the row and would POST while another process reclaims. Renewing in
the same CAS keeps the 30s POST inside the 300s lease.
"""
if token is None:
return False
try:
now = self._now()
lease_until = now + timedelta(seconds=_CLAIM_LEASE_SECONDS)
with self._write() as connection:
cursor = connection.execute(
"""
UPDATE package_outbox
SET next_attempt_at = ?
WHERE package_id = ?
AND claim_token = ?
AND (send_state IS NULL OR send_state = 'pending')
AND next_attempt_at > ?
""",
(_isoformat(lease_until), package_id, token, _isoformat(now)),
)
return cursor.rowcount == 1
except Exception:
# If renewal itself fails, do not transmit on unproven authority.
logger.warning("Unable to renew shared-metrics claim", exc_info=True)
return False
def _defer(
self, package_id: str, delay_seconds: int, reason: str, *, token: str | None = None
) -> None:
# Clamp to >= 1s so a past deadline can never make the row instantly re-eligible.
retry_at = self._now().timestamp() + max(1, int(delay_seconds))
self._mark(
package_id,
token=token,
send_state="pending",
next_attempt_at=_isoformat(datetime.fromtimestamp(retry_at, tz=timezone.utc)),
last_error=reason[:500],
)
def _send_one(self, package: dict) -> str:
"""Try one package. Returns 'sent', 'rejected', or 'deferred'.
Delivery is at-least-once: renewal plus token-fenced writes close the claim->POST
and settle-after-reclaim gaps, but a suspension MID-POST can still duplicate.
"""
package_id = package["package_id"]
token = package.get("claim_token")
body = self._body(package["payload_json"], package["derived"])
def defer(delay: int, reason: str) -> str:
self._defer(package_id, delay, reason, token=token)
return "deferred"
for attempt in range(1, self._max_attempts + 1):
# Renew before EVERY external POST so a lapsed claimant yields even before anyone
# reclaims. The ingest key is minute-prefixed: duplicates are distinct objects.
if not self._renew_claim(package_id, token):
logger.info(
"Shared-metrics claim on %s superseded or expired; yielding", package_id
)
return "deferred"
try:
response = self._post(self._endpoint, body, timeout=REQUEST_TIMEOUT_SECONDS)
except Exception as exc: # transport failure: offline, DNS, TLS
reason = f"{type(exc).__name__}: {exc}"
else:
if response.status == 202:
self._mark(
package_id, token=token, send_state="sent",
sent_at=_isoformat(self._now()), last_error=None,
)
return "sent"
if response.status in _PERMANENT_STATUSES:
logger.warning(
"Telemetry package %s rejected with HTTP %s; not retrying",
package_id,
response.status,
)
self._mark(
package_id, token=token, send_state="rejected",
last_error=f"HTTP {response.status}: {response.body[:400]}",
)
return "rejected"
if response.status == 429:
return defer(
_retry_after_seconds(response.retry_after, _FAILURE_BACKOFF_SECONDS),
"rate limited",
)
# 5xx and anything unexpected: retryable.
reason = f"HTTP {response.status}"
if attempt >= self._max_attempts:
return defer(_FAILURE_BACKOFF_SECONDS, reason)
self._sleep(self._backoff(attempt))
return defer(_FAILURE_BACKOFF_SECONDS, "attempts exhausted")
@staticmethod
def _backoff(attempt: int) -> float:
"""1s, 5s, 25s with full jitter."""
return random.uniform(0, _BACKOFF_BASE_SECONDS * (_BACKOFF_FACTOR ** (attempt - 1)))
# -- entry point -------------------------------------------------------
def send_pending(self) -> SendOutcome:
"""Run one bounded pass, one claim+send at a time, re-checking consent before each
send so revoking `send` mid-pass stops the remainder. Never raises."""
outcome = SendOutcome()
seen: set[str] = set()
for _ in range(MAX_PACKAGES_PER_PASS):
if not self._still_consented():
# Reconcile through the single consent writer so the window closes at its
# last confirmed moment.
logger.info("Shared-metrics sending disabled mid-pass; stopping")
self._reconcile(send_enabled=False)
break
try:
package = self._claim_next(self._now(), seen)
except Exception:
logger.warning("Unable to select shared-metrics packages", exc_info=True)
break
if package is None:
break
seen.add(package["package_id"])
if package.get("skip"):
# Unusable row already marked rejected during the claim.
result = "rejected"
else:
try:
result = self._send_one(package)
except Exception:
logger.warning("Unable to send shared-metrics package", exc_info=True)
result = "deferred"
setattr(outcome, result, getattr(outcome, result) + 1)
return outcome
def _reconcile(self, *, send_enabled: bool) -> None:
"""Run the single consent writer from within a pass."""
try:
with self._write() as connection:
reconcile_send_consent(connection, send_enabled, now=self._now())
except Exception:
logger.warning("Unable to reconcile shared-metrics consent", exc_info=True)
def _still_consented(self) -> bool:
"""Re-read profile-owned send consent: docs promise ``send: false`` stops transmission
immediately, and a pass can run for minutes."""
if self._consent_check is None:
return True
try:
return bool(self._consent_check())
except Exception:
# Fail CLOSED: if consent cannot be established, do not transmit.
logger.warning("Unable to confirm shared-metrics send consent; stopping", exc_info=True)
return False