fix(prometheus): a refused token stops the scraping, as Home Assistant bans an address after a few refusals (#6355)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m55s
CI / publish (push) Successful in 20s

Also names the Home Assistant connection by its address in the audit log and
timeline, where its id showed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 09:41:53 -05:00
co-authored by Claude Opus 5.5
parent a1dfa7b821
commit 66a5dd12fd
11 changed files with 94 additions and 11 deletions
+2
View File
@@ -46,6 +46,8 @@ Each target also records four metrics about itself, under its own name:
A value's unit is read from Prometheus naming: `_bytes` as bytes, `_seconds` as a span, `_timestamp_seconds` as a moment ("3h ago"), `_per_sec` as a rate.
**A refused token stops the scraping.** When an endpoint answers 401 or 403, Steward stops asking and says so on the target's page and in the overview, because asking again won't change the answer and some services, Home Assistant among them, ban an address after a few refusals. Saving the target, usually with a new token, or pausing and resuming it, starts it again. Any other failure is retried at the next scrape.
A **state set** is one series per possible state, the current one 1 and the rest 0, such as `fabledarchives_backup_target_state{target, state}`. The target page shows it as one row per target (or job), naming the current state; the other states are listed on hover. A metric is read as a state set when its name ends in `_state` and every series has a `state` label reading 0 or 1. The OpenMetrics form, where the label is named after the metric (`foo{foo="a"} 1`), counts too. Each state is still stored as its own series, so an alert rule can watch one state.
**Only what the endpoint reports now is current.** When a scrape succeeds, any series it no longer has loses its newest value. That happens when the endpoint stopped reporting it, for example an old `build_info` after an upgrade, or when a filter or the cap now leaves it out. Its history stays on the chart until retention removes it. A failed scrape leaves the newest values as they were.
+3 -3
View File
@@ -174,7 +174,7 @@ async def sign_in_callback():
verify_tls=previous[3]))
await log_audit(current_app, user_id, username, "homeassistant.connection_saved",
entity_type="homeassistant_connection", entity_id=connection_id,
detail={"url": url, "can_install": can_install})
detail={"name": url, "can_install": can_install})
return redirect("/plugins/homeassistant/")
@@ -209,11 +209,11 @@ async def delete_connection():
connection = await _connection(db)
if connection is None:
return redirect("/plugins/homeassistant/")
connection_id = connection.id
connection_id, url = connection.id, connection.url
await db.delete(connection)
ended = await _end_sign_in(connection)
await log_audit(current_app, *_actor(), "homeassistant.connection_removed",
entity_type="homeassistant_connection", entity_id=connection_id, detail={"sign_in_ended": ended})
entity_type="homeassistant_connection", entity_id=connection_id, detail={"name": url, "sign_in_ended": ended})
if not ended:
return await _render_index("Removed from Steward, but Home Assistant couldn't be reached to end the sign-in. "
"End it yourself under Security on that user's profile in Home Assistant.")
@@ -0,0 +1,23 @@
"""A target whose token was refused stops being scraped until it is saved
Revision ID: prometheus_003_refused
Revises: prometheus_002_templates
Create Date: 2026-11-01
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "prometheus_003_refused"
down_revision: Union[str, None] = "prometheus_002_templates"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("prometheus_targets", sa.Column("refused", sa.Boolean, nullable=False, server_default=sa.false()))
def downgrade() -> None:
op.drop_column("prometheus_targets", "refused")
+3 -1
View File
@@ -17,7 +17,8 @@ class PrometheusTarget(Base):
patterns ("fabledarchives_*") an admin chooses: Steward does the
filtering, the endpoint serves everything. The last_* columns are the
newest scrape's outcome, written by the scraper, so a page can say why a
target isn't answering without asking it.
target isn't answering without asking it. `refused` says its token was
refused, which stops the scraping until the target is saved or resumed.
"""
__tablename__ = "prometheus_targets"
@@ -31,6 +32,7 @@ class PrometheusTarget(Base):
drop: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
series_cap: Mapped[int] = mapped_column(Integer, nullable=False, default=1000)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
refused: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
last_scraped_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+2
View File
@@ -153,6 +153,7 @@ async def save_target(target_id: str):
# Where it is or what is kept may have changed: the last outcome no
# longer describes it, so the page says "not scraped yet" until the next scrape.
target.last_scraped_at = target.last_ok_at = target.last_error = None
target.refused = False
target.last_kept = target.last_dropped = target.last_capped = 0
if old_name != target.name:
# Its history is stored under its name, and its labelled series
@@ -188,6 +189,7 @@ async def toggle_target(target_id: str):
# Resumed: it reads "not scraped yet" until the next scrape,
# rather than stale from before the pause.
target.last_scraped_at = target.last_error = None
target.refused = False
enabled, name = target.enabled, target.name
# A rate needs two readings in a row; one from before the pause isn't one.
from . import scheduler
+3 -2
View File
@@ -40,7 +40,7 @@ def make_scrape_task(app: "Quart") -> ScheduledTask:
def is_due(target, now: datetime) -> bool:
"""Whether a target's interval has elapsed since its last scrape, with
half a tick of slack so tick jitter doesn't push it back a whole tick."""
if not target.enabled:
if not target.enabled or target.refused:
return False
if target.last_scraped_at is None:
return True
@@ -115,7 +115,7 @@ async def record(app: "Quart", targets, results, now: datetime) -> None:
from steward.core.alerts import MetricBatch
from .models import PrometheusTarget
from .scraper import ScrapeError
from .scraper import ScrapeError, TokenRefused
from .service import (IS_UP, OWN_METRICS, SCRAPE_MS, SERIES_CAPPED, SERIES_KEPT, SOURCE,
map_families)
@@ -132,6 +132,7 @@ async def record(app: "Quart", targets, results, now: datetime) -> None:
if not isinstance(result, ScrapeError):
logger.warning("Prometheus scrape of %s failed: %r", target.name, result)
row.last_error = str(result) or "The scrape failed."
row.refused = isinstance(result, TokenRefused)
batch.add(source_module=SOURCE, resource_name=row.name, metric_name=IS_UP,
value=0.0, recorded_at=now)
continue
+7 -2
View File
@@ -18,6 +18,11 @@ class ScrapeError(Exception):
"""Why a scrape got nothing, said to the person."""
class TokenRefused(ScrapeError):
"""The endpoint refused the token. Asking again won't change that, and
Home Assistant, for one, bans an address after a few refusals."""
async def scrape(url: str, token: str = "", verify_tls: bool = True,
transport: httpx.AsyncBaseTransport | None = None) -> dict[str, prometheus.Family]:
"""The endpoint's families. `transport` stands in for the network in tests."""
@@ -29,8 +34,8 @@ async def scrape(url: str, token: str = "", verify_tls: bool = True,
transport=transport, follow_redirects=False) as client:
async with client.stream("GET", url, headers=headers) as response:
if response.status_code in (401, 403):
raise ScrapeError(f"Answered {response.status_code}: the token was refused. "
"Check the token, or make a new one on the service.")
raise TokenRefused(f"Answered {response.status_code}: the token was refused. "
"Check the token, or make a new one on the service.")
if response.status_code != 200:
raise ScrapeError(f"Answered {response.status_code} {response.reason_phrase}, not metrics.")
body = bytearray()
+3
View File
@@ -271,6 +271,9 @@ class TargetState:
def target_state(target: PrometheusTarget, now: datetime) -> TargetState:
if not target.enabled:
return TargetState("paused", "Paused: not scraped until it's resumed.")
if target.refused:
return TargetState("down", f"{target.last_error} Steward stopped asking, as some services ban an "
"address after a few refusals. Save a new token to start again.")
if target.last_scraped_at is None:
return TargetState("pending", "Not scraped yet.")
if (now - target.last_scraped_at).total_seconds() > STALE_SCRAPES * max(target.interval_seconds, 1):
@@ -134,3 +134,38 @@ def test_a_scrape_records_series_rates_and_a_down_target_fires_its_rule(app):
assert rows[down].last_ok_at is None
assert down_latest[(down, "is_up")] == 0.0
assert state.state == AlertStateEnum.firing
def test_a_refused_token_is_asked_once(app):
"""Home Assistant bans an address after a few refusals, so a refused token
isn't retried at each interval, however long ago the last scrape was."""
from sqlalchemy import select
scheduler = _prom("scheduler")
PrometheusTarget = _prom("models").PrometheusTarget
TokenRefused = _prom("scraper").TokenRefused
name = f"refused-{uuid.uuid4().hex[:8]}"
asked = []
async def fake_scrape(url, token, verify_tls):
asked.append(url)
raise TokenRefused("Answered 401: the token was refused.")
async def go():
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(PrometheusTarget(name=name, url=f"http://{name}/metrics"))
await scheduler.scrape_due(app, scrape=fake_scrape)
async with app.db_sessionmaker() as s:
async with s.begin():
row = (await s.execute(select(PrometheusTarget).where(PrometheusTarget.name == name))).scalar_one()
refused = row.refused
row.last_scraped_at = row.last_scraped_at.replace(year=2000)
await scheduler.scrape_due(app, scrape=fake_scrape)
async with app.db_sessionmaker() as s:
async with s.begin():
await s.delete((await s.execute(select(PrometheusTarget).where(PrometheusTarget.name == name))).scalar_one())
return refused
assert asyncio.run(go()) is True
assert asked == [f"http://{name}/metrics"]
+1 -1
View File
@@ -15,7 +15,7 @@ def _target(name, *, enabled=True, scraped=timedelta(seconds=30), error=None,
url="http://archives:8080/metrics"):
return NS(id=f"id-{name}", name=name, url=url, token="bearer-s3cret", enabled=enabled,
interval_seconds=60, last_scraped_at=NOW - scraped if scraped else None,
last_ok_at=NOW - timedelta(hours=1), last_error=error, last_kept=120, last_dropped=30,
last_ok_at=NOW - timedelta(hours=1), last_error=error, refused=False, last_kept=120, last_dropped=30,
last_capped=0, series_cap=1000, keep=["archives_*"], drop=[])
+12 -2
View File
@@ -7,7 +7,7 @@ import pytest
from plugins.prometheus.models import PrometheusTarget
from plugins.prometheus.scheduler import is_due
from plugins.prometheus.scraper import ScrapeError, scrape
from plugins.prometheus.scraper import ScrapeError, TokenRefused, scrape
from plugins.prometheus.service import (FormError, fold_states, label_pairs, map_families, parse_form,
patterns, rate_name, resource_for, target_state, wanted)
from steward.core import prometheus
@@ -165,6 +165,16 @@ def test_state_and_due():
assert target_state(t, now).reach == "paused" and not is_due(t, now)
def test_a_refused_token_stops_the_scraping_and_the_page_says_so():
now = datetime(2026, 10, 12, 12, 0, tzinfo=timezone.utc)
t = PrometheusTarget(name="a", url="http://a", enabled=True, interval_seconds=60, refused=True,
last_error="Answered 401: the token was refused.",
last_scraped_at=now - timedelta(hours=5))
state = target_state(t, now)
assert state.reach == "down" and "stopped asking" in state.says and "Answered 401" in state.says
assert not is_due(t, now)
@pytest.mark.asyncio
async def test_a_scrape_sends_the_token_and_says_why_it_failed():
seen = {}
@@ -180,7 +190,7 @@ async def test_a_scrape_sends_the_token_and_says_why_it_failed():
transport = httpx.MockTransport(handler)
fams = await scrape("http://svc/metrics", "tok", transport=transport)
assert list(fams) == ["up_thing"] and seen["auth"] == "Bearer tok"
with pytest.raises(ScrapeError, match="token was refused"):
with pytest.raises(TokenRefused, match="token was refused"):
await scrape("http://svc/denied", "bad", transport=transport)
with pytest.raises(ScrapeError, match="no metrics"):
await scrape("http://svc/html", transport=transport)