Вихідний запит до маркетплейсу впав по таймауту. Природний рух — повторити запит. Проблема в тому, що таймаут на клієнті нічого не каже про долю запиту на сервері: можливо, він там уже обробився. Якщо повторити сліпо — отримаєте друге замовлення, повторне списання або розсинхрон залишків. Наївний 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.