Skip to content

Commit 2e9158a

Browse files
authored
fix: follow redirects in httpx clients, better url joining, more verbose health check (#6)
* fix: follow redirects in httpx clients * fix: handle trailing slashes more robustly
1 parent 3377e1b commit 2e9158a

File tree

3 files changed

+71
-10
lines changed

3 files changed

+71
-10
lines changed

src/stac_fastapi/collection_discovery/core.py

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@
2626
logger = logging.getLogger(__name__)
2727

2828

29+
def _robust_urljoin(base: str, path: str) -> str:
30+
"""Join URL parts robustly, ensuring base is treated as a directory."""
31+
# Ensure base ends with slash so it's treated as a directory
32+
if not base.endswith("/"):
33+
base += "/"
34+
return urljoin(base, path)
35+
36+
2937
COLLECTION_SEARCH_CONFORMANCE_CLASSES = [
3038
STACConformanceClasses.CORE,
3139
STACConformanceClasses.COLLECTIONS,
@@ -40,6 +48,7 @@ class UpstreamApiStatus(BaseModel):
4048
"""Status information for an upstream API."""
4149

4250
healthy: bool
51+
collection_search_conformance: list[str]
4352

4453

4554
class LifespanStatus(BaseModel):
@@ -112,7 +121,9 @@ def _get_search_state(
112121
logger.info("Continuing collection search with token pagination")
113122
else:
114123
search_state = {
115-
"current": {api: f"{api}/collections?{param_str}" for api in apis},
124+
"current": {
125+
api: _robust_urljoin(api, f"collections?{param_str}") for api in apis
126+
},
116127
"is_first_page": True,
117128
}
118129
logger.info(f"Starting new collection search across {len(apis)} APIs")
@@ -139,7 +150,9 @@ def _build_pagination_links(
139150
links.append(
140151
{
141152
"rel": "previous",
142-
"href": f"{request.base_url}collections?token={prev_token}",
153+
"href": _robust_urljoin(
154+
str(request.base_url), f"collections?token={prev_token}"
155+
),
143156
}
144157
)
145158

@@ -152,7 +165,9 @@ def _build_pagination_links(
152165
links.append(
153166
{
154167
"rel": "next",
155-
"href": f"{request.base_url}collections?token={next_token}",
168+
"href": _robust_urljoin(
169+
str(request.base_url), f"collections?token={next_token}"
170+
),
156171
}
157172
)
158173

@@ -164,7 +179,7 @@ async def _fetch_api_conformance(
164179
"""Fetch conformance classes from a single API."""
165180
async with semaphore:
166181
try:
167-
api_response = await client.get(f"{api}/conformance")
182+
api_response = await client.get(api, follow_redirects=True)
168183
if api_response.status_code == 200:
169184
conformance_data = api_response.json()
170185
return api, set(conformance_data.get("conformsTo", []))
@@ -507,12 +522,38 @@ async def health_check(
507522
async def check_api_health(client, api: str) -> tuple[str, UpstreamApiStatus]:
508523
async with semaphore:
509524
try:
510-
# Check landing page health
511-
api_response = await client.get(api)
512-
return api, UpstreamApiStatus(healthy=api_response.status_code == 200)
525+
# Check landing page health - follow redirects automatically
526+
api_response = await client.get(api, follow_redirects=True)
527+
# Consider 2xx and 3xx status codes as healthy
528+
is_healthy = 200 <= api_response.status_code < 400
529+
530+
collection_search_conformance = []
531+
if is_healthy:
532+
try:
533+
response_data = api_response.json()
534+
conforms_to = response_data.get("conformsTo", [])
535+
# Extract collection-search conformance classes with suffixes
536+
for cls in conforms_to:
537+
if "collection-search" in cls:
538+
# Extract from "collection-search" onwards
539+
collection_search_part = cls[
540+
cls.find("collection-search") :
541+
]
542+
collection_search_conformance.append(
543+
collection_search_part
544+
)
545+
except Exception as e:
546+
logger.warning(f"Failed to parse conformance from {api}: {e}")
547+
548+
return api, UpstreamApiStatus(
549+
healthy=is_healthy,
550+
collection_search_conformance=collection_search_conformance,
551+
)
513552

514553
except Exception:
515-
return api, UpstreamApiStatus(healthy=False)
554+
return api, UpstreamApiStatus(
555+
healthy=False, collection_search_conformance=[]
556+
)
516557

517558
async with AsyncClient(timeout=HTTPX_TIMEOUT) as client:
518559
tasks = [check_api_health(client, api) for api in apis]

tests/unit/test_app.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ async def test_landing_page_with_single_custom_api(self, client):
239239
async def test_conformance_with_custom_apis(self, client):
240240
"""Test conformance endpoint with custom APIs parameter."""
241241
# Mock upstream API conformance endpoints for custom APIs
242-
respx.get("https://custom-api1.example.com/conformance").mock(
242+
respx.get("https://custom-api1.example.com").mock(
243243
return_value=Response(
244244
200,
245245
json={
@@ -250,7 +250,7 @@ async def test_conformance_with_custom_apis(self, client):
250250
},
251251
)
252252
)
253-
respx.get("https://custom-api2.example.com/conformance").mock(
253+
respx.get("https://custom-api2.example.com").mock(
254254
return_value=Response(
255255
200,
256256
json={

tests/unit/test_health.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,23 @@ async def test_health_check_all_apis_unhealthy(self, mock_request):
6767

6868
api2_result = result.upstream_apis["https://api2.example.com"]
6969
assert api2_result.healthy is False
70+
71+
@pytest.mark.asyncio
72+
@respx.mock
73+
async def test_health_check_redirects_healthy(self, mock_request):
74+
"""Test health check when APIs return redirects (should be considered healthy)."""
75+
# Mock api1 to redirect once then return success
76+
respx.get("https://api1.example.com").mock(return_value=Response(301))
77+
# Mock api2 to redirect once then return success
78+
respx.get("https://api2.example.com").mock(return_value=Response(302))
79+
80+
result = await health_check(mock_request)
81+
82+
assert result.status == "UP"
83+
assert result.lifespan.status == "UP"
84+
85+
api1_result = result.upstream_apis["https://api1.example.com"]
86+
assert api1_result.healthy is True
87+
88+
api2_result = result.upstream_apis["https://api2.example.com"]
89+
assert api2_result.healthy is True

0 commit comments

Comments
 (0)