From 448f2629ae76be1cbaaa0e6733a9166335f4efdf Mon Sep 17 00:00:00 2001 From: ShryukGrandhi <85529990+ShryukGrandhi@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:04:29 -0700 Subject: [PATCH] fix(sdk/typescript): stop retrying permanent status callbacks Align reportExecutionResult and updateExecutionStatus with the Go SDK's postExecutionStatus: retry transport errors, 408, 429 and 5xx with 1+2+4+8s backoff, and fail fast on any other status (e.g. a 409 terminal-status conflict). Each attempt is signed with a fresh DID timestamp/nonce instead of reusing one signature across retries. Fixes #1088 Signed-off-by: ShryukGrandhi <85529990+ShryukGrandhi@users.noreply.github.com> --- sdk/typescript/src/client/AgentFieldClient.ts | 72 +++++++++++-------- .../tests/agentfield_client_async.test.ts | 72 +++++++++++++++++++ .../tests/agentfield_client_coverage.test.ts | 3 +- 3 files changed, 117 insertions(+), 30 deletions(-) diff --git a/sdk/typescript/src/client/AgentFieldClient.ts b/sdk/typescript/src/client/AgentFieldClient.ts index 5528e2c86..8b33a3bd8 100644 --- a/sdk/typescript/src/client/AgentFieldClient.ts +++ b/sdk/typescript/src/client/AgentFieldClient.ts @@ -367,7 +367,7 @@ export class AgentFieldClient { void request; } - async updateExecutionStatus(executionId: string, update: ExecutionStatusUpdate) { + async updateExecutionStatus(executionId: string, update: ExecutionStatusUpdate, maxRetries = 5) { if (!executionId) { throw new Error('executionId is required to update workflow status'); } @@ -381,11 +381,38 @@ export class AgentFieldClient { status_reason: update.statusReason }; - const bodyStr = JSON.stringify(payload); - const authHeaders = this.didAuthenticator.signRequest(Buffer.from(bodyStr)); - await this.http.post(`/api/v1/executions/${executionId}/status`, bodyStr, { - headers: this.mergeHeaders({ 'Content-Type': 'application/json', ...authHeaders }) - }); + await this.postExecutionStatus(executionId, JSON.stringify(payload), maxRetries); + } + + /** + * POST a status update, matching the Go SDK's postExecutionStatus: retry + * transport errors, 408, 429 and 5xx with 1s, 2s, 4s, 8s backoff; any other + * status (e.g. 404 unknown execution, 409 terminal-status conflict) will not + * change on a resend, so it fails fast. Throws the last error on failure. + */ + private async postExecutionStatus(executionId: string, bodyStr: string, maxRetries: number): Promise { + let lastErr: unknown; + for (let attempt = 0; attempt < maxRetries; attempt++) { + // Sign per attempt so each retry carries a fresh DID timestamp/nonce. + const authHeaders = this.didAuthenticator.signRequest(Buffer.from(bodyStr)); + try { + await this.http.post(`/api/v1/executions/${encodeURIComponent(executionId)}/status`, bodyStr, { + headers: this.mergeHeaders({ 'Content-Type': 'application/json', ...authHeaders }), + timeout: 30000 + }); + return; + } catch (err) { + lastErr = err; + const status = axios.isAxiosError(err) ? err.response?.status : undefined; + if (status !== undefined && !isRetryableStatus(status)) { + throw err; + } + } + if (attempt < maxRetries - 1) { + await sleep(2 ** attempt * 1000); + } + } + throw lastErr; } /** Build the `X-*` dispatch headers shared by execute / executeAsync. */ @@ -632,30 +659,12 @@ export class AgentFieldClient { if (payload.errorDetails !== undefined) body.error_details = payload.errorDetails; if (payload.usage !== undefined) body.usage = payload.usage; - const bodyStr = JSON.stringify(body); - const authHeaders = this.didAuthenticator.signRequest(Buffer.from(bodyStr)); - - for (let attempt = 0; attempt < maxRetries; attempt++) { - try { - const res = await this.http.post( - `/api/v1/executions/${encodeURIComponent(executionId)}/status`, - bodyStr, - { - headers: this.mergeHeaders({ 'Content-Type': 'application/json', ...authHeaders }), - timeout: 30000 - } - ); - if (res.status >= 200 && res.status < 300) { - return true; - } - } catch { - // fall through to backoff/retry - } - if (attempt < maxRetries - 1) { - await sleep(2 ** attempt * 1000); - } + try { + await this.postExecutionStatus(executionId, JSON.stringify(body), maxRetries); + return true; + } catch { + return false; } - return false; } async discoverCapabilities(options: DiscoveryOptions = {}): Promise { @@ -886,6 +895,11 @@ export class AgentFieldClient { } } +/** Statuses worth resending a status callback for (Go `isTransientPollStatus`). */ +function isRetryableStatus(status: number): boolean { + return status === 408 || status === 429 || status >= 500; +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/sdk/typescript/tests/agentfield_client_async.test.ts b/sdk/typescript/tests/agentfield_client_async.test.ts index 740dc9bf3..fbeecd201 100644 --- a/sdk/typescript/tests/agentfield_client_async.test.ts +++ b/sdk/typescript/tests/agentfield_client_async.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, afterEach } from 'vitest'; +import crypto from 'node:crypto'; import http from 'node:http'; import express from 'express'; import type { AddressInfo } from 'node:net'; @@ -210,4 +211,75 @@ describe('AgentFieldClient.reportExecutionResult', () => { expect(ok).toBe(false); expect(recs.length).toBe(2); }); + + it.each([400, 401, 403, 404, 409, 422])( + 'does not retry a non-retryable %i response', + async (code) => { + const { base, recs } = await startServer((req, res) => res.status(code).json({ error: 'nope' })); + const ok = await makeClient(base).reportExecutionResult('exec-1', { status: 'succeeded' }); + expect(ok).toBe(false); + expect(recs.length).toBe(1); + } + ); + + it.each([408, 429, 503])('retries a transient %i response', async (code) => { + const { base, recs } = await startServer((req, res, all) => + all.length === 1 ? res.status(code).json({ error: 'busy' }) : res.status(200).json({ ok: true }) + ); + const ok = await makeClient(base).reportExecutionResult('exec-1', { status: 'succeeded' }, 2); + expect(ok).toBe(true); + expect(recs.length).toBe(2); + }); + + it('retries transport errors', async () => { + const { base } = await startServer((req, res) => res.status(200).json({ ok: true })); + const port = new URL(base).port; + await new Promise((resolve) => server.close(() => resolve())); + const ok = await makeClient(`http://127.0.0.1:${port}`).reportExecutionResult( + 'exec-1', + { status: 'succeeded' }, + 2 + ); + expect(ok).toBe(false); + }); + + it('signs each attempt with a fresh DID nonce', async () => { + const nonces: string[] = []; + const { base } = await startServer((req, res, all) => { + nonces.push(String(req.header('X-DID-Nonce'))); + if (all.length === 1) res.status(503).json({ error: 'busy' }); + else res.status(200).json({ ok: true }); + }); + const { privateKey } = crypto.generateKeyPairSync('ed25519'); + const client = new AgentFieldClient({ + nodeId: 'node-1', + agentFieldUrl: base, + did: 'did:key:test', + privateKeyJwk: JSON.stringify(privateKey.export({ format: 'jwk' })) + }); + const ok = await client.reportExecutionResult('exec-1', { status: 'succeeded' }, 2); + expect(ok).toBe(true); + expect(nonces).toHaveLength(2); + expect(nonces[0]).not.toBe('undefined'); + expect(nonces[0]).not.toBe(nonces[1]); + }); +}); + +describe('AgentFieldClient.updateExecutionStatus', () => { + it('retries a transient 503 progress update', async () => { + const { base, recs } = await startServer((req, res, all) => + all.length === 1 ? res.status(503).json({ error: 'busy' }) : res.status(200).json({ ok: true }) + ); + await makeClient(base).updateExecutionStatus('exec-1', { status: 'running', progress: 40 }, 2); + expect(recs.length).toBe(2); + expect(recs[1].body).toMatchObject({ status: 'running', progress: 40 }); + }); + + it('throws without retrying a 409 conflict', async () => { + const { base, recs } = await startServer((req, res) => res.status(409).json({ error: 'terminal' })); + await expect( + makeClient(base).updateExecutionStatus('exec-1', { status: 'running', progress: 40 }) + ).rejects.toThrow(/409/); + expect(recs.length).toBe(1); + }); }); diff --git a/sdk/typescript/tests/agentfield_client_coverage.test.ts b/sdk/typescript/tests/agentfield_client_coverage.test.ts index 54ffb5d7d..650055a8d 100644 --- a/sdk/typescript/tests/agentfield_client_coverage.test.ts +++ b/sdk/typescript/tests/agentfield_client_coverage.test.ts @@ -407,7 +407,8 @@ describe('AgentFieldClient additional coverage', () => { { headers: expect.objectContaining({ 'Content-Type': 'application/json' - }) + }), + timeout: 30000 } ); });