fastapi-memory 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastapi_memory/__init__.py +74 -0
- fastapi_memory/caching.py +117 -0
- fastapi_memory/config.py +34 -0
- fastapi_memory/http.py +170 -0
- fastapi_memory/py.typed +0 -0
- fastapi_memory/resilience.py +116 -0
- fastapi_memory-0.1.0.dist-info/METADATA +233 -0
- fastapi_memory-0.1.0.dist-info/RECORD +11 -0
- fastapi_memory-0.1.0.dist-info/WHEEL +5 -0
- fastapi_memory-0.1.0.dist-info/licenses/LICENSE +21 -0
- fastapi_memory-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""
|
|
2
|
+
fastapi-memory
|
|
3
|
+
==============
|
|
4
|
+
|
|
5
|
+
Caching, retry, and resilient-HTTP helpers for FastAPI services.
|
|
6
|
+
|
|
7
|
+
This package provides response caching, retry policies, a resilient async
|
|
8
|
+
HTTP client, and cached config singletons — all in one import, designed for
|
|
9
|
+
FastAPI services that talk to slower upstream APIs.
|
|
10
|
+
|
|
11
|
+
Import from this package instead of juggling multiple dependencies directly::
|
|
12
|
+
|
|
13
|
+
from fastapi_memory import (
|
|
14
|
+
FmCacheManager, FmMemoryBackend, memorize,
|
|
15
|
+
retry, stop_after_retries, exponential_backoff, retry_on_error,
|
|
16
|
+
fm_lru,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
Higher-level helpers
|
|
20
|
+
---------------------
|
|
21
|
+
On top of the re-exports, fastapi-memory adds a few small conveniences:
|
|
22
|
+
|
|
23
|
+
- ``init_cache()`` -> one-line ``FmCacheManager`` setup (memory or Redis)
|
|
24
|
+
- ``clear_cache()`` -> ``await FmCacheManager.clear()``
|
|
25
|
+
- ``default_retry()`` -> the "3 attempts, exponential backoff, skip 4xx" policy
|
|
26
|
+
- ``is_retryable_httpx_error`` -> the retry predicate behind ``default_retry``
|
|
27
|
+
- ``cached_singleton`` -> ``@fm_lru(maxsize=1)`` for settings-style singletons
|
|
28
|
+
- ``FmResilientClient`` -> persistent ``httpx.AsyncClient`` + retries + JSON helpers
|
|
29
|
+
|
|
30
|
+
See the README for full usage examples and a migration guide.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from .caching import (
|
|
34
|
+
FmCacheManager,
|
|
35
|
+
FmMemoryBackend,
|
|
36
|
+
FmRedisBackend,
|
|
37
|
+
memorize,
|
|
38
|
+
clear_cache,
|
|
39
|
+
init_cache,
|
|
40
|
+
)
|
|
41
|
+
from .config import cached_singleton, fm_lru
|
|
42
|
+
from .http import FmResilientClient
|
|
43
|
+
from .resilience import (
|
|
44
|
+
default_retry,
|
|
45
|
+
is_retryable_httpx_error,
|
|
46
|
+
retry,
|
|
47
|
+
retry_on_error,
|
|
48
|
+
stop_after_retries,
|
|
49
|
+
exponential_backoff,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
__version__ = "0.1.0"
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
# caching
|
|
56
|
+
"FmCacheManager",
|
|
57
|
+
"FmMemoryBackend",
|
|
58
|
+
"FmRedisBackend",
|
|
59
|
+
"memorize",
|
|
60
|
+
"init_cache",
|
|
61
|
+
"clear_cache",
|
|
62
|
+
# resilience
|
|
63
|
+
"retry",
|
|
64
|
+
"stop_after_retries",
|
|
65
|
+
"exponential_backoff",
|
|
66
|
+
"retry_on_error",
|
|
67
|
+
"default_retry",
|
|
68
|
+
"is_retryable_httpx_error",
|
|
69
|
+
# config
|
|
70
|
+
"fm_lru",
|
|
71
|
+
"cached_singleton",
|
|
72
|
+
# http
|
|
73
|
+
"FmResilientClient",
|
|
74
|
+
]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Thin convenience layer around fastapi-cache2.
|
|
3
|
+
|
|
4
|
+
Re-exports the pieces you already use directly (``FmCacheManager``,
|
|
5
|
+
``FmMemoryBackend``, ``memorize``) and adds two small helpers:
|
|
6
|
+
|
|
7
|
+
- :func:`init_cache` - one-line setup for an in-memory or Redis-backed cache
|
|
8
|
+
- :func:`clear_cache` - ``await FmCacheManager.clear()``
|
|
9
|
+
|
|
10
|
+
The Redis backend is optional. Install it with::
|
|
11
|
+
|
|
12
|
+
pip install "fastapi-memory[redis]"
|
|
13
|
+
|
|
14
|
+
If ``redis`` isn't installed, ``FmRedisBackend`` is simply ``None`` and
|
|
15
|
+
``init_cache(backend="redis", ...)`` raises a clear ``RuntimeError``
|
|
16
|
+
explaining how to fix it. The default ``backend="memory"`` works with no
|
|
17
|
+
extra dependencies, exactly like the original::
|
|
18
|
+
|
|
19
|
+
FmCacheManager.init(FmMemoryBackend(), prefix="app-cache")
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
from fastapi_cache import FastAPICache
|
|
27
|
+
from fastapi_cache.backends.inmemory import InMemoryBackend
|
|
28
|
+
from fastapi_cache.decorator import cache
|
|
29
|
+
|
|
30
|
+
# Aliases under fastapi-memory namespace
|
|
31
|
+
FmCacheManager = FastAPICache
|
|
32
|
+
FmMemoryBackend = InMemoryBackend
|
|
33
|
+
memorize = cache
|
|
34
|
+
|
|
35
|
+
try: # pragma: no cover - exercised only when the `redis` extra is installed
|
|
36
|
+
from fastapi_cache.backends.redis import RedisBackend
|
|
37
|
+
from redis.asyncio import from_url as _redis_from_url
|
|
38
|
+
|
|
39
|
+
FmRedisBackend = RedisBackend
|
|
40
|
+
_REDIS_AVAILABLE = True
|
|
41
|
+
except ImportError: # pragma: no cover - redis extra not installed
|
|
42
|
+
FmRedisBackend = None # type: ignore[assignment, misc]
|
|
43
|
+
_redis_from_url = None
|
|
44
|
+
_REDIS_AVAILABLE = False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def init_cache(
|
|
48
|
+
backend: str = "memory",
|
|
49
|
+
*,
|
|
50
|
+
prefix: str = "fastapi-cache",
|
|
51
|
+
redis_url: Optional[str] = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""
|
|
54
|
+
Initialise :class:`FmCacheManager` with either an in-memory backend
|
|
55
|
+
(default) or a Redis backend.
|
|
56
|
+
|
|
57
|
+
Call this once during application startup, typically inside a
|
|
58
|
+
``lifespan`` handler::
|
|
59
|
+
|
|
60
|
+
from contextlib import asynccontextmanager
|
|
61
|
+
from fastapi import FastAPI
|
|
62
|
+
from fastapi_memory import init_cache
|
|
63
|
+
|
|
64
|
+
@asynccontextmanager
|
|
65
|
+
async def lifespan(app: FastAPI):
|
|
66
|
+
init_cache(prefix="app-cache") # in-memory (default)
|
|
67
|
+
yield
|
|
68
|
+
|
|
69
|
+
app = FastAPI(lifespan=lifespan)
|
|
70
|
+
|
|
71
|
+
For Redis, pass ``backend="redis"`` and a connection URL::
|
|
72
|
+
|
|
73
|
+
init_cache(backend="redis", prefix="app-cache", redis_url="redis://localhost:6379")
|
|
74
|
+
|
|
75
|
+
Parameters
|
|
76
|
+
----------
|
|
77
|
+
backend:
|
|
78
|
+
``"memory"`` (default) or ``"redis"``.
|
|
79
|
+
prefix:
|
|
80
|
+
Cache-key prefix, passed straight through to ``FmCacheManager.init``.
|
|
81
|
+
redis_url:
|
|
82
|
+
Connection string for Redis, e.g. ``redis://localhost:6379``.
|
|
83
|
+
Required when ``backend="redis"``.
|
|
84
|
+
"""
|
|
85
|
+
if backend == "memory":
|
|
86
|
+
FmCacheManager.init(FmMemoryBackend(), prefix=prefix)
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
if backend == "redis":
|
|
90
|
+
if not _REDIS_AVAILABLE:
|
|
91
|
+
raise RuntimeError(
|
|
92
|
+
"Redis backend requested but the 'redis' package is not "
|
|
93
|
+
"installed. Install it with: pip install fastapi-memory[redis]"
|
|
94
|
+
)
|
|
95
|
+
if not redis_url:
|
|
96
|
+
raise ValueError("redis_url is required when backend='redis'")
|
|
97
|
+
|
|
98
|
+
client = _redis_from_url(redis_url, encoding="utf8", decode_responses=False)
|
|
99
|
+
FmCacheManager.init(FmRedisBackend(client), prefix=prefix)
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
raise ValueError(f"Unknown backend {backend!r}, expected 'memory' or 'redis'")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def clear_cache() -> None:
|
|
106
|
+
"""Clear the entire cache - thin wrapper around ``await FmCacheManager.clear()``."""
|
|
107
|
+
await FmCacheManager.clear()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
__all__ = [
|
|
111
|
+
"FmCacheManager",
|
|
112
|
+
"FmMemoryBackend",
|
|
113
|
+
"FmRedisBackend",
|
|
114
|
+
"memorize",
|
|
115
|
+
"init_cache",
|
|
116
|
+
"clear_cache",
|
|
117
|
+
]
|
fastapi_memory/config.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tiny helper built on top of ``functools.lru_cache`` for the
|
|
3
|
+
"build-it-once-and-reuse-it" settings/config pattern.
|
|
4
|
+
|
|
5
|
+
Re-exports ``lru_cache`` as ``fm_lru``, plus :func:`cached_singleton`, a
|
|
6
|
+
small shorthand for the common::
|
|
7
|
+
|
|
8
|
+
@fm_lru(maxsize=1)
|
|
9
|
+
def get_settings() -> Settings:
|
|
10
|
+
return Settings()
|
|
11
|
+
|
|
12
|
+
pattern.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from functools import lru_cache
|
|
18
|
+
from typing import Callable, TypeVar
|
|
19
|
+
|
|
20
|
+
T = TypeVar("T")
|
|
21
|
+
|
|
22
|
+
# Alias under fastapi-memory namespace
|
|
23
|
+
fm_lru = lru_cache
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cached_singleton(func: Callable[[], T]) -> Callable[[], T]:
|
|
27
|
+
"""
|
|
28
|
+
Shorthand for ``@fm_lru(maxsize=1)`` - turns a zero-argument factory
|
|
29
|
+
function into a cached singleton getter.
|
|
30
|
+
"""
|
|
31
|
+
return fm_lru(maxsize=1)(func)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
__all__ = ["fm_lru", "cached_singleton"]
|
fastapi_memory/http.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A small resilient async HTTP client: a persistent ``httpx.AsyncClient`` with
|
|
3
|
+
connection-pooling defaults plus automatic retries via
|
|
4
|
+
:func:`fastapi_memory.resilience.default_retry`.
|
|
5
|
+
|
|
6
|
+
This mirrors the ``_upstream_get_raw`` / ``_upstream_get`` pattern: a single
|
|
7
|
+
shared client, retried with exponential backoff on network errors and 5xx
|
|
8
|
+
responses, with a thin layer that turns final failures into FastAPI
|
|
9
|
+
``HTTPException``s.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
from fastapi import HTTPException
|
|
18
|
+
|
|
19
|
+
from .resilience import default_retry
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FmResilientClient:
|
|
23
|
+
"""
|
|
24
|
+
A persistent, retrying async HTTP client for talking to an upstream API.
|
|
25
|
+
|
|
26
|
+
Example
|
|
27
|
+
-------
|
|
28
|
+
from contextlib import asynccontextmanager
|
|
29
|
+
from fastapi import FastAPI
|
|
30
|
+
from fastapi_memory import FmResilientClient
|
|
31
|
+
|
|
32
|
+
upstream = FmResilientClient(
|
|
33
|
+
base_url="http://api.example.com:8080",
|
|
34
|
+
timeout=30.0,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
@asynccontextmanager
|
|
38
|
+
async def lifespan(app: FastAPI):
|
|
39
|
+
await upstream.start()
|
|
40
|
+
yield
|
|
41
|
+
await upstream.aclose()
|
|
42
|
+
|
|
43
|
+
app = FastAPI(lifespan=lifespan)
|
|
44
|
+
|
|
45
|
+
@app.get("/api/data")
|
|
46
|
+
async def get_data():
|
|
47
|
+
return await upstream.get_json("data")
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
base_url:
|
|
52
|
+
Prepended to every ``path`` passed to :meth:`get_raw` / :meth:`get_json`.
|
|
53
|
+
Leave empty to pass full URLs directly.
|
|
54
|
+
timeout:
|
|
55
|
+
Per-request timeout in seconds, forwarded to ``httpx.AsyncClient``.
|
|
56
|
+
verify:
|
|
57
|
+
TLS verification, forwarded to ``httpx.AsyncClient``. Set to
|
|
58
|
+
``False`` for self-signed/internal endpoints (matches
|
|
59
|
+
``httpx.AsyncClient(verify=False)`` in the original code).
|
|
60
|
+
max_connections / max_keepalive_connections:
|
|
61
|
+
Forwarded to ``httpx.Limits``.
|
|
62
|
+
retry_attempts / retry_wait_min / retry_wait_max:
|
|
63
|
+
Forwarded to :func:`default_retry` for every request made through
|
|
64
|
+
this client.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
base_url: str = "",
|
|
70
|
+
*,
|
|
71
|
+
timeout: float = 30.0,
|
|
72
|
+
verify: bool = True,
|
|
73
|
+
max_connections: int = 20,
|
|
74
|
+
max_keepalive_connections: int = 10,
|
|
75
|
+
retry_attempts: int = 3,
|
|
76
|
+
retry_wait_min: float = 2,
|
|
77
|
+
retry_wait_max: float = 10,
|
|
78
|
+
) -> None:
|
|
79
|
+
self.base_url = base_url.rstrip("/")
|
|
80
|
+
self._timeout = timeout
|
|
81
|
+
self._verify = verify
|
|
82
|
+
self._limits = httpx.Limits(
|
|
83
|
+
max_connections=max_connections,
|
|
84
|
+
max_keepalive_connections=max_keepalive_connections,
|
|
85
|
+
)
|
|
86
|
+
self._retry_attempts = retry_attempts
|
|
87
|
+
self._retry_wait_min = retry_wait_min
|
|
88
|
+
self._retry_wait_max = retry_wait_max
|
|
89
|
+
self._client: Optional[httpx.AsyncClient] = None
|
|
90
|
+
|
|
91
|
+
async def start(self) -> None:
|
|
92
|
+
"""Create the underlying ``httpx.AsyncClient``. Call once on startup."""
|
|
93
|
+
self._client = httpx.AsyncClient(
|
|
94
|
+
timeout=self._timeout,
|
|
95
|
+
verify=self._verify,
|
|
96
|
+
limits=self._limits,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
async def aclose(self) -> None:
|
|
100
|
+
"""Close the underlying client. Call once on shutdown."""
|
|
101
|
+
if self._client is not None:
|
|
102
|
+
await self._client.aclose()
|
|
103
|
+
self._client = None
|
|
104
|
+
|
|
105
|
+
async def __aenter__(self) -> "FmResilientClient":
|
|
106
|
+
await self.start()
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
110
|
+
await self.aclose()
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def client(self) -> httpx.AsyncClient:
|
|
114
|
+
"""The underlying ``httpx.AsyncClient``. Raises if not started yet."""
|
|
115
|
+
if self._client is None:
|
|
116
|
+
raise RuntimeError(
|
|
117
|
+
"FmResilientClient is not started - call `await client.start()` "
|
|
118
|
+
"during application startup (e.g. in your lifespan handler), "
|
|
119
|
+
"or use it as `async with FmResilientClient(...) as client:`."
|
|
120
|
+
)
|
|
121
|
+
return self._client
|
|
122
|
+
|
|
123
|
+
def _url(self, path: str) -> str:
|
|
124
|
+
if self.base_url and not path.startswith(("http://", "https://")):
|
|
125
|
+
return f"{self.base_url}/{path.lstrip('/')}"
|
|
126
|
+
return path
|
|
127
|
+
|
|
128
|
+
async def get_raw(self, path: str, params: Optional[dict] = None) -> Any:
|
|
129
|
+
"""
|
|
130
|
+
``GET path`` with retries (see :func:`default_retry`).
|
|
131
|
+
|
|
132
|
+
Returns the parsed JSON body if possible, otherwise the raw response
|
|
133
|
+
text. Raises the underlying ``httpx`` exception on final failure -
|
|
134
|
+
use :meth:`get_json` if you'd rather get a FastAPI ``HTTPException``.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
@default_retry(
|
|
138
|
+
attempts=self._retry_attempts,
|
|
139
|
+
wait_min=self._retry_wait_min,
|
|
140
|
+
wait_max=self._retry_wait_max,
|
|
141
|
+
)
|
|
142
|
+
async def _do_request() -> Any:
|
|
143
|
+
resp = await self.client.get(self._url(path), params=params)
|
|
144
|
+
resp.raise_for_status()
|
|
145
|
+
try:
|
|
146
|
+
return resp.json()
|
|
147
|
+
except ValueError:
|
|
148
|
+
return resp.text or ""
|
|
149
|
+
|
|
150
|
+
return await _do_request()
|
|
151
|
+
|
|
152
|
+
async def get_json(self, path: str, params: Optional[dict] = None) -> Any:
|
|
153
|
+
"""
|
|
154
|
+
Like :meth:`get_raw`, but converts ``httpx`` errors (after retries
|
|
155
|
+
are exhausted) into FastAPI ``HTTPException``s:
|
|
156
|
+
|
|
157
|
+
- ``httpx.HTTPStatusError`` -> ``HTTPException(status_code=<upstream status>)``
|
|
158
|
+
- ``httpx.RequestError`` -> ``HTTPException(status_code=502)``
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
return await self.get_raw(path, params)
|
|
162
|
+
except httpx.HTTPStatusError as exc:
|
|
163
|
+
raise HTTPException(
|
|
164
|
+
status_code=exc.response.status_code, detail="Upstream request failed"
|
|
165
|
+
) from exc
|
|
166
|
+
except httpx.RequestError as exc:
|
|
167
|
+
raise HTTPException(status_code=502, detail="Upstream request error") from exc
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
__all__ = ["FmResilientClient"]
|
fastapi_memory/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Retry helpers built on top of tenacity.
|
|
3
|
+
|
|
4
|
+
Re-exports the building blocks you already use directly (``retry``,
|
|
5
|
+
``stop_after_retries``, ``exponential_backoff``, ``retry_on_error``) and
|
|
6
|
+
adds two small helpers for retry policies on flaky upstream services:
|
|
7
|
+
|
|
8
|
+
- :func:`is_retryable_httpx_error` - retry network errors and 5xx responses,
|
|
9
|
+
but never 4xx client errors.
|
|
10
|
+
- :func:`default_retry` - a ready-made decorator factory for "retry up to 3
|
|
11
|
+
times with exponential backoff (2s..10s), reraising on final failure".
|
|
12
|
+
|
|
13
|
+
These two together are exactly equivalent to::
|
|
14
|
+
|
|
15
|
+
def _should_retry(exc):
|
|
16
|
+
if isinstance(exc, httpx.RequestError):
|
|
17
|
+
return True
|
|
18
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
19
|
+
return exc.response.status_code >= 500
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
@retry(
|
|
23
|
+
stop=stop_after_retries(3),
|
|
24
|
+
wait=exponential_backoff(multiplier=1, min=2, max=10),
|
|
25
|
+
retry=retry_on_error(_should_retry),
|
|
26
|
+
reraise=True,
|
|
27
|
+
)
|
|
28
|
+
async def call_upstream(...):
|
|
29
|
+
...
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from typing import Callable, Optional
|
|
35
|
+
|
|
36
|
+
import httpx
|
|
37
|
+
from tenacity import (
|
|
38
|
+
retry,
|
|
39
|
+
retry_if_exception,
|
|
40
|
+
stop_after_attempt,
|
|
41
|
+
wait_exponential,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Aliases under fastapi-memory namespace
|
|
45
|
+
stop_after_retries = stop_after_attempt
|
|
46
|
+
exponential_backoff = wait_exponential
|
|
47
|
+
retry_on_error = retry_if_exception
|
|
48
|
+
|
|
49
|
+
RetryPredicate = Callable[[BaseException], bool]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_retryable_httpx_error(exc: BaseException) -> bool:
|
|
53
|
+
"""
|
|
54
|
+
Default retry policy for httpx calls:
|
|
55
|
+
|
|
56
|
+
- ``httpx.RequestError`` (timeouts, connection errors, DNS failures, ...) -> retry
|
|
57
|
+
- ``httpx.HTTPStatusError`` with a 5xx response -> retry
|
|
58
|
+
- ``httpx.HTTPStatusError`` with a 4xx response -> do NOT retry
|
|
59
|
+
- anything else -> do NOT retry
|
|
60
|
+
"""
|
|
61
|
+
if isinstance(exc, httpx.RequestError):
|
|
62
|
+
return True
|
|
63
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
64
|
+
return exc.response.status_code >= 500
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def default_retry(
|
|
69
|
+
attempts: int = 3,
|
|
70
|
+
*,
|
|
71
|
+
wait_min: float = 2,
|
|
72
|
+
wait_max: float = 10,
|
|
73
|
+
multiplier: float = 1,
|
|
74
|
+
retry_on: Optional[RetryPredicate] = None,
|
|
75
|
+
reraise: bool = True,
|
|
76
|
+
):
|
|
77
|
+
"""
|
|
78
|
+
A pre-configured :func:`tenacity.retry` decorator factory.
|
|
79
|
+
|
|
80
|
+
Calling ``default_retry()`` with no arguments is equivalent to::
|
|
81
|
+
|
|
82
|
+
@retry(
|
|
83
|
+
stop=stop_after_retries(3),
|
|
84
|
+
wait=exponential_backoff(multiplier=1, min=2, max=10),
|
|
85
|
+
retry=retry_on_error(is_retryable_httpx_error),
|
|
86
|
+
reraise=True,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
Example
|
|
90
|
+
-------
|
|
91
|
+
@default_retry()
|
|
92
|
+
async def call_upstream():
|
|
93
|
+
resp = await client.get(url)
|
|
94
|
+
resp.raise_for_status()
|
|
95
|
+
return resp.json()
|
|
96
|
+
|
|
97
|
+
Override any piece as needed, e.g. ``default_retry(attempts=5)`` or
|
|
98
|
+
``default_retry(retry_on=my_predicate)``.
|
|
99
|
+
"""
|
|
100
|
+
condition = retry_on or is_retryable_httpx_error
|
|
101
|
+
return retry(
|
|
102
|
+
stop=stop_after_retries(attempts),
|
|
103
|
+
wait=exponential_backoff(multiplier=multiplier, min=wait_min, max=wait_max),
|
|
104
|
+
retry=retry_on_error(condition),
|
|
105
|
+
reraise=reraise,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
__all__ = [
|
|
110
|
+
"retry",
|
|
111
|
+
"stop_after_retries",
|
|
112
|
+
"exponential_backoff",
|
|
113
|
+
"retry_on_error",
|
|
114
|
+
"default_retry",
|
|
115
|
+
"is_retryable_httpx_error",
|
|
116
|
+
]
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi-memory
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Caching, retry and resilient-HTTP helpers for FastAPI services.
|
|
5
|
+
Author-email: Alexander Stankovic <alexdarka@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/alexdarka/fastapi-memory
|
|
8
|
+
Project-URL: Repository, https://github.com/alexdarka/fastapi-memory
|
|
9
|
+
Keywords: fastapi,cache,caching,retry,tenacity,httpx,resilience,redis
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Framework :: FastAPI
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: fastapi>=0.115.0
|
|
27
|
+
Requires-Dist: fastapi-cache2>=0.2.2
|
|
28
|
+
Requires-Dist: tenacity>=8.3.0
|
|
29
|
+
Requires-Dist: httpx>=0.27.0
|
|
30
|
+
Requires-Dist: jinja2>=3.1.0
|
|
31
|
+
Provides-Extra: redis
|
|
32
|
+
Requires-Dist: redis>=5.0.0; extra == "redis"
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
35
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
36
|
+
Provides-Extra: docs
|
|
37
|
+
Requires-Dist: mkdocs; extra == "docs"
|
|
38
|
+
Requires-Dist: mkdocs-material; extra == "docs"
|
|
39
|
+
Requires-Dist: mkdocstrings[python]; extra == "docs"
|
|
40
|
+
Dynamic: license-file
|
|
41
|
+
|
|
42
|
+
# fastapi-memory
|
|
43
|
+
|
|
44
|
+
Caching, retry, and resilient-HTTP helpers for FastAPI services.
|
|
45
|
+
|
|
46
|
+
A single package that provides response caching, retry policies, a resilient
|
|
47
|
+
async HTTP client, and cached config singletons — tailored for FastAPI
|
|
48
|
+
projects that talk to upstream APIs.
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from fastapi_memory import (
|
|
52
|
+
FmCacheManager, FmMemoryBackend, memorize,
|
|
53
|
+
retry, stop_after_retries, exponential_backoff, retry_on_error,
|
|
54
|
+
fm_lru,
|
|
55
|
+
)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Why
|
|
59
|
+
|
|
60
|
+
Most FastAPI services that proxy slower or less-reliable upstream APIs end up
|
|
61
|
+
re-writing the same patterns:
|
|
62
|
+
|
|
63
|
+
1. A **cache** for endpoints or data that don't change often.
|
|
64
|
+
2. A **retry policy** for upstream calls — retry network errors and 5xx, but not 4xx.
|
|
65
|
+
3. A **cached singleton** config object (build once, reuse everywhere).
|
|
66
|
+
4. A **resilient HTTP client** with connection pooling and retries baked in.
|
|
67
|
+
|
|
68
|
+
`fastapi-memory` consolidates all of these into one import, with ready-made
|
|
69
|
+
helpers so you don't have to re-derive common patterns every time.
|
|
70
|
+
|
|
71
|
+
## Installation
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# Editable install (local development)
|
|
75
|
+
pip install -e /path/to/fastapi-memory
|
|
76
|
+
|
|
77
|
+
# From PyPI (once published)
|
|
78
|
+
pip install fastapi-memory
|
|
79
|
+
|
|
80
|
+
# With optional Redis cache backend support
|
|
81
|
+
pip install "fastapi-memory[redis]"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## What's inside
|
|
85
|
+
|
|
86
|
+
| Module | Provides | Adds |
|
|
87
|
+
|--------------|-------------------------------------------------------------------|-----------------------------------------------------------|
|
|
88
|
+
| `caching` | `FmCacheManager`, `FmMemoryBackend`, `memorize`, `FmRedisBackend` | `init_cache()`, `clear_cache()` |
|
|
89
|
+
| `resilience` | `retry`, `stop_after_retries`, `exponential_backoff`, `retry_on_error` | `default_retry()`, `is_retryable_httpx_error()` |
|
|
90
|
+
| `config` | `fm_lru` | `cached_singleton` |
|
|
91
|
+
| `http` | — | `FmResilientClient` |
|
|
92
|
+
|
|
93
|
+
Everything above is re-exported from the top-level `fastapi_memory` package,
|
|
94
|
+
so `from fastapi_memory import <anything in the table>` works.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Quick start
|
|
99
|
+
|
|
100
|
+
### Caching — response caching with a single call
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from contextlib import asynccontextmanager
|
|
104
|
+
from fastapi import FastAPI
|
|
105
|
+
from fastapi_memory import init_cache, clear_cache, memorize
|
|
106
|
+
|
|
107
|
+
@asynccontextmanager
|
|
108
|
+
async def lifespan(app: FastAPI):
|
|
109
|
+
init_cache(prefix="app-cache") # in-memory (default)
|
|
110
|
+
yield
|
|
111
|
+
|
|
112
|
+
app = FastAPI(lifespan=lifespan)
|
|
113
|
+
|
|
114
|
+
@app.get("/api/data")
|
|
115
|
+
@memorize(expire=60)
|
|
116
|
+
async def get_data():
|
|
117
|
+
return {"data": "computed result"}
|
|
118
|
+
|
|
119
|
+
@app.post("/api/cache/invalidate")
|
|
120
|
+
async def invalidate_cache():
|
|
121
|
+
await clear_cache()
|
|
122
|
+
return {"ok": True}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Switching to Redis later
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
init_cache(backend="redis", prefix="app-cache", redis_url="redis://localhost:6379")
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Retry — exponential backoff with sensible defaults
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from fastapi_memory import default_retry
|
|
135
|
+
|
|
136
|
+
@default_retry()
|
|
137
|
+
async def call_upstream():
|
|
138
|
+
resp = await client.get(url)
|
|
139
|
+
resp.raise_for_status()
|
|
140
|
+
return resp.json()
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`default_retry()` retries up to 3 times with exponential backoff (2s–10s),
|
|
144
|
+
skip 4xx, reraise on final failure. Override any piece:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
@default_retry(attempts=5, wait_max=30)
|
|
148
|
+
async def flaky_call():
|
|
149
|
+
...
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Config — cached singleton
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
from fastapi_memory import cached_singleton
|
|
156
|
+
|
|
157
|
+
class Settings:
|
|
158
|
+
BASE_URL: str = "http://api.example.com:8080"
|
|
159
|
+
CACHE_TTL: int = 300
|
|
160
|
+
|
|
161
|
+
@cached_singleton
|
|
162
|
+
def get_settings() -> Settings:
|
|
163
|
+
return Settings()
|
|
164
|
+
|
|
165
|
+
config = get_settings()
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Resilient HTTP client
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from contextlib import asynccontextmanager
|
|
172
|
+
from fastapi import FastAPI
|
|
173
|
+
from fastapi_memory import FmResilientClient, init_cache
|
|
174
|
+
|
|
175
|
+
upstream = FmResilientClient(
|
|
176
|
+
base_url="http://api.example.com:8080",
|
|
177
|
+
timeout=30.0,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
@asynccontextmanager
|
|
181
|
+
async def lifespan(app: FastAPI):
|
|
182
|
+
await upstream.start()
|
|
183
|
+
init_cache(prefix="app-cache")
|
|
184
|
+
yield
|
|
185
|
+
await upstream.aclose()
|
|
186
|
+
|
|
187
|
+
app = FastAPI(lifespan=lifespan)
|
|
188
|
+
|
|
189
|
+
@app.get("/api/data")
|
|
190
|
+
async def get_data():
|
|
191
|
+
return await upstream.get_json("data")
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`upstream.get_raw(path, params)` is also available if you want the raw
|
|
195
|
+
exceptions instead of `HTTPException`.
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Project layout
|
|
200
|
+
|
|
201
|
+
```
|
|
202
|
+
fastapi-memory/
|
|
203
|
+
├── pyproject.toml
|
|
204
|
+
├── setup.py
|
|
205
|
+
├── README.md
|
|
206
|
+
├── LICENSE
|
|
207
|
+
├── fastapi_memory/
|
|
208
|
+
│ ├── __init__.py # re-exports everything
|
|
209
|
+
│ ├── caching.py # FmCacheManager, FmMemoryBackend, memorize, init_cache, clear_cache
|
|
210
|
+
│ ├── resilience.py # retry + default_retry, is_retryable_httpx_error
|
|
211
|
+
│ ├── config.py # fm_lru + cached_singleton
|
|
212
|
+
│ └── http.py # FmResilientClient
|
|
213
|
+
└── tests/
|
|
214
|
+
└── test_imports.py
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Updating `requirements.txt`
|
|
218
|
+
|
|
219
|
+
Replace:
|
|
220
|
+
|
|
221
|
+
```
|
|
222
|
+
tenacity>=8.3.0
|
|
223
|
+
fastapi-cache2>=0.2.2
|
|
224
|
+
redis>=5.0.0
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
with:
|
|
228
|
+
|
|
229
|
+
```
|
|
230
|
+
fastapi-memory @ file:///path/to/fastapi-memory
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
(or once published: `fastapi-memory>=0.1.0`, optionally `fastapi-memory[redis]`.)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
fastapi_memory/__init__.py,sha256=IfdgrQx-vtTVeaBe5cmfwdY0Sk-QdL4x3lNILwfznDU,2047
|
|
2
|
+
fastapi_memory/caching.py,sha256=Jpk6edAsJmdlu-8d_x9wvw_1EfsuxDwYr5BJ2n2DUWw,3673
|
|
3
|
+
fastapi_memory/config.py,sha256=GhAGG3aTw_-Av0py7x_1DZkmRP6FwiPTGCH1sgpcmkI,785
|
|
4
|
+
fastapi_memory/http.py,sha256=05WQ2w2CV6YX7rf8mwc4OxxXf92cFfNOdPhFyCRPqtI,5846
|
|
5
|
+
fastapi_memory/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
fastapi_memory/resilience.py,sha256=pqG9TzCebTs90gAy-GBE1EDtA-4Ft56Qn5kqQ2wQ8PI,3393
|
|
7
|
+
fastapi_memory-0.1.0.dist-info/licenses/LICENSE,sha256=v2spsd7N1pKFFh2G8wGP_45iwe5S0DYiJzG4im8Rupc,1066
|
|
8
|
+
fastapi_memory-0.1.0.dist-info/METADATA,sha256=M7jeNnKPKepT_BUgL3TtMlHDEbUEQk5_DfB509-zSEQ,7126
|
|
9
|
+
fastapi_memory-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
fastapi_memory-0.1.0.dist-info/top_level.txt,sha256=SlnFpu4h_DxWXWff3_wTcdv64tx5xEKSd_7j7wN2DgA,15
|
|
11
|
+
fastapi_memory-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Your Name
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fastapi_memory
|