0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

urllib3 × 外部APIをほりほり(Lambda)

0
Posted at

何を整理していくんだっけ?

AWS Lambdaが外部APIと通信する際には標準搭載で軽量なurllib3を利用していくことになる
requestsとurllib3は似た役割のHTTPクライアントだがrequestsの要件が無ければ速さ・軽さから
urllib3を使っていく。
正しい使い方、はまりどころ、テンプレートを整理いていく。
挙動を理解し、外部のSaaSサービスなど外部連携を対応できるようにを目的とする。

urllib3 GET/POST 基本構文

前提:urllib3とrequetsの違い

  • requestsは多機能で便利だが重め?
  • urllib3はLambda標準で入っており軽量・高速
  • requestsは内部でurllib3を使っている
  • 一般的なPythonアプリであればrequestsが便利で書きやすいがLambdaの仕様上
    必要以上な機能であれば技術選択が変わる。

1. GET(標準テンプレ)

➤サンプル

import urllib3
http = urllib3.PoolManager()

resp = http.request(
    "GET",
    "https://api.example.com/v1/user",
    timeout=urllib3.Timeout(connect=3.0, read=5.0)
)

body = resp.data.decode("utf-8")

:bulb:ポイント

  • PoolManager は Keep-Alive で高速(Lambda と相性○)
  • timeout を付けないと Lambda が固まる(必須)
    APIが固まるとLambdaがそのまま持ち続ける
  • resp.data は bytes → decode しないと文字列にならない
  • ステータスコードは自分でチェック(200 以外はバグ)
    例えば200以外はログを出す場合
if resp.status != 200:
    raise RuntimeError(f"API error {resp.status}: {body}")

2. POST(JSON送信テンプレ)

➤サンプル

import json
import urllib3

http = urllib3.PoolManager()

def call_user_api(user_id: str, name: str) -> dict:
    url = "https://api.example.com/v1/users"

    payload = {
        "user_id": user_id,
        "name": name,
    }

    # JSON文字列に変換(urllib3 は json= 引数を持たない)
    body = json.dumps(payload).encode("utf-8")

    resp = http.request(
        "POST",
        url,
        body=body,
        headers={
            "Content-Type": "application/json",
            "Accept": "application/json",
        },
        timeout=urllib3.Timeout(connect=3.0, read=5.0),
    )

    # ステータスコードのチェック(実務では必須)
    if resp.status != 200:
        # body 一部だけログ出力
        snippet = resp.data[:200].decode("utf-8", errors="ignore")
        raise RuntimeError(f"API error {resp.status}: {snippet}")

    # レスポンスJSONを dict にする
    data = json.loads(resp.data.decode("utf-8"))
    return data

json.dumps(...).encode("utf-8")が必要な理由

  • urllib3はrequests.post(json...)のようなjson引数を持っていない
  • Bodyはbytesで渡す必要があるので
    • dictjson.dumps()でJSON文字列化
    • .encode("utf-8")でbytesにする

Content-Type: application/jsonを付ける

  • 多くのAPIはContent-Typeを見てJSONかどうか判断する
  • これがないと415、400、といったエラーが発生

Accept: application/jsonもつけておく

  • レスポンス形式を「JSONで返してほしい」と宣言するヘッダ
  • APIによってはAcceptを見てレスポンス形式を判断
  • HTMLやプレーンテキストが返ってきて、json.loads()で落ちるといった事故防止

➃timeout設定

GETと同様

➄ステータスコードチェック

  • resp.statusが201/201など成功コードを確認
  • エラー時はbodyの一部抜粋の上出力(不必要な情報流出、トークンなどログに出さない)

➅レスポンスJSONのdecode → loadsの流れ

- ネットワーク通信 → bytes を受信
raw = resp.data              # bytes
↓
- 適切な文字コードで decode(通常 UTF-8)
text = raw.decode("utf-8")   # str
↓
- JSON 文字列を dict に変換
data = json.loads(text)      # dict
  • 3ステップをテンプレとして覚えてしまえば、大体のAPIで使いまわせる

リトライ(Retry クラス)

外部APIは自身の環境ではく、制御しきれません。また、様々な人が利用されておりあらゆる理由により外部APIは失敗する前提で正しくリトライ実装を組み込むとよい

外部APIが失敗する主な要因

  • 429 Too Many Requests(レート制限)
  • 500 Internal Server Error(API 側の不具合)
  • 503 Service Unavailable(メンテ or 負荷)
  • 一時的なネットワーク断(AWS 内でも普通に起きる)

こういった一時的な失敗はリトライですくう、長期化している場合などは適切にあきらめる。

仕様はAPIごとに異なり、公開されているAPIは
サービスごとのAPI仕様書を参照

1. Retryを使ったHTTPクライアント

from urllib3.util.retry import Retry
from urllib3 import PoolManager

retry = Retry(
    total=3,                             # 最大リトライ回数
    backoff_factor=0.5,                  # ➁指数バックオフ
    status_forcelist=[429, 500, 502, 503, 504], # ➀
    allowed_methods=["GET", "POST"]      # ➄リトライ対象メソッド
)

http = PoolManager(retries=retry)        # ← ➂全リクエストに適用される

➀ status_forcelistで対象を定義

リストに入れた対象の「エラーが返ってきた場合にRetryしろ」という指定
401~404等のRetryしても意味がないエラーは対象としない。

➁ backoff_factor(指数バックオフ)

429 Too Many Requests(レート制限)発生時など間隔を開けてアクセスを行う。
また、API側から制限かけられないためにも適切な間隔を間隔を開けること

  • 0.5 sec(初回)
  • 1.0 sec(2回目)
  • 2.0 sec(3回目)

➂ retries=retryをPoolManagerに渡す

これをやらないと、GETとPOSTのためにRetryを個別設定する羽目になる

➃ 一時的エラーだけ - 自動復旧できる設計に

Retryは"どこでも・なんでも"つければいいというもんではない。入れる場所を決めておかないと設計がぐちゃぐちゃに...

対象にするのは、基本的に「時間をおけば通るかもしれない系のエラー」にするとしておけば
間違いではないです。

  • ネットワーク断(たまたま回線が不安定)
  • コネクション
  • status_forcelistに入れた429 / 500 / 502 / 503 / 504
  • ReadTimeout / ConnectTimeout(設定次第)

一方でリクエスト間違い、認証ミス、権限なし、URLミスといった「何度やっても絶対に成功しないエラー」はRetryしない。

Retryをもう少し具体で

じゃあどこに書くのか

:x: ビジネスロジックの中にRetryをべたべた書く
:x: LambdaのハンドラごとにバラバラのRetry設定を持つ

非常にコードが分かりづらく読みにくくなってしまいます。
実務では、"外部APIクライアント層"だけにRetryをまとめる

イメージ

Lambda handler / 業務ロジック
    ↓
(ここでは Retry しない)
    ↓
APIクライアントモジュール(urllib3 + Retry を一括定義)
    ↓
外部API(Jira / Slack / 自社API)

Lambdaのパッケージ構成
lambda/
├─ lambda_function.py ← エントリポイント
├─ api_client.py ← API呼び出し専用モジュール
├─ requirements.txt ← (必要なら)
└─ utils.py ← (共通処理)

サンプルコード(APIクライアントモジュール)(open属性あり)
# api_client.py
import json
import urllib3
from urllib3.util.retry import Retry

_retry = Retry(
    total=3,
    backoff_factor=0.5,
    status_forcelist=[429, 500, 502, 503, 504],
    allowed_methods=["GET", "POST", "PUT", "DELETE"],
)

_http = urllib3.PoolManager(retries=_retry)


def post_json(url: str, body: dict, headers: dict | None = None) -> dict:
    encoded = json.dumps(body).encode("utf-8")
    req_headers = {
        "Content-Type": "application/json",
        **(headers or {}),
    }

    resp = _http.request(
        "POST",
        url,
        body=encoded,
        headers=req_headers,
        timeout=urllib3.Timeout(connect=3.0, read=5.0),
    )

    # 4xx など「永続的エラー」はここで判定して例外にするなど
    if resp.status >= 400:
        # Retry はすでに“やり切った後”なのでここは失敗処理だけを書く
        raise RuntimeError(f"API error {resp.status}: {resp.data!r}")

    # いつものテンプレ 3ステップ
    raw = resp.data
    text = raw.decode("utf-8")
    return json.loads(text)

サンプルコード(handler)(open属性あり)
# lambda_function.py
from api_client import post_json

def lambda_handler(event, context):
    body = {"summary": "障害発生", "detail": "..."}

    data = post_json("https://api.example.com/v1/issues", body)
    # ここでは Retry のことを意識しなくていい
    return {"statusCode": 200, "body": "ok"}

:bulb: Retryはapi_client.pyだけに閉じ込める
ハンドラ側は「呼んで結果を見るだけ」の世界になる。

:warning:注意ポイントまとめ

  1. Retryは「外部I/Oの境界」にだけおく
  2. 永続的エラーはRetryさせない
  3. 二重Retryを避ける
  4. ログは「Retryやり切ってもダメだったとき」にまとめて出す
    → CloudWatchがリトライログで埋まるのを防ぐ

➄ allowed_methods は忘れがちだが重要

デフォルトはGET,PUT,DELETEなど(POSTが入らない)

→ POST APIだけ全くリトライされない事故が起こる

アンチパターン:自前while Trueでのリトライ

while True:
    try:
        resp = http.request("GET", url)
        break
    except:
        time.sleep(1)

問題点:

  • 無限ループ → 永遠に抜けない
  • Lambdaタイムアウト → APIに迷惑をかけ続け、SaaS側でBAN
  • エラー条件が曖昧で、調査負荷

この書き方は絶対NG。
Retryクラス側で用意されているんだったら素直に任せる

タイムアウト設計

Pythonで外部APIを叩くとき、タイムアウトを適切な値は難しい

タイムアウトは2種類ある

種類 説明 実務で発生する例
connect timeout 接続開始までの時間(DNS lookup, TCP handshake など) ネットワーク不調、DNS障害
read timeout 接続はできたが、APIが返事しない時間 APIが重い、処理中、バックエンド障害

➤ サンプル - タイムアウト設定

timeout = urllib3.Timeout(
    connect=3.0,   # ➀ネットワーク障害向け
    read=8.0       # API の遅延向け
)

resp = http.request("GET", url, timeout=timeout)

➀ connect timeoutは短め

以下のようなケースでは待っても意味がないため短くする。

  • DNSのハング
  • TCPハンドシェイクが返ってこない
  • ネットワーク障害

➁ read timeoutはAPIの特性で調整

投げて重めのAPIと軽めのAPIで平均的な応答時間から
数秒のバッファを持たせて設定する

➂ timeout未設定は危険

http.request("GET", url) 
  • API側で障害 → Lambda側でも落ちるまでずっと待機
  • Lambdaタイムアウト → 原因がどこにあるのか特定が困難
  • 同期呼び出しなら呼び出し元まで連鎖的に重くなる

:bulb:「接続エラー」と「返事が遅い API」を区別して制御できる → urllib3 の大きな強み。

JSONパース失敗の例外処理

JSONパースの安全なテンプレ

➤サンプル

import json
import logging

logger = logging.getLogger()

def safe_json_loads(data: bytes):
    try:
        text = data.decode("utf-8") #➀
    except UnicodeDecodeError:
        logger.error("Failed to decode response bytes")
        return None

    try:
        return json.loads(text)
    except json.JSONDecodeError: # ➂
        logger.error("Invalid JSON: %s", text[:200]) #➃
        return None

resp.dataはbytes

外部APIのレスポンスresp.data必ずbytes
→ decodeが必要。

text = resp.data.decode("utf-8")

➁ decodeエラーが発生

  • APIが壊れた文字列を返す
  • charsetがUTF-8ではない
  • 文字化けを起こしたHTMLを返す
    → decodeで落ちる

➂ decode成功してもJSONとは限らない

<html><body>503 Error</body></html>

こういった文字列が返ってきた際は、json.loads()で確実に例外が飛ぶ

➃ エラー時のloggingは"200文字以内とする"

APIレスポンス全文をLogに出すと情報流出にもつながるため安全ラインを設ける

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?