Skip to content

Commit 58dd7b3

Browse files
Keep the connection's non-throttling retries on the column fallback path
Derive the single-attempt policy from the connection's RetryConfig by removing only the throttling codes, so other configured codes are still retried before a metadata request fails; the async adapter exposes retry_config for that. Sort information_schema rows by ordinal_position on the client and treat non-string comments as missing, covering UNLOAD-backed and pandas cursors. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 7932642 commit 58dd7b3

5 files changed

Lines changed: 63 additions & 19 deletions

File tree

‎docs/sqlalchemy.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@ A later listing preserves metadata already fetched for a table.
100100
Table-metadata lookups propagate throttling and permission errors rather than reporting missing tables.
101101
`has_table()` propagates permission failures, including access denied by Lake Formation, instead of returning or caching `False`.
102102
For failed metadata requests, only recognized `EntityNotFoundException` responses establish absence; unrecognized errors are propagated rather than guessed to mean a missing table.
103-
Column reflection and `has_table()` make one table-metadata request; when Athena throttles it, they read `information_schema.columns` instead, executed without query result reuse, and log a warning.
103+
Column reflection and `has_table()` do not retry a throttled table-metadata request; when Athena reports throttling, they read `information_schema.columns` instead, executed without query result reuse, and log a warning.
104+
Other error codes listed in `RetryConfig.exceptions` are still retried with the connection's policy on that path; a `retry_config` in `cursor_kwargs` takes precedence.
104105
Columns reflected this way carry the type names Athena reports there, such as `varchar` for `string`, and partition columns are marked from its `extra_info` column.
105106
This fallback does not populate the table-metadata cache, and table comments and table options still come from the metadata API with the configured retries, so they propagate the throttling error.
106107
Athena applies its metadata API rate limits per account, and they are not listed in Service Quotas.

‎pyathena/aio/sqlalchemy/base.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
ProgrammingError,
2323
)
2424
from pyathena.sqlalchemy.base import AthenaDialect
25+
from pyathena.util import RetryConfig
2526

2627
if TYPE_CHECKING:
2728
from types import ModuleType
@@ -142,6 +143,10 @@ def schema_name(self) -> str | None:
142143
def cursor_kwargs(self) -> dict[str, Any]:
143144
return self._connection.cursor_kwargs # type: ignore[no-any-return]
144145

146+
@property
147+
def retry_config(self) -> RetryConfig:
148+
return self._connection.retry_config # type: ignore[no-any-return]
149+
145150
def cursor(self, **kwargs: Any) -> AsyncAdapt_pyathena_cursor:
146151
raw_cursor = self._connection.cursor(**kwargs)
147152
return AsyncAdapt_pyathena_cursor(raw_cursor)

‎pyathena/sqlalchemy/base.py‎

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,6 @@
5757

5858
_logger = logging.getLogger(__name__)
5959

60-
# Column reflection makes one metadata attempt and then answers from
61-
# information_schema, so a throttled request does not wait out the retry policy.
62-
_SINGLE_ATTEMPT = RetryConfig(attempt=1)
63-
6460

6561
ischema_names: dict[str, type[Any]] = {
6662
"boolean": types.BOOLEAN,
@@ -345,8 +341,12 @@ def _get_columns(self, connection, table_name: str, schema: str | None = None, *
345341
# A throttled metadata request switches to information_schema at once
346342
# instead of waiting out the retry policy; the query answers existence
347343
# and columns, while table comments and options still need the API.
344+
# Other retryable codes keep the connection's policy.
345+
retry_config = self._without_throttling_retries(
346+
self._cursor_option(raw_connection, "retry_config")
347+
)
348348
with raw_connection.driver_connection.cursor( # type: ignore[union-attr]
349-
retry_config=_SINGLE_ATTEMPT
349+
retry_config=retry_config
350350
) as cursor:
351351
try:
352352
metadata = self._lookup_table(cursor, schema, name, table_name)
@@ -368,6 +368,16 @@ def _get_columns(self, connection, table_name: str, schema: str | None = None, *
368368
info_cache[metadata_key] = metadata
369369
return self._columns_from_metadata(metadata)
370370

371+
@staticmethod
372+
def _without_throttling_retries(retry_config: RetryConfig) -> RetryConfig:
373+
return RetryConfig(
374+
exceptions=[c for c in retry_config.exceptions if c not in THROTTLING_ERROR_CODES],
375+
attempt=retry_config.attempt,
376+
multiplier=retry_config.multiplier,
377+
max_delay=retry_config.max_delay,
378+
exponential_base=retry_config.exponential_base,
379+
)
380+
371381
def _column(self, name: str | None, type_: str, comment: str | None, partition: bool | None):
372382
return {
373383
"name": name,
@@ -394,16 +404,24 @@ def _columns_from_information_schema(
394404
table_name = table_name.lower().replace("'", "''")
395405
with raw_connection.driver_connection.cursor() as cursor: # type: ignore[union-attr]
396406
cursor.execute(
397-
"SELECT column_name, data_type, comment, extra_info "
407+
"SELECT ordinal_position, column_name, data_type, comment, extra_info "
398408
"FROM information_schema.columns "
399-
f"WHERE table_schema = '{schema}' AND table_name = '{table_name}' "
400-
"ORDER BY ordinal_position",
409+
f"WHERE table_schema = '{schema}' AND table_name = '{table_name}'",
401410
result_reuse_enable=False,
402411
)
403412
rows = cursor.fetchall()
413+
# Sort here: UNLOAD-backed cursors do not preserve ORDER BY. Cursors that
414+
# read NULL as NaN must not turn a missing comment into a value.
404415
return [
405-
self._column(column_name, data_type, comment, extra_info == "partition key" or None)
406-
for column_name, data_type, comment, extra_info in rows
416+
self._column(
417+
column_name,
418+
data_type,
419+
comment if isinstance(comment, str) else None,
420+
extra_info == "partition key" or None,
421+
)
422+
for _, column_name, data_type, comment, extra_info in sorted(
423+
rows, key=lambda row: int(row[0])
424+
)
407425
]
408426

409427
def _get_tables(self, connection, schema: str | None = None, **kw):

‎tests/pyathena/sqlalchemy/test_base.py‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
Tinyint,
2828
get_double_type,
2929
)
30+
from pyathena.util import RetryConfig
3031
from tests.pyathena.conftest import ENV
3132

3233
# Amazon S3 Tables tests need a pre-provisioned table-bucket catalog and namespace.
@@ -51,10 +52,11 @@ def unique_s3tables_table_name(base: str) -> str:
5152

5253
class TestAthenaDialect:
5354
def test_columns_from_information_schema(self):
55+
# Rows arrive unordered, and a cursor may read a NULL comment as NaN.
5456
rows = [
55-
("id", "integer", "identifier", None),
56-
("payload", "row(a integer, b array(varchar))", None, None),
57-
("dt", "varchar", None, "partition key"),
57+
("3", "dt", "varchar", float("nan"), "partition key"),
58+
("1", "id", "integer", "identifier", None),
59+
("2", "payload", "row(a integer, b array(varchar))", None, None),
5860
]
5961
executed = []
6062

@@ -75,16 +77,29 @@ def execute(operation, **kwargs):
7577
assert columns[0]["comment"] == "identifier"
7678
assert isinstance(columns[1]["type"], AthenaStruct)
7779
assert isinstance(columns[2]["type"], types.VARCHAR)
80+
assert columns[2]["comment"] is None
7881
assert [column["dialect_options"]["awsathena_partition"] for column in columns] == [
7982
None,
8083
None,
8184
True,
8285
]
8386
((operation, kwargs),) = executed
8487
assert "WHERE table_schema = 'my_schema' AND table_name = 'o''neil'" in operation
85-
assert "ORDER BY ordinal_position" in operation
8688
assert kwargs == {"result_reuse_enable": False}
8789

90+
def test_without_throttling_retries_keeps_other_codes(self):
91+
policy = RetryConfig(
92+
exceptions=("ThrottlingException", "InternalServerException"),
93+
attempt=10,
94+
multiplier=2,
95+
max_delay=30,
96+
exponential_base=3,
97+
)
98+
derived = AthenaDialect._without_throttling_retries(policy)
99+
assert derived.exceptions == ("InternalServerException",)
100+
assert (derived.attempt, derived.multiplier, derived.max_delay) == (10, 2, 30)
101+
assert derived.exponential_base == 3
102+
88103
def test_get_table_matches_long_names_case_insensitively(self):
89104
# GetTableMetadata rejects names over 128 characters, so the lookup lists
90105
# with a lowercase filter and matches the catalog's own casing.

‎tests/sqlalchemy/test_suite.py‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -356,25 +356,30 @@ def record_query(params, **kwargs):
356356
)
357357
def test_metadata_errors_do_not_establish_absence(self, connection, monkeypatch, code):
358358
raw_connection = _raw_connection(connection)
359-
if code == "InternalServerException":
360-
# A code the retry policy covers still does not reach the existence fallback.
359+
retried = code == "InternalServerException"
360+
if retried:
361+
# A code the retry policy covers is retried with that policy, then
362+
# propagates; it does not reach the information_schema fallback.
361363
monkeypatch.setattr(
362364
raw_connection.retry_config, "exceptions", ("ThrottlingException", code)
363365
)
366+
monkeypatch.setattr(raw_connection.retry_config, "multiplier", 0)
364367
message = (
365368
"Catalog error (Service: AmazonDataCatalog; Status Code: 400; "
366369
f"Error Code: {code}; Request ID: example; Proxy: null)"
367370
if code
368371
else "Table not found"
369372
)
370373
error = _metadata_error("MetadataException", message)
371-
calls = _fail_get_table_metadata(monkeypatch, raw_connection, error)
374+
calls = _fail_get_table_metadata(
375+
monkeypatch, raw_connection, error, attempt=2 if retried else 1
376+
)
372377
inspector = inspect(connection)
373378
for _ in range(2):
374379
with pytest.raises(OperationalError) as caught:
375380
inspector.has_table("unavailable_metadata")
376381
assert caught.value.__cause__ is error
377-
assert len(calls) == 2
382+
assert len(calls) == (4 if retried else 2)
378383

379384
@sa_testing.combinations((True, sa_testing.requires.schemas), False, argnames="use_schema")
380385
def test_has_table_cache_drop(self, connection, metadata, use_schema):

0 commit comments

Comments
 (0)