diff --git a/samples/client/lit/shell/a2a-client-factory.ts b/samples/client/lit/shell/a2a-client-factory.ts new file mode 100644 index 000000000..149bd9b38 --- /dev/null +++ b/samples/client/lit/shell/a2a-client-factory.ts @@ -0,0 +1,103 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { A2AClient } from "@a2a-js/sdk/client"; + +const A2UI_EXTENSION_HEADER = + "https://a2ui.org/a2a-extension/a2ui/v0.8"; + +export interface AgentCardRetryOptions { + attempts?: number; + initialDelayMs?: number; + maxDelayMs?: number; + backoffMultiplier?: number; + fetchImpl?: typeof fetch; + clientFactory?: typeof A2AClient.fromCardUrl; + sleep?: (ms: number) => Promise; +} + +const DEFAULT_AGENT_CARD_RETRY_OPTIONS: Required< + Pick +> = { + attempts: 8, + initialDelayMs: 250, + maxDelayMs: 2_000, + backoffMultiplier: 2, +}; + +const sleep = async (ms: number) => { + await new Promise((resolve) => setTimeout(resolve, ms)); +}; + +export const createA2UIFetch = (baseFetch: typeof fetch = fetch): typeof fetch => { + return async (url, init) => { + const headers = new Headers(init?.headers); + headers.set("X-A2A-Extensions", A2UI_EXTENSION_HEADER); + return baseFetch(url, { ...init, headers }); + }; +}; + +export const isRetryableAgentCardError = (error: unknown) => { + const message = + error instanceof Error ? error.message : typeof error === "string" ? error : ""; + + return [ + "ECONNREFUSED", + "fetch failed", + "Failed to fetch", + "NetworkError", + "network error", + ].some((needle) => message.includes(needle)); +}; + +export const createA2AClientWithRetry = async ( + cardUrl: string, + options: AgentCardRetryOptions = {} +) => { + const { + attempts = DEFAULT_AGENT_CARD_RETRY_OPTIONS.attempts, + initialDelayMs = DEFAULT_AGENT_CARD_RETRY_OPTIONS.initialDelayMs, + maxDelayMs = DEFAULT_AGENT_CARD_RETRY_OPTIONS.maxDelayMs, + backoffMultiplier = DEFAULT_AGENT_CARD_RETRY_OPTIONS.backoffMultiplier, + fetchImpl, + clientFactory = A2AClient.fromCardUrl, + sleep: sleepImpl = sleep, + } = options; + + let delayMs = initialDelayMs; + let lastError: unknown; + + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + return await clientFactory(cardUrl, { + fetchImpl: fetchImpl ?? createA2UIFetch(), + }); + } catch (error) { + lastError = error; + const shouldRetry = attempt < attempts && isRetryableAgentCardError(error); + if (!shouldRetry) { + throw error; + } + + await sleepImpl(delayMs); + delayMs = Math.min(maxDelayMs, Math.round(delayMs * backoffMultiplier)); + } + } + + throw lastError instanceof Error + ? lastError + : new Error(`Failed to connect to agent card: ${cardUrl}`); +}; diff --git a/samples/client/lit/shell/app.ts b/samples/client/lit/shell/app.ts index 34e1855b8..7c6236d4c 100644 --- a/samples/client/lit/shell/app.ts +++ b/samples/client/lit/shell/app.ts @@ -429,7 +429,10 @@ export class A2UILayoutEditor extends SignalWatcher(LitElement) { #maybeRenderData() { if (this.#requesting) { - let text = "Awaiting an answer..."; + let text = + this.#lastMessages.length === 0 + ? "Waiting for the agent to start..." + : "Awaiting an answer..."; if (this.config.loadingText) { if (Array.isArray(this.config.loadingText)) { text = this.config.loadingText[this.#loadingTextIndex]; diff --git a/samples/client/lit/shell/client.ts b/samples/client/lit/shell/client.ts index 165da4df4..07abc86bf 100644 --- a/samples/client/lit/shell/client.ts +++ b/samples/client/lit/shell/client.ts @@ -17,6 +17,7 @@ import { Part, SendMessageSuccessResponse, Task } from "@a2a-js/sdk"; import { A2AClient } from "@a2a-js/sdk/client"; import { v0_8 } from "@a2ui/lit"; +import { createA2AClientWithRetry } from "./a2a-client-factory.js"; const A2UI_MIME_TYPE = "application/json+a2ui"; @@ -38,15 +39,8 @@ export class A2UIClient { // Default to localhost:10002 if no URL provided (fallback for restaurant app default) const baseUrl = this.#serverUrl || "http://localhost:10002"; - this.#client = await A2AClient.fromCardUrl( - `${baseUrl}/.well-known/agent-card.json`, - { - fetchImpl: async (url, init) => { - const headers = new Headers(init?.headers); - headers.set("X-A2A-Extensions", "https://a2ui.org/a2a-extension/a2ui/v0.8"); - return fetch(url, { ...init, headers }); - } - } + this.#client = await createA2AClientWithRetry( + `${baseUrl}/.well-known/agent-card.json` ); } return this.#client; diff --git a/samples/client/lit/shell/middleware/a2a.ts b/samples/client/lit/shell/middleware/a2a.ts index c58c602aa..9c8577186 100644 --- a/samples/client/lit/shell/middleware/a2a.ts +++ b/samples/client/lit/shell/middleware/a2a.ts @@ -24,16 +24,11 @@ import { Task, } from "@a2a-js/sdk"; import { v4 as uuidv4 } from "uuid"; +import { createA2AClientWithRetry, createA2UIFetch } from "../a2a-client-factory.js"; const A2UI_MIME_TYPE = "application/json+a2ui"; -const fetchWithCustomHeader: typeof fetch = async (url, init) => { - const headers = new Headers(init?.headers); - headers.set("X-A2A-Extensions", "https://a2ui.org/a2a-extension/a2ui/v0.8"); - - const newInit = { ...init, headers }; - return fetch(url, newInit); -}; +const fetchWithCustomHeader = createA2UIFetch(); const isJson = (str: string) => { try { @@ -51,7 +46,7 @@ let client: A2AClient | null = null; const createOrGetClient = async () => { if (!client) { // Create a client pointing to the agent's Agent Card URL. - client = await A2AClient.fromCardUrl( + client = await createA2AClientWithRetry( "http://localhost:10002/.well-known/agent-card.json", { fetchImpl: fetchWithCustomHeader } ); diff --git a/samples/client/lit/shell/tests/client-factory.test.ts b/samples/client/lit/shell/tests/client-factory.test.ts new file mode 100644 index 000000000..2aada7b5c --- /dev/null +++ b/samples/client/lit/shell/tests/client-factory.test.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { + createA2AClientWithRetry, + isRetryableAgentCardError, +} from "../a2a-client-factory.js"; + +test("createA2AClientWithRetry retries connection errors until success", async () => { + let attempts = 0; + const delays: number[] = []; + const fakeClient = { kind: "client" }; + + const result = await createA2AClientWithRetry( + "http://localhost:10002/.well-known/agent-card.json", + { + attempts: 4, + initialDelayMs: 10, + maxDelayMs: 20, + backoffMultiplier: 2, + sleep: async (ms) => { + delays.push(ms); + }, + clientFactory: async () => { + attempts += 1; + if (attempts < 3) { + throw new TypeError("fetch failed: ECONNREFUSED"); + } + return fakeClient as never; + }, + } + ); + + assert.equal(result, fakeClient); + assert.equal(attempts, 3); + assert.deepEqual(delays, [10, 20]); +}); + +test("createA2AClientWithRetry does not retry non-network errors", async () => { + let attempts = 0; + + await assert.rejects( + createA2AClientWithRetry("http://localhost:10002/.well-known/agent-card.json", { + attempts: 4, + sleep: async () => {}, + clientFactory: async () => { + attempts += 1; + throw new Error("invalid agent card payload"); + }, + }), + /invalid agent card payload/ + ); + + assert.equal(attempts, 1); +}); + +test("isRetryableAgentCardError matches common startup failures", () => { + assert.equal(isRetryableAgentCardError(new Error("fetch failed")), true); + assert.equal(isRetryableAgentCardError(new Error("socket ECONNREFUSED")), true); + assert.equal(isRetryableAgentCardError(new Error("invalid agent card payload")), false); +});