できない.dev

HTTPリクエストにタイムアウトを設定するには

HTTP リクエストに待ち時間の上限を設ける基本形を各言語で示す。
接続までの上限と応答を受け取るまでの上限が別々に効く点と、指定しないと事実上待ち続ける点までを扱う。

公開:

各言語見出しの横のバッジは検証状態を表す。実行確認済みはコードを実際に実行して確認したもの、静的確認は構文と公式 API ドキュメントで確認したものである。

Python 実行確認済み

import urllib.error
import urllib.request
 
url = "https://example.com/"
req = urllib.request.Request(url, headers={"User-Agent": "dekinai-dev-sample"})
 
try:
    with urllib.request.urlopen(req, timeout=3) as res:
        print(res.status, len(res.read()))
except TimeoutError:
    print("read timed out")
except urllib.error.URLError as e:
    print("failed:", e.reason)

urlopen の timeout はソケットの入出力が止まったままでいられる秒数であり、通信全体の所要時間の上限ではない。
接続段階で時間切れになると TimeoutError は URLError に包まれて飛んでくるため、両方を捕まえておく必要がある。

JavaScript 実行確認済み

const url = "https://example.com/";
 
try {
  const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
  console.log(res.status, (await res.text()).length);
} catch (err) {
  if (err.name === "TimeoutError") {
    console.log("timed out");
  } else {
    throw err;
  }
}

fetch 自体にタイムアウトの引数は無いので、AbortSignal.timeout でシグナルを作って渡す。
時間切れのときは name が "TimeoutError" の DOMException が飛び、自分で abort したときの "AbortError" と区別できる。

うまくいかない時: Node.js で「fetch is not defined」が解決できない(古い Node)

TypeScript 実行確認済み

async function fetchWithTimeout(
  url: string,
  ms: number,
  init: RequestInit = {}
): Promise<Response> {
  const timeout = AbortSignal.timeout(ms);
  const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
  return fetch(url, { ...init, signal });
}
 
try {
  const res = await fetchWithTimeout("https://example.com/", 3000);
  console.log(res.status, (await res.text()).length);
} catch (err) {
  if (err instanceof DOMException && err.name === "TimeoutError") {
    console.log("timed out");
  } else {
    throw err;
  }
}

呼び出し側が渡してきた signal をタイムアウトで潰さないよう、AbortSignal.any で 2 つをまとめる。
こうしておくと、利用者によるキャンセルとタイムアウトのどちらでも中断でき、catch では name を見て理由を切り分けられる。

うまくいかない時: Node.js で「fetch is not defined」が解決できない(古い Node)

Go 静的確認

package main
 
import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"time"
)
 
func main() {
	client := &http.Client{Timeout: 10 * time.Second}
 
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()
 
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com/", nil)
	if err != nil {
		panic(err)
	}
 
	res, err := client.Do(req)
	if err != nil {
		if errors.Is(err, context.DeadlineExceeded) {
			fmt.Println("timed out")
			return
		}
		panic(err)
	}
	defer res.Body.Close()
 
	fmt.Println(res.StatusCode)
}

http.Client の Timeout は接続からボディを読み終えるまでの全体に効き、ゼロ値のままだと上限なしになる。
リクエストごとに変えたい場合は context.WithTimeout を使い、両方指定したときは先に切れたほうが勝つ。

Rust 静的確認

// Cargo.toml: reqwest = { version = "0.12", features = ["blocking"] }
use std::time::Duration;
 
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = reqwest::blocking::Client::builder()
        .connect_timeout(Duration::from_secs(3))
        .timeout(Duration::from_secs(10))
        .build()?;
 
    match client.get("https://example.com/").send() {
        Ok(res) => println!("{}", res.status()),
        Err(e) if e.is_timeout() => println!("timed out"),
        Err(e) => return Err(Box::new(e)),
    }
    Ok(())
}

ClientBuilder の connect_timeout は接続が確立するまで、timeout は応答を受け取り終えるまでの上限である。
返ってきたエラーが時間切れかどうかは is_timeout で判定でき、接続拒否などと分けて扱える。

Java 静的確認

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;
 
public class Main {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(3))
                .build();
        HttpRequest req = HttpRequest.newBuilder(URI.create("https://example.com/"))
                .timeout(Duration.ofSeconds(10))
                .GET()
                .build();
 
        try {
            HttpResponse<String> res = client.send(req, HttpResponse.BodyHandlers.ofString());
            System.out.println(res.statusCode() + " " + res.body().length());
        } catch (HttpTimeoutException e) {
            System.out.println("timed out: " + e.getMessage());
        }
    }
}

接続までの上限は HttpClient.Builder の connectTimeout、応答を受け取るまでの上限は HttpRequest.Builder の timeout に分かれている。
どちらも既定では設定されないため、明示しなければ待ち続ける。
接続時の時間切れは HttpTimeoutException を継承した HttpConnectTimeoutException になるので、まとめて捕まえられる。

C# 実行確認済み

using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
 
class Program
{
    static readonly HttpClient client = new HttpClient
    {
        Timeout = TimeSpan.FromSeconds(10)
    };
 
    static async Task Main()
    {
        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
        try
        {
            using var res = await client.GetAsync("https://example.com/", cts.Token);
            res.EnsureSuccessStatusCode();
            var body = await res.Content.ReadAsStringAsync();
            Console.WriteLine($"{(int)res.StatusCode} {body.Length}");
        }
        catch (TaskCanceledException e) when (e.InnerException is TimeoutException)
        {
            Console.WriteLine("timed out (HttpClient.Timeout)");
        }
        catch (OperationCanceledException)
        {
            Console.WriteLine("timed out (CancellationToken)");
        }
    }
}

HttpClient.Timeout はそのインスタンスの全リクエストに効く既定値で、リクエストごとに短くしたいときは CancellationTokenSource の期限を渡す。
どちらの経路でも投げられるのは TaskCanceledException なので、InnerException が TimeoutException かどうかで理由を見分ける。

つまずき

タイムアウトを書いたのに止まらない、という相談で多いのは「その値がどこに効くか」を取り違えている場合である。
Python の urlopen に渡す timeout はソケットの読み書きが無反応でいられる時間であって、通信全体の上限ではない。
応答が少しずつ返り続ける相手には何分でも付き合ってしまう。
もう一つ多いのが例外の取りこぼしで、Python では接続段階の時間切れが URLError に包まれるため、TimeoutError だけを捕まえていると通り抜ける。
実際に短い値で試し、どの例外が飛ぶかを目で確かめてから本番に入れたい。

接続までと応答までは別に指定する

多くのクライアントは「相手につながるまで」と「応答を受け取り終えるまで」を別の設定として持っている。
Java の connectTimeout と HttpRequest の timeout、Rust の connect_timeout と timeout がその形である。
相手が落ちている場合は前者で、相手は生きているが遅い場合は後者で切れる。
全体を 1 つの値でしか指定できないクライアントでは、接続だけ短く切りたいという要求は満たせないので、切り分けたいなら両方を持つ API を選ぶ。

指定しない既定は「上限なし」に寄っている

値を書かないと安全側に倒れる、とは限らない。
Go の http.Client は Timeout がゼロ値のままなら上限なしで、Java の HttpClient も既定ではどちらのタイムアウトも設定されない。
Python の urlopen は socket.setdefaulttimeout に従うので、設定していなければ同じく無制限になる。
外部 API を呼ぶコードでは、遅い相手が 1 つあるだけでワーカーが埋まって全体が止まる。
呼び出しごとに上限を書く習慣にしておくほうが結果的に安い。

この記事は役立ちましたか?