Log API Errors
What a Failure Needs to Tell You Later
Retries and timeouts decide what happens during a failure. Logging decides what is known afterward. When a user reports that an answer never arrived, or a nightly job finishes with a hundred failures, the question is always the same: what happened, how often, and why? The answer exists only if the application recorded it at the time, in a form that can be searched.
A useful error log answers specific questions. Which operation failed? Which model was called? What status and error type came back? Was the failure retried, and how many times? Which request ID would a support team need? And, just as important, what was deliberately left out, such as the content of prompts and answers. This lesson builds a structured logging approach that records those facts consistently, and explains the privacy boundaries that apply to what is written down.
Why Structured Logs
A log line written as a sentence, such as "API call failed for summarize with 529", reads well to a person and works poorly for a machine. Finding every failure of one type, or counting failures by operation, requires parsing text, which breaks whenever the wording changes.
A structured log line records the same information as named fields. Each event becomes one JSON object, with a stable event name and consistent keys. A log platform can then filter by status, group by operation, or count will_retry values without any parsing. The rule is simple: the message says what kind of event occurred, and the fields say the details.
A JSON Formatter for the Standard Library
Python's logging module separates the logger, which creates records, from the handler and formatter, which write them. A small formatter turns each record into one JSON line. Extra fields are passed through the extra argument, which attaches them to the record:
"""api_logging.py: structured error logs built on the standard logging module."""
import json
import logging
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {"event": record.getMessage(), "level": record.levelname.lower()}
payload.update(getattr(record, "fields", {}))
return json.dumps(payload, ensure_ascii=False, sort_keys=True)
The formatter is the only place that knows the output format. If the team later switches to another logging backend, the calling code does not change, because it still passes events and fields to the logger.
sort_keys=True keeps the field order stable, which makes the output easy to compare between runs and in tests. It does not change the meaning of the data.
Turning an Error into Fields
Each SDK error type yields a different set of useful fields. An APIStatusError has a status, a request ID, and a body with an error type. A connection error has only a kind, and a timeout is a specific connection error. The function below extracts whatever applies, and skips what does not, so no log line is filled with placeholders:
from anthropic import APIConnectionError, APIStatusError, APITimeoutError
def error_fields(error: Exception, operation: str, attempt: int) -> dict:
fields = {"operation": operation, "attempt": attempt, "error_class": type(error).__name__}
if isinstance(error, APIStatusError):
body = error.body if isinstance(error.body, dict) else {}
fields["status"] = error.status_code
fields["error_type"] = body.get("error", {}).get("type")
fields["request_id"] = error.request_id
elif isinstance(error, APITimeoutError):
fields["kind"] = "timeout"
elif isinstance(error, APIConnectionError):
fields["kind"] = "connection"
return fields
def log_api_error(logger: logging.Logger, error: Exception, operation: str,
attempt: int, will_retry: bool) -> None:
fields = error_fields(error, operation, attempt)
fields["will_retry"] = will_retry
level = logging.WARNING if will_retry else logging.ERROR
logger.log(level, "api_call_failed", extra={"fields": fields})
How It Works
error_fields()starts with what every error has: the operation name, the attempt number, and the class name.- Status errors add the status, the error type from the body, and the request ID. The request ID is the field a support team needs most, because it locates the exact request on the service's side.
- Timeouts and connection errors add a kind, which separates them without pretending they have a status.
log_api_error()chooses the level from the decision. A failure that will be retried is a warning, since the system is handling it. A failure that will not be retried is an error, since it needs attention. The decision and the level therefore always agree.- The
extraargument carries the fields to the formatter, where they become part of the JSON object.
Recording the Request Without Its Content
Errors are often easier to diagnose when the request's shape is known: which model, how large the limit, how many messages, how much text. Those facts are safe to log, and they help. The content of the prompts and answers is different. It may contain personal information, and logs are usually kept longer and read by more people than the data itself. The safe approach records a summary of the request instead of the request:
def request_summary(params: dict) -> dict:
messages = params.get("messages", [])
characters = sum(len(str(m.get("content", ""))) for m in messages)
return {
"model": params.get("model"),
"max_tokens": params.get("max_tokens"),
"message_count": len(messages),
"input_characters": characters,
}
The summary tells an investigator that a request was very large, or used an unexpected model, without revealing a word of what it said. Whether to log even that much depends on the data and the policy in force, and the summary can be reduced further if needed.
Practical Example: Logging a Failing Job
The example below sends three failures through the logging functions: a retried overload, a terminal bad request, and a timeout. A handler writes each event to standard output as one JSON line, and the request summary is shown for one of them. The output is deterministic, because the formatter writes no timestamps, and the fields are sorted.
"""api_logging_demo.py: structured error logs for three failures."""
import logging
import sys
from api_errors import fake_status_error
from api_logging import JsonFormatter, log_api_error, request_summary
from anthropic import APITimeoutError
import httpx
logger = logging.getLogger("gocourse.api")
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.propagate = False
REQUEST = httpx.Request("POST", "https://api.anthropic.com/v1/messages")
if __name__ == "__main__":
log_api_error(logger, fake_status_error(529, "overloaded_error", "overloaded", "req_529"),
operation="summarize", attempt=1, will_retry=True)
log_api_error(logger, fake_status_error(400, "invalid_request_error", "bad input", "req_400"),
operation="summarize", attempt=1, will_retry=False)
log_api_error(logger, APITimeoutError(request=REQUEST),
operation="summarize", attempt=2, will_retry=True)
summary = request_summary({"model": "claude-opus-5-5", "max_tokens": 512,
"messages": [{"role": "user", "content": "Summarize the report."}]})
logger.info("request_sent", extra={"fields": summary})
Output:
{"attempt": 1, "error_class": "InternalServerError", "error_type": "overloaded_error", "event": "api_call_failed", "level": "warning", "operation": "summarize", "request_id": "req_529", "status": 529, "will_retry": true}
{"attempt": 1, "error_class": "BadRequestError", "error_type": "invalid_request_error", "event": "api_call_failed", "level": "error", "operation": "summarize", "request_id": "req_400", "status": 400, "will_retry": false}
{"attempt": 2, "error_class": "APITimeoutError", "event": "api_call_failed", "kind": "timeout", "level": "warning", "operation": "summarize", "will_retry": true}
{"event": "request_sent", "input_characters": 21, "level": "info", "max_tokens": 512, "message_count": 1, "model": "claude-opus-5-5"}
How It Works
- The logger is configured once, with a handler that uses the JSON formatter. The calling code only needs
log_api_error(), which keeps the output format in one place. - The overload is logged as a warning, because the request will be retried. The status, the error type, and the request ID are all present.
- The bad request is logged as an error, because retrying it would not help. The same fields appear, with
will_retryset to false, so a dashboard can count terminal failures separately. - The timeout has no status, so its line carries
kindinstead. The absence of a status is information, and the log says so by leaving the field out. - The request summary records size and model. The one message, "Summarize the report.", is 21 characters, and the log records that count while leaving out the words themselves.
What Not to Log
Certain values should never reach a log, however convenient they seem. The API key is the most obvious, and it can appear in configuration dumps or exception text if code is careless. Prompt and answer content can carry personal data. Full tool results may contain records from other systems. A log that holds any of these becomes a second copy of sensitive data, with its own retention and access rules.
The rule is to log the decisions and the facts about the work, not the work itself. When the content is needed for debugging, it belongs in a separate, access-controlled store with a shorter retention period, and it should be captured deliberately, not by default.
Environment-Level Logging
The SDK itself can log its requests and responses. The ANTHROPIC_LOG environment variable enables this at levels such as info and debug (Lesson 97). Those logs are useful during development, but they are detailed, and in production they can record more than the policy allows. Application-level structured logs, as built here, are the normal production choice, and the SDK logs are switched on only when a specific investigation needs them.
Summary
Structured logs record what failed, in fields that can be searched and counted. The request ID connects a log line to the service's own record, the status and error type explain the failure, and the will_retry field shows what the system did about it. Request summaries describe the shape of the work without its content, and secrets and personal data stay out of the logs entirely. The next lesson follows the request ID further, from the application through every attempt.