Skip to content

google-api-core: format_http_response_error crashes on list-shaped error payloads (Vertex AI streaming 429s), masking the real error and breaking retry predicates #18223

Description

@pacveru365

Environment

  • google-api-core 2.34.0 (also reproduced on 2.33.0)
  • Python 3.11
  • Surfaced via Vertex AI streamGenerateContent (through langchain-google-vertexai, but the bug is provider-agnostic within api-core)

The bug

Some REST error responses carry the JSON body as an array wrapping the error object rather than a bare object. Vertex AI's streamGenerateContent does this — a quota 429 arrives as:

[{"error": {"code": 429, "message": "Resource exhausted...", "status": "RESOURCE_EXHAUSTED"}}]

google.api_core.exceptions.format_http_response_error assumes a dict payload:

# google/api_core/exceptions.py (2.34.0)
payload = {} if not payload else payload
error_message = payload.get("error", {}).get("message", "unknown error")

A non-empty list is truthy, so it passes the {} if not payload guard and crashes on payload.get:

AttributeError: 'list' object has no attribute 'get'

Why this matters more than a cosmetic traceback

The AttributeError replaces the real exception. The caller never receives ResourceExhausted / TooManyRequests, so every retry predicate keyed on api-core exception types — including LangChain's Vertex retry list and any tenacity retry_if_exception_type(ResourceExhausted) — never fires. A transient, expected-under-Dynamic-Shared-Quota 429 becomes an immediate unretryable hard failure on streaming call paths.

Reproduction

from unittest import mock
from google.api_core import exceptions

response = mock.Mock(status_code=429, request=mock.Mock(url="https://example"))
payload = [{"error": {"code": 429, "message": "Resource exhausted", "status": "RESOURCE_EXHAUSTED"}}]

exceptions.format_http_response_error(response, "POST", "https://example", payload)
# AttributeError: 'list' object has no attribute 'get'

Suggested fix

Normalize a list payload to its first dict element before the existing dict logic:

if isinstance(payload, list):
    payload = next((p for p in payload if isinstance(p, dict)), {})

Related (optional but valuable): a 429 is currently mapped to the base TooManyRequests by HTTP status alone, even when the body declares "status": "RESOURCE_EXHAUSTED". Mapping that case to ResourceExhausted (a TooManyRequests subclass, so existing predicates keep matching) would let retry predicates that key on ResourceExhausted see quota errors from REST/streaming paths the way they do from gRPC.

We are currently carrying exactly this shim in production and would like to retire it once fixed upstream.

Prior reports

This was previously reported as #15021 (filed against python-api-core, transferred here, then bulk-closed for inactivity as not-planned — the bug was never fixed; the crash lines above are from the current 2.34.0 release) and the list-shaped Vertex body itself was described in #14703. Filing fresh per the bulk-close message's invitation, with a self-contained repro and the retry-predicate consequence spelled out.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions