All posts
Developer9 min read

Handling Odoo XML-RPC API failures gracefully: retry logic, backoff, and circuit breakers

Odoo's XML-RPC API fails in four distinct ways and each requires a different response. Here is the classification and the Python patterns that keep integrations running when Odoo is overloaded, restarting, or rejecting the request.

Most Odoo integrations handle errors the same way: wrap the call in a try/except, log the exception, and either crash or silently skip the record. Both outcomes are wrong. The crash triggers an alert at 2 AM for an error that would have resolved in 30 seconds with a retry. The silent skip produces data inconsistencies that are discovered weeks later.

Odoo XML-RPC failures fall into four categories. Each category requires a different response. Getting the classification right is the difference between an integration that runs unattended and one that pages on every Odoo maintenance window.

The four failure categories#

Category 1: Connection failure. Odoo is not reachable. This can be a network outage, a container restart, or a Caddy reverse proxy that has not yet come up after a deployment. The Python exception is ConnectionRefusedError, socket.timeout, or requests.exceptions.ConnectionError.

Category 2: Odoo server error. Odoo is running but returned an XML-RPC fault. This is a xmlrpc.client.Fault exception. The fault code and string indicate whether this is a transient condition (locks, temporary unavailability) or a permanent one (bad model name, access denied).

Category 3: Session expiry. The API key or session token is no longer valid. Odoo returns Access denied in the fault string. This is not retryable without re-authenticating.

Category 4: Data rejection. The record you are trying to create or write violates a constraint - required field, unique constraint, or a ValidationError raised by the model. This is never retryable; the data must be fixed.

Classifying faults at runtime#

python
import xmlrpc.client

def classify_fault(fault: xmlrpc.client.Fault) -> str:
    fstring = fault.faultString.lower()

    if "access denied" in fstring or "session expired" in fstring:
        return "auth"

    if any(word in fstring for word in [
        "validationerror", "constraintviolation", "missing required",
        "does not exist", "invalid field",
    ]):
        return "data"

    if any(word in fstring for word in [
        "could not serialize", "deadlock", "lock wait timeout",
    ]):
        return "transient"

    return "unknown"

Return values:

  • auth - re-authenticate and retry once
  • data - log and move to dead letter queue, do not retry
  • transient - retry with backoff
  • unknown - retry with backoff, alert on repeated failure

The retry wrapper#

python
import time
import xmlrpc.client
from functools import wraps
from typing import Callable, TypeVar

T = TypeVar("T")

def with_odoo_retry(
    max_attempts: int = 4,
    initial_delay: float = 1.0,
    backoff_factor: float = 2.0,
    max_delay: float = 30.0,
):
    def decorator(fn: Callable[..., T]) -> Callable[..., T]:
        @wraps(fn)
        def wrapper(*args, **kwargs) -> T:
            delay = initial_delay
            for attempt in range(max_attempts):
                try:
                    return fn(*args, **kwargs)
                except (ConnectionRefusedError, TimeoutError, OSError) as e:
                    if attempt == max_attempts - 1:
                        raise
                    time.sleep(min(delay, max_delay))
                    delay *= backoff_factor
                except xmlrpc.client.Fault as fault:
                    category = classify_fault(fault)
                    if category == "data":
                        raise  # Do not retry data errors
                    if category == "auth":
                        # Caller should handle re-auth
                        raise
                    if attempt == max_attempts - 1:
                        raise
                    time.sleep(min(delay, max_delay))
                    delay *= backoff_factor
        return wrapper
    return decorator

Usage:

python
@with_odoo_retry(max_attempts=4, initial_delay=2.0)
def create_partner(models_proxy, db, uid, password, vals):
    return models_proxy.execute_kw(
        db, uid, password,
        "res.partner", "create", [vals]
    )

The dead letter queue#

Data errors should not be silently dropped. They should go into a dead letter queue - a database table or a message queue with the original payload and the error message - so a human can inspect and fix them.

python
def handle_odoo_error(payload: dict, error: Exception, source: str):
    db.execute(
        """
        INSERT INTO integration_dead_letter
            (source, payload, error, created_at)
        VALUES (%s, %s, %s, now())
        """,
        (source, json.dumps(payload), str(error)),
    )

This table should have an alert: if the row count grows faster than 5 records per hour, something systematic is wrong.

Circuit breaker for sustained outages#

Exponential backoff handles brief outages. Sustained outages (Odoo maintenance window, server migration) require a circuit breaker that stops hitting a down endpoint entirely.

python
import time
from dataclasses import dataclass, field
from threading import Lock

@dataclass
class CircuitBreaker:
    failure_threshold: int = 5
    recovery_timeout: float = 60.0
    _failures: int = field(default=0, init=False)
    _state: str = field(default="closed", init=False)  # closed | open | half-open
    _opened_at: float = field(default=0.0, init=False)
    _lock: Lock = field(default_factory=Lock, init=False)

    def call(self, fn, *args, **kwargs):
        with self._lock:
            if self._state == "open":
                if time.monotonic() - self._opened_at > self.recovery_timeout:
                    self._state = "half-open"
                else:
                    raise RuntimeError("Circuit open  -  Odoo unreachable")
        try:
            result = fn(*args, **kwargs)
            with self._lock:
                self._failures = 0
                self._state = "closed"
            return result
        except (ConnectionRefusedError, TimeoutError, OSError):
            with self._lock:
                self._failures += 1
                if self._failures >= self.failure_threshold:
                    self._state = "open"
                    self._opened_at = time.monotonic()
            raise

odoo_circuit = CircuitBreaker(failure_threshold=5, recovery_timeout=120.0)

When the circuit is open, all calls immediately raise RuntimeError instead of waiting for a timeout. The caller should queue the work for later retry when the circuit recovers.

Timeouts#

The xmlrpc.client.ServerProxy default has no socket timeout. On a network partition, calls block indefinitely. Set a timeout:

python
import socket

# Set a global socket timeout before creating the proxy
socket.setdefaulttimeout(30.0)

models = xmlrpc.client.ServerProxy(
    f"{url}/xmlrpc/2/object",
    allow_none=True,
)

30 seconds is a reasonable timeout for most operations. Long-running batch operations (e.g. creating 1,000 records in a single create call) may need longer. Consider breaking large batches into smaller chunks with individual timeouts rather than raising the global timeout.

What to log#

Log at three levels:

  • DEBUG: every API call with model, method, and argument count (not the full args - those may contain PII)
  • WARNING: transient failures with attempt number and delay
  • ERROR: final failure after all retries, data validation errors, circuit breaker trips

Do not log full record payloads at INFO level in production. They fill logs quickly and may contain sensitive data.

The combination of retry, dead letter queue, and circuit breaker covers the three states an Odoo integration encounters: Odoo is fine (normal path), Odoo is momentarily unavailable (retry + backoff), and Odoo is sustainedly down (circuit breaker). Data errors are handled separately because they require human review, not retry.

Try ERPeek on your own Odoo module - ask questions, scaffold tests, and explore your codebase in plain language.

Get started free