How to Handle API Errors Effectively in Odoo 19: Complete Tutorial
If you have built integrations against Odoo's JSON-RPC or REST endpoints, you have probably experienced a frustrating scenario where you fire a request, get back a 200 OK, and then spend 20 minutes figuring out why your data was never saved. In Odoo's API, errors do not always look like errors — a 200 response can still carry a JSON payload with "error" buried inside it. Odoo 19 tightens up how the ORM propagates errors through the API layer and introduces cleaner exception handling hooks in the web and base_rest modules. This complete step by step tutorial covers what errors look like, how to catch them properly, and patterns that will save you debugging time.
What You'll Learn:
- How Odoo 19 API errors are structured in JSON-RPC responses
- How to identify and handle common exception types like ValidationError, AccessError, UserError, and MissingError
- How to implement error handling in custom Python controllers
- How to handle JSON-RPC errors on the client side with JavaScript
- How to raise custom errors in Odoo models with proper exception types
- How to use HTTP-style responses with correct status codes in REST controllers
- How to retry on transient PostgreSQL serialization failures
Understanding Odoo 19 API Error Structure
Odoo uses JSON-RPC 2.0 for most of its API surface. When a call fails, the response carries an error object inside the JSON payload rather than relying on HTTP status codes. The outer code: 200 is part of the JSON-RPC specification and refers to an application-level error, not an HTTP status. Here is what a failed call returns:
{
"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."]
}
}
}
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 |
Step 1: Basic Python-side Handling in Custom Controllers
When building custom API controllers in Odoo 19, you should wrap your business logic in try-except blocks that catch specific Odoo exceptions. This gives you control over what information is returned to the caller. Never return raw Python tracebacks to external callers — log them server-side and return a generic message to the client.
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='json', 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.',
}
Step 2: Handling Errors on the Client Side (JavaScript)
If you are calling Odoo's JSON-RPC from a frontend or external service, you must check result.error even on a 200 HTTP response. JSON-RPC errors land inside the response body, not in HTTP status codes. Always inspect the data.error property before treating a call as successful.
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);
}
Step 3: Raising Custom Errors in Odoo 19 Models
You can raise custom errors directly from your Odoo models using ValidationError for data constraint violations and UserError for business rule violations. The difference matters — use ValidationError for constraint decorators and @api.constrains, and UserError for runtime business logic failures when the data is valid but the operation should not proceed.
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()
Step 4: Using HTTP-style Responses in REST Controllers
If you are using base_rest or building REST endpoints directly, you can return proper HTTP status codes instead of relying on the JSON-RPC envelope. Use Odoo's Response object to control status codes for different error scenarios such as 404 for not found, 403 for access denied, and 500 for internal errors.
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/',
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',
)
Step 5: Retrying on Transient Errors
Odoo uses PostgreSQL transactions, and under concurrent load you may occasionally encounter psycopg2.errors.SerializationFailure. This typically happens when two requests try to write the same record at the same time. These errors are safe to retry. Never retry ValidationError or UserError as those will not resolve on their own.
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
Do Not Retry Non-Transient Errors
Never retry ValidationError or UserError as those will not resolve on their own. Only retry psycopg2.errors.SerializationFailure which occurs under concurrent database load. Use exponential backoff with the delay parameter to avoid overwhelming the database.
Best Practices for Odoo 19 API Error Handling
Always Check data.error
Never trust a 200 HTTP status code alone. Always inspect the data.error property in JSON-RPC responses before processing the result, as application errors are returned inside the JSON body.
Log Tracebacks Server-Side
Use Python's logging module with _logger.exception() to capture full tracebacks server-side. Never serialize traceback.format_exc() into responses that go outside your network.
Use Correct Exception Types
Raise ValidationError for constraint violations and UserError for business rule violations. Using the wrong exception type can confuse API callers and make debugging more difficult.
Use Proper HTTP Status Codes
For REST endpoints, return correct HTTP status codes such as 404 for not found, 403 for access denied, and 500 for internal errors. This makes your API more intuitive for external consumers.
Frequently Asked Questions
Why am I getting 200 OK but the operation still failed in Odoo API?
Odoo's JSON-RPC always returns HTTP 200 unless there is a transport-level failure. Application errors live inside the response JSON in data.error. Always check response.error before treating a call as successful because HTTP status codes only tell you whether the request arrived, not whether it did anything useful.
What is the difference between UserError and ValidationError in Odoo 19?
ValidationError is for data integrity and fires when a value violates a constraint such as wrong format, required field missing, or domain check failed. UserError is for operational failures like 'you cannot confirm this order because X.' Both show up in the API response as user-facing messages, but using the wrong one can confuse callers.
How do I catch an AccessError from an external API call in Odoo 19?
Check error.data.name in the JSON-RPC response for 'AccessError'. On the server side this usually means the authenticated user lacks read/write access to the model or record. Double-check security groups, record rules, and model access. The debug traceback in error.data.debug will tell you exactly which rule blocked it.
Can I return custom error codes from an Odoo 19 controller?
Yes. For type='json' routes, return a dict with whatever structure you want since the error handling is yours to define. For type='http' routes, return a Response object with the correct status code. You cannot change the outer JSON-RPC envelope as that is controlled by Odoo's dispatcher.
How do I log API errors without exposing stack traces to clients in Odoo 19?
Use Python's logging module to write tracebacks server-side with _logger.exception() which captures the full trace automatically, and return a sanitized message to the caller. Never serialize traceback.format_exc() directly into a response. In production, set --log-level=warn and use a log aggregator to capture error-level entries from odoo.addons.
Need Help with Odoo API Development?
Our Odoo experts can help you build robust API integrations, implement error handling patterns, and optimize your Odoo 19 development workflow.
About the author
Head of Odoo Practice
Leads Braincuber's Odoo implementations across the US, India, and EU. Shipped 50+ Odoo deployments. Specializes in NetSuite and SAP Business One migrations.
