Skip to content

Commit 8814f03

Browse files
Yuri ZmytrakovYuri Zmytrakov
authored andcommitted
feat: add retry for datetime search queries
- Add retry decorator for execute_search on datetime queries - Retry NotFoundError/ConnectionError with exponential backoff - Configurable retry count via STAC_SEARCH_MAX_RETRY env var
1 parent 186eba4 commit 8814f03

File tree

4 files changed

+119
-3
lines changed

4 files changed

+119
-3
lines changed

stac_fastapi/core/stac_fastapi/core/utilities.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44
such as converting bounding boxes to polygon representations.
55
"""
66

7+
import asyncio
78
import logging
89
import os
9-
from typing import Any, Dict, List, Optional, Set, Union
10+
import random
11+
from functools import wraps
12+
from typing import Any, Callable, Dict, List, Optional, Set, Union
1013

1114
from stac_fastapi.types.stac import Item
1215

@@ -195,3 +198,44 @@ def get_excluded_from_items(obj: dict, field_path: str) -> None:
195198
return
196199

197200
current.pop(final, None)
201+
202+
203+
def datetime_search_retry(func: Callable) -> Callable:
204+
"""Retry decorator for datetime search queries for NotFoundError/ConnectionError."""
205+
206+
@wraps(func)
207+
async def wrapper(self, *args, **kwargs):
208+
datetime_search = kwargs.get("datetime_search")
209+
210+
is_datetime_query = bool(datetime_search and isinstance(datetime_search, dict))
211+
212+
if not is_datetime_query:
213+
return await func(self, *args, **kwargs)
214+
215+
base_delay = 0.5
216+
max_retries = int(os.getenv("MAX_RETRY", "3"))
217+
218+
# Validate max_retries is between 1 and 10
219+
if max_retries < 1 or max_retries > 10:
220+
max_retries = 3
221+
222+
for attempt in range(max_retries):
223+
try:
224+
return await func(self, *args, **kwargs)
225+
except Exception as e:
226+
error_name = type(e).__name__.lower()
227+
error_msg = str(e).lower()
228+
229+
retry_error = (
230+
"notfound" in error_name
231+
or "connection" in error_name
232+
or "not found" in error_msg
233+
)
234+
235+
if not retry_error or attempt == max_retries - 1:
236+
raise
237+
delay = base_delay * (2**attempt) + random.uniform(0, 0.1)
238+
await asyncio.sleep(delay)
239+
raise Exception("Search failed after retries")
240+
241+
return wrapper

stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@
2222
CollectionSerializer,
2323
ItemSerializer,
2424
)
25-
from stac_fastapi.core.utilities import MAX_LIMIT, bbox2polygon, get_bool_env
25+
from stac_fastapi.core.utilities import (
26+
MAX_LIMIT,
27+
bbox2polygon,
28+
datetime_search_retry,
29+
get_bool_env,
30+
)
2631
from stac_fastapi.elasticsearch.config import AsyncElasticsearchSettings
2732
from stac_fastapi.elasticsearch.config import (
2833
ElasticsearchSettings as SyncElasticsearchSettings,
@@ -804,6 +809,7 @@ def populate_sort(sortby: List) -> Optional[Dict[str, Dict[str, str]]]:
804809
"""
805810
return populate_sort_shared(sortby=sortby)
806811

812+
@datetime_search_retry
807813
async def execute_search(
808814
self,
809815
search: Search,

stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@
1919
from stac_fastapi.core.base_database_logic import BaseDatabaseLogic
2020
from stac_fastapi.core.datetime_utils import format_datetime_range
2121
from stac_fastapi.core.serializers import CollectionSerializer, ItemSerializer
22-
from stac_fastapi.core.utilities import MAX_LIMIT, bbox2polygon, get_bool_env
22+
from stac_fastapi.core.utilities import (
23+
MAX_LIMIT,
24+
bbox2polygon,
25+
datetime_search_retry,
26+
get_bool_env,
27+
)
2328
from stac_fastapi.extensions.core.transaction.request import (
2429
PartialCollection,
2530
PartialItem,
@@ -831,6 +836,7 @@ def populate_sort(sortby: List) -> Optional[Dict[str, Dict[str, str]]]:
831836
"""
832837
return populate_sort_shared(sortby=sortby)
833838

839+
@datetime_search_retry
834840
async def execute_search(
835841
self,
836842
search: Search,

stac_fastapi/tests/api/test_api.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import pytest
99

10+
from stac_fastapi.core.utilities import datetime_search_retry
1011
from stac_fastapi.types.errors import ConflictError
1112

1213
from ..conftest import create_collection, create_item
@@ -1722,3 +1723,62 @@ async def test_hide_private_data_from_item(app_client, txn_client, load_test_dat
17221723
assert "private_data" not in item["properties"]
17231724

17241725
del os.environ["EXCLUDED_FROM_ITEMS"]
1726+
1727+
1728+
@pytest.mark.asyncio
1729+
async def test_datetime_search_retry_succeeds_on_retry():
1730+
"""Test retry works for datetime queries with NotFoundError."""
1731+
call_counter = {"count": 0}
1732+
1733+
@datetime_search_retry
1734+
async def mock_search(self, datetime_search=None):
1735+
call_counter["count"] += 1
1736+
if call_counter["count"] < 2:
1737+
raise Exception("not found: Index not found.")
1738+
return "success"
1739+
1740+
result = await mock_search(None, datetime_search={"datetime": "2025-01-01"})
1741+
1742+
assert result == "success"
1743+
assert call_counter["count"] == 2
1744+
1745+
1746+
@pytest.mark.asyncio
1747+
async def test_datetime_search_no_retry_for_none_datetime():
1748+
"""Test that retry is invoked only for datetime queries."""
1749+
call_counter = {"count": 0}
1750+
1751+
@datetime_search_retry
1752+
async def mock_search(self, datetime_search=None):
1753+
call_counter["count"] += 1
1754+
if call_counter["count"] < 2:
1755+
raise Exception("not found: Index not found.")
1756+
return "success"
1757+
1758+
with pytest.raises(Exception):
1759+
await mock_search(None, datetime_search={})
1760+
1761+
assert call_counter["count"] == 1
1762+
call_counter["count"] = 0
1763+
1764+
with pytest.raises(Exception):
1765+
await mock_search(None, datetime_search=None)
1766+
1767+
assert call_counter["count"] == 1
1768+
1769+
1770+
@pytest.mark.asyncio
1771+
async def test_datetime_search_max_retries():
1772+
"""Test that retry stops after max attempts."""
1773+
call_counter = {"count": 0}
1774+
1775+
@datetime_search_retry
1776+
async def mock_search(self, datetime_search=None):
1777+
call_counter["count"] += 1
1778+
raise Exception(f"Not found: Fails, attempt {call_counter['count']}")
1779+
1780+
with pytest.raises(Exception) as exc_info:
1781+
await mock_search(None, datetime_search={"start_datetime": "2025-01-01"})
1782+
1783+
assert call_counter["count"] == 3
1784+
assert "attempt 3" in str(exc_info.value).lower()

0 commit comments

Comments
 (0)