You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airflow.apache.org by ka...@apache.org on 2020/08/31 13:37:27 UTC

[airflow] branch master updated: Unify error messages and complete type field in response (#10333)

This is an automated email from the ASF dual-hosted git repository.

kamilbregula pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/master by this push:
     new aa2db70  Unify error messages and complete type field in response (#10333)
aa2db70 is described below

commit aa2db70494ee9c6369f601bbd5845bdc4ccfef92
Author: Ephraim Anierobi <sp...@gmail.com>
AuthorDate: Mon Aug 31 14:36:52 2020 +0100

    Unify error messages and complete type field in response (#10333)
    
    Co-authored-by: Kamil BreguĊ‚a <mi...@users.noreply.github.com>
---
 .../api_connexion/endpoints/connection_endpoint.py |  21 ++--
 airflow/api_connexion/endpoints/dag_endpoint.py    |   4 +-
 .../api_connexion/endpoints/dag_run_endpoint.py    |   7 +-
 .../endpoints/import_error_endpoint.py             |   5 +-
 airflow/api_connexion/exceptions.py                | 113 ++++++++++++++++++---
 airflow/api_connexion/openapi/v1.yaml              |  29 ++++++
 airflow/www/extensions/init_views.py               |   4 +-
 tests/api/auth/backend/test_basic_auth.py          |  15 +--
 .../endpoints/test_connection_endpoint.py          |  34 +++++--
 tests/api_connexion/endpoints/test_dag_endpoint.py |   3 +-
 .../endpoints/test_dag_run_endpoint.py             |  26 +++--
 .../endpoints/test_event_log_endpoint.py           |   3 +-
 .../endpoints/test_extra_link_endpoint.py          |   8 +-
 .../endpoints/test_import_error_endpoint.py        |   8 +-
 tests/api_connexion/endpoints/test_log_endpoint.py |   5 +-
 .../api_connexion/endpoints/test_pool_endpoint.py  |  53 ++++++----
 .../endpoints/test_variable_endpoint.py            |   7 +-
 tests/test_utils/api_connexion_utils.py            |   4 +-
 18 files changed, 264 insertions(+), 85 deletions(-)

diff --git a/airflow/api_connexion/endpoints/connection_endpoint.py b/airflow/api_connexion/endpoints/connection_endpoint.py
index daab4f5..ea4a91d 100644
--- a/airflow/api_connexion/endpoints/connection_endpoint.py
+++ b/airflow/api_connexion/endpoints/connection_endpoint.py
@@ -41,7 +41,10 @@ def delete_connection(connection_id, session):
     """
     connection = session.query(Connection).filter_by(conn_id=connection_id).one_or_none()
     if connection is None:
-        raise NotFound('Connection not found')
+        raise NotFound(
+            'Connection not found',
+            detail=f"The Connection with connection_id: `{connection_id}` was not found",
+        )
     session.delete(connection)
     return NoContent, 204
 
@@ -54,7 +57,10 @@ def get_connection(connection_id, session):
     """
     connection = session.query(Connection).filter(Connection.conn_id == connection_id).one_or_none()
     if connection is None:
-        raise NotFound("Connection not found")
+        raise NotFound(
+            "Connection not found",
+            detail=f"The Connection with connection_id: `{connection_id}` was not found",
+        )
     return connection_collection_item_schema.dump(connection)
 
 
@@ -87,9 +93,12 @@ def patch_connection(connection_id, session, update_mask=None):
     non_update_fields = ['connection_id', 'conn_id']
     connection = session.query(Connection).filter_by(conn_id=connection_id).first()
     if connection is None:
-        raise NotFound("Connection not found")
+        raise NotFound(
+            "Connection not found",
+            detail=f"The Connection with connection_id: `{connection_id}` was not found",
+        )
     if data.get('conn_id', None) and connection.conn_id != data['conn_id']:
-        raise BadRequest("The connection_id cannot be updated.")
+        raise BadRequest(detail="The connection_id cannot be updated.")
     if update_mask:
         update_mask = [i.strip() for i in update_mask]
         data_ = {}
@@ -97,7 +106,7 @@ def patch_connection(connection_id, session, update_mask=None):
             if field in data and field not in non_update_fields:
                 data_[field] = data[field]
             else:
-                raise BadRequest(f"'{field}' is unknown or cannot be updated.")
+                raise BadRequest(detail=f"'{field}' is unknown or cannot be updated.")
         data = data_
     for key in data:
         setattr(connection, key, data[key])
@@ -125,4 +134,4 @@ def post_connection(session):
         session.add(connection)
         session.commit()
         return connection_schema.dump(connection)
-    raise AlreadyExists("Connection already exist. ID: %s" % conn_id)
+    raise AlreadyExists(detail="Connection already exist. ID: %s" % conn_id)
diff --git a/airflow/api_connexion/endpoints/dag_endpoint.py b/airflow/api_connexion/endpoints/dag_endpoint.py
index edf2327..1fddb4b 100644
--- a/airflow/api_connexion/endpoints/dag_endpoint.py
+++ b/airflow/api_connexion/endpoints/dag_endpoint.py
@@ -41,7 +41,7 @@ def get_dag(dag_id, session):
     dag = session.query(DagModel).filter(DagModel.dag_id == dag_id).one_or_none()
 
     if dag is None:
-        raise NotFound("DAG not found")
+        raise NotFound("DAG not found", detail=f"The DAG with dag_id: {dag_id} was not found")
 
     return dag_schema.dump(dag)
 
@@ -53,7 +53,7 @@ def get_dag_details(dag_id):
     """
     dag: DAG = current_app.dag_bag.get_dag(dag_id)
     if not dag:
-        raise NotFound("DAG not found")
+        raise NotFound("DAG not found", detail=f"The DAG with dag_id: {dag_id} was not found")
     return dag_detail_schema.dump(dag)
 
 
diff --git a/airflow/api_connexion/endpoints/dag_run_endpoint.py b/airflow/api_connexion/endpoints/dag_run_endpoint.py
index 0a66b06..0bba126 100644
--- a/airflow/api_connexion/endpoints/dag_run_endpoint.py
+++ b/airflow/api_connexion/endpoints/dag_run_endpoint.py
@@ -52,7 +52,10 @@ def get_dag_run(dag_id, dag_run_id, session):
     """
     dag_run = session.query(DagRun).filter(DagRun.dag_id == dag_id, DagRun.run_id == dag_run_id).one_or_none()
     if dag_run is None:
-        raise NotFound("DAGRun not found")
+        raise NotFound(
+            "DAGRun not found",
+            detail=f"DAGRun with DAG ID: '{dag_id}' and DagRun ID: '{dag_run_id}' not found",
+        )
     return dagrun_schema.dump(dag_run)
 
 
@@ -195,7 +198,7 @@ def post_dag_run(dag_id, session):
     Trigger a DAG.
     """
     if not session.query(DagModel).filter(DagModel.dag_id == dag_id).first():
-        raise NotFound(f"DAG with dag_id: '{dag_id}' not found")
+        raise NotFound(title="DAG not found", detail=f"DAG with dag_id: '{dag_id}' not found")
 
     post_body = dagrun_schema.load(request.json, session=session)
     dagrun_instance = (
diff --git a/airflow/api_connexion/endpoints/import_error_endpoint.py b/airflow/api_connexion/endpoints/import_error_endpoint.py
index 12baeda..10ab501 100644
--- a/airflow/api_connexion/endpoints/import_error_endpoint.py
+++ b/airflow/api_connexion/endpoints/import_error_endpoint.py
@@ -38,7 +38,10 @@ def get_import_error(import_error_id, session):
     error = session.query(ImportError).filter(ImportError.id == import_error_id).one_or_none()
 
     if error is None:
-        raise NotFound("Import error not found")
+        raise NotFound(
+            "Import error not found",
+            detail=f"The ImportError with import_error_id: `{import_error_id}` was not found",
+        )
     return import_error_schema.dump(error)
 
 
diff --git a/airflow/api_connexion/exceptions.py b/airflow/api_connexion/exceptions.py
index 7e34f31..a36257c 100644
--- a/airflow/api_connexion/exceptions.py
+++ b/airflow/api_connexion/exceptions.py
@@ -16,48 +16,135 @@
 # under the License.
 from typing import Dict, Optional
 
-from connexion import ProblemException
+import werkzeug
+from connexion import FlaskApi, ProblemException, problem
+
+from airflow import version
+
+doc_link = f'https://airflow.apache.org/docs/{version.version}/stable-rest-api-ref.html'
+if 'dev' in version.version:
+    doc_link = "https://airflow.readthedocs.io/en/latest/stable-rest-api-ref.html"
+
+EXCEPTIONS_LINK_MAP = {
+    400: f"{doc_link}#section/Errors/BadRequest",
+    404: f"{doc_link}#section/Errors/NotFound",
+    401: f"{doc_link}#section/Errors/Unauthenticated",
+    409: f"{doc_link}#section/Errors/AlreadyExists",
+    403: f"{doc_link}#section/Errors/PermissionDenied",
+    500: f"{doc_link}#section/Errors/Unknown",
+}
+
+
+def common_error_handler(exception):
+    """
+    Used to capture connexion exceptions and add link to the type field
+    :type exception: Exception
+    """
+    if isinstance(exception, ProblemException):
+
+        link = EXCEPTIONS_LINK_MAP.get(exception.status, None)
+        if link:
+            response = problem(
+                status=exception.status,
+                title=exception.title,
+                detail=exception.detail,
+                type=link,
+                instance=exception.instance,
+                headers=exception.headers,
+                ext=exception.ext,
+            )
+        else:
+            response = problem(
+                status=exception.status,
+                title=exception.title,
+                detail=exception.detail,
+                type=exception.type,
+                instance=exception.instance,
+                headers=exception.headers,
+                ext=exception.ext,
+            )
+    else:
+        if not isinstance(exception, werkzeug.exceptions.HTTPException):
+            exception = werkzeug.exceptions.InternalServerError()
+
+        response = problem(title=exception.name, detail=exception.description, status=exception.code)
+
+    return FlaskApi.get_response(response)
 
 
 class NotFound(ProblemException):
     """Raise when the object cannot be found"""
 
-    def __init__(self, title='Object not found', detail=None):
-        super().__init__(status=404, title=title, detail=detail)
+    def __init__(
+        self, title: str = 'Not Found', detail: Optional[str] = None, headers: Optional[Dict] = None, **kwargs
+    ):
+        super(NotFound, self).__init__(
+            status=404, type=EXCEPTIONS_LINK_MAP[404], title=title, detail=detail, headers=headers, **kwargs
+        )
 
 
 class BadRequest(ProblemException):
     """Raise when the server processes a bad request"""
 
-    def __init__(self, title='Bad request', detail=None):
-        super().__init__(status=400, title=title, detail=detail)
+    def __init__(
+        self,
+        title: str = 'Bad Request',
+        detail: Optional[str] = None,
+        headers: Optional[Dict] = None,
+        **kwargs,
+    ):
+        super(BadRequest, self).__init__(
+            status=400, type=EXCEPTIONS_LINK_MAP[400], title=title, detail=detail, headers=headers, **kwargs
+        )
 
 
 class Unauthenticated(ProblemException):
     """Raise when the user is not authenticated"""
 
     def __init__(
-        self, title: str = 'Unauthorized', detail: Optional[str] = None, headers: Optional[Dict] = None,
+        self,
+        title: str = 'Unauthorized',
+        detail: Optional[str] = None,
+        headers: Optional[Dict] = None,
+        **kwargs,
     ):
-        super().__init__(status=401, title=title, detail=detail, headers=headers)
+        super(Unauthenticated, self).__init__(
+            status=401, type=EXCEPTIONS_LINK_MAP[401], title=title, detail=detail, headers=headers, **kwargs
+        )
 
 
 class PermissionDenied(ProblemException):
     """Raise when the user does not have the required permissions"""
 
-    def __init__(self, title='Forbidden', detail=None):
-        super().__init__(status=403, title=title, detail=detail)
+    def __init__(
+        self, title: str = 'Forbidden', detail: Optional[str] = None, headers: Optional[Dict] = None, **kwargs
+    ):
+        super(PermissionDenied, self).__init__(
+            status=403, type=EXCEPTIONS_LINK_MAP[403], title=title, detail=detail, headers=headers, **kwargs
+        )
 
 
 class AlreadyExists(ProblemException):
     """Raise when the object already exists"""
 
-    def __init__(self, title='Object already exists', detail=None):
-        super().__init__(status=409, title=title, detail=detail)
+    def __init__(
+        self, title='Conflict', detail: Optional[str] = None, headers: Optional[Dict] = None, **kwargs
+    ):
+        super(AlreadyExists, self).__init__(
+            status=409, type=EXCEPTIONS_LINK_MAP[409], title=title, detail=detail, headers=headers, **kwargs
+        )
 
 
 class Unknown(ProblemException):
     """Returns a response body and status code for HTTP 500 exception"""
 
-    def __init__(self, title='Unknown server error', detail=None):
-        super().__init__(status=500, title=title, detail=detail)
+    def __init__(
+        self,
+        title: str = 'Internal Server Error',
+        detail: Optional[str] = None,
+        headers: Optional[Dict] = None,
+        **kwargs,
+    ):
+        super(Unknown, self).__init__(
+            status=500, type=EXCEPTIONS_LINK_MAP[500], title=title, detail=detail, headers=headers, **kwargs
+        )
diff --git a/airflow/api_connexion/openapi/v1.yaml b/airflow/api_connexion/openapi/v1.yaml
index 6943563..6727462 100644
--- a/airflow/api_connexion/openapi/v1.yaml
+++ b/airflow/api_connexion/openapi/v1.yaml
@@ -173,6 +173,35 @@ info:
     For details on configuring the authentication, see
     [API Authorization](https://airflow.readthedocs.io/en/latest/security/api.html).
 
+    # Errors
+
+    We follow the error response format proposed in [RFC 7807](https://tools.ietf.org/html/rfc7807)
+    also known as Problem Details for HTTP APIs.  As with our normal API responses,
+    your client must be prepared to gracefully handle additional members of the response.
+    ## Unauthenticated
+    This indicates that the request has not been applied because it lacks valid authentication
+    credentials for the target resource. Please check that you have valid credentials.
+    ## PermissionDenied
+    This response means that the server understood the request but refuses to authorize
+    it because it lacks sufficient rights to the resource. It happens when you do not have the
+    necessary permission to execute the action you performed. You need to get the appropriate
+    permissions in other to resolve this error.
+    ## BadRequest
+    This response means that the server cannot or will not process the request due to something
+    that is perceived to be a client error (e.g., malformed request syntax, invalid request message
+    framing, or deceptive request routing). To resolve this, please ensure that your syntax is correct.
+    ## NotFound
+    This client error response indicates that the server cannot find the requested resource.
+    ## NotAcceptable
+    The target resource does not have a current representation that would be acceptable to the user
+    agent, according to the proactive negotiation header fields received in the request, and the
+    server is unwilling to supply a default representation.
+    ## AlreadyExists
+    The request could not be completed due to a conflict with the current state of the target
+    resource, meaning that the resource already exists
+    ## Unknown
+    This means that the server encountered an unexpected condition that prevented it from
+    fulfilling the request.
 
   version: '1.0.0'
   license:
diff --git a/airflow/www/extensions/init_views.py b/airflow/www/extensions/init_views.py
index ac1e7a4..ea39c8a 100644
--- a/airflow/www/extensions/init_views.py
+++ b/airflow/www/extensions/init_views.py
@@ -22,6 +22,8 @@ import connexion
 from connexion import ProblemException
 from flask import Flask
 
+from airflow.api_connexion.exceptions import common_error_handler
+
 log = logging.getLogger(__name__)
 
 # airflow/www/extesions/init_views.py => airflow/
@@ -104,7 +106,7 @@ def init_api_connexion(app: Flask) -> None:
     api_bp = connexion_app.add_api(
         specification='v1.yaml', base_path='/api/v1', validate_responses=True, strict_validation=True
     ).blueprint
-    app.register_error_handler(ProblemException, connexion_app.common_error_handler)
+    app.register_error_handler(ProblemException, common_error_handler)
     app.extensions['csrf'].exempt(api_bp)
 
 
diff --git a/tests/api/auth/backend/test_basic_auth.py b/tests/api/auth/backend/test_basic_auth.py
index 9a2a4a7..0646134 100644
--- a/tests/api/auth/backend/test_basic_auth.py
+++ b/tests/api/auth/backend/test_basic_auth.py
@@ -22,6 +22,7 @@ from flask_login import current_user
 from parameterized import parameterized
 
 from airflow.www.app import create_app
+from tests.test_utils.api_connexion_utils import assert_401
 from tests.test_utils.config import conf_vars
 from tests.test_utils.db import clear_db_pools
 
@@ -89,12 +90,7 @@ class TestBasicAuth(unittest.TestCase):
             assert response.status_code == 401
             assert response.headers["Content-Type"] == "application/problem+json"
             assert response.headers["WWW-Authenticate"] == "Basic"
-            assert response.json == {
-                'detail': None,
-                'status': 401,
-                'title': 'Unauthorized',
-                'type': 'about:blank',
-            }
+            assert_401(response)
 
     @parameterized.expand([
         ("basic " + b64encode(b"test").decode(),),
@@ -110,12 +106,7 @@ class TestBasicAuth(unittest.TestCase):
             assert response.status_code == 401
             assert response.headers["Content-Type"] == "application/problem+json"
             assert response.headers["WWW-Authenticate"] == "Basic"
-            assert response.json == {
-                'detail': None,
-                'status': 401,
-                'title': 'Unauthorized',
-                'type': 'about:blank',
-            }
+            assert_401(response)
 
     def test_experimental_api(self):
         with self.app.test_client() as test_client:
diff --git a/tests/api_connexion/endpoints/test_connection_endpoint.py b/tests/api_connexion/endpoints/test_connection_endpoint.py
index 7bde11f..134f525 100644
--- a/tests/api_connexion/endpoints/test_connection_endpoint.py
+++ b/tests/api_connexion/endpoints/test_connection_endpoint.py
@@ -18,6 +18,7 @@ import unittest
 
 from parameterized import parameterized
 
+from airflow.api_connexion.exceptions import EXCEPTIONS_LINK_MAP
 from airflow.models import Connection
 from airflow.utils.session import provide_session
 from airflow.www import app
@@ -76,7 +77,12 @@ class TestDeleteConnection(TestConnectionEndpoint):
         assert response.status_code == 404
         self.assertEqual(
             response.json,
-            {'detail': None, 'status': 404, 'title': 'Connection not found', 'type': 'about:blank'},
+            {
+                'detail': "The Connection with connection_id: `test-connection` was not found",
+                'status': 404,
+                'title': 'Connection not found',
+                'type': EXCEPTIONS_LINK_MAP[404],
+            },
         )
 
     def test_should_raises_401_unauthenticated(self):
@@ -122,7 +128,12 @@ class TestGetConnection(TestConnectionEndpoint):
         )
         assert response.status_code == 404
         self.assertEqual(
-            {'detail': None, 'status': 404, 'title': 'Connection not found', 'type': 'about:blank'},
+            {
+                'detail': "The Connection with connection_id: `invalid-connection` was not found",
+                'status': 404,
+                'title': 'Connection not found',
+                'type': EXCEPTIONS_LINK_MAP[404],
+            },
             response.json,
         )
 
@@ -362,7 +373,7 @@ class TestPatchConnection(TestConnectionEndpoint):
             environ_overrides={'REMOTE_USER': "test"},
         )
         assert response.status_code == 400
-        self.assertEqual(response.json['title'], error_message)
+        self.assertEqual(response.json['detail'], error_message)
 
     @parameterized.expand(
         [
@@ -409,7 +420,12 @@ class TestPatchConnection(TestConnectionEndpoint):
         )
         assert response.status_code == 404
         self.assertEqual(
-            {'detail': None, 'status': 404, 'title': 'Connection not found', 'type': 'about:blank'},
+            {
+                'detail': "The Connection with connection_id: `test-connection-id` was not found",
+                'status': 404,
+                'title': 'Connection not found',
+                'type': EXCEPTIONS_LINK_MAP[404],
+            },
             response.json,
         )
 
@@ -450,8 +466,8 @@ class TestPostConnection(TestConnectionEndpoint):
             {
                 'detail': "{'conn_type': ['Missing data for required field.']}",
                 'status': 400,
-                'title': 'Bad request',
-                'type': 'about:blank',
+                'title': 'Bad Request',
+                'type': EXCEPTIONS_LINK_MAP[400],
             },
         )
 
@@ -469,10 +485,10 @@ class TestPostConnection(TestConnectionEndpoint):
         self.assertEqual(
             response.json,
             {
-                'detail': None,
+                'detail': 'Connection already exist. ID: test-connection-id',
                 'status': 409,
-                'title': 'Connection already exist. ID: test-connection-id',
-                'type': 'about:blank',
+                'title': 'Conflict',
+                'type': EXCEPTIONS_LINK_MAP[409],
             },
         )
 
diff --git a/tests/api_connexion/endpoints/test_dag_endpoint.py b/tests/api_connexion/endpoints/test_dag_endpoint.py
index d89fd85..8c396d6 100644
--- a/tests/api_connexion/endpoints/test_dag_endpoint.py
+++ b/tests/api_connexion/endpoints/test_dag_endpoint.py
@@ -21,6 +21,7 @@ from datetime import datetime
 from parameterized import parameterized
 
 from airflow import DAG
+from airflow.api_connexion.exceptions import EXCEPTIONS_LINK_MAP
 from airflow.models import DagBag, DagModel
 from airflow.models.serialized_dag import SerializedDagModel
 from airflow.operators.dummy_operator import DummyOperator
@@ -329,7 +330,7 @@ class TestPatchDag(TestDagEndpoint):
                 'detail': "Property is read-only - 'schedule_interval'",
                 'status': 400,
                 'title': 'Bad Request',
-                'type': 'about:blank',
+                'type': EXCEPTIONS_LINK_MAP[400],
             },
         )
 
diff --git a/tests/api_connexion/endpoints/test_dag_run_endpoint.py b/tests/api_connexion/endpoints/test_dag_run_endpoint.py
index 816beb5..4d05ebe 100644
--- a/tests/api_connexion/endpoints/test_dag_run_endpoint.py
+++ b/tests/api_connexion/endpoints/test_dag_run_endpoint.py
@@ -19,6 +19,7 @@ from datetime import timedelta
 
 from parameterized import parameterized
 
+from airflow.api_connexion.exceptions import EXCEPTIONS_LINK_MAP
 from airflow.models import DagModel, DagRun
 from airflow.utils import timezone
 from airflow.utils.session import create_session, provide_session
@@ -118,8 +119,8 @@ class TestDeleteDagRun(TestDagRunEndpoint):
             {
                 "detail": "DAGRun with DAG ID: 'INVALID_DAG_RUN' and DagRun ID: 'INVALID_DAG_RUN' not found",
                 "status": 404,
-                "title": "Object not found",
-                "type": "about:blank",
+                "title": "Not Found",
+                "type": EXCEPTIONS_LINK_MAP[404],
             },
         )
 
@@ -169,7 +170,12 @@ class TestGetDagRun(TestDagRunEndpoint):
             "api/v1/dags/invalid-id/dagRuns/invalid-id", environ_overrides={'REMOTE_USER': "test"}
         )
         assert response.status_code == 404
-        expected_resp = {'detail': None, 'status': 404, 'title': 'DAGRun not found', 'type': 'about:blank'}
+        expected_resp = {
+            'detail': "DAGRun with DAG ID: 'invalid-id' and DagRun ID: 'invalid-id' not found",
+            'status': 404,
+            'title': 'DAGRun not found',
+            'type': EXCEPTIONS_LINK_MAP[404],
+        }
         assert expected_resp == response.json
 
     @provide_session
@@ -708,10 +714,10 @@ class TestPostDagRun(TestDagRunEndpoint):
         self.assertEqual(response.status_code, 404)
         self.assertEqual(
             {
-                "detail": None,
+                "detail": "DAG with dag_id: 'TEST_DAG_ID' not found",
                 "status": 404,
-                "title": "DAG with dag_id: 'TEST_DAG_ID' not found",
-                "type": "about:blank",
+                "title": "DAG not found",
+                "type": EXCEPTIONS_LINK_MAP[404],
             },
             response.json,
         )
@@ -726,7 +732,7 @@ class TestPostDagRun(TestDagRunEndpoint):
                     "detail": "Property is read-only - 'start_date'",
                     "status": 400,
                     "title": "Bad Request",
-                    "type": "about:blank",
+                    "type": EXCEPTIONS_LINK_MAP[400],
                 },
             ),
             (
@@ -737,7 +743,7 @@ class TestPostDagRun(TestDagRunEndpoint):
                     "detail": "Property is read-only - 'state'",
                     "status": 400,
                     "title": "Bad Request",
-                    "type": "about:blank",
+                    "type": EXCEPTIONS_LINK_MAP[400],
                 },
             ),
         ]
@@ -770,8 +776,8 @@ class TestPostDagRun(TestDagRunEndpoint):
                 "detail": "DAGRun with DAG ID: 'TEST_DAG_ID' and "
                 "DAGRun ID: 'TEST_DAG_RUN_ID_1' already exists",
                 "status": 409,
-                "title": "Object already exists",
-                "type": "about:blank",
+                "title": "Conflict",
+                "type": EXCEPTIONS_LINK_MAP[409],
             },
         )
 
diff --git a/tests/api_connexion/endpoints/test_event_log_endpoint.py b/tests/api_connexion/endpoints/test_event_log_endpoint.py
index 780f447..d8d5d21 100644
--- a/tests/api_connexion/endpoints/test_event_log_endpoint.py
+++ b/tests/api_connexion/endpoints/test_event_log_endpoint.py
@@ -19,6 +19,7 @@ import unittest
 from parameterized import parameterized
 
 from airflow import DAG
+from airflow.api_connexion.exceptions import EXCEPTIONS_LINK_MAP
 from airflow.models import Log, TaskInstance
 from airflow.operators.dummy_operator import DummyOperator
 from airflow.utils import timezone
@@ -93,7 +94,7 @@ class TestGetEventLog(TestEventLogEndpoint):
         response = self.client.get("/api/v1/eventLogs/1", environ_overrides={'REMOTE_USER': "test"})
         assert response.status_code == 404
         self.assertEqual(
-            {'detail': None, 'status': 404, 'title': 'Event Log not found', 'type': 'about:blank'},
+            {'detail': None, 'status': 404, 'title': 'Event Log not found', 'type': EXCEPTIONS_LINK_MAP[404]},
             response.json,
         )
 
diff --git a/tests/api_connexion/endpoints/test_extra_link_endpoint.py b/tests/api_connexion/endpoints/test_extra_link_endpoint.py
index f0bdd7c..19433db 100644
--- a/tests/api_connexion/endpoints/test_extra_link_endpoint.py
+++ b/tests/api_connexion/endpoints/test_extra_link_endpoint.py
@@ -21,6 +21,7 @@ from urllib.parse import quote_plus
 from parameterized import parameterized
 
 from airflow import DAG
+from airflow.api_connexion.exceptions import EXCEPTIONS_LINK_MAP
 from airflow.models.baseoperator import BaseOperatorLink
 from airflow.models.dagrun import DagRun
 from airflow.models.xcom import XCom
@@ -114,7 +115,12 @@ class TestGetExtraLinks(unittest.TestCase):
 
         self.assertEqual(404, response.status_code)
         self.assertEqual(
-            {"detail": expected_detail, "status": 404, "title": expected_title, "type": "about:blank"},
+            {
+                "detail": expected_detail,
+                "status": 404,
+                "title": expected_title,
+                "type": EXCEPTIONS_LINK_MAP[404],
+            },
             response.json,
         )
 
diff --git a/tests/api_connexion/endpoints/test_import_error_endpoint.py b/tests/api_connexion/endpoints/test_import_error_endpoint.py
index 78439fa..404ff06 100644
--- a/tests/api_connexion/endpoints/test_import_error_endpoint.py
+++ b/tests/api_connexion/endpoints/test_import_error_endpoint.py
@@ -18,6 +18,7 @@ import unittest
 
 from parameterized import parameterized
 
+from airflow.api_connexion.exceptions import EXCEPTIONS_LINK_MAP
 from airflow.models.errors import ImportError  # pylint: disable=redefined-builtin
 from airflow.utils import timezone
 from airflow.utils.session import provide_session
@@ -87,7 +88,12 @@ class TestGetImportErrorEndpoint(TestBaseImportError):
         response = self.client.get("/api/v1/importErrors/2", environ_overrides={'REMOTE_USER': "test"})
         assert response.status_code == 404
         self.assertEqual(
-            {"detail": None, "status": 404, "title": "Import error not found", "type": "about:blank",},
+            {
+                "detail": "The ImportError with import_error_id: `2` was not found",
+                "status": 404,
+                "title": "Import error not found",
+                "type": EXCEPTIONS_LINK_MAP[404],
+            },
             response.json,
         )
 
diff --git a/tests/api_connexion/endpoints/test_log_endpoint.py b/tests/api_connexion/endpoints/test_log_endpoint.py
index 2acacf3..2921a90 100644
--- a/tests/api_connexion/endpoints/test_log_endpoint.py
+++ b/tests/api_connexion/endpoints/test_log_endpoint.py
@@ -27,6 +27,7 @@ from unittest.mock import PropertyMock
 from itsdangerous.url_safe import URLSafeSerializer
 
 from airflow import DAG, settings
+from airflow.api_connexion.exceptions import EXCEPTIONS_LINK_MAP
 from airflow.config_templates.airflow_local_settings import DEFAULT_LOGGING_CONFIG
 from airflow.models import DagRun, TaskInstance
 from airflow.operators.dummy_operator import DummyOperator
@@ -256,7 +257,7 @@ class TestGetLog(unittest.TestCase):
                 'detail': None,
                 'status': 400,
                 'title': "Bad Signature. Please use only the tokens provided by the API.",
-                'type': 'about:blank',
+                'type': EXCEPTIONS_LINK_MAP[400],
             },
         )
 
@@ -269,7 +270,7 @@ class TestGetLog(unittest.TestCase):
         )
         self.assertEqual(
             response.json,
-            {'detail': None, 'status': 404, 'title': "DAG Run not found", 'type': 'about:blank'},
+            {'detail': None, 'status': 404, 'title': "DAG Run not found", 'type': EXCEPTIONS_LINK_MAP[404]},
         )
 
     def test_should_raises_401_unauthenticated(self):
diff --git a/tests/api_connexion/endpoints/test_pool_endpoint.py b/tests/api_connexion/endpoints/test_pool_endpoint.py
index baba612..4136f6a 100644
--- a/tests/api_connexion/endpoints/test_pool_endpoint.py
+++ b/tests/api_connexion/endpoints/test_pool_endpoint.py
@@ -18,6 +18,7 @@ import unittest
 
 from parameterized import parameterized
 
+from airflow.api_connexion.exceptions import EXCEPTIONS_LINK_MAP
 from airflow.models.pool import Pool
 from airflow.utils.session import provide_session
 from airflow.www import app
@@ -167,8 +168,8 @@ class TestGetPool(TestBasePoolEndpoints):
             {
                 "detail": "Pool with name:'invalid_pool' not found",
                 "status": 404,
-                "title": "Object not found",
-                "type": "about:blank",
+                "title": "Not Found",
+                "type": EXCEPTIONS_LINK_MAP[404],
             },
             response.json,
         )
@@ -200,8 +201,8 @@ class TestDeletePool(TestBasePoolEndpoints):
             {
                 "detail": "Pool with name:'invalid_pool' not found",
                 "status": 404,
-                "title": "Object not found",
-                "type": "about:blank",
+                "title": "Not Found",
+                "type": EXCEPTIONS_LINK_MAP[404],
             },
             response.json,
         )
@@ -258,8 +259,8 @@ class TestPostPool(TestBasePoolEndpoints):
             {
                 "detail": f"Pool: {pool_name} already exists",
                 "status": 409,
-                "title": "Object already exists",
-                "type": "about:blank",
+                "title": "Conflict",
+                "type": EXCEPTIONS_LINK_MAP[409],
             },
             response.json,
         )
@@ -282,7 +283,12 @@ class TestPostPool(TestBasePoolEndpoints):
         )
         assert response.status_code == 400
         self.assertDictEqual(
-            {"detail": error_detail, "status": 400, "title": "Bad request", "type": "about:blank",},
+            {
+                "detail": error_detail,
+                "status": 400,
+                "title": "Bad Request",
+                "type": EXCEPTIONS_LINK_MAP[400],
+            },
             response.json,
         )
 
@@ -338,7 +344,12 @@ class TestPatchPool(TestBasePoolEndpoints):
         )
         assert response.status_code == 400
         self.assertEqual(
-            {"detail": error_detail, "status": 400, "title": "Bad request", "type": "about:blank",},
+            {
+                "detail": error_detail,
+                "status": 400,
+                "title": "Bad Request",
+                "type": EXCEPTIONS_LINK_MAP[400],
+            },
             response.json,
         )
 
@@ -361,8 +372,8 @@ class TestModifyDefaultPool(TestBasePoolEndpoints):
             {
                 "detail": "Default Pool can't be deleted",
                 "status": 400,
-                "title": "Bad request",
-                "type": "about:blank",
+                "title": "Bad Request",
+                "type": EXCEPTIONS_LINK_MAP[400],
             },
             response.json,
         )
@@ -377,8 +388,8 @@ class TestModifyDefaultPool(TestBasePoolEndpoints):
                 {
                     "detail": "Default Pool's name can't be modified",
                     "status": 400,
-                    "title": "Bad request",
-                    "type": "about:blank",
+                    "title": "Bad Request",
+                    "type": EXCEPTIONS_LINK_MAP[400],
                 },
             ),
             (
@@ -389,8 +400,8 @@ class TestModifyDefaultPool(TestBasePoolEndpoints):
                 {
                     "detail": "Default Pool's name can't be modified",
                     "status": 400,
-                    "title": "Bad request",
-                    "type": "about:blank",
+                    "title": "Bad Request",
+                    "type": EXCEPTIONS_LINK_MAP[400],
                 },
             ),
             (
@@ -503,13 +514,13 @@ class TestPatchPoolWithUpdateMask(TestBasePoolEndpoints):
             ),
             (
                 "Invalid update mask",
-                "'names' is not a valid Pool field",
+                "Invalid field: names in update mask",
                 "api/v1/pools/test_pool?update_mask=slots, names,",
                 {"name": "test_pool_a", "slots": 2},
             ),
             (
                 "Invalid update mask",
-                "'slot' is not a valid Pool field",
+                "Invalid field: slot in update mask",
                 "api/v1/pools/test_pool?update_mask=slot, name,",
                 {"name": "test_pool_a", "slots": 2},
             ),
@@ -523,8 +534,12 @@ class TestPatchPoolWithUpdateMask(TestBasePoolEndpoints):
         session.commit()
         response = self.client.patch(url, json=patch_json, environ_overrides={'REMOTE_USER': "test"})
         assert response.status_code == 400
-        self.assertEqual
-        (
-            {"detail": error_detail, "status": 400, "title": "Bad Request", "type": "about:blank",},
+        self.assertEqual(
+            {
+                "detail": error_detail,
+                "status": 400,
+                "title": "Bad Request",
+                "type": EXCEPTIONS_LINK_MAP[400],
+            },
             response.json,
         )
diff --git a/tests/api_connexion/endpoints/test_variable_endpoint.py b/tests/api_connexion/endpoints/test_variable_endpoint.py
index 9413a3c..77e07b4 100644
--- a/tests/api_connexion/endpoints/test_variable_endpoint.py
+++ b/tests/api_connexion/endpoints/test_variable_endpoint.py
@@ -18,6 +18,7 @@ import unittest
 
 from parameterized import parameterized
 
+from airflow.api_connexion.exceptions import EXCEPTIONS_LINK_MAP
 from airflow.models import Variable
 from airflow.www import app
 from tests.test_utils.api_connexion_utils import assert_401, create_user, delete_user
@@ -185,7 +186,7 @@ class TestPatchVariable(TestVariableEndpoint):
         assert response.json == {
             "title": "Invalid post body",
             "status": 400,
-            "type": "about:blank",
+            "type": EXCEPTIONS_LINK_MAP[400],
             "detail": "key from request body doesn't match uri parameter",
         }
 
@@ -195,7 +196,7 @@ class TestPatchVariable(TestVariableEndpoint):
         assert response.json == {
             "title": "Invalid Variable schema",
             "status": 400,
-            "type": "about:blank",
+            "type": EXCEPTIONS_LINK_MAP[400],
             "detail": "{'value': ['Missing data for required field.']}",
         }
 
@@ -231,7 +232,7 @@ class TestPostVariables(TestVariableEndpoint):
         assert response.json == {
             "title": "Invalid Variable schema",
             "status": 400,
-            "type": "about:blank",
+            "type": EXCEPTIONS_LINK_MAP[400],
             "detail": "{'value': ['Missing data for required field.'], 'v': ['Unknown field.']}",
         }
 
diff --git a/tests/test_utils/api_connexion_utils.py b/tests/test_utils/api_connexion_utils.py
index bd5d34a..9da434b 100644
--- a/tests/test_utils/api_connexion_utils.py
+++ b/tests/test_utils/api_connexion_utils.py
@@ -14,6 +14,8 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+from airflow.api_connexion.exceptions import EXCEPTIONS_LINK_MAP
+
 
 def create_user(app, username, role):
     appbuilder = app.appbuilder
@@ -43,5 +45,5 @@ def assert_401(response):
         'detail': None,
         'status': 401,
         'title': 'Unauthorized',
-        'type': 'about:blank'
+        'type': EXCEPTIONS_LINK_MAP[401]
     }