Every failed call carries a service error code, an HTTP status, and a request id. Handling errors well means branching on that code reliably, in both languages. This page shows the two idioms - boto3's `ClientError` plus modeled exceptions, and SDK v3's typed exception classes - and how to read the metadata that makes failures debuggable.
## Recipe
> Catch the AWS error type, read its code, and branch.
<CodeGroup labels="Python (boto3), TypeScript (AWS SDK v3)">
```python
# --- Python (boto3) ---
from botocore.exceptions import ClientError
try:
s3.get_object(Bucket="my-bucket", Key="missing.txt")
except ClientError as err:
code = err.response["Error"]["Code"]
if code == "NoSuchKey":
print("object not found")
else:
raise
```
```typescript
// --- TypeScript (AWS SDK v3) ---
import { GetObjectCommand, NoSuchKey } from "@aws-sdk/client-s3";
try {
await s3.send(new GetObjectCommand({ Bucket: "my-bucket", Key: "missing.txt" }));
} catch (err) {
if (err instanceof NoSuchKey) {
console.log("object not found");
} else {
throw err;
}
}
```
</CodeGroup>
**When to reach for this:**
- You need different behavior for different failures (not found vs access denied vs throttled).
- You want to swallow an expected error (missing key) but re-raise unexpected ones.
- You are logging failures and need the error code and request id.
- You are deciding whether a failure is worth retrying yourself.
## Working Example
Handle several distinct outcomes of one operation, and always log the request id for support.
<CodeGroup labels="Python (boto3), TypeScript (AWS SDK v3)">
```python
# --- Python (boto3) ---
from botocore.exceptions import ClientError
def read_object(s3, bucket, key):
try:
return s3.get_object(Bucket=bucket, Key=key)["Body"].read()
except ClientError as err:
code = err.response["Error"]["Code"]
rid = err.response["ResponseMetadata"]["RequestId"]
status = err.response["ResponseMetadata"]["HTTPStatusCode"]
if code in ("NoSuchKey", "404"):
return None # expected: treat as missing
if code == "AccessDenied":
raise PermissionError(f"denied (request {rid})")
print(f"unexpected {code} status={status} request={rid}")
raise
```
```typescript
// --- TypeScript (AWS SDK v3) ---
import { GetObjectCommand, NoSuchKey } from "@aws-sdk/client-s3";
async function readObject(s3: import("@aws-sdk/client-s3").S3Client, bucket: string, key: string) {
try {
const out = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
return await out.Body?.transformToByteArray();
} catch (err) {
const e = err as { name: string; $metadata?: { requestId?: string; httpStatusCode?: number } };
const rid = e.$metadata?.requestId;
if (err instanceof NoSuchKey || e.name === "NoSuchKey") return undefined;
if (e.name === "AccessDenied") throw new Error(`denied (request ${rid})`);
console.log(`unexpected ${e.name} status=${e.$metadata?.httpStatusCode} request=${rid}`);
throw err;
}
}
```
</CodeGroup>
**What this demonstrates:**
- boto3 reads the code at `err.response["Error"]["Code"]` and metadata under `ResponseMetadata`.
- SDK v3 reads the code from `error.name` and metadata from `error.$metadata`.
- Both branch: swallow the expected missing case, remap access-denied, re-raise the rest.
- The request id is logged on every unexpected failure for AWS support.
## Deep Dive
### The Two boto3 Idioms
boto3 gives you two ways to catch a specific error:
- **Catch `ClientError` and check the code.** Works for every operation and error code, and is the most general approach.
- **Catch a modeled exception class.** Service clients expose typed exceptions like `s3.exceptions.NoSuchKey` or `dynamodb.exceptions.ConditionalCheckFailedException`, which read more cleanly when they exist.
<CodeGroup labels="Python (boto3), TypeScript (AWS SDK v3)">
```python
# --- Python (boto3) ---
# Catch-by-type using the client's modeled exceptions.
try:
s3.get_object(Bucket="my-bucket", Key="missing.txt")
except s3.exceptions.NoSuchKey:
print("object not found")
```
```typescript
// --- TypeScript (AWS SDK v3) ---
// v3's equivalent: a typed exception class exported by the client package.
import { GetObjectCommand, NoSuchKey } from "@aws-sdk/client-s3";
try {
await s3.send(new GetObjectCommand({ Bucket: "my-bucket", Key: "missing.txt" }));
} catch (err) {
if (err instanceof NoSuchKey) console.log("object not found");
}
```
</CodeGroup>
### SDK v3 Exceptions
- Every modeled error is a class extending the service's base exception (itself extending `ServiceException`).
- `instanceof NoSuchKey` is the type-safe check; `error.name` is the string fallback, useful when the class is not imported.
- `error.$metadata` carries `requestId`, `httpStatusCode`, and `attempts`.
- `error.$fault` is `"client"` or `"server"`, a quick signal of who is responsible.
### Reading the Metadata
| Field | boto3 | SDK v3 |
|-------|-------|--------|
| Error code | `err.response["Error"]["Code"]` | `error.name` |
| Message | `err.response["Error"]["Message"]` | `error.message` |
| Request id | `err.response["ResponseMetadata"]["RequestId"]` | `error.$metadata.requestId` |
| HTTP status | `err.response["ResponseMetadata"]["HTTPStatusCode"]` | `error.$metadata.httpStatusCode` |
### Retryable vs Terminal
The SDK already retries throttling and transient errors. When you catch an error yourself, it has usually exhausted retries. Treat throttling as retryable at the application level if you want; treat validation, access-denied, and not-found as terminal - retrying them cannot help.
## Gotchas
- **Bare `except Exception` / `catch (err)` with no branch** - hides which failure occurred. **Fix:** read the code and handle cases explicitly.
- **String-matching the message** - messages are human text and can change. **Fix:** branch on the code (`Error.Code` / `error.name`), never the message.
- **Assuming a modeled exception exists** - not every error has a class. **Fix:** fall back to `ClientError` + code, or `error.name`.
- **Dropping the request id** - makes support cases and debugging far harder. **Fix:** log `RequestId` / `$metadata.requestId` on failure.
- **Catching too broadly and swallowing retryable errors** - hides transient issues the SDK would have retried. **Fix:** re-raise unexpected codes.
- **Confusing S3 codes** - S3 uses `NoSuchKey`, `NoSuchBucket`, and `SlowDown`, not generic names. **Fix:** match the service's actual codes.
<span class="text-xs italic text-foreground/40" aria-hidden="true">636f64656775696465732e696f7c6367696f313136387c323032363037</span>
## Alternatives
| Alternative | Use When | Don't Use When |
|-------------|----------|----------------|
| `ClientError` + code (boto3) | You need to handle any code, generically | A clean modeled exception exists and reads better |
| Modeled exception class | A typed class exists for the case | The error has no modeled class |
| `instanceof` (SDK v3) | You imported the exception class | You only have a code string to compare |
| `error.name` branch (SDK v3) | You did not import the class | You want compile-time type narrowing |
## FAQs
<details>
<summary>Should I branch on the code or the message?</summary>
Always the code. `err.response["Error"]["Code"]` in boto3 and `error.name` in SDK v3 are stable identifiers. Messages are human-readable text that can change between releases.
</details>
<details>
<summary>What is the difference between ClientError and a modeled exception in boto3?</summary>
`ClientError` is the general base you catch and then inspect. Modeled exceptions like `s3.exceptions.NoSuchKey` are subclasses you can catch directly when one exists, which reads more cleanly.
</details>
<details>
<summary>How do I catch a specific error in SDK v3?</summary>
Import the exception class from the client package and use `instanceof`, for example `err instanceof NoSuchKey`. If it is not imported, compare `error.name` to the code string.
</details>
<details>
<summary>Where is the request id on an error?</summary>
In boto3 at `err.response["ResponseMetadata"]["RequestId"]`, and in SDK v3 at `error.$metadata.requestId`. Log it on every failure for support and tracing.
</details>
<details>
<summary>Which errors should I retry myself?</summary>
Usually none of the terminal ones - validation, access-denied, and not-found are not helped by retrying. Throttling and transient errors are already retried by the SDK before the exception reaches you.
</details>
<details>
<summary>What does $fault tell me in SDK v3?</summary>
Whether the error is a `"client"` fault (your request was invalid) or a `"server"` fault (AWS-side). It is a fast way to decide if fixing the request could help.
</details>
<details>
<summary>Do all services use the same error codes?</summary>
No. Codes are service-specific. S3 uses `NoSuchKey` and `SlowDown`; DynamoDB uses `ConditionalCheckFailedException` and `ProvisionedThroughputExceededException`. Match the codes for the services you call.
</details>
<details>
<summary>Why not just let errors bubble up?</summary>
For truly unexpected errors, bubbling up is fine. But expected outcomes (a missing object, a conditional check failure) usually warrant specific handling so the workflow behaves correctly instead of crashing.
</details>
## Related
- [Pagination & Retries Basics](./pagination-and-retries-basics.md) - handling a throttling error for the first time.
- [Exponential Backoff & Jitter Configuration](./exponential-backoff-and-jitter-configuration.md) - which errors the SDK retries for you.
- [Idempotency Tokens & Safe Retries](./idempotency-tokens-and-safe-retries.md) - retrying writes without duplicating effects.
- [Pagination, Waiters & Retries Best Practices](./best-practices.md) - error-handling defaults to set.
> **Stack versions:** This page was written for **boto3 1.43.x** (Python 3.10+) and the **AWS SDK for JavaScript v3** (Node.js 18+).
</content>