fastapi-reverse-proxy 0.1.1__tar.gz → 0.3.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_reverse_proxy-0.1.1/src/fastapi_reverse_proxy.egg-info → fastapi_reverse_proxy-0.3.0}/PKG-INFO +37 -25
- {fastapi_reverse_proxy-0.1.1 → fastapi_reverse_proxy-0.3.0}/README.md +36 -24
- {fastapi_reverse_proxy-0.1.1 → fastapi_reverse_proxy-0.3.0}/pyproject.toml +1 -1
- {fastapi_reverse_proxy-0.1.1 → fastapi_reverse_proxy-0.3.0}/src/fastapi_reverse_proxy/__init__.py +2 -1
- {fastapi_reverse_proxy-0.1.1 → fastapi_reverse_proxy-0.3.0}/src/fastapi_reverse_proxy/load_balance.py +3 -3
- fastapi_reverse_proxy-0.3.0/src/fastapi_reverse_proxy/proxy_httpx.py +30 -0
- {fastapi_reverse_proxy-0.1.1 → fastapi_reverse_proxy-0.3.0}/src/fastapi_reverse_proxy/proxy_pass.py +46 -13
- {fastapi_reverse_proxy-0.1.1 → fastapi_reverse_proxy-0.3.0/src/fastapi_reverse_proxy.egg-info}/PKG-INFO +37 -25
- fastapi_reverse_proxy-0.1.1/src/fastapi_reverse_proxy/proxy_httpx.py +0 -15
- {fastapi_reverse_proxy-0.1.1 → fastapi_reverse_proxy-0.3.0}/LICENSE +0 -0
- {fastapi_reverse_proxy-0.1.1 → fastapi_reverse_proxy-0.3.0}/setup.cfg +0 -0
- {fastapi_reverse_proxy-0.1.1 → fastapi_reverse_proxy-0.3.0}/src/fastapi_reverse_proxy/health_check.py +0 -0
- {fastapi_reverse_proxy-0.1.1 → fastapi_reverse_proxy-0.3.0}/src/fastapi_reverse_proxy.egg-info/SOURCES.txt +0 -0
- {fastapi_reverse_proxy-0.1.1 → fastapi_reverse_proxy-0.3.0}/src/fastapi_reverse_proxy.egg-info/dependency_links.txt +0 -0
- {fastapi_reverse_proxy-0.1.1 → fastapi_reverse_proxy-0.3.0}/src/fastapi_reverse_proxy.egg-info/requires.txt +0 -0
- {fastapi_reverse_proxy-0.1.1 → fastapi_reverse_proxy-0.3.0}/src/fastapi_reverse_proxy.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-reverse-proxy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A robust, streaming-capable reverse proxy for FastAPI including WebSocket support.
|
|
5
5
|
Author-email: Tomás <tomas@suricatingss.xyz>
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -29,58 +29,69 @@ A robust, streaming-capable reverse proxy for FastAPI/Starlette with built-in **
|
|
|
29
29
|
|
|
30
30
|
## Features
|
|
31
31
|
|
|
32
|
-
- **
|
|
32
|
+
- **Async**: Async by default.
|
|
33
|
+
- **Httpx Pool**: Async HTTPX Pool for proxying.
|
|
34
|
+
- **Streaming Ready**: Handles SSE (Server-Sent Events) and large payloads (such as big files) while keeping RAM usage low.
|
|
33
35
|
- **WebSocket Support**: Seamless bidirectional tunneling with automated subprotocol negotiation.
|
|
34
36
|
- **Unified Load Balancing**: Standard Round-Robin or Smart routing using a single utility.
|
|
35
37
|
- **Latency-Based Routing**: Automatically routes traffic to the fastest healthy server (HEAD probe).
|
|
36
38
|
- **Advanced Overrides**: Granular control over headers, body, and HTTP methods.
|
|
37
|
-
- **
|
|
39
|
+
- **Smart Error Mapping**: Automatically converts upstream connection failures into standard HTTP 502 (Bad Gateway) and 504 (Gateway Timeout) responses.
|
|
40
|
+
- **Resilient Handshakes**: Customizable `open_timeout` for WebSockets to prevent proxy hangs during backend connection attempts.
|
|
38
41
|
- **Version Agnostic**: Automatically handles `websockets` library version differences (12.0+ vs Legacy).
|
|
39
42
|
|
|
40
|
-
## Quick Start
|
|
43
|
+
## Quick Start
|
|
41
44
|
|
|
42
|
-
|
|
45
|
+
Use the **lifespan** handler as shown for an easy launch.
|
|
46
|
+
|
|
47
|
+
The simplest way to use the proxy is to use **proxy_pass** and/or **proxy_pass_websocket** on the endpoints.
|
|
43
48
|
|
|
44
49
|
```python
|
|
45
50
|
from fastapi import FastAPI, Request, WebSocket
|
|
46
51
|
from contextlib import asynccontextmanager
|
|
47
52
|
|
|
48
|
-
from fastapi_reverse_proxy import
|
|
49
|
-
HealthChecker, LoadBalancer,
|
|
50
|
-
create_httpx_client, close_httpx_client
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
# 1. Setup health monitoring and load balancing
|
|
54
|
-
checker = HealthChecker(["http://localhost:8080", "http://localhost:8081"])
|
|
55
|
-
lb = LoadBalancer(checker)
|
|
53
|
+
from fastapi_reverse_proxy import Proxy, proxy_pass, proxy_pass_websocket
|
|
56
54
|
|
|
57
55
|
@asynccontextmanager
|
|
58
56
|
async def lifespan(app: FastAPI):
|
|
59
|
-
|
|
60
|
-
await create_httpx_client(app)
|
|
61
|
-
async with checker: # Starts background health loop
|
|
57
|
+
async with Proxy(app):
|
|
62
58
|
yield
|
|
63
|
-
await close_httpx_client(app)
|
|
64
59
|
|
|
65
60
|
app = FastAPI(lifespan=lifespan)
|
|
66
61
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
62
|
+
# catch-all route. recommended for a reverse proxy
|
|
63
|
+
@app.api_route("/{path:path}", methods=["GET","POST","PUT","DELETE"]) # don't forget to add the methods.
|
|
64
|
+
async def index(req: Request):
|
|
65
|
+
"""
|
|
66
|
+
You always need to pass the "Request" object and to specify the host
|
|
67
|
+
If you don't add a path, it will be the same as the original (/login --> http://127.0.0.1/login)
|
|
68
|
+
"""
|
|
69
|
+
return await proxy_pass(req, "http://127.0.0.1:8080")
|
|
71
70
|
|
|
72
|
-
@app.websocket("/ws/{path:path}")
|
|
73
|
-
async def ws_tunnel(websocket: WebSocket, path: str):
|
|
74
|
-
# Automatic subprotocol negotiation + Tunneling
|
|
75
|
-
await lb.proxy_pass_websocket(websocket, path=f"/{path}")
|
|
76
71
|
```
|
|
77
72
|
|
|
73
|
+
## 🛡️ Resilience & Error Handling
|
|
74
|
+
|
|
75
|
+
Error Handlingfastapi-reverse-proxy transforms upstream crashes into meaningful HTTPException responses (e.g., 502 Bad Gateway or 504 Gateway Timeout).
|
|
76
|
+
|
|
77
|
+
This allows you to implement custom failover logic, retry mechanisms, or specific error pages.
|
|
78
|
+
|
|
79
|
+
For a full implementation of a primary-to-backup failover system, see the [example](https://github.com/tfsantos05/fastapi-reverse-proxy/tree/main/examples/02_http_errors.py).
|
|
80
|
+
|
|
81
|
+
## Advanced Examples:
|
|
82
|
+
|
|
83
|
+
Check [examples](https://github.com/tfsantos05/fastapi-reverse-proxy/tree/main/examples) for full examples, including:
|
|
84
|
+
- **Websocket Proxy**
|
|
85
|
+
- **Socket.IO Proxy**
|
|
86
|
+
- **Error Handling & Failover** (`examples/error_handling_example.py`)
|
|
87
|
+
|
|
78
88
|
## Advanced Proxying
|
|
79
89
|
|
|
80
90
|
The `proxy_pass` function and `LoadBalancer.proxy_pass` provide deep customization for upstream requests:
|
|
81
91
|
|
|
82
92
|
| Parameter | Type | Description |
|
|
83
93
|
| :--- | :--- | :--- |
|
|
94
|
+
| `timeout` | `float` | Total request timeout in seconds (Default: `60.0`). |
|
|
84
95
|
| `method` | `str` | Force a specific HTTP method (e.g., `"POST"`). |
|
|
85
96
|
| `override_body` | `bytes` | Send custom data instead of the incoming request body. |
|
|
86
97
|
| `additional_headers` | `dict` | Append custom headers to the proxied request. |
|
|
@@ -116,6 +127,7 @@ The library implements "deferred negotiation" for WebSockets:
|
|
|
116
127
|
2. It establishes an upstream connection first.
|
|
117
128
|
3. Once the upstream accepts a protocol, the proxy calls `websocket.accept(subprotocol=...)` back to the client.
|
|
118
129
|
4. This ensures the entire tunnel (Client <-> Proxy <-> Upstream) uses the same negotiated protocol.
|
|
130
|
+
5. **Handshake Timeout**: Supports a customizable `timeout` parameter (default `10.0s`) to prevent hangs if the backend is unresponsive.
|
|
119
131
|
|
|
120
132
|
## Robustness & Safety
|
|
121
133
|
|
|
@@ -4,58 +4,69 @@ A robust, streaming-capable reverse proxy for FastAPI/Starlette with built-in **
|
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- **
|
|
7
|
+
- **Async**: Async by default.
|
|
8
|
+
- **Httpx Pool**: Async HTTPX Pool for proxying.
|
|
9
|
+
- **Streaming Ready**: Handles SSE (Server-Sent Events) and large payloads (such as big files) while keeping RAM usage low.
|
|
8
10
|
- **WebSocket Support**: Seamless bidirectional tunneling with automated subprotocol negotiation.
|
|
9
11
|
- **Unified Load Balancing**: Standard Round-Robin or Smart routing using a single utility.
|
|
10
12
|
- **Latency-Based Routing**: Automatically routes traffic to the fastest healthy server (HEAD probe).
|
|
11
13
|
- **Advanced Overrides**: Granular control over headers, body, and HTTP methods.
|
|
12
|
-
- **
|
|
14
|
+
- **Smart Error Mapping**: Automatically converts upstream connection failures into standard HTTP 502 (Bad Gateway) and 504 (Gateway Timeout) responses.
|
|
15
|
+
- **Resilient Handshakes**: Customizable `open_timeout` for WebSockets to prevent proxy hangs during backend connection attempts.
|
|
13
16
|
- **Version Agnostic**: Automatically handles `websockets` library version differences (12.0+ vs Legacy).
|
|
14
17
|
|
|
15
|
-
## Quick Start
|
|
18
|
+
## Quick Start
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
Use the **lifespan** handler as shown for an easy launch.
|
|
21
|
+
|
|
22
|
+
The simplest way to use the proxy is to use **proxy_pass** and/or **proxy_pass_websocket** on the endpoints.
|
|
18
23
|
|
|
19
24
|
```python
|
|
20
25
|
from fastapi import FastAPI, Request, WebSocket
|
|
21
26
|
from contextlib import asynccontextmanager
|
|
22
27
|
|
|
23
|
-
from fastapi_reverse_proxy import
|
|
24
|
-
HealthChecker, LoadBalancer,
|
|
25
|
-
create_httpx_client, close_httpx_client
|
|
26
|
-
)
|
|
27
|
-
|
|
28
|
-
# 1. Setup health monitoring and load balancing
|
|
29
|
-
checker = HealthChecker(["http://localhost:8080", "http://localhost:8081"])
|
|
30
|
-
lb = LoadBalancer(checker)
|
|
28
|
+
from fastapi_reverse_proxy import Proxy, proxy_pass, proxy_pass_websocket
|
|
31
29
|
|
|
32
30
|
@asynccontextmanager
|
|
33
31
|
async def lifespan(app: FastAPI):
|
|
34
|
-
|
|
35
|
-
await create_httpx_client(app)
|
|
36
|
-
async with checker: # Starts background health loop
|
|
32
|
+
async with Proxy(app):
|
|
37
33
|
yield
|
|
38
|
-
await close_httpx_client(app)
|
|
39
34
|
|
|
40
35
|
app = FastAPI(lifespan=lifespan)
|
|
41
36
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
37
|
+
# catch-all route. recommended for a reverse proxy
|
|
38
|
+
@app.api_route("/{path:path}", methods=["GET","POST","PUT","DELETE"]) # don't forget to add the methods.
|
|
39
|
+
async def index(req: Request):
|
|
40
|
+
"""
|
|
41
|
+
You always need to pass the "Request" object and to specify the host
|
|
42
|
+
If you don't add a path, it will be the same as the original (/login --> http://127.0.0.1/login)
|
|
43
|
+
"""
|
|
44
|
+
return await proxy_pass(req, "http://127.0.0.1:8080")
|
|
46
45
|
|
|
47
|
-
@app.websocket("/ws/{path:path}")
|
|
48
|
-
async def ws_tunnel(websocket: WebSocket, path: str):
|
|
49
|
-
# Automatic subprotocol negotiation + Tunneling
|
|
50
|
-
await lb.proxy_pass_websocket(websocket, path=f"/{path}")
|
|
51
46
|
```
|
|
52
47
|
|
|
48
|
+
## 🛡️ Resilience & Error Handling
|
|
49
|
+
|
|
50
|
+
Error Handlingfastapi-reverse-proxy transforms upstream crashes into meaningful HTTPException responses (e.g., 502 Bad Gateway or 504 Gateway Timeout).
|
|
51
|
+
|
|
52
|
+
This allows you to implement custom failover logic, retry mechanisms, or specific error pages.
|
|
53
|
+
|
|
54
|
+
For a full implementation of a primary-to-backup failover system, see the [example](https://github.com/tfsantos05/fastapi-reverse-proxy/tree/main/examples/02_http_errors.py).
|
|
55
|
+
|
|
56
|
+
## Advanced Examples:
|
|
57
|
+
|
|
58
|
+
Check [examples](https://github.com/tfsantos05/fastapi-reverse-proxy/tree/main/examples) for full examples, including:
|
|
59
|
+
- **Websocket Proxy**
|
|
60
|
+
- **Socket.IO Proxy**
|
|
61
|
+
- **Error Handling & Failover** (`examples/error_handling_example.py`)
|
|
62
|
+
|
|
53
63
|
## Advanced Proxying
|
|
54
64
|
|
|
55
65
|
The `proxy_pass` function and `LoadBalancer.proxy_pass` provide deep customization for upstream requests:
|
|
56
66
|
|
|
57
67
|
| Parameter | Type | Description |
|
|
58
68
|
| :--- | :--- | :--- |
|
|
69
|
+
| `timeout` | `float` | Total request timeout in seconds (Default: `60.0`). |
|
|
59
70
|
| `method` | `str` | Force a specific HTTP method (e.g., `"POST"`). |
|
|
60
71
|
| `override_body` | `bytes` | Send custom data instead of the incoming request body. |
|
|
61
72
|
| `additional_headers` | `dict` | Append custom headers to the proxied request. |
|
|
@@ -91,6 +102,7 @@ The library implements "deferred negotiation" for WebSockets:
|
|
|
91
102
|
2. It establishes an upstream connection first.
|
|
92
103
|
3. Once the upstream accepts a protocol, the proxy calls `websocket.accept(subprotocol=...)` back to the client.
|
|
93
104
|
4. This ensures the entire tunnel (Client <-> Proxy <-> Upstream) uses the same negotiated protocol.
|
|
105
|
+
5. **Handshake Timeout**: Supports a customizable `timeout` parameter (default `10.0s`) to prevent hangs if the backend is unresponsive.
|
|
94
106
|
|
|
95
107
|
## Robustness & Safety
|
|
96
108
|
|
{fastapi_reverse_proxy-0.1.1 → fastapi_reverse_proxy-0.3.0}/src/fastapi_reverse_proxy/__init__.py
RENAMED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from .proxy_pass import proxy_pass, proxy_pass_websocket
|
|
2
|
-
from .proxy_httpx import create_httpx_client, close_httpx_client, get_httpx_client
|
|
2
|
+
from .proxy_httpx import create_httpx_client, close_httpx_client, get_httpx_client, Proxy
|
|
3
3
|
from .load_balance import LoadBalancer
|
|
4
4
|
from .health_check import HealthChecker
|
|
5
5
|
|
|
@@ -9,6 +9,7 @@ __all__ = [
|
|
|
9
9
|
"create_httpx_client",
|
|
10
10
|
"close_httpx_client",
|
|
11
11
|
"get_httpx_client",
|
|
12
|
+
"Proxy",
|
|
12
13
|
"LoadBalancer",
|
|
13
14
|
"HealthChecker",
|
|
14
15
|
]
|
|
@@ -158,12 +158,12 @@ class LoadBalancer:
|
|
|
158
158
|
|
|
159
159
|
u = urlparse(target)
|
|
160
160
|
origin = f"{u.scheme}://{u.netloc}"
|
|
161
|
-
|
|
162
|
-
dest_url = f"{origin.rstrip('/')}/{path.lstrip('/') if path is not None else websocket.url.path}"
|
|
161
|
+
|
|
163
162
|
|
|
164
163
|
return await _proxy_pass_ws(
|
|
165
164
|
websocket,
|
|
166
|
-
|
|
165
|
+
host=origin,
|
|
166
|
+
path=path,
|
|
167
167
|
subprotocols=subprotocols,
|
|
168
168
|
forward_query=forward_query,
|
|
169
169
|
additional_headers=additional_headers,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from fastapi import FastAPI, Request
|
|
3
|
+
|
|
4
|
+
class Proxy:
|
|
5
|
+
def __init__(self, app: FastAPI):
|
|
6
|
+
"""Initializes the HTTP client and stores it in the app state."""
|
|
7
|
+
self.__app = app # store the app as internal variable
|
|
8
|
+
self.__app.state.http_proxy_client = httpx.AsyncClient()
|
|
9
|
+
|
|
10
|
+
async def close(self):
|
|
11
|
+
"""Closes the HTTP client stored in the app state."""
|
|
12
|
+
if hasattr(self.__app.state, "http_proxy_client"):
|
|
13
|
+
await self.__app.state.http_proxy_client.aclose() # close it
|
|
14
|
+
|
|
15
|
+
async def __aenter__(self): return self # __init__ on async with
|
|
16
|
+
|
|
17
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb): await self.close() # self-close on async with
|
|
18
|
+
|
|
19
|
+
async def create_httpx_client(app: FastAPI):
|
|
20
|
+
"""Initializes the HTTP client and stores it in the app state."""
|
|
21
|
+
app.state.http_proxy_client = httpx.AsyncClient()
|
|
22
|
+
|
|
23
|
+
async def close_httpx_client(app: FastAPI):
|
|
24
|
+
"""Closes the HTTP client stored in the app state."""
|
|
25
|
+
if hasattr(app.state, "http_proxy_client"):
|
|
26
|
+
await app.state.http_proxy_client.aclose() # close it
|
|
27
|
+
|
|
28
|
+
async def get_httpx_client(req: Request) -> httpx.AsyncClient:
|
|
29
|
+
"""Retrieves the HTTP client from the app state."""
|
|
30
|
+
return req.app.state.http_proxy_client
|
{fastapi_reverse_proxy-0.1.1 → fastapi_reverse_proxy-0.3.0}/src/fastapi_reverse_proxy/proxy_pass.py
RENAMED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from fastapi import Request, WebSocket, Response
|
|
1
|
+
from fastapi import Request, WebSocket, Response, HTTPException
|
|
2
2
|
from fastapi.responses import StreamingResponse
|
|
3
3
|
from starlette.background import BackgroundTask
|
|
4
4
|
from url_normalize import url_normalize
|
|
@@ -9,6 +9,7 @@ import logging
|
|
|
9
9
|
import inspect
|
|
10
10
|
from typing import Optional
|
|
11
11
|
from .proxy_httpx import get_httpx_client
|
|
12
|
+
from urllib.parse import urlparse
|
|
12
13
|
|
|
13
14
|
logger = logging.getLogger("fastapi_reverse_proxy")
|
|
14
15
|
|
|
@@ -19,6 +20,21 @@ EXCLUDED_HEADERS = {
|
|
|
19
20
|
"proxy-authorization", "te", "trailers", "transfer-encoding", "upgrade"
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
def url_normalize_ws(url:str):
|
|
24
|
+
u = urlparse(url)
|
|
25
|
+
return url_normalize(url.replace(u.scheme, "http", 1)).replace("http", u.scheme, 1)
|
|
26
|
+
|
|
27
|
+
async def handle_proxy_exception(e: Exception):
|
|
28
|
+
"""Maps internal exceptions to FastAPI HTTPExceptions for the client."""
|
|
29
|
+
if isinstance(e, httpx.ConnectTimeout):
|
|
30
|
+
raise HTTPException(status_code=504, detail="Gateway Timeout")
|
|
31
|
+
if isinstance(e, (httpx.ConnectError, httpx.RemoteProtocolError)):
|
|
32
|
+
raise HTTPException(status_code=502, detail="Bad Gateway")
|
|
33
|
+
if isinstance(e, httpx.ReadTimeout):
|
|
34
|
+
raise HTTPException(status_code=504, detail="Upstream Read Timeout")
|
|
35
|
+
# Re-raise other exceptions (like CancelledError or internal bugs) to avoid masking
|
|
36
|
+
raise e
|
|
37
|
+
|
|
22
38
|
async def proxy_pass(
|
|
23
39
|
request: Request,
|
|
24
40
|
host: str,
|
|
@@ -33,7 +49,7 @@ async def proxy_pass(
|
|
|
33
49
|
"""
|
|
34
50
|
Forwards incoming HTTP requests to the target service using streaming.
|
|
35
51
|
- host: The host itself (without ending slash)
|
|
36
|
-
- path: The path
|
|
52
|
+
- path: The path w/ beggining slash (by default copies requests's path)
|
|
37
53
|
- forward_query: If True, automatically appends the request's query string.
|
|
38
54
|
- additional_headers: Headers to add to the upstream request.
|
|
39
55
|
- override_headers: Use these headers instead of original request headers.
|
|
@@ -146,25 +162,35 @@ async def proxy_pass(
|
|
|
146
162
|
# Catch EVERY exception (including CancelledError) for local client cleanup
|
|
147
163
|
if not is_global_client and client:
|
|
148
164
|
await client.aclose()
|
|
149
|
-
|
|
165
|
+
|
|
166
|
+
# Transform httpx errors into proper HTTP responses
|
|
167
|
+
if not isinstance(e, asyncio.CancelledError):
|
|
168
|
+
await handle_proxy_exception(e)
|
|
169
|
+
|
|
150
170
|
raise e
|
|
151
171
|
|
|
152
172
|
|
|
153
|
-
|
|
154
173
|
async def proxy_pass_websocket(
|
|
155
174
|
websocket: WebSocket,
|
|
156
|
-
|
|
175
|
+
host: str,
|
|
176
|
+
path: Optional[str] = None,
|
|
157
177
|
subprotocols: Optional[list[str]] = None,
|
|
158
178
|
forward_query: bool = True,
|
|
159
179
|
additional_headers: Optional[dict] = None,
|
|
160
|
-
override_headers: Optional[dict] = None
|
|
180
|
+
override_headers: Optional[dict] = None,
|
|
181
|
+
timeout: float = 10.0
|
|
161
182
|
):
|
|
162
183
|
"""
|
|
163
184
|
Forwards incoming WebSocket connections to the target service.
|
|
164
|
-
-
|
|
185
|
+
- host: The host itself (without ending slash)
|
|
186
|
+
- path: The path with beggining slash (by default copies requests's path)
|
|
165
187
|
- forward_query: If True, automatically appends the request's query string.
|
|
188
|
+
- timeout: Time to wait for the connection handshake (open_timeout).
|
|
166
189
|
"""
|
|
167
|
-
|
|
190
|
+
|
|
191
|
+
if path is None: path = websocket.url.path
|
|
192
|
+
url = url_normalize_ws(host + path)
|
|
193
|
+
|
|
168
194
|
if forward_query and websocket.url.query:
|
|
169
195
|
url = f"{url}?{websocket.url.query}" if "?" not in url else f"{url}&{websocket.url.query}"
|
|
170
196
|
|
|
@@ -200,7 +226,8 @@ async def proxy_pass_websocket(
|
|
|
200
226
|
|
|
201
227
|
connect_kwargs = {
|
|
202
228
|
header_param: headers,
|
|
203
|
-
"subprotocols": supported_subprotocols
|
|
229
|
+
"subprotocols": supported_subprotocols,
|
|
230
|
+
"open_timeout": timeout
|
|
204
231
|
}
|
|
205
232
|
|
|
206
233
|
async with websockets.connect(url, **connect_kwargs) as target_ws:
|
|
@@ -210,7 +237,16 @@ async def proxy_pass_websocket(
|
|
|
210
237
|
|
|
211
238
|
except BaseException as e:
|
|
212
239
|
if not isinstance(e, asyncio.CancelledError):
|
|
213
|
-
|
|
240
|
+
# If the connection fails before accept(), we can raise a proper 502
|
|
241
|
+
if not websocket.client.connected: # Roughly checking if handshake finished
|
|
242
|
+
logger.error(f"WebSocket Connection Error: {e}")
|
|
243
|
+
# This is a bit tricky in WS, but if we haven't accepted yet, we can raise
|
|
244
|
+
try:
|
|
245
|
+
raise HTTPException(status_code=502, detail="Bad Gateway: WebSocket connection failed")
|
|
246
|
+
except RuntimeError: # If already accepted, we can't raise HTTPException
|
|
247
|
+
pass
|
|
248
|
+
else:
|
|
249
|
+
logger.error(f"WebSocket Proxy Error: {e}")
|
|
214
250
|
raise e
|
|
215
251
|
finally:
|
|
216
252
|
try:
|
|
@@ -219,9 +255,6 @@ async def proxy_pass_websocket(
|
|
|
219
255
|
pass
|
|
220
256
|
|
|
221
257
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
258
|
async def _handle_ws_bidirectional(websocket: WebSocket, target_ws):
|
|
226
259
|
"""Internal helper to manage bidirectional WS traffic with clean cancellation."""
|
|
227
260
|
async def client_to_target():
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-reverse-proxy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A robust, streaming-capable reverse proxy for FastAPI including WebSocket support.
|
|
5
5
|
Author-email: Tomás <tomas@suricatingss.xyz>
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -29,58 +29,69 @@ A robust, streaming-capable reverse proxy for FastAPI/Starlette with built-in **
|
|
|
29
29
|
|
|
30
30
|
## Features
|
|
31
31
|
|
|
32
|
-
- **
|
|
32
|
+
- **Async**: Async by default.
|
|
33
|
+
- **Httpx Pool**: Async HTTPX Pool for proxying.
|
|
34
|
+
- **Streaming Ready**: Handles SSE (Server-Sent Events) and large payloads (such as big files) while keeping RAM usage low.
|
|
33
35
|
- **WebSocket Support**: Seamless bidirectional tunneling with automated subprotocol negotiation.
|
|
34
36
|
- **Unified Load Balancing**: Standard Round-Robin or Smart routing using a single utility.
|
|
35
37
|
- **Latency-Based Routing**: Automatically routes traffic to the fastest healthy server (HEAD probe).
|
|
36
38
|
- **Advanced Overrides**: Granular control over headers, body, and HTTP methods.
|
|
37
|
-
- **
|
|
39
|
+
- **Smart Error Mapping**: Automatically converts upstream connection failures into standard HTTP 502 (Bad Gateway) and 504 (Gateway Timeout) responses.
|
|
40
|
+
- **Resilient Handshakes**: Customizable `open_timeout` for WebSockets to prevent proxy hangs during backend connection attempts.
|
|
38
41
|
- **Version Agnostic**: Automatically handles `websockets` library version differences (12.0+ vs Legacy).
|
|
39
42
|
|
|
40
|
-
## Quick Start
|
|
43
|
+
## Quick Start
|
|
41
44
|
|
|
42
|
-
|
|
45
|
+
Use the **lifespan** handler as shown for an easy launch.
|
|
46
|
+
|
|
47
|
+
The simplest way to use the proxy is to use **proxy_pass** and/or **proxy_pass_websocket** on the endpoints.
|
|
43
48
|
|
|
44
49
|
```python
|
|
45
50
|
from fastapi import FastAPI, Request, WebSocket
|
|
46
51
|
from contextlib import asynccontextmanager
|
|
47
52
|
|
|
48
|
-
from fastapi_reverse_proxy import
|
|
49
|
-
HealthChecker, LoadBalancer,
|
|
50
|
-
create_httpx_client, close_httpx_client
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
# 1. Setup health monitoring and load balancing
|
|
54
|
-
checker = HealthChecker(["http://localhost:8080", "http://localhost:8081"])
|
|
55
|
-
lb = LoadBalancer(checker)
|
|
53
|
+
from fastapi_reverse_proxy import Proxy, proxy_pass, proxy_pass_websocket
|
|
56
54
|
|
|
57
55
|
@asynccontextmanager
|
|
58
56
|
async def lifespan(app: FastAPI):
|
|
59
|
-
|
|
60
|
-
await create_httpx_client(app)
|
|
61
|
-
async with checker: # Starts background health loop
|
|
57
|
+
async with Proxy(app):
|
|
62
58
|
yield
|
|
63
|
-
await close_httpx_client(app)
|
|
64
59
|
|
|
65
60
|
app = FastAPI(lifespan=lifespan)
|
|
66
61
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
62
|
+
# catch-all route. recommended for a reverse proxy
|
|
63
|
+
@app.api_route("/{path:path}", methods=["GET","POST","PUT","DELETE"]) # don't forget to add the methods.
|
|
64
|
+
async def index(req: Request):
|
|
65
|
+
"""
|
|
66
|
+
You always need to pass the "Request" object and to specify the host
|
|
67
|
+
If you don't add a path, it will be the same as the original (/login --> http://127.0.0.1/login)
|
|
68
|
+
"""
|
|
69
|
+
return await proxy_pass(req, "http://127.0.0.1:8080")
|
|
71
70
|
|
|
72
|
-
@app.websocket("/ws/{path:path}")
|
|
73
|
-
async def ws_tunnel(websocket: WebSocket, path: str):
|
|
74
|
-
# Automatic subprotocol negotiation + Tunneling
|
|
75
|
-
await lb.proxy_pass_websocket(websocket, path=f"/{path}")
|
|
76
71
|
```
|
|
77
72
|
|
|
73
|
+
## 🛡️ Resilience & Error Handling
|
|
74
|
+
|
|
75
|
+
Error Handlingfastapi-reverse-proxy transforms upstream crashes into meaningful HTTPException responses (e.g., 502 Bad Gateway or 504 Gateway Timeout).
|
|
76
|
+
|
|
77
|
+
This allows you to implement custom failover logic, retry mechanisms, or specific error pages.
|
|
78
|
+
|
|
79
|
+
For a full implementation of a primary-to-backup failover system, see the [example](https://github.com/tfsantos05/fastapi-reverse-proxy/tree/main/examples/02_http_errors.py).
|
|
80
|
+
|
|
81
|
+
## Advanced Examples:
|
|
82
|
+
|
|
83
|
+
Check [examples](https://github.com/tfsantos05/fastapi-reverse-proxy/tree/main/examples) for full examples, including:
|
|
84
|
+
- **Websocket Proxy**
|
|
85
|
+
- **Socket.IO Proxy**
|
|
86
|
+
- **Error Handling & Failover** (`examples/error_handling_example.py`)
|
|
87
|
+
|
|
78
88
|
## Advanced Proxying
|
|
79
89
|
|
|
80
90
|
The `proxy_pass` function and `LoadBalancer.proxy_pass` provide deep customization for upstream requests:
|
|
81
91
|
|
|
82
92
|
| Parameter | Type | Description |
|
|
83
93
|
| :--- | :--- | :--- |
|
|
94
|
+
| `timeout` | `float` | Total request timeout in seconds (Default: `60.0`). |
|
|
84
95
|
| `method` | `str` | Force a specific HTTP method (e.g., `"POST"`). |
|
|
85
96
|
| `override_body` | `bytes` | Send custom data instead of the incoming request body. |
|
|
86
97
|
| `additional_headers` | `dict` | Append custom headers to the proxied request. |
|
|
@@ -116,6 +127,7 @@ The library implements "deferred negotiation" for WebSockets:
|
|
|
116
127
|
2. It establishes an upstream connection first.
|
|
117
128
|
3. Once the upstream accepts a protocol, the proxy calls `websocket.accept(subprotocol=...)` back to the client.
|
|
118
129
|
4. This ensures the entire tunnel (Client <-> Proxy <-> Upstream) uses the same negotiated protocol.
|
|
130
|
+
5. **Handshake Timeout**: Supports a customizable `timeout` parameter (default `10.0s`) to prevent hangs if the backend is unresponsive.
|
|
119
131
|
|
|
120
132
|
## Robustness & Safety
|
|
121
133
|
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import httpx
|
|
2
|
-
from fastapi import FastAPI, Request
|
|
3
|
-
|
|
4
|
-
async def create_httpx_client(app: FastAPI):
|
|
5
|
-
"""Initializes the HTTP client and stores it in the app state."""
|
|
6
|
-
app.state.http_proxy_client = httpx.AsyncClient()
|
|
7
|
-
|
|
8
|
-
async def close_httpx_client(app: FastAPI):
|
|
9
|
-
"""Closes the HTTP client stored in the app state."""
|
|
10
|
-
if hasattr(app.state, "http_proxy_client"):
|
|
11
|
-
await app.state.http_proxy_client.aclose() # close it
|
|
12
|
-
|
|
13
|
-
async def get_httpx_client(req: Request) -> httpx.AsyncClient:
|
|
14
|
-
"""Retrieves the HTTP client from the app state."""
|
|
15
|
-
return req.app.state.http_proxy_client
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|