Skip to Content

Помилка зовнішнього API на одному записі пакета: пишемо в поле, а не відкочуємо транзакцію

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

Компроміси

  1. Частковий успіх: документ підтверджено, у сервісі його немає. Без фільтра по полю це непомітно.
  2. Повтор: кнопка «Повторити» викликає той самий _send_to_service з тим самим id.
  3. Вузький 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'.

Помилка зовнішнього API на одному записі пакета: пишемо в поле, а не відкочуємо транзакцію
admin 23 жовтня 2026 р.
Поділитися цією публікацією
Теги
Архів