Задача: підтвердити пакет записів, де кожен запис відправляється у зовнішній сервіс, і щоб збій на одному не відкочував решту. Приклад узагальнений, для Odoo 18, з моделлю x.document, методом action_confirm і викликом _send_to_service.
Заготовка моделі. ServiceRejected і _call_service лише позначають зовнішній виклик, у вашому коді там буде реальний HTTP-запит. Модель успадковує mail.thread, бо нижче ми пишемо в чаттер. Методи з наступних сніпетів додаємо в цей клас:
import logging
import uuid
import requests
from odoo import _, fields, models
_logger = logging.getLogger(__name__)
class ServiceRejected(Exception):
pass
class XDocument(models.Model):
_name = 'x.document'
_inherit = ['mail.thread']
_description = 'Document'
name = fields.Char(required=True)
state = fields.Selection(
[('draft', 'Draft'), ('confirmed', 'Confirmed')], default='draft')
external_ref = fields.Char(copy=False)
def _call_service(self, payload):
raise NotImplementedError
Спочатку як пишуть зазвичай:
def action_confirm(self):
for doc in self:
doc._send_to_service()
self.write({'state': 'confirmed'})
Виняток на k-му записі виходить з RPC-методу, курсор закривається без коміту, і відкочується весь пакет. Разом із локальними змінами для записів 1…k-1, хоча сервіс їх уже прийняв. Так виходить із коду ядра, на живій системі я цього окремо не відтворював.
Як робимо ми:
def action_confirm(self):
for doc in self:
try:
with self.env.cr.savepoint():
doc._send_to_service()
except (requests.exceptions.RequestException,
ServiceRejected) as exc:
doc._record_error(exc)
else:
doc.last_error = False
self.write({'state': 'confirmed'})
return True
Тут слід зазначити три речі. Цикл іде по записах, тому виняток належить одному запису. Список винятків вузький: AccessError чи KeyError виходять нагору, як і раніше. І write викликається один раз для всіх. Ну ок, запис із помилкою теж підтверджується. Таке рішення я приймаю для цього прикладу, бо документ валідний, збій лише в інтеграції, а відправку можна повторити вручну. Якщо ваш процес інший, відфільтруйте такі записи перед write.
Помилку кладемо в поле запису, після відкату савпойнта:
last_error = fields.Text(readonly=True, copy=False)
def _record_error(self, exc):
self.ensure_one()
_logger.warning(
'Service call failed for %s id=%s: %s',
self._name, self.id, exc, exc_info=True)
self.last_error = str(exc)
self.message_post(body=_('Service call failed: %(error)s') % {
'error': exc})
<div class="alert alert-warning" role="alert"
invisible="not last_error">
<field name="last_error" readonly="1"/>
</div>
Користувач бачить банер на формі і запис у чаттері. Ще додайте у пошук фільтр [('last_error', '!=', False)], інакше збої непомітні в списку.
Навіщо savepoint
Що робить cr.savepoint() в Odoo 18. На вході він робить flush, на виході з винятком скидає кеш (cr.clear()) і виконує ROLLBACK TO SAVEPOINT. Виняток летить далі, тому без зовнішнього try він дійде до користувача.
Різницю видно на проміжному записі всередині _send_to_service:
def _send_to_service(self):
self.ensure_one()
self.external_ref = 'pending'
key = uuid.uuid5(uuid.NAMESPACE_URL, '%s:%s' % (self._name, self.id))
reply = self._call_service({'id': str(key), 'name': self.name})
self.external_ref = reply['id']
Неправильно: try/except без savepoint. Після збою в external_ref лишається 'pending', хоча сервіс запис не прийняв.
Правильно: той самий try/except із with self.env.cr.savepoint(). Проміжний запис відкочується, external_ref порожній.
Окремий випадок — помилка бази. Після неї транзакція в PostgreSQL переходить у стан aborted, і наступні запити падають з InFailedSqlTransaction. Ловити psycopg2.Error без savepoint марно:
import psycopg2
from odoo.tests.common import TransactionCase
from odoo.tools import mute_logger
class TestDbError(TransactionCase):
@mute_logger('odoo.sql_db')
def test_db_error_needs_savepoint(self):
cr = self.env.cr
with self.assertRaises(psycopg2.errors.InFailedSqlTransaction):
with cr.savepoint():
try:
cr.execute('SELECT 1/0')
except psycopg2.Error:
pass
cr.execute('SELECT 1')
З savepoint навколо SELECT 1/0 наступний запит проходить. Тому помилки API й помилки SQL я ловлю різними шляхами: перші в except вище, другі лише всередині savepoint.
Важливо! Усередину savepoint не кладемо запис самої помилки: він відкотився б разом з усім. І пам'ятайте, що savepoint скасовує тільки локальні зміни. Якщо сервіс уже прийняв запит, а локальний крок після нього впав, слід загубиться, тому id запиту робимо детермінованим (uuid5 від моделі й id запису), щоб повторна відправка не створила дубль. Працює воно, лише якщо сервіс сам відсікає дублікати за id.
Компроміси
- Частковий успіх: документ підтверджено, у сервісі його немає. Без фільтра по полю це непомітно.
- Повтор: кнопка «Повторити» викликає той самий
_send_to_serviceз тим самим id. - Вузький
except. Якщо ловитиException, ви замаскуєте помилки програмування. Я ловлю лише помилки сервісу.
Як тестувати
Для пакета з трьох записів другий отримує ReadTimeout:
from unittest.mock import patch
import requests
from odoo import exceptions
from odoo.tests.common import TransactionCase
class TestBatch(TransactionCase):
def setUp(self):
super().setUp()
self.docs = self.env['x.document'].create(
[{'name': 'A'}, {'name': 'B'}, {'name': 'C'}])
self.a, self.b, self.c = self.docs
def test_batch_one_failure_keeps_others(self):
Doc = type(self.env['x.document'])
with patch.object(Doc, '_call_service', side_effect=[
{'id': 'r1'},
requests.exceptions.ReadTimeout('Read timed out'),
{'id': 'r3'}]):
self.docs.action_confirm()
self.assertEqual(self.docs.mapped('state'), ['confirmed'] * 3)
self.assertEqual(self.a.external_ref, 'r1')
self.assertEqual(self.c.external_ref, 'r3')
self.assertIn('Read timed out', self.b.last_error)
self.assertFalse(self.b.external_ref)
self.assertFalse(self.a.last_error)
Негативний контроль, бо сторонній виняток не має ковтатися (той самий клас тестів, Doc визначено вище):
with patch.object(Doc, '_call_service',
side_effect=exceptions.AccessError('no rights')):
with self.assertRaises(exceptions.AccessError):
self.docs.action_confirm()
self.assertEqual(self.docs.mapped('state'), ['draft'] * 3)
Щоб побачити, що тест справді ловить різницю, прибираємо with self.env.cr.savepoint() і запускаємо знову: external_ref збійного запису лишається 'pending'.