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.
Files changed (15) hide show
  1. {fastapi_reverse_proxy-0.3.0/src/fastapi_reverse_proxy.egg-info → fastapi_reverse_proxy-0.4.0}/PKG-INFO +72 -3
  2. {fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/README.md +68 -1
  3. {fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/pyproject.toml +6 -2
  4. {fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/src/fastapi_reverse_proxy/proxy_pass.py +76 -50
  5. {fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0/src/fastapi_reverse_proxy.egg-info}/PKG-INFO +72 -3
  6. {fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/LICENSE +0 -0
  7. {fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/setup.cfg +0 -0
  8. {fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/src/fastapi_reverse_proxy/__init__.py +0 -0
  9. {fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/src/fastapi_reverse_proxy/health_check.py +0 -0
  10. {fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/src/fastapi_reverse_proxy/load_balance.py +0 -0
  11. {fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/src/fastapi_reverse_proxy/proxy_httpx.py +0 -0
  12. {fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/src/fastapi_reverse_proxy.egg-info/SOURCES.txt +0 -0
  13. {fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/src/fastapi_reverse_proxy.egg-info/dependency_links.txt +0 -0
  14. {fastapi_reverse_proxy-0.3.0 → fastapi_reverse_proxy-0.4.0}/src/fastapi_reverse_proxy.egg-info/requires.txt +0 -0
  15. {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.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` | Send custom data instead of the incoming request body. |
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` | Send custom data instead of the incoming request body. |
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.3.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"]
@@ -1,10 +1,13 @@
1
- from fastapi import Request, WebSocket, Response, HTTPException
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
- # Hop-by-hop headers that should typically not be forwarded by a proxy
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
- # Let httpx handle the host header and connection management
95
- headers.pop("host", None)
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
- content = override_body
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.aiter_bytes(),
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
- # If the connection fails before accept(), we can raise a proper 502
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
- # 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
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
- raise e
251
- finally:
252
- try:
253
- await websocket.close()
254
- except Exception:
255
- pass
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
- except (Exception, asyncio.CancelledError):
272
- # Exit loop on error or cancellation
273
- pass
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 (Exception, asyncio.CancelledError, websockets.ConnectionClosed):
284
- # Exit loop on error, closure, or cancellation
285
- pass
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.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` | Send custom data instead of the incoming request body. |
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.