何を整理していくんだっけ?
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")
ポイント
- 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で渡す必要があるので
-
dict→json.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をもう少し具体で
じゃあどこに書くのか
ビジネスロジックの中に
Retryをべたべた書く
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"}
Retryはapi_client.pyだけに閉じ込める
ハンドラ側は「呼んで結果を見るだけ」の世界になる。
注意ポイントまとめ
- Retryは「外部I/Oの境界」にだけおく
- 永続的エラーはRetryさせない
- 二重Retryを避ける
- ログは「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タイムアウト → 原因がどこにあるのか特定が困難
- 同期呼び出しなら呼び出し元まで連鎖的に重くなる
「接続エラー」と「返事が遅い 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に出すと情報流出にもつながるため安全ラインを設ける