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
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,22 @@ metadata and are never recursively removed or rewritten by the CLI.

## Run idempotency

Use an explicit idempotency key when a run may need to be repeated without another charge:
The CLI generates a fresh random idempotency key for every logical invocation. Use an explicit
key when a run may need to be resumed manually without another charge:

```sh
anyapi run reddit.search --input '{"query":"anyapi"}' --idempotency-key k1
```

Repeating the same request with the same key returns the original result without another charge once the gateway supports idempotency. Keys must be 1 to 255 visible ASCII characters. The CLI does not generate a key unless you explicitly opt in with `auto`:
Repeating the same request with the same key returns the original result without another charge.
Keys must be 1 to 255 visible ASCII characters. You can explicitly request another random key with
`auto`:

```sh
anyapi run reddit.search --input '{"query":"anyapi"}' --idempotency-key auto
```

`auto` derives a deterministic key from the SKU, canonical JSON input, and current UTC date. Equivalent JSON formatting and property order produce the same key and request body during that day. Use `auto` to protect against accidental same-day reruns, but omit it when repeated runs are intentional.
`auto` creates a random key for that invocation. It does not deduplicate a separate CLI command.

If a key is already running, the CLI asks you to retry shortly with the same key. If a key was used with a different SKU or input, the CLI asks you to use a new key or retry the original request.

Expand Down
237 changes: 156 additions & 81 deletions __tests__/run.test.ts
Original file line number Diff line number Diff line change
@@ -1,124 +1,176 @@
import { join } from 'node:path';
import { PassThrough } from 'node:stream';
import { describe, expect, it } from 'vitest';
import { AnyApiClient } from '../src/api.js';
import { runCommand } from '../src/commands.js';
import { ApiError } from '../src/errors.js';
import { join } from "node:path";
import { PassThrough } from "node:stream";
import { Command } from "commander";
import { describe, expect, it } from "vitest";
import { AnyApiClient } from "../src/api.js";
import { runCommand } from "../src/commands.js";
import { ApiError } from "../src/errors.js";
import {
buildRunOutputPath,
formatIdempotencyError,
formatTrialCapMessage,
isTrialCapReached,
normalizeRunCLIOptions,
parseRunInput,
prepareRunIdempotency,
} from '../src/run.js';
import type { CommandContext } from '../src/io.js';
import type { FetchLike } from '../src/types.js';
} from "../src/run.js";
import type { CommandContext } from "../src/io.js";
import type { FetchLike } from "../src/types.js";

describe('run idempotency', () => {
it('preserves customer output fields without recursive rewriting', async () => {
describe("run idempotency", () => {
it("maps Commander negated --no-wait into the transport option", () => {
const command = new Command().exitOverride().option("--no-wait");
command.parse(["node", "test", "--no-wait"]);
expect(normalizeRunCLIOptions(command.opts()).noWait).toBe(true);
});

it("preserves customer output fields without recursive rewriting", async () => {
const responseBody = {
output: {
found: true,
data: {
creditScore: 812,
provider: 'source named by the customer API',
providers: ['first source', 'second source'],
nested: { provider: { name: 'structured provider value' } },
provider: "source named by the customer API",
providers: ["first source", "second source"],
nested: { provider: { name: "structured provider value" } },
},
},
provider: 'AnyAPI',
provider: "AnyAPI",
costUsd: 0.01,
items: 1,
};
const ctx = commandContext(async () => Response.json(responseBody));

await runCommand(ctx, { apiKey: 'aa_live_test' }, 'finance.profile', {
input: '{}',
await runCommand(ctx, { apiKey: "aa_live_test" }, "finance.profile", {
input: "{}",
json: true,
});

const stdout = ctx.stdout.read()?.toString().trim();
expect(JSON.parse(stdout)).toEqual(responseBody);
});

it('preserves balance response fields without recursive rewriting', async () => {
it("preserves balance response fields without recursive rewriting", async () => {
const responseBody = {
balanceUsd: 1.25,
creditScore: 812,
provider: 'account-data-source',
providers: ['account-data-source'],
provider: "account-data-source",
providers: ["account-data-source"],
};
const client = new AnyApiClient({
apiKey: 'aa_live_test',
apiKey: "aa_live_test",
fetchImpl: async () => Response.json(responseBody),
restBaseUrl: 'https://example.test/v1',
restBaseUrl: "https://example.test/v1",
});

await expect(client.balance()).resolves.toEqual(responseBody);
});

it('passes the command flag through to the idempotency key header', async () => {
it("passes the command flag through to the idempotency key header", async () => {
let requestInit: RequestInit | undefined;
const fetchImpl: FetchLike = async (_input, init) => {
requestInit = init;
return Response.json({ output: {}, provider: 'AnyAPI', costUsd: 0.01, items: 1 });
return Response.json({
output: {},
provider: "AnyAPI",
costUsd: 0.01,
items: 1,
});
};

await runCommand(commandContext(fetchImpl), { apiKey: 'aa_live_test' }, 'reddit.search', {
input: '{"query":"anyapi"}',
idempotencyKey: 'k1',
json: true,
});
await runCommand(
commandContext(fetchImpl),
{ apiKey: "aa_live_test" },
"reddit.search",
{
input: '{"query":"anyapi"}',
idempotencyKey: "k1",
json: true,
},
);

expect(new Headers(requestInit?.headers).get('Idempotency-Key')).toBe('k1');
expect(new Headers(requestInit?.headers).get("Idempotency-Key")).toBe("k1");
});

it('omits the idempotency key header when the flag is absent', async () => {
it("generates a fresh idempotency key when the flag is absent", async () => {
let requestInit: RequestInit | undefined;
const fetchImpl: FetchLike = async (_input, init) => {
requestInit = init;
return Response.json({ output: {}, provider: 'AnyAPI', costUsd: 0.01, items: 1 });
return Response.json({
output: {},
provider: "AnyAPI",
costUsd: 0.01,
items: 1,
});
};

await runCommand(commandContext(fetchImpl), { apiKey: 'aa_live_test' }, 'reddit.search', {
input: '{"query":"anyapi"}',
json: true,
});
await runCommand(
commandContext(fetchImpl),
{ apiKey: "aa_live_test" },
"reddit.search",
{
input: '{"query":"anyapi"}',
json: true,
},
);

expect(new Headers(requestInit?.headers).has('Idempotency-Key')).toBe(false);
expect(new Headers(requestInit?.headers).get("Idempotency-Key")).toMatch(
/^anyapi-auto-/,
);
});

it('derives the same auto key and request body from equivalent JSON input', async () => {
it("derives a fresh auto key while keeping equivalent request bodies stable", async () => {
const firstInput = await parseRunInput({
input: '{"query":"anyapi","filters":{"sort":"new","limit":5}}',
});
const secondInput = await parseRunInput({
input: '{ "filters": { "limit": 5, "sort": "new" }, "query": "anyapi" }',
});
const date = new Date('2026-07-27T12:00:00Z');
const date = new Date("2026-07-27T12:00:00Z");

const first = prepareRunIdempotency('reddit.search', firstInput, 'auto', date);
const second = prepareRunIdempotency('reddit.search', secondInput, 'auto', date);
const first = prepareRunIdempotency(
"reddit.search",
firstInput,
"auto",
date,
);
const second = prepareRunIdempotency(
"reddit.search",
secondInput,
"auto",
date,
);

expect(second.idempotencyKey).toBe(first.idempotencyKey);
expect(second.idempotencyKey).not.toBe(first.idempotencyKey);
expect(JSON.stringify(second.input)).toBe(JSON.stringify(first.input));
expect(first.idempotencyKey).toMatch(/^anyapi-auto-[a-f0-9]{64}$/);
expect(first.idempotencyKey).toMatch(/^anyapi-auto-[a-f0-9-]{36}$/);
expect(second.idempotencyKey).toMatch(/^anyapi-auto-[a-f0-9-]{36}$/);
});

it('rejects explicit keys outside the gateway wire format', () => {
expect(() => prepareRunIdempotency('reddit.search', {}, '')).toThrow('1 to 255 visible ASCII');
expect(() => prepareRunIdempotency('reddit.search', {}, 'contains space')).toThrow('1 to 255 visible ASCII');
expect(() => prepareRunIdempotency('reddit.search', {}, 'ends-with-newline\n')).toThrow('1 to 255 visible ASCII');
expect(() => prepareRunIdempotency('reddit.search', {}, 'x'.repeat(256))).toThrow('1 to 255 visible ASCII');
expect(prepareRunIdempotency('reddit.search', {}, 'x'.repeat(255)).idempotencyKey).toHaveLength(255);
it("rejects explicit keys outside the gateway wire format", () => {
expect(() => prepareRunIdempotency("reddit.search", {}, "")).toThrow(
"1 to 255 visible ASCII",
);
expect(() =>
prepareRunIdempotency("reddit.search", {}, "contains space"),
).toThrow("1 to 255 visible ASCII");
expect(() =>
prepareRunIdempotency("reddit.search", {}, "ends-with-newline\n"),
).toThrow("1 to 255 visible ASCII");
expect(() =>
prepareRunIdempotency("reddit.search", {}, "x".repeat(256)),
).toThrow("1 to 255 visible ASCII");
expect(
prepareRunIdempotency("reddit.search", {}, "x".repeat(255))
.idempotencyKey,
).toHaveLength(255);
});
});

function commandContext(fetchImpl: FetchLike): CommandContext {
return {
cwd: '/tmp',
homeDir: '/tmp',
cwd: "/tmp",
homeDir: "/tmp",
env: {},
stdin: new PassThrough(),
stdout: new PassThrough(),
Expand All @@ -127,68 +179,91 @@ function commandContext(fetchImpl: FetchLike): CommandContext {
};
}

describe('run output paths', () => {
it('uses sku and a file-safe ISO timestamp under .anyapi', () => {
const path = buildRunOutputPath('reddit.search', new Date('2026-07-05T19:20:30.456Z'), '/tmp/project');
expect(path).toBe(join('/tmp/project', '.anyapi', 'reddit.search-2026-07-05T19-20-30-456Z.json'));
describe("run output paths", () => {
it("uses sku and a file-safe ISO timestamp under .anyapi", () => {
const path = buildRunOutputPath(
"reddit.search",
new Date("2026-07-05T19:20:30.456Z"),
"/tmp/project",
);
expect(path).toBe(
join(
"/tmp/project",
".anyapi",
"reddit.search-2026-07-05T19-20-30-456Z.json",
),
);
});

it('replaces unsafe sku characters', () => {
const path = buildRunOutputPath('web/scrape test', new Date('2026-07-05T19:20:30.456Z'), '/tmp/project');
expect(path.endsWith('web_scrape_test-2026-07-05T19-20-30-456Z.json')).toBe(true);
it("replaces unsafe sku characters", () => {
const path = buildRunOutputPath(
"web/scrape test",
new Date("2026-07-05T19:20:30.456Z"),
"/tmp/project",
);
expect(path.endsWith("web_scrape_test-2026-07-05T19-20-30-456Z.json")).toBe(
true,
);
});
});

describe('402 handling', () => {
it('detects trial cap errors and relays the server upgrade guidance', async () => {
describe("402 handling", () => {
it("detects trial cap errors and relays the server upgrade guidance", async () => {
const fetchImpl: FetchLike = async () =>
new Response(
JSON.stringify({ error: 'trial_cap_reached', message: 'Trial budget used up; run anyapi connect.' }),
{ status: 402, headers: { 'Content-Type': 'application/json' } },
JSON.stringify({
error: "trial_cap_reached",
message: "Trial budget used up; run anyapi connect.",
}),
{ status: 402, headers: { "Content-Type": "application/json" } },
);
const client = new AnyApiClient({
apiKey: 'aa_live_test',
apiKey: "aa_live_test",
fetchImpl,
restBaseUrl: 'https://example.test/v1',
restBaseUrl: "https://example.test/v1",
});

try {
await client.run('reddit.search', { query: 'anyapi' });
throw new Error('Expected run to fail');
await client.run("reddit.search", { query: "anyapi" });
throw new Error("Expected run to fail");
} catch (error) {
expect(error).toBeInstanceOf(ApiError);
expect(isTrialCapReached(error)).toBe(true);
expect(formatTrialCapMessage(error)).toBe('Trial budget used up; run anyapi connect.');
expect(formatTrialCapMessage(error)).toBe(
"Trial budget used up; run anyapi connect.",
);
}
});

it('falls back to a connect nudge when the 402 body has no message', () => {
const error = new ApiError('trial_cap_reached', 402, { error: 'trial_cap_reached' });
it("falls back to a connect nudge when the 402 body has no message", () => {
const error = new ApiError("trial_cap_reached", 402, {
error: "trial_cap_reached",
});
expect(isTrialCapReached(error)).toBe(true);
expect(formatTrialCapMessage(error)).toContain('anyapi connect');
expect(formatTrialCapMessage(error)).toContain("anyapi connect");
});
});

describe('409 idempotency handling', () => {
it('explains when the key belongs to a different request using the error code', () => {
const error = new ApiError('Original request is still running.', 409, {
error: 'Original request is still running.',
code: 'idempotency_conflict',
describe("409 idempotency handling", () => {
it("explains when the key belongs to a different request using the error code", () => {
const error = new ApiError("Original request is still running.", 409, {
error: "Original request is still running.",
code: "idempotency_conflict",
});

expect(formatIdempotencyError(error)).toBe(
'This idempotency key was already used for a different request. Use a new key, or retry with the original SKU and input.',
"This idempotency key was already used for a different request. Use a new key, or retry with the original SKU and input.",
);
});

it('explains when the original request is still running using the error code', () => {
const error = new ApiError('This key belongs to another request.', 409, {
error: 'This key belongs to another request.',
code: 'idempotency_in_progress',
it("explains when the original request is still running using the error code", () => {
const error = new ApiError("This key belongs to another request.", 409, {
error: "This key belongs to another request.",
code: "idempotency_in_progress",
});

expect(formatIdempotencyError(error)).toBe(
'The original request for this idempotency key is still running. Retry shortly with the same key.',
"The original request for this idempotency key is still running. Retry shortly with the same key.",
);
});
});
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "anyapi-cli",
"version": "0.5.0",
"version": "0.6.0",
"description": "Official CLI for AnyAPI, a unified marketplace for scraping and data APIs.",
"type": "module",
"bin": {
Expand Down
Loading
Loading