Skip to content

Commit c9c9f70

Browse files
authored
Merge pull request #160 from python-astrodynamics/feature/ratelimit-callback-wait
Fix rate limit callback waits and support the predicate cache on trio
2 parents 5edce48 + 8f59dd7 commit c9c9f70

10 files changed

Lines changed: 221 additions & 164 deletions

File tree

newsfragments/160.changed.2.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Predicate caching for keyword validation now works when using :class:`~spacetrack.aio.AsyncSpaceTrackClient` with trio.
2+
Previously, the on-disk cache was skipped because the file locking implementation only supported asyncio.

newsfragments/160.changed.3.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
:class:`~spacetrack.aio.AsyncSpaceTrackClient` no longer performs blocking cache file reads and writes on the event loop; they now run in a worker thread.

newsfragments/160.changed.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Rate limit waits now guarantee that the ``callback`` has finished before the request is retried, in all clients.
2+
As a result, an exception raised by the callback now propagates instead of being silently discarded: directly in :class:`~spacetrack.base.SpaceTrackClient`, and as an :class:`ExceptionGroup` on asyncio, matching the existing trio behaviour.

newsfragments/160.fixed.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
:class:`~spacetrack.aio.AsyncSpaceTrackClient` now accepts a plain function as the rate limit ``callback``, as shown in the documentation, in addition to a coroutine function.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ classifiers = [
2323
"Programming Language :: Python :: 3.15",
2424
]
2525
dependencies = [
26+
"anyio>=4.10",
2627
"filelock>=3.17.0",
2728
"httpx2>=2.9.1",
2829
"logbook>=1.10.1",
@@ -31,7 +32,6 @@ dependencies = [
3132
"python-dateutil>=2.9.0.post0; python_version < '3.11'",
3233
"represent>=2.1",
3334
"rush>=2021.4.0",
34-
"sniffio>=1.3.1",
3535
]
3636

3737
[project.urls]

src/spacetrack/aio.py

Lines changed: 39 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,26 @@
1-
import asyncio
1+
import inspect
22
import time
33
import weakref
4+
from functools import partial
45

6+
import anyio
57
import httpx2
68
import outcome
7-
import sniffio
8-
from filelock import AsyncFileLock
9+
from filelock import Timeout
910
from httpx2 import USE_CLIENT_DEFAULT
1011

1112
from .base import (
1213
BASE_URL,
13-
AcquireLock,
14+
AcquireFileLock,
1415
Event,
1516
IterContent,
1617
IterLines,
1718
NormalRequest,
1819
RateLimitWait,
1920
ReadResponse,
20-
ReleaseLock,
21+
ReleaseFileLock,
22+
RunBlocking,
2123
SpaceTrackClient,
22-
UnsupportedAsyncLibrary,
2324
logger,
2425
)
2526

@@ -38,7 +39,6 @@ class AsyncSpaceTrackClient(SpaceTrackClient):
3839
be an ``httpx2.AsyncClient``.
3940
"""
4041

41-
_file_lock_cls = AsyncFileLock
4242
_httpx_client_cls = httpx2.AsyncClient
4343

4444
def __init__(
@@ -62,7 +62,6 @@ def __init__(
6262
additional_rate_limit=additional_rate_limit,
6363
cache_path=cache_path,
6464
)
65-
self._ratelimit_tasks = set()
6665

6766
def _setup_finalizer(self):
6867
self._finalizer = weakref.finalize(
@@ -90,15 +89,32 @@ async def _handle_event(self, event):
9089
return _iter_content_generator(event.response, event.decode)
9190
elif isinstance(event, RateLimitWait):
9291
await self._ratelimit_wait(event.duration)
93-
elif isinstance(event, AcquireLock):
94-
if (
95-
isinstance(event.lock, AsyncFileLock)
96-
and sniffio.current_async_library() != "asyncio"
97-
):
98-
raise UnsupportedAsyncLibrary
99-
await event.lock.acquire()
100-
elif isinstance(event, ReleaseLock):
101-
await event.lock.release()
92+
elif isinstance(event, AcquireFileLock):
93+
# Mirror filelock's AsyncFileLock structure with backend-agnostic
94+
# primitives: non-blocking acquire attempts in a worker thread,
95+
# with a cancellable sleep between attempts. Each attempt is
96+
# shielded so that a cancellation cannot discard an acquired
97+
# lock; cancellation is delivered at the sleep instead.
98+
while True:
99+
with anyio.CancelScope(shield=True):
100+
try:
101+
await anyio.to_thread.run_sync(
102+
partial(event.lock.acquire, blocking=False)
103+
)
104+
except Timeout:
105+
acquired = False
106+
else:
107+
acquired = True
108+
if acquired:
109+
break
110+
await anyio.sleep(0.05)
111+
elif isinstance(event, ReleaseFileLock):
112+
# Shielded so that a cancelled scope cannot skip the release and
113+
# leak the lock.
114+
with anyio.CancelScope(shield=True):
115+
await anyio.to_thread.run_sync(event.lock.release)
116+
elif isinstance(event, RunBlocking):
117+
return await anyio.to_thread.run_sync(event.func)
102118
else:
103119
raise RuntimeError(f"Unknown event type: {type(event)}")
104120

@@ -240,29 +256,15 @@ async def _ratelimit_callback(self, until):
240256
logger.info("Rate limit reached. Sleeping for {:d} seconds.", duration)
241257

242258
if self.callback is not None:
243-
await self.callback(until)
259+
result = self.callback(until)
260+
if inspect.isawaitable(result):
261+
await result
244262

245263
async def _ratelimit_wait(self, duration):
246-
async_library = sniffio.current_async_library()
247-
if async_library == "asyncio":
248-
await self._ratelimit_wait_asyncio(duration)
249-
elif async_library == "trio":
250-
await self._ratelimit_wait_trio(duration)
251-
252-
async def _ratelimit_wait_asyncio(self, duration):
253-
until = time.monotonic() + duration
254-
task = asyncio.create_task(self._ratelimit_callback(until))
255-
self._ratelimit_tasks.add(task)
256-
task.add_done_callback(self._ratelimit_tasks.discard)
257-
await asyncio.sleep(duration)
258-
259-
async def _ratelimit_wait_trio(self, duration):
260-
import trio
261-
262264
until = time.monotonic() + duration
263-
async with trio.open_nursery() as nursery:
264-
nursery.start_soon(self._ratelimit_callback, until)
265-
nursery.start_soon(trio.sleep, duration)
265+
async with anyio.create_task_group() as tg:
266+
tg.start_soon(self._ratelimit_callback, until)
267+
tg.start_soon(anyio.sleep, duration)
266268

267269
async def get_predicates(self, class_, controller=None):
268270
"""Get full predicate information for given request class, and cache

src/spacetrack/base.py

Lines changed: 55 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,17 @@
77
import warnings
88
import weakref
99
from collections import OrderedDict
10-
from collections.abc import Mapping
10+
from collections.abc import Callable, Mapping
1111
from datetime import datetime, timedelta, timezone
1212
from functools import partial
1313
from json import JSONDecodeError
1414
from pathlib import Path
15+
from typing import Any
1516
from urllib.parse import quote
1617

17-
import attr
1818
import httpx2
1919
import outcome
20+
from attrs import define
2021
from filelock import FileLock
2122
from httpx2 import USE_CLIENT_DEFAULT
2223
from logbook import Logger
@@ -79,48 +80,47 @@ class Event:
7980
pass
8081

8182

82-
@attr.s(slots=True)
83+
@define
8384
class NormalRequest(Event):
84-
request = attr.ib()
85-
stream = attr.ib(default=False)
86-
follow_redirects = attr.ib(default=False)
85+
request: httpx2.Request
86+
stream: bool = False
87+
follow_redirects: bool = False
8788

8889

89-
@attr.s(slots=True)
90+
@define
9091
class ReadResponse(Event):
91-
response = attr.ib()
92+
response: httpx2.Response
9293

9394

94-
@attr.s(slots=True)
95+
@define
9596
class IterLines(Event):
96-
response = attr.ib()
97+
response: httpx2.Response
9798

9899

99-
@attr.s(slots=True)
100+
@define
100101
class IterContent(Event):
101-
response = attr.ib()
102-
decode = attr.ib()
102+
response: httpx2.Response
103+
decode: bool
103104

104105

105-
@attr.s(slots=True)
106+
@define
106107
class RateLimitWait(Event):
107-
duration = attr.ib()
108+
duration: float
108109

109110

110-
@attr.s(slots=True)
111-
class AcquireLock(Event):
112-
lock = attr.ib()
111+
@define
112+
class AcquireFileLock(Event):
113+
lock: FileLock
113114

114115

115-
@attr.s(slots=True)
116-
class ReleaseLock(Event):
117-
lock = attr.ib()
116+
@define
117+
class ReleaseFileLock(Event):
118+
lock: FileLock
118119

119120

120-
class UnsupportedAsyncLibrary(Exception):
121-
"""Raised internally when an event cannot be handled with the active async
122-
library.
123-
"""
121+
@define
122+
class RunBlocking(Event):
123+
func: Callable[[], Any]
124124

125125

126126
class Predicate(ReprHelperMixin):
@@ -299,7 +299,6 @@ class SpaceTrackClient:
299299
Predicate("favorites", "str"),
300300
}
301301

302-
_file_lock_cls = FileLock
303302
_httpx_client_cls = httpx2.Client
304303

305304
def __init__(
@@ -403,10 +402,12 @@ def _handle_event(self, event):
403402
return _iter_content_generator(event.response, event.decode)
404403
elif isinstance(event, RateLimitWait):
405404
self._ratelimit_wait(event.duration)
406-
elif isinstance(event, AcquireLock):
405+
elif isinstance(event, AcquireFileLock):
407406
event.lock.acquire()
408-
elif isinstance(event, ReleaseLock):
407+
elif isinstance(event, ReleaseFileLock):
409408
event.lock.release()
409+
elif isinstance(event, RunBlocking):
410+
return event.func()
410411
else:
411412
raise RuntimeError(f"Unknown event type: {type(event)}")
412413

@@ -779,10 +780,20 @@ def _ratelimit_callback(self, until):
779780

780781
def _ratelimit_wait(self, duration):
781782
until = time.monotonic() + duration
782-
t = threading.Thread(target=self._ratelimit_callback, args=(until,))
783+
callback_outcome = None
784+
785+
def run_callback():
786+
nonlocal callback_outcome
787+
callback_outcome = outcome.capture(self._ratelimit_callback, until)
788+
789+
t = threading.Thread(target=run_callback)
783790
t.daemon = True
784791
t.start()
785792
time.sleep(duration)
793+
t.join()
794+
# Match the async client, where a callback exception propagates and
795+
# aborts the request.
796+
callback_outcome.unwrap()
786797

787798
def __getattr__(self, attr):
788799
if attr in self.request_controllers:
@@ -867,41 +878,33 @@ def _get_predicates_generator(self, class_, controller, *, force=False):
867878
hasher.update(key.encode())
868879
hashkey = hasher.hexdigest()[:16]
869880
cache_file = self._cache_path / f"predicates-{hashkey}.json"
870-
predicates_data = self._read_cache_file(
871-
cache_file, PREDICATE_CACHE_EXPIRY_TIME
881+
read_cache = partial(
882+
self._read_cache_file, cache_file, PREDICATE_CACHE_EXPIRY_TIME
872883
)
884+
predicates_data = yield RunBlocking(read_cache)
873885

874886
if predicates_data is None:
875-
self._cache_path.mkdir(parents=True, exist_ok=True)
887+
yield RunBlocking(
888+
partial(self._cache_path.mkdir, parents=True, exist_ok=True)
889+
)
876890

877891
lock_file = cache_file.with_name(cache_file.name + ".lock")
878-
lock = self._file_lock_cls(lock_file)
879-
try:
880-
yield AcquireLock(lock)
881-
except UnsupportedAsyncLibrary:
882-
if not force:
883-
# The file lock doesn't support Trio, skip predicate
884-
# checking by setting None
885-
self._predicates[key] = None
886-
return self._predicates[key]
887-
lock_acquired = False
888-
else:
889-
lock_acquired = True
892+
# thread_local=False because the async client acquires and
893+
# releases the lock from different worker threads.
894+
lock = FileLock(lock_file, thread_local=False)
895+
yield AcquireFileLock(lock)
890896

891897
try:
892-
if lock_acquired:
893-
predicates_data = self._read_cache_file(
894-
cache_file, PREDICATE_CACHE_EXPIRY_TIME
895-
)
898+
predicates_data = yield RunBlocking(read_cache)
896899
if predicates_data is None:
897900
predicates_data = yield from self._download_predicate_data_generator(
898901
class_, controller
899902
)
900-
if lock_acquired:
901-
self._write_cache_file(cache_file, predicates_data)
903+
yield RunBlocking(
904+
partial(self._write_cache_file, cache_file, predicates_data)
905+
)
902906
finally:
903-
if lock_acquired:
904-
yield ReleaseLock(lock)
907+
yield ReleaseFileLock(lock)
905908

906909
predicate_objects = self._parse_predicates_data(predicates_data)
907910

0 commit comments

Comments
 (0)