The Muebles Roble system implements a centralized exception hierarchy for consistent error handling across all modules. This approach provides clear error messages, proper HTTP status codes, and a unified error response format.
class ValidationError(AppException): """Excepción para errores de validación de datos de entrada.""" def __init__( self, message: str = 'Datos de entrada inválidos', payload: Optional[dict] = None, ): super().__init__(message, status_code=400, payload=payload)
Usage:
# In service layerif not name or not name.strip(): raise ValidationError('El nombre del color es requerido')if len(name) > 50: raise ValidationError('El nombre no puede exceder 50 caracteres')
class NotFoundError(AppException): """Excepción para recursos no encontrados.""" def __init__( self, message: str = 'Recurso no encontrado', payload: Optional[dict] = None ): super().__init__(message, status_code=404, payload=payload)
Usage:
# In service layercolor = Color.query.get(id_color)if not color: raise NotFoundError(f'No se encontró un color con ID {id_color}')# With additional contextraise NotFoundError( 'Color no encontrado', payload={'id_color': id_color, 'searched_at': datetime.now()})
Raised when a resource conflict occurs (e.g., duplicate entries).
app/exceptions.py
class ConflictError(AppException): """Excepción para conflictos de datos (ej: duplicados).""" def __init__( self, message: str = 'Conflicto con el recurso existente', payload: Optional[dict] = None, ): super().__init__(message, status_code=409, payload=payload)
Usage:
# In service layerexisting = Color.query.filter_by(name=name).first()if existing: raise ConflictError(f"Ya existe un color con el nombre '{name}'")# Check uniquenessif db.session.query(Color.id_color).filter( func.lower(Color.name) == name.lower(), Color.id_color != id_color).first(): raise ConflictError('Ya existe otro color con ese nombre')
# 1. ROUTE LAYER - Catches exceptions@colors_bp.route('/create', methods=['GET', 'POST'])def create_color(): form = ColorForm() if form.validate_on_submit(): data = {'name': form.name.data} try: # Delegate to service ColorService.create(data) flash('Color creado exitosamente', 'success') return redirect(url_for('colors.create_color')) except ConflictError as e: # Handle specific exception flash(e.message, 'error') except ValidationError as e: # Handle validation errors flash(e.message, 'error') return render_template('colors/create.html', form=form)# 2. SERVICE LAYER - Raises exceptionsclass ColorService: @staticmethod def create(data: dict) -> dict: name = data.get('name') # Business validation if not name or not name.strip(): raise ValidationError('El nombre del color es requerido') name = name.strip() # Check for duplicates existing = Color.query.filter( func.lower(Color.name) == name.lower() ).first() if existing: raise ConflictError( f"Ya existe un color con el nombre '{name}'" ) # Create and persist color = Color(name=name) db.session.add(color) try: db.session.commit() except IntegrityError: db.session.rollback() raise ConflictError( f"Ya existe un color con el nombre '{name}'" ) return color.to_dict()
# Empty or missing required fieldsif not name or not name.strip(): raise ValidationError('El nombre es requerido')# Length constraintsif len(name) > 50: raise ValidationError('El nombre no puede exceder 50 caracteres')# Format validationif not re.match(r'^#[0-9A-Fa-f]{6}$', hex_code): raise ValidationError('Código hex inválido')# Range validationif quantity < 0: raise ValidationError('La cantidad no puede ser negativa')
NotFoundError - Missing Resources
Use when a resource doesn’t exist:
# Entity not found by IDcolor = Color.query.get(id_color)if not color: raise NotFoundError(f'No se encontró color con ID {id_color}')# Entity not found by criteriauser = User.query.filter_by(email=email).first()if not user: raise NotFoundError('Usuario no encontrado')
ConflictError - Resource Conflicts
Use when a conflict occurs:
# Duplicate entryexisting = Color.query.filter_by(name=name).first()if existing: raise ConflictError('Ya existe un color con ese nombre')# State conflictif order.status == 'shipped': raise ConflictError('No se puede modificar un pedido enviado')# Concurrent modificationif entity.version != expected_version: raise ConflictError('El recurso fue modificado por otro usuario')
# Good - Specific and actionableraise ValidationError('El nombre del color es requerido')raise NotFoundError(f'No se encontró color con ID {id_color}')# Bad - Generic and unhelpfulraise ValidationError('Error de validación')raise NotFoundError('No encontrado')
# Good - Clear for end usersraise ConflictError( f"Ya existe un color con el nombre '{name}'. " "Por favor, elige un nombre diferente.")# Bad - Technical jargonraise ConflictError( f"UNIQUE constraint failed on colors.name for value '{name}'")
# Good - Provides contextraise ValidationError( 'El nombre debe tener entre 1 y 50 caracteres', payload={'current_length': len(name), 'max_length': 50})# Acceptable - Simple messageraise ValidationError('El nombre debe tener entre 1 y 50 caracteres')
import pytestfrom app.exceptions import ValidationError, ConflictError, NotFoundErrorfrom app.catalogs.colors.services import ColorServicedef test_create_color_validation_error(): """Test that empty name raises ValidationError.""" with pytest.raises(ValidationError) as exc_info: ColorService.create({'name': ''}) assert 'nombre del color es requerido' in str(exc_info.value)def test_create_color_conflict_error(db_session): """Test that duplicate name raises ConflictError.""" # Create first color ColorService.create({'name': 'Red'}) # Try to create duplicate with pytest.raises(ConflictError) as exc_info: ColorService.create({'name': 'Red'}) assert 'Ya existe' in str(exc_info.value)def test_get_color_not_found_error(): """Test that non-existent ID raises NotFoundError.""" with pytest.raises(NotFoundError) as exc_info: ColorService.get_by_id(99999) assert '99999' in str(exc_info.value)
The exception hierarchy can be extended as needed:
app/exceptions.py
class UnauthorizedError(AppException): """Excepción para acceso no autorizado (401).""" def __init__(self, message='No autorizado', payload=None): super().__init__(message, status_code=401, payload=payload)class ForbiddenError(AppException): """Excepción para permisos insuficientes (403).""" def __init__(self, message='Acceso prohibido', payload=None): super().__init__(message, status_code=403, payload=payload)class BusinessLogicError(AppException): """Excepción para errores de lógica de negocio (422).""" def __init__(self, message='Error de lógica de negocio', payload=None): super().__init__(message, status_code=422, payload=payload)class DatabaseError(AppException): """Excepción para errores de base de datos (500).""" def __init__(self, message='Error de base de datos', payload=None): super().__init__(message, status_code=500, payload=payload)