Skip to content

Commit f9528b3

Browse files
authored
fix: Multitenancy support for public agent card doesn't match the client. (#1111)
fix(client): use /.well-known/{tenant}/agent-card.json for tenant cards - Align client URL pattern with server route: embed tenant inside well-known path - Add fallback to original base URL when computed card URL returns HTTP error - Extend stripWellKnownSuffix to handle tenant-specific suffixes Fixes: #1107 Signed-off-by: Emmanuel Hugonnet <ehugonne@redhat.com>
1 parent d204ab6 commit f9528b3

8 files changed

Lines changed: 265 additions & 35 deletions

File tree

‎docs/content/1_3_0_Final/extra/multi-tenancy.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,11 @@ public void execute(RequestContext context, AgentEmitter emitter) throws A2AErro
148148

149149
## Tenant Source
150150

151-
The tenant is read from the `tenant` field in the request payload (e.g. `MessageSendParams.tenant()`, `CancelTaskParams.tenant()`). For the REST transport the tenant can also come from the URL path (e.g. `/\{tenant}/extendedAgentCard`); the payload value takes precedence when both are present.
151+
The tenant is read from the `tenant` field in the request payload (e.g. `MessageSendParams.tenant()`, `CancelTaskParams.tenant()`).
152+
153+
For the **public agent card**, the tenant is encoded in the URL path — `/.well-known/\{tenant}/agent-card.json` — and is served by all transports (JSON-RPC and REST).
154+
155+
For the **REST transport**, task-operation URLs also encode the tenant as a path prefix (e.g. `/\{tenant}/message:send`, `/\{tenant}/extendedAgentCard`); the payload `tenant` field takes precedence when both are present.
152156

153157
Tenant identifiers are restricted to `a-zA-Z0-9_-.` characters — path segments containing `/` or `?` are rejected.
154158

‎docs/content/dev/extra/multi-tenancy.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,11 @@ public void execute(RequestContext context, AgentEmitter emitter) throws A2AErro
148148

149149
## Tenant Source
150150

151-
The tenant is read from the `tenant` field in the request payload (e.g. `MessageSendParams.tenant()`, `CancelTaskParams.tenant()`). For the REST transport the tenant can also come from the URL path (e.g. `/\{tenant}/extendedAgentCard`); the payload value takes precedence when both are present.
151+
The tenant is read from the `tenant` field in the request payload (e.g. `MessageSendParams.tenant()`, `CancelTaskParams.tenant()`).
152+
153+
For the **public agent card**, the tenant is encoded in the URL path — `/.well-known/\{tenant}/agent-card.json` — and is served by all transports (JSON-RPC and REST).
154+
155+
For the **REST transport**, task-operation URLs also encode the tenant as a path prefix (e.g. `/\{tenant}/message:send`, `/\{tenant}/extendedAgentCard`); the payload `tenant` field takes precedence when both are present.
152156

153157
Tenant identifiers are restricted to `a-zA-Z0-9_-.` characters — path segments containing `/` or `?` are rejected.
154158

‎http-client/src/main/java/org/a2aproject/sdk/client/http/A2ACardResolver.java‎

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
* <h2>Features</h2>
3030
* <ul>
3131
* <li>Standard agent card endpoint discovery ({@code /.well-known/agent-card.json})</li>
32-
* <li>Tenant-specific path support ({@code /tenant/.well-known/agent-card.json})</li>
32+
* <li>Tenant-specific path support ({@code /.well-known/{tenant}/agent-card.json})</li>
3333
* <li>Custom card path support for non-standard agent card locations</li>
3434
* <li>Custom authentication header injection</li>
3535
* <li>Pluggable HTTP client via {@link A2AHttpClientFactory}</li>
@@ -43,7 +43,7 @@
4343
* .build();
4444
* AgentCard card = resolver.getAgentCard();
4545
*
46-
* // With tenant path
46+
* // With tenant — fetches from /.well-known/my-tenant/agent-card.json
4747
* A2ACardResolver resolver = A2ACardResolver.builder()
4848
* .baseUrl("http://localhost:9999")
4949
* .tenant("my-tenant")
@@ -83,22 +83,41 @@ public class A2ACardResolver {
8383

8484
private final A2AHttpClient httpClient;
8585
private final String cardUrl;
86+
private final @Nullable String fallbackUrl;
8687
private final @Nullable Map<String, String> authHeaders;
8788

8889
private A2ACardResolver(A2AHttpClient httpClient, String baseUrl, @Nullable String tenant, @Nullable String agentCardPath, @Nullable Map<String, String> authHeaders) throws A2AClientError {
8990
checkNotNullParam("httpClient", httpClient);
9091
checkNotNullParam("baseUrl", baseUrl);
9192
this.httpClient = httpClient;
9293
try {
93-
// Strip any well-known suffix from baseUrl before appending the tenant,
94-
// so that a full card URL like https://host/.well-known/agent-card.json + tenant
95-
// doesn't produce a malformed path.
94+
// Strip any well-known suffix from baseUrl so that a full card URL like
95+
// https://host/.well-known/agent-card.json doesn't produce a malformed path.
9696
String cleanBase = Utils.stripWellKnownSuffix(baseUrl);
97-
String baseUrlWithTenant = Utils.buildBaseUrl(cleanBase, tenant);
98-
Utils.validateAbsoluteUrl(baseUrlWithTenant);
99-
this.cardUrl = (agentCardPath == null || agentCardPath.isEmpty())
100-
? Utils.buildCardUrl(baseUrlWithTenant, Utils.DEFAULT_AGENT_CARD_PATH)
101-
: Utils.buildCardUrl(baseUrlWithTenant, agentCardPath);
97+
String resolvedCardUrl;
98+
@Nullable String resolvedFallbackUrl;
99+
if (agentCardPath != null && !agentCardPath.isEmpty()) {
100+
// Custom path: tenant goes as path prefix (explicit override); no fallback.
101+
String baseUrlWithTenant = Utils.buildBaseUrl(cleanBase, tenant);
102+
Utils.validateAbsoluteUrl(baseUrlWithTenant);
103+
resolvedCardUrl = Utils.buildCardUrl(baseUrlWithTenant, agentCardPath);
104+
resolvedFallbackUrl = null;
105+
} else {
106+
// Standard well-known path: optionally embed tenant inside the path.
107+
if (tenant != null && !tenant.isBlank()) {
108+
// {base}/.well-known/{tenant}/agent-card.json
109+
Utils.validateTenant(tenant);
110+
Utils.validateAbsoluteUrl(cleanBase);
111+
resolvedCardUrl = Utils.buildCardUrl(cleanBase, "/.well-known/" + Utils.normalizeTenant(tenant) + "/agent-card.json");
112+
} else {
113+
// {base}/.well-known/agent-card.json
114+
Utils.validateAbsoluteUrl(cleanBase);
115+
resolvedCardUrl = Utils.buildCardUrl(cleanBase, Utils.DEFAULT_AGENT_CARD_PATH);
116+
}
117+
resolvedFallbackUrl = isSameUrl(resolvedCardUrl, baseUrl) ? null : cleanBase;
118+
}
119+
this.cardUrl = resolvedCardUrl;
120+
this.fallbackUrl = resolvedFallbackUrl;
102121
} catch (URISyntaxException e) {
103122
throw new A2AClientError("Invalid agent URL", e);
104123
}
@@ -223,8 +242,10 @@ public A2ACardResolver build() throws A2AClientError {
223242
* Fetches the agent card for this resolver's configured agent.
224243
*
225244
* <p>Fetches from the custom {@code agentCardPath} when one was supplied, otherwise fetches
226-
* from the standard {@code /.well-known/agent-card.json} endpoint. No automatic fallback
227-
* is performed; errors are propagated directly to the caller.
245+
* from the standard {@code /.well-known/agent-card.json} (or tenant-specific variant) endpoint.
246+
* When no custom path was provided and the computed card URL differs from the originally
247+
* supplied base URL, a 404 on the primary URL triggers a single retry against the
248+
* original base URL before propagating the error.
228249
*
229250
* @return the agent card
230251
* @throws A2AClientHTTPError If the server returns a non-2xx response (carries status, body, and headers)
@@ -234,9 +255,30 @@ public A2ACardResolver build() throws A2AClientError {
234255
*/
235256
public AgentCard getAgentCard() throws A2AClientError, A2AClientJSONError {
236257
LOGGER.debug("Fetching agent card from URL: {}", cardUrl);
258+
try {
259+
return fetchAgentCard(cardUrl);
260+
} catch (A2AClientHTTPError e) {
261+
if (fallbackUrl != null && e.getCode() == 404) {
262+
LOGGER.debug("Failed to fetch agent card from {} (status {}); retrying with provided URL: {}", cardUrl, e.getCode(), fallbackUrl);
263+
try {
264+
return fetchAgentCard(fallbackUrl);
265+
} catch (A2AClientError fallbackEx) {
266+
fallbackEx.addSuppressed(e);
267+
throw fallbackEx;
268+
}
269+
}
270+
throw e;
271+
}
272+
}
273+
274+
private static boolean isSameUrl(String a, String b) {
275+
String stripSlash = b.endsWith("/") ? b.substring(0, b.length() - 1) : b;
276+
return a.equals(stripSlash);
277+
}
237278

279+
private AgentCard fetchAgentCard(String url) throws A2AClientError, A2AClientJSONError {
238280
A2AHttpClient.GetBuilder builder = httpClient.createGet()
239-
.url(cardUrl)
281+
.url(url)
240282
.addHeader("Content-Type", "application/json");
241283

242284
if (authHeaders != null) {
@@ -248,11 +290,11 @@ public AgentCard getAgentCard() throws A2AClientError, A2AClientJSONError {
248290
A2AHttpResponse response = builder.get();
249291
if (!response.success()) {
250292
String msg = "Failed to obtain agent card: " + response.status();
251-
LOGGER.debug("Failed to fetch agent card from {}, status: {}", cardUrl, response.status());
293+
LOGGER.debug("Failed to fetch agent card from {}, status: {}", url, response.status());
252294
throw new A2AClientHTTPError(response.status(), msg, response.body(), response.headers().toMap());
253295
}
254296
body = response.body();
255-
LOGGER.debug("Successfully fetched agent card from {}", cardUrl);
297+
LOGGER.debug("Successfully fetched agent card from {}", url);
256298
} catch (InterruptedException e) {
257299
Thread.currentThread().interrupt();
258300
throw new A2AClientError("Failed to obtain agent card", e);

‎http-client/src/test/java/org/a2aproject/sdk/client/http/A2ACardResolverTest.java‎

Lines changed: 135 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ public void testGetAgentCard_interruptedExceptionThrows() throws Exception {
179179
public void testGetAgentCard_withTenant() throws Exception {
180180
TestHttpClient client = createTestClient();
181181
A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com").tenant("my-tenant").build().getAgentCard();
182-
assertEquals("http://example.com/my-tenant" + AGENT_CARD_PATH, client.url);
182+
assertEquals("http://example.com/.well-known/my-tenant/agent-card.json", client.url);
183183
}
184184

185185
@Test
@@ -233,16 +233,16 @@ public void testBuilder_malformedBaseUrl_throws() {
233233

234234
@Test
235235
public void testFullWellKnownUrlWithTenant() throws Exception {
236-
// Full well-known URL + tenant must strip the suffix before appending tenant,
237-
// not produce a malformed path like ...agent-card.json/my-tenant/.well-known/agent-card.json
236+
// Full well-known URL + tenant must strip the suffix before embedding tenant inside the path,
237+
// not produce a malformed path like ...agent-card.json/.well-known/my-tenant/agent-card.json
238238
TestHttpClient client = createTestClient();
239239
A2ACardResolver resolver = A2ACardResolver.builder()
240240
.httpClient(client)
241241
.baseUrl("https://example.com/.well-known/agent-card.json")
242242
.tenant("my-tenant")
243243
.build();
244244
resolver.getAgentCard();
245-
assertEquals("https://example.com/my-tenant" + AGENT_CARD_PATH, client.url);
245+
assertEquals("https://example.com/.well-known/my-tenant/agent-card.json", client.url);
246246
}
247247

248248
// -------------------------------------------------------------------------
@@ -260,7 +260,7 @@ public void testSpec03PathPreservation_wellKnown() throws Exception {
260260
public void testSpec03PathPreservation_withTenant() throws Exception {
261261
TestHttpClient client = createTestClient();
262262
A2ACardResolver.builder().httpClient(client).baseUrl("https://example.com/spec03").tenant("my-tenant").build().getAgentCard();
263-
assertEquals("https://example.com/spec03/my-tenant" + AGENT_CARD_PATH, client.url);
263+
assertEquals("https://example.com/spec03/.well-known/my-tenant/agent-card.json", client.url);
264264
}
265265

266266
@Test
@@ -303,6 +303,134 @@ public void testFullWellKnownUrlWithSameAgentCardPath() throws Exception {
303303
assertEquals("https://example.com/spec03/.well-known/agent-card.json", client.url);
304304
}
305305

306+
// -------------------------------------------------------------------------
307+
// Tenant URL — correct /.well-known/{tenant}/agent-card.json pattern
308+
// -------------------------------------------------------------------------
309+
310+
@Test
311+
public void testTenantUrl_embeddedInWellKnownPath() throws Exception {
312+
TestHttpClient client = createTestClient();
313+
A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com").tenant("acme").build().getAgentCard();
314+
assertEquals("http://example.com/.well-known/acme/agent-card.json", client.url);
315+
}
316+
317+
@Test
318+
public void testTenantUrl_fullTenantCardUrlProvidedAsBase_notDuplicated() throws Exception {
319+
// When the caller already supplies the tenant card URL, it must be used as-is.
320+
TestHttpClient client = createTestClient();
321+
String tenantCardUrl = "http://example.com/.well-known/acme/agent-card.json";
322+
A2ACardResolver.builder().httpClient(client).baseUrl(tenantCardUrl).tenant("acme").build().getAgentCard();
323+
assertEquals(tenantCardUrl, client.url);
324+
assertEquals(1, client.urlsCalled.size());
325+
}
326+
327+
@Test
328+
public void testTenantUrl_fullTenantCardUrlProvidedAsBase_withTrailingSlash_notDuplicated() throws Exception {
329+
TestHttpClient client = createTestClient();
330+
A2ACardResolver.builder().httpClient(client)
331+
.baseUrl("http://example.com/.well-known/acme/agent-card.json/")
332+
.tenant("acme")
333+
.build()
334+
.getAgentCard();
335+
assertEquals("http://example.com/.well-known/acme/agent-card.json", client.url);
336+
assertEquals(1, client.urlsCalled.size());
337+
}
338+
339+
@Test
340+
public void testTenantUrl_fullTenantCardUrlProvidedAsBase_noFallback() throws Exception {
341+
// No fallback when the provided URL already IS the computed card URL.
342+
TestHttpClient client = createTestClient();
343+
client.status = 404;
344+
String tenantCardUrl = "http://example.com/.well-known/acme/agent-card.json";
345+
A2ACardResolver resolver = A2ACardResolver.builder().httpClient(client).baseUrl(tenantCardUrl).tenant("acme").build();
346+
assertThrows(A2AClientHTTPError.class, resolver::getAgentCard);
347+
assertEquals(1, client.urlsCalled.size());
348+
}
349+
350+
@Test
351+
public void testTenantUrl_withSubBasePath() throws Exception {
352+
TestHttpClient client = createTestClient();
353+
A2ACardResolver.builder().httpClient(client).baseUrl("https://example.com/spec03").tenant("acme").build().getAgentCard();
354+
assertEquals("https://example.com/spec03/.well-known/acme/agent-card.json", client.url);
355+
}
356+
357+
// -------------------------------------------------------------------------
358+
// Fallback to provided URL on HTTP error
359+
// -------------------------------------------------------------------------
360+
361+
@Test
362+
public void testGetAgentCard_httpError_fallsBackToProvidedUrl() throws Exception {
363+
// Primary URL (/.well-known/agent-card.json) returns 404; fallback to original base URL succeeds.
364+
TestHttpClient client = createTestClient();
365+
client.statusSequence.add(404);
366+
client.statusSequence.add(200);
367+
A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com").build().getAgentCard();
368+
assertEquals(2, client.urlsCalled.size());
369+
assertEquals("http://example.com" + AGENT_CARD_PATH, client.urlsCalled.get(0));
370+
assertEquals("http://example.com", client.urlsCalled.get(1));
371+
}
372+
373+
@Test
374+
public void testGetAgentCard_doubleSlashBaseUrl_fallbackUrlNormalized() throws Exception {
375+
// A baseUrl with a double trailing slash must not produce a double-slash fallback URL.
376+
TestHttpClient client = createTestClient();
377+
client.statusSequence.add(404);
378+
client.statusSequence.add(200);
379+
A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com//").build().getAgentCard();
380+
assertEquals(2, client.urlsCalled.size());
381+
assertEquals("http://example.com" + AGENT_CARD_PATH, client.urlsCalled.get(0));
382+
// cleanBase strips one trailing slash from "http://example.com//", yielding "http://example.com/"
383+
assertEquals("http://example.com/", client.urlsCalled.get(1));
384+
}
385+
386+
@Test
387+
public void testGetAgentCard_httpError_bothFail_throwsLastError() throws Exception {
388+
// Both primary (/.well-known/agent-card.json) and fallback return 404; last error is propagated
389+
// and the primary error is attached as a suppressed exception.
390+
TestHttpClient client = createTestClient();
391+
client.status = 404;
392+
A2ACardResolver resolver = A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com").build();
393+
A2AClientHTTPError error = assertThrows(A2AClientHTTPError.class, resolver::getAgentCard);
394+
assertEquals(404, error.getCode());
395+
assertEquals(2, client.urlsCalled.size());
396+
assertEquals(1, error.getSuppressed().length);
397+
assertTrue(error.getSuppressed()[0] instanceof A2AClientHTTPError);
398+
assertEquals(404, ((A2AClientHTTPError) error.getSuppressed()[0]).getCode());
399+
}
400+
401+
@Test
402+
public void testGetAgentCard_nonNotFound_httpError_noFallback() throws Exception {
403+
// Non-404 errors (e.g. 503) must not trigger the fallback — only 1 request made.
404+
TestHttpClient client = createTestClient();
405+
client.status = 503;
406+
A2ACardResolver resolver = A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com").build();
407+
A2AClientHTTPError error = assertThrows(A2AClientHTTPError.class, resolver::getAgentCard);
408+
assertEquals(503, error.getCode());
409+
assertEquals(1, client.urlsCalled.size());
410+
}
411+
412+
@Test
413+
public void testGetAgentCard_noFallback_whenUrlAlreadyIsCardUrl() throws Exception {
414+
// When the provided URL already equals the computed card URL, no fallback.
415+
TestHttpClient client = createTestClient();
416+
client.status = 404;
417+
String fullCardUrl = "http://example.com" + AGENT_CARD_PATH;
418+
A2ACardResolver resolver = A2ACardResolver.builder().httpClient(client).baseUrl(fullCardUrl).build();
419+
assertThrows(A2AClientHTTPError.class, resolver::getAgentCard);
420+
assertEquals(1, client.urlsCalled.size());
421+
}
422+
423+
@Test
424+
public void testGetAgentCard_withTenant_httpError_fallsBackToProvidedUrl() throws Exception {
425+
TestHttpClient client = createTestClient();
426+
client.statusSequence.add(404);
427+
client.statusSequence.add(200);
428+
A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com").tenant("acme").build().getAgentCard();
429+
assertEquals(2, client.urlsCalled.size());
430+
assertEquals("http://example.com/.well-known/acme/agent-card.json", client.urlsCalled.get(0));
431+
assertEquals("http://example.com", client.urlsCalled.get(1));
432+
}
433+
306434
// -------------------------------------------------------------------------
307435
// Helpers
308436
// -------------------------------------------------------------------------
@@ -319,6 +447,7 @@ private String printAgentCard(AgentCard agentCard) throws InvalidProtocolBufferE
319447

320448
private static class TestHttpClient implements A2AHttpClient {
321449
int status = 200;
450+
List<Integer> statusSequence = new ArrayList<>();
322451
String body;
323452
String url;
324453
boolean throwIOException = false;
@@ -352,7 +481,7 @@ public A2AHttpResponse get() throws IOException, InterruptedException {
352481
if (throwInterruptedException) {
353482
throw new InterruptedException("Simulated interrupt");
354483
}
355-
int effectiveStatus = status;
484+
int effectiveStatus = statusSequence.isEmpty() ? status : statusSequence.remove(0);
356485
return new A2AHttpResponse() {
357486
@Override
358487
public int status() {

0 commit comments

Comments
 (0)