FastAPI/Starlette+Requests实现反向代理

因为某些拉垮的业务需要,不得不在代码里去反向代理别的 HTTP API

一般格式如下:

  @app.get('/other/{other_path:path}')
  @app.post('/other/{other_path:path}')
  async def other_api(other_path: str, req: Request):
      """透传 API"""
      host = 'http://example.intranet'
      url = '{}/other/{}'.format(host, other_path)
      body = bytes(await req.body()) or None
      r = requests.request(
          req.method, url,
          headers={
              'Cookie': req.headers.get('cookie') or '',
              'Content-Type': req.headers.get('Content-Type')},
          params=req.query_params, data=body, stream=True,
          allow_redirects=False)

      h = dict(r.headers)
      h.pop('Content-Length', None)
      return StreamingResponse(r.raw, headers=h, status_code=r.status_code)

解释一下为啥要这么写:

  1. 采用 stream=True + StreamingResponse,防止上游给你返回一个GB体量的文件把 FastAPI 进程内存撑爆
  2. 只摘取浏览器的 Cookie + Content-Type 两个头。别的不给上游。
  3. 要去掉返回的 Content-Length 头。因为 StreamingResponse 会自己定义返回长度。

这样写基本能跑起来了,但是有2个问题:

  1. 如果上游返回一个 302/303/307 的跳转,那么很有可能把内网的跳转网址直接返回给浏览器了。因为 HTTP/1.1 的标准 RFC2616 强制规定,Location 的值必须是一个绝对网址。所以这个时候就冒犯一下这个规定,强行截断:
        loc = h.pop('Location', '')
        if loc.startswith(host):
            h['Location'] = loc[len(host):]
    

注意这里不要用 .lstrip() 。这方法和你想象的完全不是一回事。它是基于单个字符的挨个替换而不是整个字符串。
2. 实测这种在业务代码实现的「软」反向代理性能堪忧。上游4s左右的整个页面加载时间,反向代理之后就变成了 120s 左右。盲猜是 StreamingResponse 是一个字节一个字节去遍历迭代器?实测并不是。主要的锅还是 Requests.raw 是按照 chunked 一段一段返回的。最好还是准备个 4KB 的 buffer,提高性能。解决方案是把 r.raw 改成 r.raw.stream(4096000) 性能一下就高了。注意这里不要用官方文档提供的 .iter_contents() 或者 .iter_lines() 这两者又会自作聪明的去解析文本编码或者换行符,降低了性能。

Comments