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#
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 oncedata- log and move to dead letter queue, do not retrytransient- retry with backoffunknown- retry with backoff, alert on repeated failure
The retry wrapper#
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 decoratorUsage:
@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.
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.
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:
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.

