feat: Constrain HttpModelClient to single concurrency mode...#439
feat: Constrain HttpModelClient to single concurrency mode...#439
Conversation
Constrain each HttpModelClient instance to sync or async at construction time, eliminating dual-mode lifecycle complexity that caused transport leaks and cross-mode teardown bugs. - Add ClientConcurrencyMode StrEnum replacing Literal type alias - Add concurrency_mode constructor param with mode enforcement guards on _get_sync_client / _get_async_client - Simplify close()/aclose() to single-mode teardown (cross-mode calls are no-ops) - Thread client_concurrency_mode through factory chain from DATA_DESIGNER_ASYNC_ENGINE env var - Add ModelRegistry.arun_health_check() async mirror and wire async dispatch in ColumnWiseDatasetBuilder - Make ensure_async_engine_loop public (used cross-module) - Fix test helpers to derive concurrency mode from injected client - Add PR-5 architecture notes
Greptile SummaryThis PR constrains each Key changes:
All previously surfaced issues (double-close, timeout guard, mismatch validation, traceback preservation) are fixed in
|
| Filename | Overview |
|---|---|
| packages/data-designer-engine/src/data_designer/engine/models/clients/adapters/http_model_client.py | Core of the PR — adds ClientConcurrencyMode enum, mode enforcement guards, and simplified single-path close()/aclose(). Previous issues (double-close, mismatch validation) are fixed. Logic is sound. |
| packages/data-designer-engine/src/data_designer/engine/models/registry.py | Adds arun_health_check() as a clean async mirror of run_health_check(). Uses bare raise (preserving traceback). All three GenerationType branches covered. |
| packages/data-designer-engine/src/data_designer/engine/dataset_builders/column_wise_builder.py | Dispatches async health check via run_coroutine_threadsafe with a 180s timeout and proper cancellation on TimeoutError. No issues; the TimeoutError catch is correct because the async path enforces Python 3.11+. |
| packages/data-designer-engine/src/data_designer/engine/resources/resource_provider.py | Derives client_concurrency_mode from DATA_DESIGNER_ASYNC_ENGINE and threads it through to create_model_registry. Factory chain is complete and consistent. |
| packages/data-designer-engine/tests/engine/models/test_model_registry.py | Good async health check coverage for EMBEDDING and CHAT_COMPLETION paths. Minor inconsistency: mock_agenerate_image assertion uses await_count while the others use call_count. |
| packages/data-designer-engine/tests/engine/models/clients/test_native_http_clients.py | Comprehensive lifecycle, mode enforcement, constructor validation, and lazy-initialization tests for both OpenAI and Anthropic adapters. |
Sequence Diagram
sequenceDiagram
participant CWB as ColumnWiseDatasetBuilder
participant ACL as async event loop
participant MR as ModelRegistry
participant MF as ModelFacade
participant HMC as HttpModelClient (ASYNC mode)
Note over CWB: DATA_DESIGNER_ASYNC_ENGINE=1
CWB->>ACL: ensure_async_engine_loop()
CWB->>ACL: run_coroutine_threadsafe(arun_health_check, loop)
CWB->>CWB: future.result(timeout=180)
ACL->>MR: arun_health_check(model_aliases)
loop for each model_alias
MR->>MF: get_model(model_alias)
alt EMBEDDING
MR->>MF: await agenerate_text_embeddings(...)
MF->>HMC: _get_async_client()
HMC-->>MF: httpx.AsyncClient
MF-->>MR: embeddings result
else CHAT_COMPLETION
MR->>MF: await agenerate(...)
MF->>HMC: _get_async_client()
HMC-->>MF: httpx.AsyncClient
MF-->>MR: generation result
else IMAGE
MR->>MF: await agenerate_image(...)
MF->>HMC: _get_async_client()
HMC-->>MF: httpx.AsyncClient
MF-->>MR: image result
end
MR->>MR: log ✅ Passed!
end
MR-->>CWB: (future resolves)
Note over CWB: DATA_DESIGNER_ASYNC_ENGINE=0
CWB->>MR: run_health_check(model_aliases) [sync path]
Prompt To Fix All With AI
This is a comment left during a code review.
Path: packages/data-designer-engine/tests/engine/models/test_model_registry.py
Line: 396-398
Comment:
**Inconsistent assertion style for `mock_agenerate_image`**
`mock_agenerate_image` is asserted with `await_count` while both `mock_agenerate` and `mock_agenerate_text_embeddings` use `call_count`. For `AsyncMock`, when `await mock(...)` is executed both counters are incremented together, so both will pass — but the inconsistency masks the intent. The sync counterpart `test_run_health_check_success` uses `call_count` for all three checks including `mock_generate_image.call_count == 1`.
```suggestion
assert mock_agenerate.call_count == 2
assert mock_agenerate_text_embeddings.call_count == 1
assert mock_agenerate_image.call_count == 1
```
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: "fix: address Greptil..."
...s/data-designer-engine/src/data_designer/engine/models/clients/adapters/http_model_client.py
Show resolved
Hide resolved
packages/data-designer-engine/src/data_designer/engine/dataset_builders/column_wise_builder.py
Outdated
Show resolved
Hide resolved
...s/data-designer-engine/src/data_designer/engine/models/clients/adapters/http_model_client.py
Show resolved
Hide resolved
- Fix transport double-close in close()/aclose() by delegating teardown to the httpx client when one exists (if/elif pattern); only close transport directly if no client was ever created - Reject mismatched client/mode injection in constructor (e.g. async_client on a sync-mode instance raises ValueError) - Add 5-minute wall-clock timeout to future.result() in async health check dispatch - Add constructor validation tests for both mismatch directions - Update PR-5 architecture notes Made-with: Cursor
packages/data-designer-engine/src/data_designer/engine/dataset_builders/column_wise_builder.py
Outdated
Show resolved
Hide resolved
Claude Code caught this one: |
packages/data-designer-engine/src/data_designer/engine/models/registry.py
Show resolved
Hide resolved
packages/data-designer-engine/tests/engine/models/clients/test_native_http_clients.py
Outdated
Show resolved
Hide resolved
andreatgretel
left a comment
There was a problem hiding this comment.
nice work on the single-mode lifecycle - the design is clean and the tests are thorough. two reviews ran on this (Claude Code + Codex). both agree the core design is solid. main items:
- timeout cancellation: if
future.result(timeout=300)times out, cancel the future so the coroutine doesn't linger on the shared loop - transport leak: eagerly created transport isn't closed when a client is injected (test-only in practice)
arun_health_checkis a near-complete duplicate ofrun_health_check- consider extracting shared iteration logic- a few test coverage gaps: async health check dispatch, IMAGE generation type, env var derivation
nothing critical. ship it with a quick follow-up for the timeout/transport items
Updated in 1afb1d7ffdf0eeed4cce9013dd156f69aa3d2314 so that transport is lazily created and the constructor accepts one incase consumer provides a custom transport to go along with the client they inject. |
- Type _transport as RetryTransport | None, removing type: ignore suppressions in close()/aclose() - Make transport fully lazy (None by default) and accept optional transport constructor param so injected-client paths don't eagerly allocate an unused RetryTransport - Cancel future on TimeoutError in async health check dispatch so timed-out coroutines don't linger on the shared event loop - Set health check timeout to 180s (3 min) matching architecture notes - Rename _SYNC_CLIENT_CASES to _CLIENT_FACTORY_CASES since the list parametrizes both sync and async mode tests - Update architecture notes timeout from 180→300 back to 180 to match implementation Made-with: Cursor
packages/data-designer-engine/src/data_designer/engine/models/registry.py
Outdated
Show resolved
Hide resolved
packages/data-designer-engine/tests/engine/models/test_model_registry.py
Show resolved
Hide resolved
...s/data-designer-engine/src/data_designer/engine/models/clients/adapters/http_model_client.py
Show resolved
Hide resolved
- Use bare `raise` instead of `raise e` in both run_health_check and arun_health_check to preserve original traceback frames - Add GenerationType.IMAGE test coverage for sync and async health checks (stub-image config + generate_image/agenerate_image patches) Made-with: Cursor
packages/data-designer-engine/tests/engine/models/test_model_registry.py
Outdated
Show resolved
Hide resolved
…egistry.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Summary
Fifth PR in the model facade overhaul series (plan, architecture notes). Constrains each
HttpModelClientinstance to a single execution mode — sync or async — at construction time, eliminating the dual-mode lifecycle complexity that caused transport leaks and cross-mode teardown bugs surfaced during PR-4 review (#426). AddsModelRegistry.arun_health_check()so health checks use the async path whenDATA_DESIGNER_ASYNC_ENGINE=1.Previous PRs:
Changes
Added
ClientConcurrencyModeStrEnum (http_model_client.py) — replacesLiteral["sync", "async"]type alias with a proper enum for runtime type identity and IDE autocompleteModelRegistry.arun_health_check()(registry.py) — async mirror ofrun_health_check()that callsagenerate/agenerate_text_embeddings/agenerate_imageon model facadescolumn_wise_builder.py) — submitsarun_health_check()to the background event loop viaasyncio.run_coroutine_threadsafewhenDATA_DESIGNER_ASYNC_ENGINE=1plans/343/model-facade-overhaul-pr-5-architecture-notes.md)Changed
HttpModelClient(http_model_client.py) — constructor acceptsconcurrency_modeparameter;_get_sync_client()/_get_async_client()raiseRuntimeErrorif called in the wrong mode;close()andaclose()simplified to single-mode teardown (cross-mode calls are no-ops)client_concurrency_modeparameter threaded throughcreate_model_client→create_model_registry→create_resource_provider, derived fromDATA_DESIGNER_ASYNC_ENGINEenv varensure_async_engine_loop(async_concurrency.py) — renamed from_ensure_async_engine_loop(now public, used cross-module)test_anthropic.py,test_openai_compatible.py) — auto-deriveconcurrency_modefrom which mock client is injectedFixed
close()on a dual-mode client left the async transport open;aclose()never touched the transport at allclose()could notawait aclient.aclose();aclose()had to also handle sync cleanupAttention Areas
http_model_client.py— mode enforcement guards in_get_sync_client/_get_async_clientand simplifiedclose()/aclose()concurrency_modeflows from env var throughresource_provider.py→models/factory.py→clients/factory.py→ adapter constructorsregistry.py—arun_health_check()mirrorsrun_health_check()with async facade methodscolumn_wise_builder.py— async health check dispatch viarun_coroutine_threadsafeTest plan
uv run ruff checkon all changed source filesuv run pyteston all new and updated test filesRuntimeErrorclient_concurrency_modereaches adapter constructorsMade with Cursor