👺

FastAPIで重いファイル生成を安全にダウンロードさせる

に公開

FastAPIでCSVなどのファイルを生成してダウンロードさせるとき、実装次第でタイムアウトやメモリ枯渇が起きる。
StreamingResponseを使っていても実装次第で普通にタイムアウトする。

レスポンス開始が「CSV生成後」になる実装

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import csv
import io

app = FastAPI()

@app.get("/csv")
def download_csv():
    buf = io.StringIO()
    writer = csv.writer(buf)

    writer.writerow(["id", "name", "comment"])
    for i in range(1_000_000):
        writer.writerow([i, "user", 'hello, "world"\n'])

    buf.seek(0)
    return StreamingResponse(
        buf,
        media_type="text/csv",
        headers={
            "Content-Disposition": "attachment; filename=users.csv"
        }
    )

この実装の特徴

  • CSVをすべて生成し終えてからレスポンスが始まる
  • StreamingResponseを使っていても実質バッファリング
  • 生成が重いと timeout しやすい

レスポンス開始が「CSV生成途中」になる実装

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import csv
import io

app = FastAPI()

@app.get("/csv")
def download_csv():
    def iter_csv():
        buf = io.StringIO()
        writer = csv.writer(buf)

        writer.writerow(["id", "name", "comment"])
        yield buf.getvalue()
        buf.seek(0)
        buf.truncate(0)

        for i in range(1_000_000):
            writer.writerow([i, "user", 'hello, "world"\n'])
            yield buf.getvalue()
            buf.seek(0)
            buf.truncate(0)

    return StreamingResponse(
        iter_csv(),
        media_type="text/csv",
        headers={
            "Content-Disposition": "attachment; filename=users.csv"
        }
    )

この実装の特徴

  • 1行生成するたびにレスポンスが進む
  • レスポンスがすぐに開始される
  • メモリ使用量が小さい
  • 大きなCSVでも安定しやすい

実装を分けているポイント

  • StreamingResponseかどうかではなく「いつ yield されるか」
  • 「全部生成 → 返す」と「生成しながら返す」の違い
  • 重い処理ほど後者が効く

Discussion