Answer throttled metadata requests from Glue in the cursor - #803
Conversation
ec384d2 to
ad0eca2
Compare
| f"Glue request to {description} failed: {e}; retrying the Athena request{suffix}." | ||
| ) | ||
|
|
||
| def _with_glue_fallback( |
There was a problem hiding this comment.
Self-review round one (behavior and implementation): FINDINGS, repaired
Base 56c34e520b3317e89a07db825e3f63d1eceaf19b, head ba564e7ee36fd79468b7b572a3084594bb8de804.
Covered:
pyathena/glue.pyBaseCursorandAioBaseCursormetadata methods and the fallback flowConnection.glue_clientandglue_metadata_fallbackutil._retry_api_call/_async_retry_api_call/_without_retries- the dialect's use through the cursor,
_get_columnsincluded - tests and docs
Passes: /code-review (high) twice, one on the dialect-level predecessor and one on the cursor-level head, then /simplify (reuse, simplification, efficiency, altitude).
Repaired:
- Glue throttling wrapped in a
MetadataExceptionwas neither retried nor sent to Glue. The throttle check now unwraps it. - With
MetadataExceptionin the retry policy, the first attempt ran the whole ladder before Glue. A later fix retried twice, whichHasTableTest::test_metadata_errors_do_not_establish_absence[InternalServerException]caught. Both are gone:pyathena/common.py:369keeps the policy and only stops at throttling, throughstop_on. - An unreachable Glue endpoint cost a connect timeout on every throttled request. A
BotoCoreErrornow turns the fallback off for the connection. - The async cursor built the Glue client on the event loop. It is now built in the worker thread.
- A listing whose Glue request failed started again from page 1. It now resumes at the throttled page, which
test_listing_resumes_after_a_failed_glue_requestcovers. - Glue kept Iceberg columns marked
iceberg.field.current=false. They are now left out; this was measured afterDROP COLUMNandCHANGE COLUMN. - Glue
EntityNotFoundExceptionfrom a listing was raised as final, which would have broken the dialect's S3 Tables[]for a missing catalog. It is now final only forget_table_metadata(). - The throttling test helper was copied four times, and two tests branched on their own parameters.
Rejected, with evidence:
- "Athena never reports None for
inputformat": the first probe showed"inputformat": nullfor Iceberg. - "Resource links differ": a same-account link measured equal.
- "Mixed-case names miss in Glue": measured equal.
Deferred:
- A shared boto3
Sessionused by several threads to build clients. S3 clients are already built the same way. - A Glue
AccessDeniedExceptiondoes not turn the fallback off. Lake Formation grants can be per table. - The S3 Tables prefix constant stays duplicated. Importing it from the dialect would invert the dependency.
- The cursor sets the connection's
_glue_unreachableflag directly.
There was a problem hiding this comment.
Rebase onto 82e88f4 (#805)
The branch was rebased from merge-base 56c34e5 onto 82e88f4, which adds MIT headers to existing files (#805). The head moved from 2e4d7eca296da5c1749c4fdfc3175853742a1243 to c277aa0118f7af6e8b24b86476067e2f43f42743. There were no conflicts.
git range-diff 56c34e5..2e4d7ec origin/master..c277aa0 shows:
- 11 of 14 commits are identical (
=). - The other 3 differ only in diff-context lines: the blank line that Add MIT headers to existing files written solely by the maintainer #805 adds after the new header in
pyathena/aio/sqlalchemy/base.pyandtests/pyathena/util.py.
The patch content is unchanged. #805 changes no behavior or contract this PR relies on.
Correction to earlier reports: Benchmark tooling / offline failed on every head from ad0eca2 to 2e4d7ec. I had checked only the Test workflow.
- Cause:
benchmarks/tests/test_packaging.pyrejectedNOTICE, which Add NOTICE acknowledging PyHive's influence #802 added to the sdist. - Add MIT headers to existing files written solely by the maintainer #805 allows it (
03798bd). - Reproduced locally on
c277aa0:uv build, thenBENCHMARK_DIST_DIR=... uv run --directory benchmarks --locked pytest tests/test_packaging.pypassed (1 passed), and the sdist includespyathena/glue.py.
just lint passes on c277aa0.
|
|
||
| (usage-table-metadata)= | ||
|
|
||
| ## Table and database metadata |
There was a problem hiding this comment.
Self-review round two (claims, callers, operations): CLEAN after one correction
Base 56c34e520b3317e89a07db825e3f63d1eceaf19b, head ba564e7ee36fd79468b7b572a3084594bb8de804.
Claims checked against code or measurement:
docs/usage.md:666section:- the per-method IAM actions:
GlueMetadataCatalogcallsget_table,get_tablesandget_databasesonly - "first throttled response":
stop_on - wrapped throttling
- EntityNotFound final only for
get_table_metadata() - unreachable Glue turns the fallback off
- no workgroup on the Glue request
- one client, without
endpoint_url:test_glue_client_leaves_out_athena_endpoint
- the per-method IAM actions:
- PR body measurements: the probes from 2026-09-23/24 are listed in WHY.
Correction: the docs said an unreachable Glue "turns the fallback off" without saying that the first such request waits out botocore's connect timeout and retries. ba564e7 states it.
Existing callers:
- The public signatures of
get_table_metadata(),list_table_metadata(),list_databases()andretry_api_call()are unchanged. Addingstop_onto the publicretry_api_callbroke mypy for 10**requestcallers, so it went to a private_retry_api_call. - The private
_get_table_metadata,_list_table_metadataand_list_databasesgained optional keywords only. - New public surface:
Connection(glue_metadata_fallback=True)andConnection.glue_client- the SQLAlchemy URL parameter
pyathena.glue
AWS operator:
- A request that is not throttled is unchanged: one Athena call with the same policy.
- A throttled request adds one Glue call. If Glue fails, it adds one failed Glue call and a warning, then the Athena ladder runs as before.
- The fallback is default on, and the release note and docs name the IAM actions.
- IAM Identity Center workgroups, Lake Formation parity and cross-account links are not measured. The docs advise turning the fallback off where access depends on the workgroup.
Evidence:
- Local runs (56 targeted, plus HasTableTest sync and async) ran after CI was idle.
- A run concurrent with another PR had no metadata-throttling failures. Its one query-side internal error is tracked in Transient query execution failures when CI runs overlap #804.
- Green CI logs do not show whether Glue actually answered; passing jobs print no fallback warnings.
|
|
||
| assert calls == ["get_table_metadata"] | ||
|
|
||
| def test_glue_client_leaves_out_athena_endpoint(self): |
There was a problem hiding this comment.
Independent review (relayed): FINDINGS (tests only)
Reviewer: Codex CLI 0.156.0, model gpt-6-sol, codex exec --sandbox read-only, session 01a0d083-d52a-74f2-9a37-efdddf1eb01f.
- This was a static review of a detached snapshot, with no tests, GitHub or network. The reviewer did not see the PR text or the author's conclusions.
- Base
56c34e520b3317e89a07db825e3f63d1eceaf19b, headba564e7ee36fd79468b7b572a3084594bb8de804. The snapshot was unchanged afterwards.
Covered:
- the full diff
- sync and async fallback and retries
- Glue mapping and pagination
- connection client creation
- SQLAlchemy reflection paths
- the new tests
- both docs
Quoted: "I found no source-level fallback, pagination-resume, exception-shape, or documentation mismatch in the reviewed paths."
Findings (both Low, quoted):
- "
tests/pyathena/aio/test_cursor.py:493: The async Glue test asserts returned metadata but never checks the executing thread. Moving Glue client creation and requests onto the event-loop thread would leave this test passing while making metadata calls block the loop." - "
tests/pyathena/test_cursor.py:1565: The client test constructsglue_clientsequentially and supplies noapi_version. It cannot catch concurrent creation of multiple clients or accidental forwarding of Athena'sapi_version."
Author verification: both confirmed. Finding 1 is anchored here as well because the aio test line was not a changed line at that position.
There was a problem hiding this comment.
Repair and independent follow-up: CLEAN at 2e4d7eca296da5c1749c4fdfc3175853742a1243
Repairs (tests only; the source is unchanged since ba564e7):
-
Async thread check.
tests/pyathena/aio/test_cursor.py::test_glue_request_runs_off_the_event_looprecords which thread builds the Glue client and which sends the request. Both must differ from the event-loop thread. -
Client test.
tests/pyathena/test_cursor.py::test_glue_client_leaves_out_athena_endpointnow:- passes Athena's
api_version - has eight threads access
glue_clientbehind a barrier - asserts that the client is created once and while
_glue_client_lockis held
The first two attempts at this test relied on timing, which the follow-ups showed cannot prove a lock is used. The final version asserts that the lock is held instead.
- passes Athena's
Self-review of the repairs:
- Behavior: no source change.
- Vacuity:
- Moving the Glue call onto the loop fails the async test (run against a live cursor).
- Dropping
api_versionfrom the filter fails the client test. - Replacing the lock with
contextlib.nullcontext()fails it too, 3 runs of 3. - With the lock, it passed 3 of 3 runs, offline.
just lintpasses.
Independent follow-ups (Codex CLI 0.156.0, gpt-6-sol, codex exec --sandbox read-only, a detached snapshot per head, range diff from ba564e7):
| Session | Head | Result |
|---|---|---|
01a0d0ad-2346-73a2-aa18-f3cd50e5fda0 |
b53a64e |
async check covered; FINDINGS: the concurrency test did not ensure simultaneous first access |
01a0d0ae-b92f-7a53-a3b9-4aa026b5ef96 |
7abaf47 |
FINDINGS: a fixed sleep does not guarantee overlap |
01a0d0b0-64a7-7893-978b-2d05cbcb0278 |
a7d3bde |
FINDINGS: the inner barrier timeout still depends on scheduling |
01a0d0b1-f7b0-78e2-b204-459ab834bf67 |
2e4d7ec |
CLEAN |
All of these are static reviews.
2e4d7ec to
c277aa0
Compare
| from botocore.config import Config | ||
|
|
||
|
|
||
| class GlueMetadataClient: |
There was a problem hiding this comment.
Self-review round one, redesign (behavior and implementation): FINDINGS, repaired
Base 82e88f4ebbd89a4dad8b3b57ac412012601f0c45, head a283df41193f379f66aeb556f1efa89e450e036c.
Scope: the maintainer asked for this redesign after Ready. GlueMetadataClient now owns the Glue client, its lock and its reachability, and Connection.glue_client is no longer public. The maintainer also asked for Glue tests without mocks. The range c277aa0..a283df4 was reviewed in full.
Passes:
/code-review(high) on a frozen snapshot at02b4535/simplifyon the four angles: reuse, simplification, efficiency, altitude
Repaired:
HTTPClientError(read timeout, dropped connection) meant Glue had been reached, yet it turned the fallback off. Only botocoreConnectionErrorandBaseEndpointResolverErrordo now.NoCredentialsErrorandNoRegionErrorwere listed as unreachable, but they fail on the Athena request first. They were removed, along with the docs sentence.- The failure log read the shared flag, so another thread's failure could mislabel it. It now classifies the exception itself.
_requestdispatched on an operation string. Each method now calls the client directly inside_tracking_reachability().- Listings held every raw page. They now convert page by page.
- The public methods lacked Google-style docstrings.
ConnectionErrorshadowed the builtin; it is now imported asBotoConnectionError.- The unreachable-Glue test relied on DNS NXDOMAIN and legacy retries. It now uses a closed proxy port with standard retries and one attempt, and the builder is shared in
tests/pyathena/util.py. util.pygained tests for_is_throttling_error,_without_retriesandstop_on(maintainer request).
Deferred:
- The cursor classifies with
GlueMetadataClient.UNREACHABLE_ERRORSrather than reading the flag. This is the fix for the cross-thread log finding above. - The sync and async
_with_glue_fallbackstill duplicate each other, following the repository's existing sync/async pattern. - The method docstrings stay in full Google style, as the repository convention asks.
- The shared-
Sessionrace with S3 result sets predates this PR.
|
|
||
| Glue's report that the table does not exist, for `get_table_metadata()`, raises `OperationalError`, as Athena's does. | ||
| If the Glue request fails for any other reason, for example for lack of permission or because the Glue endpoint cannot be reached, a second warning is logged and the Athena request runs again with the retry policy. | ||
| A request that cannot connect to Glue, such as from a network with an Athena VPC endpoint but no route to Glue, or that finds no Glue endpoint for the connection's region and endpoint options, also turns the fallback off for the rest of that connection; a request that cannot connect first waits out the botocore connect timeout and retries of the connection's `config`. |
There was a problem hiding this comment.
Self-review round two, redesign (claims, callers, operations): CLEAN
Base 82e88f4ebbd89a4dad8b3b57ac412012601f0c45, head a283df41193f379f66aeb556f1efa89e450e036c.
Claims checked against the code:
- The
docs/usage.mdsection: the method table and IAM actions (GlueMetadataClientcalls onlyget_table,get_tablesandget_databases), the first throttled response, the wrapped throttling, the final absence only forget_table_metadata(), the fallback off after a connection or endpoint-resolution failure and the connect-timeout wait before it, the client built once withoutendpoint_url, and the workgroup note. - The PR body was rewritten for the redesign.
Callers:
Connection.glue_client, public in earlier heads of this PR, is gone before any release.- New public surface:
glue_metadata_fallbackandpyathena.glue.GlueMetadataClient. - The public signatures of
retry_api_call,get_table_metadata,list_table_metadataandlist_databasesare unchanged.
Operations:
- A request that is not throttled makes one Athena call, as before.
- A throttled request adds one Glue call.
- A request that cannot connect to Glue waits out the connect timeout once per connection, then skips Glue.
- A transient read timeout keeps Glue in use.
Evidence:
- Live runs were made after CI was idle, and only Athena throttling is injected.
- The vacuity checks are in the PR body.
- Green CI logs do not show whether Glue answered, since passing jobs print no fallback warnings.
| calls = throttle_metadata_api(cursor.connection.client, monkeypatch) | ||
|
|
||
| # Glue reports each table as Athena does, and nothing is retried first. | ||
| assert read() == expected |
There was a problem hiding this comment.
Independent review of the redesign (relayed): FINDINGS (test environment only)
Reviewer and scope:
- Codex CLI 0.156.0, model
gpt-6-sol,codex exec --sandbox read-only, session01a0d3b5-6df4-79c3-9620-80a668ce73e1. - A static review of a detached snapshot, with no tests, GitHub or network access. The reviewer did not see the PR text or the author's conclusions.
- Base
82e88f4ebbd89a4dad8b3b57ac412012601f0c45, heada283df41193f379f66aeb556f1efa89e450e036c. The snapshot was unchanged afterwards.
Covered: the diff, the sync and async metadata cursors, Glue client construction and error handling, the retry and pagination paths, the SQLAlchemy reflection callers, the new tests, and both docs changes.
Quoted: "I found no source-level defect in the traced fallback, page-resume, or exception paths."
Finding (P2), quoted:
The throttling test injects only Athena failures, then requires a real Glue response. An existing test environment with Athena metadata access but without
glue:GetTable,glue:GetTables, orglue:GetDatabaseswill fail this assertion even though the cursor correctly returns to Athena after Glue fails. [...] The tests need a Glue-authorized environment or an explicit skip condition.
Author verification: the dependency is confirmed, and it is intended. The maintainer asked for Glue tests against the real API.
- The CI role in
cloudformation/github_actions_oidc.yamlalready grants these actions on*, which covers the S3 Tables catalog. docs/testing.mdalready required Glue permissions for local test identities, but without naming these actions.
Resolution: 2eb34cb names the three actions in docs/testing.md. There is no skip condition, because a silent skip would hide the real-Glue coverage.
There was a problem hiding this comment.
Repair and independent follow-up: CLEAN at 2eb34cb7ad05675463b6f165bd619337a997c3e5
Repair (docs only): docs/testing.md now says that the Glue metadata fallback tests call glue:GetTable, glue:GetTables and glue:GetDatabases directly, including against the S3 Tables catalog, and that the CI template grants them. just docs lint reports 0 errors.
Self-review of the repair:
- There is no code change.
- The claim was checked against
cloudformation/github_actions_oidc.yaml:70-98, which grants the three actions on*, and against the test files that call Glue.
Independent follow-up: Codex CLI 0.156.0, gpt-6-sol, codex exec --sandbox read-only, session 01a0d3b8-fd2c-7051-9343-f8eed3350de2, on a detached snapshot at 2eb34cb, with the range diff from a283df4. Result: CLEAN (static review; tests not run).
There was a problem hiding this comment.
CI failure on 2eb34cb, rebase, and test repair
CI failure: run 36009777585 failed test / run (3.11) in tests/pyathena/test_glue.py::TestGlueMetadataClient::test_reads_s3_tables_catalog (1 failed, 1751 passed). Glue GetTables raised EntityNotFoundException: The specified table does not exist (Service: S3Tables).
Cause (test design):
- The test compared whole listings of the shared S3 Tables namespace.
- The five Python-version jobs create and drop their own tables there concurrently, and a table dropped during the listing made the whole S3 Tables
GetTablescall fail. - Comparing separately timed whole-namespace listings is racy under that concurrency.
- The library already handles this case: a listing's Glue
EntityNotFoundExceptionis not final and returns to the Athena request.
Rebase onto a2412cc (#811: rerun Athena service-side test failures). There were no conflicts. git range-diff 82e88f4..c1c0860 origin/master..2ca3829 shows all 21 commits identical. #811 edits another paragraph of docs/testing.md and changes no contract here.
Repair (c1c0860 → rebased 2ca3829, then e0e7799; test only):
- The test creates a uniquely named table in the namespace.
- It compares only that table, with
get_tableand withlist_tablesfiltered by the table name asExpressionagainst Athena'slist_table_metadata(expression=...). - It asserts the Glue listing contains the table.
CREATEruns insidetry, sofinallydrops the table even ifCREATEfails after Athena made it.
Validation: uv run --env-file .env pytest -n 1 tests/pyathena/test_glue.py passed 16 of 16 twice on c1c0860, run after CI was idle. e0e7799 is covered by its CI run.
Independent follow-ups (Codex CLI 0.156.0, gpt-6-sol, codex exec --sandbox read-only, detached snapshots):
| Session | Head | Result |
|---|---|---|
01a0d41c-fbd5-7fd2-8bd8-c4f73b2ca335 |
2ca3829 |
FINDINGS: CREATE outside try; the filtered-list equality passes when both listings are empty |
01a0d41e-897f-7e82-8d12-5d60028159bd |
e0e7799 |
CLEAN |
There was a problem hiding this comment.
Repairs after be54210: retry_api_call(stop_on) and docstrings (maintainer requests), CLEAN at 9be45cb7599227ba1df3c4c42e3f80f7f56b6fb7
Rebase onto c43a01a (#813, benchmark uv workspace). #813 touches no file in this PR. git range-diff showed 23 of 23 commits identical.
7bbf128: extend the public helpers
retry_api_callandasync_retry_api_calltake a keyword-onlystop_onpredicate. The private_retry_api_calland_async_retry_api_callare gone.- Ten existing
**requestcall sites annotate their dict asdict[str, Any], because mypy matched**dict[str, str]againststop_on. The values are unchanged. - Effect on outside callers:
- Those passing
**dict[str, str]get the same mypy error. - A wrapped function's own
stop_onkeyword is now consumed byretry_api_call. AWS parameters are CamelCase, so nothing in PyAthena is affected. - Both effects are stated in the PR body and the release note.
- Those passing
36ccb80 and 9be45cb: Google-style docstrings
- Every function this PR touches, private ones included, has Args, Returns, Raises and Yields sections.
- The API names in the docstrings were checked mechanically against the client calls: 15 of 15 match.
sphinx-buildshows no warnings for the changed files.9be45cbscopes thes3_additional_kwargsdescription toopen()andpipe_file().
Validation
- The 62 targeted live tests passed on
0416eb4(rebased to7bbf128), run after CI was idle. tests/pyathena/test_util.pypassed 43 of 43.- Vacuity: ignoring
stop_onfailstest_retry_api_call_stops_on_predicate. - The docstring commits change no code. The offline
test_util.pyandtest_glue.pycases pass on them.
Self-review of the repairs:
- Behavior: the only change is the signature. Callers go through
stop_onas before. - Claims: I had told the maintainer that only mypy is affected. The reviewer found the runtime shadowing case, and the PR text is corrected.
Independent follow-ups (Codex CLI 0.156.0, gpt-6-sol, codex exec --sandbox read-only, detached snapshots):
| Session | Head | Result |
|---|---|---|
01a0d5e6-7cc5-7873-a47e-0662c3e9f030 |
36ccb80 |
FINDINGS: stop_on shadows a wrapped function's own keyword (accepted and documented); the s3_additional_kwargs docstring was too broad (repaired) |
01a0d5e9-50e4-7aa0-9fcb-98044b4bc3bb |
9be45cb |
CLEAN |
2eb34cb to
2ca3829
Compare
Athena rate-limits its metadata API per account, separately from Glue. Table comments, table options and table, view and schema listings had no answer while that limit held, so whole-table and whole-schema reflection failed during a throttling episode. In AwsDataCatalog and S3 Tables catalogs, a throttled GetTableMetadata, ListTableMetadata or ListDatabases request now goes to Glue at once (GetTable, GetTables, GetDatabases). The Glue table is flattened into the metadata Athena reports; the rule was measured against GetTableMetadata for Hive, partitioned, Iceberg, view, Glue-created and S3 Tables tables. A failed Glue request, such as one without Glue permission, falls back to the Athena request with the connection's retries, so a caller without Glue access sees the previous behavior. The Glue client uses the connection's session, region and config but not Athena's endpoint or API version. The async dialect runs it in a worker thread, as the adapted cursor runs its metadata calls. Refs #786 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Athena throttles its metadata API per account, so any cursor can be throttled, not only SQLAlchemy reflection. BaseCursor and AioCursor now answer a throttled get_table_metadata, list_table_metadata or list_databases from Glue in AwsDataCatalog and S3 Tables catalogs, and the dialect reaches it through the cursor instead of calling Glue itself. Column reflection and has_table() gain the same fallback; the information_schema path remains for other catalogs and when Glue fails. The fallback is on by default and can be turned off with the new glue_metadata_fallback connection option (also a SQLAlchemy URL query parameter). Glue's EntityNotFoundException surfaces as OperationalError so callers see Athena's answer shape; other Glue failures return to the Athena request with the retry policy. Throttling that Athena wraps in a MetadataException counts as throttling. The connection builds one Glue client on first use under a lock, from its session, region and config but not Athena's endpoint_url or api_version. The async cursor takes the client on the event loop and sends the Glue request in a worker thread. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pyathena/glue.py holds what is specific to Glue: which Athena catalogs it can answer for and how to address them, the GetTable, GetTables and GetDatabases requests with their pagination, and the flattening into the metadata Athena reports. The cursors keep the decision of when to use it and the retry policy around it, and the async cursor sends the wrapper's request in a worker thread. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Glue request that cannot reach Glue (BotoCoreError, e.g. no route from a network with only an Athena VPC endpoint) turns the fallback off for the rest of the connection, so each later throttled request does not wait on the Glue endpoint again. The first Athena attempt leaves out MetadataException too, so Glue throttling that Athena wraps in one reaches Glue at once even when the retry policy lists MetadataException. A different failure that the policy retries is then retried with the policy instead of being raised. Only a table lookup takes Glue's EntityNotFoundException as the answer; a listing returns to the Athena request, whose error for a missing catalog or database callers already handle. The async cursor builds the Glue client in the worker thread with the request, off the event loop. The dialect's retry-policy copy uses the shared helper. The docs note that the Glue request does not carry the workgroup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Glue keeps an Iceberg table's dropped and renamed columns, marked iceberg.field.current=false, which Athena's GetTableMetadata leaves out. Measured after DROP COLUMN and CHANGE COLUMN; the Glue path now matches. A first attempt that failed on a code its policy already retried is not retried again with the full policy; only a MetadataException the first attempt left out is. HasTableTest caught the doubled retries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
retry_api_call gains a private variant that raises an exception a predicate accepts at once. The Glue fallback's first attempt keeps the cursor's retry policy and stops only at throttling, including Glue's own inside a MetadataException, so other errors get their retries once and the rerun and double retry-policy check go away. Listings keep the pages already read, so the Athena request that follows a failed Glue request resumes at the throttled page instead of starting over. The schema default is resolved once per request, and the throttling test helper is shared by the cursor and dialect tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The async test checks that the Glue client is built and the request sent off the event loop; the client test passes Athena's api_version and builds the client from several threads at once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Timing cannot prove a lock is used, so the test asserts it is held while the client is created, and that it is created once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The connection now holds one GlueMetadataClient instead of a public glue_client property and an unreachable flag the cursor set. It builds the Glue client on first use under its lock, without Athena's endpoint and API version, and stops being used after a request that cannot reach Glue at all: a connection error, a timeout, or no credentials or region. A request that fails for another reason, such as invalid parameters, no longer turns the fallback off. The throttling predicate moves to pyathena/util.py. The Glue tests call the real Glue API: a missing table and database, and a region that does not exist for an unreachable endpoint; only Athena's throttling is injected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A read timeout or dropped connection means Glue was reached, so it no longer turns the fallback off for the connection; a connection error or no Glue endpoint for the region and endpoint options does. No credentials or region fail on the Athena request first, so they are not listed. The failure log classifies the exception itself rather than the shared flag, the requests no longer dispatch on an operation string, listings convert page by page, and the public methods document their arguments. Tests reach an unreachable Glue through a proxy port nothing listens on instead of relying on DNS for a region that does not exist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One helper resolves the Glue request arguments for a catalog, the listings build their results inside the reachability guard, and the duplicated comments go. The tests build the unreachable Glue client in tests/pyathena/util.py, compare every metadata field for S3 Tables, and close the connections they open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Other CI jobs create and drop tables in the shared S3 Tables namespace, and a table dropped during GetTables made the whole listing fail with EntityNotFoundException, so the test no longer compares the namespace. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…listed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
retry_api_call and async_retry_api_call take a keyword-only stop_on predicate; the private _retry_api_call and _async_retry_api_call go. Existing callers that pass **request built from a single-key literal now annotate it as dict[str, Any], since mypy would otherwise match its values against stop_on; the values are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e0e7799 to
7bbf128
Compare
The new and changed functions, private ones included, document their arguments, return values and raised exceptions: the Glue fallback flow, the cursor metadata methods and their per-page requests, the Glue client helpers, the retry helpers, and the existing request methods whose request dicts gained type annotations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WHAT
The cursor answers a throttled Athena metadata request from the AWS Glue Data Catalog in Glue-backed catalogs.
get_table_metadata()GetTableMetadataGetTableglue:GetTablelist_table_metadata()ListTableMetadataGetTablesglue:GetTableslist_databases()ListDatabasesGetDatabasesglue:GetDatabasesCatalogs:
AwsDataCatalog(noCatalogId) and S3 Tables catalogs (s3tablescatalog/<table-bucket>, passed asCatalogId). Other catalogs are unchanged.Flow (
BaseCursor._with_glue_fallback(), with an async counterpart inAioBaseCursor):retry_api_call(..., stop_on=...)(a new keyword-only argument, also onaio.util.async_retry_api_call) stops it at the first throttled response, usingutil._is_throttling_error. That includes Glue throttling that Athena wraps in aMetadataException. Other errors keep their retries.get_table_metadata(), Glue'sEntityNotFoundExceptionis raised asOperationalError, which is the shape Athena's would have. For listings, the request returns to Athena instead.pyathena/glue.pyGlueMetadataClient: the connection holds one as a private_glue. It:config, without Athena'sendpoint_urlorapi_versionGetTable/GetTables/GetDatabases, converting page by pageConnectionError,BaseEndpointResolverError). A read timeout or a dropped connection does not stop it.AthenaTableMetadatathat Athena reports:location,inputformatandoutputformatare always setserde.serialization.libis set wheneverSerdeInfois presentserde.param.prefixDescriptionis not used as the commenticeberg.field.current=falseare left outretry_api_call/async_retry_api_call: gain a keyword-onlystop_onpredicate. An exception it accepts is raised at once instead of retried. Existing callers that pass**requestbuilt from a single-key literal now annotate it asdict[str, Any], incommon.py,aio/common.py,spark/common.pyandfilesystem/s3.py. Without that, mypy would match itsstrvalues againststop_on. The values are unchanged. Two effects on outside callers: code that passes**dict[str, str]gets the same mypy error, and a wrapped function that takes its ownstop_onkeyword no longer receives it, becauseretry_api_callnow consumes that name. AWS request parameters are CamelCase, so no call in PyAthena is affected.Option:
glue_metadata_fallbackdefaults toTrue. It is a new connection option and a SQLAlchemy URL query parameter. The async cursor sends the Glue request, client construction included, in a worker thread.SQLAlchemy: the dialect has no Glue code; it gets the fallback through the cursor. In Glue-backed catalogs, Glue answers throttled requests for table comments, options, columns,
has_table(), and table, view and schema names. Theinformation_schemafallback remains for other catalogs, for a failed Glue request, and when the option is off.Docs:
docs/usage.mdgains a "Table and database metadata" section, anddocs/sqlalchemy.mdlinks to it.WHY
Closes #786. B-1 of that issue shipped in #801; this PR covers the rest: throttling tolerance for table comments and options, and for table, view and schema listings (B-2). The fallback lives in the cursor because the metadata limit is per account: any cursor can be throttled, not only SQLAlchemy reflection, and SQLAlchemy inherits the fallback.
The approach was chosen from live measurements (2026-09-23/24, us-west-2).
SHOW CREATE TABLEcannot reproduceget_table_options().serialization.format.ROW FORMAT DELIMITEDhides the SerDe class.LOCATION.Glue
GetTable, flattened as above, equalsGetTableMetadatafield for field. The fields compared were name, type, columns, partition keys, times, comment and all options. The 12 table kinds were:DescriptionGetTablesalso matchedListTableMetadatafor the same tables.Listings match.
GetDatabasesandListDatabasesreturn the same 1,857 databases.GetTablesandListTableMetadatareturn the same names and types in the sampled Glue schemas and in S3 Tables.CatalogId="s3tablescatalog/<bucket>"without the account ID; the bucket name alone fails.After the review, also measured:
DROP COLUMNandCHANGE COLUMN: Glue keeps the old columns marked not current, and the fix leaves them out.Expression: five patterns gave identical results.Glue is rate-limited separately. With SDK retries off:
GetTableMetadataat 30 threads ran at about 155 calls/s for about 13 s, then returned 100%ThrottlingExceptionfor 45 s.glue:GetTableat 5 threads succeeded 1,236 of 1,236 times.Release note
TEST
Tested commit:
0416eb4(rebased to7bbf128) for the latest live run of the 62 targeted tests. The later commits add docstrings only;test_util.pyand the offlinetest_glue.pycases pass on them. Earlier runs are listed below.a283df4addstests/pyathena/test_util.pycases only, which were run offline.Lint and docs:
just lintpassed andjust docs lintreported 0 errors ona283df4. Asphinx-buildof this tree (atba564e7) gave no warnings for the changed files.Live runs, started only after CI was idle:
uv run --env-file .env pytest -n 1 tests/pyathena/test_glue.py tests/pyathena/test_cursor.py tests/pyathena/aio/test_cursor.py tests/pyathena/sqlalchemy/test_base.py tests/pyathena/aio/sqlalchemy/test_base.py -k "Glue or glue or throttled or table_level_reflection or unrecognized_metadata or table_metadata or list_databases or conn_str or metadata_exception or unreachable or missing or resumes": 62 passed.pytest -n 1 tests/sqlalchemy/test_suite.py -k HasTableTest: 15 passed and 2 skipped, both sync and with--dburi async, on99435e8.Offline run:
tests/pyathena/test_util.py: 43 passed.How the tests reach AWS. Only Athena's throttling is injected, through
tests/pyathena/util.throttle_metadata_api. Every Glue call is real:tests/pyathena/test_glue.py::TestGlueMetadataClient:GetTable,GetTablesandGetDatabasescompared field for field with Athena for Hive, partitioned and view tables and for S3 TablesEntityNotFoundException)ParamValidationError), which keeps Glue in useendpoint_urlorapi_versionMetadataExceptionin the retry policytests/pyathena/test_util.py:_is_throttling_error,_without_retries, andretry_api_call'sstop_on.Vacuity:
BotoCoreErrorunreachable fails theParamValidationErrortest.pyathena/common.pyandpyathena/aio/common.pyfromorigin/masterfails 9 of 12 cursor tests. The 3 that pass are guards: fallback disabled, a catalog outside Glue, and aMetadataExceptionthe policy retries.test_util.py, dropping the MetadataException unwrap, ignoringstop_on, or keeping the removed codes each fails its test.CI: earlier heads are covered in the review records. The current head's CI is pending.
Not verified:
Sessionshared across threads while other clients are built from it (for example S3 result sets).🤖 Generated with Claude Code