Ревью pull request по diff

Проводит ревью изменений по diff и описанию PR: соответствует ли код заявленной цели, что сломается рядом, чего не хватает в тестах, — и пишет комментарии в формате для GitHub или GitLab.

// промпт
Ты — опытный ревьюер, который смотрит pull request так, как смотрит его тимлид перед мержем: не переписывает код под свой вкус, а проверяет, что изменение делает заявленное, не ломает соседнее и не оставляет долгов. Проведи ревью моего PR. ## Контекст - **Стек и проект:** {{stek}} - **Описание PR — зачем это изменение:** {{opisanie_izmenenii}} - **Diff:** {{diff}} ## Как действовать 1. **Цель против изменений.** Сначала сверь описание PR с diff: всё ли заявленное реализовано, нет ли изменений, которые к цели не относятся и должны уйти в отдельный PR. 2. **Корректность.** Пройди по изменённой логике: граничные случаи, null и пустые коллекции, ошибки внешних вызовов, конкурентный доступ. Отдельно — что происходит с кодом, который вызывает изменённые функции, но в diff не попал. 3. **Контракты.** Изменились ли публичные сигнатуры, форматы API, схема данных, конфигурация? Кто от этого сломается и нужна ли миграция или обратная совместимость. 4. **Тесты.** Покрывают ли тесты новое поведение и найденные граничные случаи; какие тесты стоит добавить — конкретно, с названием и проверяемым сценарием. 5. **Читаемость.** Только то, что мешает следующему разработчику: непонятные имена, скрытые побочные эффекты, магические значения. Вкусовщину не пиши. ## Формат ответа 1. **Вердикт:** «можно мержить», «мержить после правок» или «нужна переработка» — и почему, в двух предложениях. 2. **Комментарии к строкам** — в виде, готовом для вставки в ревью: `файл:строка`, метка «блокер», «важно» или «мелочь», суть замечания и предложенная правка кодом. 3. **Вопросы автору** — то, что нельзя решить по diff без контекста. Если в diff не видно чего-то нужного для вердикта (вызывающий код, схема, конфиг), так и скажи, а не додумывай.
Заполните переменные

Пример запуска

Сгенерировано Claude Opus 5.5, 20 октября 2026 г.

Значения переменных

Стек
Python 3.11, FastAPI, httpx, Redis 7 (redis-py), pytest; сервис biller-service, ходит во внутренний платёжный гейтвей biller-gateway по HTTP
Описание изменений
Контекст: за последние две недели в проде было 7 случаев двойного списания по одному заказу. Расследование показало, что при таймауте на вызове biller-gateway (сеть иногда отваливается на 3-5 секунд) наш код просто пробрасывал исключение наружу, Celery-таск ретраил весь платёж с нуля без идемпотентности — gateway создавал второй charge на тот же заказ. Что сделано в этом PR: 1. В app/payments/client.py добавлена ретрай-логика с экспоненциальным backoff (до 3 попыток) для сетевых ошибок (ConnectTimeout, ReadTimeout, ConnectError) при вызове POST /v1/charges. 2. Добавлена генерация и кеширование Idempotency-Key в Redis по order_id (TTL 24 часа), чтобы повторные попытки (как внутри retry-цикла, так и при повторном вызове из Celery) слались с одним и тем же ключом и gateway не создавал дубликат charge. 3. Увеличен дефолтный timeout на вызов с 5.0 до 15.0 секунд — по метрикам biller-gateway p99 отклика сейчас около 9 секунд при нагрузке, 5 секунд было слишком мало. 4. Добавлен один тест на успешный ретрай после временного таймаута. Что НЕ входит в этот PR (сознательно оставлено за скобками): не трогал Celery-таск, который дёргает create_charge, и не трогал обработку платежей в биллинге — это отдельная задача BILL-482, запланирована на следующий спринт. Миграций БД нет, Redis-схема новая (ключи idemp:{order_id}), конфиг Redis уже используется в проекте для кеша тарифов, инфраструктура не менялась.
Diff
diff --git a/app/payments/client.py b/app/payments/client.py index 3a1f2c0..9b7e441 100644 --- a/app/payments/client.py +++ b/app/payments/client.py @@ -1,10 +1,14 @@ import logging +import time +import uuid import httpx +from app.cache import redis_client from app.payments.models import ChargeResult logger = logging.getLogger(__name__) BILLER_GATEWAY_URL = "https://biller-gateway.internal.corp" +MAX_RETRIES = 3 +BACKOFF_BASE = 2 @@ -12,19 +16,47 @@ BILLER_GATEWAY_URL = "https://biller-gateway.internal.corp" def create_charge( order_id: str, amount: int, currency: str, - timeout: float = 5.0, + timeout: float = 15.0, ) -> ChargeResult: - response = httpx.post( - f"{BILLER_GATEWAY_URL}/v1/charges", - json={"order_id": order_id, "amount": amount, "currency": currency}, - timeout=timeout, - ) - response.raise_for_status() - return ChargeResult.parse_obj(response.json()) + idempotency_key = _get_or_create_idempotency_key(order_id) + + for attempt in range(1, MAX_RETRIES + 1): + try: + response = httpx.post( + f"{BILLER_GATEWAY_URL}/v1/charges", + json={"order_id": order_id, "amount": amount, "currency": currency}, + headers={"Idempotency-Key": idempotency_key}, + timeout=timeout, + ) + response.raise_for_status() + return ChargeResult.parse_obj(response.json()) + except (httpx.ConnectTimeout, httpx.ReadTimeout, httpx.ConnectError) as exc: + logger.warning( + "biller-gateway retry %s/%s for order %s: %s", + attempt, MAX_RETRIES, order_id, exc, + ) + time.sleep(BACKOFF_BASE ** attempt) + continue + except Exception as exc: + logger.error("biller-gateway unexpected error for order %s: %s", order_id, exc) + return None + + logger.error("biller-gateway exhausted retries for order %s", order_id) + return None + + +def _get_or_create_idempotency_key(order_id: str) -> str: + cached = redis_client.get(f"idemp:{order_id}") + if cached: + return cached.decode() + key = str(uuid.uuid4()) + redis_client.set(f"idemp:{order_id}", key, ex=60 * 60 * 24) + return key diff --git a/app/payments/service.py b/app/payments/service.py index 7c2d9aa..7c2d9aa 100644 --- a/app/payments/service.py +++ b/app/payments/service.py @@ -24,7 +24,7 @@ def process_order_payment(order_id: str, amount: int, currency: str) -> bool: order = get_order(order_id) if order is None: raise OrderNotFoundError(order_id) - result = create_charge(order_id, order.amount, order.currency) + result = create_charge(order_id, amount, currency) order.status = "charged" order.charge_id = result.id save_order(order) diff --git a/tests/test_payments.py b/tests/test_payments.py index 1a2b3cc..5f6e7dd 100644 --- a/tests/test_payments.py +++ b/tests/test_payments.py @@ -1,6 +1,7 @@ import httpx import pytest import respx +from unittest.mock import patch from app.payments.client import create_charge @@ -20,3 +21,22 @@ def test_create_charge_success(respx_mock): result = create_charge("order-1", 1000, "RUB") assert result.id == "ch_123" + + +@patch("app.payments.client.redis_client") +def test_create_charge_retries_on_timeout(mock_redis, respx_mock): + mock_redis.get.return_value = None + route = respx_mock.post("https://biller-gateway.internal.corp/v1/charges") + route.side_effect = [ + httpx.ConnectTimeout("timeout"), + httpx.Response(200, json={"id": "ch_456", "status": "succeeded"}), + ] + + result = create_charge("order-2", 500, "RUB") + + assert result.id == "ch_456" + assert route.call_count == 2

Ревью PR: ретраи и Idempotency-Key для create_charge

1. Вердикт

Мержить после правок. Подход верный: идемпотентный ключ плюс ретраи сетевых ошибок закрывают описанный сценарий двойного списания. Но в текущем виде ключ создаётся с гонкой и может быть вытеснен из Redis, то есть дубль charge всё ещё возможен. Кроме того, create_charge теперь молча возвращает None вместо исключения, а в service.py попала незаявленная смена источника суммы списания. Это три блокера, и все они правятся в рамках этого diff.

Сверка с описанием:

  • Пункты 1–4 реализованы.
  • Изменение в app/payments/service.py в описании не упомянуто. Оно противоречит фразе «не трогал обработку платежей в биллинге» и к цели PR не относится.
  • У service.py в заголовке diff одинаковые хеши (index 7c2d9aa..7c2d9aa), чего у реального изменения быть не может. Проверь, что diff собран из ветки, а не вручную, и что это изменение действительно в ней есть.

Что не видно по diff и влияет на вердикт:

  • Celery-таск, вызывающий process_order_payment: его autoretry_for, max_retries, soft_time_limit.
  • Конфиг redis_client: decode_responses, maxmemory-policy, persistence.
  • Контракт идемпотентности biller-gateway.

Без них часть выводов ниже условна, это отмечено в комментариях и в вопросах.


2. Комментарии к строкам

app/payments/client.py:49-55 — блокер: гонка при создании ключа

get, а затем set выполняются неатомарно. Два конкурентных вызова для одного order_id оба видят None, генерируют разные UUID и шлют разные ключи. Конкурентные вызовы — это, например, Celery-ретрай, запущенный, пока первый воркер ещё висит в ReadTimeout (15 с × 3), или дубль таска. Gateway создаёт два charge, то есть ровно тот баг, который чинит PR.

Есть ещё одна связанная проблема. Redis в проекте уже используется под кеш тарифов, и если там maxmemory-policy allkeys-lru или allkeys-random, ключ idemp:* может быть вытеснен. Тот же эффект дадут рестарт без AOF и failover. Следующая попытка получит новый ключ.

Предлагаю детерминированный ключ: он убирает гонку, вытеснение, TTL и зависимость от Redis в критическом пути одновременно.

_IDEMPOTENCY_NAMESPACE = uuid.UUID("<сгенерировать один раз и зафиксировать>")


def _idempotency_key(order_id: str) -> str:
    # Детерминированный ключ: одинаков для любых повторов по заказу,
    # не зависит от доступности/вытеснения Redis и не имеет гонки.
    return str(uuid.uuid5(_IDEMPOTENCY_NAMESPACE, f"charge:{order_id}"))

Если по заказу легитимно возможны повторные charge (например, после decline другой картой), в ключ нужно добавить номер попытки оплаты из БД, а не из Redis. Если Redis всё же оставляете, то как минимум сделайте атомарную запись:

IDEMPOTENCY_KEY_TTL = 60 * 60 * 24


def _get_or_create_idempotency_key(order_id: str) -> str:
    redis_key = f"idemp:{order_id}"
    candidate = str(uuid.uuid4())
    # SET NX атомарен: из конкурирующих вызовов выиграет ровно один
    if redis_client.set(redis_key, candidate, nx=True, ex=IDEMPOTENCY_KEY_TTL):
        return candidate
    existing = redis_client.get(redis_key)
    if existing is None:
        raise IdempotencyKeyUnavailable(order_id)  # не генерировать новый молча
    return existing.decode() if isinstance(existing, bytes) else existing

Плюс отдельный инстанс или DB Redis с noeviction для этих ключей.

app/payments/client.py:41-43 — блокер: except Exception: return None глотает все ошибки

Под этот except попадают:

  • HTTPStatusError: 4xx (decline, 409 или 422 по идемпотентности, 400 валидации) и 5xx;
  • ошибки парсинга ChargeResult;
  • любые баги в коде.

Вызывающий код получает None без причины. Раньше исключение пробрасывалось, теперь отказ банка неотличим от падения сети.

Отдельно: сигнатура осталась -> ChargeResult, то есть аннотация врёт.

Предлагаю пробрасывать неретраибельные ошибки как есть:

        except RETRYABLE_EXCEPTIONS as exc:
            ...
        # никакого except Exception — 4xx/парсинг/баги летят наружу как раньше

app/payments/client.py:45-46 — блокер: исчерпание ретраев возвращает None

Раньше после сетевой ошибки летело исключение. Теперь возвращается None, а service.py:30 падает с AttributeError: 'NoneType' object has no attribute 'id'.

Как на это отреагирует Celery, по diff не видно. Если в таске autoretry_for=(httpx.HTTPError,) или аналог, ретрая больше не будет и платёж зависнет. Если autoretry_for=(Exception,), ретраить будет, но по AttributeError, что в логах выглядит как баг. В обоих случаях это изменение контракта для кода, который PR сознательно не трогает (BILL-482).

Предлагаю сохранить контракт «ошибка = исключение»:

class BillerGatewayUnavailable(Exception):
    def __init__(self, order_id: str):
        super().__init__(f"biller-gateway unavailable for order {order_id}")
        self.order_id = order_id

...
    logger.error("biller-gateway exhausted retries for order %s", order_id)
    raise BillerGatewayUnavailable(order_id) from last_exc

Если хочется сохранить совместимость с текущим autoretry_for, можно наследоваться от httpx.TransportError, но лучше явно проверить таск.

app/payments/service.py:28 — блокер: незаявленная смена источника суммы

Было order.amount и order.currency из БД, стало amount и currency из аргументов. Это меняет, сколько списывается: теперь сумму определяет вызывающий, а не заказ. Если аргументы где-то расходятся с заказом (устаревшее значение в payload таска, копейки против рублей), получим неверное списание.

Это не связано с ретраями и идемпотентностью. Нужно либо откатить, либо вынести в отдельный PR с обоснованием.

    result = create_charge(order_id, order.amount, order.currency)

Если цель была в том, чтобы аргументы amount и currency перестали быть мёртвыми, правильнее сверять их с заказом и падать при расхождении. Но это тоже отдельный PR.

app/payments/service.py:28-31 — важно: вызывающий код не адаптирован к новому контракту

Даже если create_charge останется с None, здесь нет проверки. Кроме того, order.status = "charged" выставляется до проверки результата. Если order — объект ORM-сессии с autoflush или commit в обработчике ошибок, статус charged может сохраниться без charge_id. Если create_charge бросает исключение (см. выше), проблема снимается. Для надёжности порядок стоит поменять:

    result = create_charge(order_id, order.amount, order.currency)
    order.charge_id = result.id
    order.status = "charged"
    save_order(order)

app/payments/client.py:34 — важно: неполный список ретраибельных ошибок

Сейчас за бортом:

  • WriteTimeout и PoolTimeout;
  • ReadError и RemoteProtocolError: обрыв соединения посреди ответа, типичный случай для «сеть отваливается на 3–5 секунд»;
  • 502/503/504 от gateway или балансировщика.

С идемпотентным ключом всё это безопасно ретраить. Сейчас они уходят в except Exception и превращаются в None.

RETRYABLE_EXCEPTIONS = (
    httpx.TimeoutException,       # Connect/Read/Write/PoolTimeout
    httpx.ConnectError,
    httpx.ReadError,
    httpx.RemoteProtocolError,
)
RETRYABLE_STATUSES = {502, 503, 504}

...
        try:
            response = httpx.post(...)
            response.raise_for_status()
            return ChargeResult.parse_obj(response.json())
        except httpx.HTTPStatusError as exc:
            if exc.response.status_code not in RETRYABLE_STATUSES:
                raise
            last_exc = exc
        except RETRYABLE_EXCEPTIONS as exc:
            last_exc = exc
        logger.warning(
            "biller-gateway retry %s/%s for order %s: %r",
            attempt, MAX_RETRIES, order_id, last_exc,
        )
        if attempt < MAX_RETRIES:
            time.sleep(BACKOFF_BASE ** attempt)

app/payments/client.py:39 — важно: sleep после последней попытки и суммарное время

После третьей неудачи код спит ещё 8 секунд, прежде чем сдаться. Это лишнее.

Худший случай сейчас: 3 × 15 с таймаута + 2 + 4 + 8 = ~59 с блокировки воркера. Нужно сверить это с soft_time_limit и time_limit Celery-таска, а при Redis-брокере ещё и с visibility_timeout. Если лимит меньше, таск убьют посреди попытки и Celery перезапустит его. С детерминированным ключом это безопасно, с текущей реализацией нет.

Правка if attempt < MAX_RETRIES показана выше. Ещё стоит добавить jitter (+ random.uniform(0, 1)), чтобы после сетевого сбоя все воркеры не ретраили синхронно.

app/payments/client.py:20,30 — важно: timeout=15.0 применяется и к connect

В httpx скаляр задаёт одинаковый таймаут для connect, read, write и pool. Ответ p99 ~9 с — аргумент про read. Ждать TCP-коннект до внутреннего сервиса 15 секунд бессмысленно: при «отвалившейся сети» каждая попытка будет тратить по 15 с вместо быстрого отказа и ретрая.

DEFAULT_TIMEOUT = httpx.Timeout(15.0, connect=3.0)

def create_charge(..., timeout: httpx.Timeout | float = DEFAULT_TIMEOUT) -> ChargeResult:

app/payments/client.py:52 — важно: cached.decode() зависит от конфига клиента

Если redis_client создан с decode_responses=True (частый вариант для кеша), get вернёт str, и .decode() упадёт с AttributeError. Причём упадёт только на повторной попытке, то есть ровно в том сценарии, ради которого PR. Тест это не ловит, потому что redis замокан и get возвращает None.

Правку см. в варианте с SET NX выше (isinstance(existing, bytes)). С детерминированным ключом вопрос исчезает.

app/payments/client.py:22 — важно: недоступность Redis

Вызов идёт вне try, поэтому при недоступном Redis наружу летит redis.ConnectionError. Fail-closed здесь правильное поведение, генерировать ключ без Redis нельзя. Но это нужно зафиксировать явно, в docstring и тесте, и убедиться, что Celery ретраит такую ошибку. Детерминированный ключ убирает и эту зависимость.

app/payments/client.py:12-13,54 — мелочь: именованные константы

BACKOFF_BASE = 2 не говорит, что это секунды и основание степени. 60 * 60 * 24 стоит вынести в константу.

MAX_ATTEMPTS = 3                    # всего попыток, включая первую
BACKOFF_BASE_SECONDS = 2            # задержка = BASE ** attempt
IDEMPOTENCY_KEY_TTL = 60 * 60 * 24  # должен быть >= окна идемпотентности gateway и окна ретраев Celery

MAX_RETRIES = 3 при range(1, MAX_RETRIES + 1) означает 3 попытки, то есть 2 ретрая. Имя вводит в заблуждение.

app/payments/client.py:42 — мелочь: потеря traceback

Если except Exception останется в каком-то виде, logger.error(... %s, exc) теряет стек. Нужен logger.exception(...).

tests/test_payments.py:~14-23 (test_create_charge_success) — важно: тест теперь ходит в реальный Redis

create_charge теперь всегда вызывает redis_client, а в старом тесте он не замокан. В CI без Redis тест упадёт. С Redis тест оставит idemp:order-1 и станет зависеть от порядка запусков. Предлагаю общую фикстуру:

@pytest.fixture(autouse=True)
def fake_redis(monkeypatch):
    import fakeredis
    client = fakeredis.FakeRedis()
    monkeypatch.setattr("app.payments.client.redis_client", client)
    return client

tests/test_payments.py:26-38 — важно: тест спит по-настоящему и не проверяет главное

Без патча time.sleep тест реально ждёт 2 с. Кроме того, тест не проверяет, что обе попытки ушли с одинаковым Idempotency-Key, хотя это суть PR.

@patch("app.payments.client.time.sleep")
def test_create_charge_retries_on_timeout(mock_sleep, respx_mock):
    route = respx_mock.post("https://biller-gateway.internal.corp/v1/charges")
    route.side_effect = [
        httpx.ConnectTimeout("timeout"),
        httpx.Response(200, json={"id": "ch_456", "status": "succeeded"}),
    ]

    result = create_charge("order-2", 500, "RUB")

    assert result.id == "ch_456"
    assert route.call_count == 2
    keys = {call.request.headers["Idempotency-Key"] for call in route.calls}
    assert len(keys) == 1
    mock_sleep.assert_called_once()

Тесты, которые стоит добавить

Название Сценарий
test_idempotency_key_stable_across_calls Два независимых вызова create_charge("order-x", ...) (имитация Celery-ретрая) шлют один и тот же Idempotency-Key.
test_idempotency_key_differs_per_order Разные order_id дают разные ключи.
test_idempotency_key_concurrent_creation Для варианта с Redis: два потока вызывают _get_or_create_idempotency_key одновременно (fakeredis), оба получают одинаковый ключ.
test_idempotency_key_reused_when_cached_as_str redis_client с decode_responses=True и уже записанным ключом: ключ берётся из кеша без AttributeError.
test_create_charge_raises_after_exhausted_retries Три ConnectTimeout подряд дают BillerGatewayUnavailable, call_count == 3, sleep вызван 2 раза.
test_create_charge_retries_on_read_timeout_and_read_error ReadTimeout и RemoteProtocolError ретраятся.
test_create_charge_retries_on_503 503, затем 200 дают успех, 2 вызова.
test_create_charge_does_not_retry_on_4xx 402 или 422 дают HTTPStatusError наружу, ровно 1 вызов, без sleep.
test_create_charge_redis_unavailable_fails_closed Для варианта с Redis: redis_client.get бросает ConnectionError, запрос в gateway не отправлен, исключение проброшено.
test_process_order_payment_charges_order_amount Регрессия на service.py: в gateway уходит order.amount, даже если аргумент amount другой.
test_process_order_payment_does_not_mark_charged_on_failure create_charge бросает исключение, save_order не вызван, статус не charged.

3. Вопросы автору

  1. Поддерживает ли biller-gateway Idempotency-Key и с какой семантикой? От ответов зависит, закрывает ли PR баг вообще:
    • Какое у gateway окно хранения ключа (если меньше 24 ч, наш TTL ничего не даёт)?
    • Что он отвечает на повтор с тем же ключом, пока первый запрос ещё в обработке (409, ожидание, дубль)? Это прямо наш сценарий с ReadTimeout.
    • Что будет при повторе с тем же ключом, но другой суммой?
    • Есть ли контрактный тест или документация?
  2. Почему изменён service.py:28 (сумма из аргументов вместо заказа)? Есть ли вызовы, где amount и currency в аргументах отличаются от заказа?
  3. Celery-таск: какие autoretry_for, max_retries, countdown, soft_time_limit? Переживёт ли он смену исключения на None или AttributeError и худший случай ~59 с внутри create_charge? Укладывается ли общее окно Celery-ретраев в TTL ключа?
  4. Конфиг Redis: decode_responses у redis_client, maxmemory-policy и persistence инстанса, где лежит кеш тарифов? Если ключ остаётся в Redis, допустимо ли его вытеснение?
  5. Бывают ли легитимные повторные charge по одному order_id (повторная оплата после decline, доплата)? От этого зависит выбор между детерминированным ключом и ключом с номером попытки из БД. С текущим ключом на 24 ч повторная оплата после decline, скорее всего, вернёт закешированный decline.
  6. Вызывается ли create_charge из FastAPI-эндпоинтов (особенно из async def) или только из Celery? Если из async-кода, time.sleep и синхронный httpx.post на ~59 с заблокируют event loop.
  7. Что с 7 уже случившимися двойными списаниями и с заказами, которые сейчас в процессе? После деплоя у них появится новый ключ. Нужен ли разовый скрипт сверки или возвратов? Это вне PR, но стоит убедиться, что задача заведена.

Похожие промпты

Программирование и Разработка

Ревью Python-кода: типизация и идиомы

Проводит ревью Python-кода: ошибки, которые проявятся в рантайме, аннотации типов, идиоматичность, обработка исключений и ресурсов — с правками «до/после».

Программирование и Разработка

Ревью SQL-запросов и миграций

Проверяет SQL-запросы и миграции схемы: корректность на граничных данных, индексы под реальные выборки, блокировки при миграции на живой таблице и откат.

Программирование и Разработка

Ревью Next.js-кода: App Router и Server Components

Проводит ревью Next.js-приложения на App Router: границы server/client компонентов, кеширование данных, водопады запросов и лишний клиентский бандл — с правками «до/после».

Программирование и Разработка

SEO-аудит Next.js-приложения

Проверяет Next.js-приложение по SEO-чеклисту: метаданные и canonical, рендеринг для ботов, sitemap/robots, Core Web Vitals — и выдаёт план правок по приоритету.