Skip to Content

Retry вебхуків в Odoo: ідемпотентність, backoff і транзакційна пастка cron

Вихідний запит до маркетплейсу впав по таймауту. Природний рух — повторити запит. Проблема в тому, що таймаут на клієнті нічого не каже про долю запиту на сервері: можливо, він там уже обробився. Якщо повторити сліпо — отримаєте друге замовлення, повторне списання або розсинхрон залишків. Наївний retry в такому випадку гірший за його відсутність: без retry маєте один пропущений виклик, видний у логах. З наївним retry — маєте дублікат, який тихо живе в базі, поки хтось не порахує гроші.

Що робить наш HTTP-клієнт

kw_api_connector — базовий фреймворк усіх вихідних інтеграцій KitWorks (банки, доставка, фіскалізація, AI). Його api_request жодного retry не робить:

try:
    response = requests.request(
        method=method, url=full_url, timeout=60,
        allow_redirects=True, **kw)
except Exception as e:
    if self.is_log_enabled:
        self.kw_http_request_log_source_id.update_log(
            log, {'error': e, 'process_time': fields.Datetime.now()})
    if not silent:
        raise exceptions.ValidationError(_(
            'Connector "%(credential)s" connection error: "%(error)s"'
        ) % {'credential': self.name, 'error': e})
    return False

Таймаут — 60 секунд, жорстко прописаний. Виняток логується, метод повертає False (або кидає ValidationError, якщо викликач передав silent=False). is_api_success перевіряє тільки 200 <= code < 300 і використовується для вибору гілки логування "успіх / помилка" — і саме ця гілка помилки веде до єдиного винятку з правила "ніякого retry", про який далі. Розділення 4xx проти 5xx тут відсутнє в принципі: рішення "повторювати чи ні" не приймається за класом статус-коду. Ну ок, чесно кажучи — я б сказав, що retry в KitWorks-модулях зроблений не тут, а рівнем вище.

Ідемпотентність на практиці

Маркетплейс-модулі (kw_marketplace_promua_api, kw_marketplace_rozetka_api) опитують API по cron кожні 5 хвилин — це і є їхній фактичний retry: не вдалося зараз, спробує через 5 хвилин. Безпечно це лише тому, що обробка замовлення ідемпотентна — перед створенням шукаємо запис за референсом маркетплейсу:

@api.model
def kw_mp_update_from_marketplace_prom(self, merchant, res, **kw):
    obj = self.search([
        ('kw_mp_merchant_id', '=', merchant.id),
        ('kw_mp_ref', '=', str(res['id'])), ], limit=1, )
    obj_id = obj.id if obj else False
    if obj.state in ['done']:
        _logger.warning(f"Order {obj.name} is locked. Skipping update.")
        return obj.id
    if not obj:
        values = self.kw_mp_prepare_create_values_prom(
            merchant=merchant, res=res)
        with self._in_new_transaction() as nself:
            obj = nself.create(values)
            obj_id = obj.id
        return obj_id
    ...

Ключ ідемпотентності тут — не згенерований UUID, а пара (merchant_id, kw_mp_ref): ідентифікатор, який маркетплейс сам присвоїв замовленню. Ми не генеруємо власний idempotency-key на кшталт Stripe — покладаємось на стабільний зовнішній id. Якщо замовлення вже done, воно залочене й повторний прихід ігнорується. Той самий патерн (kw.update.from.marketplace.mixin) повторюється для товарів (kw.mp.offer) і контактів (res.partner).

Неправильно — зловити виняток HTTP-запиту і одразу повторити той самий create(). Правильно — шукати за зовнішнім референсом перед створенням і повторювати весь цикл "запит → пошук → створення чи оновлення" на рівні наступного cron-тіка, а не одного HTTP-виклику.

Backoff і межа спроб

Загального backoff і ліміту спроб на рівні HTTP-виклику в маркетплейс-модулях немає: cron б'є в API рівно кожні 5 хвилин незалежно від того, скільки разів підряд це падало. Єдиний виняток — вузький одноразовий ретрай на token-refresh (той самий патерн, що і в kw_api_connector, дзеркально повторений у kw_marketplace/models/merchant.py): якщо токен протух, конектор оновлює його і повторює виклик один раз, під захистом прапорця renew_token, щоб не зациклитись. Це не заміна загальної retry-стратегії, а вужчий частковий випадок — і плутати "немає ліміту спроб узагалі" з "немає ліміту спроб на генеричні мережеві помилки" не варто.

<record id="kw_marketplace_kw_mp_merchant_load_orders_rozetka" model="ir.cron">
    <field name="code">model.cron_load_orders_rozetka()</field>
    <field name="interval_number">5</field>
    <field name="interval_type">minutes</field>
</record>

Межу тут ставить сам Odoo, не наш код. ir.cron рахує послідовні фейли й після 5 підряд протягом щонайменше 7 днів деактивує джобу сам:

MIN_FAILURE_COUNT_BEFORE_DEACTIVATION = 5
MIN_DELTA_BEFORE_DEACTIVATION = timedelta(days=7)

Важливо! _notify_admin, який мав би про це попередити, у базовому Odoo не робить нічого, крім _logger.warning(message). Без email, без активності — деактивацію побачите, тільки якщо самі підете дивитись Scheduled Actions. У наших модулях цей метод не перевизначений, окремого алерта на "остаточний" фейл теж немає: єдиний слід — прапорець is_correct = False на kw.mp.offer, видний у фільтрованому списку, плюс записи в kw.marketplace.log. Я б не назвав це dead-letter queue в класичному сенсі — це ручний розбір за фільтром, і працює він, поки обсяг помилок малий.

Поруч, але про інше: kw_marketplace_bs (Background Service черги офферів) сам піднімає паузу між проходами, коли черга порожня, з 1 до 120 секунд кроком по 10:

if objs:
    self.sleep_timeout = 1
else:
    self.sleep_timeout = min(self.sleep_timeout + 10, 120)

Це backoff неробочого стану черги, а не backoff помилки — плутати ці дві речі не варто. Застереження щодо версії: код ідентичний на 16.0 і на гілці 18.0, але на 18.0 модуль зараз позначений 'installable': False — порт іде («initiate marketplace 18.0 porting»), і встановити його на цій версії поки не можна. На 16.0 модуль installable: True і працює в проді. Механізм не вигаданий, просто на цільовій версії статті він у процесі порту, а не в експлуатації.

Транзакційна пастка cron

Найнеприємніший баг ховається тут. ir.cron._callback ловить будь-який виняток і робить self.env.cr.rollback() перед тим, як прокинути виняток далі:

except Exception:
    self.pool.reset_changes()
    _logger.exception(
        'Job %r (%s) server action #%s failed',
        cron_name, self.id, server_action_id)
    self.env.cr.rollback()
    raise

Якщо всередині крон-джоби ви звичайним create() записали "спроба виклику API — впала", а через два рядки нижче стався ще один виняток, що завершив джобу, — запис про невдалу спробу відкотиться разом з усім іншим. Слід зникне саме тоді, коли він найпотрібніший.

Рішення — _in_new_transaction() з generic.mixin.transaction.utils, який відкриває окремий курсор до бази:

@contextmanager
def _in_new_transaction(self, lock=False, no_raise=False):
    with self.env.registry.cursor() as new_cr:
        new_env = self.env(cr=new_cr)
        nself = self.with_env(new_env)
        try:
            yield nself
        except Exception:
            if no_raise:
                new_cr.rollback()
            else:
                raise
        else:
            new_cr.flush()

self.env.registry.cursor() — окреме з'єднання до БД, не той курсор, у якому виконується основна крон-транзакція. Базовий клас курсора в Odoo прямо документує цю поведінку: "cr is committed if no failure occurred". Тобто якщо yield nself пройшов без винятку — новий курсор комітить сам себе незалежно від того, що станеться далі в основній транзакції. Саме так kw_marketplace_promua_api пише лог кожного HTTP-виклику до того, як запит взагалі пішов: якщо основна транзакція за секунду впаде, запис про старт виклику вже лежить у базі окремим комітом.

Той самий трюк рятує і від сусідньої проблеми: cron_load_orders обробляє мерчантів по черзі, і кожен обгорнутий у власний _in_new_transaction():

@api.model
def cron_load_orders(self):
    for obj_id in self.env['kw.mp.merchant'].sudo().search([]).ids:
        with self._in_new_transaction() as nself:
            obj = nself.browse(obj_id)
            obj.load_orders()

Якщо третій мерчант з десяти впаде виключенням, замовлення, вже створені для першого й другого, нікуди не зникнуть — вони закомічені в окремих курсорах ще до того, як третій встиг зламатись. Без цієї обгортки один зламаний мерчант посеред циклу відкотив би роботу, зроблену для всіх попередніх.

Тема за матеріалом https://www.cybrosys.com/blog/overview-of-webhook-error-handling-and-retry-strategies-in-odoo-19. Код і поведінку перевірено на Odoo 18.0.

Retry вебхуків в Odoo: ідемпотентність, backoff і транзакційна пастка cron
KitWorks, Volodymyr Karabanov 28 липня 2026 р.
Поділитися цією публікацією
Теги
Архів