Skip to content

Commit 5334bb6

Browse files
authored
feat: support durable AnyAPI requests (#11)
1 parent 13fd079 commit 5334bb6

10 files changed

Lines changed: 483 additions & 203 deletions

File tree

‎README.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,19 +63,22 @@ metadata and are never recursively removed or rewritten by the CLI.
6363

6464
## Run idempotency
6565

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

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

72-
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`:
73+
Repeating the same request with the same key returns the original result without another charge.
74+
Keys must be 1 to 255 visible ASCII characters. You can explicitly request another random key with
75+
`auto`:
7376

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

78-
`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.
81+
`auto` creates a random key for that invocation. It does not deduplicate a separate CLI command.
7982

8083
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.
8184

‎__tests__/run.test.ts‎

Lines changed: 156 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,124 +1,176 @@
1-
import { join } from 'node:path';
2-
import { PassThrough } from 'node:stream';
3-
import { describe, expect, it } from 'vitest';
4-
import { AnyApiClient } from '../src/api.js';
5-
import { runCommand } from '../src/commands.js';
6-
import { ApiError } from '../src/errors.js';
1+
import { join } from "node:path";
2+
import { PassThrough } from "node:stream";
3+
import { Command } from "commander";
4+
import { describe, expect, it } from "vitest";
5+
import { AnyApiClient } from "../src/api.js";
6+
import { runCommand } from "../src/commands.js";
7+
import { ApiError } from "../src/errors.js";
78
import {
89
buildRunOutputPath,
910
formatIdempotencyError,
1011
formatTrialCapMessage,
1112
isTrialCapReached,
13+
normalizeRunCLIOptions,
1214
parseRunInput,
1315
prepareRunIdempotency,
14-
} from '../src/run.js';
15-
import type { CommandContext } from '../src/io.js';
16-
import type { FetchLike } from '../src/types.js';
16+
} from "../src/run.js";
17+
import type { CommandContext } from "../src/io.js";
18+
import type { FetchLike } from "../src/types.js";
1719

18-
describe('run idempotency', () => {
19-
it('preserves customer output fields without recursive rewriting', async () => {
20+
describe("run idempotency", () => {
21+
it("maps Commander negated --no-wait into the transport option", () => {
22+
const command = new Command().exitOverride().option("--no-wait");
23+
command.parse(["node", "test", "--no-wait"]);
24+
expect(normalizeRunCLIOptions(command.opts()).noWait).toBe(true);
25+
});
26+
27+
it("preserves customer output fields without recursive rewriting", async () => {
2028
const responseBody = {
2129
output: {
2230
found: true,
2331
data: {
2432
creditScore: 812,
25-
provider: 'source named by the customer API',
26-
providers: ['first source', 'second source'],
27-
nested: { provider: { name: 'structured provider value' } },
33+
provider: "source named by the customer API",
34+
providers: ["first source", "second source"],
35+
nested: { provider: { name: "structured provider value" } },
2836
},
2937
},
30-
provider: 'AnyAPI',
38+
provider: "AnyAPI",
3139
costUsd: 0.01,
3240
items: 1,
3341
};
3442
const ctx = commandContext(async () => Response.json(responseBody));
3543

36-
await runCommand(ctx, { apiKey: 'aa_live_test' }, 'finance.profile', {
37-
input: '{}',
44+
await runCommand(ctx, { apiKey: "aa_live_test" }, "finance.profile", {
45+
input: "{}",
3846
json: true,
3947
});
4048

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

45-
it('preserves balance response fields without recursive rewriting', async () => {
53+
it("preserves balance response fields without recursive rewriting", async () => {
4654
const responseBody = {
4755
balanceUsd: 1.25,
4856
creditScore: 812,
49-
provider: 'account-data-source',
50-
providers: ['account-data-source'],
57+
provider: "account-data-source",
58+
providers: ["account-data-source"],
5159
};
5260
const client = new AnyApiClient({
53-
apiKey: 'aa_live_test',
61+
apiKey: "aa_live_test",
5462
fetchImpl: async () => Response.json(responseBody),
55-
restBaseUrl: 'https://example.test/v1',
63+
restBaseUrl: "https://example.test/v1",
5664
});
5765

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

61-
it('passes the command flag through to the idempotency key header', async () => {
69+
it("passes the command flag through to the idempotency key header", async () => {
6270
let requestInit: RequestInit | undefined;
6371
const fetchImpl: FetchLike = async (_input, init) => {
6472
requestInit = init;
65-
return Response.json({ output: {}, provider: 'AnyAPI', costUsd: 0.01, items: 1 });
73+
return Response.json({
74+
output: {},
75+
provider: "AnyAPI",
76+
costUsd: 0.01,
77+
items: 1,
78+
});
6679
};
6780

68-
await runCommand(commandContext(fetchImpl), { apiKey: 'aa_live_test' }, 'reddit.search', {
69-
input: '{"query":"anyapi"}',
70-
idempotencyKey: 'k1',
71-
json: true,
72-
});
81+
await runCommand(
82+
commandContext(fetchImpl),
83+
{ apiKey: "aa_live_test" },
84+
"reddit.search",
85+
{
86+
input: '{"query":"anyapi"}',
87+
idempotencyKey: "k1",
88+
json: true,
89+
},
90+
);
7391

74-
expect(new Headers(requestInit?.headers).get('Idempotency-Key')).toBe('k1');
92+
expect(new Headers(requestInit?.headers).get("Idempotency-Key")).toBe("k1");
7593
});
7694

77-
it('omits the idempotency key header when the flag is absent', async () => {
95+
it("generates a fresh idempotency key when the flag is absent", async () => {
7896
let requestInit: RequestInit | undefined;
7997
const fetchImpl: FetchLike = async (_input, init) => {
8098
requestInit = init;
81-
return Response.json({ output: {}, provider: 'AnyAPI', costUsd: 0.01, items: 1 });
99+
return Response.json({
100+
output: {},
101+
provider: "AnyAPI",
102+
costUsd: 0.01,
103+
items: 1,
104+
});
82105
};
83106

84-
await runCommand(commandContext(fetchImpl), { apiKey: 'aa_live_test' }, 'reddit.search', {
85-
input: '{"query":"anyapi"}',
86-
json: true,
87-
});
107+
await runCommand(
108+
commandContext(fetchImpl),
109+
{ apiKey: "aa_live_test" },
110+
"reddit.search",
111+
{
112+
input: '{"query":"anyapi"}',
113+
json: true,
114+
},
115+
);
88116

89-
expect(new Headers(requestInit?.headers).has('Idempotency-Key')).toBe(false);
117+
expect(new Headers(requestInit?.headers).get("Idempotency-Key")).toMatch(
118+
/^anyapi-auto-/,
119+
);
90120
});
91121

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

101-
const first = prepareRunIdempotency('reddit.search', firstInput, 'auto', date);
102-
const second = prepareRunIdempotency('reddit.search', secondInput, 'auto', date);
131+
const first = prepareRunIdempotency(
132+
"reddit.search",
133+
firstInput,
134+
"auto",
135+
date,
136+
);
137+
const second = prepareRunIdempotency(
138+
"reddit.search",
139+
secondInput,
140+
"auto",
141+
date,
142+
);
103143

104-
expect(second.idempotencyKey).toBe(first.idempotencyKey);
144+
expect(second.idempotencyKey).not.toBe(first.idempotencyKey);
105145
expect(JSON.stringify(second.input)).toBe(JSON.stringify(first.input));
106-
expect(first.idempotencyKey).toMatch(/^anyapi-auto-[a-f0-9]{64}$/);
146+
expect(first.idempotencyKey).toMatch(/^anyapi-auto-[a-f0-9-]{36}$/);
147+
expect(second.idempotencyKey).toMatch(/^anyapi-auto-[a-f0-9-]{36}$/);
107148
});
108149

109-
it('rejects explicit keys outside the gateway wire format', () => {
110-
expect(() => prepareRunIdempotency('reddit.search', {}, '')).toThrow('1 to 255 visible ASCII');
111-
expect(() => prepareRunIdempotency('reddit.search', {}, 'contains space')).toThrow('1 to 255 visible ASCII');
112-
expect(() => prepareRunIdempotency('reddit.search', {}, 'ends-with-newline\n')).toThrow('1 to 255 visible ASCII');
113-
expect(() => prepareRunIdempotency('reddit.search', {}, 'x'.repeat(256))).toThrow('1 to 255 visible ASCII');
114-
expect(prepareRunIdempotency('reddit.search', {}, 'x'.repeat(255)).idempotencyKey).toHaveLength(255);
150+
it("rejects explicit keys outside the gateway wire format", () => {
151+
expect(() => prepareRunIdempotency("reddit.search", {}, "")).toThrow(
152+
"1 to 255 visible ASCII",
153+
);
154+
expect(() =>
155+
prepareRunIdempotency("reddit.search", {}, "contains space"),
156+
).toThrow("1 to 255 visible ASCII");
157+
expect(() =>
158+
prepareRunIdempotency("reddit.search", {}, "ends-with-newline\n"),
159+
).toThrow("1 to 255 visible ASCII");
160+
expect(() =>
161+
prepareRunIdempotency("reddit.search", {}, "x".repeat(256)),
162+
).toThrow("1 to 255 visible ASCII");
163+
expect(
164+
prepareRunIdempotency("reddit.search", {}, "x".repeat(255))
165+
.idempotencyKey,
166+
).toHaveLength(255);
115167
});
116168
});
117169

118170
function commandContext(fetchImpl: FetchLike): CommandContext {
119171
return {
120-
cwd: '/tmp',
121-
homeDir: '/tmp',
172+
cwd: "/tmp",
173+
homeDir: "/tmp",
122174
env: {},
123175
stdin: new PassThrough(),
124176
stdout: new PassThrough(),
@@ -127,68 +179,91 @@ function commandContext(fetchImpl: FetchLike): CommandContext {
127179
};
128180
}
129181

130-
describe('run output paths', () => {
131-
it('uses sku and a file-safe ISO timestamp under .anyapi', () => {
132-
const path = buildRunOutputPath('reddit.search', new Date('2026-07-05T19:20:30.456Z'), '/tmp/project');
133-
expect(path).toBe(join('/tmp/project', '.anyapi', 'reddit.search-2026-07-05T19-20-30-456Z.json'));
182+
describe("run output paths", () => {
183+
it("uses sku and a file-safe ISO timestamp under .anyapi", () => {
184+
const path = buildRunOutputPath(
185+
"reddit.search",
186+
new Date("2026-07-05T19:20:30.456Z"),
187+
"/tmp/project",
188+
);
189+
expect(path).toBe(
190+
join(
191+
"/tmp/project",
192+
".anyapi",
193+
"reddit.search-2026-07-05T19-20-30-456Z.json",
194+
),
195+
);
134196
});
135197

136-
it('replaces unsafe sku characters', () => {
137-
const path = buildRunOutputPath('web/scrape test', new Date('2026-07-05T19:20:30.456Z'), '/tmp/project');
138-
expect(path.endsWith('web_scrape_test-2026-07-05T19-20-30-456Z.json')).toBe(true);
198+
it("replaces unsafe sku characters", () => {
199+
const path = buildRunOutputPath(
200+
"web/scrape test",
201+
new Date("2026-07-05T19:20:30.456Z"),
202+
"/tmp/project",
203+
);
204+
expect(path.endsWith("web_scrape_test-2026-07-05T19-20-30-456Z.json")).toBe(
205+
true,
206+
);
139207
});
140208
});
141209

142-
describe('402 handling', () => {
143-
it('detects trial cap errors and relays the server upgrade guidance', async () => {
210+
describe("402 handling", () => {
211+
it("detects trial cap errors and relays the server upgrade guidance", async () => {
144212
const fetchImpl: FetchLike = async () =>
145213
new Response(
146-
JSON.stringify({ error: 'trial_cap_reached', message: 'Trial budget used up; run anyapi connect.' }),
147-
{ status: 402, headers: { 'Content-Type': 'application/json' } },
214+
JSON.stringify({
215+
error: "trial_cap_reached",
216+
message: "Trial budget used up; run anyapi connect.",
217+
}),
218+
{ status: 402, headers: { "Content-Type": "application/json" } },
148219
);
149220
const client = new AnyApiClient({
150-
apiKey: 'aa_live_test',
221+
apiKey: "aa_live_test",
151222
fetchImpl,
152-
restBaseUrl: 'https://example.test/v1',
223+
restBaseUrl: "https://example.test/v1",
153224
});
154225

155226
try {
156-
await client.run('reddit.search', { query: 'anyapi' });
157-
throw new Error('Expected run to fail');
227+
await client.run("reddit.search", { query: "anyapi" });
228+
throw new Error("Expected run to fail");
158229
} catch (error) {
159230
expect(error).toBeInstanceOf(ApiError);
160231
expect(isTrialCapReached(error)).toBe(true);
161-
expect(formatTrialCapMessage(error)).toBe('Trial budget used up; run anyapi connect.');
232+
expect(formatTrialCapMessage(error)).toBe(
233+
"Trial budget used up; run anyapi connect.",
234+
);
162235
}
163236
});
164237

165-
it('falls back to a connect nudge when the 402 body has no message', () => {
166-
const error = new ApiError('trial_cap_reached', 402, { error: 'trial_cap_reached' });
238+
it("falls back to a connect nudge when the 402 body has no message", () => {
239+
const error = new ApiError("trial_cap_reached", 402, {
240+
error: "trial_cap_reached",
241+
});
167242
expect(isTrialCapReached(error)).toBe(true);
168-
expect(formatTrialCapMessage(error)).toContain('anyapi connect');
243+
expect(formatTrialCapMessage(error)).toContain("anyapi connect");
169244
});
170245
});
171246

172-
describe('409 idempotency handling', () => {
173-
it('explains when the key belongs to a different request using the error code', () => {
174-
const error = new ApiError('Original request is still running.', 409, {
175-
error: 'Original request is still running.',
176-
code: 'idempotency_conflict',
247+
describe("409 idempotency handling", () => {
248+
it("explains when the key belongs to a different request using the error code", () => {
249+
const error = new ApiError("Original request is still running.", 409, {
250+
error: "Original request is still running.",
251+
code: "idempotency_conflict",
177252
});
178253

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

184-
it('explains when the original request is still running using the error code', () => {
185-
const error = new ApiError('This key belongs to another request.', 409, {
186-
error: 'This key belongs to another request.',
187-
code: 'idempotency_in_progress',
259+
it("explains when the original request is still running using the error code", () => {
260+
const error = new ApiError("This key belongs to another request.", 409, {
261+
error: "This key belongs to another request.",
262+
code: "idempotency_in_progress",
188263
});
189264

190265
expect(formatIdempotencyError(error)).toBe(
191-
'The original request for this idempotency key is still running. Retry shortly with the same key.',
266+
"The original request for this idempotency key is still running. Retry shortly with the same key.",
192267
);
193268
});
194269
});

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "anyapi-cli",
3-
"version": "0.5.0",
3+
"version": "0.6.0",
44
"description": "Official CLI for AnyAPI, a unified marketplace for scraping and data APIs.",
55
"type": "module",
66
"bin": {

0 commit comments

Comments
 (0)