6

I have a FastAPI application for testing/development purposes. What I want is for every request that arrives at my main app to be automatically sent, as is, to a different app on another server with exactly the same parameters and same endpoint.

This is not a redirect, because I still want the main app to process requests and return values as usual. I just want to initiate a similar request to a different version of the app on a different server, without waiting for the answer from that server, so that the second app gets the request as if the original request was sent to it.

How can I achieve that? Below is a sample code that I use for handling the request:

@app.post("/my_endpoint/some_parameters")
def process_request(
    params: MyParamsClass,
    pwd: str = Depends(authenticate),
):
    # send the same request to http://my_other_url/my_endpoint/
    return_value = process_the_request(params)
    return return_value.as_json()
0

1 Answer 1

18

You could use the AsyncClient() from the httpx library, as described in this answer, as well as this answer and this answer (have a look at those answers for more details on the approach demonstrated below). You can spawn a Client inside the startup event handler, store it on the app instance—as described here, as well as here and here—and reuse it every time you need it. You can explicitly close the Client once you are done with it, using the shutdown event handler (Update: Since startup and shutdown events are now deprecated and might be completely removed in the future, the example below has been updated based on this answer, which demonstrates how to use a lifespan event handler instead).

Working Example

The Main Server

When building the request that is about to be forwarded to the other server, the main server uses request.stream() to read the client's request body in chunks. The request.stream() method provides an async iterator, so that if the client sent a request with some large body (for instance, the client uploaded a large file), the main server would not have to wait for the entire body to be received and loaded into memory, before forwarding the request, which would be the case when using await request.body() instead (and which would likely cause server issues, if the entire request body could not fit into the server's RAM).

You could add multiple routes in the same way the /upload one has been defined below, specifying the path, as well as the HTTP method for the endpoint. Note that the /upload route below uses Starlette's path convertor to capture arbitrary paths, as demonstrated here and here. You could also specify the exact path parameters, if you wish, but the example below provides a more convenient way, if there were multiple path parameters. Regardless, the path will be evaluated against the endpoint in the other server to which the request should be forwarded (demonstrated below), where you could explicitly specify the path parameters.

from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
from starlette.background import BackgroundTask
from contextlib import asynccontextmanager
import httpx


@asynccontextmanager
async def lifespan(app: FastAPI):
    # Initialize the Client on startup and add it to the state
    # http://127.0.0.1:8001/ is the base_url of the other server that requests should be forwarded to
    async with httpx.AsyncClient(base_url='http://127.0.0.1:8001/') as client:
        yield {'client': client}
        # The Client closes on shutdown 


app = FastAPI(lifespan=lifespan)


async def _reverse_proxy(request: Request):
    client = request.state.client
    url = httpx.URL(path=request.url.path, query=request.url.query.encode('utf-8'))
    headers = [(k, v) for k, v in request.headers.raw if k != b'host']
    req = client.build_request(
        request.method, url, headers=headers, content=request.stream()
    )
    r = await client.send(req, stream=True)
    return StreamingResponse(
        r.aiter_raw(),
        status_code=r.status_code,
        headers=r.headers,
        background=BackgroundTask(r.aclose)
    )


app.add_route('/upload/{path:path}', _reverse_proxy, ['POST'])


if __name__ == '__main__':
    import uvicorn
    uvicorn.run(app, host='0.0.0.0', port=8000)

The Other Server

Again, for the simplicity of this example, the Request object is used to read the requet body, but you could isntead define UploadFile, Form, Pydantic models and other parameters/dependencies as usual, which would be useful for validation purposes as well (see related answers here and here). In the example below, the server is listenning on port 8001.

from fastapi import FastAPI, Request


app = FastAPI()


@app.post('/upload/{p1}/{p2}')
async def upload(p1: str, p2: str, q1: str, request: Request):
    body = await request.body()
    print(f'p1: {p1}, p2: {p2}, q1: {q1}\nbody: {body}\n')
    return 'OK'
    
    
if __name__ == '__main__':
    import uvicorn
    uvicorn.run(app, host='0.0.0.0', port=8001)

Test the above example using httpx

import httpx

url = 'http://127.0.0.1:8000/upload/hello/world'
params = {'q1': 'This is a query param'}

# send a multipart/form-data encoded request
files = {'file': open('file.txt', 'rb')}
r = httpx.post(url, params=params, files=files)
print(r.content)

# send an application/json encoded request
payload = {'msg': 'This is JSON data'}
r = httpx.post(url, params=params, json=payload)
print(r.content)
Sign up to request clarification or add additional context in comments.

1 Comment

when forwarding the http request to the other server you probably shouldn't set the original host header, as the receiving server will most of the time not accept this. So instead of headers=request.headers.raw one could write headers=[(key, value) for key, value in request.headers.raw if key != b'host']

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.