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" と区別できる。
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 を見て理由を切り分けられる。
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 つあるだけでワーカーが埋まって全体が止まる。
呼び出しごとに上限を書く習慣にしておくほうが結果的に安い。