Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 93 additions & 93 deletions poetry.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ pytest = "^8.4.1"
pytest-asyncio = "^1.0.0"
pytest-cov = "^6.0.0"
pytest-nhsd-apim = "^5.0.0"
aiohttp = "^3.12.14"
aiohttp = "^3.12.13"
awscli = "^1.37.24"
awscli-local = "^0.22.0"
polyfactory = "^2.20.0"
Expand Down
4 changes: 2 additions & 2 deletions src/eligibility_signposting_api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
from mangum.types import LambdaContext, LambdaEvent

from eligibility_signposting_api import audit, repos, services
from eligibility_signposting_api.common.error_handler import handle_exception
from eligibility_signposting_api.common.request_validator import validate_request_params
from eligibility_signposting_api.config.config import config, init_logging
from eligibility_signposting_api.error_handler import handle_exception
from eligibility_signposting_api.views import eligibility_blueprint
from eligibility_signposting_api.wrapper import validate_request_params

init_logging()
logger = logging.getLogger(__name__)
Expand Down
2 changes: 1 addition & 1 deletion src/eligibility_signposting_api/audit/audit_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
RequestAuditQueryParams,
)
from eligibility_signposting_api.audit.audit_service import AuditService
from eligibility_signposting_api.model.eligibility import (
from eligibility_signposting_api.model.eligibility_status import (
CohortGroupResult,
ConditionName,
IterationResult,
Expand Down
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# How to Use the API Error Response Module

This document outlines how to use the `api_error_response.py` module for standardized error handling within the Eligibility Signposting API. The module ensures that all API errors are consistent, logged appropriately, and conform to the FHIR `OperationOutcome` standard.

## Core Concepts

The error handling mechanism is built around the class `APIErrorResponse`.

1. **`APIErrorResponse` Class**: This class is a constructor for a specific type of error. An instance of this class holds configuration for an error, such as the `HTTPStatus`, severity, and various FHIR-specific codes.
2. **Pre-defined Error Instances**: The module defines several singleton instances of for common, application-specific errors. Examples include:
- `INVALID_CATEGORY_ERROR`
- `NHS_NUMBER_MISMATCH_ERROR`
- `INTERNAL_SERVER_ERROR`
3. **`log_and_generate_response()` Method**: This is the primary method to be used. When called on an `APIErrorResponse` instance, it performs two actions:
- Logs the error with a detailed internal message.
- Generates a complete HTTP response dictionary (`statusCode`, `headers`, `body`) containing a FHIR-compliant `OperationOutcome` payload.

## How to Use

The primary way to handle errors is to import a pre-defined error object from `api_error_response.py` and call its `log_and_generate_response()` method.

### 1. Handling Specific, Known Errors

For handling validation failures or other expected error conditions, use one of the pre-defined error instances.
The `wrapper.py` module uses this pattern to validate query parameters. If a parameter is invalid, it calls the corresponding error function.

#### Example: Invalid "category" parameter

``` python
# wrapper.py

from eligibility_signposting_api.api_error_response import INVALID_CATEGORY_ERROR

def get_category_error_response(category: str) -> dict[str, Any]:
"""Generates an error response for an invalid category."""

return INVALID_CATEGORY_ERROR.log_and_generate_response(
log_message=f"Invalid category query param: '{category}'",
diagnostics=f"{category} is not a category that is supported by the API",
location_param="category"
)
```

#### Key Parameters for `log_and_generate_response()`

- `log_message`: A detailed message for internal logging. This should contain specific information useful for debugging.
- `diagnostics`: The user-facing error message that will be included in the API response body.
- `location_param` (optional): The name of the parameter that caused the error. This helps pinpoint the issue for API consumers.

### 2. Handling Unexpected Exceptions (Global Error Handler)

For unexpected errors, a global exception handler in `error_handler.py` catches any unhandled exception and returns a generic 500 Internal Server Error. This prevents sensitive information from leaking in stack traces.

``` python
# error_handler.py

from eligibility_signposting_api.api_error_response import INTERNAL_SERVER_ERROR

def handle_exception(e: Exception) -> ResponseReturnValue | HTTPException:
# Generate a generic, safe response for the client
response = INTERNAL_SERVER_ERROR.log_and_generate_response(
log_message=f"An unexpected error occurred: {traceback.format_exception(e)}",
diagnostics="An unexpected error occurred."
)
return make_response(response.get("body"), response.get("statusCode"), response.get("headers"))
```

### 3. Creating New Error Types

If a new, reusable error condition is identified, you should add a new instance of `APIErrorResponse` to `api_error_response.py`
Follow the existing pattern:

``` python
# api_error_response.py

# ... (other error definitions)

SOME_NEW_ERROR = APIErrorResponse(
status_code=HTTPStatus.BAD_REQUEST,
fhir_issue_code=FHIRIssueCode.VALUE,
fhir_issue_severity=FHIRIssueSeverity.ERROR,
fhir_coding_system=FHIR_SPINE_ERROR_CODE_SYSTEM,
fhir_error_code=FHIRSpineErrorCode.INVALID_PARAMETER,
fhir_display_message="A new specific error message for display",
)
```

By centralizing error definitions, we ensure that the API provides a consistent and predictable experience for its consumers.
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from flask.typing import ResponseReturnValue
from werkzeug.exceptions import HTTPException

from eligibility_signposting_api.api_error_response import INTERNAL_SERVER_ERROR
from eligibility_signposting_api.common.api_error_response import INTERNAL_SERVER_ERROR

logger = logging.getLogger(__name__)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from mangum.types import LambdaContext, LambdaEvent

from eligibility_signposting_api.api_error_response import (
from eligibility_signposting_api.common.api_error_response import (
INVALID_CATEGORY_ERROR,
INVALID_CONDITION_FORMAT_ERROR,
INVALID_INCLUDE_ACTIONS_ERROR,
Expand Down
2 changes: 1 addition & 1 deletion src/eligibility_signposting_api/repos/person_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from boto3.resources.base import ServiceResource
from wireup import Inject, service

from eligibility_signposting_api.model.eligibility import NHSNumber
from eligibility_signposting_api.model.eligibility_status import NHSNumber
from eligibility_signposting_api.repos.exceptions import NotFoundError

logger = logging.getLogger(__name__)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@

from wireup import service

from eligibility_signposting_api.model import eligibility, rules
from eligibility_signposting_api.model.eligibility import (
from eligibility_signposting_api.model import eligibility_status, rules
from eligibility_signposting_api.model.eligibility_status import (
ActionCode,
ActionDescription,
ActionType,
Expand Down Expand Up @@ -58,15 +58,15 @@ class EligibilityCalculator:
person_data: Row
campaign_configs: Collection[rules.CampaignConfig]

results: list[eligibility.Condition] = field(default_factory=list)
results: list[eligibility_status.Condition] = field(default_factory=list)

@property
def active_campaigns(self) -> list[rules.CampaignConfig]:
return [cc for cc in self.campaign_configs if cc.campaign_live]

def campaigns_grouped_by_condition_name(
self, conditions: list[str], category: str
) -> Iterator[tuple[eligibility.ConditionName, list[rules.CampaignConfig]]]:
) -> Iterator[tuple[eligibility_status.ConditionName, list[rules.CampaignConfig]]]:
"""Generator that yields campaign groups filtered by condition names and campaign category."""

mapping = {
Expand Down Expand Up @@ -100,9 +100,9 @@ def get_the_best_cohort_memberships(
cohort_results: dict[str, CohortGroupResult],
) -> tuple[Status, list[CohortGroupResult]]:
if not cohort_results:
return eligibility.Status.not_eligible, []
return eligibility_status.Status.not_eligible, []

best_status = eligibility.Status.best(*[result.status for result in cohort_results.values()])
best_status = eligibility_status.Status.best(*[result.status for result in cohort_results.values()])
best_cohorts = [result for result in cohort_results.values() if result.status == best_status]

best_cohorts = [
Expand Down Expand Up @@ -158,7 +158,7 @@ def get_action_rules_components(

def evaluate_eligibility(
self, include_actions: str, conditions: list[str], category: str
) -> eligibility.EligibilityStatus:
) -> eligibility_status.EligibilityStatus:
include_actions_flag = include_actions.upper() == "Y"
condition_results: dict[ConditionName, IterationResult] = {}
actions: list[SuggestedAction] | None = []
Expand Down Expand Up @@ -186,7 +186,7 @@ def evaluate_eligibility(
),
) = max(iteration_results.items(), key=lambda item: item[1][1].status.value)
else:
best_candidate = IterationResult(eligibility.Status.not_eligible, [], actions)
best_candidate = IterationResult(eligibility_status.Status.not_eligible, [], actions)
best_campaign_id = None
best_campaign_version = None
best_active_iteration = None
Expand Down Expand Up @@ -233,7 +233,7 @@ def evaluate_eligibility(

# Consolidate all the results and return
final_result = self.build_condition_results(condition_results)
return eligibility.EligibilityStatus(conditions=final_result)
return eligibility_status.EligibilityStatus(conditions=final_result)

def get_iteration_results(
self, actions: list[SuggestedAction] | None, campaign_group: list[CampaignConfig]
Expand Down Expand Up @@ -406,20 +406,20 @@ def evaluate_suppression_rules(

def evaluate_rules_priority_group(
self, rules_group: Iterator[rules.IterationRule]
) -> tuple[eligibility.Status, list[eligibility.Reason], bool]:
) -> tuple[eligibility_status.Status, list[eligibility_status.Reason], bool]:
is_rule_stop = False
exclusion_reasons = []
best_status = eligibility.Status.not_eligible
best_status = eligibility_status.Status.not_eligible

for rule in rules_group:
is_rule_stop = rule.rule_stop or is_rule_stop
rule_calculator = RuleCalculator(person_data=self.person_data, rule=rule)
status, reason = rule_calculator.evaluate_exclusion()
if status.is_exclusion:
best_status = eligibility.Status.best(status, best_status)
best_status = eligibility_status.Status.best(status, best_status)
exclusion_reasons.append(reason)
else:
best_status = eligibility.Status.actionable
best_status = eligibility_status.Status.actionable

return best_status, exclusion_reasons, is_rule_stop

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from hamcrest.core.string_description import StringDescription

from eligibility_signposting_api.model import eligibility, rules
from eligibility_signposting_api.model import eligibility_status, rules
from eligibility_signposting_api.services.rules.operators import OperatorRegistry

Row = Collection[Mapping[str, Any]]
Expand All @@ -17,15 +17,15 @@ class RuleCalculator:
person_data: Row
rule: rules.IterationRule

def evaluate_exclusion(self) -> tuple[eligibility.Status, eligibility.Reason]:
def evaluate_exclusion(self) -> tuple[eligibility_status.Status, eligibility_status.Reason]:
"""Evaluate if a particular rule excludes this person. Return the result, and the reason for the result."""
attribute_value = self.get_attribute_value()
status, reason, matcher_matched = self.evaluate_rule(attribute_value)
reason = eligibility.Reason(
rule_name=eligibility.RuleName(self.rule.name),
rule_type=eligibility.RuleType(self.rule.type),
rule_priority=eligibility.RulePriority(str(self.rule.priority)),
rule_description=eligibility.RuleDescription(self.rule.description),
reason = eligibility_status.Reason(
rule_name=eligibility_status.RuleName(self.rule.name),
rule_type=eligibility_status.RuleType(self.rule.type),
rule_priority=eligibility_status.RulePriority(str(self.rule.priority)),
rule_description=eligibility_status.RuleDescription(self.rule.description),
matcher_matched=matcher_matched,
)
return status, reason
Expand Down Expand Up @@ -71,7 +71,7 @@ def get_value(dictionary: Mapping[str, Any] | None, key: str) -> dict:
v = dictionary.get(key, {}) if isinstance(dictionary, dict) else {}
return v if isinstance(v, dict) else {}

def evaluate_rule(self, attribute_value: str | None) -> tuple[eligibility.Status, str, bool]:
def evaluate_rule(self, attribute_value: str | None) -> tuple[eligibility_status.Status, str, bool]:
"""Evaluate a rule against a person data attribute. Return the result, and the reason for the result."""
matcher_class = OperatorRegistry.get(self.rule.operator)
matcher = matcher_class(rule_value=self.rule.comparator)
Expand All @@ -81,12 +81,12 @@ def evaluate_rule(self, attribute_value: str | None) -> tuple[eligibility.Status
if matcher_matched:
matcher.describe_match(attribute_value, reason)
status = {
rules.RuleType.filter: eligibility.Status.not_eligible,
rules.RuleType.suppression: eligibility.Status.not_actionable,
rules.RuleType.redirect: eligibility.Status.actionable,
rules.RuleType.not_eligible_actions: eligibility.Status.not_eligible,
rules.RuleType.not_actionable_actions: eligibility.Status.not_actionable,
rules.RuleType.filter: eligibility_status.Status.not_eligible,
rules.RuleType.suppression: eligibility_status.Status.not_actionable,
rules.RuleType.redirect: eligibility_status.Status.actionable,
rules.RuleType.not_eligible_actions: eligibility_status.Status.not_eligible,
rules.RuleType.not_actionable_actions: eligibility_status.Status.not_actionable,
}[self.rule.type]
return status, str(reason), matcher_matched
matcher.describe_mismatch(attribute_value, reason)
return eligibility.Status.actionable, str(reason), matcher_matched
return eligibility_status.Status.actionable, str(reason), matcher_matched
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from wireup import service

from eligibility_signposting_api.model import eligibility
from eligibility_signposting_api.model import eligibility_status
from eligibility_signposting_api.repos import CampaignRepo, NotFoundError, PersonRepo
from eligibility_signposting_api.services.calculators import eligibility_calculator as calculator

Expand Down Expand Up @@ -32,11 +32,11 @@ def __init__(

def get_eligibility_status(
self,
nhs_number: eligibility.NHSNumber,
nhs_number: eligibility_status.NHSNumber,
include_actions: str,
conditions: list[str],
category: str,
) -> eligibility.EligibilityStatus:
) -> eligibility_status.EligibilityStatus:
"""Calculate a person's eligibility for vaccination given an NHS number."""
if nhs_number:
try:
Expand Down
Loading