Срез 0 [5.2] T11.1: PUBLISH_RETRIES — число повторов после первой попытки (до 4 попыток), R-13
publish делает первую попытку и до retries повторов (retries=0 — одна попытка), PublishFailed.attempts — фактическое число попыток. Комментарий PUBLISH_RETRIES в config.py уточнён. Докстринги: PublishFailed не значит «не доставлено» (T11.3), колбэк подписки должен возвращаться быстро (T11.4). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
5d82607715
commit
c3a62bb27c
@ -60,7 +60,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# NATS: подписка на recognition.image.request и публикация. Выключено — воркер не запускается (dev без NATS)
|
||||
NATS_ENABLED: bool = True
|
||||
PUBLISH_RETRIES: PositiveInt = 3 # попытки публикации результата/ack
|
||||
PUBLISH_RETRIES: PositiveInt = 3 # повторы публикации результата/ack после первой попытки (всего до 4)
|
||||
DONE_CACHE_SIZE: PositiveInt = 1000 # размер LRU обработанных job_id (дедупликация повторов)
|
||||
|
||||
# Горячие папки ИС Сканирование: local — обычный каталог (dev, тесты), smb — SMB-шара платформы
|
||||
|
||||
@ -12,8 +12,8 @@
|
||||
инстансов сообщение получает ровно один. `cb` получает исходное сообщение nats-py (`msg.data`, `msg.headers`);
|
||||
исключения `cb` логируются, подписка живёт.
|
||||
- `publish(subject, payload, msg_id)` — публикация с заголовком `Nats-Msg-Id` и `flush` (подтверждение сервером) с
|
||||
повторами на временных ошибках; исчерпаны — `PublishFailed`. Воркер логирует error и идёт дальше: ядро закроет
|
||||
задание по `reply_deadline` (D-7).
|
||||
повторами на временных ошибках (первая попытка + до `PUBLISH_RETRIES` повторов); исчерпаны — `PublishFailed`.
|
||||
Воркер логирует error и идёт дальше: ядро закроет задание по `reply_deadline` (D-7).
|
||||
- Stream'ов и consumer'ов JetStream нет: `jetstream()` не вызывается.
|
||||
"""
|
||||
|
||||
@ -79,7 +79,9 @@ class NatsNotConnectedError(NatsError):
|
||||
|
||||
|
||||
class PublishFailed(NatsError):
|
||||
"""Публикация не удалась: попытки исчерпаны или ошибка не временная. Причина последней попытки — в `__cause__`."""
|
||||
"""Публикация не подтверждена: повторы исчерпаны или ошибка не временная. Причина последней попытки — в
|
||||
`__cause__`; `attempts` — сколько попыток фактически сделано (0 — соединения не было, при исчерпанных повторах —
|
||||
`retries + 1`)."""
|
||||
|
||||
def __init__(self, message: str, *, subject: str, msg_id: str, attempts: int) -> None:
|
||||
super().__init__(message)
|
||||
@ -211,6 +213,8 @@ class NatsGateway:
|
||||
подтверждения сервером (`flush`).
|
||||
|
||||
`cb` получает исходное `Msg` (`data`, `headers`); его исключения логируются, подписка не падает.
|
||||
`cb` должен возвращаться быстро: nats-py вызывает колбэки одной подписки последовательно, долгая обработка
|
||||
(ожидание ИС) задерживает следующие запросы — её выносят в отдельную задачу.
|
||||
"""
|
||||
if self._client is None:
|
||||
raise NatsNotConnectedError(f"Нет соединения с NATS: подписка на {IMAGE_REQUEST_SUBJECT} невозможна")
|
||||
@ -237,14 +241,18 @@ class NatsGateway:
|
||||
async def publish(self, subject: str, payload: bytes, msg_id: str, *, retries: int | None = None) -> None:
|
||||
"""Публикует `payload` с заголовком `Nats-Msg-Id: <msg_id>` и ждёт подтверждения сервера (`flush`).
|
||||
|
||||
`retries` — число попыток **всего** (по умолчанию `PUBLISH_RETRIES` = 3: первая и до двух повторов), между
|
||||
попытками — пауза `retry_delay`. Повторяются только временные ошибки (`TRANSIENT_PUBLISH_ERRORS`). Попытки
|
||||
исчерпаны, ошибка не временная или соединения не было вовсе (`attempts = 0`) — `PublishFailed`.
|
||||
`retries` — число **повторов** после первой попытки (спека: «повторить до трёх раз»; по умолчанию
|
||||
`PUBLISH_RETRIES` = 3, т.е. не более 4 попыток; `retries = 0` — ровно одна попытка), перед каждым повтором —
|
||||
пауза `retry_delay`. Повторяются только временные ошибки (`TRANSIENT_PUBLISH_ERRORS`). Повторы исчерпаны,
|
||||
ошибка не временная или соединения не было вовсе (`attempts = 0`) — `PublishFailed`.
|
||||
Дубликаты при повторе безопасны: ядро дедуплицирует по `Nats-Msg-Id`.
|
||||
`PublishFailed` не означает «не доставлено»: сообщения, буферизованные nats-py во время переподключения, могут
|
||||
уйти после восстановления соединения; ядро отбросит копии по `Nats-Msg-Id`.
|
||||
"""
|
||||
attempts_total = self._settings.PUBLISH_RETRIES if retries is None else retries
|
||||
if attempts_total < 1:
|
||||
raise ValueError(f"retries должно быть ≥ 1, получено {attempts_total}")
|
||||
retry_count = self._settings.PUBLISH_RETRIES if retries is None else retries
|
||||
if retry_count < 0:
|
||||
raise ValueError(f"retries должно быть ≥ 0, получено {retry_count}")
|
||||
attempts_total = retry_count + 1
|
||||
if self._client is None:
|
||||
raise PublishFailed(
|
||||
f"Нет соединения с NATS: {msg_id} в {subject} не опубликовано",
|
||||
@ -261,7 +269,7 @@ class NatsGateway:
|
||||
except TRANSIENT_PUBLISH_ERRORS as exc:
|
||||
if attempt == attempts_total:
|
||||
raise PublishFailed(
|
||||
f"Публикация {msg_id} в {subject} не удалась за {attempt} попыток ({type(exc).__name__})",
|
||||
f"Публикация {msg_id} в {subject} не удалась, попыток: {attempt} ({type(exc).__name__})",
|
||||
subject=subject,
|
||||
msg_id=msg_id,
|
||||
attempts=attempt,
|
||||
|
||||
@ -298,9 +298,19 @@ async def test_publish_retries_transient_errors_until_success(
|
||||
|
||||
await gateway.publish(RESULT_SUBJECT, b"payload", "ack:job:1")
|
||||
|
||||
assert len(fake.published) == 3 # PUBLISH_RETRIES=3 — три попытки всего
|
||||
assert len(fake.published) == 3 # первая попытка + два повтора из PUBLISH_RETRIES=3
|
||||
assert all(headers == {"Nats-Msg-Id": "ack:job:1"} for _, _, headers in fake.published)
|
||||
assert sleeps == [0, 0] # пауза между попытками, после последней — нет
|
||||
assert sleeps == [0, 0] # пауза перед каждым повтором
|
||||
|
||||
|
||||
async def test_publish_succeeds_on_last_retry(gateway: NatsGateway, fake: FakeNatsClient, sleeps: list[float]):
|
||||
await gateway.connect()
|
||||
fake.publish_errors = [nats_errors.ConnectionClosedError()] * 3
|
||||
|
||||
await gateway.publish(RESULT_SUBJECT, b"payload", "ack:job:1")
|
||||
|
||||
assert len(fake.published) == 4 # 1 + 3 повтора: последний повтор успешен
|
||||
assert sleeps == [0, 0, 0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@ -315,7 +325,7 @@ async def test_publish_retries_transient_errors_until_success(
|
||||
ConnectionResetError(),
|
||||
],
|
||||
)
|
||||
async def test_publish_raises_publish_failed_after_exactly_retries_attempts(
|
||||
async def test_publish_raises_publish_failed_after_initial_attempt_and_all_retries(
|
||||
gateway: NatsGateway, fake: FakeNatsClient, error: BaseException
|
||||
):
|
||||
await gateway.connect()
|
||||
@ -324,8 +334,8 @@ async def test_publish_raises_publish_failed_after_exactly_retries_attempts(
|
||||
with pytest.raises(PublishFailed) as exc_info:
|
||||
await gateway.publish(RESULT_SUBJECT, b"payload", "result:job:1")
|
||||
|
||||
assert len(fake.published) == 3
|
||||
assert exc_info.value.attempts == 3
|
||||
assert len(fake.published) == 4 # PUBLISH_RETRIES=3: первая попытка + 3 повтора
|
||||
assert exc_info.value.attempts == 4
|
||||
assert exc_info.value.msg_id == "result:job:1"
|
||||
assert exc_info.value.__cause__ is error
|
||||
|
||||
@ -336,7 +346,20 @@ async def test_publish_retries_parameter_overrides_settings(gateway: NatsGateway
|
||||
|
||||
with pytest.raises(PublishFailed):
|
||||
await gateway.publish(RESULT_SUBJECT, b"payload", "result:job:1", retries=5)
|
||||
assert len(fake.published) == 5
|
||||
assert len(fake.published) == 6
|
||||
|
||||
|
||||
async def test_publish_zero_retries_makes_exactly_one_attempt(
|
||||
gateway: NatsGateway, fake: FakeNatsClient, sleeps: list[float]
|
||||
):
|
||||
await gateway.connect()
|
||||
fake.publish_errors = [nats_errors.ConnectionClosedError()] * 10
|
||||
|
||||
with pytest.raises(PublishFailed) as exc_info:
|
||||
await gateway.publish(RESULT_SUBJECT, b"payload", "result:job:1", retries=0)
|
||||
assert len(fake.published) == 1
|
||||
assert exc_info.value.attempts == 1
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
async def test_publish_uses_publish_retries_from_settings(monkeypatch: pytest.MonkeyPatch, fake: FakeNatsClient):
|
||||
@ -347,7 +370,7 @@ async def test_publish_uses_publish_retries_from_settings(monkeypatch: pytest.Mo
|
||||
|
||||
with pytest.raises(PublishFailed):
|
||||
await gateway.publish(RESULT_SUBJECT, b"payload", "result:job:1")
|
||||
assert len(fake.published) == 2
|
||||
assert len(fake.published) == 3 # PUBLISH_RETRIES=2: 1 + 2 повтора
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error", [nats_errors.MaxPayloadError(), nats_errors.BadSubjectError(), ValueError("x")])
|
||||
@ -371,10 +394,10 @@ async def test_publish_without_connection_fails_without_attempts(gateway: NatsGa
|
||||
assert fake.published == []
|
||||
|
||||
|
||||
async def test_publish_rejects_non_positive_retries(gateway: NatsGateway):
|
||||
async def test_publish_rejects_negative_retries(gateway: NatsGateway):
|
||||
await gateway.connect()
|
||||
with pytest.raises(ValueError, match="retries"):
|
||||
await gateway.publish(RESULT_SUBJECT, b"payload", "result:job:1", retries=0)
|
||||
await gateway.publish(RESULT_SUBJECT, b"payload", "result:job:1", retries=-1)
|
||||
|
||||
|
||||
# --- публикация конверта ----------------------------------------------------------------------------------------
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user