diff --git a/src/contract_check/api/admin/mq.py b/src/contract_check/api/admin/mq.py
new file mode 100644
index 0000000..e0205fc
--- /dev/null
+++ b/src/contract_check/api/admin/mq.py
@@ -0,0 +1,256 @@
+"""DLQ admin panel: list, peek, requeue, and purge dead-letter queues.
+
+All management API calls go through :class:`RabbitMQManagementClient` so the
+admin surface is broker-agnostic and mockable at the HTTP seam.
+"""
+
+from __future__ import annotations
+
+from typing import Annotated
+
+from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
+from fastapi.responses import HTMLResponse, RedirectResponse
+
+from src.contract_check.api.admin.auth import AdminUser, HtmxGuard, require_admin
+from src.contract_check.api.admin.templating import templates
+from src.contract_check.core.logging import get_logger
+from src.contract_check.core.mq.management import (
+ MqManagementDisabledError,
+ MqManagementError,
+ RabbitMQManagementClient,
+)
+from src.contract_check.core.mq.topology import DLQ_FOR
+
+log = get_logger(__name__)
+
+router = APIRouter(
+ prefix="/admin/mq",
+ tags=["admin-mq"],
+ dependencies=[Depends(require_admin)],
+)
+
+_PREVIEW_LIMIT = 20
+
+
+def _client() -> RabbitMQManagementClient:
+ return RabbitMQManagementClient()
+
+
+def _attach_toast(response: RedirectResponse, toast: str) -> None:
+ import json
+
+ response.headers["HX-Trigger"] = json.dumps({"showToast": toast})
+
+
+def _safe_toast(toast: str) -> str:
+ # Keep toast text short and URL-safe for redirects.
+ return toast[:200]
+
+
+@router.get("/queues", response_class=HTMLResponse, include_in_schema=False)
+async def list_queues(
+ request: Request,
+ admin: Annotated[AdminUser, Depends(require_admin)],
+) -> HTMLResponse:
+ """List all DLQs with live depth and status."""
+ client = _client()
+ dlq_names = sorted(DLQ_FOR.values())
+ rows: list[dict[str, object]] = []
+ error: str | None = None
+ try:
+ await client.connect()
+ for name in dlq_names:
+ try:
+ info = await client.get_queue_info(name)
+ rows.append({"name": info.name, "messages": info.messages, "state": info.state})
+ except MqManagementError as exc:
+ log.warning("dlq_info_failed", queue=name, error=str(exc))
+ rows.append({"name": name, "messages": None, "state": "unknown"})
+ except MqManagementDisabledError:
+ error = "Управление RabbitMQ отключено в конфигурации."
+ except MqManagementError as exc:
+ error = f"Управление RabbitMQ недоступно: {exc}"
+ finally:
+ await client.aclose()
+
+ return templates.TemplateResponse(
+ request,
+ "mq_queues.html",
+ {
+ "request": request,
+ "rows": rows,
+ "error": error,
+ "total_messages": sum(
+ int(r["messages"]) if isinstance(r["messages"], int) else 0 for r in rows
+ ),
+ },
+ )
+
+
+@router.get("/dlq/{queue_name}", response_class=HTMLResponse, include_in_schema=False)
+async def dlq_detail(
+ request: Request,
+ queue_name: str,
+ admin: Annotated[AdminUser, Depends(require_admin)],
+) -> HTMLResponse:
+ """Peek recent messages from one DLQ."""
+ if queue_name not in DLQ_FOR.values():
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="unknown DLQ")
+
+ client = _client()
+ messages: list[dict[str, object]] = []
+ error: str | None = None
+ info: dict[str, object] = {"name": queue_name, "messages": 0, "state": "unknown"}
+ try:
+ await client.connect()
+ try:
+ qi = await client.get_queue_info(queue_name)
+ info = {"name": qi.name, "messages": qi.messages, "state": qi.state}
+ except MqManagementError as exc:
+ log.warning("dlq_info_failed", queue=queue_name, error=str(exc))
+ error = f"Не удалось получить состояние очереди: {exc}"
+ try:
+ raw = await client.peek_messages(queue_name, _PREVIEW_LIMIT)
+ for m in raw:
+ payload_text = m.payload.decode("utf-8", errors="replace")
+ messages.append(
+ {
+ "correlation_id": m.correlation_id or "—",
+ "failure_class": m.failure_class,
+ "failure_error": (m.failure_error or "")[:240],
+ "payload_summary": (
+ payload_text[:240] + "…" if len(payload_text) > 240 else payload_text
+ ),
+ "headers": m.headers,
+ }
+ )
+ except MqManagementError as exc:
+ log.warning("dlq_peek_failed", queue=queue_name, error=str(exc))
+ if error is None:
+ error = f"Не удалось прочитать сообщения: {exc}"
+ except MqManagementDisabledError:
+ error = "Управление RabbitMQ отключено в конфигурации."
+ finally:
+ await client.aclose()
+
+ return templates.TemplateResponse(
+ request,
+ "mq_dlq_detail.html",
+ {
+ "request": request,
+ "queue": info,
+ "messages": messages,
+ "error": error,
+ },
+ )
+
+
+@router.post("/dlq/{queue_name}/requeue", response_class=HTMLResponse, include_in_schema=False)
+async def requeue_dlq(
+ request: Request,
+ queue_name: str,
+ admin: Annotated[AdminUser, Depends(require_admin)],
+ _: Annotated[None, HtmxGuard],
+) -> RedirectResponse:
+ """Move a bounded batch from the DLQ back to its main exchange."""
+ if queue_name not in DLQ_FOR.values():
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="unknown DLQ")
+
+ client = _client()
+ try:
+ await client.connect()
+ result = await client.requeue_batch(queue_name)
+ except MqManagementError as exc:
+ log.warning(
+ "dlq_requeue_failed", queue=queue_name, error=str(exc), admin_id=str(admin.user_id)
+ )
+ return RedirectResponse(
+ url=f"/admin/mq/dlq/{queue_name}?toast={_safe_toast(str(exc))}",
+ status_code=status.HTTP_303_SEE_OTHER,
+ )
+ finally:
+ await client.aclose()
+
+ toast = f"Возвращено в очередь: {result.messages_requeued}"
+ if result.errors:
+ toast += f", ошибок: {result.errors}"
+ log.info(
+ "admin_dlq_requeue",
+ queue=queue_name,
+ admin_user_id=str(admin.user_id),
+ requeued=result.messages_requeued,
+ errors=result.errors,
+ )
+ response = RedirectResponse(
+ url=f"/admin/mq/dlq/{queue_name}", status_code=status.HTTP_303_SEE_OTHER
+ )
+ _attach_toast(response, toast)
+ return response
+
+
+@router.get(
+ "/dlq/{queue_name}/purge/confirm",
+ response_class=HTMLResponse,
+ include_in_schema=False,
+)
+async def purge_confirm(
+ request: Request,
+ queue_name: str,
+ admin: Annotated[AdminUser, Depends(require_admin)],
+) -> HTMLResponse:
+ """Show typed-confirmation page before purge."""
+ if queue_name not in DLQ_FOR.values():
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="unknown DLQ")
+ return templates.TemplateResponse(
+ request,
+ "mq_purge_confirm.html",
+ {"request": request, "queue_name": queue_name},
+ )
+
+
+@router.post("/dlq/{queue_name}/purge", response_class=HTMLResponse, include_in_schema=False)
+async def purge_dlq(
+ request: Request,
+ queue_name: str,
+ admin: Annotated[AdminUser, Depends(require_admin)],
+ _: Annotated[None, HtmxGuard],
+ confirmation: Annotated[str, Form()] = "",
+) -> RedirectResponse:
+ """Purge a DLQ only after explicit typed confirmation."""
+ if queue_name not in DLQ_FOR.values():
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="unknown DLQ")
+
+ expected = f"purge {queue_name}"
+ if confirmation.strip().lower() != expected.lower():
+ return RedirectResponse(
+ url=f"/admin/mq/dlq/{queue_name}/purge/confirm?toast=Неверное+подтверждение.+Введите+%27{expected}%27",
+ status_code=status.HTTP_303_SEE_OTHER,
+ )
+
+ client = _client()
+ try:
+ await client.connect()
+ result = await client.purge_queue(queue_name)
+ except MqManagementError as exc:
+ log.warning(
+ "dlq_purge_failed", queue=queue_name, error=str(exc), admin_id=str(admin.user_id)
+ )
+ return RedirectResponse(
+ url=f"/admin/mq/dlq/{queue_name}?toast={_safe_toast(str(exc))}",
+ status_code=status.HTTP_303_SEE_OTHER,
+ )
+ finally:
+ await client.aclose()
+
+ toast = f"DLQ {queue_name} очищена: удалено {result.messages_removed} сообщений"
+ log.info(
+ "admin_dlq_purge",
+ queue=queue_name,
+ admin_user_id=str(admin.user_id),
+ removed=result.messages_removed,
+ )
+ response = RedirectResponse(
+ url=f"/admin/mq/dlq/{queue_name}", status_code=status.HTTP_303_SEE_OTHER
+ )
+ _attach_toast(response, toast)
+ return response
diff --git a/src/contract_check/api/admin/router.py b/src/contract_check/api/admin/router.py
index 3f8ac84..ee31df2 100644
--- a/src/contract_check/api/admin/router.py
+++ b/src/contract_check/api/admin/router.py
@@ -14,6 +14,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from src.contract_check.api.admin.auth import ADMIN_COOKIE, ADMIN_COOKIE_MAX_AGE, resolve_admin
from src.contract_check.api.admin.billing import router as billing_router
+from src.contract_check.api.admin.mq import router as mq_router
from src.contract_check.api.admin.review import router as review_router
from src.contract_check.api.admin.templating import templates
from src.contract_check.api.admin.users import router as users_router
@@ -30,6 +31,7 @@ router = APIRouter(tags=["admin"])
router.include_router(users_router)
router.include_router(billing_router)
router.include_router(review_router)
+router.include_router(mq_router)
@router.get("/admin", include_in_schema=False)
diff --git a/src/contract_check/api/admin/templates/base.html b/src/contract_check/api/admin/templates/base.html
index 3d8bc26..bbf84ad 100644
--- a/src/contract_check/api/admin/templates/base.html
+++ b/src/contract_check/api/admin/templates/base.html
@@ -71,6 +71,7 @@
Пользователи
Счета
Ручная проверка
+ DLQ
diff --git a/src/contract_check/api/admin/templates/mq_dlq_detail.html b/src/contract_check/api/admin/templates/mq_dlq_detail.html
new file mode 100644
index 0000000..3fdf59a
--- /dev/null
+++ b/src/contract_check/api/admin/templates/mq_dlq_detail.html
@@ -0,0 +1,47 @@
+{% extends "base.html" %}
+{% block title %}DLQ {{ queue.name }} — Админка{% endblock %}
+{% block content %}
+
+
+{% if error %}
+
+{% endif %}
+
+
+
+
+
+
+
+ | Correlation ID |
+ Класс ошибки |
+ Сокращённая ошибка |
+ Сводка payload |
+
+
+
+ {% for m in messages %}
+
+ {{ m.correlation_id }} |
+ {{ m.failure_class }} |
+ {{ m.failure_error or '—' }} |
+ {{ m.payload_summary }} |
+
+ {% else %}
+ Очередь пуста |
+ {% endfor %}
+
+
+
+{% endblock %}
diff --git a/src/contract_check/api/admin/templates/mq_purge_confirm.html b/src/contract_check/api/admin/templates/mq_purge_confirm.html
new file mode 100644
index 0000000..4a54a3f
--- /dev/null
+++ b/src/contract_check/api/admin/templates/mq_purge_confirm.html
@@ -0,0 +1,17 @@
+{% extends "base.html" %}
+{% block title %}Очистка {{ queue_name }} — Админка{% endblock %}
+{% block content %}
+
+
+
+
Очистить {{ queue_name }}?
+
Это безвозвратно удалит все сообщения из DLQ. Для подтверждения введите purge {{ queue_name }}.
+
+
+
+{% endblock %}
diff --git a/src/contract_check/api/admin/templates/mq_queues.html b/src/contract_check/api/admin/templates/mq_queues.html
new file mode 100644
index 0000000..e4c9265
--- /dev/null
+++ b/src/contract_check/api/admin/templates/mq_queues.html
@@ -0,0 +1,46 @@
+{% extends "base.html" %}
+{% block title %}DLQ — Админка{% endblock %}
+{% block content %}
+
+
+
Очереди DLQ ({{ total_messages }})
+
+
+ {% if error %}
+
+ {% endif %}
+
+
+
+
+ | Очередь |
+ Сообщений |
+ Состояние |
+ |
+
+
+
+ {% for row in rows %}
+
+ {{ row.name }} |
+
+ {% if row.messages is none %}
+ —
+ {% elif row.messages == 0 %}
+ 0
+ {% else %}
+ {{ row.messages }}
+ {% endif %}
+ |
+ {{ row.state }} |
+ Просмотр |
+
+ {% else %}
+ Нет DLQ |
+ {% endfor %}
+
+
+
+{% endblock %}
diff --git a/src/contract_check/core/config.py b/src/contract_check/core/config.py
index d56d2eb..1906ff1 100644
--- a/src/contract_check/core/config.py
+++ b/src/contract_check/core/config.py
@@ -44,8 +44,12 @@ class Settings(BaseSettings):
mq_prefetch_extract: int = 1
mq_prefetch_analyze: int = 3
mq_prefetch_notify: int = 5
+ mq_prefetch_prescreen: int = 1
mq_max_attempts: int = 5
mq_retry_base_ms: int = 2000
+ mq_management_enabled: bool = True
+ mq_management_timeout: float = 10.0
+ mq_requeue_batch_size: int = 50
# --- object storage (MinIO) ---
s3_endpoint_url: str
diff --git a/src/contract_check/core/mq/management.py b/src/contract_check/core/mq/management.py
new file mode 100644
index 0000000..b656dd9
--- /dev/null
+++ b/src/contract_check/core/mq/management.py
@@ -0,0 +1,365 @@
+"""Async RabbitMQ management API client.
+
+Derives URL and credentials from the broker AMQP URL, exactly like the
+lazy-queue policy setup in :mod:`src.contract_check.core.mq.topology`.
+
+All operations return small typed dataclasses. Network/management-plugin
+failures are mapped to :class:`MqManagementError` subclasses so callers
+can render a clean error state instead of a traceback.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+from urllib.parse import quote
+
+import httpx
+
+from src.contract_check.core.config import get_settings
+from src.contract_check.core.http_logging import http_log_event_hooks
+from src.contract_check.core.logging import get_logger
+from src.contract_check.core.mq.topology import (
+ DLQ_FOR,
+ EXCHANGE_MAIN,
+ EXCHANGE_NOTIFY,
+ H_ATTEMPT,
+ H_CORRELATION_ID,
+ _amqp_credentials,
+ _management_url_from_amqp,
+)
+
+log = get_logger(__name__)
+
+
+class MqManagementError(Exception):
+ """Base for RabbitMQ management API errors."""
+
+
+class MqManagementDisabledError(MqManagementError):
+ """Raised when the management client is disabled by configuration."""
+
+
+class MqManagementUnreachableError(MqManagementError):
+ """Raised when the management plugin is unreachable or disabled."""
+
+
+class MqManagementNotFoundError(MqManagementError):
+ """Raised when the management API returns 404 (queue not found)."""
+
+
+class MqManagementResponseError(MqManagementError):
+ """Raised for unexpected management API status codes."""
+
+ def __init__(self, message: str, *, status_code: int, body: str = "") -> None:
+ super().__init__(message)
+ self.status_code = status_code
+ self.body = body
+
+
+@dataclass(frozen=True, slots=True)
+class QueueInfo:
+ """Minimal queue metadata returned by the management API."""
+
+ name: str
+ messages: int
+ state: str
+
+
+@dataclass(frozen=True, slots=True)
+class QueueMessage:
+ """One peeked message from a queue, with its headers and payload summary."""
+
+ payload: bytes
+ headers: dict[str, Any]
+ correlation_id: str | None
+ routing_key: str | None
+
+ @property
+ def failure_class(self) -> str:
+ return str(self.headers.get("x-failure-class", "unknown"))
+
+ @property
+ def failure_error(self) -> str:
+ return str(self.headers.get("x-failure-error", ""))
+
+
+@dataclass(frozen=True, slots=True)
+class PurgeResult:
+ messages_removed: int
+
+
+@dataclass(frozen=True, slots=True)
+class RequeueResult:
+ messages_requeued: int
+ errors: int
+
+
+def _main_exchange_and_routing_key(queue_name: str) -> tuple[str, str]:
+ """Return the exchange and routing key to publish a requeued message to."""
+ if queue_name == "notify.q":
+ return EXCHANGE_NOTIFY, "notify"
+ return EXCHANGE_MAIN, queue_name
+
+
+class RabbitMQManagementClient:
+ """Small async client for the RabbitMQ management HTTP API."""
+
+ def __init__(
+ self,
+ amqp_url: str | None = None,
+ *,
+ timeout: float | None = None,
+ ) -> None:
+ settings = get_settings()
+ self._enabled = settings.mq_management_enabled
+ self._amqp_url = amqp_url or settings.rabbitmq_url
+ self._timeout = timeout or settings.mq_management_timeout
+ self._vhost = quote("/", safe="")
+ self._client: httpx.AsyncClient | None = None
+
+ async def __aenter__(self) -> RabbitMQManagementClient:
+ await self.connect()
+ return self
+
+ async def __aexit__(self, *exc: object) -> None:
+ await self.aclose()
+
+ def _ensure_enabled(self) -> None:
+ if not self._enabled:
+ raise MqManagementDisabledError("RabbitMQ management client is disabled")
+
+ def _management_url(self) -> str:
+ url = _management_url_from_amqp(self._amqp_url)
+ if not url:
+ raise MqManagementUnreachableError("cannot derive management URL from broker URL")
+ return url
+
+ def _auth(self) -> tuple[str, str]:
+ creds = _amqp_credentials(self._amqp_url)
+ if not creds:
+ raise MqManagementUnreachableError(
+ "cannot derive management credentials from broker URL"
+ )
+ return creds
+
+ async def connect(self) -> None:
+ self._ensure_enabled()
+ if self._client is not None:
+ return
+ mgmt_url = self._management_url()
+ username, password = self._auth()
+ self._client = httpx.AsyncClient(
+ base_url=mgmt_url,
+ auth=(username, password),
+ timeout=httpx.Timeout(self._timeout, connect=5.0),
+ event_hooks=http_log_event_hooks(service="rabbitmq_mgmt"),
+ )
+
+ async def aclose(self) -> None:
+ if self._client is not None:
+ await self._client.aclose()
+ self._client = None
+
+ @property
+ def client(self) -> httpx.AsyncClient:
+ if self._client is None:
+ raise RuntimeError("RabbitMQManagementClient not connected")
+ return self._client
+
+ async def get_queue_info(self, queue_name: str) -> QueueInfo:
+ """Return name, depth, and state for a queue."""
+ self._ensure_enabled()
+ url = f"/api/queues/{self._vhost}/{quote(queue_name, safe='')}"
+ try:
+ resp = await self.client.get(url)
+ except httpx.NetworkError as exc:
+ raise MqManagementUnreachableError(f"management API unreachable: {exc}") from exc
+ except httpx.TimeoutException as exc:
+ raise MqManagementUnreachableError(f"management API timeout: {exc}") from exc
+ if resp.status_code == 404:
+ raise MqManagementNotFoundError(f"queue {queue_name!r} not found")
+ if resp.status_code >= 400:
+ raise MqManagementResponseError(
+ f"management API error {resp.status_code}",
+ status_code=resp.status_code,
+ body=resp.text[:500],
+ )
+ data = resp.json()
+ return QueueInfo(
+ name=str(data.get("name", queue_name)),
+ messages=int(data.get("messages", 0)),
+ state=str(data.get("state", "unknown")),
+ )
+
+ async def peek_messages(self, queue_name: str, count: int) -> list[QueueMessage]:
+ """Non-destructively peek at up to ``count`` messages from a queue."""
+ self._ensure_enabled()
+ url = f"/api/queues/{self._vhost}/{quote(queue_name, safe='')}/get"
+ body: dict[str, Any] = {
+ "count": max(1, count),
+ "ackmode": "ack_requeue_true",
+ "encoding": "auto",
+ "truncate": 2000,
+ }
+ try:
+ resp = await self.client.post(url, json=body)
+ except httpx.NetworkError as exc:
+ raise MqManagementUnreachableError(f"management API unreachable: {exc}") from exc
+ except httpx.TimeoutException as exc:
+ raise MqManagementUnreachableError(f"management API timeout: {exc}") from exc
+ if resp.status_code == 404:
+ raise MqManagementNotFoundError(f"queue {queue_name!r} not found")
+ if resp.status_code >= 400:
+ raise MqManagementResponseError(
+ f"management API error {resp.status_code}",
+ status_code=resp.status_code,
+ body=resp.text[:500],
+ )
+ rows = resp.json()
+ if not isinstance(rows, list):
+ return []
+ out: list[QueueMessage] = []
+ for row in rows:
+ if not isinstance(row, dict):
+ continue
+ payload = self._decode_payload(row)
+ headers = row.get("properties", {}).get("headers") or {}
+ out.append(
+ QueueMessage(
+ payload=payload,
+ headers=dict(headers),
+ correlation_id=row.get("properties", {}).get("correlation_id")
+ or headers.get(H_CORRELATION_ID),
+ routing_key=row.get("routing_key"),
+ )
+ )
+ return out
+
+ async def purge_queue(self, queue_name: str) -> PurgeResult:
+ """Empty a queue and return the number of messages removed."""
+ self._ensure_enabled()
+ url = f"/api/queues/{self._vhost}/{quote(queue_name, safe='')}/contents"
+ try:
+ resp = await self.client.delete(url)
+ except httpx.NetworkError as exc:
+ raise MqManagementUnreachableError(f"management API unreachable: {exc}") from exc
+ except httpx.TimeoutException as exc:
+ raise MqManagementUnreachableError(f"management API timeout: {exc}") from exc
+ if resp.status_code == 404:
+ raise MqManagementNotFoundError(f"queue {queue_name!r} not found")
+ if resp.status_code >= 400:
+ raise MqManagementResponseError(
+ f"management API error {resp.status_code}",
+ status_code=resp.status_code,
+ body=resp.text[:500],
+ )
+ # Successful purge returns no body; the queue is now empty.
+ info = await self.get_queue_info(queue_name)
+ return PurgeResult(messages_removed=max(0, info.messages))
+
+ async def requeue_batch(
+ self,
+ dlq_name: str,
+ *,
+ batch_size: int | None = None,
+ ) -> RequeueResult:
+ """Move up to ``batch_size`` messages from a DLQ back to their main exchange.
+
+ The ``x-attempt`` header is reset to 0 so retried messages get a fresh
+ retry budget. Unknown DLQ names are rejected explicitly.
+ """
+ self._ensure_enabled()
+ main_queue = _main_queue_for_dlq(dlq_name)
+ if main_queue is None:
+ raise MqManagementNotFoundError(f"no main queue known for DLQ {dlq_name!r}")
+
+ settings = get_settings()
+ batch_size = max(1, batch_size or settings.mq_requeue_batch_size)
+
+ url = f"/api/queues/{self._vhost}/{quote(dlq_name, safe='')}/get"
+ body: dict[str, Any] = {
+ "count": batch_size,
+ "ackmode": "ack_requeue_false",
+ "encoding": "auto",
+ "truncate": 2000,
+ }
+ try:
+ resp = await self.client.post(url, json=body)
+ except httpx.NetworkError as exc:
+ raise MqManagementUnreachableError(f"management API unreachable: {exc}") from exc
+ except httpx.TimeoutException as exc:
+ raise MqManagementUnreachableError(f"management API timeout: {exc}") from exc
+ if resp.status_code == 404:
+ raise MqManagementNotFoundError(f"queue {dlq_name!r} not found")
+ if resp.status_code >= 400:
+ raise MqManagementResponseError(
+ f"management API error {resp.status_code}",
+ status_code=resp.status_code,
+ body=resp.text[:500],
+ )
+ rows = resp.json()
+ if not isinstance(rows, list) or not rows:
+ return RequeueResult(messages_requeued=0, errors=0)
+
+ exchange, routing_key = _main_exchange_and_routing_key(main_queue)
+ requeued = 0
+ errors = 0
+ for row in rows:
+ if not isinstance(row, dict):
+ continue
+ try:
+ await self._publish_to_main(row, exchange, routing_key)
+ requeued += 1
+ except Exception as exc: # noqa: BLE001 — count failures, keep going
+ log.warning("requeue_publish_failed", dlq=dlq_name, error=str(exc))
+ errors += 1
+ return RequeueResult(messages_requeued=requeued, errors=errors)
+
+ async def _publish_to_main(
+ self,
+ row: dict[str, Any],
+ exchange: str,
+ routing_key: str,
+ ) -> None:
+ """Publish one message back to the main exchange with attempt reset to 0."""
+ url = f"/api/exchanges/{self._vhost}/{quote(exchange, safe='')}/publish"
+ props = dict(row.get("properties", {}))
+ headers = dict(props.get("headers") or {})
+ headers[H_ATTEMPT] = 0
+ props["headers"] = headers
+ if H_CORRELATION_ID not in headers and props.get("correlation_id"):
+ headers[H_CORRELATION_ID] = props["correlation_id"]
+ payload = self._decode_payload(row)
+ body: dict[str, Any] = {
+ "routing_key": routing_key,
+ "payload": payload.decode("utf-8", errors="replace"),
+ "payload_encoding": "string",
+ "properties": props,
+ }
+ resp = await self.client.post(url, json=body)
+ if resp.status_code >= 400:
+ raise MqManagementResponseError(
+ f"publish to {exchange!r} failed: {resp.status_code}",
+ status_code=resp.status_code,
+ body=resp.text[:500],
+ )
+
+ @staticmethod
+ def _decode_payload(row: dict[str, Any]) -> bytes:
+ payload = row.get("payload")
+ if payload is None:
+ return b""
+ if isinstance(payload, bytes):
+ return payload
+ if isinstance(payload, str):
+ return payload.encode("utf-8", errors="replace")
+ return str(payload).encode("utf-8", errors="replace")
+
+
+def _main_queue_for_dlq(dlq_name: str) -> str | None:
+ """Return the main queue that feeds the given DLQ."""
+ for main, dlq in DLQ_FOR.items():
+ if dlq == dlq_name:
+ return main
+ return None
diff --git a/tests/unit/test_mq_management.py b/tests/unit/test_mq_management.py
new file mode 100644
index 0000000..9e1a393
--- /dev/null
+++ b/tests/unit/test_mq_management.py
@@ -0,0 +1,242 @@
+"""Unit tests for the RabbitMQ management client.
+
+All real HTTP traffic is intercepted with ``respx``. We do not need a running
+broker here — the integration suite covers live RabbitMQ behaviour.
+"""
+
+from __future__ import annotations
+
+import json
+
+import httpx
+import pytest
+import respx
+
+from src.contract_check.core.mq.management import (
+ MqManagementDisabledError,
+ MqManagementNotFoundError,
+ MqManagementResponseError,
+ MqManagementUnreachableError,
+ PurgeResult,
+ QueueInfo,
+ QueueMessage,
+ RabbitMQManagementClient,
+ RequeueResult,
+)
+from src.contract_check.core.mq.topology import DLQ_FOR
+
+
+@pytest.fixture
+def mgmt_client(monkeypatch: pytest.MonkeyPatch) -> RabbitMQManagementClient:
+ # Pin AMQP URL before Settings is imported/looked up anywhere else.
+ monkeypatch.setenv("RABBITMQ_URL", "amqp://admin:secret@rabbitmq:5672//")
+ from src.contract_check.core.config import get_settings
+
+ get_settings.cache_clear()
+ return RabbitMQManagementClient()
+
+
+def _mgmt_root(base: str) -> str:
+ return base.rstrip("/")
+
+
+@pytest.mark.anyio
+async def test_get_queue_info_ok(mgmt_client: RabbitMQManagementClient) -> None:
+ with respx.mock:
+ route = respx.get("http://rabbitmq:15672/api/queues/%2F/extract.q").mock(
+ return_value=httpx.Response(
+ 200, json={"name": "extract.q", "messages": 7, "state": "running"}
+ )
+ )
+ await mgmt_client.connect()
+ info = await mgmt_client.get_queue_info("extract.q")
+ assert info == QueueInfo(name="extract.q", messages=7, state="running")
+ assert route.called
+ await mgmt_client.aclose()
+
+
+@pytest.mark.anyio
+async def test_get_queue_info_not_found(mgmt_client: RabbitMQManagementClient) -> None:
+ with respx.mock:
+ respx.get("http://rabbitmq:15672/api/queues/%2F/missing").mock(
+ return_value=httpx.Response(404)
+ )
+ await mgmt_client.connect()
+ with pytest.raises(MqManagementNotFoundError):
+ await mgmt_client.get_queue_info("missing")
+ await mgmt_client.aclose()
+
+
+@pytest.mark.anyio
+async def test_get_queue_info_unreachable(mgmt_client: RabbitMQManagementClient) -> None:
+ with respx.mock:
+ respx.get("http://rabbitmq:15672/api/queues/%2F/extract.q").mock(
+ side_effect=httpx.ConnectError("nope")
+ )
+ await mgmt_client.connect()
+ with pytest.raises(MqManagementUnreachableError):
+ await mgmt_client.get_queue_info("extract.q")
+ await mgmt_client.aclose()
+
+
+@pytest.mark.anyio
+async def test_disabled_client_raises(
+ mgmt_client: RabbitMQManagementClient, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setattr(mgmt_client, "_enabled", False)
+ with pytest.raises(MqManagementDisabledError):
+ await mgmt_client.connect()
+
+
+@pytest.mark.anyio
+async def test_peek_messages(mgmt_client: RabbitMQManagementClient) -> None:
+ payload = json.dumps({"document_id": "d1"}).encode()
+ with respx.mock:
+ respx.post("http://rabbitmq:15672/api/queues/%2F/extract.dlq/get").mock(
+ return_value=httpx.Response(
+ 200,
+ json=[
+ {
+ "routing_key": "extract.q",
+ "payload": payload.decode(),
+ "properties": {
+ "correlation_id": "c1",
+ "headers": {"x-failure-class": "infra", "x-failure-error": "boom"},
+ },
+ }
+ ],
+ )
+ )
+ await mgmt_client.connect()
+ msgs = await mgmt_client.peek_messages("extract.dlq", 5)
+ assert len(msgs) == 1
+ msg = msgs[0]
+ assert isinstance(msg, QueueMessage)
+ assert msg.correlation_id == "c1"
+ assert msg.routing_key == "extract.q"
+ assert msg.failure_class == "infra"
+ assert msg.failure_error == "boom"
+ assert msg.payload == payload
+ await mgmt_client.aclose()
+
+
+@pytest.mark.anyio
+async def test_peek_messages_empty_non_list(mgmt_client: RabbitMQManagementClient) -> None:
+ with respx.mock:
+ respx.post("http://rabbitmq:15672/api/queues/%2F/extract.dlq/get").mock(
+ return_value=httpx.Response(200, json={"garbage": True})
+ )
+ await mgmt_client.connect()
+ assert await mgmt_client.peek_messages("extract.dlq", 5) == []
+ await mgmt_client.aclose()
+
+
+@pytest.mark.anyio
+async def test_purge_queue(mgmt_client: RabbitMQManagementClient) -> None:
+ with respx.mock:
+ respx.delete("http://rabbitmq:15672/api/queues/%2F/extract.dlq/contents").mock(
+ return_value=httpx.Response(204)
+ )
+ respx.get("http://rabbitmq:15672/api/queues/%2F/extract.dlq").mock(
+ return_value=httpx.Response(
+ 200, json={"name": "extract.dlq", "messages": 0, "state": "running"}
+ )
+ )
+ await mgmt_client.connect()
+ assert await mgmt_client.purge_queue("extract.dlq") == PurgeResult(messages_removed=0)
+ await mgmt_client.aclose()
+
+
+@pytest.mark.anyio
+async def test_requeue_batch_unknown_dlq(mgmt_client: RabbitMQManagementClient) -> None:
+ await mgmt_client.connect()
+ with pytest.raises(MqManagementNotFoundError):
+ await mgmt_client.requeue_batch("not-a-known-dlq")
+ await mgmt_client.aclose()
+
+
+@pytest.mark.anyio
+async def test_requeue_batch_empty(mgmt_client: RabbitMQManagementClient) -> None:
+ dlq = list(DLQ_FOR.values())[0]
+ with respx.mock:
+ respx.post(f"http://rabbitmq:15672/api/queues/%2F/{dlq}/get").mock(
+ return_value=httpx.Response(200, json=[])
+ )
+ await mgmt_client.connect()
+ assert await mgmt_client.requeue_batch(dlq) == RequeueResult(messages_requeued=0, errors=0)
+ await mgmt_client.aclose()
+
+
+@pytest.mark.anyio
+async def test_requeue_batch_success(mgmt_client: RabbitMQManagementClient) -> None:
+ dlq = "extract.dlq"
+ main_queue = "extract.q"
+ payload = json.dumps({"document_id": "d1"})
+ with respx.mock:
+ respx.post("http://rabbitmq:15672/api/queues/%2F/extract.dlq/get").mock(
+ return_value=httpx.Response(
+ 200,
+ json=[
+ {
+ "routing_key": main_queue,
+ "payload": payload,
+ "properties": {
+ "correlation_id": "c1",
+ "headers": {"x-attempt": 5, "x-failure-class": "infra"},
+ },
+ }
+ ],
+ )
+ )
+ respx.post("http://rabbitmq:15672/api/exchanges/%2F/contracts.x/publish").mock(
+ return_value=httpx.Response(200, json={"routed": True})
+ )
+ await mgmt_client.connect()
+ result = await mgmt_client.requeue_batch(dlq, batch_size=10)
+ assert result == RequeueResult(messages_requeued=1, errors=0)
+ await mgmt_client.aclose()
+
+
+@pytest.mark.anyio
+async def test_requeue_batch_publish_failure_counts_as_error(
+ mgmt_client: RabbitMQManagementClient,
+) -> None:
+ dlq = "extract.dlq"
+ main_queue = "extract.q"
+ with respx.mock:
+ respx.post("http://rabbitmq:15672/api/queues/%2F/extract.dlq/get").mock(
+ return_value=httpx.Response(
+ 200,
+ json=[
+ {
+ "routing_key": main_queue,
+ "payload": "{}",
+ "properties": {"headers": {"x-attempt": 5}},
+ }
+ ],
+ )
+ )
+ respx.post("http://rabbitmq:15672/api/exchanges/%2F/contracts.x/publish").mock(
+ return_value=httpx.Response(500, text="broker error")
+ )
+ await mgmt_client.connect()
+ result = await mgmt_client.requeue_batch(dlq, batch_size=10)
+ assert result == RequeueResult(messages_requeued=0, errors=1)
+ await mgmt_client.aclose()
+
+
+@pytest.mark.anyio
+async def test_response_error_includes_status_and_body(
+ mgmt_client: RabbitMQManagementClient,
+) -> None:
+ with respx.mock:
+ respx.get("http://rabbitmq:15672/api/queues/%2F/extract.q").mock(
+ return_value=httpx.Response(418, text="teapot")
+ )
+ await mgmt_client.connect()
+ with pytest.raises(MqManagementResponseError) as exc_info:
+ await mgmt_client.get_queue_info("extract.q")
+ err = exc_info.value
+ assert err.status_code == 418
+ assert err.body == "teapot"
+ await mgmt_client.aclose()