Skip to content
Merged
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
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,12 @@ python3 -m uvicorn app.main:app --port 8001 --reload

`APP_PREFIX` defaults to `/`. Set it only when the app should be served under a path prefix, for example `/api/resolver`.

### Legacy URL resolver metrics
### Resolver outcome metrics

In addition to HTTP metrics, `/metrics` exposes
`legacy_url_resolver_requests_total`. Its `outcome` label records the resolver
result independently of the HTTP status:
In addition to HTTP metrics, `/metrics` exposes `resolver_requests_total`.
Its `endpoint` and `outcome` labels record the resolver result independently
of the HTTP status. Endpoints are `legacy_url`, `stable_id`, `rapid`, and
`rapid_stable_id`:

| Outcome | Meaning |
| --- | --- |
Expand Down
23 changes: 15 additions & 8 deletions app/api/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,27 @@
from prometheus_client import Counter


LEGACY_URL_RESOLVER_REQUESTS = Counter(
"legacy_url_resolver_requests",
"Legacy URL resolver requests by outcome and response mode.",
("outcome", "response_mode"),
RESOLVER_REQUESTS = Counter(
"resolver_requests",
"Resolver requests by endpoint, outcome, and response mode.",
("endpoint", "outcome", "response_mode"),
)


def record_legacy_url_resolver_outcome(outcome: str, response_mode: str) -> None:
"""Record the product outcome of one legacy resolver request.
def record_resolver_outcome(
endpoint: str, outcome: str, response_mode: str
) -> None:
"""Record the product outcome of one resolver request.

HTTP status codes retain their protocol meaning, while this metric separates
expected user-facing outcomes (such as an HTML interstitial) from failures
that need operational attention.
"""
LEGACY_URL_RESOLVER_REQUESTS.labels(
outcome=outcome, response_mode=response_mode
RESOLVER_REQUESTS.labels(
endpoint=endpoint, outcome=outcome, response_mode=response_mode
).inc()


def record_legacy_url_resolver_outcome(outcome: str, response_mode: str) -> None:
"""Record an outcome for the ``/legacy`` endpoint."""
record_resolver_outcome("legacy_url", outcome, response_mode)
36 changes: 24 additions & 12 deletions app/api/resources/rapid_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import logging

from app.api.error_response import response_error_handler
from app.api.metrics import record_resolver_outcome
from app.api.models.resolver import (
RapidResolverResponse,
RapidResolverHtmlResponseType,
Expand Down Expand Up @@ -40,14 +41,16 @@ async def resolve_rapid_stable_id(request: Request, stable_id: str):
# Handle only gene stable id for now
params = SearchPayload(stable_id=stable_id, type="gene", per_page=10)
rapid_archive_url = construct_rapid_archive_url(request)
response_mode = "json" if is_json_request(request) else "html"

try:
# fm_py performs synchronous Redb file I/O. Run it off the async event
# loop so concurrent resolver requests can continue to be served.
search_results = await run_in_threadpool(get_search_results, params)

if not search_results or not search_results.get("matches"):
if is_json_request(request):
record_resolver_outcome("rapid_stable_id", "not_found", response_mode)
if response_mode == "json":
return response_error_handler({"status": 404})
res = StableIdResolverResponse(
stable_id=stable_id,
Expand All @@ -67,13 +70,15 @@ async def resolve_rapid_stable_id(request: Request, stable_id: str):
results = build_stable_id_resolver_content(metadata_results)
stable_id_resolver_response.content = results

if is_json_request(request):
record_resolver_outcome("rapid_stable_id", "resolved", response_mode)
if response_mode == "json":
return results

return HTMLResponse(generate_rapid_id_page(stable_id_resolver_response))
except Exception as e:
logging.error(f"Error: {e}")
if is_json_request(request):
record_resolver_outcome("rapid_stable_id", "internal_error", response_mode)
if response_mode == "json":
return response_error_handler({"status": 500, "details": str(e)})
res = StableIdResolverResponse(
stable_id=stable_id,
Expand Down Expand Up @@ -129,15 +134,9 @@ async def resolve_species(
assembly_accession_id = format_assembly_accession(species_url_name)

if assembly_accession_id is None:
input_error_response = RapidResolverResponse(
response_type=RapidResolverHtmlResponseType.ERROR,
code=422,
resolved_url=f"{ENSEMBL_URL}/genome-selector",
message="Invalid input accession ID",
species_name=species_url_name,
rapid_archive_url=rapid_archive_url,
raise HTTPException(
status_code=422, detail="Invalid input accession ID"
)
return rapid_resolved_response(input_error_response, request)

genome_object = get_genome_id_from_assembly_accession_id(assembly_accession_id)

Expand Down Expand Up @@ -197,7 +196,20 @@ async def resolve_home(request: Request):


def rapid_resolved_response(response: RapidResolverResponse, request: Request):
if is_json_request(request):
response_mode = "json" if is_json_request(request) else "html"
if response.response_type == RapidResolverHtmlResponseType.ERROR:
outcome = (
"invalid_request"
if response.code == 422
else "not_found"
if response.code == 404
else "internal_error"
)
else:
outcome = "resolved"
record_resolver_outcome("rapid", outcome, response_mode)

if response_mode == "json":
if response.response_type == RapidResolverHtmlResponseType.ERROR:
raise HTTPException(
status_code=response.code,
Expand Down
11 changes: 8 additions & 3 deletions app/api/resources/resolver_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import logging

from app.api.error_response import response_error_handler
from app.api.metrics import record_resolver_outcome
from app.api.models.resolver import SearchPayload, StableIdResolverResponse
from app.api.utils.commons import build_stable_id_resolver_content, is_json_request
from app.api.utils.metadata import get_metadata
Expand All @@ -31,13 +32,15 @@ async def resolve(
):

params = SearchPayload(stable_id=stable_id, type=type, per_page=10)
response_mode = "json" if is_json_request(request) else "html"
try:
# fm_py performs synchronous Redb file I/O. Run it off the async event
# loop so concurrent resolver requests can continue to be served.
search_results = await run_in_threadpool(get_search_results, params)

if not search_results or not search_results.get("matches"):
if is_json_request(request):
record_resolver_outcome("stable_id", "not_found", response_mode)
if response_mode == "json":
return response_error_handler({"status": 404})

res = StableIdResolverResponse(
Expand All @@ -61,7 +64,8 @@ async def resolve(
results = build_stable_id_resolver_content(metadata_results)
stable_id_resolver_response.content = results

if is_json_request(request):
record_resolver_outcome("stable_id", "resolved", response_mode)
if response_mode == "json":
return results

if len(results) == 1:
Expand All @@ -74,7 +78,8 @@ async def resolve(
return HTMLResponse(generate_resolver_id_page(stable_id_resolver_response))
except Exception as e:
logging.error(f"Error: {e}")
if is_json_request(request):
record_resolver_outcome("stable_id", "internal_error", response_mode)
if response_mode == "json":
return response_error_handler({"status": 500, "details": str(e)})
res = StableIdResolverResponse(
stable_id=stable_id, code=500, message=str(e), content=None
Expand Down
10 changes: 8 additions & 2 deletions tests/test_rapid.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,12 +191,14 @@ def test_rapid_species_404_not_found(
self.assertEqual(response.status_code, 404)

# Test invalid url entity
def test_rapid_species_422_unprocessable_entity(self):
@patch("app.api.resources.rapid_view.record_resolver_outcome")
def test_rapid_species_422_unprocessable_entity(self, mock_record_outcome):
response = self.client.get(
f"{self.mock_rapid_api_url}/Invalid_Name/",
headers={"accept": "application/json"},
)
self.assertEqual(response.status_code, 422)
mock_record_outcome.assert_called_once_with("rapid", "invalid_request", "json")

# Test POST
def test_rapid_species_post_method_not_allowed(self):
Expand Down Expand Up @@ -225,8 +227,9 @@ def test_rapid_species_500_internal_server_error(
)
self.assertEqual(response.status_code, 500)

@patch("app.api.resources.rapid_view.record_resolver_outcome")
@patch("app.api.resources.rapid_view.get_search_results")
def test_rapid_id_resolve_404(self, mock_get_search_results):
def test_rapid_id_resolve_404(self, mock_get_search_results, mock_record_outcome):

mock_get_search_results.return_value = {}

Expand All @@ -236,3 +239,6 @@ def test_rapid_id_resolve_404(self, mock_get_search_results):
headers={"accept": "application/json"},
)
self.assertEqual(response.status_code, 404)
mock_record_outcome.assert_called_once_with(
"rapid_stable_id", "not_found", "json"
)
17 changes: 17 additions & 0 deletions tests/test_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,23 @@ def test_resolve_404(self, mock_get_search_results):
)
self.assertEqual(response.status_code, 404)

@patch("app.api.resources.resolver_view.record_resolver_outcome")
@patch("app.api.resources.resolver_view.get_search_results")
def test_records_stable_id_not_found_outcome(
self, mock_get_search_results, mock_record_outcome
):
"""Record stable-ID misses independently of their HTTP response."""
mock_get_search_results.return_value = {}

response = self.client.get(
f"{self.mock_search_api_url}/{self.stable_id}",
headers={"accept": "application/json"},
follow_redirects=False,
)

self.assertEqual(response.status_code, 404)
mock_record_outcome.assert_called_once_with("stable_id", "not_found", "json")

@patch("app.api.resources.resolver_view.get_search_results")
def test_resolve_404_html_includes_archive_url(self, mock_get_search_results):
"""Offer the main Ensembl archive when a stable ID has no match."""
Expand Down
Loading