@tuppence/node 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/http.d.ts ADDED
@@ -0,0 +1,71 @@
1
+ export declare const VERSION = "0.1.0";
2
+ export declare const DEFAULT_API_VERSION = "2026-09-20";
3
+ export type ClientOptions = {
4
+ /** Default: `TUPPENCE_API_URL` from the environment, else `https://api.tuppence.ai`. */
5
+ baseUrl?: string;
6
+ /** Pin the API version; default: the version your account is pinned to. */
7
+ apiVersion?: string;
8
+ /** Retries after the first attempt, for network errors, 409 in-flight, 429 and 5xx. Default 2. */
9
+ maxRetries?: number;
10
+ /** Per attempt. Default 30 000 ms. */
11
+ timeoutMs?: number;
12
+ /** Bring your own `fetch` (tests, proxies). Default: the global one (Node 18+). */
13
+ fetch?: typeof fetch;
14
+ /**
15
+ * Tuppence sometimes says something about a request that succeeded — today, that a payment is
16
+ * small enough that prepaid credit would cost less (`small_payment`). Each code is passed on
17
+ * ONCE per client, to `console.warn` unless you handle it yourself. `onNotice: () => {}`
18
+ * silences them.
19
+ */
20
+ onNotice?: (notice: {
21
+ code: string;
22
+ message: string;
23
+ }) => void;
24
+ /**
25
+ * Tuppence Connect: act as this connected account (`acct_…`) on every request — sent as the
26
+ * `Tuppence-Account` header. `tuppence.as("acct_…")` makes such a client from yours; a
27
+ * request's own `tuppenceAccount` wins.
28
+ */
29
+ tuppenceAccount?: string;
30
+ };
31
+ export type RequestOptions = {
32
+ /**
33
+ * Makes a POST safe to retry: the API does it once, however often it is sent. The SDK sets a
34
+ * fresh one per call — and reuses it across that call's own retries — unless you pass one.
35
+ */
36
+ idempotencyKey?: string;
37
+ /** Override the client's API version for this request. */
38
+ apiVersion?: string;
39
+ maxRetries?: number;
40
+ timeoutMs?: number;
41
+ /**
42
+ * Tuppence Connect: make this request as one of your connected accounts (`acct_…`), with your
43
+ * own key. Sent as the `Tuppence-Account` header — never in the path or the body.
44
+ */
45
+ tuppenceAccount?: string;
46
+ };
47
+ /**
48
+ * A body already encoded — the multipart of `files.create` — sent as these exact bytes on every
49
+ * attempt: a retry must be the same request, down to the boundary, or its key would not match.
50
+ */
51
+ export declare class EncodedBody {
52
+ readonly bytes: Uint8Array<ArrayBuffer>;
53
+ readonly contentType: string;
54
+ constructor(bytes: Uint8Array<ArrayBuffer>, contentType: string);
55
+ }
56
+ type Query = Record<string, string | number | boolean | undefined | null>;
57
+ export declare class HttpClient {
58
+ private readonly apiKey;
59
+ private readonly options;
60
+ private readonly baseUrl;
61
+ private readonly fetch;
62
+ constructor(apiKey: string, options?: ClientOptions);
63
+ request<T>(method: "GET" | "POST" | "DELETE", path: string, { body, query, ...options }?: RequestOptions & {
64
+ body?: unknown;
65
+ query?: Query;
66
+ }): Promise<T>;
67
+ private readonly noticed;
68
+ /** `code; message` — said once per code, and never twice for the same kind of request. */
69
+ private notice;
70
+ }
71
+ export {};
package/dist/http.js ADDED
@@ -0,0 +1,134 @@
1
+ import { ConnectionError, TuppenceError } from "./errors.js";
2
+ export const VERSION = "0.1.0";
3
+ export const DEFAULT_API_VERSION = "2026-09-20";
4
+ /**
5
+ * A body already encoded — the multipart of `files.create` — sent as these exact bytes on every
6
+ * attempt: a retry must be the same request, down to the boundary, or its key would not match.
7
+ */
8
+ export class EncodedBody {
9
+ bytes;
10
+ contentType;
11
+ constructor(bytes, contentType) {
12
+ this.bytes = bytes;
13
+ this.contentType = contentType;
14
+ }
15
+ }
16
+ const RETRY_BASE_MS = 500;
17
+ const RETRY_MAX_MS = 8_000;
18
+ /** Full-jitter exponential backoff (AWS): the retries of many clients do not arrive together. */
19
+ const backoff = (attempt) => Math.random() * Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** attempt);
20
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
21
+ export class HttpClient {
22
+ apiKey;
23
+ options;
24
+ baseUrl;
25
+ fetch;
26
+ constructor(apiKey, options = {}) {
27
+ this.apiKey = apiKey;
28
+ this.options = options;
29
+ if (!apiKey || typeof apiKey !== "string") {
30
+ throw new TypeError("new Tuppence(apiKey): an API key (sk_test_… or sk_live_…) is required.");
31
+ }
32
+ // `TUPPENCE_API_URL`: point code you did not write (a docs example) at a test API.
33
+ this.baseUrl = (options.baseUrl ??
34
+ globalThis.process?.env?.TUPPENCE_API_URL ??
35
+ "https://api.tuppence.ai").replace(/\/+$/, "");
36
+ const fetcher = options.fetch ?? globalThis.fetch;
37
+ if (!fetcher)
38
+ throw new TypeError("fetch is not available: use Node 18+ or pass options.fetch.");
39
+ this.fetch = fetcher;
40
+ }
41
+ async request(method, path, { body, query, ...options } = {}) {
42
+ const url = new URL(`${this.baseUrl}${path}`);
43
+ for (const [key, value] of Object.entries(query ?? {})) {
44
+ if (value !== undefined && value !== null)
45
+ url.searchParams.set(key, String(value));
46
+ }
47
+ const headers = {
48
+ Authorization: `Bearer ${this.apiKey}`,
49
+ Accept: "application/json",
50
+ "User-Agent": `tuppence-node/${VERSION}`,
51
+ };
52
+ const version = options.apiVersion ?? this.options.apiVersion;
53
+ if (version)
54
+ headers["Tuppence-Version"] = version;
55
+ const account = options.tuppenceAccount ?? this.options.tuppenceAccount;
56
+ if (account)
57
+ headers["Tuppence-Account"] = account;
58
+ const encoded = body instanceof EncodedBody ? body : undefined;
59
+ if (encoded)
60
+ headers["Content-Type"] = encoded.contentType;
61
+ else if (body !== undefined)
62
+ headers["Content-Type"] = "application/json";
63
+ // One key per CALL, reused by every retry of it — that is what makes a retry safe.
64
+ if (method === "POST")
65
+ headers["Idempotency-Key"] = options.idempotencyKey ?? crypto.randomUUID();
66
+ const retries = options.maxRetries ?? this.options.maxRetries ?? 2;
67
+ const timeoutMs = options.timeoutMs ?? this.options.timeoutMs ?? 30_000;
68
+ for (let attempt = 0;; attempt += 1) {
69
+ let response;
70
+ try {
71
+ response = await this.fetch(url, {
72
+ method,
73
+ headers,
74
+ body: encoded ? encoded.bytes : body === undefined ? undefined : JSON.stringify(body),
75
+ signal: AbortSignal.timeout(timeoutMs),
76
+ });
77
+ }
78
+ catch (error) {
79
+ if (attempt < retries) {
80
+ await sleep(backoff(attempt));
81
+ continue;
82
+ }
83
+ const timedOut = error.name === "TimeoutError";
84
+ throw new ConnectionError(timedOut
85
+ ? `No answer from Tuppence within ${timeoutMs} ms (${retries + 1} attempts).`
86
+ : `Could not reach Tuppence (${retries + 1} attempts): ${error.message}`, error);
87
+ }
88
+ if (response.ok) {
89
+ this.notice(response.headers.get("tuppence-notice"));
90
+ return (response.status === 204 ? undefined : await response.json());
91
+ }
92
+ const text = await response.text();
93
+ let error;
94
+ try {
95
+ error = JSON.parse(text).error ?? { message: text };
96
+ }
97
+ catch {
98
+ error = { message: text || `HTTP ${response.status}` };
99
+ }
100
+ error.request_id ??= response.headers.get("request-id") ?? undefined;
101
+ if (attempt < retries && retryable(response.status, error)) {
102
+ const after = Number(response.headers.get("retry-after"));
103
+ await sleep(Number.isFinite(after) && after > 0
104
+ ? Math.min(after * 1_000, RETRY_MAX_MS)
105
+ : backoff(attempt));
106
+ continue;
107
+ }
108
+ throw TuppenceError.from(error, response.status);
109
+ }
110
+ }
111
+ noticed = new Set();
112
+ /** `code; message` — said once per code, and never twice for the same kind of request. */
113
+ notice(header) {
114
+ if (!header)
115
+ return;
116
+ const [code = "", ...rest] = header.split(";");
117
+ const message = rest.join(";").trim();
118
+ if (!code || this.noticed.has(code))
119
+ return;
120
+ this.noticed.add(code);
121
+ const handler = this.options.onNotice;
122
+ if (handler)
123
+ handler({ code, message });
124
+ else
125
+ console.warn(`Tuppence: ${message || code}`);
126
+ }
127
+ }
128
+ /**
129
+ * What is worth trying again: the server's own trouble, a rate limit, and a duplicate that is
130
+ * still in flight. Never a 4xx that the same request would get again.
131
+ */
132
+ function retryable(status, error) {
133
+ return (status >= 500 || status === 429 || (status === 409 && error.code === "idempotency_in_progress"));
134
+ }