If you've built integrations against Odoo's JSON-RPC or REST endpoints, you've probably had this happen: you fire a request, get back a 200 OK, and then spend 20 minutes figuring out why your data was never saved. That's the thing about Odoo's API: errors don't always look like errors. A 200 response can still carry a JSON payload with "error" buried inside it.
Odoo 19 doesn't reinvent this, but it does tighten up how the ORM propagates errors through the API layer, and it introduces cleaner exception handling hooks in the web and base_rest modules. This post covers the practical side: what errors look like, how to catch them properly, and a few patterns that'll save you debugging time.
What Odoo 19 API Errors Actually Look Like
Odoo uses JSON-RPC 2.0 for most of its API surface. Here's what a failed call returns:
json{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": 200,
"message": "Odoo Server Error",
"data": {
"name": "odoo.exceptions.ValidationError",
"debug": "Traceback (most recent call last):\n ...",
"message": "The field 'email' is required.",
"arguments": ["The field 'email' is required."]
}
}
}The outer code: 200 is misleading it's part of the JSON-RPC spec and refers to an application-level error, not an HTTP status.
Common exception types in Odoo 19
| Odoo Class | When it fires |
| ValidationError | Constraint failures, required fields |
| AccessError | Record-level or model-level ACL |
| UserError | Business logic violations |
| MissingError | Record deleted mid-transaction |
Error Handling Patterns
1. Basic Python-side Handling in Custom Controllers
from odoo import http
from odoo.exceptions import ValidationError, AccessError, UserError
from odoo.http import request
import json
import logging
_logger = logging.getLogger(__name__)
class MyApiController(http.Controller):
@http.route('/api/v1/partner/create', type=jsonrpc, auth='user', methods=['POST'])
def create_partner(self, **kwargs):
try:
partner = request.env['res.partner'].create({
'name': kwargs.get('name'),
'email': kwargs.get('email'),
})
return {'success': True, 'partner_id': partner.id}
except ValidationError as e:
_logger.warning("Validation failed: %s", str(e))
return {
'success': False,
'error_type': 'validation_error',
'message': str(e),
}
except AccessError as e:
_logger.error("Access denied for user %s: %s", request.env.user.name, str(e))
return {
'success': False,
'error_type': 'access_error',
'message': 'You do not have permission to perform this action.',
}
except UserError as e:
return {
'success': False,
'error_type': 'user_error',
'message': str(e),
}
except Exception as e:
_logger.exception("Unexpected error in create_partner")
return {
'success': False,
'error_type': 'server_error',
'message': 'An internal error occurred. Please contact support.',
}
One thing worth noting: never return raw Python tracebacks to external callers. Log them server-side, return a generic message to the client.
2. Handling Errors on the Client Side (JavaScript)
If you're calling Odoo's JSON-RPC from a frontend or external service, you need to check result.error even on a 200 response:
async function callOdooApi(endpoint, params) {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
id: Date.now(),
params: params,
}),
});
const data = await response.json();
// JSON-RPC errors land here, not in HTTP status codes
if (data.error) {
const errorName = data.error?.data?.name;
const errMsg = data.error?.data?.message || 'Unknown error';
if (errorName.includes("ValidationError")) {
throw new Error(`Validation failed: ${errMsg}`);
} else if (errorName.includes("AccessError")) {
throw new Error(`Permission denied: ${errMsg}`);
} else if (errorName.includes("UserError")) {
throw new Error(`Business logic error: ${errMsg}`);
}
}
return data.result;
}
// Usage
try {
const result = await callOdooApi('/web/dataset/call_kw', {
model: 'res.partner',
method: 'create',
args: [{ name: 'Test', email: '' }],
kwargs: {},
});
console.log('Created partner:', result);
} catch (err) {
console.error('API call failed:', err.message);
}3. Raising Custom Errors in Odoo 19 Models
from odoo import models, fields, api
from odoo.exceptions import ValidationError, UserError
class SaleOrderCustom(models.Model):
_inherit = 'sale.order'
@api.constrains('amount_total')
def _check_minimum_order_value(self):
for order in self:
if order.amount_total < 50.0:
raise ValidationError(
f"Order {order.name} must be at least $50.00. "
f"Current total: ${order.amount_total:.2f}"
)
def action_confirm(self):
for order in self:
if not order.partner_id.email:
raise UserError(
"Cannot confirm order: customer has no email address on file."
)
return super().action_confirm()
The difference between ValidationError and UserError matters. Use ValidationError for data constraint violations (constraint decorators, @api.constrains). Use UserError for business rule violations that happen at runtime when the data is valid but the operation shouldn't proceed.
4. Using HTTP-style Responses in REST Controllers
If you're using base_rest or building REST endpoints directly, you get proper HTTP status codes:
from odoo import http
from odoo.http import request, Response
from odoo.exceptions import ValidationError, AccessError
import json
class RestApiController(http.Controller):
@http.route('/api/v2/partner/<int:partner_id>', type='http', auth='user', methods=['GET'])
def get_partner(self, partner_id, **kwargs):
try:
partner = request.env['res.partner'].browse(partner_id)
if not partner.exists():
return Response(
json.dumps({'error': 'Partner not found', 'code': 404}),
status=404,
content_type='application/json',
)
return Response(
json.dumps({
'id': partner.id,
'name': partner.name,
'email': partner.email,
}),
status=200,
content_type='application/json',
)
except AccessError:
return Response(
json.dumps({'error': 'Access denied', 'code': 403}),
status=403,
content_type='application/json',
)
except Exception as e:
return Response(
json.dumps({'error': 'Internal server error', 'code': 500}),
status=500,
content_type='application/json',
)
5. Retrying on Transient Errors
Odoo uses PostgreSQL transactions, and under concurrent load you'll occasionally get psycopg2.errors. SerializationFailure usually when two requests try to write the same record at the same time. These are safe to retry:
import time
import psycopg2
from odoo import api, registry
def call_with_retry(db_name, model_name, method, args, max_retries=3, delay=0.5):
for attempt in range(max_retries):
try:
with registry(db_name).cursor() as cr:
env = api.Environment(cr, SUPERUSER_ID, {})
result = getattr(env[model_name], method)(*args)
cr.commit()
return result
except psycopg2.errors.SerializationFailure:
if attempt < max_retries - 1:
time.sleep(delay * (attempt + 1))
continue
raise
Don't retry ValidationError or UserError those won't resolve on their own.
To read more about How to Configure Odoo REST API Module in Odoo 18, refer to our blog How to Configure Odoo REST API Module in Odoo 18.