Skip to content
Draft
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
149 changes: 90 additions & 59 deletions packages/evo-objects/src/evo/objects/endpoints/api/objects_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,19 @@
API version: 1.21.0
"""

import logging

from evo.common.connector import APIConnector
from evo.common.data import EmptyResponse, RequestMethod
from evo.common.utils import get_header_metadata
from evo.common.utils import EvoAPIRetry, get_header_metadata
from evo.common.utils.retry import BackoffExponential

from ..models import * # noqa: F403

__all__ = ["ObjectsApi"]

logger = logging.getLogger("objects.endpoints.api")


class ObjectsApi:
"""API client for the Objects endpoint.
Expand All @@ -46,6 +51,12 @@ class ObjectsApi:

def __init__(self, connector: APIConnector):
self.connector = connector
self.api_retry = EvoAPIRetry(
logger,
max_attempts=3,
backoff_method=BackoffExponential(backoff_factor=1, max_delay=15),
statuses={429, 503},
)

async def delete_object_by_path(
self,
Expand Down Expand Up @@ -103,15 +114,19 @@ async def delete_object_by_path(
"204": EmptyResponse,
}

return await self.connector.call_api(
method=RequestMethod.DELETE,
resource_path="/geoscience-object/orgs/{org_id}/workspaces/{workspace_id}/objects/path/{objects_path}",
path_params=_path_params,
header_params=_header_params,
collection_formats=_collection_formats,
response_types_map=_response_types_map,
request_timeout=request_timeout,
)
async for handle in self.api_retry():
with handle.suppress_errors():
return await self.connector.call_api(
method=RequestMethod.DELETE,
resource_path="/geoscience-object/orgs/{org_id}/workspaces/{workspace_id}/objects/path/{objects_path}",
path_params=_path_params,
header_params=_header_params,
collection_formats=_collection_formats,
response_types_map=_response_types_map,
request_timeout=request_timeout,
)

raise RuntimeError("EvoAPIRetry neither yielded nor raised any errors")

async def delete_objects_by_id(
self,
Expand Down Expand Up @@ -170,15 +185,19 @@ async def delete_objects_by_id(
"204": EmptyResponse,
}

return await self.connector.call_api(
method=RequestMethod.DELETE,
resource_path="/geoscience-object/orgs/{org_id}/workspaces/{workspace_id}/objects/{object_id}",
path_params=_path_params,
header_params=_header_params,
collection_formats=_collection_formats,
response_types_map=_response_types_map,
request_timeout=request_timeout,
)
async for handle in self.api_retry():
with handle.suppress_errors():
return await self.connector.call_api(
method=RequestMethod.DELETE,
resource_path="/geoscience-object/orgs/{org_id}/workspaces/{workspace_id}/objects/{object_id}",
path_params=_path_params,
header_params=_header_params,
collection_formats=_collection_formats,
response_types_map=_response_types_map,
request_timeout=request_timeout,
)

raise RuntimeError("EvoAPIRetry neither yielded nor raised any errors")

async def get_object(
self,
Expand Down Expand Up @@ -257,16 +276,20 @@ async def get_object(
"304": EmptyResponse,
}

return await self.connector.call_api(
method=RequestMethod.GET,
resource_path="/geoscience-object/orgs/{org_id}/workspaces/{workspace_id}/objects/path/{objects_path}",
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
collection_formats=_collection_formats,
response_types_map=_response_types_map,
request_timeout=request_timeout,
)
async for handle in self.api_retry():
with handle.suppress_errors():
return await self.connector.call_api(
method=RequestMethod.GET,
resource_path="/geoscience-object/orgs/{org_id}/workspaces/{workspace_id}/objects/path/{objects_path}",
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
collection_formats=_collection_formats,
response_types_map=_response_types_map,
request_timeout=request_timeout,
)

raise RuntimeError("EvoAPIRetry neither yielded nor raised any errors")

async def get_object_by_id(
self,
Expand Down Expand Up @@ -351,16 +374,20 @@ async def get_object_by_id(
"304": EmptyResponse,
}

return await self.connector.call_api(
method=RequestMethod.GET,
resource_path="/geoscience-object/orgs/{org_id}/workspaces/{workspace_id}/objects/{object_id}",
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
collection_formats=_collection_formats,
response_types_map=_response_types_map,
request_timeout=request_timeout,
)
async for handle in self.api_retry():
with handle.suppress_errors():
return await self.connector.call_api(
method=RequestMethod.GET,
resource_path="/geoscience-object/orgs/{org_id}/workspaces/{workspace_id}/objects/{object_id}",
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
collection_formats=_collection_formats,
response_types_map=_response_types_map,
request_timeout=request_timeout,
)

raise RuntimeError("EvoAPIRetry neither yielded nor raised any errors")

async def list_object_version_ids(
self,
Expand Down Expand Up @@ -645,16 +672,18 @@ async def list_objects(
"200": ListObjectsResponse, # noqa: F405
}

return await self.connector.call_api(
method=RequestMethod.GET,
resource_path="/geoscience-object/orgs/{org_id}/workspaces/{workspace_id}/objects",
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
collection_formats=_collection_formats,
response_types_map=_response_types_map,
request_timeout=request_timeout,
)
async for handle in self.api_retry():
with handle.suppress_errors():
return await self.connector.call_api(
method=RequestMethod.GET,
resource_path="/geoscience-object/orgs/{org_id}/workspaces/{workspace_id}/objects",
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
collection_formats=_collection_formats,
response_types_map=_response_types_map,
request_timeout=request_timeout,
)

async def list_objects_by_org(
self,
Expand Down Expand Up @@ -788,16 +817,18 @@ async def list_objects_by_org(
"200": ListOrgObjectsResponse, # noqa: F405
}

return await self.connector.call_api(
method=RequestMethod.GET,
resource_path="/geoscience-object/orgs/{org_id}/objects",
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
collection_formats=_collection_formats,
response_types_map=_response_types_map,
request_timeout=request_timeout,
)
async for handle in self.api_retry():
with handle.suppress_errors():
return await self.connector.call_api(
method=RequestMethod.GET,
resource_path="/geoscience-object/orgs/{org_id}/objects",
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
collection_formats=_collection_formats,
response_types_map=_response_types_map,
request_timeout=request_timeout,
)

async def post_objects(
self,
Expand Down
2 changes: 2 additions & 0 deletions packages/evo-sdk-common/src/evo/common/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from .cache import Cache
from .data import parse_order_by
from .evo_api_retry import EvoAPIRetry
from .feedback import (
NoFeedback,
PartialFeedback,
Expand All @@ -30,6 +31,7 @@
"BackoffLinear",
"BackoffMethod",
"Cache",
"EvoAPIRetry",
"NoFeedback",
"PartialFeedback",
"Retry",
Expand Down
158 changes: 158 additions & 0 deletions packages/evo-sdk-common/src/evo/common/utils/evo_api_retry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# Copyright © 2026 Bentley Systems, Incorporated
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import asyncio
import contextlib
import logging
import typing as tp
from collections.abc import Set
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from random import random

from evo.common.exceptions import EvoAPIException, RetryError
from evo.common.utils.retry import BackoffMethod


class EvoAPIRetryHandler:
"""Handler for a single retry attempt in EvoAPIRetry."""

def __init__(self, logger: logging.Logger, attempt: int):
self.logger = logger
self.attempt = attempt
self.exception: Exception | None = None

@contextlib.contextmanager
def suppress_errors(
self, excs: type[BaseException] | tuple[type[BaseException], ...] | None = None
) -> tp.Generator[None, tp.Any, tp.Any]:
"""Suppress errors raised during a retry attempt.

:param excs: Additional exception types to suppress
"""
try:
yield
except Exception as exc:
if isinstance(exc, EvoAPIException) or (excs is not None and isinstance(exc, excs)):
self.exception = exc
else:
raise

def set_exception(self, exc: Exception) -> None:
"""Set the exception handled during the retry attempt.

:param exc: The exception to set.
"""
self.exception = exc

@property
def succeeded(self) -> bool:
return self.exception is None

@property
def failed(self) -> bool:
return self.exception is not None


def _parse_retry_after(logger: logging.Logger, retry_after_str: str | None) -> float | None:
if retry_after_str is None or retry_after_str == "":
return None
try:
return float(retry_after_str)
except ValueError:
try:
retry_after = parsedate_to_datetime(retry_after_str)
if retry_after.tzinfo is None:
retry_after = retry_after.replace(tzinfo=timezone.utc)
return (retry_after - datetime.now(timezone.utc)).total_seconds()
except (TypeError, ValueError, IndexError):
logger.info("Failed to parse Retry-After header: %s", repr(retry_after_str))
return None


class EvoAPIRetry:
"""EvoAPIException-aware retry implementation

.. note:: Retrying requests that have different outcomes each time they are called can lead to unexpected results such
as duplicate transactions or data corruption. Although operations such as GET, PUT and DELETE are generally safe to retry,
it is the responsibility of the caller to ensure that retrying is safe. Consult the API documentation or contact
the API provider for guidance on which operations are safe to retry.

Usage::
retry = EvoAPIRetry(logger=logging.getLogger(__name__), max_attempts=3, backoff_method=BackoffLinear(1))
async for handler in retry(): # mandatory to call the retry object
# do some things
...
with handler.suppress_errors(): # mandatory to suppress EvoAPIException
# make request
...
if handler.failed:
# do some cleanup
...
"""

def __init__(
self,
logger: logging.Logger,
max_attempts: int,
backoff_method: BackoffMethod,
statuses: Set[int] = frozenset({429, 503}),
) -> None:
"""Initialise a EvoAPIRetry object used when retrying after failures.

:param logger: Logger instance for logging retry attempts.
:param max_attempts: Maximum number of times to retry.
:param backoff_method: Backoff method to apply.
:param statuses: HTTP status codes that should trigger a retry.
"""
if max_attempts < 1:
raise ValueError("max_attempts must be greater than 0")
if len(statuses) == 0:
raise ValueError("statuses must contain at least one status code")

self._statuses = statuses
self._logger = logger
self._max_attempts = max_attempts
self._backoff_method = backoff_method

async def _recover(self, handler: EvoAPIRetryHandler) -> None:
"""Recover from a failed attempt, applying backoff and jitter before the next attempt."""

retry_after: float | None = None
if isinstance(handler.exception, EvoAPIException) and handler.exception.status in self._statuses:
headers = handler.exception.headers if handler.exception.headers else {}
retry_after = _parse_retry_after(self._logger, headers.get("Retry-After", "").strip())

delay = self._backoff_method.get_backoff_time(handler.attempt)
if retry_after is not None:
delay = max(delay, retry_after)

delay += random() # jitter
self._logger.debug(f"Waiting {delay}s")
await asyncio.sleep(delay)

async def __call__(self) -> tp.AsyncGenerator[EvoAPIRetryHandler, None]:
"""Returns an async generator that yields EvoAPIRetryHandler objects for each retry attempt."""
errors: list[Exception] = []

for attempt in range(1, self._max_attempts + 1):
handler = EvoAPIRetryHandler(self._logger, attempt)
yield handler

if handler.succeeded:
break
else:
assert handler.exception is not None
errors.append(handler.exception)
if attempt < self._max_attempts:
await self._recover(handler)
else:
raise RetryError("Retry failed", errors)
Loading
Loading