Skip to content

Commit 9783477

Browse files
committed
Basic custom transports
1 parent b50e46e commit 9783477

File tree

9 files changed

+2062
-1338
lines changed

9 files changed

+2062
-1338
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ repos:
4848
- id: no-commit-to-branch
4949
args: [--branch, dev, --branch, int, --branch, main]
5050
- repo: https://github.com/astral-sh/ruff-pre-commit
51-
rev: v0.13.1
51+
rev: v0.14.2
5252
hooks:
5353
- id: ruff
5454
args: [--fix, --exit-non-zero-on-fix]

.pyproject_generation/pyproject_custom.toml

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "ghga_service_commons"
3-
version = "5.1.0"
3+
version = "5.2.0"
44
description = "A library that contains common functionality used in services of GHGA"
55
readme = "README.md"
66
authors = [
@@ -14,15 +14,18 @@ api = [
1414
"uvicorn[standard]>=0.35, <0.37",
1515
"ghga-service-commons[http,objectstorage]",
1616
]
17-
http = [
18-
"httpx>=0.27, <0.29",
19-
]
17+
http = ["httpx>=0.27, <0.29"]
2018
auth = ["jwcrypto>=1.5.6, <2", "pydantic[email]>=2, <3"]
2119
crypt = ["pynacl>=1.5, <2", "crypt4gh>=1.6, <2"]
2220
dev = ["requests>=2.31, <3"]
2321
objectstorage = ["hexkit>=6"]
22+
transports = [
23+
"hishel>=0.1.1, <0.2",
24+
"tenacity >=9.0.0, <10",
25+
"ghga-service-commons[transports]",
26+
]
2427

25-
all = ["ghga-service-commons[api,auth,crypt,dev,http,objectstorage]"]
28+
all = ["ghga-service-commons[api,auth,crypt,dev,http,objectstorage,transports]"]
2629

2730
[project.license]
2831
text = "Apache 2.0"
@@ -49,7 +52,7 @@ legacy_tox_ini = """
4952
deps =
5053
--no-deps -r ./lock/requirements-dev.txt
5154
commands =
52-
py3{9,10}: pip install --no-deps \
55+
py310: pip install --no-deps \
5356
backports.asyncio.runner==1.2.0 \
5457
exceptiongroup==1.3.0
5558
pytest {posargs}

lock/requirements-dev.txt

Lines changed: 945 additions & 768 deletions
Large diffs are not rendered by default.

lock/requirements.txt

Lines changed: 747 additions & 560 deletions
Large diffs are not rendered by default.

pyproject.toml

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ classifiers = [
2323
"Intended Audience :: Developers",
2424
]
2525
name = "ghga_service_commons"
26-
version = "5.1.0"
26+
version = "5.2.0"
2727
description = "A library that contains common functionality used in services of GHGA"
2828
dependencies = [
2929
"pydantic >=2, <3",
@@ -55,8 +55,13 @@ dev = [
5555
objectstorage = [
5656
"hexkit>=6",
5757
]
58+
transports = [
59+
"hishel>=0.1.1, <0.2",
60+
"tenacity >=9.0.0, <10",
61+
"ghga-service-commons[transports]",
62+
]
5863
all = [
59-
"ghga-service-commons[api,auth,crypt,dev,http,objectstorage]",
64+
"ghga-service-commons[api,auth,crypt,dev,http,objectstorage,transports]",
6065
]
6166

6267
[project.urls]
@@ -174,4 +179,24 @@ asyncio_mode = "strict"
174179
asyncio_default_fixture_loop_scope = "function"
175180

176181
[tool.tox]
177-
legacy_tox_ini = " [tox]\n env_list = py3{10,11,12,13}\n\n [gh-actions]\n python =\n 3.10: py310\n 3.11: py311\n 3.12: py312\n 3.13: py313\n\n [testenv]\n pass_env =\n TC_HOST\n DOCKER_HOST\n deps =\n --no-deps -r ./lock/requirements-dev.txt\n commands =\n py3{9,10}: pip install --no-deps backports.asyncio.runner==1.2.0 exceptiongroup==1.3.0\n pytest {posargs}\n"
182+
legacy_tox_ini = """
183+
[tox]
184+
env_list = py3{10,11,12,13}
185+
186+
[gh-actions]
187+
python =
188+
3.10: py310
189+
3.11: py311
190+
3.12: py312
191+
3.13: py313
192+
193+
[testenv]
194+
pass_env =
195+
TC_HOST
196+
DOCKER_HOST
197+
deps =
198+
--no-deps -r ./lock/requirements-dev.txt
199+
commands =
200+
py310: pip install --no-deps backports.asyncio.runner==1.2.0 exceptiongroup==1.3.0
201+
pytest {posargs}
202+
"""
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Copyright 2021 - 2025 Universität Tübingen, DKFZ, EMBL, and Universität zu Köln
2+
# for the German Human Genome-Phenome Archive (GHGA)
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""TODO"""
17+
18+
from pydantic import Field, NonNegativeFloat, NonNegativeInt, PositiveInt
19+
from pydantic_settings import BaseSettings
20+
21+
22+
class CacheTransportConfig(BaseSettings):
23+
"""TODO"""
24+
25+
cache_ttl: NonNegativeInt = Field(
26+
default=60,
27+
description="Number of seconds after which a stored response is considered stale.",
28+
)
29+
cache_capacity: PositiveInt = Field(
30+
default=128,
31+
description="Maximum number of entries to store in the cache. Older entries are evicted once this limit is reached.",
32+
)
33+
34+
35+
class RatelimitingTransportConfig(BaseSettings):
36+
"""TODO"""
37+
38+
jitter: NonNegativeFloat = Field(
39+
default=0.05, description="Max amoun of jitter to add to each request"
40+
)
41+
reset_after: int | None = Field(
42+
default=None,
43+
description="Amount of requests after which the stored delay from a 429 response is ignored again. If set to `None`, it's never forgotten.",
44+
)
45+
46+
47+
class RetryTransportConfig(BaseSettings):
48+
"""TODO"""
49+
50+
exponential_backoff_max: NonNegativeInt = Field(
51+
default=60,
52+
description="Maximum number of seconds to wait for when using exponential backoff retry strategies.",
53+
)
54+
log_retries: bool = Field(
55+
default=False, description="If true, retry informtion will be logged"
56+
)
57+
max_retries: NonNegativeInt = Field(
58+
default=3, description="Number of times to retry failed API calls."
59+
)
60+
retry_status_codes: list[NonNegativeInt] = Field(
61+
default=[408, 429, 500, 502, 503, 504],
62+
description="List of status codes that should trigger retrying a request.",
63+
)
64+
65+
66+
class CompositeConfig(RatelimitingTransportConfig, RetryTransportConfig):
67+
"""TOOD"""
68+
69+
70+
class CompositeCacheConfig(
71+
CacheTransportConfig, RatelimitingTransportConfig, RetryTransportConfig
72+
):
73+
"""TOOD"""
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Copyright 2021 - 2025 Universität Tübingen, DKFZ, EMBL, and Universität zu Köln
2+
# for the German Human Genome-Phenome Archive (GHGA)
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""Provides factories for different flavors of httpx.AsyncHTTPTransport."""
17+
18+
from hishel import AsyncCacheTransport, AsyncInMemoryStorage
19+
from httpx import AsyncHTTPTransport
20+
21+
from .ratelimiting import AsyncRatelimitingTransport
22+
from .retry import AsyncRetryTransport
23+
24+
25+
class CompositeTransportFactory:
26+
"""TODO"""
27+
28+
@classmethod
29+
def _create_common_transport_layers(cls, config, transport):
30+
"""TODO"""
31+
retry_transport = AsyncRetryTransport(config=config, transport=transport)
32+
ratelimiting_transport = AsyncRatelimitingTransport(
33+
config=config, transport=retry_transport
34+
)
35+
return ratelimiting_transport
36+
37+
@classmethod
38+
def create_ratelimiting_retry_transport(cls, config) -> AsyncRatelimitingTransport:
39+
"""TODO"""
40+
base_transport = AsyncHTTPTransport()
41+
return cls._create_common_transport_layers(config, base_transport)
42+
43+
@classmethod
44+
def create_ratelimiting_retry_transport_with_cache(
45+
cls, config
46+
) -> AsyncRatelimitingTransport:
47+
"""TODO"""
48+
base_transport = AsyncHTTPTransport()
49+
storage = AsyncInMemoryStorage(ttl=config.cache_ttl)
50+
cache_tranport = AsyncCacheTransport(transport=base_transport, storage=storage)
51+
return cls._create_common_transport_layers(config, cache_tranport)
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Copyright 2021 - 2025 Universität Tübingen, DKFZ, EMBL, and Universität zu Köln
2+
# for the German Human Genome-Phenome Archive (GHGA)
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""Provides an httpx.AsyncTransport that handles rate limiting responses."""
17+
18+
import asyncio
19+
import random
20+
from datetime import datetime, timezone
21+
from types import TracebackType
22+
from typing import Self
23+
24+
import httpx
25+
26+
from ghga_service_commons.transports.config import RatelimitingTransportConfig
27+
28+
29+
class AsyncRatelimitingTransport(httpx.AsyncBaseTransport):
30+
"""TODO"""
31+
32+
def __init__(
33+
self, config: RatelimitingTransportConfig, transport: httpx.AsyncBaseTransport
34+
) -> None:
35+
self._jitter = config.jitter
36+
self._num_requests = 0
37+
self._reset_after: int | None = config.reset_after
38+
self._transport = transport
39+
self._last_call_time = datetime.now(timezone.utc)
40+
self._wait_time: float = 0
41+
42+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
43+
"""
44+
Handles HTTP requests while also implementing HTTP caching.
45+
46+
:param request: An HTTP request
47+
:type request: httpx.Request
48+
:return: An HTTP response
49+
:rtype: httpx.Response
50+
"""
51+
# Caculate seconds since the last request has been fired and corresponding wait time
52+
time_elapsed = (self._last_call_time - datetime.now(timezone.utc)).seconds
53+
remaining_wait = max(0, self._wait_time - time_elapsed)
54+
55+
# Add jitter to both cases and sleep
56+
if remaining_wait < self._jitter:
57+
await asyncio.sleep(random.uniform(remaining_wait, self._jitter)) # noqa: S311
58+
else:
59+
await asyncio.sleep(
60+
random.uniform(remaining_wait, remaining_wait + self._jitter) # noqa: S311
61+
)
62+
63+
# Update timestamp and delegate call
64+
self._last_call_time = datetime.now(timezone.utc)
65+
response = await self._transport.handle_async_request(request=request)
66+
67+
# Update state
68+
self._num_requests += 1
69+
if response.status_code == 429:
70+
self._wait_time = float(response.headers["Retry-After"])
71+
self._num_requests = 0
72+
elif self._reset_after and self._reset_after <= self._num_requests:
73+
self._wait_time = 0
74+
self._num_requests = 0
75+
return response
76+
77+
async def aclose(self) -> None:
78+
await self._transport.aclose()
79+
80+
async def __aenter__(self) -> Self:
81+
return self
82+
83+
async def __aexit__(
84+
self,
85+
exc_type: type[BaseException] | None = None,
86+
exc_value: BaseException | None = None,
87+
traceback: TracebackType | None = None,
88+
) -> None:
89+
await self.aclose()

0 commit comments

Comments
 (0)