headroom/tests/test_usage_reporter_snapshot.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

73 lines
2.1 KiB
Python
Raw Normal View History

fix(telemetry): only advance usage-report baseline after a 200 (#2149) ## Description The usage reporter permanently drops a reporting window's usage whenever the send to the cloud fails. `UsageReporter._report_usage` computes usage as a **delta** against the last snapshot, POSTs it, and then rebases the baseline: ```python try: resp = await client.post(f"{self._cloud_url}/v1/license/usage", json=payload, timeout=10.0) if resp.status_code == 200: ... else: logger.warning("Usage report returned status %d", resp.status_code) except Exception: logger.warning("Failed to send usage report", exc_info=True) # Update snapshot self._snapshot_metrics() # runs on success, non-200, AND exception self._last_report_time = now ``` `_snapshot_metrics()` rebases `_last_tokens_saved_by_model` / `_last_tokens_sent_by_model` / `_last_requests_by_model` to the current cumulative counters. Because it runs unconditionally after the POST, a report that fails to send — non-200 or a raised exception — still advances the baseline. The module is explicitly built to tolerate a briefly-unreachable cloud (7-day grace, cached license), so this is a normal, recurring situation. The consequence: the failed window's requests and tokens are never re-included. The next report is a delta from the advanced baseline, so that window is silently and permanently lost from usage-based billing / quota. Every transient network blip under-counts usage. (The `total_requests == 0` early-return already gets this right — it advances only `_last_report_time`, without snapshotting, since there's nothing to lose.) ## Fix Advance the baseline (`_snapshot_metrics()` and `_last_report_time`) only inside the `resp.status_code == 200` branch. On a non-200 or an exception, both baselines are left intact, so the next report covers the full period since the last successful send and re-includes the previously-failed window. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/telemetry/reporter.py`: move `_snapshot_metrics()` + `_last_report_time = now` into the 200 branch of `_report_usage`. - `tests/test_usage_reporter_snapshot.py`: new tests — baseline advances on 200, and stays intact on a non-200 and on an exception (window preserved). - `CHANGELOG.md`: Bug Fixes entry. ## Testing - [ ] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ uvx ruff@0.15.17 check headroom/telemetry/reporter.py tests/test_usage_reporter_snapshot.py All checks passed! $ python -m py_compile headroom/telemetry/reporter.py tests/test_usage_reporter_snapshot.py OK ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17`. Importing `headroom` pulls in the torch/transformers stack and a full `pytest` gets OOM-killed on this box, so I verified the delta accounting with a dependency-free script that models two windows across a failed then successful send, and left the full pytest to CI. - Exact command / steps: window 1 saves 100 tokens and the send fails; window 2 saves another 50 (cumulative 150) and the send succeeds. Ran under the old (unconditional snapshot) and new (snapshot-on-200) logic. - Observed result: old delivers only 50 tokens total (window 1's 100 dropped when the baseline advanced on the failed send); new delivers the full 150 (window 2's delta re-includes window 1). The new tests assert the baseline advances on 200 and is untouched on a non-200 / exception, driving the real `_report_usage` with a fake proxy + client. - Not tested: a live cloud round-trip; full local `pytest` deferred to CI (OOM, per above). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Additional Notes The "unit tests pass locally" and "type checking" boxes are unchecked because the full suite imports the ML stack, which I can't run in this environment; the change moves two lines into the success branch, verified by the standalone delta-accounting proof and new tests that drive the real `_report_usage` via `object.__new__` with a fake proxy and HTTP client (200, non-200, and exception). Co-authored-by: JerrettDavis <mxjerrett@gmail.com> Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-14 21:49:43 +05:30
"""UsageReporter must only advance its delta baseline after a confirmed 200.
Snapshotting on a failed send permanently drops that window's usage from the
delta-based usage report (billing/quota under-count)."""
from __future__ import annotations
from types import SimpleNamespace
import anyio
from headroom.telemetry.reporter import UsageReporter
class _Resp:
def __init__(self, status_code: int) -> None:
self.status_code = status_code
def json(self) -> dict:
return {}
def _make_reporter(post_outcome) -> UsageReporter:
r = object.__new__(UsageReporter)
ct = SimpleNamespace(
_tokens_saved_by_model={"m": 100},
_tokens_sent_by_model={"m": 400},
_requests_by_model={"m": 5},
)
r._proxy = SimpleNamespace(cost_tracker=ct)
r._last_report_time = None
r._last_tokens_saved_by_model = {}
r._last_tokens_sent_by_model = {}
r._last_requests_by_model = {}
r._license_key = "k"
r._cloud_url = "https://cloud.example"
r._license_info = None
class _Client:
async def post(self, *args, **kwargs):
if isinstance(post_outcome, Exception):
raise post_outcome
return _Resp(post_outcome)
async def _get_client():
return _Client()
r._get_client = _get_client
return r
def test_baseline_advances_only_on_success():
r = _make_reporter(200)
anyio.run(r._report_usage)
# Success -> baseline rebased to the current cumulative counters.
assert r._last_tokens_saved_by_model == {"m": 100}
assert r._last_requests_by_model == {"m": 5}
def test_baseline_not_advanced_on_non_200():
r = _make_reporter(500)
anyio.run(r._report_usage)
# Failed send -> baseline untouched so the window is retried next report.
assert r._last_tokens_saved_by_model == {}
assert r._last_requests_by_model == {}
def test_baseline_not_advanced_on_exception():
r = _make_reporter(RuntimeError("network down"))
anyio.run(r._report_usage)
assert r._last_tokens_saved_by_model == {}
assert r._last_requests_by_model == {}