Skip to content

Add async aget_conn and acreate_embeddings to OpenAIHook - #74149

Merged
dabla merged 18 commits into
apache:mainfrom
dabla:feature/openai-hook-async-conn
Oct 16, 2026
Merged

dabla merged 18 commits into
apache:mainfrom
dabla:feature/openai-hook-async-conn

Conversation

@dabla

@dabla dabla commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

OpenAIHook only has a synchronous path: get_conn() resolves the connection with get_connection() and reads extra_dejson, whose secret masking sends MaskSecret to the supervisor synchronously. Inside an async task with another async SDK call in flight (e.g. items running concurrently), both raise DeadlockImminentError, so the hook can't be used from async code.

This adds an async path that mirrors the sync one:

  • aget_conn() returns an AsyncOpenAI client configured exactly like get_conn()'s client (openai_client_kwargs, base_url, api_key, auth_type api_key / workload_identity). The connection comes from get_async_connection(hook=self), so a subclass override of aget_connection() applies as get_connection() does for get_conn(), and its extra from get_async_extra_dejson() (common.compat, Add get_async_extra_dejson to common.compat connection helpers #74147): Connection.aextra_dejson() on Airflow 3.3.2+ (Fix DeadlockImminentError when a connection is resolved inside an async task #71890), extra_dejson from a worker thread on older versions. Resolving the connection without any synchronous Task SDK call is guaranteed from Airflow 3.3.2: before that, core's aget_connection() still masks the connection's secrets synchronously, which a separate common.compat change would have to cover.
  • The client settings move into one _client_kwargs() helper shared by get_conn() and aget_conn(), so the two paths can't drift.
  • acreate_embeddings() is the async counterpart of create_embeddings(), same overloads and return shape (one vector for a single input, one per item for a batch), on a client built by aget_conn() for the call and closed when it returns. Each aget_conn() call builds a new client, which the caller closes with async with or await client.close(), as AnthropicHook.get_async_conn() does.

Further async counterparts (chat completions, responses, ...) can follow the same pattern.

Tests: the existing get_conn tests pass unchanged; new tests cover aget_conn (API key in password/extra, client kwargs, workload identity, invalid auth_type, a subclass overriding aget_connection(), and a connection without aextra_dejson() as on Airflow < 3.3.2, whose extra_dejson may only run off the event loop) with a connection whose extra_dejson raises on the loop and whose aextra_dejson() must have been awaited, a sync get_connection that raises, and acreate_embeddings for text, token and batch input, plus three concurrent calls on one hook each closing their own client.


Was generative AI tooling used to co-author this PR?
  • Yes — Claude Opus 5.5

Generated-by: Claude Opus 5.5 following the guidelines

Comment thread providers/openai/src/airflow/providers/openai/hooks/openai.py Outdated
Comment thread providers/openai/src/airflow/providers/openai/hooks/openai.py Outdated
Comment thread providers/openai/src/airflow/providers/openai/hooks/openai.py
Comment thread providers/openai/tests/unit/openai/hooks/test_openai.py Outdated
Comment thread providers/openai/tests/unit/openai/hooks/test_openai.py Outdated
dabla and others added 7 commits October 12, 2026 11:46
get_conn() resolves the connection with get_connection() and reads
extra_dejson, whose secret masking sends to the supervisor
synchronously; inside an async task with another async SDK call in
flight both raise DeadlockImminentError. aget_conn() returns an
AsyncOpenAI client configured exactly like get_conn()'s, from
get_async_connection() with the extra from Connection.aextra_dejson()
(apache#71890, Airflow 3.3.2+), or the raw extra via json.loads on older
versions (as in apache#72130). The client settings move into
_client_kwargs(), shared by both paths. acreate_embeddings() is the
async counterpart of create_embeddings().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace the local aextra_dejson fallback with the common.compat helper
added in apache#74147, which masks the extra's secrets asynchronously on
Airflow 3.3.2+ and reads extra_dejson from a worker thread on older
versions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MagicMock() without a spec accepts any attribute, so a typo on the async
path such as client.embedding would still pass. spec=AsyncOpenAI guards
the client's namespace the way the sync tests' spec=OpenAI does.
The connection mock carried an extra string and no test checked that
aextra_dejson() was awaited, so a hook reading json.loads(conn.extra),
which skips the secret masking, kept every test green. Drop extra from
the mock's spec and assert the await in the tests that have it.
acreate_embeddings copied the three lines that pick one vector for a
single input and sort one vector per item for a batch. Move them into
_embeddings_result, used by create_embeddings and acreate_embeddings,
so the two paths cannot drift, like _client_kwargs for the client.
get_conn resolves through self.get_connection, so a subclass override
applies there, but aget_conn called get_async_connection without hook=
and the lookup went through BaseHook. Pass hook=self, as
AnthropicHook.get_async_conn does, and test it with a subclass that
overrides aget_connection, through the real common.compat helper.
_aget_cached_conn checked the cache before two awaits, so concurrent
calls on a fresh hook each built a client, dropped all but one
unclosed, and the kept one was never closed and stayed bound to the
first event loop. aget_conn now builds a client per call and says to
close it, as AnthropicHook.get_async_conn does; acreate_embeddings
closes its client with async with. Tested with three concurrent calls
on one hook.
@dabla
dabla force-pushed the feature/openai-hook-async-conn branch from 47f89bb to c0c1940 Compare October 12, 2026 09:48
Comment thread providers/openai/src/airflow/providers/openai/hooks/openai.py Outdated
Comment thread providers/openai/tests/unit/openai/hooks/test_openai.py Outdated
Comment thread providers/openai/src/airflow/providers/openai/hooks/openai.py Outdated
….3.2

Before 3.3.2, core's aget_connection still masks the connection's secrets
synchronously, so the docstring promised more than older cores give.
dabla added 5 commits October 14, 2026 23:22
Patching aget_conn let the test supply distinct clients itself, so it kept
passing if aget_conn cached one client on the hook again.
The SDK caches the exchanged token on each client, so callers making many
calls should know they can keep one client open and reuse it.
The hook imports get_async_extra_dejson at module level, so with an older
common.compat the whole hook, sync get_conn included, fails to import.
Only release managers raise provider dependency floors; the marker lets
them bump it to the release that ships get_async_extra_dejson.
mypy checks the annotated _async_client helper and rejects the dict
passed to CreateEmbeddingResponse.usage, which is typed Usage.
@dabla
dabla requested review from YAshhh29 and kaxil October 15, 2026 07:41
Comment thread providers/openai/src/airflow/providers/openai/hooks/openai.py Outdated
Comment thread providers/openai/src/airflow/providers/openai/hooks/openai.py Outdated
Comment thread providers/openai/src/airflow/providers/openai/hooks/openai.py Outdated
Comment thread providers/openai/tests/unit/openai/hooks/test_openai.py Outdated
@dabla
dabla merged commit 6997dc7 into apache:main Oct 16, 2026
96 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants