Отказ в alert-ack перестаёт съедать следующий алерт #3490
2 changed files with 152 additions and 6 deletions
|
|
@ -339,15 +339,24 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._reply(code, page.encode())
|
self._reply(code, page.encode())
|
||||||
|
|
||||||
def do_POST(self) -> None: # noqa: N802 — имя из stdlib
|
def do_POST(self) -> None: # noqa: N802 — имя из stdlib
|
||||||
|
# Тело читается ДО любой развилки и ветки отказа. protocol_version =
|
||||||
|
# HTTP/1.1, то есть соединение переиспользуется, а Caddy перед нами
|
||||||
|
# держит пул к апстриму. Ответить 401/404/503, не вычитав тело, значит
|
||||||
|
# оставить его в сокете — и следующий запрос по тому же соединению
|
||||||
|
# начнётся с чужих байт. Проверено на проде 12.09.2026: неавторизованный
|
||||||
|
# зонд на /glitchtip, а следом законный алерт получил
|
||||||
|
# 501 Unsupported method ('{"text":"probe"}POST'). То есть один
|
||||||
|
# отказ ронял следующий НАСТОЯЩИЙ алерт — ровно то, ради чего этот
|
||||||
|
# резервный канал и заводился.
|
||||||
parsed = urllib.parse.urlsplit(self.path)
|
parsed = urllib.parse.urlsplit(self.path)
|
||||||
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
|
raw = self.rfile.read(length) if length else b"{}"
|
||||||
if parsed.path == "/glitchtip":
|
if parsed.path == "/glitchtip":
|
||||||
self._handle_glitchtip(parsed)
|
self._handle_glitchtip(parsed, raw)
|
||||||
return
|
return
|
||||||
if parsed.path != "/alertmanager":
|
if parsed.path != "/alertmanager":
|
||||||
self._reply(404, b"not found", "text/plain; charset=utf-8")
|
self._reply(404, b"not found", "text/plain; charset=utf-8")
|
||||||
return
|
return
|
||||||
length = int(self.headers.get("Content-Length") or 0)
|
|
||||||
raw = self.rfile.read(length) if length else b"{}"
|
|
||||||
try:
|
try:
|
||||||
payload = json.loads(raw.decode() or "{}")
|
payload = json.loads(raw.decode() or "{}")
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
|
|
@ -361,7 +370,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._reply(200, b"accepted", "text/plain; charset=utf-8")
|
self._reply(200, b"accepted", "text/plain; charset=utf-8")
|
||||||
threading.Thread(target=_send_alert, args=(payload,), daemon=True).start()
|
threading.Thread(target=_send_alert, args=(payload,), daemon=True).start()
|
||||||
|
|
||||||
def _handle_glitchtip(self, parsed: urllib.parse.SplitResult) -> None:
|
def _handle_glitchtip(self, parsed: urllib.parse.SplitResult, raw: bytes) -> None:
|
||||||
"""POST /glitchtip — резервный получатель GlitchTip-алертов (#3471).
|
"""POST /glitchtip — резервный получатель GlitchTip-алертов (#3471).
|
||||||
|
|
||||||
Секрет — из заголовка ``X-GlitchTip-Secret`` (предпочтительно, не течёт
|
Секрет — из заголовка ``X-GlitchTip-Secret`` (предпочтительно, не течёт
|
||||||
|
|
@ -381,8 +390,6 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._reply(401, b"invalid or missing secret", "text/plain; charset=utf-8")
|
self._reply(401, b"invalid or missing secret", "text/plain; charset=utf-8")
|
||||||
return
|
return
|
||||||
|
|
||||||
length = int(self.headers.get("Content-Length") or 0)
|
|
||||||
raw = self.rfile.read(length) if length else b"{}"
|
|
||||||
try:
|
try:
|
||||||
payload = json.loads(raw.decode() or "{}")
|
payload = json.loads(raw.decode() or "{}")
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
|
|
|
||||||
|
|
@ -115,3 +115,142 @@ def test_send_glitchtip_alert_forwards_via_tg(monkeypatch) -> None:
|
||||||
assert sent["message_thread_id"] == alert_ack.TOPIC_ID
|
assert sent["message_thread_id"] == alert_ack.TOPIC_ID
|
||||||
assert "reply_markup" not in sent
|
assert "reply_markup" not in sent
|
||||||
assert "boom" in sent["text"]
|
assert "boom" in sent["text"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Keep-alive: отказ не должен ронять СЛЕДУЮЩИЙ запрос ──────────────────────
|
||||||
|
# Эти четыре теста — единственные, что поднимают настоящий сокет. Дефект,
|
||||||
|
# который они стерегут, живёт именно в транспорте и на уровне функций невидим:
|
||||||
|
# ветка отказа отвечала, не вычитав тело запроса, а `protocol_version` здесь
|
||||||
|
# HTTP/1.1, то есть соединение переиспользуется (и Caddy перед сервисом держит
|
||||||
|
# пул к апстриму). Непрочитанное тело оставалось в сокете, и следующий запрос
|
||||||
|
# по тому же соединению начинался с чужих байт.
|
||||||
|
#
|
||||||
|
# Поймано на проде 12.09.2026: зонд без секрета получил 401, а следующий —
|
||||||
|
# уже с верным секретом — вернул 501 Unsupported method ('{"text":"probe"}POST').
|
||||||
|
# То есть один отказ съедал следующий НАСТОЯЩИЙ алерт.
|
||||||
|
|
||||||
|
|
||||||
|
def _serve_in_background(monkeypatch):
|
||||||
|
"""Поднимает Handler на эфемерном порту, глушит отправку в Telegram."""
|
||||||
|
import threading as _threading
|
||||||
|
from http.server import ThreadingHTTPServer
|
||||||
|
|
||||||
|
sent: list[dict] = []
|
||||||
|
monkeypatch.setattr(alert_ack, "_send_glitchtip_alert", sent.append)
|
||||||
|
monkeypatch.setattr(alert_ack, "_send_alert", sent.append)
|
||||||
|
|
||||||
|
srv = ThreadingHTTPServer(("127.0.0.1", 0), alert_ack.Handler)
|
||||||
|
thread = _threading.Thread(target=srv.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
return srv, sent
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_post(sock, path: str, body: bytes, headers: str = "") -> str:
|
||||||
|
"""Шлёт POST по уже открытому сокету и возвращает статусную строку."""
|
||||||
|
req = (
|
||||||
|
f"POST {path} HTTP/1.1\r\n"
|
||||||
|
f"Host: localhost\r\n"
|
||||||
|
f"Content-Type: application/json\r\n"
|
||||||
|
f"Content-Length: {len(body)}\r\n"
|
||||||
|
f"{headers}"
|
||||||
|
f"\r\n"
|
||||||
|
).encode() + body
|
||||||
|
sock.sendall(req)
|
||||||
|
# Читаем ровно заголовки: тела короткие, Content-Length всегда проставлен.
|
||||||
|
buf = b""
|
||||||
|
while b"\r\n\r\n" not in buf:
|
||||||
|
chunk = sock.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
buf += chunk
|
||||||
|
head, _, rest = buf.partition(b"\r\n\r\n")
|
||||||
|
length = 0
|
||||||
|
for line in head.split(b"\r\n")[1:]:
|
||||||
|
if line.lower().startswith(b"content-length:"):
|
||||||
|
length = int(line.split(b":")[1])
|
||||||
|
while len(rest) < length:
|
||||||
|
rest += sock.recv(4096)
|
||||||
|
return head.split(b"\r\n")[0].decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _pair_on_one_connection(monkeypatch, first_headers: str, first_path: str = "/glitchtip"):
|
||||||
|
import socket
|
||||||
|
|
||||||
|
srv, sent = _serve_in_background(monkeypatch)
|
||||||
|
try:
|
||||||
|
sock = socket.create_connection(srv.server_address, timeout=5)
|
||||||
|
try:
|
||||||
|
first = _raw_post(sock, first_path, b'{"text":"probe"}', first_headers)
|
||||||
|
second = _raw_post(
|
||||||
|
sock,
|
||||||
|
"/glitchtip",
|
||||||
|
b'{"text":"real alert"}',
|
||||||
|
"X-GlitchTip-Secret: correct-secret\r\n",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
finally:
|
||||||
|
srv.shutdown()
|
||||||
|
srv.server_close()
|
||||||
|
return first, second, sent
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejected_request_does_not_break_next_one_on_same_connection(monkeypatch) -> None:
|
||||||
|
"""401 без секрета, следом законный алерт по ТОМУ ЖЕ соединению — 200."""
|
||||||
|
first, second, sent = _pair_on_one_connection(monkeypatch, "")
|
||||||
|
assert "401" in first
|
||||||
|
assert "200" in second, f"второй запрос испорчен первым: {second}"
|
||||||
|
assert sent == [{"text": "real alert"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unconfigured_secret_does_not_break_next_request(monkeypatch) -> None:
|
||||||
|
"""503 при пустом секрете тоже обязан вычитать тело."""
|
||||||
|
import socket
|
||||||
|
|
||||||
|
monkeypatch.setattr(alert_ack, "GLITCHTIP_SECRET", "")
|
||||||
|
srv, _sent = _serve_in_background(monkeypatch)
|
||||||
|
try:
|
||||||
|
sock = socket.create_connection(srv.server_address, timeout=5)
|
||||||
|
try:
|
||||||
|
first = _raw_post(sock, "/glitchtip", b'{"text":"probe"}')
|
||||||
|
second = _raw_post(sock, "/glitchtip", b'{"text":"again"}')
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
finally:
|
||||||
|
srv.shutdown()
|
||||||
|
srv.server_close()
|
||||||
|
assert "503" in first
|
||||||
|
assert "503" in second, f"второй запрос испорчен первым: {second}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_path_does_not_break_next_request(monkeypatch) -> None:
|
||||||
|
"""404 на чужом пути — та же ветка раннего ответа, то же требование."""
|
||||||
|
first, second, sent = _pair_on_one_connection(monkeypatch, "", first_path="/nope")
|
||||||
|
assert "404" in first
|
||||||
|
assert "200" in second, f"второй запрос испорчен первым: {second}"
|
||||||
|
assert sent == [{"text": "real alert"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_json_on_alertmanager_does_not_break_next_request(monkeypatch) -> None:
|
||||||
|
"""400 на неразобранном теле /alertmanager — тело уже вычитано, связь цела."""
|
||||||
|
import socket
|
||||||
|
|
||||||
|
srv, sent = _serve_in_background(monkeypatch)
|
||||||
|
try:
|
||||||
|
sock = socket.create_connection(srv.server_address, timeout=5)
|
||||||
|
try:
|
||||||
|
first = _raw_post(sock, "/alertmanager", b"{not json")
|
||||||
|
second = _raw_post(
|
||||||
|
sock,
|
||||||
|
"/glitchtip",
|
||||||
|
b'{"text":"real alert"}',
|
||||||
|
"X-GlitchTip-Secret: correct-secret\r\n",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
finally:
|
||||||
|
srv.shutdown()
|
||||||
|
srv.server_close()
|
||||||
|
assert "400" in first
|
||||||
|
assert "200" in second, f"второй запрос испорчен первым: {second}"
|
||||||
|
assert sent == [{"text": "real alert"}]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue