fastapi-proxykit 0.1.0__tar.gz
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_proxykit-0.1.0/PKG-INFO +246 -0
- fastapi_proxykit-0.1.0/README.md +221 -0
- fastapi_proxykit-0.1.0/pyproject.toml +40 -0
- fastapi_proxykit-0.1.0/src/fastapi_proxykit/__init__.py +13 -0
- fastapi_proxykit-0.1.0/src/fastapi_proxykit/breaker.py +24 -0
- fastapi_proxykit-0.1.0/src/fastapi_proxykit/client.py +20 -0
- fastapi_proxykit-0.1.0/src/fastapi_proxykit/errors.py +7 -0
- fastapi_proxykit-0.1.0/src/fastapi_proxykit/models.py +43 -0
- fastapi_proxykit-0.1.0/src/fastapi_proxykit/openapi.py +82 -0
- fastapi_proxykit-0.1.0/src/fastapi_proxykit/py.typed +0 -0
- fastapi_proxykit-0.1.0/src/fastapi_proxykit/router.py +218 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: fastapi-proxykit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A production-ready FastAPI plugin for transparent HTTP proxying with per-route circuit breakers and OpenTelemetry observability.
|
|
5
|
+
Keywords: fastapi,proxy,http-proxy,circuit-breaker,opentelemetry,resilience
|
|
6
|
+
Author: Satyam Soni
|
|
7
|
+
Author-email: Satyam Soni <satyam_soni1@epam.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Framework :: FastAPI
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: System :: Networking
|
|
16
|
+
Requires-Dist: fastapi>=0.135.1
|
|
17
|
+
Requires-Dist: httpx>=0.28.1
|
|
18
|
+
Requires-Dist: opentelemetry-api>=1.40.0
|
|
19
|
+
Requires-Dist: opentelemetry-instrumentation-httpx>=0.61b0
|
|
20
|
+
Requires-Dist: opentelemetry-sdk>=1.40.0
|
|
21
|
+
Requires-Dist: pybreaker>=1.4.1
|
|
22
|
+
Requires-Dist: structlog>=24.0.0
|
|
23
|
+
Requires-Python: >=3.13
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
<p align="center">
|
|
27
|
+
<img src="https://img.shields.io/badge/Python-3.13+-3775A9?style=for-the-badge&logo=python&logoColor=white" alt="Python" />
|
|
28
|
+
<img src="https://img.shields.io/badge/FastAPI-0.115+-009688?style=for-the-badge&logo=fastapi&logoColor=white" alt="FastAPI" />
|
|
29
|
+
<img src="https://img.shields.io/github/license/satyamsoni2211/fastapi_proxykit?style=for-the-badge&color=green" alt="License" />
|
|
30
|
+
<img src="https://img.shields.io/github/stars/satyamsoni2211/fastapi_proxykit?style=for-the-badge&color=yellow" alt="Stars" />
|
|
31
|
+
<img src="https://img.shields.io/badge/Install%20from%20source-✓-success?style=for-the-badge" alt="Installable" />
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
<h1 align="center">⚡ fastapi-proxykit</h1>
|
|
35
|
+
|
|
36
|
+
<p align="center">
|
|
37
|
+
<b>Production-ready transparent proxy routes for FastAPI</b><br>
|
|
38
|
+
Turn your FastAPI app into a resilient API gateway with per-route circuit breakers, OpenTelemetry observability, and automatic OpenAPI merging — zero boilerplate.
|
|
39
|
+
</p>
|
|
40
|
+
|
|
41
|
+
<p align="center">
|
|
42
|
+
<a href="#-features">Features</a> •
|
|
43
|
+
<a href="#-quick-start">Quick Start</a> •
|
|
44
|
+
<a href="#-installation">Installation</a> •
|
|
45
|
+
<a href="#-configuration">Configuration</a> •
|
|
46
|
+
<a href="#-examples">Examples</a> •
|
|
47
|
+
<a href="#-why-use-it">Why?</a> •
|
|
48
|
+
<a href="#-license">License</a>
|
|
49
|
+
</p>
|
|
50
|
+
|
|
51
|
+
<div align="center">
|
|
52
|
+
<img src="https://via.placeholder.com/900x450/0d1117/58a6ff?text=fastapi-proxykit+in+action" alt="fastapi-proxykit architecture" width="900" />
|
|
53
|
+
<!-- Replace with real diagram later (e.g. Excalidraw: client → FastAPI → proxykit → multiple backends with traces & breakers) -->
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
## ✨ Features
|
|
57
|
+
|
|
58
|
+
- 🔀 **Transparent proxying** — preserve path, query params, headers automatically
|
|
59
|
+
- 🛡️ **Per-route circuit breakers** — isolated resilience with `pybreaker` (no cascading failures)
|
|
60
|
+
- 📊 **Full OpenTelemetry support** — tracing, metrics, custom tracer/meter injection
|
|
61
|
+
- 📖 **Automatic OpenAPI merging** — unified `/docs` from all backend services
|
|
62
|
+
- ⚡ **Non-blocking I/O** — `httpx.AsyncClient` with pooling & configurable limits
|
|
63
|
+
- 🧩 **Declarative config** — clean Pydantic-powered routes & settings
|
|
64
|
+
- 🛠 **Structured errors** — consistent JSON responses (503 breaker open, 504 timeout, etc.)
|
|
65
|
+
- 🔌 **Lifespan-aware** — client auto cleanup on shutdown
|
|
66
|
+
- 🆓 **Zero external agents** required for observability
|
|
67
|
+
|
|
68
|
+
## 🚀 Quick Start
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# Clone & install from source
|
|
72
|
+
git clone https://github.com/satyamsoni2211/fastapi_proxykit.git
|
|
73
|
+
cd fastapi_proxykit
|
|
74
|
+
pip install . # or: uv pip install .
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from fastapi import FastAPI
|
|
79
|
+
from fastapi_proxykit import proxy_router, ProxyConfig, ProxyRoute, BreakerConfig
|
|
80
|
+
|
|
81
|
+
app = FastAPI()
|
|
82
|
+
|
|
83
|
+
app.include_router(
|
|
84
|
+
proxy_router(
|
|
85
|
+
ProxyConfig(
|
|
86
|
+
routes=[
|
|
87
|
+
ProxyRoute(
|
|
88
|
+
path_prefix="/api/users",
|
|
89
|
+
target_base_url="https://users.example.com",
|
|
90
|
+
breaker=BreakerConfig(failure_threshold=5, timeout=30),
|
|
91
|
+
strip_prefix=True,
|
|
92
|
+
),
|
|
93
|
+
ProxyRoute(
|
|
94
|
+
path_prefix="/api/orders",
|
|
95
|
+
target_base_url="https://orders.example.com",
|
|
96
|
+
breaker=BreakerConfig(failure_threshold=3, timeout=15),
|
|
97
|
+
),
|
|
98
|
+
]
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
→ `GET /api/users/42` proxies to `https://users.example.com/42`
|
|
105
|
+
|
|
106
|
+
## 📦 Installation
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# From source (recommended for now)
|
|
110
|
+
pip install git+https://github.com/satyamsoni2211/fastapi_proxykit.git
|
|
111
|
+
# or clone & install locally
|
|
112
|
+
git clone https://github.com/satyamsoni2211/fastapi_proxykit.git
|
|
113
|
+
cd fastapi_proxykit
|
|
114
|
+
pip install .
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Requirements**: Python 3.13+
|
|
118
|
+
|
|
119
|
+
## ⚙️ Configuration
|
|
120
|
+
|
|
121
|
+
Full power via `ProxyConfig`:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from fastapi_proxykit import ProxyConfig, ProxyRoute, BreakerConfig, ObservabilityConfig, ClientConfig
|
|
125
|
+
|
|
126
|
+
config = ProxyConfig(
|
|
127
|
+
routes=[
|
|
128
|
+
ProxyRoute(
|
|
129
|
+
path_prefix="/api/v1/users",
|
|
130
|
+
target_base_url="https://users-service.internal",
|
|
131
|
+
strip_prefix=True,
|
|
132
|
+
breaker=BreakerConfig(failure_threshold=5, timeout=30),
|
|
133
|
+
include_in_openapi=True,
|
|
134
|
+
),
|
|
135
|
+
# ... more routes
|
|
136
|
+
],
|
|
137
|
+
observability=ObservabilityConfig(
|
|
138
|
+
tracer=your_tracer, # opentelemetry trace.get_tracer()
|
|
139
|
+
meter=your_meter, # opentelemetry metrics.get_meter()
|
|
140
|
+
logger=your_logger, # optional structlog / logging
|
|
141
|
+
),
|
|
142
|
+
client=ClientConfig(
|
|
143
|
+
timeout=15.0,
|
|
144
|
+
max_connections=200,
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Unified OpenAPI (recommended)
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
app = FastAPI(openapi_url=None, docs_url=None, redoc_url=None)
|
|
153
|
+
app.include_router(proxy_router(config))
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
→ All backend OpenAPI specs merged at `/docs` with prefixed paths.
|
|
157
|
+
|
|
158
|
+
## 📚 Examples
|
|
159
|
+
|
|
160
|
+
See the [`examples/`](./examples) folder:
|
|
161
|
+
|
|
162
|
+
- `api_gateway/` — Multi-service gateway with different breaker settings
|
|
163
|
+
- `legacy_facade/` — Modern prefix for legacy backend
|
|
164
|
+
- `multi_env/` — Route to dev/staging/prod based on env
|
|
165
|
+
|
|
166
|
+
Run any example:
|
|
167
|
+
```bash
|
|
168
|
+
uv run python -m uvicorn examples.api_gateway.main:app --reload --port 8000
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## 🤔 Why fastapi-proxykit? — Real Developer Benefits
|
|
172
|
+
|
|
173
|
+
Building proxy/routing logic in FastAPI often means repeating the same boilerplate — manual `httpx` calls, error handling, timeouts, resilience patterns, tracing, and fragmented docs. **fastapi-proxykit** eliminates this repetition with a **single, configurable, production-grade component**.
|
|
174
|
+
|
|
175
|
+
Here's how it directly benefits you as a developer:
|
|
176
|
+
|
|
177
|
+
- **Save hours (or days) of repetitive coding**
|
|
178
|
+
Instead of hand-writing proxy endpoints for every backend service (with custom path handling, headers forwarding, timeouts, etc.), you define routes declaratively once via `ProxyRoute`. Drop it in with `app.include_router(proxy_router(config))` — instant transparent proxying. No more duplicating `async def proxy_xxx(...)` functions.
|
|
179
|
+
|
|
180
|
+
- **Prevent cascading failures & protect your backends**
|
|
181
|
+
Per-route circuit breakers (`pybreaker`) isolate failures: if `/api/users` backend flakes out (e.g., 5 failures in a row), that route "opens" automatically — returning fast 503s instead of hanging clients or hammering the failing service. Other routes (e.g., `/api/orders`) keep working normally. This is huge for microservices/gateway patterns — no more "one slow service kills the whole app".
|
|
182
|
+
|
|
183
|
+
- **Debug & monitor like a pro — zero extra instrumentation**
|
|
184
|
+
Full OpenTelemetry integration (traces, metrics, optional structured logs) out-of-the-box. Inject your existing tracer/meter/logger — every proxied request gets spans with target URL, status, duration, errors, etc.
|
|
185
|
+
→ Quickly spot slow backends, high-latency routes, error spikes, or retry storms in production. No manual `@tracer.start_as_current_span()` everywhere.
|
|
186
|
+
|
|
187
|
+
- **Unified Swagger/OpenAPI docs — one `/docs` to rule them all**
|
|
188
|
+
Automatically fetches each backend's `/openapi.json`, prefixes paths (e.g., `/api/users/*` → shows as `/api/users/...` in UI), and merges into your app's docs.
|
|
189
|
+
→ Developers/consumers see a single, complete API surface instead of jumping between 5+ service docs. Great for internal APIs, partner integrations, or self-documenting gateways.
|
|
190
|
+
|
|
191
|
+
- **Scale confidently with non-blocking, pooled I/O**
|
|
192
|
+
Uses `httpx.AsyncClient` under the hood with configurable connection limits, timeouts, and pooling. Fully async — no thread blocking, supports high concurrency without spiking CPU/memory.
|
|
193
|
+
→ Your gateway stays responsive even under heavy load or when proxying many slow backends.
|
|
194
|
+
|
|
195
|
+
- **Consistent, client-friendly errors — no ugly 502s**
|
|
196
|
+
Structured JSON responses for failures:
|
|
197
|
+
```json
|
|
198
|
+
{"error": "circuit_breaker_open", "message": "Target service temporarily unavailable"}
|
|
199
|
+
```
|
|
200
|
+
or 504 on timeout. Easy for frontend/mobile clients to handle gracefully.
|
|
201
|
+
|
|
202
|
+
- **Clean separation for complex architectures**
|
|
203
|
+
Ideal for:
|
|
204
|
+
- **Microservices gateway** — route `/users`, `/orders`, `/payments` to isolated services with different resilience rules
|
|
205
|
+
- **Legacy modernization** — facade old APIs behind modern prefixes without rewriting clients
|
|
206
|
+
- **Multi-env routing** — `/dev/*` → dev cluster, `/prod/*` → production
|
|
207
|
+
- **Observability-first teams** — plug into existing OTEL collectors (Jaeger, Zipkin, Prometheus, etc.) without changing code
|
|
208
|
+
|
|
209
|
+
In short: **fastapi-proxykit** turns painful, error-prone proxy boilerplate into a **declarative, resilient, observable feature** — letting you focus on business logic instead of infrastructure plumbing.
|
|
210
|
+
|
|
211
|
+
Many FastAPI developers end up reinventing 80% of this themselves. With proxykit, you get it right the first time — resilient, observable, and maintainable.
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
| Without proxykit | With fastapi-proxykit |
|
|
216
|
+
|-------------------------------------------|------------------------------------------------|
|
|
217
|
+
| Manual httpx per endpoint | One config → all routes |
|
|
218
|
+
| No resilience → cascading failures | Per-route circuit breakers |
|
|
219
|
+
| Fragmented /docs per service | Merged, prefixed OpenAPI in single UI |
|
|
220
|
+
| Custom tracing boilerplate | Automatic OpenTelemetry spans & metrics |
|
|
221
|
+
| Risk of blocking I/O | Fully async + pooled connections |
|
|
222
|
+
|
|
223
|
+
## Contributing
|
|
224
|
+
|
|
225
|
+
Contributions welcome!
|
|
226
|
+
1. Fork the repo
|
|
227
|
+
2. Create feature branch (`git checkout -b feature/amazing-thing`)
|
|
228
|
+
3. Commit (`git commit -m 'Add amazing thing'`)
|
|
229
|
+
4. Push & open PR
|
|
230
|
+
|
|
231
|
+
## 📄 License
|
|
232
|
+
|
|
233
|
+
MIT License — see [`LICENSE`](./LICENSE)
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
<p align="center">
|
|
238
|
+
Made with ❤️ by <a href="https://github.com/satyamsoni2211">Satyam Soni</a> •
|
|
239
|
+
<a href="https://x.com/_satyamsoni_">@_satyamsoni_</a>
|
|
240
|
+
</p>
|
|
241
|
+
|
|
242
|
+
<p align="center">
|
|
243
|
+
<a href="https://github.com/satyamsoni2211/fastapi_proxykit/issues/new?labels=enhancement&title=Feature+request">Suggest Feature</a>
|
|
244
|
+
·
|
|
245
|
+
<a href="https://github.com/satyamsoni2211/fastapi_proxykit/issues/new?labels=bug&title=Bug">Report Bug</a>
|
|
246
|
+
</p>
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://img.shields.io/badge/Python-3.13+-3775A9?style=for-the-badge&logo=python&logoColor=white" alt="Python" />
|
|
3
|
+
<img src="https://img.shields.io/badge/FastAPI-0.115+-009688?style=for-the-badge&logo=fastapi&logoColor=white" alt="FastAPI" />
|
|
4
|
+
<img src="https://img.shields.io/github/license/satyamsoni2211/fastapi_proxykit?style=for-the-badge&color=green" alt="License" />
|
|
5
|
+
<img src="https://img.shields.io/github/stars/satyamsoni2211/fastapi_proxykit?style=for-the-badge&color=yellow" alt="Stars" />
|
|
6
|
+
<img src="https://img.shields.io/badge/Install%20from%20source-✓-success?style=for-the-badge" alt="Installable" />
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<h1 align="center">⚡ fastapi-proxykit</h1>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<b>Production-ready transparent proxy routes for FastAPI</b><br>
|
|
13
|
+
Turn your FastAPI app into a resilient API gateway with per-route circuit breakers, OpenTelemetry observability, and automatic OpenAPI merging — zero boilerplate.
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
<p align="center">
|
|
17
|
+
<a href="#-features">Features</a> •
|
|
18
|
+
<a href="#-quick-start">Quick Start</a> •
|
|
19
|
+
<a href="#-installation">Installation</a> •
|
|
20
|
+
<a href="#-configuration">Configuration</a> •
|
|
21
|
+
<a href="#-examples">Examples</a> •
|
|
22
|
+
<a href="#-why-use-it">Why?</a> •
|
|
23
|
+
<a href="#-license">License</a>
|
|
24
|
+
</p>
|
|
25
|
+
|
|
26
|
+
<div align="center">
|
|
27
|
+
<img src="https://via.placeholder.com/900x450/0d1117/58a6ff?text=fastapi-proxykit+in+action" alt="fastapi-proxykit architecture" width="900" />
|
|
28
|
+
<!-- Replace with real diagram later (e.g. Excalidraw: client → FastAPI → proxykit → multiple backends with traces & breakers) -->
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
## ✨ Features
|
|
32
|
+
|
|
33
|
+
- 🔀 **Transparent proxying** — preserve path, query params, headers automatically
|
|
34
|
+
- 🛡️ **Per-route circuit breakers** — isolated resilience with `pybreaker` (no cascading failures)
|
|
35
|
+
- 📊 **Full OpenTelemetry support** — tracing, metrics, custom tracer/meter injection
|
|
36
|
+
- 📖 **Automatic OpenAPI merging** — unified `/docs` from all backend services
|
|
37
|
+
- ⚡ **Non-blocking I/O** — `httpx.AsyncClient` with pooling & configurable limits
|
|
38
|
+
- 🧩 **Declarative config** — clean Pydantic-powered routes & settings
|
|
39
|
+
- 🛠 **Structured errors** — consistent JSON responses (503 breaker open, 504 timeout, etc.)
|
|
40
|
+
- 🔌 **Lifespan-aware** — client auto cleanup on shutdown
|
|
41
|
+
- 🆓 **Zero external agents** required for observability
|
|
42
|
+
|
|
43
|
+
## 🚀 Quick Start
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# Clone & install from source
|
|
47
|
+
git clone https://github.com/satyamsoni2211/fastapi_proxykit.git
|
|
48
|
+
cd fastapi_proxykit
|
|
49
|
+
pip install . # or: uv pip install .
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from fastapi import FastAPI
|
|
54
|
+
from fastapi_proxykit import proxy_router, ProxyConfig, ProxyRoute, BreakerConfig
|
|
55
|
+
|
|
56
|
+
app = FastAPI()
|
|
57
|
+
|
|
58
|
+
app.include_router(
|
|
59
|
+
proxy_router(
|
|
60
|
+
ProxyConfig(
|
|
61
|
+
routes=[
|
|
62
|
+
ProxyRoute(
|
|
63
|
+
path_prefix="/api/users",
|
|
64
|
+
target_base_url="https://users.example.com",
|
|
65
|
+
breaker=BreakerConfig(failure_threshold=5, timeout=30),
|
|
66
|
+
strip_prefix=True,
|
|
67
|
+
),
|
|
68
|
+
ProxyRoute(
|
|
69
|
+
path_prefix="/api/orders",
|
|
70
|
+
target_base_url="https://orders.example.com",
|
|
71
|
+
breaker=BreakerConfig(failure_threshold=3, timeout=15),
|
|
72
|
+
),
|
|
73
|
+
]
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
→ `GET /api/users/42` proxies to `https://users.example.com/42`
|
|
80
|
+
|
|
81
|
+
## 📦 Installation
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# From source (recommended for now)
|
|
85
|
+
pip install git+https://github.com/satyamsoni2211/fastapi_proxykit.git
|
|
86
|
+
# or clone & install locally
|
|
87
|
+
git clone https://github.com/satyamsoni2211/fastapi_proxykit.git
|
|
88
|
+
cd fastapi_proxykit
|
|
89
|
+
pip install .
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Requirements**: Python 3.13+
|
|
93
|
+
|
|
94
|
+
## ⚙️ Configuration
|
|
95
|
+
|
|
96
|
+
Full power via `ProxyConfig`:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from fastapi_proxykit import ProxyConfig, ProxyRoute, BreakerConfig, ObservabilityConfig, ClientConfig
|
|
100
|
+
|
|
101
|
+
config = ProxyConfig(
|
|
102
|
+
routes=[
|
|
103
|
+
ProxyRoute(
|
|
104
|
+
path_prefix="/api/v1/users",
|
|
105
|
+
target_base_url="https://users-service.internal",
|
|
106
|
+
strip_prefix=True,
|
|
107
|
+
breaker=BreakerConfig(failure_threshold=5, timeout=30),
|
|
108
|
+
include_in_openapi=True,
|
|
109
|
+
),
|
|
110
|
+
# ... more routes
|
|
111
|
+
],
|
|
112
|
+
observability=ObservabilityConfig(
|
|
113
|
+
tracer=your_tracer, # opentelemetry trace.get_tracer()
|
|
114
|
+
meter=your_meter, # opentelemetry metrics.get_meter()
|
|
115
|
+
logger=your_logger, # optional structlog / logging
|
|
116
|
+
),
|
|
117
|
+
client=ClientConfig(
|
|
118
|
+
timeout=15.0,
|
|
119
|
+
max_connections=200,
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Unified OpenAPI (recommended)
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
app = FastAPI(openapi_url=None, docs_url=None, redoc_url=None)
|
|
128
|
+
app.include_router(proxy_router(config))
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
→ All backend OpenAPI specs merged at `/docs` with prefixed paths.
|
|
132
|
+
|
|
133
|
+
## 📚 Examples
|
|
134
|
+
|
|
135
|
+
See the [`examples/`](./examples) folder:
|
|
136
|
+
|
|
137
|
+
- `api_gateway/` — Multi-service gateway with different breaker settings
|
|
138
|
+
- `legacy_facade/` — Modern prefix for legacy backend
|
|
139
|
+
- `multi_env/` — Route to dev/staging/prod based on env
|
|
140
|
+
|
|
141
|
+
Run any example:
|
|
142
|
+
```bash
|
|
143
|
+
uv run python -m uvicorn examples.api_gateway.main:app --reload --port 8000
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## 🤔 Why fastapi-proxykit? — Real Developer Benefits
|
|
147
|
+
|
|
148
|
+
Building proxy/routing logic in FastAPI often means repeating the same boilerplate — manual `httpx` calls, error handling, timeouts, resilience patterns, tracing, and fragmented docs. **fastapi-proxykit** eliminates this repetition with a **single, configurable, production-grade component**.
|
|
149
|
+
|
|
150
|
+
Here's how it directly benefits you as a developer:
|
|
151
|
+
|
|
152
|
+
- **Save hours (or days) of repetitive coding**
|
|
153
|
+
Instead of hand-writing proxy endpoints for every backend service (with custom path handling, headers forwarding, timeouts, etc.), you define routes declaratively once via `ProxyRoute`. Drop it in with `app.include_router(proxy_router(config))` — instant transparent proxying. No more duplicating `async def proxy_xxx(...)` functions.
|
|
154
|
+
|
|
155
|
+
- **Prevent cascading failures & protect your backends**
|
|
156
|
+
Per-route circuit breakers (`pybreaker`) isolate failures: if `/api/users` backend flakes out (e.g., 5 failures in a row), that route "opens" automatically — returning fast 503s instead of hanging clients or hammering the failing service. Other routes (e.g., `/api/orders`) keep working normally. This is huge for microservices/gateway patterns — no more "one slow service kills the whole app".
|
|
157
|
+
|
|
158
|
+
- **Debug & monitor like a pro — zero extra instrumentation**
|
|
159
|
+
Full OpenTelemetry integration (traces, metrics, optional structured logs) out-of-the-box. Inject your existing tracer/meter/logger — every proxied request gets spans with target URL, status, duration, errors, etc.
|
|
160
|
+
→ Quickly spot slow backends, high-latency routes, error spikes, or retry storms in production. No manual `@tracer.start_as_current_span()` everywhere.
|
|
161
|
+
|
|
162
|
+
- **Unified Swagger/OpenAPI docs — one `/docs` to rule them all**
|
|
163
|
+
Automatically fetches each backend's `/openapi.json`, prefixes paths (e.g., `/api/users/*` → shows as `/api/users/...` in UI), and merges into your app's docs.
|
|
164
|
+
→ Developers/consumers see a single, complete API surface instead of jumping between 5+ service docs. Great for internal APIs, partner integrations, or self-documenting gateways.
|
|
165
|
+
|
|
166
|
+
- **Scale confidently with non-blocking, pooled I/O**
|
|
167
|
+
Uses `httpx.AsyncClient` under the hood with configurable connection limits, timeouts, and pooling. Fully async — no thread blocking, supports high concurrency without spiking CPU/memory.
|
|
168
|
+
→ Your gateway stays responsive even under heavy load or when proxying many slow backends.
|
|
169
|
+
|
|
170
|
+
- **Consistent, client-friendly errors — no ugly 502s**
|
|
171
|
+
Structured JSON responses for failures:
|
|
172
|
+
```json
|
|
173
|
+
{"error": "circuit_breaker_open", "message": "Target service temporarily unavailable"}
|
|
174
|
+
```
|
|
175
|
+
or 504 on timeout. Easy for frontend/mobile clients to handle gracefully.
|
|
176
|
+
|
|
177
|
+
- **Clean separation for complex architectures**
|
|
178
|
+
Ideal for:
|
|
179
|
+
- **Microservices gateway** — route `/users`, `/orders`, `/payments` to isolated services with different resilience rules
|
|
180
|
+
- **Legacy modernization** — facade old APIs behind modern prefixes without rewriting clients
|
|
181
|
+
- **Multi-env routing** — `/dev/*` → dev cluster, `/prod/*` → production
|
|
182
|
+
- **Observability-first teams** — plug into existing OTEL collectors (Jaeger, Zipkin, Prometheus, etc.) without changing code
|
|
183
|
+
|
|
184
|
+
In short: **fastapi-proxykit** turns painful, error-prone proxy boilerplate into a **declarative, resilient, observable feature** — letting you focus on business logic instead of infrastructure plumbing.
|
|
185
|
+
|
|
186
|
+
Many FastAPI developers end up reinventing 80% of this themselves. With proxykit, you get it right the first time — resilient, observable, and maintainable.
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
| Without proxykit | With fastapi-proxykit |
|
|
191
|
+
|-------------------------------------------|------------------------------------------------|
|
|
192
|
+
| Manual httpx per endpoint | One config → all routes |
|
|
193
|
+
| No resilience → cascading failures | Per-route circuit breakers |
|
|
194
|
+
| Fragmented /docs per service | Merged, prefixed OpenAPI in single UI |
|
|
195
|
+
| Custom tracing boilerplate | Automatic OpenTelemetry spans & metrics |
|
|
196
|
+
| Risk of blocking I/O | Fully async + pooled connections |
|
|
197
|
+
|
|
198
|
+
## Contributing
|
|
199
|
+
|
|
200
|
+
Contributions welcome!
|
|
201
|
+
1. Fork the repo
|
|
202
|
+
2. Create feature branch (`git checkout -b feature/amazing-thing`)
|
|
203
|
+
3. Commit (`git commit -m 'Add amazing thing'`)
|
|
204
|
+
4. Push & open PR
|
|
205
|
+
|
|
206
|
+
## 📄 License
|
|
207
|
+
|
|
208
|
+
MIT License — see [`LICENSE`](./LICENSE)
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
<p align="center">
|
|
213
|
+
Made with ❤️ by <a href="https://github.com/satyamsoni2211">Satyam Soni</a> •
|
|
214
|
+
<a href="https://x.com/_satyamsoni_">@_satyamsoni_</a>
|
|
215
|
+
</p>
|
|
216
|
+
|
|
217
|
+
<p align="center">
|
|
218
|
+
<a href="https://github.com/satyamsoni2211/fastapi_proxykit/issues/new?labels=enhancement&title=Feature+request">Suggest Feature</a>
|
|
219
|
+
·
|
|
220
|
+
<a href="https://github.com/satyamsoni2211/fastapi_proxykit/issues/new?labels=bug&title=Bug">Report Bug</a>
|
|
221
|
+
</p>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fastapi-proxykit"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A production-ready FastAPI plugin for transparent HTTP proxying with per-route circuit breakers and OpenTelemetry observability."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = {text = "MIT"}
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Satyam Soni", email = "satyam_soni1@epam.com" }
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.13"
|
|
11
|
+
keywords = ["fastapi", "proxy", "http-proxy", "circuit-breaker", "opentelemetry", "resilience"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Framework :: FastAPI",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Topic :: System :: Networking",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"fastapi>=0.135.1",
|
|
23
|
+
"httpx>=0.28.1",
|
|
24
|
+
"opentelemetry-api>=1.40.0",
|
|
25
|
+
"opentelemetry-instrumentation-httpx>=0.61b0",
|
|
26
|
+
"opentelemetry-sdk>=1.40.0",
|
|
27
|
+
"pybreaker>=1.4.1",
|
|
28
|
+
"structlog>=24.0.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["uv_build>=0.9.5,<0.10.0"]
|
|
33
|
+
build-backend = "uv_build"
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = [
|
|
37
|
+
"pytest>=9.0.2",
|
|
38
|
+
"pytest-asyncio>=1.3.0",
|
|
39
|
+
"pytest-httpserver>=1.1.5",
|
|
40
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from fastapi_proxykit.router import proxy_router
|
|
2
|
+
from fastapi_proxykit.models import ProxyConfig, ProxyRoute, BreakerConfig, ObservabilityConfig, ClientConfig
|
|
3
|
+
from fastapi_proxykit.errors import ProxyErrorResponse
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"proxy_router",
|
|
7
|
+
"ProxyConfig",
|
|
8
|
+
"ProxyRoute",
|
|
9
|
+
"BreakerConfig",
|
|
10
|
+
"ObservabilityConfig",
|
|
11
|
+
"ClientConfig",
|
|
12
|
+
"ProxyErrorResponse",
|
|
13
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
|
|
3
|
+
import pybreaker
|
|
4
|
+
|
|
5
|
+
from fastapi_proxykit.models import BreakerConfig
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@functools.lru_cache(maxsize=None)
|
|
9
|
+
def _create_breaker_cached(
|
|
10
|
+
route_name: str, failure_threshold: int, timeout: int
|
|
11
|
+
) -> pybreaker.CircuitBreaker:
|
|
12
|
+
"""Cached internal factory — one breaker instance per (route_name, failure_threshold, timeout)."""
|
|
13
|
+
breaker = pybreaker.CircuitBreaker(
|
|
14
|
+
name=route_name,
|
|
15
|
+
fail_max=failure_threshold,
|
|
16
|
+
reset_timeout=timeout,
|
|
17
|
+
exclude=[pybreaker.CircuitBreakerError],
|
|
18
|
+
)
|
|
19
|
+
return breaker
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def create_breaker(route_name: str, config: BreakerConfig) -> pybreaker.CircuitBreaker:
|
|
23
|
+
"""Create (or return cached) a pybreaker circuit breaker for a given route."""
|
|
24
|
+
return _create_breaker_cached(route_name, config.failure_threshold, config.timeout)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
|
3
|
+
|
|
4
|
+
from fastapi_proxykit.models import ClientConfig
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def create_http_client(config: ClientConfig, tracer_provider=None) -> httpx.AsyncClient:
|
|
8
|
+
"""Create a shared httpx.AsyncClient with connection pooling and optional OTel instrumentation."""
|
|
9
|
+
client = httpx.AsyncClient(
|
|
10
|
+
timeout=httpx.Timeout(config.timeout),
|
|
11
|
+
limits=httpx.Limits(
|
|
12
|
+
max_connections=config.max_connections,
|
|
13
|
+
max_keepalive_connections=config.max_connections,
|
|
14
|
+
),
|
|
15
|
+
)
|
|
16
|
+
if tracer_provider:
|
|
17
|
+
HTTPXClientInstrumentor.instrument_client(
|
|
18
|
+
client=client, tracer_provider=tracer_provider
|
|
19
|
+
)
|
|
20
|
+
return client
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class BreakerConfig(BaseModel):
|
|
6
|
+
failure_threshold: int = Field(default=5, ge=1, description="Failures before opening circuit")
|
|
7
|
+
timeout: int = Field(default=30, ge=1, description="Seconds before transitioning from open to half-open")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ObservabilityConfig(BaseModel):
|
|
11
|
+
tracer: Optional[object] = Field(default=None, description="OpenTelemetry tracer")
|
|
12
|
+
meter: Optional[object] = Field(default=None, description="OpenTelemetry meter")
|
|
13
|
+
logger: Optional[object] = Field(default=None, description="standard logger")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ClientConfig(BaseModel):
|
|
17
|
+
timeout: float = Field(default=10.0, gt=0)
|
|
18
|
+
max_connections: int = Field(default=100, ge=1)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ProxyRoute(BaseModel):
|
|
22
|
+
path_prefix: str = Field(description="Route path prefix, e.g. /api/users")
|
|
23
|
+
target_base_url: str = Field(description="Target base URL, e.g. https://users.example.com")
|
|
24
|
+
breaker: BreakerConfig = Field(default_factory=BreakerConfig)
|
|
25
|
+
strip_prefix: bool = Field(
|
|
26
|
+
default=False,
|
|
27
|
+
description="Strip path_prefix from forwarded URL (default: forward as-is)",
|
|
28
|
+
)
|
|
29
|
+
openapi_url: Optional[str] = Field(
|
|
30
|
+
default=None,
|
|
31
|
+
description="Override URL for target's OpenAPI spec. "
|
|
32
|
+
"Defaults to {target_base_url}/openapi.json",
|
|
33
|
+
)
|
|
34
|
+
include_in_openapi: bool = Field(
|
|
35
|
+
default=True,
|
|
36
|
+
description="Include this route's target OpenAPI paths in the proxy's /docs",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ProxyConfig(BaseModel):
|
|
41
|
+
routes: list[ProxyRoute] = Field(min_length=1)
|
|
42
|
+
observability: ObservabilityConfig = Field(default_factory=ObservabilityConfig)
|
|
43
|
+
client: ClientConfig = Field(default_factory=ClientConfig)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import structlog
|
|
3
|
+
|
|
4
|
+
logger = structlog.get_logger()
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def fetch_target_openapi(
|
|
8
|
+
target_base_url: str,
|
|
9
|
+
explicit_url: str | None,
|
|
10
|
+
timeout: float = 5.0,
|
|
11
|
+
) -> dict | None:
|
|
12
|
+
"""
|
|
13
|
+
Fetch OpenAPI spec from a target service.
|
|
14
|
+
|
|
15
|
+
If explicit_url is provided, use it. Otherwise, construct
|
|
16
|
+
{target_base_url.rstrip('/')}/openapi.json.
|
|
17
|
+
|
|
18
|
+
Returns None if the fetch fails or the response is not valid JSON.
|
|
19
|
+
"""
|
|
20
|
+
url = explicit_url or f"{target_base_url.rstrip('/')}/openapi.json"
|
|
21
|
+
try:
|
|
22
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
23
|
+
response = await client.get(url)
|
|
24
|
+
response.raise_for_status()
|
|
25
|
+
return response.json()
|
|
26
|
+
except Exception as exc:
|
|
27
|
+
logger.warning(
|
|
28
|
+
"proxy.openapi.fetch_failed",
|
|
29
|
+
target_base_url=target_base_url,
|
|
30
|
+
openapi_url=url,
|
|
31
|
+
exc=exc,
|
|
32
|
+
)
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def merge_openapi_schemas(
|
|
37
|
+
proxy_spec: dict,
|
|
38
|
+
target_specs: list[dict],
|
|
39
|
+
path_prefix: str,
|
|
40
|
+
) -> dict:
|
|
41
|
+
"""
|
|
42
|
+
Merge target OpenAPI paths into the proxy's OpenAPI schema.
|
|
43
|
+
|
|
44
|
+
For each path in target_specs:
|
|
45
|
+
- If the path starts with the last segment of path_prefix (e.g., "/users" for prefix "/api/users"),
|
|
46
|
+
replace that segment with path_prefix (e.g., "/users" → "/api/users", "/users/{id}" → "/api/users/{id}")
|
|
47
|
+
- Otherwise, prepend path_prefix to the path
|
|
48
|
+
- Deduplicate: if a path already exists in proxy_spec, skip it
|
|
49
|
+
- Copy only the path item (no component/schema resolution — out of scope)
|
|
50
|
+
|
|
51
|
+
Returns the merged spec dict.
|
|
52
|
+
"""
|
|
53
|
+
result = {
|
|
54
|
+
"openapi": proxy_spec.get("openapi", "3.1.0"),
|
|
55
|
+
"info": proxy_spec.get("info", {"title": "Proxy API", "version": "1.0.0"}),
|
|
56
|
+
"paths": dict(proxy_spec.get("paths", {})),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Get the last segment of path_prefix for substitution matching
|
|
60
|
+
prefix_segments = path_prefix.strip("/").split("/")
|
|
61
|
+
last_prefix_segment = prefix_segments[-1] if prefix_segments else ""
|
|
62
|
+
|
|
63
|
+
for spec in target_specs:
|
|
64
|
+
if not spec:
|
|
65
|
+
logger.debug("proxy.openapi.skipping_empty_spec")
|
|
66
|
+
continue
|
|
67
|
+
target_paths = spec.get("paths", {})
|
|
68
|
+
for path, path_item in target_paths.items():
|
|
69
|
+
# Check if path starts with the last segment of prefix (for substitution)
|
|
70
|
+
if last_prefix_segment and path.startswith(f"/{last_prefix_segment}"):
|
|
71
|
+
# Replace the first occurrence of /{last_segment} with path_prefix
|
|
72
|
+
rest_of_path = path[len(f"/{last_prefix_segment}"):]
|
|
73
|
+
prefixed = path_prefix.rstrip("/") + rest_of_path
|
|
74
|
+
else:
|
|
75
|
+
# Otherwise, simply prepend the prefix
|
|
76
|
+
prefixed = f"{path_prefix.rstrip('/')}/{path.lstrip('/')}"
|
|
77
|
+
prefixed = "/" + prefixed.strip("/")
|
|
78
|
+
if prefixed not in result["paths"]:
|
|
79
|
+
result["paths"][prefixed] = path_item
|
|
80
|
+
logger.debug("proxy.openapi.path_merged", path=prefixed)
|
|
81
|
+
|
|
82
|
+
return result
|
|
File without changes
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
from contextlib import asynccontextmanager
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Request, Response
|
|
4
|
+
import httpx
|
|
5
|
+
import pybreaker
|
|
6
|
+
import time
|
|
7
|
+
import structlog
|
|
8
|
+
from opentelemetry.trace import StatusCode
|
|
9
|
+
from opentelemetry import metrics
|
|
10
|
+
|
|
11
|
+
from fastapi_proxykit.models import ProxyConfig, ProxyRoute
|
|
12
|
+
from fastapi_proxykit.breaker import create_breaker
|
|
13
|
+
from fastapi_proxykit.client import create_http_client
|
|
14
|
+
from fastapi_proxykit.errors import ProxyErrorResponse
|
|
15
|
+
from fastapi_proxykit.openapi import fetch_target_openapi, merge_openapi_schemas
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def proxy_router(config: ProxyConfig) -> APIRouter:
|
|
19
|
+
"""Create a pluggable proxy router for a FastAPI app."""
|
|
20
|
+
tracer = config.observability.tracer
|
|
21
|
+
http_client = create_http_client(config.client, tracer_provider=tracer)
|
|
22
|
+
|
|
23
|
+
@asynccontextmanager
|
|
24
|
+
async def lifespan(app: APIRouter):
|
|
25
|
+
yield
|
|
26
|
+
await http_client.aclose()
|
|
27
|
+
|
|
28
|
+
router = APIRouter(lifespan=lifespan)
|
|
29
|
+
|
|
30
|
+
route_map: dict[str, ProxyRoute] = {r.path_prefix: r for r in config.routes}
|
|
31
|
+
breakers: dict[str, pybreaker.CircuitBreaker] = {
|
|
32
|
+
r.path_prefix: create_breaker(r.path_prefix, r.breaker) for r in config.routes
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async def _build_merged_openapi() -> dict:
|
|
36
|
+
"""Fetch and merge all target OpenAPI specs."""
|
|
37
|
+
merged = {
|
|
38
|
+
"openapi": "3.1.0",
|
|
39
|
+
"info": {"title": "Proxy API", "version": "1.0.0"},
|
|
40
|
+
"paths": {},
|
|
41
|
+
}
|
|
42
|
+
for route in config.routes:
|
|
43
|
+
if not route.include_in_openapi:
|
|
44
|
+
continue
|
|
45
|
+
openapi_fetch_url = route.openapi_url or f"{route.target_base_url}/openapi.json"
|
|
46
|
+
spec = await fetch_target_openapi(route.target_base_url, openapi_fetch_url)
|
|
47
|
+
if spec:
|
|
48
|
+
merged = merge_openapi_schemas(merged, [spec], route.path_prefix)
|
|
49
|
+
else:
|
|
50
|
+
logger.warning(
|
|
51
|
+
"proxy.openapi.skipping_route",
|
|
52
|
+
route=route.path_prefix,
|
|
53
|
+
reason="fetch_failed",
|
|
54
|
+
)
|
|
55
|
+
return merged
|
|
56
|
+
|
|
57
|
+
# Lazily built and cached merged spec
|
|
58
|
+
_cached_openapi: dict | None = None
|
|
59
|
+
|
|
60
|
+
@router.get("/openapi.json", include_in_schema=False)
|
|
61
|
+
async def get_openapi(request: Request) -> dict:
|
|
62
|
+
nonlocal _cached_openapi
|
|
63
|
+
if _cached_openapi is None:
|
|
64
|
+
_cached_openapi = await _build_merged_openapi()
|
|
65
|
+
return _cached_openapi
|
|
66
|
+
|
|
67
|
+
@router.get("/docs", include_in_schema=False)
|
|
68
|
+
async def get_docs():
|
|
69
|
+
from fastapi.responses import RedirectResponse
|
|
70
|
+
return RedirectResponse(url="/docs")
|
|
71
|
+
|
|
72
|
+
@router.get("/redoc", include_in_schema=False)
|
|
73
|
+
async def get_redoc():
|
|
74
|
+
from fastapi.responses import RedirectResponse
|
|
75
|
+
return RedirectResponse(url="/redoc")
|
|
76
|
+
|
|
77
|
+
# Observability instruments
|
|
78
|
+
meter = config.observability.meter
|
|
79
|
+
request_counter = None
|
|
80
|
+
request_latency = None
|
|
81
|
+
if meter:
|
|
82
|
+
request_counter = meter.create_counter(
|
|
83
|
+
name="proxy.requests",
|
|
84
|
+
description="Total proxy requests",
|
|
85
|
+
unit="1",
|
|
86
|
+
)
|
|
87
|
+
request_latency = meter.create_histogram(
|
|
88
|
+
name="proxy.request.duration",
|
|
89
|
+
description="Proxy request duration in seconds",
|
|
90
|
+
unit="s",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
logger = config.observability.logger or structlog.get_logger()
|
|
94
|
+
|
|
95
|
+
async def _make_request(
|
|
96
|
+
method: str, target_url: str, request: Request
|
|
97
|
+
) -> httpx.Response:
|
|
98
|
+
"""Bare HTTP request — called inside breaker.call() for circuit management."""
|
|
99
|
+
resp = await http_client.request(
|
|
100
|
+
method=method,
|
|
101
|
+
url=target_url,
|
|
102
|
+
headers=dict(request.headers),
|
|
103
|
+
content=await request.body(),
|
|
104
|
+
params=request.query_params,
|
|
105
|
+
)
|
|
106
|
+
return resp
|
|
107
|
+
|
|
108
|
+
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
|
109
|
+
async def proxy_request(path: str, request: Request) -> Response:
|
|
110
|
+
# Longest-prefix match with normalized slashes
|
|
111
|
+
matched_route: ProxyRoute | None = None
|
|
112
|
+
matched_prefix: str | None = None
|
|
113
|
+
for prefix in route_map:
|
|
114
|
+
normalized_prefix = prefix if prefix.startswith("/") else "/" + prefix
|
|
115
|
+
normalized_path = "/" + path
|
|
116
|
+
if normalized_path.startswith(normalized_prefix):
|
|
117
|
+
if matched_prefix is None or len(prefix) > len(matched_prefix):
|
|
118
|
+
matched_prefix = prefix
|
|
119
|
+
matched_route = route_map[prefix]
|
|
120
|
+
|
|
121
|
+
if matched_route is None:
|
|
122
|
+
return Response(status_code=404, content="No matching route")
|
|
123
|
+
|
|
124
|
+
# Strip the matched prefix from the path to get the actual target path
|
|
125
|
+
if matched_route.strip_prefix:
|
|
126
|
+
prefix_len = len(matched_route.path_prefix)
|
|
127
|
+
if not matched_route.path_prefix.startswith("/"):
|
|
128
|
+
prefix_len -= 1
|
|
129
|
+
remaining_path = path[prefix_len:] if len(path) > prefix_len else ""
|
|
130
|
+
else:
|
|
131
|
+
remaining_path = path
|
|
132
|
+
target_url = f"{matched_route.target_base_url.rstrip('/')}/{remaining_path.lstrip('/')}"
|
|
133
|
+
breaker = breakers[matched_route.path_prefix]
|
|
134
|
+
|
|
135
|
+
span = None
|
|
136
|
+
if tracer:
|
|
137
|
+
span = tracer.start_span(f"proxy/{matched_route.path_prefix}")
|
|
138
|
+
span.set_attribute("route", matched_route.path_prefix)
|
|
139
|
+
span.set_attribute("target_url", target_url)
|
|
140
|
+
|
|
141
|
+
start_time = time.perf_counter()
|
|
142
|
+
try:
|
|
143
|
+
resp = await breaker.call(_make_request, request.method, target_url, request)
|
|
144
|
+
duration = time.perf_counter() - start_time
|
|
145
|
+
result = Response(
|
|
146
|
+
content=resp.content,
|
|
147
|
+
status_code=resp.status_code,
|
|
148
|
+
headers=dict(resp.headers),
|
|
149
|
+
)
|
|
150
|
+
if span:
|
|
151
|
+
span.set_attribute("http.status_code", result.status_code)
|
|
152
|
+
|
|
153
|
+
if request_counter:
|
|
154
|
+
request_counter.add(1, {"route": matched_route.path_prefix, "status": str(result.status_code)})
|
|
155
|
+
if request_latency:
|
|
156
|
+
request_latency.record(duration, {"route": matched_route.path_prefix})
|
|
157
|
+
|
|
158
|
+
logger.info(
|
|
159
|
+
"proxy.request.forwarded",
|
|
160
|
+
route=matched_route.path_prefix,
|
|
161
|
+
method=request.method,
|
|
162
|
+
path=path,
|
|
163
|
+
status_code=result.status_code,
|
|
164
|
+
duration_ms=round(duration * 1000, 2),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
return result
|
|
168
|
+
except pybreaker.CircuitBreakerError as exc:
|
|
169
|
+
duration = time.perf_counter() - start_time
|
|
170
|
+
if span:
|
|
171
|
+
span.set_attribute("http.status_code", 503)
|
|
172
|
+
span.record_exception(exc)
|
|
173
|
+
span.set_status(StatusCode.ERROR, str(exc))
|
|
174
|
+
logger.error("proxy.circuit_breaker.open", route=matched_route.path_prefix)
|
|
175
|
+
return Response(
|
|
176
|
+
status_code=503,
|
|
177
|
+
content=ProxyErrorResponse(
|
|
178
|
+
error="circuit_breaker_open",
|
|
179
|
+
message="Target service unavailable",
|
|
180
|
+
route=matched_route.path_prefix,
|
|
181
|
+
).model_dump_json(),
|
|
182
|
+
media_type="application/json",
|
|
183
|
+
)
|
|
184
|
+
except httpx.TimeoutException as exc:
|
|
185
|
+
breaker.increment_failure()
|
|
186
|
+
if span:
|
|
187
|
+
span.record_exception(exc)
|
|
188
|
+
span.set_status(StatusCode.ERROR, str(exc))
|
|
189
|
+
logger.error("proxy.timeout", route=matched_route.path_prefix, exc=exc)
|
|
190
|
+
return Response(
|
|
191
|
+
status_code=504,
|
|
192
|
+
content=ProxyErrorResponse(
|
|
193
|
+
error="timeout",
|
|
194
|
+
message="Gateway timeout",
|
|
195
|
+
route=matched_route.path_prefix,
|
|
196
|
+
).model_dump_json(),
|
|
197
|
+
media_type="application/json",
|
|
198
|
+
)
|
|
199
|
+
except Exception as exc:
|
|
200
|
+
breaker.increment_failure()
|
|
201
|
+
if span:
|
|
202
|
+
span.record_exception(exc)
|
|
203
|
+
span.set_status(StatusCode.ERROR, str(exc))
|
|
204
|
+
logger.error("proxy.error", route=matched_route.path_prefix, exc=exc)
|
|
205
|
+
return Response(
|
|
206
|
+
status_code=503,
|
|
207
|
+
content=ProxyErrorResponse(
|
|
208
|
+
error="connection_error",
|
|
209
|
+
message="Target service unavailable",
|
|
210
|
+
route=matched_route.path_prefix,
|
|
211
|
+
).model_dump_json(),
|
|
212
|
+
media_type="application/json",
|
|
213
|
+
)
|
|
214
|
+
finally:
|
|
215
|
+
if span:
|
|
216
|
+
span.end()
|
|
217
|
+
|
|
218
|
+
return router
|