fastapi-proxykit 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.
@@ -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,7 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class ProxyErrorResponse(BaseModel):
5
+ error: str
6
+ message: str
7
+ route: str
@@ -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
@@ -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,11 @@
1
+ fastapi_proxykit/__init__.py,sha256=S5mcqjIzsFnJ0opam0LP4J21YqfqCqjeA8x8EKl2_oQ,380
2
+ fastapi_proxykit/breaker.py,sha256=CJWuEG-bWXYClIZzdXCyRDTuw08C0M9B5xsHy88z980,812
3
+ fastapi_proxykit/client.py,sha256=Mg2PSATe4XJqvbzRToy8707zFTCs9xRMdP0gojSYFe8,730
4
+ fastapi_proxykit/errors.py,sha256=TrgDG2Ol-M1GP8qFzMCVnTjBtJ_7PYmpPmBQqP1OM6I,117
5
+ fastapi_proxykit/models.py,sha256=4F4PUP-SJy7CPLeoJgJFcugYKsmQDNtvyalE-79UDGA,1732
6
+ fastapi_proxykit/openapi.py,sha256=XNjdnVkit32cyIpSAG8tra-ryEqfaqMyM3r6VGZUNZU,3038
7
+ fastapi_proxykit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ fastapi_proxykit/router.py,sha256=cMhvqMRk7hTDfZXNjqDqqF3IWGJYYcejflJs9FL1qU0,8532
9
+ fastapi_proxykit-0.1.0.dist-info/WHEEL,sha256=9sjN42GvvIkyGb9JrWAWXnA96E2dxDe0tzHzrLxUlD4,81
10
+ fastapi_proxykit-0.1.0.dist-info/METADATA,sha256=4Mt7H8uo2f7Hyn1U-T0Ib2Hay5_pMfF4njTiZnGcZeA,11120
11
+ fastapi_proxykit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.10.11
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any