Technology

httpxとasyncio.gatherで複数のHTTPリクエストを並行実行する(タイムアウト・例外処理付き)

外部のWeb APIを複数呼び出して、その結果をまとめたい場合はよくあります。リクエストを1件ずつ順番に処理すると、全体の所要時間は「各リクエストの時間の合計」になります。一方、HTTPリクエストにかかる時間のほとんどは「サーバーから応答を待っている時間」で、その間CPUはほとんど何もしません。そこで、複数のリクエストを同時に投げて待ち時間を重ねれば、全体の所要時間は「最も遅い1件」に近づきます。

本記事では、非同期HTTPクライアントのhttpxと、Python標準のasyncio.gatherを組み合わせて、複数のHTTPリクエストを並行実行する方法を解説します。あわせて、「タイムアウトの指定」と「1件失敗しても全体を止めない例外処理」も扱います。題材にはOpen-MeteoのForecast APIを使います。APIキーや事前登録が不要なので、コピー&ペーストでそのまま試せます。複数地点の天気を順番にではなく並行で取得する手順を、実際に動くコードで試します。

ここで言う「並行(concurrency)」は、スレッドやプロセスを増やす並列化ではなく、1つのスレッド上でイベントループを回すことで、待ち時間を有効活用して別の処理を走らせることで効率化する方式です。HTTPリクエストのようなI/O待ちが中心の処理に向いています。

前提

本記事は、以下の環境を用いています。

  • Python言語を用いて、httpxライブラリを使います。
  • Python 3.12で動作確認をしています。

※ この記事の動作確認は2026年6月前半に実施しています。

目次

事前準備

httpxをインストールします。uvまたはpipの場合には、以下のコマンドでインストールできます。

# pipを使用している場合
pip install httpx 
# uvを使用している場合
uv add httpx

題材として用いるAPIは、天気情報を取得することができるOpen-MeteoのForecast API(https://api.open-meteo.com/v1/forecast)です。HTTPのGETにクエリパラメータを付けるだけで、JSONが返ります。非商用利用であれば、APIキー不要で事前登録もいりません。

Open-Meteoは認証なしで使えます。商用利用の場合はライセンス条件を公式で確認してください。

httpx.AsyncClientの基本

非同期版のクライアントhttpx.AsyncClientを使い、東京の現在の気温と風速を取得してみます。

import asyncio
import httpx
import json

async def main():
    async with httpx.AsyncClient() as client: # 非同期クライアントの定義
        response = await client.get(
            "https://api.open-meteo.com/v1/forecast", # エンドポイント
            params={ # クエリ
                "latitude": 35.6895,
                "longitude": 139.6917,
                "current": "temperature_2m,wind_speed_10m",
            },
        )
        response.raise_for_status() # 通信エラーのチェック
        res_data = response.json() # 応答をJSONに変換
        print(json.dumps(res_data, indent=4, ensure_ascii=False))

asyncio.run(main())

ポイントは以下の通りです。

async def main():
関数の頭にasyncを付けると、非同期関数(コルーチンとも呼びます)になります。非同期関数はその関数の処理の待ち時間に、別の処理を入れることができる関数です。この関数は、その場では実行されず、後述のawaitか、プログラム全体の入口で使うasyncio.runを処理したときに実行されます。「待ち時間を別の処理に回せる関数を書きたい」ときにasync defを使用すると覚えておくとよいと思います。

■ async with httpx.AsyncClient() as client:
クライアントを開きます。クライアントとは、HTTP通信に必要な設定や道具(例えばgetpostなど)をひとまとめにしたオブジェクトです。async withは普通のwithの非同期版で、ブロックを抜けたときに、対象(ここではclient)を自動的に閉じてくれます。

クライアントは、内部にコネクションプールを持っています。コネクションプールは「使い終わったサーバーへの接続を、すぐ捨てずにとっておく場所」です。それにより再度接続するための前準備の時間を削減できます。このコネクションプールでの問題として、接続が閉じられないまま残り続けるコネクションリークが起こることですが、async with のブロックを抜けるとクライアントが閉じられ、プールに残っていた接続もまとめて閉じられるためリークは起こりません。

await client.get(url, params=...)
GETリクエストを行います。GETはURLを指定して情報を取りに行く、HTTPの基本的なメソッドです。params に渡した辞書は、自動的にクエリパラメータ(URL の末尾に ?キー1=値1&キー2=値2&... の形で付くデータ)に変換されます。たとえば上のコードは、最終的に次のような URL にアクセスします。

await client.get(...) の先頭の await は、「ここで応答を待つ」という意味です。重要なのは、await で待っているあいだ、Python はそのプログラム自体を止めず、他に動かせる処理があればそちらに切り替わることです。あとで「3件同時に投げる」をやるとき、この性質が効いてきます。

response.raise_for_status()
HTTP の応答には、ステータスコードという3桁の番号が付いています。200番台は成功、400番台はクライアント側の問題、500番台はサーバー側の問題などがあります。response.raise_for_status()は、ステータスコードが200番台以外だったときに例外(httpx.HTTPStatusError)を出します。例外は、「処理を中断して呼び出し元に異常を伝える」Python の仕組みで、何もしないとプログラムは止まります。ステータスコードが200番台の場合は何もせずに、次の行に進みます。

■ asyncio.run(main())
非同期関数を起動します。async defで定義した関数を実行するためのスタート地点がasyncio.runです。asyncio.run(main()) を呼ぶと、イベントループ(複数の非同期処理を切り替えて回すスケジューラ)を起動して、main を最後まで走らせます。プログラムの一番外側で1回だけ呼ぶのが基本です。

実行すると、次のようなJSONが辞書として表示されます(数値は実行時の天候によって変わります)。

{
    "latitude": 35.7,
    "longitude": 139.6875,
    "generationtime_ms": 0.05936622619628906,
    "utc_offset_seconds": 0,
    "timezone": "GMT",
    "timezone_abbreviation": "GMT",
    "elevation": 40.0,
    "current_units": {
        "time": "iso8601",
        "interval": "seconds",
        "temperature_2m": "°C",
        "wind_speed_10m": "km/h"
    },
    "current": {
        "time": "2026-06-08T19:00",
        "interval": 900,
        "temperature_2m": 19.1,
        "wind_speed_10m": 3.6
    }
}

current の中に、要求した変数(temperature_2mwind_speed_10m)が入っています。これが「東京の現在の気温と風速」です。

1件のリクエストを関数にまとめる

先ほどのコードでは、東京1地点の天気を取得することができました。本記事の目的は、複数リクエストを並行実行することなので、この天気情報取得の題材においては、東京・大阪・札幌などの複数地点での天気情報を同時に取得することになります。

そのためには、まず「1地点ぶんのリクエストを送って結果を返す」という処理を1つの関数(ここではfetch_oneという名前にする)にまとめておくと便利です。この関数を作成するにあたって、「タイムアウトを指定する」「失敗したら例外を投げず、Noneを返す」という処理を組み込むことにします。

応答が得られないリクエストがある場合、1つが応答しないだけで、他の2地点の結果を受け取れずプログラムが止まったように見えてしまいます。これは、タイムアウトを指定することで回避できます。また、例えば、3地点の情報を取得したい場合、1地点が失敗しても、残り2地点の情報を表示できた方がいい場合がほとんどであるため、例外処理でプログラムを止めるのではなく、Noneを返すようにします。

import json
import httpx

async def fetch_one(
    client: httpx.AsyncClient, # 非同期クライアント
    url: str,                  # エンドポイント
    params: dict,              # クエリパラメータ
) -> dict | None:
    try:
        response = await client.get(url, params=params, timeout=10.0)
        response.raise_for_status()
        return response.json()
    except (httpx.HTTPError, json.JSONDecodeError) as e:
        print(f"リクエスト失敗 (params={params}): {e!r}")
        return None

成功すれば応答の辞書を返し、失敗すればNoneを返します。

await client.get(..., timeout=10.0)の部分でタイムアウトを設定しています。デフォルトでは5秒のタイムアウトとなっていますが、コードでは10秒に設定しています。この指定時間を超えるとhttpx.TimeoutExceptionという例外が発生します。

また、except (httpx.HTTPError, json.JSONDecodeError) as e:の部分で例外処理を行っています。httpx.HTTPError は、httpx が定義している例外の親クラスで、これ1つで httpx 由来の失敗を広くカバーできます。具体的には、次のような階層になっています。

HTTPError
├─ RequestError
│   └─ TransportError
│       ├─ TimeoutException(ConnectTimeout / ReadTimeout / ...)
│       └─ NetworkError(ConnectError / ...)
└─ HTTPStatusError(raise_for_status が送出)

httpxの例外の全体像はこちらにのっています。Pythonの例外は親子関係を持っていて、except 親クラスと書くと、その親自身と、すべての子クラスの例外を受け止められます。このため、except httpx.HTTPError 1本で、次のすべてを捕まえられます。

  • タイムアウトTimeoutException 系):前述のタイムアウトに引っかかったとき
  • 接続エラーNetworkError 系):そもそも相手のサーバーに繋がらないとき
  • 4xx・5xx ステータスHTTPStatusError):raise_for_status() が投げたもの

もう1つ並べている json.JSONDecodeError は、httpx の例外ではなく Python 標準ライブラリの例外です。response.json() が、応答本文を JSON として読もうとして失敗したときに投げられます。たとえばサーバーが200 OKを返しても、本文がJSONではなくメンテナンス用のHTMLページだったりした場合に、例外で落ちないようにしています。

失敗をNoneに変換することで、関数の外側の処理では「成功なら辞書、失敗なら None」のどちらかを淡々と扱うだけで済み、例外処理を設ける必要がなくなります。このNoneで返すことが後述のasyncio.gatherと組み合わせることで「1つの失敗が全体の処理を止めない」ことにつながります。

複数の地点を並行で取得する

複数のリクエストを並行で走らせるためにasyncio.gatherを使います。取得する地点は東京、大阪、札幌とし、APIに渡すクエリパラメータを辞書(CITIESという名前)で定義しておきます。

BASE_URL = "https://api.open-meteo.com/v1/forecast"

CITIES = {
    "tokyo":   {"latitude": 35.6895, "longitude": 139.6917, "current": "temperature_2m,wind_speed_10m"},
    "osaka":   {"latitude": 34.6937, "longitude": 135.5023, "current": "temperature_2m,wind_speed_10m"},
    "sapporo": {"latitude": 43.0618, "longitude": 141.3545, "current": "temperature_2m,wind_speed_10m"},
}

各地点の天気情報を並行で取得するための非同期関数を以下のように作成します。先ほど作成したfetch_oneを使用しています。

import asyncio
import httpx

async def fetch_all(
    client: httpx.AsyncClient, # 共通の非同期クライアント
    url: str,                  # 共通のエンドポイント
    queries: dict[str, dict],  # 各地点のクエリを格納した辞書
) -> dict[str, dict | None]:
    names = list(queries.keys())
    tasks = [fetch_one(client, url, queries[name]) for name in names]
    results = await asyncio.gather(*tasks) # 非同期関数の並行実行
    return dict(zip(names, results))

ポイントは以下の通りです。

tasks = [fetch_one(client, url, queries[name]) for name in names]
これから実行する処理のリストを作成しています。重要なのはasync defの関数を呼んでも実行されず、「これから実行できる処理」(コルーチンオブジェクト)が返ってくるだけなので、tasksの中には、まだ実行されていない非同期関数のリストが格納されているということです。

■ results = await asyncio.gather(*tasks)
*はリストを「中身を順に並べた引数」に展開する記法です。引数で与えた非同期関数を並行に実行し、応答待ちになっている間はリスト内の他の非同期関数の処理を進めることで効率を高めます。await asyncio.gather(…)は「全部終わるまで待つ」という意味になります。ここで、重要な特性として、gatherはどの非同期関数が先に終わったかにかかわらず、渡した順番どおりに結果を並べてリストとして返します。

■ return dict(zip(names, results))
zip は、複数のリストを「同じ位置どうしのタプル」にまとめる関数です。たとえば zip(["a", "b"], [1, 2])[("a", 1), ("b", 2)] のような並びを作ります。dict(...) でこれを辞書に変換できます。gather のおかげで results の並びは names と一致しているので、zip(names, results) で正しく対応づけられ、{"tokyo": ..., "osaka": ..., "sapporo": ...} という辞書ができあがります。

非同期クライアントは内部で作らずに、外部から引数で受け取っています。これにより、fetch_allを何度も呼ぶ必要がある場合は、クライアントで作られたコネクションプールを使いまわすことができるため、接続のための初期コストを抑えることができます。

fetch_oneでは失敗したときに、Noneを返すようにしました。もし fetch_one の中で try/except をしておらず1地点が例外を投げたら、その時点で fetch_all 全体が落ち、残りのリクエストは放置されたまま走り続ける、というまずい状態になります。fetch_oneの中で失敗時にNoneを返すようにすることで、Noneの場合もawaitが正常に終了したと処理し、それにより他の処理は継続されることとなります。その結果、データ取得に失敗した地点があっても、取得できる地点のデータは取得できるようになります。

呼び出し側を作成する

呼び出し側のコードは次のようになります。

async def main():
    async with httpx.AsyncClient() as client:   # クライアントはここで一度だけ作る
        results = await fetch_all(client, BASE_URL, CITIES)

    for name, data in results.items():
        if data is None:
            print(f"{name}: 取得できませんでした")
            continue
        cur = data["current"]
        units = data["current_units"]
        print(
            f"{name}: "
            f"{cur['temperature_2m']}{units['temperature_2m']}, "
            f"風速 {cur['wind_speed_10m']}{units['wind_speed_10m']}"
        )

asyncio.run(main())

呼び出し側でhttpx.AsyncClient()を使ってクライアントを1度だけ作成し、それを使いまわすようにしています。

results = await fetch_all(client, BASE_URL, CITIES)fetch_all を呼び出します。返ってくるのは {"tokyo": ..., "osaka": ..., "sapporo": ...} という辞書で、値は「応答の辞書」または None です。この結果を画面に出力します。

これまでのコードを1つのファイルにまとめてから実行すると、次のような出力が得られます(数値は実行時の天候によって変わります)。

tokyo: 17.2°C, 風速 0.7km/h
osaka: 17.9°C, 風速 1.9km/h
sapporo: 7.8°C, 風速 4.7km/h

データが取得できない場合はタイムアウト時間を調整してみてください(タイムアウトはfetch_onegetの引数として与えていました。また、Open-Meteoのサーバーの状態によって応答がなかなか得られない場合もあります)。

わざと失敗させて挙動を確かめる

「1件失敗しても全体は止まらない」が本当にそうなるか、確かめてみましょう。CITIES に、わざと範囲外の緯度を持つ地点を1件足します。緯度は -90〜90 が有効範囲なので、999.0 を渡すと、Open-Meteo は HTTP 400(Bad Request)を返します。

CITIES = {
    "tokyo":   {"latitude": 35.6895, "longitude": 139.6917, "current": "temperature_2m,wind_speed_10m"},
    "osaka":   {"latitude": 34.6937, "longitude": 135.5023, "current": "temperature_2m,wind_speed_10m"},
    "sapporo": {"latitude": 43.0618, "longitude": 141.3545, "current": "temperature_2m,wind_speed_10m"},
    "broken":  {"latitude": 999.000, "longitude": 0.0,      "current": "temperature_2m,wind_speed_10m"},
}

このまま main() を実行すると、出力はこのようになります。

リクエスト失敗 (params={'latitude': 999.0, 'longitude': 0.0, 'current': 'temperature_2m,wind_speed_10m'}): HTTPStatusError("Client error '400 Bad Request' for url 'https://api.open-meteo.com/v1/forecast?latitude=999.0&longitude=0.0&current=temperature_2m%2Cwind_speed_10m'\nFor more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400")
tokyo: 17.3°C, 風速 1.1km/h
osaka: 17.7°C, 風速 2.9km/h
sapporo: 7.8°C, 風速 4.8km/h
broken: 取得できませんでした

流れとしては以下のようになります。

  1. broken のリクエストはサーバーから 400 で返ってきます。
  2. response.raise_for_status()httpx.HTTPStatusError を送出します。
  3. httpx.HTTPError の子クラスなので、fetch_oneexcept (httpx.HTTPError, json.JSONDecodeError) で受け止められます。
  4. 受け止めた fetch_one は診断行を print してから None を返します。
  5. asyncio.gather から見ると、3件のうち1件が「None という結果で正常に終わった」ように見えるだけです。

別の書き方:return_exceptions=True

例外を関数の中で受け止めるのではなく、asyncio.gather 側でまとめて回収する書き方もあります。gatherreturn_exceptions=True を渡すと、コルーチンの中で投げられた例外は呼び出し元に伝わらず、結果リストの中にそのまま例外オブジェクトとして入って返ってきます。これを使うと、各リクエストを try/except で包まずに済みます。

async def request_json(client, url, params):
    # ここでは例外を捕まえない(gather 側に任せる)
    response = await client.get(url, params=params, timeout=10.0)
    response.raise_for_status()
    return response.json()

async def fetch_all(client, url, queries):
    names = list(queries.keys())
    tasks = [request_json(client, url, queries[name]) for name in names]
    results = await asyncio.gather(*tasks, return_exceptions=True) # 例外オブジェクトとして返すオプションをTrue
    out = {}
    for name, result in zip(names, results):
        if isinstance(result, Exception):
            out[name] = None
        else:
            out[name] = result
    return out

前章の fetch_all を置き換える形で使います。

まとめ

本記事では、非同期関数を用いてWeb APIの並行実行をOpen-Meteoを用いて試しました。要点を以下にまとめます。

  • 複数の HTTP リクエストは、httpx.AsyncClientasyncio.gather で並行に走らせることができます。全体の所要時間が「合計」から「最も遅い1件」に近づきます。
  • 非同期関数は async def で定義し、asyncio.runawait で実行します。await で待っているあいだ、Python は他のコルーチンに処理を切り替えます。
  • クライアントは main 側で一度だけ async with で作り、各処理に渡して使い回します。同じクライアント=同じコネクションプールを共有することで、接続が再利用されます。
  • タイムアウトは timeout= で指定します(httpx の既定は5秒)。
  • 1件の失敗で全体を止めないために、各リクエストの中で例外(httpx.HTTPErrorjson.JSONDecodeError)を捕まえて None を返すのが扱いやすい方法です。return_exceptions=True を使う別解もあり、用途に応じて選びます。

-Technology
-,