動かざることバグの如し

近づきたいよ 君の理想に

fastapiとHTTPXで非同期APIリクエストをやってみる

環境

やりたいこと

fastapiというPythonAPIフレームワークがある

fastapi.tiangolo.com

APIが外部の別のAPIを呼び出すことはよくある。fatsapiがネイティブで非同期(async)に対応していることもあり、 せっかくなのでHTTPリクエスト部分も非同期で書いてみたかった

コード

PythonでHTTPリクエストする場合はrequests使うのが一番多いと思うがasyncには対応してないのでHTTPXというライブラリを使う

pip install httpx

でインストール可能

例外処理も含めたサンプルコードが以下

import httpx
from fastapi import HTTPException, status, FastAPI

app = FastAPI()

async def request():
  async with httpx.AsyncClient() as client:
    response: httpx.Response = await client.get("https://httpbin.org/uuid")
    if response.status_code != httpx.codes.OK:
      raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE)
  return response.json()

@app.get('/httpx')
async def bench_httpx():
  return await request()

こんな感じに表示されるはず

{
  "uuid": "8716ba20-8443-4857-bf30-4fcd39e7a909"
}

せっかくなのでrequests版も作ってベンチマークとってみた

from fastapi import HTTPException, status, FastAPI
import requests

app = FastAPI()

@app.get('/requests')
def bench_requests():
  response = requests.get('https://httpbin.org/uuid')
  if response.status_code != requests.codes.ok:
    raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE)
  return response.json()

で結果 HTTPX版の方/

❯ hey http://localhost:3000/httpx -n 1000 -c 50 -t 2

Summary:
  Total:        4.8898 secs
  Slowest:      2.3744 secs
  Fastest:      0.7238 secs
  Average:      1.0300 secs
  Requests/sec: 40.9016
  
  Total data:   9400 bytes
  Size/request: 47 bytes

Response time histogram:
  0.724 [1]     |■
  0.889 [56]    |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  1.054 [78]    |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  1.219 [30]    |■■■■■■■■■■■■■■■
  1.384 [21]    |■■■■■■■■■■■
  1.549 [12]    |■■■■■■
  1.714 [1]     |■
  1.879 [0]     |
  2.044 [0]     |
  2.209 [0]     |
  2.374 [1]     |■


Latency distribution:
  10% in 0.8375 secs
  25% in 0.8842 secs
  50% in 0.9557 secs
  75% in 1.1458 secs
  90% in 1.2995 secs
  95% in 1.4624 secs
  99% in 1.5652 secs

Details (average, fastest, slowest):
  DNS+dialup:   0.0002 secs, 0.7238 secs, 2.3744 secs
  DNS-lookup:   0.0001 secs, 0.0000 secs, 0.0021 secs
  req write:    0.0001 secs, 0.0000 secs, 0.0006 secs
  resp wait:    1.0296 secs, 0.7236 secs, 2.3720 secs
  resp read:    0.0001 secs, 0.0000 secs, 0.0002 secs

Status code distribution:
  [200] 200 responses

以下はrequests版

❯ hey http://localhost:3000/requests -n 1000 -c 50 -t 2

Summary:
  Total:        5.5102 secs
  Slowest:      2.9772 secs
  Fastest:      0.6944 secs
  Average:      0.9763 secs
  Requests/sec: 36.2963
  
  Total data:   9400 bytes
  Size/request: 47 bytes

Response time histogram:
  0.694 [1]     |
  0.923 [129]   |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  1.151 [36]    |■■■■■■■■■■■
  1.379 [9]     |■■■
  1.608 [9]     |■■■
  1.836 [7]     |■■
  2.064 [1]     |
  2.292 [6]     |■■
  2.521 [0]     |
  2.749 [0]     |
  2.977 [2]     |■


Latency distribution:
  10% in 0.7313 secs
  25% in 0.7558 secs
  50% in 0.8162 secs
  75% in 1.0259 secs
  90% in 1.5373 secs
  95% in 1.8023 secs
  99% in 2.9076 secs

Details (average, fastest, slowest):
  DNS+dialup:   0.0007 secs, 0.6944 secs, 2.9772 secs
  DNS-lookup:   0.0004 secs, 0.0000 secs, 0.0019 secs
  req write:    0.0002 secs, 0.0000 secs, 0.0016 secs
  resp wait:    0.9752 secs, 0.6943 secs, 2.9770 secs
  resp read:    0.0001 secs, 0.0000 secs, 0.0012 secs

Status code distribution:
  [200] 200 responses

あれ、あんまり変わらない。。。? それどころかHTTPX版のほうが遅いときもある

参考リンク

Python3.10からimport Optionalしなくてよくなった

環境

from typing import Optionalしなくて良くなった

昨今のPythonでは型アノテーションが流行っているが、その中でもOptionalは使う機会が多い

例えばPythonの組み込みメソッドであるstr.startswith()は文字列が指定された文字列から開始しているかを調べてくれるが、実は第2引数に整数を渡すことで何文字目から調べるか指定できる。Noneが渡されたときにはなかったことにされる。

'1234567890'.startswith('123')
> True

'1234567890'.startswith('4567')
> False

'1234567890'.startswith('4567', 3)
> True

これをOptionalを使うと

def startswith(prefix:str, start:Optional[int]=None)
  ...

と定義できる。

だが、このままだとOptionalがないと怒られてしまう。

NameError: name 'Optional' is not defined

IDEのオートインポート使えよって話かもしれないが、そんな面倒なことをしなくても気軽に書けるのがPythonのメリットではなかったのか。

ってことでPython 3.10からは簡略記法で記述できる。

def startswith(prefix:str, start:int|None=None):
  ...

うーんこっちのほうが見やすい。何よりimport不要になったのはデカい。

はじめからこっちにしておけばよかったのに

参考リンク

fastapiでvalue is not a valid dictエラーになる

環境

  • fastapi v0.75

状況

以下のようなエラーが出て動かない

pydantic.error_wrappers.ValidationError: 1 validation error for User
response -> 0
  value is not a valid dict (type=type_error.dict)

main.pyコードは以下 よくあるコードなので抜粋のみ

from fastapi import Depends, FastAPI
from sqlalchemy.orm import Session

from . import models, schemas
from .database import SessionLocal, engine

models.Base.metadata.create_all(bind=engine)

app = FastAPI()

def get_db():
  db = SessionLocal()
  try:
    yield db
  finally:
    db.close()

@app.get("/users", response_model=list[schemas.User])
def all_fetch(db: Session = Depends(get_db)):
  users = db.query(models.User).all()
  return users

でschema.pyが以下

from pydantic import BaseModel

class User(BaseModel):
  id: int
  email: str

対処法

schema.pyに追記して、 orm_mode = True にする必要がある。

追記したコードが以下

from pydantic import BaseModel

class User(BaseModel):
  id: int
  email: str

  class Config:
    orm_mode = True

なぜ

response_model=list[schemas.User] に書いてあるように、レスポンスは辞書(dict)を期待している。

が、実際にはSQLAlchemyのモデルが返ってきてしまっているので invalid となりエラーになってしまう

動いたり動かなかったりする

ここがfastapi初心者あるあるで原因がよくわかってないからコードのどこが原因でエラーになるのかが分かりづらい。

原因にもあるようにresponse_modelでdictを指定しているのに反しているからエラーになっているので指定しなければエラーにならない

@app.get("/users")
def all_fetch(db: Session = Depends(get_db)):
  users = db.query(models.User).all()
  return users

また、結果として辞書型になっていればいいので変換すると一応動いてしまう

# 動くが、よくないやり方
@app.get("/users")
def all_fetch(db: Session = Depends(get_db)):
  users = db.query(models.User).all()
  return return [ x.__dict__ for x in users]

ちゃんと定義はschema.pyに書こう

参考リンク

fastapiのmodels.pyとschema.pyのモデルの違い

環境

  • fastapi v0.75

モデルが2つある?

fastapiやってると、多くのチュートリアルでmodels.py、schema.pyが出てくる。

が、その違いがいまいち分からず混乱したのでメモ

models.pyはSQLAlchemy用、schema.pyはPydantic用ファイル

実際のソースコードでは何を継承しているかを見るとわかりやすい

models.pyの一部

from .database import Base

class User(Base):
  __tablename__ = "users"
  id = Column(Integer, primary_key=True, index=True)
  email = Column(String, unique=True, index=True)

database.pyの一部

from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()

なるほど、models.pyのクラスはSQLAlchemyパッケージの declarative_base() で生成したクラスを継承して生成されている

一方 schema.pyは

from pydantic import BaseModel

class User(BaseModel):
  email: str
  password: str

こっちはシンプルで pydanticクラスを継承して生成されている。

pydanticはデータベースとは直接の関係は一切ない

fastapiの公式ドキュメントには

  • SQLAlchemyは「モデル」という用語を、データベースと相互作用するこれらのクラスやインスタンスを指すのに使用しています。
  • しかしPydanticは「モデル」という用語を、データの検証、変換、ドキュメンタリーのクラスやインスタンスという、別のものを指すのにも使っています。

と書かれている。

原文は以下

SQLAlchemy uses the term "model" to refer to these classes and instances that interact with the database. But Pydantic also uses the term "model" to refer to something different, the data validation, conversion, and documentation classes and instances.

https://fastapi.tiangolo.com/tutorial/sql-databases/

VSCodeにPylanceを入れてみた

環境

最近だとVScodePython Language Serverは「Pylance」がオススメと言う記事をよく見る

現状不満があるわけではないが、せっかくのGWでもあるので導入してみた

インストール

普通に拡張機能としてインストールするだけ。よく見るとMicrosoftが開発元だった

marketplace.visualstudio.com

Pythonの拡張機能が依存しているのでインストール時に一緒にインストールされる。

有効化

デフォルトでは使えないので、VSCodeの設定を開いて

"python.languageServer": "Pylance",

にする。終わり

エラー

VSCodeのremote developmentだとできなかったが、Python拡張機能インストール後にPython自体のアップデートをしていたのが原因だった

拡張機能を再インストールすることで解決した。