boto3 のエラーは、AWS が返したエラー(botocore.exceptions.ClientError)と、リクエストを送る前後に手元で起きるエラー(BotoCoreError の仲間)の 2 系統に分かれます。ClientError は e.response["Error"]["Code"] でエラーコードを取り出して分岐するのが基本形です。

この記事では、e.response の中身を実際の辞書で確かめ、client.exceptions.XXX との使い分け、サービス別のエラーコード、botocore.exceptions の発生条件を表にします。後半では Config(retries=...) の 3 つのモードで「何回送られるか」を数え、リトライされるエラーとされないエラー、自前のバックオフと重ねたときの回数までコードで確かめます。

確認環境:Ubuntu 24.04、Python 3.11.15、boto3 1.43.103(botocore 1.43.103)。AWS の API をローカルで再現するツール(moto 5.2.3)で実行しました。S3 などを呼ぶコードは、実際の AWS でそのまま使える形(接続先の指定なし)で載せています。リトライの回数を数える節だけは、決まった応答を返す検証用サーバーに接続します。

boto3エラーハンドリングの早見表

知りたいこと 書き方
AWS が返したエラーを捕まえる except botocore.exceptions.ClientError as e:
エラーコード e.response["Error"]["Code"]
エラーメッセージ e.response["Error"]["Message"]
HTTP ステータス e.response["ResponseMetadata"]["HTTPStatusCode"]
リクエスト ID(問い合わせ用) e.response["ResponseMetadata"]["RequestId"]
SDK が再送した回数 e.response["ResponseMetadata"]["RetryAttempts"]
どの API で起きたか e.operation_name
決まった 1 種類だけ捕まえる except client.exceptions.NoSuchKey:
resource から同じことをする except resource.meta.client.exceptions.NoSuchKey:
手元で起きるエラーをまとめて捕まえる except botocore.exceptions.BotoCoreError:
リトライの回数とモードを決める Config(retries={"mode": "standard", "total_max_attempts": 5})
タイムアウトを決める Config(connect_timeout=5, read_timeout=10)
系統 親クラス 起きる場面 例
AWS が返したエラー ClientError リクエストは届き、サービスがエラー応答を返した NoSuchKey、AccessDenied、ThrottlingException
手元で起きるエラー BotoCoreError 引数の検証、認証情報・リージョンの解決、接続、待機 ParamValidationError、NoCredentialsError、EndpointConnectionError

ClientError と BotoCoreError は親子関係にありません。 両方を受けたいときは、except を 2 つ書きます。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「boto3のエラーハンドリング|ClientErrorのコード取得とリトライ設定」

ClientErrorのe.responseの中身を見る

以降のコードは、rjc-demo-bucket というバケット(中にオブジェクトが 1 つある)を用意した状態で動かしています。存在しないキーを get_object して、例外の中身を表示します。

# err_response.py
import pprint
import boto3
from botocore.exceptions import ClientError

s3 = boto3.client("s3")
try:
    s3.get_object(Bucket="rjc-demo-bucket", Key="report.csv")
except ClientError as e:
    print("例外クラス :", type(e).__name__)
    print("str(e)     :", e)
    print("操作名     :", e.operation_name)
    print("Code       :", e.response["Error"]["Code"])
    print("Message    :", e.response["Error"]["Message"])
    meta = e.response["ResponseMetadata"]
    print("HTTPStatus :", meta["HTTPStatusCode"])
    print("RequestId  :", meta["RequestId"])
    print("RetryAttempts:", meta["RetryAttempts"])
    print("--- e.response 全体(HTTPHeaders は長いので省略して表示)")
    shown = dict(e.response)
    shown["ResponseMetadata"] = {**meta, "HTTPHeaders": "…(省略)…"}
    pprint.pprint(shown)
例外クラス : NoSuchKey
str(e)     : An error occurred (NoSuchKey) when calling the GetObject operation: The specified key does not exist.
操作名     : GetObject
Code       : NoSuchKey
Message    : The specified key does not exist.
HTTPStatus : 404
RequestId  : pEPoQ6Y09IvPuoKHiFJEu6OhHgbEtbaHbRqy9OM9zm4yj0AinJSf
RetryAttempts: 0
--- e.response 全体(HTTPHeaders は長いので省略して表示)
{'Error': {'Code': 'NoSuchKey',
           'Key': 'report.csv',
           'Message': 'The specified key does not exist.'},
 'ResponseMetadata': {'HTTPHeaders': '…(省略)…',
                      'HTTPStatusCode': 404,
                      'RequestId': 'pEPoQ6Y09IvPuoKHiFJEu6OhHgbEtbaHbRqy9OM9zm4yj0AinJSf',
                      'RetryAttempts': 0}}
キー 中身 使いどころ
Error.Code エラーコード(文字列) 分岐の条件にする
Error.Message 人が読むための説明 ログに残す。文言は変わりうるので分岐には使わない
Error のその他のキー サービスごとの追加情報(上の例では Key) 対象の特定
ResponseMetadata.HTTPStatusCode HTTP ステータス 4xx(呼び出し側の原因)と 5xx(サービス側の原因)の大まかな区別
ResponseMetadata.RequestId リクエスト ID AWS サポートへの問い合わせ、ログの突き合わせ
ResponseMetadata.RetryAttempts SDK が自動で再送した回数 リトライが効いたかの確認
ResponseMetadata.HTTPHeaders 応答ヘッダーの辞書 詳しい調査

公式ドキュメントの例では、ResponseMetadata に HostId というキーも載っています。どのサービスでも必ずあるのは Error.Code と ResponseMetadataと考え、追加のキーは .get() で読むと安心です。

client.exceptionsとClientError+コード判定の使い分け

捕まえ方は 2 通りあります。

# two_ways.py
import boto3
from botocore.exceptions import ClientError

s3 = boto3.client("s3")

# 方法1: client.exceptions.XXX で、決まった 1 種類だけを捕まえる
try:
    s3.get_object(Bucket="rjc-demo-bucket", Key="report.csv")
except s3.exceptions.NoSuchKey as e:
    print("方法1 NoSuchKey を捕まえた:", e.response["Error"]["Message"])

# 方法2: ClientError で受けて、Error.Code で分岐する
def read_or_none(bucket, key):
    try:
        return s3.get_object(Bucket=bucket, Key=key)["Body"].read()
    except ClientError as e:
        code = e.response["Error"]["Code"]
        if code in ("NoSuchKey", "404"):
            return None                      # 無いのは想定内
        if code == "NoSuchBucket":
            raise RuntimeError(f"バケット {bucket} がありません") from e
        raise                                # それ以外は上に伝える

print("方法2 無いキー      :", read_or_none("rjc-demo-bucket", "report.csv"))
try:
    read_or_none("rjc-none-bucket", "report.csv")
except RuntimeError as e:
    print("方法2 無いバケット  :", e)

# client.exceptions に無いコードは、ClientError でしか受けられない
for label, call in [
    ("head_object(無いキー)", lambda: s3.head_object(Bucket="rjc-demo-bucket", Key="report.csv")),
    ("delete_bucket(空でない)", lambda: s3.delete_bucket(Bucket="rjc-demo-bucket")),
]:
    try:
        call()
    except s3.exceptions.NoSuchKey:
        print(label, "-> NoSuchKey クラス")
    except ClientError as e:
        print(label, "->", type(e).__name__, "/ Code =", e.response["Error"]["Code"])

# client.exceptions に用意されているクラスの数
for name in ["s3", "dynamodb", "sqs", "iam", "sts"]:
    c = boto3.client(name)
    names = [n for n in dir(c.exceptions) if n[0].isupper() and n != "ClientError"]
    print(f"{name:9} {len(names):3} クラス 例: {', '.join(names[:3])}")
方法1 NoSuchKey を捕まえた: The specified key does not exist.
方法2 無いキー      : None
方法2 無いバケット  : バケット rjc-none-bucket がありません
head_object(無いキー) -> ClientError / Code = 404
delete_bucket(空でない) -> ClientError / Code = BucketNotEmpty
s3         20 クラス 例: AccessDenied, AnnotationLimitExceeded, AnnotationNameTooLong
dynamodb   34 クラス 例: BackupInUseException, BackupNotFoundException, ConditionalCheckFailedException
sqs        28 クラス 例: BatchEntryIdsNotDistinct, BatchRequestTooLong, EmptyBatchRequest
iam        38 クラス 例: AccountNotManagementOrDelegatedAdministratorException, CallerIsNotManagementAccountException, ConcurrentModificationException
sts        12 クラス 例: ExpiredTokenException, ExpiredTradeInTokenException, IDPCommunicationErrorException

出力の 4〜5 行目がポイントです。head_object で無いキーを指定したときのコードは "404"、空でないバケットを消そうとしたときは BucketNotEmpty で、どちらも client.exceptions にクラスが無く、素の ClientError として届きます。 client.exceptions に用意されているのは、API 定義に載っているエラーだけです。

比べる点 client.exceptions.XXX ClientError + Error.Code
書き方 except s3.exceptions.NoSuchKey: except ClientError as e: のあと e.response["Error"]["Code"] で分岐
捕まえられる範囲 API 定義にあるエラーだけ サービスが返すすべてのエラー
複数のコードをまとめる except を並べる if code in (...) で 1 か所にまとめる
クライアントが必要か 必要(そのクライアントの属性) 不要(import だけで書ける)
向いている場面 「このエラーだけは想定内」と 1 種類を読みやすく書きたい AccessDenied・スロットリングなど横断的なコードも扱う、共通関数にする

公式ドキュメントにも、ClientError で受けてエラーコードを調べる方法が、サービス側のすべてのエラーを捕まえる最も確実な方法だと書かれています。迷ったら右側の書き方を基本にして、読みやすさが欲しい箇所だけ左側を使うとよいでしょう。

なお、client.exceptions のクラスは ClientError の子クラスです。except ClientError を先に書くと、後ろの except s3.exceptions.NoSuchKey には届かないので、細かいほうを先に書きます。

サービス別:よく出るエラーコード一覧

次のスクリプトで、5 つのサービスのエラーを実際に起こし、例外クラス名・Error.Code・HTTP ステータスを並べました。

# codes.py
import boto3
from botocore.exceptions import ClientError

s3, ddb, sqs, iam = (boto3.client(n) for n in ["s3", "dynamodb", "sqs", "iam"])
key = [{"AttributeName": "id", "KeyType": "HASH"}]
attr = [{"AttributeName": "id", "AttributeType": "S"}]
ddb.create_table(TableName="Tasks", KeySchema=key, AttributeDefinitions=attr, BillingMode="PAY_PER_REQUEST")
ddb.put_item(TableName="Tasks", Item={"id": {"S": "1"}})
queue_url = sqs.create_queue(QueueName="tasks")["QueueUrl"]
iam.create_user(UserName="taro")

cases = [
    ("s3.get_object 無いキー", lambda: s3.get_object(Bucket="rjc-demo-bucket", Key="x")),
    ("s3.head_object 無いキー", lambda: s3.head_object(Bucket="rjc-demo-bucket", Key="x")),
    ("s3.get_object 無いバケット", lambda: s3.get_object(Bucket="rjc-none-bucket", Key="x")),
    ("s3.create_bucket 作成済み", lambda: s3.create_bucket(Bucket="rjc-demo-bucket", CreateBucketConfiguration={"LocationConstraint": "ap-northeast-1"})),
    ("s3.delete_bucket 空でない", lambda: s3.delete_bucket(Bucket="rjc-demo-bucket")),
    ("ddb.get_item 無いテーブル", lambda: ddb.get_item(TableName="Nothing", Key={"id": {"S": "1"}})),
    ("ddb.create_table 作成済み", lambda: ddb.create_table(TableName="Tasks", KeySchema=key, AttributeDefinitions=attr, BillingMode="PAY_PER_REQUEST")),
    ("ddb.put_item 条件に合わない", lambda: ddb.put_item(TableName="Tasks", Item={"id": {"S": "1"}}, ConditionExpression="attribute_not_exists(id)")),
    ("ddb.get_item キー名が違う", lambda: ddb.get_item(TableName="Tasks", Key={"idx": {"S": "1"}})),
    ("sqs.get_queue_url 無いキュー", lambda: sqs.get_queue_url(QueueName="nothing")),
    ("sqs.create_queue 属性違いで再作成", lambda: sqs.create_queue(QueueName="tasks", Attributes={"VisibilityTimeout": "99"})),
    ("sqs.delete_message 受信ハンドルが不正", lambda: sqs.delete_message(QueueUrl=queue_url, ReceiptHandle="bogus")),
    ("iam.get_role 無いロール", lambda: iam.get_role(RoleName="nothing")),
    ("iam.create_user 作成済み", lambda: iam.create_user(UserName="taro")),
    ("iam.create_policy JSON が不正", lambda: iam.create_policy(PolicyName="p", PolicyDocument="{bad")),
]
for label, call in cases:
    try:
        call()
    except ClientError as e:
        err, status = e.response["Error"], e.response["ResponseMetadata"]["HTTPStatusCode"]
        print(f"{label}\n    class={type(e).__name__}  Code={err['Code']}  HTTP={status}")
s3.get_object 無いキー
    class=NoSuchKey  Code=NoSuchKey  HTTP=404
s3.head_object 無いキー
    class=ClientError  Code=404  HTTP=404
s3.get_object 無いバケット
    class=NoSuchBucket  Code=NoSuchBucket  HTTP=404
s3.create_bucket 作成済み
    class=BucketAlreadyOwnedByYou  Code=BucketAlreadyOwnedByYou  HTTP=409
s3.delete_bucket 空でない
    class=ClientError  Code=BucketNotEmpty  HTTP=409
ddb.get_item 無いテーブル
    class=ResourceNotFoundException  Code=ResourceNotFoundException  HTTP=400
ddb.create_table 作成済み
    class=ResourceInUseException  Code=ResourceInUseException  HTTP=400
ddb.put_item 条件に合わない
    class=ConditionalCheckFailedException  Code=ConditionalCheckFailedException  HTTP=400
ddb.get_item キー名が違う
    class=ClientError  Code=ValidationException  HTTP=400
sqs.get_queue_url 無いキュー
    class=QueueDoesNotExist  Code=AWS.SimpleQueueService.NonExistentQueue  HTTP=400
sqs.create_queue 属性違いで再作成
    class=QueueNameExists  Code=QueueAlreadyExists  HTTP=400
sqs.delete_message 受信ハンドルが不正
    class=ReceiptHandleIsInvalid  Code=ReceiptHandleIsInvalid  HTTP=400
iam.get_role 無いロール
    class=NoSuchEntityException  Code=NoSuchEntity  HTTP=404
iam.create_user 作成済み
    class=EntityAlreadyExistsException  Code=EntityAlreadyExists  HTTP=409
iam.create_policy JSON が不正
    class=MalformedPolicyDocumentException  Code=MalformedPolicyDocument  HTTP=400

クラス名と Error.Code が一致しないサービスがあります。 IAM はクラス名が NoSuchEntityException、コードが NoSuchEntity です。SQS はクラス名が QueueDoesNotExist、コードが AWS.SimpleQueueService.NonExistentQueue です。コードで分岐するときは、クラス名から推測せず、一度出力して確かめるのが確実です。

実行して確かめたものに、公式ドキュメントと botocore 同梱の API 定義から読み取ったものを足した一覧です(「確認」の列が「実行」のものは上の出力のとおり)。

S3

Error.Code HTTP 出る場面 client.exceptions のクラス 確認
NoSuchKey 404 get_object で無いキー NoSuchKey 実行
404 404 head_object・head_bucket で対象が無い なし 実行
NoSuchBucket 404 無いバケットを指定 NoSuchBucket 実行
BucketAlreadyOwnedByYou 409 自分が持っている名前で create_bucket BucketAlreadyOwnedByYou 実行
BucketAlreadyExists 409 ほかの利用者が使っている名前で create_bucket BucketAlreadyExists 公式
BucketNotEmpty 409 空でないバケットを delete_bucket なし 実行
AccessDenied 403 権限が足りない AccessDenied 公式・API 定義
InvalidAccessKeyId 403 アクセスキー ID が登録されていない なし 公式
SignatureDoesNotMatch 403 署名が合わない(シークレットキーの誤りなど) なし 公式
ExpiredToken 400 一時的な認証情報の期限切れ なし 公式
RequestTimeTooSkewed 403 端末の時刻とサーバーの時刻の差が大きい なし 公式
PreconditionFailed 412 指定した前提条件に合わない なし 公式
SlowDown 503 リクエストの頻度が高い なし 公式
InternalError 500 サービス内部のエラー なし 公式

DynamoDB

「再試行」の列は、公式ドキュメントのエラー表にある「OK to retry?」の値です。

Error.Code HTTP 出る場面 再試行 client.exceptions のクラス 確認
ResourceNotFoundException 400 無いテーブル・索引を指定 しない あり 実行
ResourceInUseException 400 作成済みのテーブルを作る、変更中のテーブルを操作 しない あり 実行
ConditionalCheckFailedException 400 ConditionExpression に合わない しない あり 実行
ValidationException 400 キーや式の書き方が合わない しない なし 実行
AccessDeniedException 400 権限が足りない しない なし 公式
ProvisionedThroughputExceededException 400 設定したスループットを超えた する あり 公式
ThrottlingException 400 リクエストの頻度が許容量を超えた する あり 公式
RequestLimitExceeded 400 アカウントのスループット上限を超えた する あり 公式
LimitExceededException 400 同時に行える操作の数を超えた する あり 公式
ItemCollectionSizeLimitExceededException 400 アイテムコレクションのサイズ上限を超えた する あり 公式
InternalServerError 500 サービス内部のエラー する あり 公式
ServiceUnavailable 503 一時的に利用できない する なし 公式

SQS

Error.Code HTTP 出る場面 client.exceptions のクラス 確認
AWS.SimpleQueueService.NonExistentQueue 400 無いキューを指定 QueueDoesNotExist 実行
QueueAlreadyExists 400 同じ名前・違う属性で create_queue QueueNameExists 実行
ReceiptHandleIsInvalid 400 受信ハンドルが正しくない ReceiptHandleIsInvalid 実行

API 定義には、このほか次のクラスがあります(説明は API 定義の文面から)。

client.exceptions のクラス 出る場面
QueueDeletedRecently キューを消してから 60 秒以内に同じ名前で作成
PurgeQueueInProgress 直近 60 秒以内に PurgeQueue を受け付けたキューに再度 PurgeQueue
TooManyEntriesInBatchRequest 一括操作のエントリが上限(10 件)を超えた
BatchRequestTooLong 一括送信のメッセージ合計が上限を超えた
OverLimit 処理中メッセージ数などの上限に達した
RequestThrottled リクエストの頻度が許容量を超えた

IAM

Error.Code HTTP 出る場面 client.exceptions のクラス 確認
NoSuchEntity 404 無いユーザー・ロール・ポリシーを指定 NoSuchEntityException 実行
EntityAlreadyExists 409 同じ名前で作成 EntityAlreadyExistsException 実行
MalformedPolicyDocument 400 ポリシーの JSON が正しくない MalformedPolicyDocumentException 実行
DeleteConflict 409 付随するものが残っているリソースを削除 DeleteConflictException API 定義
LimitExceeded 409 アカウントの上限を超えて作成 LimitExceededException API 定義
ConcurrentModification 409 同じ対象への変更が同時に送られた ConcurrentModificationException API 定義

STS

Error.Code HTTP 出る場面 client.exceptions のクラス 確認
ExpiredTokenException 400 渡した Web ID トークンの期限切れ ExpiredTokenException API 定義
InvalidIdentityToken 400 Web ID トークンを検証できない InvalidIdentityTokenException API 定義
MalformedPolicyDocument 400 セッションポリシーの JSON が正しくない MalformedPolicyDocumentException API 定義
PackedPolicyTooLarge 400 セッションポリシーとタグの合計が大きい PackedPolicyTooLargeException API 定義
RegionDisabledException 403 そのリージョンで STS が有効になっていない RegionDisabledException API 定義
ThrottlingException 400 リクエストの頻度が高い なし 公式
ServiceUnavailable 503 一時的に利用できない なし 公式

各サービスの全コードは、公式ドキュメントが案内しているとおり、サービスごとの API リファレンスにあるエラーの節で確認できます。

botocore.exceptionsの発生条件一覧

ここからは、AWS に届く前後に手元で起きるエラーです。認証情報もリージョンも設定していない状態を作り、順に起こしました。

# botocore_errors.py
import boto3
import botocore.exceptions as be
from botocore.config import Config

def show(label, func):
    try:
        func()
    except be.ClientError as e:
        print(f"[{label}] ClientError: {e}")
    except be.BotoCoreError as e:
        parents = [c.__name__ for c in type(e).__mro__[1:] if c.__module__ == "botocore.exceptions"]
        print(f"[{label}] {type(e).__name__}(親: {' > '.join(parents)})\n    {str(e).strip()[:110]}")

fast = Config(connect_timeout=1, read_timeout=1, retries={"total_max_attempts": 1})

show("引数の型が違う", lambda: boto3.client("s3", region_name="ap-northeast-1").list_objects_v2(Bucket="rjc-demo-bucket", MaxKeys="ten"))
show("必須の引数が無い", lambda: boto3.client("s3", region_name="ap-northeast-1").get_object(Bucket="rjc-demo-bucket"))
show("認証情報が無い", lambda: boto3.client("s3", region_name="ap-northeast-1").list_buckets())
show("認証情報が片方だけ", lambda: boto3.client("s3", region_name="ap-northeast-1", aws_access_key_id="AKIAEXAMPLE").list_buckets())
show("リージョンが無い", lambda: boto3.client("dynamodb"))
show("profile が無い", lambda: boto3.Session(profile_name="nothing"))
show("サービス名が違う", lambda: boto3.client("s33", region_name="ap-northeast-1"))
show("接続先に届かない", lambda: boto3.client(
    "s3", region_name="ap-northeast-1", endpoint_url="http://localhost:9219",
    aws_access_key_id="x", aws_secret_access_key="x", config=fast).list_buckets())
show("ページ分割に非対応の操作", lambda: boto3.client("s3", region_name="ap-northeast-1").get_paginator("get_object"))
show("retries の指定が違う", lambda: boto3.client("s3", region_name="ap-northeast-1", config=Config(retries={"mode": "fast"})))

設定を空にするため、次のように実行しています。

env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_DEFAULT_REGION \
    AWS_CONFIG_FILE=./empty AWS_SHARED_CREDENTIALS_FILE=./empty \
    AWS_EC2_METADATA_DISABLED=true python3 botocore_errors.py
[引数の型が違う] ParamValidationError(親: BotoCoreError)
    Parameter validation failed:
Invalid type for parameter MaxKeys, value: ten, type: <class 'str'>, valid types:
[必須の引数が無い] ParamValidationError(親: BotoCoreError)
    Parameter validation failed:
Missing required parameter in input: "Key"
[認証情報が無い] NoCredentialsError(親: BotoCoreError)
    Unable to locate credentials
[認証情報が片方だけ] PartialCredentialsError(親: BotoCoreError)
    Partial credentials found in explicit, missing: aws_secret_access_key
[リージョンが無い] NoRegionError(親: BaseEndpointResolverError > BotoCoreError)
    You must specify a region.
[profile が無い] ProfileNotFound(親: BotoCoreError)
    The config profile (nothing) could not be found
[サービス名が違う] UnknownServiceError(親: DataNotFoundError > BotoCoreError)
    Unknown service: 's33'. Valid service names are: accessanalyzer, account, account-access, acm, acm-pca, agent-
[接続先に届かない] EndpointConnectionError(親: ConnectionError > BotoCoreError)
    Could not connect to the endpoint URL: "http://localhost:9219/"
[ページ分割に非対応の操作] OperationNotPageableError(親: BotoCoreError)
    Operation cannot be paginated: get_object
[retries の指定が違う] InvalidRetryModeError(親: InvalidRetryConfigurationError > BotoCoreError)
    Invalid value provided to "mode": "fast" must be one of: ('legacy', 'standard', 'adaptive')

待機(waiter)が時間内に終わらないときは WaiterError です。

# waiter_error.py
import boto3
from botocore.exceptions import WaiterError

s3 = boto3.client("s3")
try:
    s3.get_waiter("bucket_exists").wait(
        Bucket="rjc-none-bucket", WaiterConfig={"Delay": 1, "MaxAttempts": 3})
except WaiterError as e:
    print(type(e).__name__, ":", e)
    print("最後の応答の Code:", e.last_response["Error"]["Code"])
WaiterError : Waiter BucketExists failed: Max attempts exceeded. Previously accepted state: Matched expected HTTP status code: 404
最後の応答の Code: 404
例外 起きるタイミング 発生条件 対処の方向 確認
ParamValidationError 送信前 引数の型が違う、必須の引数が無い、長さが範囲外 コードを直す(再試行しても同じ) 実行
NoCredentialsError 送信前 どの探索先にも認証情報が無い 環境変数・profile・ロールを設定 実行
PartialCredentialsError 送信前 アクセスキーとシークレットキーの片方だけ 両方をそろえる 実行
NoRegionError クライアント作成時 リージョンがどこにも指定されていない region_name か AWS_DEFAULT_REGION 実行
ProfileNotFound Session 作成時 指定した profile が設定ファイルに無い profile 名と設定ファイルの場所を確認 実行
UnknownServiceError クライアント作成時 サービス名の綴りが違う メッセージ内の一覧から正しい名前を選ぶ 実行
EndpointConnectionError 送信時 接続先に届かない(名前解決・ネットワーク・接続先の指定) ネットワークとリージョン名を確認 実行
ReadTimeoutError 応答待ち read_timeout の時間内に応答が無い タイムアウトと処理量を見直す 実行(後述)
WaiterError 待機中 MaxAttempts 回確認しても目的の状態にならない Delay・MaxAttempts を調整 実行
OperationNotPageableError paginator 取得時 ページ分割に対応していない操作を指定 対応する操作を使う 実行
InvalidRetryModeError Config 作成時 retries の mode が 3 種類以外 legacy・standard・adaptive から選ぶ 実行

botocore.exceptions に定義されているクラスは、公式ドキュメントにある次のコードで一覧できます(この環境では 101 個でした)。

import botocore.exceptions

for key, value in sorted(botocore.exceptions.__dict__.items()):
    if isinstance(value, type):
        print(key)

まとめて受ける定型

import boto3
from botocore.exceptions import BotoCoreError, ClientError, ParamValidationError

s3 = boto3.client("s3")

def fetch(bucket, key):
    try:
        return s3.get_object(Bucket=bucket, Key=key)["Body"].read()
    except s3.exceptions.NoSuchKey:
        return None                                   # 想定内:無ければ None
    except ClientError as e:                          # AWS が返したエラー
        err, meta = e.response["Error"], e.response["ResponseMetadata"]
        print(f"AWS error op={e.operation_name} code={err['Code']} "
              f"http={meta['HTTPStatusCode']} request_id={meta['RequestId']}")
        raise
    except ParamValidationError as e:                 # 引数の誤り
        raise ValueError(f"引数を確認してください: {e}") from e
    except BotoCoreError as e:                        # 認証情報・接続など
        print(f"client-side error {type(e).__name__}: {e}")
        raise

print(fetch("rjc-demo-bucket", "report.csv"))
None

リトライ設定:legacy・standard・adaptiveの比較

boto3 は、一時的なエラーを自動で再送します。挙動は Config の retries で決めます。

import boto3
from botocore.config import Config

config = Config(retries={"mode": "standard", "total_max_attempts": 5})
sqs = boto3.client("sqs", config=config)

公式ドキュメント(Retries)の内容を表にします。

比べる点 legacy standard adaptive
位置づけ 指定しないときの既定 各言語の SDK で共通化されたルール standard + クライアント側の流量調整(公式では試験的な扱い)
既定の最大試行回数(初回を含む) 5 3 3
再送する HTTP ステータス 429、500、502、503、504、509 500、502、503、504 standard と同じ
再送するエラーコード Throttling、ThrottlingException、ThrottledException、RequestThrottledException、ProvisionedThroughputExceededException 左に加えて RequestTimeout、PriorRequestNotComplete、TooManyRequestsException、TransactionInProgressException、RequestLimitExceeded、LimitExceededException、SlowDown など standard と同じ
再送する接続系のエラー ConnectionError、ConnectionClosedError、ReadTimeoutError、EndpointConnectionError ConnectionError、HTTPClientError standard と同じ
待ち時間 2 を底にした指数バックオフ 同じ(最大 20 秒) 同じ + 送信ペースの調整
そのほか ― 障害が続くときに再送を控える仕組み エラーの応答に応じて送信レートを更新

設定できる場所は 3 つです。

場所 書き方 max_attempts の意味
設定ファイル(~/.aws/config) retry_mode = standard と max_attempts = 5 初回を含む総回数
環境変数 AWS_RETRY_MODE と AWS_MAX_ATTEMPTS 初回を含む総回数
Config オブジェクト retries={"mode": ..., "max_attempts": N} 再送の回数(初回を含まない)
Config オブジェクト retries={"mode": ..., "total_max_attempts": N} 初回を含む総回数

Config の max_attempts だけ意味が違うので、コードでは total_max_attempts を使うと、設定ファイルや環境変数と数え方がそろいます。

何回送られるかを数える

実際の AWS で 503 やスロットリングを狙って起こすことは難しいので、決まったエラー応答を返す小さな HTTP サーバーを立て、そこへ SQS の ListQueues を送って回数を数えます。次の 2 ファイルを同じフォルダーに置きます(このサーバーは回数を数えるためのもので、接続先を endpoint_url で切り替えています)。

# stub_server.py
"""決まった応答を返す検証用の HTTP サーバー(SQS の ListQueues 向け)"""
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

RESPONSES = {
    "503": (503, {"__type": "ServiceUnavailable", "message": "Service Unavailable"}),
    "throttle": (400, {"__type": "ThrottlingException", "message": "Rate exceeded"}),
    "denied": (403, {"__type": "AccessDeniedException", "message": "not authorized"}),
    "invalid": (400, {"__type": "InvalidParameterValue", "message": "invalid value"}),
}
state = {"mode": "503", "fail_times": 999, "requests": 0}

class Handler(BaseHTTPRequestHandler):
    def log_message(self, *args):
        pass

    def do_POST(self):
        self.rfile.read(int(self.headers.get("Content-Length", 0)))
        state["requests"] += 1
        if state["requests"] <= state["fail_times"]:
            if state["mode"] == "slow":
                time.sleep(3)
                status, body = 200, {"QueueUrls": []}
            else:
                status, body = RESPONSES[state["mode"]]
        else:
            status, body = 200, {"QueueUrls": []}
        data = json.dumps(body).encode()
        try:
            self.send_response(status)
            self.send_header("Content-Type", "application/x-amz-json-1.0")
            self.send_header("x-amzn-RequestId", f"req-{state['requests']:04d}")
            self.send_header("Content-Length", str(len(data)))
            self.end_headers()
            self.wfile.write(data)
        except OSError:
            pass  # クライアントが先に切った場合

def start(port=9211):
    server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
    threading.Thread(target=server.serve_forever, daemon=True).start()
    return f"http://127.0.0.1:{port}"

def reset(mode, fail_times=999):
    state.update(mode=mode, fail_times=fail_times, requests=0)
# retry_lab.py
import time
import boto3
from botocore.config import Config
from botocore.exceptions import BotoCoreError, ClientError
import stub_server

URL = stub_server.start()

def run(label, retries, mode, fail_times=999):
    stub_server.reset(mode, fail_times)
    sqs = boto3.client(
        "sqs", endpoint_url=URL, region_name="ap-northeast-1",
        aws_access_key_id="x", aws_secret_access_key="x",
        config=Config(retries=retries) if retries else None)
    started = time.time()
    try:
        meta = sqs.list_queues()["ResponseMetadata"]
        result = f"成功 RetryAttempts={meta['RetryAttempts']}"
    except ClientError as e:
        meta = e.response["ResponseMetadata"]
        result = f"{e.response['Error']['Code']} RetryAttempts={meta['RetryAttempts']}"
    except BotoCoreError as e:
        result = type(e).__name__
    print(f"{label:32} 送信 {stub_server.state['requests']} 回 {time.time() - started:4.1f} 秒  {result}")

print("== 503 を返し続ける")
run("指定なし", None, "503")
for mode in ["legacy", "standard", "adaptive"]:
    run(f"mode={mode}", {"mode": mode}, "503")
print("== ThrottlingException(HTTP 400)を返し続ける")
for mode in ["legacy", "standard", "adaptive"]:
    run(f"mode={mode}", {"mode": mode}, "throttle")
print("== リトライされないエラー")
for mode in ["legacy", "standard"]:
    run(f"mode={mode} AccessDenied", {"mode": mode}, "denied")
    run(f"mode={mode} InvalidParameter", {"mode": mode}, "invalid")
print("== max_attempts と total_max_attempts")
run("standard max_attempts=3", {"mode": "standard", "max_attempts": 3}, "503")
run("standard total_max_attempts=3", {"mode": "standard", "total_max_attempts": 3}, "503")
run("standard total_max_attempts=1", {"mode": "standard", "total_max_attempts": 1}, "503")
print("== 2 回エラーのあと成功する")
run("standard total_max_attempts=5", {"mode": "standard", "total_max_attempts": 5}, "503", fail_times=2)
== 503 を返し続ける
指定なし                             送信 5 回  8.7 秒  ServiceUnavailable RetryAttempts=4
mode=legacy                      送信 5 回  8.1 秒  ServiceUnavailable RetryAttempts=4
mode=standard                    送信 3 回  1.4 秒  ServiceUnavailable RetryAttempts=2
mode=adaptive                    送信 3 回  1.8 秒  ServiceUnavailable RetryAttempts=2
== ThrottlingException(HTTP 400)を返し続ける
mode=legacy                      送信 5 回  3.8 秒  ThrottlingException RetryAttempts=4
mode=standard                    送信 3 回  0.9 秒  ThrottlingException RetryAttempts=2
mode=adaptive                    送信 3 回  4.0 秒  ThrottlingException RetryAttempts=2
== リトライされないエラー
mode=legacy AccessDenied         送信 1 回  0.0 秒  AccessDeniedException RetryAttempts=0
mode=legacy InvalidParameter     送信 1 回  0.0 秒  InvalidParameterValue RetryAttempts=0
mode=standard AccessDenied       送信 1 回  0.0 秒  AccessDeniedException RetryAttempts=0
mode=standard InvalidParameter   送信 1 回  0.0 秒  InvalidParameterValue RetryAttempts=0
== max_attempts と total_max_attempts
standard max_attempts=3          送信 4 回  0.7 秒  ServiceUnavailable RetryAttempts=3
standard total_max_attempts=3    送信 3 回  1.6 秒  ServiceUnavailable RetryAttempts=2
standard total_max_attempts=1    送信 1 回  0.0 秒  ServiceUnavailable RetryAttempts=0
== 2 回エラーのあと成功する
standard total_max_attempts=5    送信 3 回  1.6 秒  成功 RetryAttempts=2

読み取れることを整理します。

確かめたこと 結果
指定なしの挙動 legacy と同じ 5 回(初回 1 + 再送 4)
standard・adaptive の既定 3 回(初回 1 + 再送 2)
HTTP 400 でも ThrottlingException は再送される 5 回/3 回
AccessDeniedException・InvalidParameterValue 1 回だけ(再送しても結果が変わらないエラーは再送されない)
Config の max_attempts=3 4 回送られる(再送が 3 回)
Config の total_max_attempts=3 3 回
total_max_attempts=1 自動の再送なし
途中で成功した場合 成功した応答の RetryAttempts に、それまでの再送回数(2)が入る

かかった秒数は、待ち時間に乱数が入るため実行のたびに変わります。

リトライされるエラー・されないエラー

種類 例 SDK の自動リトライ 自分で書く対処
スロットリング ThrottlingException、ProvisionedThroughputExceededException、SlowDown される 回数を増やす、呼び出しの頻度を下げる
サービス側の一時的なエラー HTTP 500・502・503・504 される 総回数とタイムアウトを決めておく
接続・タイムアウト ConnectionError、ReadTimeoutError される connect_timeout・read_timeout を明示
権限・認証 AccessDenied、ExpiredToken されない 権限や認証情報を直す
入力の誤り ValidationException、ParamValidationError されない コードを直す
対象が無い・状態が合わない NoSuchKey、ResourceNotFoundException、ConditionalCheckFailedException されない 業務上の分岐として扱う

タイムアウトとリトライは掛け算になる

応答に 3 秒かかるサーバーへ、read_timeout=1 で送ります。

# timeout_demo.py
import time
import boto3
from botocore.config import Config
from botocore.exceptions import ReadTimeoutError
import stub_server

URL = stub_server.start()

for label, retries in [("total_max_attempts=3", 3), ("total_max_attempts=1", 1)]:
    stub_server.reset("slow")          # 応答まで 3 秒かかる
    sqs = boto3.client(
        "sqs", endpoint_url=URL, region_name="ap-northeast-1",
        aws_access_key_id="x", aws_secret_access_key="x",
        config=Config(connect_timeout=2, read_timeout=1,
                      retries={"mode": "standard", "total_max_attempts": retries}))
    started = time.time()
    try:
        sqs.list_queues()
    except ReadTimeoutError as e:
        print(f"{label}: 送信 {stub_server.state['requests']} 回 {time.time() - started:.1f} 秒")
        print("   ", type(e).__name__, ":", e)
total_max_attempts=3: 送信 3 回 4.0 秒
    ReadTimeoutError : Read timeout on endpoint URL: "http://127.0.0.1:9211/"
total_max_attempts=1: 送信 1 回 1.0 秒
    ReadTimeoutError : Read timeout on endpoint URL: "http://127.0.0.1:9211/"

1 回の呼び出しが返るまでの最長時間は、おおよそ「タイムアウト × 総回数 + 待ち時間の合計」になります。read_timeout の既定は 60 秒なので、画面の応答や Lambda の実行時間に上限がある処理では、Config でタイムアウトと総回数の両方を決めておくと見積もりやすくなります。

自前バックオフとの二重リトライに注意

SDK の自動リトライの外側に自前のリトライを書くと、回数は掛け算になります。

# double_retry.py
import time
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
import stub_server

URL = stub_server.start()
RETRYABLE = {"ThrottlingException", "ServiceUnavailable"}

def make_client(**retries):
    return boto3.client(
        "sqs", endpoint_url=URL, region_name="ap-northeast-1",
        aws_access_key_id="x", aws_secret_access_key="x",
        config=Config(retries=retries))

def call_with_backoff(client, tries=3, base=0.2):
    """自前の指数バックオフ(リトライしてよいコードだけ)"""
    for attempt in range(1, tries + 1):
        try:
            return client.list_queues()
        except ClientError as e:
            if e.response["Error"]["Code"] not in RETRYABLE or attempt == tries:
                raise
            time.sleep(base * 2 ** (attempt - 1))

for label, client in [
    ("SDK 3 回 × 自前 3 回", make_client(mode="standard", total_max_attempts=3)),
    ("SDK 1 回 × 自前 3 回", make_client(mode="standard", total_max_attempts=1)),
]:
    stub_server.reset("throttle")
    started = time.time()
    try:
        call_with_backoff(client)
    except ClientError as e:
        print(f"{label}: 送信 {stub_server.state['requests']} 回 "
              f"{time.time() - started:.1f} 秒 -> {e.response['Error']['Code']}")
SDK 3 回 × 自前 3 回: 送信 9 回 4.1 秒 -> ThrottlingException
SDK 1 回 × 自前 3 回: 送信 3 回 0.6 秒 -> ThrottlingException

SDK が 3 回送るクライアントを自前のループで 3 回呼ぶと、合計 9 回送られました。スロットリング中のサービスに送る回数が増えると、回復までの時間も延びやすくなります。

方針 設定 向いている場面
SDK に任せる(基本) retries={"mode": "standard", "total_max_attempts": 5} のように回数だけ調整し、自前のループは書かない ほとんどの API 呼び出し
自前で制御する SDK 側を total_max_attempts=1 にして、自前のループで回数と待ち時間を決める 待ち時間の上限や、再試行のたびのログ出力を細かく決めたい
両方を使う 自前のループは「SDK が再送しないが、業務上は待てば解決するもの」だけを対象にする 作成直後のリソースが見えるまで待つ、など

DynamoDB の batch_write_item が返す UnprocessedItems のように、エラーにならずに「残り」が返る API は SDK の自動リトライの対象外です。公式ドキュメントは、残りを送り直すときに指数バックオフを使うことを強く勧めています。

よくある質問

Q. except Exception でまとめて受けてもよいですか? A. 動きますが、原因の切り分けがしにくくなります。ClientError(AWS が返したエラー)と BotoCoreError(手元のエラー)を分けて受け、ClientError は Error.Code をログに残す形がおすすめです。

Q. エラーコードの文字列はどこで調べられますか? A. 各サービスの API リファレンスのエラーの節です。手元では、一度エラーを起こして e.response["Error"]["Code"] を表示するのが確実です。クラス名とコードが違うサービス(IAM・SQS など)があります。

Q. リトライが本当に行われたかを確かめるには? A. 応答(成功時も例外時も)の ResponseMetadata["RetryAttempts"] を見ます。公式ドキュメントによると、DEBUG ログにも再送の記録が出ます(legacy は botocore.retryhandler、standard・adaptive は botocore.retries.standard)。

Q. resource を使っているときは、どう捕まえますか? A. resource.meta.client.exceptions.XXX か、client のときと同じ ClientError + コード判定で捕まえます。

まとめ

  • boto3 のエラーは ClientError(AWS が返した)と BotoCoreError(手元で起きた)の 2 系統で、親子関係はありません。
  • ClientError は e.response["Error"]["Code"] で分岐し、RequestId・HTTPStatusCode・RetryAttempts をログに残します。
  • client.exceptions.XXX は API 定義にあるエラーだけです。"404" や BucketNotEmpty のように、ClientError でしか受けられないコードがあります。
  • クラス名とコードが違うサービスがあるので、コードは実際に出力して確かめます。
  • リトライは指定なしだと legacy の 5 回、standard・adaptive は 3 回です。回数は total_max_attempts で指定すると数え方がそろいます。
  • スロットリング・5xx・接続系は自動で再送され、権限・入力の誤り・対象なしは再送されません。
  • 自前のバックオフを重ねると回数は掛け算になります。SDK に任せるか、SDK 側を 1 回にして自前で制御するかを決めます。

参考・出典

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「boto3のエラーハンドリング|ClientErrorのコード取得とリトライ設定」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?