fastapi-reverse-proxy 0.3.0__tar.gz → 0.4.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.3.0/src/fastapi_reverse_proxy.egg-info → fastapi_reverse_proxy-0.4.0}/PKG-INFO +72 -3
- {fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/README.md +68 -1
- {fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/pyproject.toml +6 -2
- {fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/src/fastapi_reverse_proxy/proxy_pass.py +76 -50
- {fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0/src/fastapi_reverse_proxy.egg-info}/PKG-INFO +72 -3
- {fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/LICENSE +0 -0
- {fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/setup.cfg +0 -0
- {fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/src/fastapi_reverse_proxy/__init__.py +0 -0
- {fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/src/fastapi_reverse_proxy/health_check.py +0 -0
- {fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/src/fastapi_reverse_proxy/load_balance.py +0 -0
- {fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/src/fastapi_reverse_proxy/proxy_httpx.py +0 -0
- {fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/src/fastapi_reverse_proxy.egg-info/SOURCES.txt +0 -0
- {fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/src/fastapi_reverse_proxy.egg-info/dependency_links.txt +0 -0
- {fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/src/fastapi_reverse_proxy.egg-info/requires.txt +0 -0
- {fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/src/fastapi_reverse_proxy.egg-info/top_level.txt +0 -0
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-reverse-proxy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/tfsantos05/fastapi-reverse-proxy
|
|
8
|
+
Project-URL: Issues, https://github.com/tfsantos05/fastapi-reverse-proxy/issues
|
|
6
9
|
Classifier: Programming Language :: Python :: 3
|
|
7
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
8
10
|
Classifier: Operating System :: OS Independent
|
|
9
11
|
Classifier: Framework :: FastAPI
|
|
10
12
|
Classifier: Topic :: Internet :: Proxy Servers
|
|
@@ -93,10 +95,11 @@ The `proxy_pass` function and `LoadBalancer.proxy_pass` provide deep customizati
|
|
|
93
95
|
| :--- | :--- | :--- |
|
|
94
96
|
| `timeout` | `float` | Total request timeout in seconds (Default: `60.0`). |
|
|
95
97
|
| `method` | `str` | Force a specific HTTP method (e.g., `"POST"`). |
|
|
96
|
-
| `override_body` | `bytes` |
|
|
98
|
+
| `override_body` | `bytes \| str \| dict \| list` | Use this instead of streaming the request body. `str` is UTF-8 encoded; `dict`/`list` are JSON-serialized automatically (automatically sets `Content-Type: application/json` if not already set). |
|
|
97
99
|
| `additional_headers` | `dict` | Append custom headers to the proxied request. |
|
|
98
100
|
| `override_headers` | `dict` | Use these headers *instead* of original request headers. |
|
|
99
101
|
| `forward_query` | `bool` | Whether to append the incoming query string (Default: `True`). |
|
|
102
|
+
| `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). |
|
|
100
103
|
|
|
101
104
|
## Monitoring & Configuration
|
|
102
105
|
|
|
@@ -128,8 +131,74 @@ The library implements "deferred negotiation" for WebSockets:
|
|
|
128
131
|
3. Once the upstream accepts a protocol, the proxy calls `websocket.accept(subprotocol=...)` back to the client.
|
|
129
132
|
4. This ensures the entire tunnel (Client <-> Proxy <-> Upstream) uses the same negotiated protocol.
|
|
130
133
|
5. **Handshake Timeout**: Supports a customizable `timeout` parameter (default `10.0s`) to prevent hangs if the backend is unresponsive.
|
|
134
|
+
6. **Error Handling**: Raises `fastapi.WebSocketException` when the upstream connection fails or is rejected with proper WS codes such as 1008 or 1011
|
|
135
|
+
7. **Debug**: Disruptions from either side are logged on debug level.
|
|
131
136
|
|
|
132
137
|
## Robustness & Safety
|
|
133
138
|
|
|
134
139
|
- **Termination Safety**: Resource cleanup (closing `httpx` clients and sockets) is triggered even on task cancellation (`BaseException`).
|
|
135
140
|
- **Introspection-Based Compatibility**: Uses `inspect.signature` to automatically detect version-specific parameters in the `websockets` library.
|
|
141
|
+
- **RFC 7230 Compliant Header Handling**: Hop-by-hop headers (`Connection`,
|
|
142
|
+
`Transfer-Encoding`, `TE`, `Trailers`, `Keep-Alive`, `Proxy-Authenticate`,
|
|
143
|
+
`Proxy-Authorization`) are stripped from both outbound requests and responses,
|
|
144
|
+
per spec. WebSocket handshake headers (`Sec-WebSocket-Key`, `Upgrade`, etc.)
|
|
145
|
+
from the client are never forwarded to the target, avoiding handshake collisions.
|
|
146
|
+
- **Content-Length Safety**: Always stripped from the outbound request and
|
|
147
|
+
recalculated by `httpx` (or sent chunked when streaming), preventing
|
|
148
|
+
`LocalProtocolError` when `override_body` differs in size from the original
|
|
149
|
+
request.
|
|
150
|
+
- **Content-Encoding Passthrough**: Compressed upstream responses (`gzip`, `br`,
|
|
151
|
+
etc.) are streamed back to the client raw via `aiter_raw()`, with
|
|
152
|
+
`Content-Encoding` preserved — the client decompresses it itself, avoiding
|
|
153
|
+
corrupted/garbled response bodies.
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
## Running Behind a Reverse Proxy (Nginx/Apache)
|
|
158
|
+
|
|
159
|
+
By default, `proxy_pass` and `proxy_pass_websocket` forward the client's original
|
|
160
|
+
headers as-is — they do **not** set or rewrite `X-Real-IP`, `X-Forwarded-For`,
|
|
161
|
+
`X-Forwarded-Proto`, or `X-Forwarded-Host`. If this library sits behind Nginx or
|
|
162
|
+
Apache (the common setup), your upstream server is responsible for setting those
|
|
163
|
+
headers before the request reaches this proxy:
|
|
164
|
+
|
|
165
|
+
```nginx
|
|
166
|
+
location / {
|
|
167
|
+
proxy_pass http://your-fastapi-app;
|
|
168
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
169
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
170
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
171
|
+
proxy_set_header Host $host;
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
For WebSocket routes, Nginx also needs explicit upgrade handling:
|
|
176
|
+
|
|
177
|
+
```nginx
|
|
178
|
+
map $http_upgrade $connection_upgrade {
|
|
179
|
+
default upgrade;
|
|
180
|
+
'' close;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
location /ws/ {
|
|
184
|
+
proxy_pass http://your-fastapi-app;
|
|
185
|
+
proxy_http_version 1.1;
|
|
186
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
187
|
+
proxy_set_header Connection $connection_upgrade;
|
|
188
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
189
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
190
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Without this configuration, `X-Forwarded-*` headers will be empty or missing by
|
|
195
|
+
the time they reach your application.
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
### Streaming & Response Buffering
|
|
199
|
+
|
|
200
|
+
`X-Accel-Buffering: no` is set automatically on every proxied response to
|
|
201
|
+
disable Nginx's response buffering for streamed content (SSE, chunked
|
|
202
|
+
transfers, large file downloads). If deploying behind Apache instead, disable
|
|
203
|
+
buffering via your Apache config (`mod_proxy` directives) — there's no
|
|
204
|
+
equivalent response header Apache recognizes.
|
|
@@ -68,10 +68,11 @@ The `proxy_pass` function and `LoadBalancer.proxy_pass` provide deep customizati
|
|
|
68
68
|
| :--- | :--- | :--- |
|
|
69
69
|
| `timeout` | `float` | Total request timeout in seconds (Default: `60.0`). |
|
|
70
70
|
| `method` | `str` | Force a specific HTTP method (e.g., `"POST"`). |
|
|
71
|
-
| `override_body` | `bytes` |
|
|
71
|
+
| `override_body` | `bytes \| str \| dict \| list` | Use this instead of streaming the request body. `str` is UTF-8 encoded; `dict`/`list` are JSON-serialized automatically (automatically sets `Content-Type: application/json` if not already set). |
|
|
72
72
|
| `additional_headers` | `dict` | Append custom headers to the proxied request. |
|
|
73
73
|
| `override_headers` | `dict` | Use these headers *instead* of original request headers. |
|
|
74
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). |
|
|
75
76
|
|
|
76
77
|
## Monitoring & Configuration
|
|
77
78
|
|
|
@@ -103,8 +104,74 @@ The library implements "deferred negotiation" for WebSockets:
|
|
|
103
104
|
3. Once the upstream accepts a protocol, the proxy calls `websocket.accept(subprotocol=...)` back to the client.
|
|
104
105
|
4. This ensures the entire tunnel (Client <-> Proxy <-> Upstream) uses the same negotiated protocol.
|
|
105
106
|
5. **Handshake Timeout**: Supports a customizable `timeout` parameter (default `10.0s`) to prevent hangs if the backend is unresponsive.
|
|
107
|
+
6. **Error Handling**: Raises `fastapi.WebSocketException` when the upstream connection fails or is rejected with proper WS codes such as 1008 or 1011
|
|
108
|
+
7. **Debug**: Disruptions from either side are logged on debug level.
|
|
106
109
|
|
|
107
110
|
## Robustness & Safety
|
|
108
111
|
|
|
109
112
|
- **Termination Safety**: Resource cleanup (closing `httpx` clients and sockets) is triggered even on task cancellation (`BaseException`).
|
|
110
113
|
- **Introspection-Based Compatibility**: Uses `inspect.signature` to automatically detect version-specific parameters in the `websockets` library.
|
|
114
|
+
- **RFC 7230 Compliant Header Handling**: Hop-by-hop headers (`Connection`,
|
|
115
|
+
`Transfer-Encoding`, `TE`, `Trailers`, `Keep-Alive`, `Proxy-Authenticate`,
|
|
116
|
+
`Proxy-Authorization`) are stripped from both outbound requests and responses,
|
|
117
|
+
per spec. WebSocket handshake headers (`Sec-WebSocket-Key`, `Upgrade`, etc.)
|
|
118
|
+
from the client are never forwarded to the target, avoiding handshake collisions.
|
|
119
|
+
- **Content-Length Safety**: Always stripped from the outbound request and
|
|
120
|
+
recalculated by `httpx` (or sent chunked when streaming), preventing
|
|
121
|
+
`LocalProtocolError` when `override_body` differs in size from the original
|
|
122
|
+
request.
|
|
123
|
+
- **Content-Encoding Passthrough**: Compressed upstream responses (`gzip`, `br`,
|
|
124
|
+
etc.) are streamed back to the client raw via `aiter_raw()`, with
|
|
125
|
+
`Content-Encoding` preserved — the client decompresses it itself, avoiding
|
|
126
|
+
corrupted/garbled response bodies.
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
## Running Behind a Reverse Proxy (Nginx/Apache)
|
|
131
|
+
|
|
132
|
+
By default, `proxy_pass` and `proxy_pass_websocket` forward the client's original
|
|
133
|
+
headers as-is — they do **not** set or rewrite `X-Real-IP`, `X-Forwarded-For`,
|
|
134
|
+
`X-Forwarded-Proto`, or `X-Forwarded-Host`. If this library sits behind Nginx or
|
|
135
|
+
Apache (the common setup), your upstream server is responsible for setting those
|
|
136
|
+
headers before the request reaches this proxy:
|
|
137
|
+
|
|
138
|
+
```nginx
|
|
139
|
+
location / {
|
|
140
|
+
proxy_pass http://your-fastapi-app;
|
|
141
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
142
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
143
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
144
|
+
proxy_set_header Host $host;
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
For WebSocket routes, Nginx also needs explicit upgrade handling:
|
|
149
|
+
|
|
150
|
+
```nginx
|
|
151
|
+
map $http_upgrade $connection_upgrade {
|
|
152
|
+
default upgrade;
|
|
153
|
+
'' close;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
location /ws/ {
|
|
157
|
+
proxy_pass http://your-fastapi-app;
|
|
158
|
+
proxy_http_version 1.1;
|
|
159
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
160
|
+
proxy_set_header Connection $connection_upgrade;
|
|
161
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
162
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
163
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Without this configuration, `X-Forwarded-*` headers will be empty or missing by
|
|
168
|
+
the time they reach your application.
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
### Streaming & Response Buffering
|
|
172
|
+
|
|
173
|
+
`X-Accel-Buffering: no` is set automatically on every proxied response to
|
|
174
|
+
disable Nginx's response buffering for streamed content (SSE, chunked
|
|
175
|
+
transfers, large file downloads). If deploying behind Apache instead, disable
|
|
176
|
+
buffering via your Apache config (`mod_proxy` directives) — there's no
|
|
177
|
+
equivalent response header Apache recognizes.
|
|
@@ -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.4.0"
|
|
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",
|
|
@@ -31,5 +31,9 @@ dependencies = [
|
|
|
31
31
|
"websockets>=16.0"
|
|
32
32
|
]
|
|
33
33
|
|
|
34
|
+
[project.urls]
|
|
35
|
+
Repository = "https://github.com/tfsantos05/fastapi-reverse-proxy"
|
|
36
|
+
Issues = "https://github.com/tfsantos05/fastapi-reverse-proxy/issues"
|
|
37
|
+
|
|
34
38
|
[tool.setuptools.packages.find]
|
|
35
39
|
where = ["src"]
|
{fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/src/fastapi_reverse_proxy/proxy_pass.py
RENAMED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
from fastapi import Request, WebSocket,
|
|
1
|
+
from fastapi import Request, WebSocket, HTTPException, WebSocketException
|
|
2
|
+
from fastapi import status as http_codes
|
|
3
|
+
from fastapi.websockets import WebSocketState
|
|
2
4
|
from fastapi.responses import StreamingResponse
|
|
3
5
|
from starlette.background import BackgroundTask
|
|
4
6
|
from url_normalize import url_normalize
|
|
5
7
|
import httpx
|
|
6
8
|
import websockets
|
|
7
9
|
import asyncio
|
|
10
|
+
import json
|
|
8
11
|
import logging
|
|
9
12
|
import inspect
|
|
10
13
|
from typing import Optional
|
|
@@ -13,13 +16,20 @@ from urllib.parse import urlparse
|
|
|
13
16
|
|
|
14
17
|
logger = logging.getLogger("fastapi_reverse_proxy")
|
|
15
18
|
|
|
16
|
-
#
|
|
17
|
-
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/TE
|
|
19
|
+
# RFC 7230 §6.1 — hop-by-hop headers, strip on every proxied HTTP request/response
|
|
18
20
|
EXCLUDED_HEADERS = {
|
|
19
|
-
"connection", "keep-alive", "proxy-authenticate",
|
|
21
|
+
"connection", "keep-alive", "proxy-authenticate",
|
|
20
22
|
"proxy-authorization", "te", "trailers", "transfer-encoding", "upgrade"
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
# WebSocket handshake headers — belong to ONE handshake (client<->you),
|
|
26
|
+
# must not be reused for the separate handshake you<->target
|
|
27
|
+
WS_HANDSHAKE_HEADERS = {
|
|
28
|
+
"connection", "upgrade", "host", "sec-websocket-key",
|
|
29
|
+
"sec-websocket-version", "sec-websocket-extensions",
|
|
30
|
+
"sec-websocket-protocol"
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
def url_normalize_ws(url:str):
|
|
24
34
|
u = urlparse(url)
|
|
25
35
|
return url_normalize(url.replace(u.scheme, "http", 1)).replace("http", u.scheme, 1)
|
|
@@ -43,8 +53,9 @@ async def proxy_pass(
|
|
|
43
53
|
forward_query: bool = True,
|
|
44
54
|
additional_headers: Optional[dict] = None,
|
|
45
55
|
override_headers: Optional[dict] = None,
|
|
46
|
-
override_body: Optional[bytes] = None,
|
|
47
|
-
method: Optional[str] = None
|
|
56
|
+
override_body: Optional[bytes | list | dict | str] = None,
|
|
57
|
+
method: Optional[str] = None,
|
|
58
|
+
override_host: Optional[str] = None
|
|
48
59
|
):
|
|
49
60
|
"""
|
|
50
61
|
Forwards incoming HTTP requests to the target service using streaming.
|
|
@@ -76,25 +87,27 @@ async def proxy_pass(
|
|
|
76
87
|
headers = dict(override_headers)
|
|
77
88
|
else:
|
|
78
89
|
headers = dict(request.headers)
|
|
79
|
-
# Identify the client's real IP and forward it
|
|
80
|
-
client_host = request.client.host if request.client else "unknown"
|
|
81
|
-
headers["X-Real-IP"] = client_host
|
|
82
|
-
if "X-Forwarded-For" in headers:
|
|
83
|
-
headers["X-Forwarded-For"] = f"{headers['X-Forwarded-For']}, {client_host}"
|
|
84
|
-
else:
|
|
85
|
-
headers["X-Forwarded-For"] = client_host
|
|
86
|
-
|
|
87
|
-
headers["X-Forwarded-Proto"] = request.url.scheme
|
|
88
|
-
headers["X-Forwarded-Host"] = headers.get("host", request.url.netloc)
|
|
89
90
|
|
|
90
91
|
# Apply additional headers
|
|
91
92
|
if additional_headers:
|
|
92
93
|
headers.update(additional_headers)
|
|
93
94
|
|
|
94
|
-
|
|
95
|
-
|
|
95
|
+
if override_host:
|
|
96
|
+
# use the supplied host header
|
|
97
|
+
headers['host'] = override_host
|
|
98
|
+
else:
|
|
99
|
+
# Let httpx handle the host header
|
|
100
|
+
headers.pop("host", None)
|
|
101
|
+
|
|
102
|
+
# Let httpx handle connection management
|
|
96
103
|
headers.pop("connection", None)
|
|
97
104
|
|
|
105
|
+
# HTTPX should calculate this one automatically. No point in keeping it
|
|
106
|
+
headers.pop("content-length", None)
|
|
107
|
+
|
|
108
|
+
# Remove hop-to-hop headers
|
|
109
|
+
headers = {k: v for k, v in headers.items() if k.lower() not in EXCLUDED_HEADERS}
|
|
110
|
+
|
|
98
111
|
client = None
|
|
99
112
|
try:
|
|
100
113
|
client = await get_httpx_client(request)
|
|
@@ -107,7 +120,16 @@ async def proxy_pass(
|
|
|
107
120
|
try:
|
|
108
121
|
# Prepare content
|
|
109
122
|
if override_body is not None:
|
|
110
|
-
|
|
123
|
+
if isinstance(override_body, (list, dict)):
|
|
124
|
+
# Stringify the JSON and convert to binary
|
|
125
|
+
content = json.dumps(override_body).encode()
|
|
126
|
+
# If no content type is set, set to JSON
|
|
127
|
+
headers.setdefault("content-type","application/json")
|
|
128
|
+
elif isinstance(override_body, str):
|
|
129
|
+
# Convert to binary if it's a string
|
|
130
|
+
content = override_body.encode()
|
|
131
|
+
else:
|
|
132
|
+
content = override_body
|
|
111
133
|
else:
|
|
112
134
|
# Stream the request body to the target (efficient for large uploads)
|
|
113
135
|
async def request_generator():
|
|
@@ -133,14 +155,12 @@ async def proxy_pass(
|
|
|
133
155
|
for k, v in rp_resp.headers.items():
|
|
134
156
|
if k.lower() in EXCLUDED_HEADERS:
|
|
135
157
|
continue
|
|
136
|
-
if k.lower() == "content-encoding":
|
|
137
|
-
continue
|
|
138
158
|
if k.lower() == "content-length":
|
|
139
159
|
continue
|
|
140
160
|
resp_headers[k] = v
|
|
141
|
-
|
|
161
|
+
|
|
162
|
+
# Prevents NGINX from buffering
|
|
142
163
|
resp_headers["X-Accel-Buffering"] = "no"
|
|
143
|
-
resp_headers["Cache-Control"] = "no-cache"
|
|
144
164
|
|
|
145
165
|
async def cleanup():
|
|
146
166
|
await rp_resp.aclose()
|
|
@@ -148,7 +168,7 @@ async def proxy_pass(
|
|
|
148
168
|
await client.aclose()
|
|
149
169
|
|
|
150
170
|
return StreamingResponse(
|
|
151
|
-
rp_resp.
|
|
171
|
+
rp_resp.aiter_raw(),
|
|
152
172
|
status_code=rp_resp.status_code,
|
|
153
173
|
headers=resp_headers,
|
|
154
174
|
background=BackgroundTask(cleanup)
|
|
@@ -204,13 +224,8 @@ async def proxy_pass_websocket(
|
|
|
204
224
|
if override_headers is not None:
|
|
205
225
|
headers = dict(override_headers)
|
|
206
226
|
else:
|
|
207
|
-
client_host = websocket.client.host if websocket.client else "unknown"
|
|
208
|
-
headers =
|
|
209
|
-
"X-Real-IP": client_host,
|
|
210
|
-
"X-Forwarded-For": client_host,
|
|
211
|
-
"X-Forwarded-Proto": websocket.url.scheme,
|
|
212
|
-
"X-Forwarded-Host": websocket.headers.get("host", websocket.url.netloc)
|
|
213
|
-
}
|
|
227
|
+
#client_host = websocket.client.host if websocket.client else "unknown"
|
|
228
|
+
headers = dict(websocket.headers)
|
|
214
229
|
|
|
215
230
|
if additional_headers:
|
|
216
231
|
headers.update(additional_headers)
|
|
@@ -218,6 +233,9 @@ async def proxy_pass_websocket(
|
|
|
218
233
|
# Use subprotocols from scope if not provided explicitly
|
|
219
234
|
supported_subprotocols = subprotocols or websocket.scope.get("subprotocols")
|
|
220
235
|
|
|
236
|
+
# Strip hop-to-hop headers
|
|
237
|
+
headers = {k: v for k, v in headers.items() if k.lower() not in WS_HANDSHAKE_HEADERS}
|
|
238
|
+
|
|
221
239
|
try:
|
|
222
240
|
# Determine the correct header parameter name for this version of websockets
|
|
223
241
|
# Modern (12.0+): additional_headers, Legacy: extra_headers
|
|
@@ -235,24 +253,28 @@ async def proxy_pass_websocket(
|
|
|
235
253
|
await websocket.accept(subprotocol=target_ws.subprotocol)
|
|
236
254
|
await _handle_ws_bidirectional(websocket, target_ws)
|
|
237
255
|
|
|
256
|
+
# Graceful close
|
|
257
|
+
if websocket.client_state == WebSocketState.CONNECTED:
|
|
258
|
+
await websocket.close()
|
|
259
|
+
|
|
260
|
+
except websockets.exceptions.InvalidStatus as e:
|
|
261
|
+
status = e.response.status_code
|
|
262
|
+
logger.error(f"WebSocket handshake rejected by upstream: {status}")
|
|
263
|
+
raise WebSocketException(code=http_codes.WS_1008_POLICY_VIOLATION, reason=f"WebSocket handshake rejected by upstream: {status}"[:123])
|
|
264
|
+
|
|
238
265
|
except BaseException as e:
|
|
239
266
|
if not isinstance(e, asyncio.CancelledError):
|
|
240
|
-
|
|
241
|
-
if not websocket.client.connected: # Roughly checking if handshake finished
|
|
267
|
+
if websocket.client_state != WebSocketState.CONNECTED: # Roughly checking if handshake finished
|
|
242
268
|
logger.error(f"WebSocket Connection Error: {e}")
|
|
243
|
-
|
|
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
|
|
269
|
+
raise WebSocketException(code=http_codes.WS_1011_INTERNAL_ERROR, reason=f"Bad Gateway: {e}"[:123])
|
|
248
270
|
else:
|
|
249
271
|
logger.error(f"WebSocket Proxy Error: {e}")
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
272
|
+
raise WebSocketException(code=http_codes.WS_1008_POLICY_VIOLATION, reason=f"WebSocket Proxy Error: {e}"[:123]) # max 123 byte
|
|
273
|
+
else:
|
|
274
|
+
if websocket.client_state == WebSocketState.CONNECTED:
|
|
275
|
+
try: await websocket.close(code=http_codes.WS_1012_SERVICE_RESTART)
|
|
276
|
+
except: pass
|
|
277
|
+
raise e
|
|
256
278
|
|
|
257
279
|
|
|
258
280
|
async def _handle_ws_bidirectional(websocket: WebSocket, target_ws):
|
|
@@ -268,9 +290,12 @@ async def _handle_ws_bidirectional(websocket: WebSocket, target_ws):
|
|
|
268
290
|
await target_ws.send(message["bytes"])
|
|
269
291
|
elif message["type"] == "websocket.disconnect":
|
|
270
292
|
break
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
293
|
+
# Exit the loop on error or in cancellation
|
|
294
|
+
except asyncio.CancelledError: pass # Just cancelled, no error
|
|
295
|
+
except Exception as e:
|
|
296
|
+
# Log on Debug level
|
|
297
|
+
logger.debug(f"WS relay (client→target) ended: {e}")
|
|
298
|
+
|
|
274
299
|
|
|
275
300
|
async def target_to_client():
|
|
276
301
|
try:
|
|
@@ -280,9 +305,10 @@ async def _handle_ws_bidirectional(websocket: WebSocket, target_ws):
|
|
|
280
305
|
await websocket.send_text(message)
|
|
281
306
|
else:
|
|
282
307
|
await websocket.send_bytes(message)
|
|
283
|
-
except (
|
|
284
|
-
|
|
285
|
-
|
|
308
|
+
except (asyncio.CancelledError, websockets.ConnectionClosed): pass # cancelled or close -> no error
|
|
309
|
+
except Exception as e:
|
|
310
|
+
# Log on Debug Level
|
|
311
|
+
logger.debug(f"WS relay (target→client) ended: {e}")
|
|
286
312
|
|
|
287
313
|
# Wrap in tasks for cancellation
|
|
288
314
|
tasks = [
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-reverse-proxy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/tfsantos05/fastapi-reverse-proxy
|
|
8
|
+
Project-URL: Issues, https://github.com/tfsantos05/fastapi-reverse-proxy/issues
|
|
6
9
|
Classifier: Programming Language :: Python :: 3
|
|
7
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
8
10
|
Classifier: Operating System :: OS Independent
|
|
9
11
|
Classifier: Framework :: FastAPI
|
|
10
12
|
Classifier: Topic :: Internet :: Proxy Servers
|
|
@@ -93,10 +95,11 @@ The `proxy_pass` function and `LoadBalancer.proxy_pass` provide deep customizati
|
|
|
93
95
|
| :--- | :--- | :--- |
|
|
94
96
|
| `timeout` | `float` | Total request timeout in seconds (Default: `60.0`). |
|
|
95
97
|
| `method` | `str` | Force a specific HTTP method (e.g., `"POST"`). |
|
|
96
|
-
| `override_body` | `bytes` |
|
|
98
|
+
| `override_body` | `bytes \| str \| dict \| list` | Use this instead of streaming the request body. `str` is UTF-8 encoded; `dict`/`list` are JSON-serialized automatically (automatically sets `Content-Type: application/json` if not already set). |
|
|
97
99
|
| `additional_headers` | `dict` | Append custom headers to the proxied request. |
|
|
98
100
|
| `override_headers` | `dict` | Use these headers *instead* of original request headers. |
|
|
99
101
|
| `forward_query` | `bool` | Whether to append the incoming query string (Default: `True`). |
|
|
102
|
+
| `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). |
|
|
100
103
|
|
|
101
104
|
## Monitoring & Configuration
|
|
102
105
|
|
|
@@ -128,8 +131,74 @@ The library implements "deferred negotiation" for WebSockets:
|
|
|
128
131
|
3. Once the upstream accepts a protocol, the proxy calls `websocket.accept(subprotocol=...)` back to the client.
|
|
129
132
|
4. This ensures the entire tunnel (Client <-> Proxy <-> Upstream) uses the same negotiated protocol.
|
|
130
133
|
5. **Handshake Timeout**: Supports a customizable `timeout` parameter (default `10.0s`) to prevent hangs if the backend is unresponsive.
|
|
134
|
+
6. **Error Handling**: Raises `fastapi.WebSocketException` when the upstream connection fails or is rejected with proper WS codes such as 1008 or 1011
|
|
135
|
+
7. **Debug**: Disruptions from either side are logged on debug level.
|
|
131
136
|
|
|
132
137
|
## Robustness & Safety
|
|
133
138
|
|
|
134
139
|
- **Termination Safety**: Resource cleanup (closing `httpx` clients and sockets) is triggered even on task cancellation (`BaseException`).
|
|
135
140
|
- **Introspection-Based Compatibility**: Uses `inspect.signature` to automatically detect version-specific parameters in the `websockets` library.
|
|
141
|
+
- **RFC 7230 Compliant Header Handling**: Hop-by-hop headers (`Connection`,
|
|
142
|
+
`Transfer-Encoding`, `TE`, `Trailers`, `Keep-Alive`, `Proxy-Authenticate`,
|
|
143
|
+
`Proxy-Authorization`) are stripped from both outbound requests and responses,
|
|
144
|
+
per spec. WebSocket handshake headers (`Sec-WebSocket-Key`, `Upgrade`, etc.)
|
|
145
|
+
from the client are never forwarded to the target, avoiding handshake collisions.
|
|
146
|
+
- **Content-Length Safety**: Always stripped from the outbound request and
|
|
147
|
+
recalculated by `httpx` (or sent chunked when streaming), preventing
|
|
148
|
+
`LocalProtocolError` when `override_body` differs in size from the original
|
|
149
|
+
request.
|
|
150
|
+
- **Content-Encoding Passthrough**: Compressed upstream responses (`gzip`, `br`,
|
|
151
|
+
etc.) are streamed back to the client raw via `aiter_raw()`, with
|
|
152
|
+
`Content-Encoding` preserved — the client decompresses it itself, avoiding
|
|
153
|
+
corrupted/garbled response bodies.
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
## Running Behind a Reverse Proxy (Nginx/Apache)
|
|
158
|
+
|
|
159
|
+
By default, `proxy_pass` and `proxy_pass_websocket` forward the client's original
|
|
160
|
+
headers as-is — they do **not** set or rewrite `X-Real-IP`, `X-Forwarded-For`,
|
|
161
|
+
`X-Forwarded-Proto`, or `X-Forwarded-Host`. If this library sits behind Nginx or
|
|
162
|
+
Apache (the common setup), your upstream server is responsible for setting those
|
|
163
|
+
headers before the request reaches this proxy:
|
|
164
|
+
|
|
165
|
+
```nginx
|
|
166
|
+
location / {
|
|
167
|
+
proxy_pass http://your-fastapi-app;
|
|
168
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
169
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
170
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
171
|
+
proxy_set_header Host $host;
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
For WebSocket routes, Nginx also needs explicit upgrade handling:
|
|
176
|
+
|
|
177
|
+
```nginx
|
|
178
|
+
map $http_upgrade $connection_upgrade {
|
|
179
|
+
default upgrade;
|
|
180
|
+
'' close;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
location /ws/ {
|
|
184
|
+
proxy_pass http://your-fastapi-app;
|
|
185
|
+
proxy_http_version 1.1;
|
|
186
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
187
|
+
proxy_set_header Connection $connection_upgrade;
|
|
188
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
189
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
190
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Without this configuration, `X-Forwarded-*` headers will be empty or missing by
|
|
195
|
+
the time they reach your application.
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
### Streaming & Response Buffering
|
|
199
|
+
|
|
200
|
+
`X-Accel-Buffering: no` is set automatically on every proxied response to
|
|
201
|
+
disable Nginx's response buffering for streamed content (SSE, chunked
|
|
202
|
+
transfers, large file downloads). If deploying behind Apache instead, disable
|
|
203
|
+
buffering via your Apache config (`mod_proxy` directives) — there's no
|
|
204
|
+
equivalent response header Apache recognizes.
|
|
File without changes
|
|
File without changes
|
{fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/src/fastapi_reverse_proxy/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/src/fastapi_reverse_proxy/proxy_httpx.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|