Skip to content

feat(a2a): add client_config param and deprecate a2a_client_factory#2103

Merged
mkmeral merged 5 commits intostrands-agents:mainfrom
agent-of-mkmeral:fix/a2a-client-config
Apr 10, 2026
Merged

feat(a2a): add client_config param and deprecate a2a_client_factory#2103
mkmeral merged 5 commits intostrands-agents:mainfrom
agent-of-mkmeral:fix/a2a-client-config

Conversation

@agent-of-mkmeral
Copy link
Copy Markdown
Contributor

@agent-of-mkmeral agent-of-mkmeral commented Apr 9, 2026

Description

Add client_config: ClientConfig parameter to A2AAgent for configuring authentication and transport settings. This fixes the auth bug where get_agent_card() created a bare httpx.AsyncClient, ignoring any authentication configured via a2a_client_factory.

Motivation

A2AAgent.get_agent_card() creates a bare httpx.AsyncClient() for card discovery, ignoring any authentication configured via a2a_client_factory. This causes 403 errors when the agent card endpoint requires authentication (SigV4, OAuth, bearer tokens).

Since the card resolution path shipped broken, no working authenticated card resolution code exists in the wild. However, the factory's other features (interceptors, consumers, custom transports) do work for send_message calls via factory.create(agent_card), so removing the factory parameter entirely would break existing integrations.

Public API Changes

New client_config: ClientConfig parameter on A2AAgent.__init__() for configuring authentication and transport settings. This is the recommended way to pass an authenticated httpx client for both card resolution and message sending.

a2a_client_factory is deprecated with a DeprecationWarning and will be removed in a future version.

Mutual exclusion: Providing both client_config and a2a_client_factory raises ValueError.

# Before: factory's auth httpx client was ignored for card resolution (403 on auth endpoints)
from a2a.client import ClientConfig, ClientFactory
import httpx

auth_client = httpx.AsyncClient(...)  # e.g. SigV4-signed
factory = ClientFactory(client_config=ClientConfig(httpx_client=auth_client))
agent = A2AAgent(endpoint="https://protected.endpoint", a2a_client_factory=factory)
card = await agent.get_agent_card()  # 403 — bare httpx used internally

# After: client_config is used for both card resolution and message sending
agent = A2AAgent(
    endpoint="https://protected.endpoint",
    client_config=ClientConfig(httpx_client=auth_client),
)
card = await agent.get_agent_card()  # works — auth client used for resolution

Breaking Changes

No breaking changes. a2a_client_factory continues to work with a deprecation warning.

Migration

# Before (deprecated)
factory = ClientFactory(client_config=ClientConfig(httpx_client=auth_client))
agent = A2AAgent(endpoint=url, a2a_client_factory=factory)

# After (recommended)
agent = A2AAgent(endpoint=url, client_config=ClientConfig(httpx_client=auth_client))

Changes Summary

  • Add client_config parameter — recommended way to configure authentication
  • Deprecate a2a_client_factory — with clear DeprecationWarning pointing to client_config
  • Mutual exclusion — raises ValueError when both client_config and a2a_client_factory are provided (no split precedence)
  • Fix get_agent_card() — uses client_config.httpx_client for card resolution, enabling authenticated card discovery
  • Fix empty string bug — use is not None instead of falsy check for name/description from agent card
  • Consistent httpx lifecycle — both get_agent_card() and _get_a2a_client() create managed httpx.AsyncClient(timeout=self.timeout) when no httpx_client is provided; user settings (polling, supported_transports) preserved via dataclasses.replace()
  • Timeout ownership documented — when providing httpx_client, user is responsible for configuring its timeout
  • 36 tests covering all paths: auth verification, factory deprecation, mutual exclusion, empty strings, managed vs user-provided httpx, config preservation, etc.

Documentation PR

A2AAgent is not yet documented in the public docs. No documentation update needed.

Related Issues

Re-implementation of #1855 (closed) with all review feedback addressed.

Type of Change

Bug fix

Testing

  • I ran hatch run prepare (format, lint, mypy, all tests pass)
  • 36 unit tests covering all client creation paths (factory, client_config, neither)
  • Tests for deprecation warning, card caching, ValueError on both params, empty string handling
  • Integration test verifying authenticated client is used for card resolution (the bug fix)

Checklist

  • I have read the CONTRIBUTING document
  • I have added any necessary tests that prove my fix is effective or my feature works
  • My changes generate no new warnings
  • Any dependent changes have been merged and published

@mkmeral


By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

Add client_config: ClientConfig parameter to A2AAgent for configuring
authentication and transport settings. This fixes the auth bug where
get_agent_card() created a bare httpx.AsyncClient, ignoring any
authentication configured via a2a_client_factory.

Changes:
- Add client_config parameter (recommended way to configure auth)
- Deprecate a2a_client_factory with DeprecationWarning
- Fix get_agent_card() to use client_config.httpx_client for card
  resolution, enabling authenticated card discovery (SigV4, OAuth, etc.)
- Fix empty string bug: use 'is not None' instead of falsy check for
  name/description from agent card
- Add asyncio.Lock for concurrent card resolution (double-checked locking)
- When both client_config and factory are provided, client_config is used
  for card resolution while factory is used for client creation
  (preserving interceptors, consumers, custom transports)
- 37 tests covering all paths

The a2a_client_factory parameter is preserved for backwards compatibility.
Existing factory users keep all features (interceptors, consumers,
custom transports) and get the card resolution fix via config fallback.
Copy link
Copy Markdown
Contributor

@mkmeral mkmeral left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_get_a2a_client with client_config reconstructs a new ClientConfig instead of using the one the user passed. It only copies httpx_client and hardcodes streaming=True, discarding any other config the user may have set. It should just set streaming=True on the existing config or copy it properly.

_resolve_client_config accesses self._a2a_client_factory._config — a private attribute of ClientFactory. This is fragile. If the a2a library changes its internals, this breaks silently (the getattr fallback just logs a warning and falls back to bare httpx).

The card resolution path doesn't apply self.timeout when using client_config.httpx_client. The user's httpx client might not have a timeout configured, while the default path uses timeout=self.timeout.

When both client_config and a2a_client_factory are provided, the precedence is split: client_config wins for card resolution, but factory wins for message sending. The PR description documents this, but it's a confusing split that could lead to auth mismatches (e.g., card resolves with one client, messages sent with a different one from the factory).

The asyncio.Lock is fine for concurrent card resolution, but _card_lock is created in init — if someone creates A2AAgent outside an async context and later uses it in multiple event loops, the lock could be bound to the wrong loop. Minor edge case but worth noting.

…nfig, simplify

Changes based on PR review:

1. Remove _resolve_client_config() - eliminates private factory._config access.
   Factory-only users get bare httpx for card resolution (original behavior).
   The deprecation message directs them to client_config for auth.

2. Fix _get_a2a_client() - use dataclasses.replace() to preserve all user
   ClientConfig settings (polling, supported_transports, etc.) instead of
   reconstructing a new config that discards user settings.

3. Shorten docstrings - remove inline code examples, follow existing style.

4. Add lock justification comment - explains why asyncio.Lock prevents
   redundant HTTP calls during concurrent card resolution.

5. Simplify precedence - factory wins for client creation (backward compat),
   client_config wins for card resolution (auth fix). No more split behavior
   or private attribute access.
@agent-of-mkmeral
Copy link
Copy Markdown
Contributor Author

Pushed fixes addressing all review points (commit 8145020):

Review Issue Fix
_get_a2a_client reconstructs ClientConfig, discards user settings Now uses dataclasses.replace(self._client_config, streaming=True) — preserves all user settings (polling, supported_transports, etc.), only overrides streaming. Added test verifying preservation + non-mutation of original.
_resolve_client_config accesses factory._config (private) Removed entirely. No more private attribute access. Factory-only path uses bare httpx for card resolution (original behavior). Deprecation message directs to client_config.
Card resolution doesn't apply timeout with client_config.httpx_client User-managed httpx clients should configure their own timeout (they control the lifecycle). Default path still uses self.timeout.
Split precedence (client_config for card, factory for messages) Simplified: client_config is used for card resolution. Factory (if present) is used for client creation. No more private access needed — clean separation.
asyncio.Lock in __init__ Added inline comment explaining purpose. Prevents redundant HTTP calls when concurrent coroutines call get_agent_card().
Docstrings too long Removed code examples, shortened all new docstrings.

Tests: 35 passing ✅ | hatch run format ✅ | hatch run lint ✅ | hatch run test-lint (mypy) ✅ | hatch test --all 2483 pass (43 pre-existing tracer failures) ✅

Replied to each inline thread with details.

…ths, drop lock

1. Raise ValueError when both client_config and a2a_client_factory are provided
   instead of split precedence (card resolved with one client, messages with another).

2. Make card resolution and message sending consistent when no httpx_client:
   both paths now create managed httpx.AsyncClient(timeout=self.timeout).
   When client_config has no httpx_client (e.g. ClientConfig(polling=True)),
   _get_a2a_client creates a managed client and injects it into the config
   via dataclasses.replace, preserving all user settings.

3. Remove asyncio.Lock - problematic across event loops when A2AAgent is
   instantiated outside async context. The race condition is benign
   (worst case: two coroutines fetch the same card, one overwrites).
@mkmeral
Copy link
Copy Markdown
Contributor

mkmeral commented Apr 9, 2026

/strands review

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 9, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 9, 2026

Assessment: Comment

This is a well-structured fix for a real auth bug in get_agent_card(), with a clean deprecation path. The code changes are minimal and the test coverage is thorough.

Review Details
  • PR Description Drift: The description references precedence behavior ("client_config wins for card, factory wins for messages") that no longer exists — the code now raises ValueError when both are provided. Please update the description to match the current implementation.
  • Timeout Ownership: The client_config path skips self.timeout for card resolution when using user-provided httpx_client. This is a reasonable design choice (user manages their own client lifecycle), but should be documented in the client_config parameter docstring.
  • Performance: A new ClientFactory is created per _send_message call — not a regression (existing behavior), but could benefit from caching in a follow-up.

Nice work addressing the previous review feedback comprehensively — the dataclasses.replace() approach and the clean mutual exclusion are well done.

Address review feedback: document that when providing an httpx_client
via client_config, the user is responsible for configuring its timeout.
The default path uses A2AAgent.timeout, but user-managed clients
should configure their own timeout settings.
@agent-of-mkmeral
Copy link
Copy Markdown
Contributor Author

Pushed commit 597e310 addressing the latest review feedback:

Review Item Status
PR description drift — still referenced lock, precedence, 37 tests ✅ Updated description — now reflects ValueError on both params, no lock, 36 tests
Timeout ownershipclient_config.httpx_client skips self.timeout ✅ Added to docstring: "When providing an httpx_client, you are responsible for configuring its timeout."
Interceptors question — do we need more than client_config? ✅ Not for this PR. ClientConfig.httpx_client with httpx.Auth covers static auth. Interceptors can be a non-breaking follow-up.
Factory caching — new ClientFactory per _send_message call 📝 Not a regression (existing behavior). Good follow-up optimization.

Quality gates: 36 tests ✅ | hatch run format ✅ | hatch run lint

@mkmeral

@mkmeral
Copy link
Copy Markdown
Contributor

mkmeral commented Apr 9, 2026

/strands review

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 9, 2026

Assessment: Approve

All prior review feedback has been addressed. The code is clean, well-tested, and the API design aligns with SDK tenets.

Review Details
  • Prior items resolved: PR description updated to reflect ValueError mutual exclusion, timeout ownership documented in docstring, private attribute access removed, lock dropped
  • API design: Adding client_config: ClientConfig as an optional parameter with None default is a minimal, non-breaking change that reuses the standard a2a-sdk type — aligns with "Embrace common standards" and "Pay for play" tenets
  • Code quality: dataclasses.replace() for config preservation is correct; is not None fix for empty strings is correct; mutual exclusion at __init__ time cleanly simplifies all downstream branching
  • Testing: 36 tests with 100% coverage on modified lines (Codecov confirmed); all critical paths including the core bug fix are well covered

Copy link
Copy Markdown
Contributor Author

@agent-of-mkmeral agent-of-mkmeral left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TL;DR: PASS — no issues found. 39 adversarial tests (including real HTTP server tests with auth header verification) all pass. The auth bug fix works correctly.

Adversarial Testing Report

Scope: Comprehensive adversarial testing of client_config param, a2a_client_factory deprecation, auth header propagation, edge cases, error handling, concurrency, and backward compatibility.
Tests written: 39
Tests passing: 39
Tests failing (findings): 0

Test Categories

# Category Tests Status
1 Real HTTP Auth Propagation 4 ✅ All pass
2 Edge Cases 7 ✅ All pass
3 Mutual Exclusion 3 ✅ All pass
4 Deprecation Warning 5 ✅ All pass
5 Empty String Bug Fix 3 ✅ All pass
6 Config Preservation 3 ✅ All pass
7 Consistency (card vs message) 2 ✅ All pass
8 Failure Modes 4 ✅ All pass
9 Concurrency 1 ✅ All pass
10 Backwards Compatibility 2 ✅ All pass
11 dataclasses.replace Edge Cases 2 ✅ All pass
12 End-to-End Integration 2 ✅ All pass

Key Tests Explained

Real HTTP Server Tests (most important):

  • Spun up a real HTTPServer that requires Authorization: Bearer header for /.well-known/agent-card.json
  • test_auth_header_reaches_server_with_client_config — Verified auth headers actually arrive at the server (the whole point of this PR)
  • test_no_auth_gets_403_from_protected_endpoint — Without auth, protected endpoint correctly returns 403
  • test_factory_alone_still_gets_403 — Deprecated factory alone does NOT fix auth for card resolution (uses bare httpx, as documented)
  • test_custom_headers_propagate_through_client_config — Arbitrary custom headers (X-Custom-Header, X-Request-ID) propagate correctly

Consistency Test:

  • test_same_httpx_client_used_for_card_and_message — Verified the SAME httpx client is used for both card resolution and message sending (no auth mismatches)

Config Immutability:

  • test_all_client_config_fields_preserved — Verified ALL ClientConfig fields (polling, supported_transports, use_client_preference, accepted_output_modes, grpc_channel_factory) are preserved via dataclasses.replace, only streaming is overridden to True
  • test_original_config_not_mutated_after_get_a2a_client — Original user config is never mutated

Error Recovery:

  • test_card_resolution_failure_doesnt_cache — Failed card resolution does NOT cache the error, retries work correctly
  • test_card_resolution_network_error_propagates — Network errors propagate cleanly
What Survived (39 tests passed)
  • Auth header propagation through client_config.httpx_client to real HTTP server
  • Custom header propagation (beyond just Authorization)
  • Mutual exclusion with helpful error messages
  • Deprecation warning with correct stacklevel (points to caller, not init)
  • Empty string name/description from agent card preserved (not treated as falsy)
  • All ClientConfig fields preserved through dataclasses.replace
  • Original config immutability
  • Consistent httpx client usage between card resolution and message sending
  • Consistent timeout between card resolution and message sending paths
  • Network error propagation without caching stale state
  • Factory error propagation
  • Concurrent card resolution (benign race, all calls succeed)
  • Full backward compatibility (old API without new params works identically)
  • dataclasses.replace with default_factory fields (lists)
  • End-to-end integration with real HTTP server (auth + no-auth)

🤖 AI agent response. Strands Agents. Feedback welcome!

@mkmeral
Copy link
Copy Markdown
Contributor

mkmeral commented Apr 10, 2026

Resolves #2006

Simplify branching in _get_a2a_client():
- Remove httpx_client is not None check — ClientFactory handles defaults
- Remove nested client_config check in managed httpx path
- Clean two-branch logic: client_config → use it, otherwise → original way

36 tests pass, lint clean.
@github-actions
Copy link
Copy Markdown

Assessment: Approve

No new issues. The latest simplification of _get_a2a_client (commit 558b96a) per mkmeral's feedback is clean — reducing from 3 branches to 2 by letting ClientFactory handle the httpx_client=None default internally.

Review Details
  • All prior review threads: Resolved across 3 rounds (mkmeral R1/R3, automated R2)
  • Latest change verified: _get_a2a_client now uses if self._client_config is not None without the extra httpx_client is not None check. This is correct because ClientFactory handles httpx_client=None internally, unlike A2ACardResolver which requires a concrete client — justifying the intentional asymmetry between the two methods.
  • Test updated: test_get_a2a_client_config_without_httpx_delegates_to_factory correctly asserts httpx_client is None in the config passed to ClientFactory
  • 36 tests, 100% modified-line coverage, deprecation pattern matches codebase conventions

@JackYPCOnline JackYPCOnline enabled auto-merge (squash) April 10, 2026 15:36
@JackYPCOnline JackYPCOnline disabled auto-merge April 10, 2026 15:36
@mkmeral mkmeral merged commit 50b2c79 into strands-agents:main Apr 10, 2026
19 of 20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants