feat(traefik): each router problem says what it means, whether it's real, and what to do (#5712)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m22s
CI / unit (push) Failing after 1m33s
CI / publish (push) Skipped

#5712 "Each router problem says what it means, whether it's real, and what
to do". A problem card now names its kind and reads its codes:
- Server errors: 500 means the app is failing; 502/503/504 means Traefik
  couldn't reach it or it timed out.
- Client errors: 404 scanners, a 401/403 login wall, 429 rate limiting, or
  499 clients giving up on a slow app.
- Slow: p95 against p50.
Each card says whether the problem is likely real, and ends with one next
step. A fault links to the swarm service behind the router, matched by
name, and traffic links to Settings → Thresholds. The wording lives in
plugins/traefik/meaning.py.

The plugin manager now hands out a loaded plugin's submodule
(loaded_plugin_submodule), so Traefik and host_agent reach Docker's
models the same way (#5775).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 21:04:39 -04:00
co-authored by Claude Opus 5.5
parent 31a6cf09a9
commit 685850e681
7 changed files with 307 additions and 13 deletions
+129
View File
@@ -0,0 +1,129 @@
"""Each router problem says what it means, whether it's real, and what to do
(#5712): plugins/traefik/meaning.py's wording, and the problem card that
carries it."""
import pytest
from plugins.traefik import meaning
from plugins.traefik.routes import STATE_WINDOW_WORDS, explain_problems, router_state
from steward.core.settings import to_thresholds_cfg
from tests._templates import render_plugin_template
from tests.plugins.traefik.test_traefik_page import _latest
TH = to_thresholds_cfg({})
@pytest.mark.parametrize("latest, kind", [
(_latest(e5=2.5), "5xx"),
(_latest(p95=450.0), "slow"),
(_latest(e4=5.0), "4xx"),
(_latest(e5=2.5, e4=5.0, p95=450.0), "5xx"), # graded on its worst
(_latest(p95=450.0, e4=5.0), "slow"), # router_state checks p95 before 4xx
(_latest(), None),
])
def test_the_kind_follows_router_state_wording(latest, kind):
for over in ("", STATE_WINDOW_WORDS):
state, why = router_state(latest, TH, over)
assert meaning.problem_kind(state, why) == kind
@pytest.mark.parametrize("codes, says, action", [
([("500", 30), ("502", 2)], "the app is failing requests", "act"),
([("502", 40), ("500", 1)], "couldn't reach the app", "act"),
([("504", 5)], "Mostly 504: Traefik couldn't reach the app, or it timed out", "act"),
([], "The app is answering with server errors", "act"),
])
def test_server_errors_are_real_and_point_at_the_app(codes, says, action):
got = meaning.explain("5xx", codes)
assert says in got["meaning"] and got["action"] == action and "real fault" in got["likely"]
@pytest.mark.parametrize("codes, says, action", [
([("404", 1812), ("401", 9)], "scanners and bots", "tune"),
([("401", 300), ("404", 2)], "a login wall doing its job", "tune"),
([("403", 30)], "Mostly 403: a login wall", "tune"),
([("429", 12)], "rate limiting", "tune"),
([("499", 50), ("404", 10)], "clients giving up", "act"), # mostly 499: a slow app
([("404", 50), ("499", 10)], "scanners and bots", "tune"),
([("400", 7)], "Mostly 400: requests the app turned away", "tune"),
([], "often bots, scanners or a login wall", "tune"),
])
def test_client_errors_say_which_kind_of_traffic(codes, says, action):
got = meaning.explain("4xx", codes)
assert says in got["meaning"] and got["action"] == action
def test_a_4xx_past_its_own_normal_says_something_changed():
assert "Usually traffic" in meaning.explain("4xx", [("404", 9)])["likely"]
assert "Above its usual level" in meaning.explain("4xx", [("404", 9)], normed=True)["likely"]
def test_slow_compares_p95_with_p50():
tail = meaning.explain("slow", [], p50_ms=40.0, p95_ms=900.0)
assert "p95 is 22× p50 (40 ms): a few slow requests" in tail["meaning"] and tail["action"] == "act"
whole = meaning.explain("slow", [], p50_ms=600.0, p95_ms=900.0)
assert "the whole app is slow" in whole["meaning"]
assert meaning.explain("slow", [], p95_ms=900.0)["meaning"] == "The app is slow overall, or one endpoint is."
assert "Slower than usual" in meaning.explain("slow", [], normed=True)["likely"]
SERVICES = ["arr_radarr", "codex_app", "codex_db", "steward_app", "steward_db",
"personal_nextcloud", "fabled_search_searxng", "traefik_router", "radarr_old_radarr"]
@pytest.mark.parametrize("router, service", [
("codex@swarm", "codex_app"), # its stack's app
("steward@swarm", "steward_app"),
("nextcloud@swarm", "personal_nextcloud"), # the one service ending in it
("fabled-searxng@swarm", "fabled_search_searxng"), # by its last word
("radarr@swarm", None), # two services end in _radarr: can't tell
("home@swarm", None),
("jellyfin@file", None), # not a swarm router
("traefik_router@docker", "traefik_router"),
])
def test_a_router_finds_the_swarm_service_behind_it(router, service):
assert meaning.swarm_service(router, SERVICES) == service
def _problem(latest, codes):
state, why = router_state(latest, TH)
return {"name": "app@swarm", "latest": latest, "state": state, "why": why, "codes": codes,
"normed": False, **{f"sparkline_{k}": "" for k in ("req", "bytes", "p95", "4xx", "5xx")}}
async def _card(app, router, codes_known=True):
async with app.test_request_context("/plugins/traefik/rows"):
page = await render_plugin_template(
app, "traefik", "traefik/rows.html", problems=[router], healthy=[], gone=[], global_stat=None,
certs=[], old_certs=[], range_key="24h", current_range="24h", error_4xx=1.0, error_5xx=0.1,
codes_known=codes_known)
page = " ".join(page.split())
return page[page.index(">app@swarm</h3>"):]
async def test_a_404_heavy_card_says_scanners_and_points_to_tuning(app):
r = _problem(_latest(e4=6.0), [("404", 1812), ("401", 9)])
explain_problems([r], ["app_app"])
card = await _card(app, r)
assert "<strong>Client errors</strong>: 6.0% of responses are 4xx" in card
assert "scanners and bots" in card and "Usually traffic rather than a fault" in card
assert 'href="/settings/thresholds/#traefik"' in card and "/plugins/docker/service/" not in card
assert card.index("scanners") < card.index("<table")
async def test_a_502_card_points_to_its_service(app):
r = _problem(_latest(e5=2.5), [("502", 40)])
explain_problems([r], ["app_app", "app_db"])
card = await _card(app, r)
assert "<strong>Server errors</strong>" in card and "couldn't reach the app" in card
assert 'href="/plugins/docker/service/app_app">app_app: its tasks and logs' in card
assert "/settings/thresholds/" not in card
async def test_a_card_for_an_unknown_service_says_so(app):
r = _problem(_latest(e5=2.5), [])
explain_problems([r], [])
card = await _card(app, r, codes_known=False)
assert "Status codes not recorded yet" in card
assert "The app is answering with server errors" in card
assert "Steward can't tell which service serves this router" in card