Files
FabledSteward/tests/plugins/traefik/test_meaning.py
T
bvandeusenandClaude Opus 5.5 685850e681
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m22s
CI / unit (push) Failing after 1m33s
CI / publish (push) Skipped
feat(traefik): each router problem says what it means, whether it's real, and what to do (#5712)
#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>
2026-10-14 21:04:39 -04:00

130 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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