Error Classification, Logging, and Troubleshooting

Objective

Break “an API call failed” into actionable error layers. Design stable exception boundaries and safe logs. When faced with an HTML error page, invalid JSON, schema changes, business rejection, or network failure, retain enough evidence without exposing credentials.

Use layers, not one except Exception

One call can fail at at least these layers:

LayerExampleRetry automatically?
Local configurationWrong base URL, missing environment variableNo; correct configuration.
Request constructionObject cannot be JSON serialized, parameter validation failsNo; correct code or input.
TransportDNS, refused connection, connection/read timeoutDepends on stage and idempotency.
TLS identity validationCertificate chain, hostname, or trust configuration failureNo; check CA, hostname, system time, and proxy. Do not hide it with retry or verify=False.
HTTP401, 404, 429, 503Follow status and API contract.
RepresentationExpected JSON but received HTML; malformed JSONUsually investigate gateway or contract first.
Schema/businessMissing field, business error code, task state failedFollow business rule.

Classification lets upper layers decide correctly: invalid user input can prompt immediately, an authentication error can update configuration, 429 can enter a later queue, a create with unknown outcome needs a status lookup, and only transient server failure enters a finite retry.

Design your exception boundary

Do not make a business layer depend on every requests.exceptions detail. A client module can translate them into a small stable set:

class ApiClientError(Exception):  # Define the stable base exception the business layer needs to recognize.
    """Base class for API-call failures that this client has classified."""  # All subclasses represent a categorized API-call failure.
 
 
class ApiTransportError(ApiClientError):  # Represent DNS, connection, or read failure before a usable HTTP response exists.
    """No usable HTTP response was obtained."""  # An upper layer can decide on a finite retry from operation idempotency.
 
 
class ApiHttpError(ApiClientError):  # Represent an HTTP status and safely recordable server information.
    def __init__(self, status: int, code: str | None, request_id: str | None):  # Accept status, machine-readable code, and server request ID.
        super().__init__(f"API returned HTTP {status}, code={code!r}")  # Produce a stable message with no response body or credential.
        self.status = status  # Retain HTTP status for retry or user-feedback policy.
        self.code = code  # Retain a documented machine code; do not branch on natural-language detail.
        self.request_id = request_id  # Retain a diagnostic server ID for safe support correlation.
 
 
class ApiResponseError(ApiClientError):  # Represent a media type, JSON syntax, or schema that violates client expectations.
    """Response representation or schema does not meet the contract."""  # Investigate gateway, version, and server behavior before blind retry.

An upper layer can catch only ApiClientError for common presentation, then use the subtype to choose retry, alerting, or user correction. Use raise ... from exc to preserve causality. Debug logs can retain a stack trace, but user-facing messages must not expose local paths or response bodies.

HTTP error bodies

RFC 9457 defines application/problem+json, commonly with type, title, status, detail, and instance; an API can add extension fields. It provides a general representation but is not universal, so the client still follows the target contract.

{
  "type": "https://api.example.com/problems/invalid-cursor",
  "title": "Invalid cursor",
  "status": 400,
  "detail": "Cursor has expired",
  "request_id": "req_123"
}

JSON does not allow valid end-of-line comments: type is a stable problem-type URI; title and detail are human-facing and unsuitable for program branching; status agrees with the HTTP status; and request_id can safely correlate server diagnosis. A real error object can have machine-readable codes defined by its target API.

  • Programs should branch first on status, type, or a documented machine code.
  • detail is for people and can change, so do not control logic with substring matching.
  • A server must not put stacks, SQL, internal paths, or secrets in detail.
  • Before displaying detail, a client must consider whether it includes sensitive business data.

Parse a response safely

from typing import Any  # Represent fields in a JSON-decoded object that can have multiple value types.
 
import requests  # Use Requests Response typing and its JSONDecodeError explicitly.
 
 
def parse_json_object(response: requests.Response) -> dict[str, Any]:  # Narrow an obtained HTTP response to a trustworthy JSON object.
    media_type = (  # Extract the primary media type from the header.
        response.headers.get("Content-Type", "")  # Use an empty string when missing so it is uniformly rejected below.
        .split(";", 1)[0]  # Remove charset and similar parameters.
        .strip()  # Remove possible boundary whitespace.
        .lower()  # Normalize case for comparison.
    )
    if media_type != "application/json" and not media_type.endswith("+json"):  # Accept standard JSON and vendor `+json` responses.
        raise ApiResponseError(f"expected JSON, received Content-Type={media_type!r}")  # Do not disguise HTML or text as a business object.
 
    try:  # The body can be truncated or invalid JSON even with a correct header.
        value = response.json()  # Call Requests' response-body decoder.
    except requests.exceptions.JSONDecodeError as exc:  # Catch the JSON decoding failure defined by the library.
        raise ApiResponseError("response declared JSON but could not be parsed") from exc  # Retain causal context while exposing a stable category.
 
    if not isinstance(value, dict):  # This client contract does not accept an array, string, or null as the top-level response.
        raise ApiResponseError("expected a JSON object")  # Block type errors and semantic drift before field reads.
    return value  # Return an object that passed the representation-layer check; callers validate fields next.

A real project also needs field-level schema validation, with a dataclass, TypedDict plus manual checks, or Pydantic/JSON Schema. On validation failure, log a safe field path and contract version; do not log multi-megabyte raw responses in full.

What to log

Recommended structured fields:

FieldExampleNote
eventapi_request_finishedStable event name.
servicevector_storeLogical service name; it need not include the full host.
methodPOSTHTTP method.
route/v1/jobs/{id}Route template; avoids high-cardinality values.
status_code503Empty when no response exists.
duration_ms842State whether it is one attempt or total duration.
attempt2Current attempt number.
request_idServer-provided IDCorrelates a support case.
error_kindread_timeoutStable classification.
retry_wait_ms1200When a retry occurs.

Do not log Authorization, Cookie, API keys, complete signed URLs, user private data, unredacted prompts/documents, or oversized bodies. A header allowlist is safer than “log everything then remove sensitive fields,” because future headers can be sensitive too.

Division of labor: logs, metrics, and traces

  • Logs answer “what happened on this call?” and suit request IDs and error context.
  • Metrics answer “is the system abnormal overall?” for example request count, success rate, status counts, latency quantiles, retry rate, and rate-limit rate.
  • Traces connect an Agent, model, tools, and multiple API calls into one end-to-end task.

Do not use user ID, full URL, or request ID as a metric label; high cardinality creates cost. Normalize an endpoint to a route such as /items/{id}.

Investigation order

  1. Reproduction boundary: which environment, endpoint, method, time window, and frequency?
  2. Local evidence: error category, status, duration, attempts, and server request ID—while hiding credentials.
  3. Request contract: base URL, API version, authentication header, content type, parameter types, and required fields.
  4. Response evidence: status, content type, documented machine code, rate-limit headers, and only necessary excerpts.
  5. Dependency state: an official status page or internal monitoring. Do not infer a global outage from one 503.
  6. Minimum reproduction: remove Agent and framework layers and call directly with a fixed non-sensitive payload.
  7. Compare changes: did SDK, configuration, permission, network, or proxy just change?
  8. State a conclusion: separate proven cause, reasonable inference, and still unknown items.

Typical symptoms

  • 200 but .json() fails: you may have an HTML login/proxy page; inspect content type and final URL first.
  • Intermittent 401: possible token-refresh race, clock issue, or wrong environment; do not hide it with simple retry.
  • Higher latency and retry rate: retries may be amplifying server pressure; inspect first-attempt metrics first.
  • 429 only in batch jobs: inspect global concurrency and whether each worker rate-limits independently.
  • Duplicate resources after POST timeout: confirm reuse of the idempotency key and server deduplication window.

Common mistakes

  • except Exception: return None, merging “not found” and “the service is down” into one result.
  • Call only raise_for_status() and retain no safely recordable machine code or request ID.
  • Print every header and body during diagnosis.
  • Decide retry from an error-message string alone.
  • Automatically follow an unknown redirect and treat the final response as success from the original endpoint.
  • Log only “failed” with no route template, status, duration, or attempts.
  • Turn every failure into a user-visible stack trace, leaking local paths and implementation details.

Exercises and self-check

  1. Map 401, 429, invalid JSON, a schema missing a field, and read timeout to exception types.
  2. Design one structured failure log that excludes tokens, query signatures, and request body.
  3. Rewrite an except Exception example into “specific exception → client exception → upper-layer decision.”
  4. Explain why request_id suits a log field but usually not a metric label.
  • I can distinguish configuration, construction, transport, HTTP, representation, schema, and business errors.
  • I can design a small set of stable client exceptions.
  • I know the separate questions answered by logs, metrics, and traces.
  • I can provide reproducible evidence without exposing credentials.

References

Retrieved on 2026-07-22. Next: Project: reliable API client.