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
106 changes: 106 additions & 0 deletions contrib/examples/issue-276-rpc-timeout-budget/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Timeout budgets for policy-client deployment RPC calls

Closes #276.

`src/policy-client.ts`'s `createPolicyClient` issues `simulate`,
`deployInstance`, and `recordDeployment` requests through a shared
`req<T>()` helper that calls the injected `fetch` with no timeout at all —
a stalled network connection or a slow gateway hangs the call indefinitely,
with no way for a caller to bound how long they're willing to wait. Because
contributor changes are confined to `contrib/`, this entry provides a
self-contained, directly-portable implementation of the fix
([`policy-deploy-timeout.ts`](policy-deploy-timeout.ts)) that a maintainer
can fold into `src/policy-client.ts`.

## What changes in `policy-client.ts`

1. **A configurable timeout budget per RPC call**, not one blanket timeout
for the whole client — `simulate`, `deployInstance`, and
`recordDeployment` have different expected latencies (simulate is a dry
run; deploy/record involve on-chain interaction server-side), so each
gets its own default and its own override:

```ts
export interface PolicyDeployTimeoutBudgets {
simulate: number;
deployInstance: number;
recordDeployment: number;
}

export const DEFAULT_POLICY_DEPLOY_TIMEOUTS: PolicyDeployTimeoutBudgets = {
simulate: 10_000,
deployInstance: 30_000,
recordDeployment: 15_000,
};
```

`PolicyClientOptions` would grow an optional `timeouts?: Partial<PolicyDeployTimeoutBudgets>`
field, merged over the defaults the same way `options.timeouts` is merged
in [`createTimedPolicyDeployClient`](policy-deploy-timeout.ts).

2. **A distinct typed timeout error**, `PolicyDeployTimeoutError`, thrown via
`AbortController` + `setTimeout` inside the shared request helper. It is
deliberately **not** a `PolicyApiError` subclass:

- `PolicyApiError` means the server responded and said no — per the
existing `retryable` logic in `src/policy-types.ts`, that's sometimes
retryable (5xx, 429, 408) and sometimes not (4xx in general).
- `PolicyDeployTimeoutError` means no response was ever received — we
never learned what the server decided. A caller can choose to retry
with a longer budget, but should not conflate this with "the server
rejected the deploy."

Keeping them as separate types (rather than, say, `PolicyApiError` with
`status: 0`, which is already used for transport failures) lets a caller
`catch` and branch on `instanceof` without inspecting a status code, and
keeps "we gave up waiting" legible as its own failure mode when read out
of a stack trace or an error-tracking dashboard.

## Applying this to the real client

In `src/policy-client.ts`, the `req<T>()` helper's `doFetch(...)` call would
gain an `AbortController` scoped to the timeout for that specific call site,
with `deployInstance`/`recordDeployment`/`simulate` each passing their own
budget from `PolicyClientOptions.timeouts` (falling back to
`DEFAULT_POLICY_DEPLOY_TIMEOUTS`), exactly as shown in
[`createTimedPolicyDeployClient`](policy-deploy-timeout.ts). The
`AbortError` case is caught and re-thrown as `PolicyDeployTimeoutError`
before it can surface as an opaque `AbortError` to the caller.

## README documentation for the option

The SDK's top-level README's policy-client usage section should document
the new option next to `apiUrl`/`network`/`fetch`:

```ts
const policyClient = createPolicyClient({
apiUrl: "https://api.example.com",
network: "testnet",
// Optional per-call timeout budgets (ms) for the deployment RPC calls.
// Falls back to DEFAULT_POLICY_DEPLOY_TIMEOUTS for any field not given.
timeouts: { deployInstance: 45_000 },
});
```

## Run it

```sh
npx tsx policy-deploy-timeout.ts
```

Demonstrates a `simulate` call against a mock fetch that takes 5s to
resolve, configured with a 200ms budget — the call throws
`PolicyDeployTimeoutError` instead of hanging.

## Tests

```sh
npx vitest run contrib/examples/issue-276-rpc-timeout-budget
```

Covers: a call resolving normally inside its budget, a call timing out as
configured, budgets tracked independently per call (`simulate` timing out
doesn't affect `deployInstance`'s separate budget), unset budgets falling
back to the documented defaults, the timeout error carrying the path and
configured timeout, and a non-timeout error (e.g. DNS failure) propagating
unchanged rather than being misreported as a timeout.
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_POLICY_DEPLOY_TIMEOUTS,
PolicyDeployTimeoutError,
createTimedPolicyDeployClient,
} from "./policy-deploy-timeout";

/** A mock fetch that resolves after `delayMs` with a JSON body, respecting
* the abort signal the way the real global fetch does. */
function createDelayedJsonFetch(delayMs: number, body: unknown): typeof fetch {
return ((_url: string, init?: RequestInit) =>
new Promise((resolve, reject) => {
const timer = setTimeout(() => resolve(new Response(JSON.stringify(body))), delayMs);
init?.signal?.addEventListener("abort", () => {
clearTimeout(timer);
reject(new DOMException("The operation was aborted", "AbortError"));
});
})) as typeof fetch;
}

describe("createTimedPolicyDeployClient — timeout triggers as configured", () => {
it("simulate() resolves normally when the response arrives inside the budget", async () => {
const client = createTimedPolicyDeployClient({
apiUrl: "https://api.example.com",
fetch: createDelayedJsonFetch(10, { ok: true, minResourceFee: "100" }),
timeouts: { simulate: 200 },
});
const result = await client.simulate("policy-1", "GWALLET1");
expect(result).toEqual({ ok: true, minResourceFee: "100" });
});

it("simulate() throws PolicyDeployTimeoutError when the response exceeds its budget", async () => {
const client = createTimedPolicyDeployClient({
apiUrl: "https://api.example.com",
fetch: createDelayedJsonFetch(1000, { ok: true }),
timeouts: { simulate: 20 },
});
await expect(client.simulate("policy-1", "GWALLET1")).rejects.toThrow(PolicyDeployTimeoutError);
});

it("deployInstance() respects its own configured budget independent of simulate", async () => {
const client = createTimedPolicyDeployClient({
apiUrl: "https://api.example.com",
fetch: createDelayedJsonFetch(1000, { contractId: "C123" }),
timeouts: { simulate: 5000, deployInstance: 20 },
});
await expect(client.deployInstance("policy-1", "GWALLET1")).rejects.toThrow(
PolicyDeployTimeoutError,
);
});

it("recordDeployment() succeeds within its budget", async () => {
const client = createTimedPolicyDeployClient({
apiUrl: "https://api.example.com",
fetch: createDelayedJsonFetch(10, { policy: { id: "p1" } }),
timeouts: { recordDeployment: 500 },
});
await expect(client.recordDeployment("policy-1", "tx-hash")).resolves.toEqual({
policy: { id: "p1" },
});
});

it("falls back to DEFAULT_POLICY_DEPLOY_TIMEOUTS for any budget not overridden", async () => {
const client = createTimedPolicyDeployClient({
apiUrl: "https://api.example.com",
fetch: createDelayedJsonFetch(10, { ok: true }),
timeouts: { simulate: 50 },
});
// deployInstance/recordDeployment keep their defaults; a fast mock still
// resolves well inside DEFAULT_POLICY_DEPLOY_TIMEOUTS.deployInstance.
expect(DEFAULT_POLICY_DEPLOY_TIMEOUTS.deployInstance).toBeGreaterThan(10);
await expect(client.deployInstance("policy-1", "GWALLET1")).resolves.toBeDefined();
});

it("the timeout error names the path and configured timeout", async () => {
const client = createTimedPolicyDeployClient({
apiUrl: "https://api.example.com",
fetch: createDelayedJsonFetch(1000, {}),
timeouts: { simulate: 15 },
});
await expect(client.simulate("policy-42", "GWALLET1")).rejects.toMatchObject({
name: "PolicyDeployTimeoutError",
path: "/policy-42/simulate",
timeoutMs: 15,
});
});

it("propagates a non-timeout error unchanged", async () => {
const failingFetch: typeof fetch = (async () => {
throw new Error("DNS failure");
}) as typeof fetch;
const client = createTimedPolicyDeployClient({
apiUrl: "https://api.example.com",
fetch: failingFetch,
});
await expect(client.simulate("policy-1", "GWALLET1")).rejects.toThrow("DNS failure");
});
});
153 changes: 153 additions & 0 deletions contrib/examples/issue-276-rpc-timeout-budget/policy-deploy-timeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Example: add a configurable timeout budget to the policy-deployment RPC
// calls in src/policy-client.ts (`simulate`, `deployInstance`,
// `recordDeployment`), which today have no explicit timeout and can hang
// indefinitely on a stalled network request.
//
// This mirrors src/policy-client.ts's own `req<T>()` helper shape (base URL,
// injected fetch, PolicyApiError on non-2xx) but adds an AbortController-based
// timeout with a distinct error type, so a caller can tell "the server said
// no" (PolicyApiError) apart from "we gave up waiting" (PolicyDeployTimeoutError)
// and react differently — the former is not safely retryable in general, the
// latter usually is.
//
// Run with: npx tsx policy-deploy-timeout.ts

/** Thrown when a policy-deployment RPC call exceeds its timeout budget.
* Deliberately NOT a PolicyApiError subclass — a timeout means we never
* learned what the server decided, which is a different situation than a
* server response we didn't like. */
export class PolicyDeployTimeoutError extends Error {
readonly path: string;
readonly timeoutMs: number;

constructor(path: string, timeoutMs: number) {
super(`Policy deployment request to "${path}" did not complete within ${timeoutMs}ms`);
this.name = "PolicyDeployTimeoutError";
this.path = path;
this.timeoutMs = timeoutMs;
}
}

/** Per-call timeout budgets (ms) for each deployment-path RPC call. Deploy
* and record involve on-chain interaction on the server side and are given
* more room than the lighter simulate call. Every value is overridable. */
export interface PolicyDeployTimeoutBudgets {
simulate: number;
deployInstance: number;
recordDeployment: number;
}

export const DEFAULT_POLICY_DEPLOY_TIMEOUTS: PolicyDeployTimeoutBudgets = {
simulate: 10_000,
deployInstance: 30_000,
recordDeployment: 15_000,
};

export interface TimedPolicyDeployClientOptions {
apiUrl: string;
fetch?: typeof fetch;
timeouts?: Partial<PolicyDeployTimeoutBudgets>;
}

/** Minimal stand-ins for the real GeneratedPolicy/SimulateResult shapes
* (src/policy-types.ts) — kept local so this example has no dependency on
* src/, per the contrib sandbox rules. */
export interface SimulateResultLike {
ok: boolean;
minResourceFee?: string;
error?: string;
}

/**
* A timeout-aware wrapper around the policy-deployment RPC calls
* (`simulate`, `deployInstance`, `recordDeployment`), demonstrating the
* budget-per-call pattern that should be applied to `createPolicyClient` in
* src/policy-client.ts.
*/
export function createTimedPolicyDeployClient(options: TimedPolicyDeployClientOptions) {
const base = options.apiUrl.replace(/\/+$/, "");
const doFetch = options.fetch ?? fetch;
const budgets: PolicyDeployTimeoutBudgets = {
...DEFAULT_POLICY_DEPLOY_TIMEOUTS,
...options.timeouts,
};

async function reqWithTimeout<T>(path: string, timeoutMs: number, init?: RequestInit): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await doFetch(`${base}/policies${path}`, {
headers: init?.body ? { "content-type": "application/json" } : undefined,
...init,
signal: controller.signal,
});
const payload = (await res.json().catch(() => ({}))) as { message?: string; error?: string } & T;
if (!res.ok) {
throw new Error(payload.message ?? payload.error ?? `Request failed (${res.status})`);
}
return payload;
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
throw new PolicyDeployTimeoutError(path, timeoutMs);
}
throw err;
} finally {
clearTimeout(timer);
}
}

return {
simulate(policyId: string, wallet: string): Promise<SimulateResultLike> {
return reqWithTimeout<SimulateResultLike>(`/${policyId}/simulate`, budgets.simulate, {
method: "POST",
body: JSON.stringify({ wallet }),
});
},
async deployInstance(policyId: string, wallet: string): Promise<{ contractId: string }> {
const { contractId } = await reqWithTimeout<{ contractId: string }>(
`/${policyId}/deploy-instance`,
budgets.deployInstance,
{ method: "POST", body: JSON.stringify({ wallet }) },
);
return { contractId };
},
recordDeployment(policyId: string, txHash: string, contractId?: string): Promise<unknown> {
return reqWithTimeout(`/deploy`, budgets.recordDeployment, {
method: "POST",
body: JSON.stringify({ policyId, txHash, contractId }),
});
},
};
}

async function main() {
const slowFetch: typeof fetch = (async (_url: string, init?: RequestInit) => {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => resolve(new Response(JSON.stringify({ ok: true }))), 5000);
init?.signal?.addEventListener("abort", () => {
clearTimeout(timer);
reject(new DOMException("The operation was aborted", "AbortError"));
});
});
}) as typeof fetch;

const client = createTimedPolicyDeployClient({
apiUrl: "https://api.example.com",
fetch: slowFetch,
timeouts: { simulate: 200 },
});

try {
await client.simulate("policy-1", "GWALLET...");
} catch (err) {
if (err instanceof PolicyDeployTimeoutError) {
console.log(`Timed out as expected: ${err.message}`);
} else {
throw err;
}
}
}

if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
Loading
Loading