できない.dev

HTTP GETリクエストを送るには

各言語で HTTP GET を送り、ステータスコードを確かめてからレスポンス本文を読む基本形を示す。
標準ライブラリで書ける範囲と、外部ライブラリが必要になる境目までを扱う。

公開:

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

Python 実行確認済み

import json
import urllib.request
 
url = "https://api.github.com/repos/python/cpython"
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=10) as res:
    if res.status != 200:
        raise RuntimeError(f"unexpected status: {res.status}")
    data = json.loads(res.read().decode("utf-8"))
 
print(data["name"])

標準ライブラリの urllib.request だけで GET は送れるため、依存を増やしたくない場面ではこれで足りる。
with で開くのはレスポンスのソケットを確実に閉じるため、timeout を渡すのは相手が応答しないときに無限に待たないためである。

うまくいかない時: Python で「json.decoder.JSONDecodeError: Expecting value」が解消できない

JavaScript 実行確認済み

const url = "https://api.github.com/repos/nodejs/node";
 
async function main() {
  const res = await fetch(url, { headers: { Accept: "application/json" } });
  if (!res.ok) {
    throw new Error(`unexpected status: ${res.status}`);
  }
  const data = await res.json();
  console.log(data.name);
}
 
main();

Node.js 18 以降はグローバルの fetch がそのまま使えるので、外部パッケージは不要である。
res.ok を先に見るのは、fetch が 404 や 500 でも reject せず解決してしまうため、確認を挟まないとエラー本文を正常なデータとして扱ってしまうからである。

うまくいかない時: Node.js で「fetch is not defined」が解決できない(古い Node)Node.js で fetch が「self-signed certificate in certificate chain」で接続できない

TypeScript 実行確認済み

type Repo = { name: string; stargazers_count: number };
 
const url = "https://api.github.com/repos/microsoft/TypeScript";
 
async function main(): Promise<void> {
  const res = await fetch(url, { headers: { Accept: "application/json" } });
  if (!res.ok) {
    throw new Error(`unexpected status: ${res.status}`);
  }
  const data = (await res.json()) as Repo;
  console.log(data.name, data.stargazers_count);
}
 
main();

res.json() の戻り値は any ではなく Promise<unknown> 相当として扱われるため、使う形を型で宣言してから受け取る。
ここで as を書いているのは実行時に検証しているわけではないので、外部 API を相手にするなら zod などのスキーマ検証を挟むほうが安全である。

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

Go 静的確認

package main
 
import (
	"encoding/json"
	"fmt"
	"net/http"
	"time"
)
 
func main() {
	client := &http.Client{Timeout: 10 * time.Second}
	res, err := client.Get("https://api.github.com/repos/golang/go")
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
 
	if res.StatusCode != http.StatusOK {
		panic(fmt.Sprintf("unexpected status: %d", res.StatusCode))
	}
 
	var data struct{ Name string }
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		panic(err)
	}
	fmt.Println(data.Name)
}

http.Get ではなく http.Client を組み立てているのは、既定のクライアントにタイムアウトが無く応答しない相手を無期限に待ってしまうためである。
res.Body.Close() を defer するのは、閉じ忘れると接続が再利用されずリークするからである。

Rust 静的確認

// Cargo.toml: reqwest = { version = "0.12", features = ["blocking"] }
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let body = reqwest::blocking::Client::new()
        .get("https://api.github.com/repos/rust-lang/rust")
        .header("User-Agent", "dekinai-dev-sample")
        .send()?
        .error_for_status()?
        .text()?;
 
    println!("{}", &body[..body.len().min(200)]);
    Ok(())
}

標準ライブラリに HTTP クライアントが無いため、Rust では reqwest のような外部クレートを入れるのが前提になる。
error_for_status() を挟むのは、4xx / 5xx を Err に変換して本文をそのまま読み進めないようにするためである。

Java 実行確認済み

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
 
public class Main {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(10))
                .build();
        HttpRequest req = HttpRequest.newBuilder(URI.create("https://api.github.com/repos/openjdk/jdk"))
                .header("Accept", "application/json")
                .GET()
                .build();
 
        HttpResponse<String> res = client.send(req, HttpResponse.BodyHandlers.ofString());
        if (res.statusCode() != 200) {
            throw new RuntimeException("unexpected status: " + res.statusCode());
        }
        System.out.println(res.body());
    }
}

Java 11 で java.net.http.HttpClient が標準に入ったので、GET を送るだけなら外部ライブラリは要らない。
JSON を構造体へ写す段になって初めて Jackson などが必要になる、という切り分けで考えると依存を最小にできる。

C# 静的確認

using System;
using System.Net.Http;
using System.Threading.Tasks;
 
class Program
{
    static readonly HttpClient client = new HttpClient();
 
    static Program()
    {
        client.DefaultRequestHeaders.UserAgent.ParseAdd("dekinai-dev-sample");
    }
 
    static async Task Main()
    {
        using var res = await client.GetAsync("https://api.github.com/repos/dotnet/runtime");
        res.EnsureSuccessStatusCode();
 
        string body = await res.Content.ReadAsStringAsync();
        Console.WriteLine(body.Length);
    }
}

HttpClient は static フィールドとして 1 つ作り回すのが定石である。
リクエストごとに new して using で破棄すると、TIME_WAIT のソケットが積み上がって SocketException に至るためである。
GitHub API は User-Agent が無いリクエストを 403 で弾くが、HttpClient は既定で User-Agent を送らないため、静的コンストラクタで DefaultRequestHeaders に明示している。
EnsureSuccessStatusCode() は 4xx / 5xx を例外に変える。

つまずき

GET まわりの不具合でいちばん多いのは、ステータスコードを見ずに本文を読んでしまうことである。
とくに JavaScript の fetch は 404 や 500 でも Promise を reject せず解決するため、res.ok を確認しないとエラーページの HTML を JSON としてパースしようとして、本来の原因とは別の場所で例外が出る。
Python の urllib.request は 4xx / 5xx で HTTPError を投げる、Go は err を返さず StatusCode に入れる、と言語ごとに扱いが違うので、まず「失敗がどこに現れるのか」を確かめてから本文を読む順序にしておきたい。

タイムアウトは自分で指定する

既定のタイムアウトは言語によってばらつきがあり、Go の http.DefaultClient や C# の HttpClient のように事実上待ち続ける実装もある。
応答の無い相手に当たったとき、リクエストを投げた側のスレッドやコネクションが解放されないまま詰まっていくため、GET を書く時点で秒数を明示しておくほうがよい。
Python なら urlopen の timeout 引数、Go なら http.Client の Timeout、fetch なら AbortSignal.timeout(ms) を signal に渡す形になる。

User-Agent を要求する API がある

GitHub API のように User-Agent が無いリクエストを 403 で弾くサービスがある。
ブラウザの fetch は自動で付けるが、サーバサイドの HTTP クライアントは既定で付けないことがあり、ローカルでは動いたのに CI やサーバで急に 403 になる、という形で表面化する。
403 が返ったときは認証を疑う前に、送っているヘッダを一度出力して確認したい。