Coverage for /home/airflow/.local/lib/python3.12/site-packages/airflow/api_fastapi/common/exceptions.py: 94%
84 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 14:22 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 14:22 +0000
1# Licensed to the Apache Software Foundation (ASF) under one
2# or more contributor license agreements. See the NOTICE file
3# distributed with this work for additional information
4# regarding copyright ownership. The ASF licenses this file
5# to you under the Apache License, Version 2.0 (the
6# "License"); you may not use this file except in compliance
7# with the License. You may obtain a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing,
12# software distributed under the License is distributed on an
13# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14# KIND, either express or implied. See the License for the
15# specific language governing permissions and limitations
16# under the License.
18from __future__ import annotations
20import logging
21import traceback
22from abc import ABC, abstractmethod
23from enum import Enum
24from typing import Generic, TypeVar
26from fastapi import FastAPI, HTTPException, Request, status
27from sqlalchemy.exc import DataError, IntegrityError, SQLAlchemyError
29from airflow.api_fastapi.compat import HTTP_422_UNPROCESSABLE_CONTENT
30from airflow.configuration import conf
31from airflow.exceptions import DeserializationError
32from airflow.utils.strings import get_random_string
34T = TypeVar("T", bound=Exception)
35DBError = TypeVar("DBError", bound=SQLAlchemyError)
37log = logging.getLogger(__name__)
40class BaseErrorHandler(Generic[T], ABC):
41 """Base class for error handlers."""
43 def __init__(self, exception_cls: T) -> None:
44 self.exception_cls = exception_cls
46 @abstractmethod
47 def exception_handler(self, request: Request, exc: T):
48 """exception_handler method."""
49 raise NotImplementedError
52class _DatabaseDialect(Enum):
53 SQLITE = "sqlite"
54 MYSQL = "mysql"
55 POSTGRES = "postgres"
58class _DatabaseErrorHandler(BaseErrorHandler[DBError]):
59 """
60 Base for handlers that turn a SQLAlchemy error into an actionable HTTP response.
62 The failing statement is logged under a random lookup id and echoed back to the
63 caller only when ``[api] expose_stacktrace`` is set; otherwise the response just
64 points at that id in the api server logs. Subclasses set ``status_code`` and
65 ``reason`` and may override ``_should_handle`` to skip exceptions they do not own.
66 """
68 status_code: int
69 reason: str
71 def _should_handle(self, exc: DBError) -> bool:
72 return True
74 def _raise_database_error_response(self, exc: DBError) -> None:
75 statement = getattr(exc, "statement", "hidden")
76 orig_error = getattr(exc, "orig", "hidden")
77 exception_id = get_random_string()
78 stacktrace = "".join(traceback.format_tb(exc.__traceback__))
79 log_message = f"Error with id {exception_id}, statement: {statement}\n{stacktrace}"
80 log.error(log_message)
82 if conf.get("api", "expose_stacktrace") == "True": 82 ↛ 83line 82 didn't jump to line 83 because the condition on line 82 was never true
83 message = log_message
84 statement_out = str(statement)
85 orig_error_out = str(orig_error)
86 else:
87 message = (
88 "Serious error when handling your request. Check logs for more details - "
89 f"you will find it in api server when you look for ID {exception_id}"
90 )
91 statement_out = "hidden"
92 orig_error_out = "hidden"
94 raise HTTPException(
95 status_code=self.status_code,
96 detail={
97 "reason": self.reason,
98 "statement": statement_out,
99 "orig_error": orig_error_out,
100 "message": message,
101 },
102 )
104 def exception_handler(self, request: Request, exc: DBError):
105 if not self._should_handle(exc):
106 return
107 self._raise_database_error_response(exc)
110class _UniqueConstraintErrorHandler(_DatabaseErrorHandler[IntegrityError]):
111 """Translate a unique-constraint ``IntegrityError`` into a 409, matched per database dialect."""
113 status_code = status.HTTP_409_CONFLICT
114 reason = "Unique constraint violation"
116 unique_constraint_error_prefix_dict: dict[_DatabaseDialect, str] = {
117 _DatabaseDialect.SQLITE: "UNIQUE constraint failed",
118 _DatabaseDialect.MYSQL: "Duplicate entry",
119 _DatabaseDialect.POSTGRES: "violates unique constraint",
120 }
122 def __init__(self):
123 super().__init__(IntegrityError)
124 self.dialect: _DatabaseDialect | None = None
126 def _should_handle(self, exc: IntegrityError) -> bool:
127 return self._is_dialect_matched(exc)
129 def _is_dialect_matched(self, exc: IntegrityError) -> bool:
130 """Check if the exception matches the unique constraint error message for any dialect."""
131 exc_orig_str = str(exc.orig)
132 for dialect, error_msg in self.unique_constraint_error_prefix_dict.items():
133 if error_msg in exc_orig_str:
134 self.dialect = dialect
135 return True
136 return False
139class DataErrorHandler(_DatabaseErrorHandler[DataError]):
140 """
141 Translate a ``sqlalchemy.exc.DataError`` into a 422.
143 The database rejected a value that passed Pydantic validation (too long, out of
144 range, or the wrong type for its column), so it is a client error, not a 500.
145 """
147 status_code = HTTP_422_UNPROCESSABLE_CONTENT
148 reason = "Value rejected by database"
150 def __init__(self):
151 super().__init__(DataError)
154class DagErrorHandler(BaseErrorHandler[DeserializationError]):
155 """Handler for Dag related errors."""
157 def __init__(self):
158 super().__init__(DeserializationError)
160 def exception_handler(self, request: Request, exc: DeserializationError):
161 """Handle Dag deserialization exceptions."""
162 raise HTTPException(
163 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
164 detail=f"An error occurred while trying to deserialize Dag: {exc}",
165 )
168class SQLAlchemyErrorHandler(_DatabaseErrorHandler[SQLAlchemyError]):
169 """Generic handler for SQLAlchemyError -> 500 responses."""
171 status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
172 reason = "Database error"
174 def __init__(self):
175 super().__init__(SQLAlchemyError)
178ERROR_HANDLERS: list[BaseErrorHandler] = [
179 _UniqueConstraintErrorHandler(),
180 DataErrorHandler(),
181 SQLAlchemyErrorHandler(),
182 DagErrorHandler(),
183]
186def init_error_handlers(app: FastAPI) -> None:
187 for handler in ERROR_HANDLERS:
188 app.add_exception_handler(handler.exception_cls, handler.exception_handler)