Skip to content

Commit

Permalink
[REFACTOR]Add exception handlers
Browse files Browse the repository at this point in the history
Merge pull request #411 from annuaire-entreprises-data-gouv-fr/handle-exceptions
  • Loading branch information
HAEKADI authored Sep 2, 2024
2 parents 4fd1b11 + 58543b8 commit 2886b85
Show file tree
Hide file tree
Showing 9 changed files with 174 additions and 88 deletions.
48 changes: 29 additions & 19 deletions app/controller/search_params_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
VALID_FIELD_VALUES,
VALID_FIELDS_TO_SELECT,
)
from app.exceptions.exceptions import (
InvalidParamError,
)
from app.utils.helpers import (
check_params_are_none_except_excluded,
clean_str,
Expand Down Expand Up @@ -83,7 +86,7 @@ def cast_as_integer(cls, value: str, info) -> int:
try:
int(value)
except ValueError:
raise ValueError(
raise InvalidParamError(
f"Veuillez indiquer un paramètre `{info.field_name}` entier."
)
return int(value)
Expand All @@ -95,7 +98,7 @@ def cast_as_float(cls, value: str, info) -> float:
raise ValueError
float(value)
except ValueError:
raise ValueError(
raise InvalidParamError(
f"Veuillez indiquer un paramètre `{info.field_name}` flottant."
)
return float(value)
Expand All @@ -114,7 +117,7 @@ def cast_as_float(cls, value: str, info) -> float:
def check_if_number_in_range(cls, value, info):
limits = NUMERIC_FIELD_LIMITS.get(info.field_name)
if value < limits.get("min") or value > limits.get("max"):
raise ValueError(
raise InvalidParamError(
f"Veuillez indiquer un paramètre `{info.field_name}` entre "
f"`{limits.get('min')}` et `{limits.get('max')}`, "
f"par défaut `{limits['default']}`."
Expand Down Expand Up @@ -157,7 +160,7 @@ def list_of_values_should_match_regular_expression(
for value in list_values:
valid_values = VALID_FIELD_VALUES.get(info.field_name)["valid_values"]
if not re.search(valid_values, value):
raise ValueError(
raise InvalidParamError(
f"Au moins une valeur du paramètre {info.field_name} "
"est non valide."
)
Expand All @@ -177,7 +180,7 @@ def list_of_values_must_be_valid(cls, list_of_values: list[str], info) -> list[s
valid_values = VALID_FIELD_VALUES.get(info.field_name)["valid_values"]
for value in list_of_values:
if value not in valid_values:
raise ValueError(
raise InvalidParamError(
f"Au moins un paramètre "
f"`{VALID_FIELD_VALUES.get(info.field_name)['alias']}` "
f"est non valide. "
Expand All @@ -189,7 +192,7 @@ def list_of_values_must_be_valid(cls, list_of_values: list[str], info) -> list[s
def field_must_be_in_valid_list(cls, value: str, info) -> str:
valid_values = VALID_FIELD_VALUES.get(info.field_name)["valid_values"]
if value not in valid_values:
raise ValueError(
raise InvalidParamError(
f"Le paramètre `{VALID_FIELD_VALUES.get(info.field_name)['alias']}` "
f"doit prendre une des valeurs suivantes {valid_values}."
)
Expand Down Expand Up @@ -219,7 +222,9 @@ def field_must_be_in_valid_list(cls, value: str, info) -> str:
def convert_str_to_bool(cls, boolean: str, info) -> bool:
param_name = info.field_name
if boolean.upper() not in ["TRUE", "FALSE"]:
raise ValueError(f"{param_name} doit prendre la valeur 'true' ou 'false' !")
raise InvalidParamError(
f"{param_name} doit prendre la valeur 'true' ou 'false' !"
)
return boolean.upper() == "TRUE"

@field_validator("est_societe_mission", mode="after")
Expand All @@ -230,7 +235,7 @@ def convert_bool_to_insee_value(cls, boolean: bool) -> str:
def check_str_length(cls, field_value: str, info) -> str:
field_length = FIELD_LENGTHS.get(info.field_name)
if len(field_value) != field_length:
raise ValueError(
raise InvalidParamError(
f"Le paramètre `{info.field_name}` "
f"doit contenir {field_length} caractères."
)
Expand All @@ -241,7 +246,7 @@ def check_min_str_length_in_list(cls, list_values: list[str], info) -> list[str]
min_value_len = FIELD_LENGTHS.get(info.field_name)
for value in list_values:
if len(value) < min_value_len:
raise ValueError(
raise InvalidParamError(
"""Chaque identifiant code insee d'une collectivité
territoriale doit contenir au moins 2 caractères."""
)
Expand All @@ -258,7 +263,7 @@ def check_date_format(cls, date_string: str) -> date:
try:
return date.fromisoformat(date_string)
except Exception:
raise ValueError(
raise InvalidParamError(
"Veuillez indiquer une date sous "
"le format : aaaa-mm-jj. Exemple : '1990-01-02'"
)
Expand All @@ -274,7 +279,7 @@ def validate_include(cls, list_fields: list[str], info) -> list[str]:
valid_fields_lowercase = [
field.lower() for field in valid_fields_to_check
]
raise ValueError(
raise InvalidParamError(
"Au moins un champ à inclure est non valide. "
f"Les champs valides : {valid_fields_lowercase}."
)
Expand All @@ -286,7 +291,7 @@ def total_results_should_be_smaller_than_10000(self):
page = self.page
per_page = self.per_page
if page * per_page > NUMERIC_FIELD_LIMITS["total_results"]["max"]:
raise ValueError(
raise InvalidParamError(
"Le nombre total de résultats est restreint à 10 000. "
"Pour garantir cela, le produit du numéro de page "
"(par défaut, page = 1) et du nombre de résultats par page "
Expand All @@ -301,7 +306,7 @@ def validate_date_range(self):
max_date_naiss = self.max_date_naiss_personne
if min_date_naiss and max_date_naiss:
if max_date_naiss < min_date_naiss:
raise ValueError(
raise InvalidParamError(
"Veuillez indiquer une date minimale inférieure à la date maximale."
)
return self
Expand All @@ -311,7 +316,7 @@ def validate_inclusion_fields(self):
include = self.include
minimal = self.minimal
if include and (minimal is None or minimal is False):
raise ValueError(
raise InvalidParamError(
"Veuillez indiquer si vous souhaitez une réponse minimale "
"avec le filtre `minimal=True`` avant de préciser les "
"champs à inclure."
Expand All @@ -323,7 +328,8 @@ def check_if_all_empty_params(self):
"""
If all parameters are empty (except matching size and pagination
because they always have a default value) raise value error
Check if all non-default parameters are empty, raise a ValueError if they are
Check if all non-default parameters are empty, raise a InvalidParamError
if they are
"""
excluded_fields = [
"page",
Expand All @@ -340,7 +346,9 @@ def check_if_all_empty_params(self):
self.dict(exclude_unset=True), excluded_fields
)
if all_fields_are_null_except_excluded:
raise ValueError("Veuillez indiquer au moins un paramètre de recherche.")
raise InvalidParamError(
"Veuillez indiquer au moins un paramètre de recherche."
)
return self

@model_validator(mode="after")
Expand Down Expand Up @@ -369,7 +377,7 @@ def check_if_short_terms_and_no_other_param(self):
and len(self.terms) < FIELD_LENGTHS["terms"]
and all_fields_are_null_except_excluded
):
raise ValueError(
raise InvalidParamError(
"3 caractères minimum pour les termes de la requête "
+ "(ou utilisez au moins un filtre)"
)
Expand All @@ -378,7 +386,9 @@ def check_if_short_terms_and_no_other_param(self):
@model_validator(mode="after")
def check_if_both_lon_and_lat_are_given(self):
if (self.lat is None) and (self.lon is not None):
raise ValueError("Veuillez indiquer une latitude entre -90° et 90°.")
raise InvalidParamError("Veuillez indiquer une latitude entre -90° et 90°.")
if (self.lon is None) and (self.lat is not None):
raise ValueError("Veuillez indiquer une longitude entre -180° et 180°.")
raise InvalidParamError(
"Veuillez indiquer une longitude entre -180° et 180°."
)
return self
46 changes: 0 additions & 46 deletions app/decorators/http_exception.py

This file was deleted.

13 changes: 0 additions & 13 deletions app/decorators/value_exception.py

This file was deleted.

Empty file added app/exceptions/__init__.py
Empty file.
70 changes: 70 additions & 0 deletions app/exceptions/exception_handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import logging
from typing import Callable

from fastapi import FastAPI, Request
from fastapi.responses import ORJSONResponse
from sentry_sdk import capture_exception, push_scope

from app.exceptions.exceptions import (
InternalError,
InvalidParamError,
InvalidSirenError,
NotFoundError,
SearchApiError,
)


def create_exception_handler(
status_code: int = 500, initial_detail: str = "Service is unavailable"
) -> Callable[[Request, SearchApiError], ORJSONResponse]:
async def exception_handler(
request: Request, exc: SearchApiError
) -> ORJSONResponse:
detail = {
"status_code": exc.status_code or status_code,
"message": exc.message or initial_detail,
}

if isinstance(exc, InvalidParamError):
with push_scope() as scope:
scope.fingerprint = ["InvalidParamError"]
logging.warning(f"Bad Request: {exc.message}")

return ORJSONResponse(
status_code=detail["status_code"],
content={"erreur": detail["message"]},
)

return exception_handler


async def unhandled_exception_handler(
request: Request, exc: Exception
) -> ORJSONResponse:
logging.error(f"Unhandled exception occurred: {exc}", exc_info=True)

with push_scope() as scope:
scope.set_context(
"request",
{
"url": str(request.url),
"method": request.method,
"headers": dict(request.headers),
"query_params": dict(request.query_params),
},
)
capture_exception(exc)

internal_error = InternalError(
"Une erreur inattendue s'est produite. Veuillez réessayer plus tard."
)

handler = create_exception_handler()
return await handler(request, internal_error)


def add_exception_handlers(app: FastAPI) -> None:
app.add_exception_handler(InvalidSirenError, create_exception_handler())
app.add_exception_handler(InvalidParamError, create_exception_handler())
app.add_exception_handler(NotFoundError, create_exception_handler())
app.add_exception_handler(Exception, unhandled_exception_handler)
60 changes: 60 additions & 0 deletions app/exceptions/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
from fastapi import status


class SearchApiError(Exception):
"""Base exception class"""

def __init__(
self,
message: str = "Service is unavailable.",
name: str = "API Recherche des entreprises",
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR,
):
self.message = message
self.name = name
self.status_code = status_code
super().__init__(self.message, self.name)


class InvalidSirenError(SearchApiError):
"""Custom exception for invalid SIREN number"""

def __init__(self):
super().__init__(
message="Numéro Siren invalide.",
name="",
status_code=status.HTTP_400_BAD_REQUEST,
)


class InvalidParamError(SearchApiError):
"""Invalid parameters in request"""

def __init__(self, message):
super().__init__(
message=message,
name="",
status_code=status.HTTP_400_BAD_REQUEST,
)


class InternalError(SearchApiError):
"""Internal service error"""

def __init__(self, message):
super().__init__(
message=message,
name="",
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)


class NotFoundError(SearchApiError):
"""Resource not found error"""

def __init__(self, message="Ressource non trouvée."):
super().__init__(
message=message,
name="",
status_code=status.HTTP_404_NOT_FOUND,
)
Loading

0 comments on commit 2886b85

Please sign in to comment.