fastapi-reverse-proxy 0.2.0__tar.gz → 0.3.1__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.2.0/src/fastapi_reverse_proxy.egg-info → fastapi_reverse_proxy-0.3.1}/PKG-INFO +64 -3
- {fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/README.md +62 -1
- {fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/pyproject.toml +2 -2
- {fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/src/fastapi_reverse_proxy/__init__.py +2 -1
- {fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/src/fastapi_reverse_proxy/proxy_pass.py +82 -30
- {fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1/src/fastapi_reverse_proxy.egg-info}/PKG-INFO +64 -3
- {fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/LICENSE +0 -0
- {fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/setup.cfg +0 -0
- {fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/src/fastapi_reverse_proxy/health_check.py +0 -0
- {fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/src/fastapi_reverse_proxy/load_balance.py +0 -0
- {fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/src/fastapi_reverse_proxy/proxy_httpx.py +0 -0
- {fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/src/fastapi_reverse_proxy.egg-info/SOURCES.txt +0 -0
- {fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/src/fastapi_reverse_proxy.egg-info/dependency_links.txt +0 -0
- {fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/src/fastapi_reverse_proxy.egg-info/requires.txt +0 -0
- {fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/src/fastapi_reverse_proxy.egg-info/top_level.txt +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-reverse-proxy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: A robust, streaming-capable reverse proxy for FastAPI including WebSocket support.
|
|
5
5
|
Author-email: Tomás <tomas@suricatingss.xyz>
|
|
6
|
+
License-Expression: MIT
|
|
6
7
|
Classifier: Programming Language :: Python :: 3
|
|
7
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
8
8
|
Classifier: Operating System :: OS Independent
|
|
9
9
|
Classifier: Framework :: FastAPI
|
|
10
10
|
Classifier: Topic :: Internet :: Proxy Servers
|
|
@@ -36,6 +36,8 @@ A robust, streaming-capable reverse proxy for FastAPI/Starlette with built-in **
|
|
|
36
36
|
- **Unified Load Balancing**: Standard Round-Robin or Smart routing using a single utility.
|
|
37
37
|
- **Latency-Based Routing**: Automatically routes traffic to the fastest healthy server (HEAD probe).
|
|
38
38
|
- **Advanced Overrides**: Granular control over headers, body, and HTTP methods.
|
|
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.
|
|
39
41
|
- **Version Agnostic**: Automatically handles `websockets` library version differences (12.0+ vs Legacy).
|
|
40
42
|
|
|
41
43
|
## Quick Start
|
|
@@ -68,11 +70,20 @@ async def index(req: Request):
|
|
|
68
70
|
|
|
69
71
|
```
|
|
70
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
|
+
|
|
71
81
|
## Advanced Examples:
|
|
72
82
|
|
|
73
|
-
Check [
|
|
83
|
+
Check [examples](https://github.com/tfsantos05/fastapi-reverse-proxy/tree/main/examples) for full examples, including:
|
|
74
84
|
- **Websocket Proxy**
|
|
75
85
|
- **Socket.IO Proxy**
|
|
86
|
+
- **Error Handling & Failover** (`examples/error_handling_example.py`)
|
|
76
87
|
|
|
77
88
|
## Advanced Proxying
|
|
78
89
|
|
|
@@ -80,11 +91,13 @@ The `proxy_pass` function and `LoadBalancer.proxy_pass` provide deep customizati
|
|
|
80
91
|
|
|
81
92
|
| Parameter | Type | Description |
|
|
82
93
|
| :--- | :--- | :--- |
|
|
94
|
+
| `timeout` | `float` | Total request timeout in seconds (Default: `60.0`). |
|
|
83
95
|
| `method` | `str` | Force a specific HTTP method (e.g., `"POST"`). |
|
|
84
96
|
| `override_body` | `bytes` | Send custom data instead of the incoming request body. |
|
|
85
97
|
| `additional_headers` | `dict` | Append custom headers to the proxied request. |
|
|
86
98
|
| `override_headers` | `dict` | Use these headers *instead* of original request headers. |
|
|
87
99
|
| `forward_query` | `bool` | Whether to append the incoming query string (Default: `True`). |
|
|
100
|
+
| `override_host` | `str` | Override the outbound `Host` header sent to the target (useful for multi-host/virtual-hosting backends that key off the original requested host). |
|
|
88
101
|
|
|
89
102
|
## Monitoring & Configuration
|
|
90
103
|
|
|
@@ -115,8 +128,56 @@ The library implements "deferred negotiation" for WebSockets:
|
|
|
115
128
|
2. It establishes an upstream connection first.
|
|
116
129
|
3. Once the upstream accepts a protocol, the proxy calls `websocket.accept(subprotocol=...)` back to the client.
|
|
117
130
|
4. This ensures the entire tunnel (Client <-> Proxy <-> Upstream) uses the same negotiated protocol.
|
|
131
|
+
5. **Handshake Timeout**: Supports a customizable `timeout` parameter (default `10.0s`) to prevent hangs if the backend is unresponsive.
|
|
118
132
|
|
|
119
133
|
## Robustness & Safety
|
|
120
134
|
|
|
121
135
|
- **Termination Safety**: Resource cleanup (closing `httpx` clients and sockets) is triggered even on task cancellation (`BaseException`).
|
|
122
136
|
- **Introspection-Based Compatibility**: Uses `inspect.signature` to automatically detect version-specific parameters in the `websockets` library.
|
|
137
|
+
- **RFC 7230 Compliant Header Handling**: Hop-by-hop headers (`Connection`,
|
|
138
|
+
`Transfer-Encoding`, `TE`, `Trailers`, `Keep-Alive`, `Proxy-Authenticate`,
|
|
139
|
+
`Proxy-Authorization`) are stripped from both outbound requests and responses,
|
|
140
|
+
per spec. WebSocket handshake headers (`Sec-WebSocket-Key`, `Upgrade`, etc.)
|
|
141
|
+
from the client are never forwarded to the target, avoiding handshake collisions.
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
## Running Behind a Reverse Proxy (Nginx/Apache)
|
|
146
|
+
|
|
147
|
+
By default, `proxy_pass` and `proxy_pass_websocket` forward the client's original
|
|
148
|
+
headers as-is — they do **not** set or rewrite `X-Real-IP`, `X-Forwarded-For`,
|
|
149
|
+
`X-Forwarded-Proto`, or `X-Forwarded-Host`. If this library sits behind Nginx or
|
|
150
|
+
Apache (the common setup), your upstream server is responsible for setting those
|
|
151
|
+
headers before the request reaches this proxy:
|
|
152
|
+
|
|
153
|
+
```nginx
|
|
154
|
+
location / {
|
|
155
|
+
proxy_pass http://your-fastapi-app;
|
|
156
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
157
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
158
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
159
|
+
proxy_set_header Host $host;
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
For WebSocket routes, Nginx also needs explicit upgrade handling:
|
|
164
|
+
|
|
165
|
+
```nginx
|
|
166
|
+
map $http_upgrade $connection_upgrade {
|
|
167
|
+
default upgrade;
|
|
168
|
+
'' close;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
location /ws/ {
|
|
172
|
+
proxy_pass http://your-fastapi-app;
|
|
173
|
+
proxy_http_version 1.1;
|
|
174
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
175
|
+
proxy_set_header Connection $connection_upgrade;
|
|
176
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
177
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
178
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Without this configuration, `X-Forwarded-*` headers will be empty or missing by
|
|
183
|
+
the time they reach your application.
|
|
@@ -11,6 +11,8 @@ A robust, streaming-capable reverse proxy for FastAPI/Starlette with built-in **
|
|
|
11
11
|
- **Unified Load Balancing**: Standard Round-Robin or Smart routing using a single utility.
|
|
12
12
|
- **Latency-Based Routing**: Automatically routes traffic to the fastest healthy server (HEAD probe).
|
|
13
13
|
- **Advanced Overrides**: Granular control over headers, body, and HTTP methods.
|
|
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.
|
|
14
16
|
- **Version Agnostic**: Automatically handles `websockets` library version differences (12.0+ vs Legacy).
|
|
15
17
|
|
|
16
18
|
## Quick Start
|
|
@@ -43,11 +45,20 @@ async def index(req: Request):
|
|
|
43
45
|
|
|
44
46
|
```
|
|
45
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
|
+
|
|
46
56
|
## Advanced Examples:
|
|
47
57
|
|
|
48
|
-
Check [
|
|
58
|
+
Check [examples](https://github.com/tfsantos05/fastapi-reverse-proxy/tree/main/examples) for full examples, including:
|
|
49
59
|
- **Websocket Proxy**
|
|
50
60
|
- **Socket.IO Proxy**
|
|
61
|
+
- **Error Handling & Failover** (`examples/error_handling_example.py`)
|
|
51
62
|
|
|
52
63
|
## Advanced Proxying
|
|
53
64
|
|
|
@@ -55,11 +66,13 @@ The `proxy_pass` function and `LoadBalancer.proxy_pass` provide deep customizati
|
|
|
55
66
|
|
|
56
67
|
| Parameter | Type | Description |
|
|
57
68
|
| :--- | :--- | :--- |
|
|
69
|
+
| `timeout` | `float` | Total request timeout in seconds (Default: `60.0`). |
|
|
58
70
|
| `method` | `str` | Force a specific HTTP method (e.g., `"POST"`). |
|
|
59
71
|
| `override_body` | `bytes` | Send custom data instead of the incoming request body. |
|
|
60
72
|
| `additional_headers` | `dict` | Append custom headers to the proxied request. |
|
|
61
73
|
| `override_headers` | `dict` | Use these headers *instead* of original request headers. |
|
|
62
74
|
| `forward_query` | `bool` | Whether to append the incoming query string (Default: `True`). |
|
|
75
|
+
| `override_host` | `str` | Override the outbound `Host` header sent to the target (useful for multi-host/virtual-hosting backends that key off the original requested host). |
|
|
63
76
|
|
|
64
77
|
## Monitoring & Configuration
|
|
65
78
|
|
|
@@ -90,8 +103,56 @@ The library implements "deferred negotiation" for WebSockets:
|
|
|
90
103
|
2. It establishes an upstream connection first.
|
|
91
104
|
3. Once the upstream accepts a protocol, the proxy calls `websocket.accept(subprotocol=...)` back to the client.
|
|
92
105
|
4. This ensures the entire tunnel (Client <-> Proxy <-> Upstream) uses the same negotiated protocol.
|
|
106
|
+
5. **Handshake Timeout**: Supports a customizable `timeout` parameter (default `10.0s`) to prevent hangs if the backend is unresponsive.
|
|
93
107
|
|
|
94
108
|
## Robustness & Safety
|
|
95
109
|
|
|
96
110
|
- **Termination Safety**: Resource cleanup (closing `httpx` clients and sockets) is triggered even on task cancellation (`BaseException`).
|
|
97
111
|
- **Introspection-Based Compatibility**: Uses `inspect.signature` to automatically detect version-specific parameters in the `websockets` library.
|
|
112
|
+
- **RFC 7230 Compliant Header Handling**: Hop-by-hop headers (`Connection`,
|
|
113
|
+
`Transfer-Encoding`, `TE`, `Trailers`, `Keep-Alive`, `Proxy-Authenticate`,
|
|
114
|
+
`Proxy-Authorization`) are stripped from both outbound requests and responses,
|
|
115
|
+
per spec. WebSocket handshake headers (`Sec-WebSocket-Key`, `Upgrade`, etc.)
|
|
116
|
+
from the client are never forwarded to the target, avoiding handshake collisions.
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
## Running Behind a Reverse Proxy (Nginx/Apache)
|
|
121
|
+
|
|
122
|
+
By default, `proxy_pass` and `proxy_pass_websocket` forward the client's original
|
|
123
|
+
headers as-is — they do **not** set or rewrite `X-Real-IP`, `X-Forwarded-For`,
|
|
124
|
+
`X-Forwarded-Proto`, or `X-Forwarded-Host`. If this library sits behind Nginx or
|
|
125
|
+
Apache (the common setup), your upstream server is responsible for setting those
|
|
126
|
+
headers before the request reaches this proxy:
|
|
127
|
+
|
|
128
|
+
```nginx
|
|
129
|
+
location / {
|
|
130
|
+
proxy_pass http://your-fastapi-app;
|
|
131
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
132
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
133
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
134
|
+
proxy_set_header Host $host;
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
For WebSocket routes, Nginx also needs explicit upgrade handling:
|
|
139
|
+
|
|
140
|
+
```nginx
|
|
141
|
+
map $http_upgrade $connection_upgrade {
|
|
142
|
+
default upgrade;
|
|
143
|
+
'' close;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
location /ws/ {
|
|
147
|
+
proxy_pass http://your-fastapi-app;
|
|
148
|
+
proxy_http_version 1.1;
|
|
149
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
150
|
+
proxy_set_header Connection $connection_upgrade;
|
|
151
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
152
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
153
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Without this configuration, `X-Forwarded-*` headers will be empty or missing by
|
|
158
|
+
the time they reach your application.
|
|
@@ -4,16 +4,16 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fastapi-reverse-proxy"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
authors = [
|
|
9
9
|
{ name="Tomás", email="tomas@suricatingss.xyz" },
|
|
10
10
|
]
|
|
11
11
|
description = "A robust, streaming-capable reverse proxy for FastAPI including WebSocket support."
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.8"
|
|
14
|
+
license = "MIT"
|
|
14
15
|
classifiers = [
|
|
15
16
|
"Programming Language :: Python :: 3",
|
|
16
|
-
"License :: OSI Approved :: MIT License",
|
|
17
17
|
"Operating System :: OS Independent",
|
|
18
18
|
"Framework :: FastAPI",
|
|
19
19
|
"Topic :: Internet :: Proxy Servers",
|
{fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/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
|
]
|
{fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/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
|
|
@@ -13,18 +13,35 @@ from urllib.parse import urlparse
|
|
|
13
13
|
|
|
14
14
|
logger = logging.getLogger("fastapi_reverse_proxy")
|
|
15
15
|
|
|
16
|
-
#
|
|
17
|
-
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/TE
|
|
16
|
+
# RFC 7230 §6.1 — hop-by-hop headers, strip on every proxied HTTP request/response
|
|
18
17
|
EXCLUDED_HEADERS = {
|
|
19
|
-
"connection", "keep-alive", "proxy-authenticate",
|
|
18
|
+
"connection", "keep-alive", "proxy-authenticate",
|
|
20
19
|
"proxy-authorization", "te", "trailers", "transfer-encoding", "upgrade"
|
|
21
20
|
}
|
|
22
21
|
|
|
22
|
+
# WebSocket handshake headers — belong to ONE handshake (client<->you),
|
|
23
|
+
# must not be reused for the separate handshake you<->target
|
|
24
|
+
WS_HANDSHAKE_HEADERS = {
|
|
25
|
+
"connection", "upgrade", "host", "sec-websocket-key",
|
|
26
|
+
"sec-websocket-version", "sec-websocket-extensions",
|
|
27
|
+
"sec-websocket-protocol"
|
|
28
|
+
}
|
|
29
|
+
|
|
23
30
|
def url_normalize_ws(url:str):
|
|
24
31
|
u = urlparse(url)
|
|
25
32
|
return url_normalize(url.replace(u.scheme, "http", 1)).replace("http", u.scheme, 1)
|
|
26
|
-
|
|
27
33
|
|
|
34
|
+
async def handle_proxy_exception(e: Exception):
|
|
35
|
+
"""Maps internal exceptions to FastAPI HTTPExceptions for the client."""
|
|
36
|
+
if isinstance(e, httpx.ConnectTimeout):
|
|
37
|
+
raise HTTPException(status_code=504, detail="Gateway Timeout")
|
|
38
|
+
if isinstance(e, (httpx.ConnectError, httpx.RemoteProtocolError)):
|
|
39
|
+
raise HTTPException(status_code=502, detail="Bad Gateway")
|
|
40
|
+
if isinstance(e, httpx.ReadTimeout):
|
|
41
|
+
raise HTTPException(status_code=504, detail="Upstream Read Timeout")
|
|
42
|
+
# Re-raise other exceptions (like CancelledError or internal bugs) to avoid masking
|
|
43
|
+
raise e
|
|
44
|
+
|
|
28
45
|
async def proxy_pass(
|
|
29
46
|
request: Request,
|
|
30
47
|
host: str,
|
|
@@ -34,7 +51,8 @@ async def proxy_pass(
|
|
|
34
51
|
additional_headers: Optional[dict] = None,
|
|
35
52
|
override_headers: Optional[dict] = None,
|
|
36
53
|
override_body: Optional[bytes] = None,
|
|
37
|
-
method: Optional[str] = None
|
|
54
|
+
method: Optional[str] = None,
|
|
55
|
+
override_host: Optional[str] = None
|
|
38
56
|
):
|
|
39
57
|
"""
|
|
40
58
|
Forwards incoming HTTP requests to the target service using streaming.
|
|
@@ -68,23 +86,32 @@ async def proxy_pass(
|
|
|
68
86
|
headers = dict(request.headers)
|
|
69
87
|
# Identify the client's real IP and forward it
|
|
70
88
|
client_host = request.client.host if request.client else "unknown"
|
|
71
|
-
headers["X-Real-IP"] = client_host
|
|
72
|
-
if "X-Forwarded-For" in headers:
|
|
73
|
-
|
|
74
|
-
else:
|
|
75
|
-
|
|
89
|
+
#headers["X-Real-IP"] = client_host
|
|
90
|
+
#if "X-Forwarded-For" in headers:
|
|
91
|
+
# headers["X-Forwarded-For"] = f"{headers['X-Forwarded-For']}, {client_host}"
|
|
92
|
+
#else:
|
|
93
|
+
# headers["X-Forwarded-For"] = client_host
|
|
76
94
|
|
|
77
|
-
headers["X-Forwarded-Proto"] = request.url.scheme
|
|
78
|
-
headers["X-Forwarded-Host"] = headers.get("host", request.url.netloc)
|
|
95
|
+
#headers["X-Forwarded-Proto"] = request.url.scheme
|
|
96
|
+
#headers["X-Forwarded-Host"] = headers.get("host", request.url.netloc)
|
|
79
97
|
|
|
80
98
|
# Apply additional headers
|
|
81
99
|
if additional_headers:
|
|
82
100
|
headers.update(additional_headers)
|
|
83
101
|
|
|
84
|
-
|
|
85
|
-
|
|
102
|
+
if override_host:
|
|
103
|
+
# use the supplied host header
|
|
104
|
+
headers['host'] = override_host
|
|
105
|
+
else:
|
|
106
|
+
# Let httpx handle the host header
|
|
107
|
+
headers.pop("host", None)
|
|
108
|
+
|
|
109
|
+
# Let httpx handle connection management
|
|
86
110
|
headers.pop("connection", None)
|
|
87
111
|
|
|
112
|
+
# Remove hop-to-hop headers
|
|
113
|
+
headers = {k: v for k, v in headers.items() if k.lower() not in EXCLUDED_HEADERS}
|
|
114
|
+
|
|
88
115
|
client = None
|
|
89
116
|
try:
|
|
90
117
|
client = await get_httpx_client(request)
|
|
@@ -130,7 +157,7 @@ async def proxy_pass(
|
|
|
130
157
|
resp_headers[k] = v
|
|
131
158
|
|
|
132
159
|
resp_headers["X-Accel-Buffering"] = "no"
|
|
133
|
-
resp_headers["Cache-Control"] = "no-cache"
|
|
160
|
+
#resp_headers["Cache-Control"] = "no-cache"
|
|
134
161
|
|
|
135
162
|
async def cleanup():
|
|
136
163
|
await rp_resp.aclose()
|
|
@@ -152,11 +179,14 @@ async def proxy_pass(
|
|
|
152
179
|
# Catch EVERY exception (including CancelledError) for local client cleanup
|
|
153
180
|
if not is_global_client and client:
|
|
154
181
|
await client.aclose()
|
|
155
|
-
|
|
182
|
+
|
|
183
|
+
# Transform httpx errors into proper HTTP responses
|
|
184
|
+
if not isinstance(e, asyncio.CancelledError):
|
|
185
|
+
await handle_proxy_exception(e)
|
|
186
|
+
|
|
156
187
|
raise e
|
|
157
188
|
|
|
158
189
|
|
|
159
|
-
|
|
160
190
|
async def proxy_pass_websocket(
|
|
161
191
|
websocket: WebSocket,
|
|
162
192
|
host: str,
|
|
@@ -164,13 +194,15 @@ async def proxy_pass_websocket(
|
|
|
164
194
|
subprotocols: Optional[list[str]] = None,
|
|
165
195
|
forward_query: bool = True,
|
|
166
196
|
additional_headers: Optional[dict] = None,
|
|
167
|
-
override_headers: Optional[dict] = None
|
|
197
|
+
override_headers: Optional[dict] = None,
|
|
198
|
+
timeout: float = 10.0
|
|
168
199
|
):
|
|
169
200
|
"""
|
|
170
201
|
Forwards incoming WebSocket connections to the target service.
|
|
171
202
|
- host: The host itself (without ending slash)
|
|
172
203
|
- path: The path with beggining slash (by default copies requests's path)
|
|
173
204
|
- forward_query: If True, automatically appends the request's query string.
|
|
205
|
+
- timeout: Time to wait for the connection handshake (open_timeout).
|
|
174
206
|
"""
|
|
175
207
|
|
|
176
208
|
if path is None: path = websocket.url.path
|
|
@@ -190,12 +222,13 @@ async def proxy_pass_websocket(
|
|
|
190
222
|
headers = dict(override_headers)
|
|
191
223
|
else:
|
|
192
224
|
client_host = websocket.client.host if websocket.client else "unknown"
|
|
193
|
-
headers =
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
225
|
+
headers = dict(websocket.headers)
|
|
226
|
+
#headers = {
|
|
227
|
+
# "X-Real-IP": client_host,
|
|
228
|
+
# "X-Forwarded-For": client_host,
|
|
229
|
+
# "X-Forwarded-Proto": websocket.url.scheme,
|
|
230
|
+
# "X-Forwarded-Host": websocket.headers.get("host", websocket.url.netloc)
|
|
231
|
+
#}
|
|
199
232
|
|
|
200
233
|
if additional_headers:
|
|
201
234
|
headers.update(additional_headers)
|
|
@@ -203,6 +236,9 @@ async def proxy_pass_websocket(
|
|
|
203
236
|
# Use subprotocols from scope if not provided explicitly
|
|
204
237
|
supported_subprotocols = subprotocols or websocket.scope.get("subprotocols")
|
|
205
238
|
|
|
239
|
+
# Strip hop-to-hop headers
|
|
240
|
+
headers = {k: v for k, v in headers.items() if k.lower() not in WS_HANDSHAKE_HEADERS}
|
|
241
|
+
|
|
206
242
|
try:
|
|
207
243
|
# Determine the correct header parameter name for this version of websockets
|
|
208
244
|
# Modern (12.0+): additional_headers, Legacy: extra_headers
|
|
@@ -211,7 +247,8 @@ async def proxy_pass_websocket(
|
|
|
211
247
|
|
|
212
248
|
connect_kwargs = {
|
|
213
249
|
header_param: headers,
|
|
214
|
-
"subprotocols": supported_subprotocols
|
|
250
|
+
"subprotocols": supported_subprotocols,
|
|
251
|
+
"open_timeout": timeout
|
|
215
252
|
}
|
|
216
253
|
|
|
217
254
|
async with websockets.connect(url, **connect_kwargs) as target_ws:
|
|
@@ -219,9 +256,27 @@ async def proxy_pass_websocket(
|
|
|
219
256
|
await websocket.accept(subprotocol=target_ws.subprotocol)
|
|
220
257
|
await _handle_ws_bidirectional(websocket, target_ws)
|
|
221
258
|
|
|
259
|
+
except websockets.exceptions.InvalidStatus as e:
|
|
260
|
+
status = e.response.status_code
|
|
261
|
+
logger.error(f"WebSocket handshake rejected by upstream: {status}")
|
|
262
|
+
try:
|
|
263
|
+
raise HTTPException(status_code=status, detail=f"Upstream rejected WebSocket handshake: {status}")
|
|
264
|
+
except RuntimeError: # If already accepted, we can't raise HTTPException
|
|
265
|
+
pass
|
|
266
|
+
raise e
|
|
267
|
+
|
|
222
268
|
except BaseException as e:
|
|
223
269
|
if not isinstance(e, asyncio.CancelledError):
|
|
224
|
-
|
|
270
|
+
# If the connection fails before accept(), we can raise a proper 502
|
|
271
|
+
if not websocket.client.connected: # Roughly checking if handshake finished
|
|
272
|
+
logger.error(f"WebSocket Connection Error: {e}")
|
|
273
|
+
# This is a bit tricky in WS, but if we haven't accepted yet, we can raise
|
|
274
|
+
try:
|
|
275
|
+
raise HTTPException(status_code=502, detail="Bad Gateway: WebSocket connection failed")
|
|
276
|
+
except RuntimeError: # If already accepted, we can't raise HTTPException
|
|
277
|
+
pass
|
|
278
|
+
else:
|
|
279
|
+
logger.error(f"WebSocket Proxy Error: {e}")
|
|
225
280
|
raise e
|
|
226
281
|
finally:
|
|
227
282
|
try:
|
|
@@ -230,9 +285,6 @@ async def proxy_pass_websocket(
|
|
|
230
285
|
pass
|
|
231
286
|
|
|
232
287
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
288
|
async def _handle_ws_bidirectional(websocket: WebSocket, target_ws):
|
|
237
289
|
"""Internal helper to manage bidirectional WS traffic with clean cancellation."""
|
|
238
290
|
async def client_to_target():
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-reverse-proxy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: A robust, streaming-capable reverse proxy for FastAPI including WebSocket support.
|
|
5
5
|
Author-email: Tomás <tomas@suricatingss.xyz>
|
|
6
|
+
License-Expression: MIT
|
|
6
7
|
Classifier: Programming Language :: Python :: 3
|
|
7
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
8
8
|
Classifier: Operating System :: OS Independent
|
|
9
9
|
Classifier: Framework :: FastAPI
|
|
10
10
|
Classifier: Topic :: Internet :: Proxy Servers
|
|
@@ -36,6 +36,8 @@ A robust, streaming-capable reverse proxy for FastAPI/Starlette with built-in **
|
|
|
36
36
|
- **Unified Load Balancing**: Standard Round-Robin or Smart routing using a single utility.
|
|
37
37
|
- **Latency-Based Routing**: Automatically routes traffic to the fastest healthy server (HEAD probe).
|
|
38
38
|
- **Advanced Overrides**: Granular control over headers, body, and HTTP methods.
|
|
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.
|
|
39
41
|
- **Version Agnostic**: Automatically handles `websockets` library version differences (12.0+ vs Legacy).
|
|
40
42
|
|
|
41
43
|
## Quick Start
|
|
@@ -68,11 +70,20 @@ async def index(req: Request):
|
|
|
68
70
|
|
|
69
71
|
```
|
|
70
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
|
+
|
|
71
81
|
## Advanced Examples:
|
|
72
82
|
|
|
73
|
-
Check [
|
|
83
|
+
Check [examples](https://github.com/tfsantos05/fastapi-reverse-proxy/tree/main/examples) for full examples, including:
|
|
74
84
|
- **Websocket Proxy**
|
|
75
85
|
- **Socket.IO Proxy**
|
|
86
|
+
- **Error Handling & Failover** (`examples/error_handling_example.py`)
|
|
76
87
|
|
|
77
88
|
## Advanced Proxying
|
|
78
89
|
|
|
@@ -80,11 +91,13 @@ The `proxy_pass` function and `LoadBalancer.proxy_pass` provide deep customizati
|
|
|
80
91
|
|
|
81
92
|
| Parameter | Type | Description |
|
|
82
93
|
| :--- | :--- | :--- |
|
|
94
|
+
| `timeout` | `float` | Total request timeout in seconds (Default: `60.0`). |
|
|
83
95
|
| `method` | `str` | Force a specific HTTP method (e.g., `"POST"`). |
|
|
84
96
|
| `override_body` | `bytes` | Send custom data instead of the incoming request body. |
|
|
85
97
|
| `additional_headers` | `dict` | Append custom headers to the proxied request. |
|
|
86
98
|
| `override_headers` | `dict` | Use these headers *instead* of original request headers. |
|
|
87
99
|
| `forward_query` | `bool` | Whether to append the incoming query string (Default: `True`). |
|
|
100
|
+
| `override_host` | `str` | Override the outbound `Host` header sent to the target (useful for multi-host/virtual-hosting backends that key off the original requested host). |
|
|
88
101
|
|
|
89
102
|
## Monitoring & Configuration
|
|
90
103
|
|
|
@@ -115,8 +128,56 @@ The library implements "deferred negotiation" for WebSockets:
|
|
|
115
128
|
2. It establishes an upstream connection first.
|
|
116
129
|
3. Once the upstream accepts a protocol, the proxy calls `websocket.accept(subprotocol=...)` back to the client.
|
|
117
130
|
4. This ensures the entire tunnel (Client <-> Proxy <-> Upstream) uses the same negotiated protocol.
|
|
131
|
+
5. **Handshake Timeout**: Supports a customizable `timeout` parameter (default `10.0s`) to prevent hangs if the backend is unresponsive.
|
|
118
132
|
|
|
119
133
|
## Robustness & Safety
|
|
120
134
|
|
|
121
135
|
- **Termination Safety**: Resource cleanup (closing `httpx` clients and sockets) is triggered even on task cancellation (`BaseException`).
|
|
122
136
|
- **Introspection-Based Compatibility**: Uses `inspect.signature` to automatically detect version-specific parameters in the `websockets` library.
|
|
137
|
+
- **RFC 7230 Compliant Header Handling**: Hop-by-hop headers (`Connection`,
|
|
138
|
+
`Transfer-Encoding`, `TE`, `Trailers`, `Keep-Alive`, `Proxy-Authenticate`,
|
|
139
|
+
`Proxy-Authorization`) are stripped from both outbound requests and responses,
|
|
140
|
+
per spec. WebSocket handshake headers (`Sec-WebSocket-Key`, `Upgrade`, etc.)
|
|
141
|
+
from the client are never forwarded to the target, avoiding handshake collisions.
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
## Running Behind a Reverse Proxy (Nginx/Apache)
|
|
146
|
+
|
|
147
|
+
By default, `proxy_pass` and `proxy_pass_websocket` forward the client's original
|
|
148
|
+
headers as-is — they do **not** set or rewrite `X-Real-IP`, `X-Forwarded-For`,
|
|
149
|
+
`X-Forwarded-Proto`, or `X-Forwarded-Host`. If this library sits behind Nginx or
|
|
150
|
+
Apache (the common setup), your upstream server is responsible for setting those
|
|
151
|
+
headers before the request reaches this proxy:
|
|
152
|
+
|
|
153
|
+
```nginx
|
|
154
|
+
location / {
|
|
155
|
+
proxy_pass http://your-fastapi-app;
|
|
156
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
157
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
158
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
159
|
+
proxy_set_header Host $host;
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
For WebSocket routes, Nginx also needs explicit upgrade handling:
|
|
164
|
+
|
|
165
|
+
```nginx
|
|
166
|
+
map $http_upgrade $connection_upgrade {
|
|
167
|
+
default upgrade;
|
|
168
|
+
'' close;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
location /ws/ {
|
|
172
|
+
proxy_pass http://your-fastapi-app;
|
|
173
|
+
proxy_http_version 1.1;
|
|
174
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
175
|
+
proxy_set_header Connection $connection_upgrade;
|
|
176
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
177
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
178
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Without this configuration, `X-Forwarded-*` headers will be empty or missing by
|
|
183
|
+
the time they reach your application.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/src/fastapi_reverse_proxy/proxy_httpx.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|