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.
Files changed (15) hide show
  1. {fastapi_reverse_proxy-0.2.0/src/fastapi_reverse_proxy.egg-info → fastapi_reverse_proxy-0.3.1}/PKG-INFO +64 -3
  2. {fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/README.md +62 -1
  3. {fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/pyproject.toml +2 -2
  4. {fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/src/fastapi_reverse_proxy/__init__.py +2 -1
  5. {fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/src/fastapi_reverse_proxy/proxy_pass.py +82 -30
  6. {fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1/src/fastapi_reverse_proxy.egg-info}/PKG-INFO +64 -3
  7. {fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/LICENSE +0 -0
  8. {fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/setup.cfg +0 -0
  9. {fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/src/fastapi_reverse_proxy/health_check.py +0 -0
  10. {fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/src/fastapi_reverse_proxy/load_balance.py +0 -0
  11. {fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/src/fastapi_reverse_proxy/proxy_httpx.py +0 -0
  12. {fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/src/fastapi_reverse_proxy.egg-info/SOURCES.txt +0 -0
  13. {fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/src/fastapi_reverse_proxy.egg-info/dependency_links.txt +0 -0
  14. {fastapi_reverse_proxy-0.2.0 → fastapi_reverse_proxy-0.3.1}/src/fastapi_reverse_proxy.egg-info/requires.txt +0 -0
  15. {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.2.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 [example.py](example.py) for full examples, including
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 [example.py](example.py) for full examples, including
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.2.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",
@@ -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
  ]
@@ -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
- # 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
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
- headers["X-Forwarded-For"] = f"{headers['X-Forwarded-For']}, {client_host}"
74
- else:
75
- headers["X-Forwarded-For"] = client_host
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
- # Let httpx handle the host header and connection management
85
- headers.pop("host", None)
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
- # Re-raise so the server can handle the cancellation/error
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
- "X-Real-IP": client_host,
195
- "X-Forwarded-For": client_host,
196
- "X-Forwarded-Proto": websocket.url.scheme,
197
- "X-Forwarded-Host": websocket.headers.get("host", websocket.url.netloc)
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
- logger.error(f"WebSocket Proxy Error: {e}")
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.2.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 [example.py](example.py) for full examples, including
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.