Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 43 additions & 29 deletions sdk/typescript/src/client/AgentFieldClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand All @@ -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<void> {
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. */
Expand Down Expand Up @@ -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<DiscoveryResult> {
Expand Down Expand Up @@ -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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
Expand Down
72 changes: 72 additions & 0 deletions sdk/typescript/tests/agentfield_client_async.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<void>((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);
});
});
3 changes: 2 additions & 1 deletion sdk/typescript/tests/agentfield_client_coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,8 @@ describe('AgentFieldClient additional coverage', () => {
{
headers: expect.objectContaining({
'Content-Type': 'application/json'
})
}),
timeout: 30000
}
);
});
Expand Down
Loading