@posthaste/sdk 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/README.md +454 -0
- package/dist/client.d.ts +234 -0
- package/dist/client.js +459 -0
- package/dist/errors.d.ts +100 -0
- package/dist/errors.js +154 -0
- package/dist/http.d.ts +150 -0
- package/dist/http.js +258 -0
- package/dist/ids.d.ts +25 -0
- package/dist/ids.js +15 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +14 -0
- package/dist/pagination.d.ts +45 -0
- package/dist/pagination.js +69 -0
- package/dist/types.d.ts +593 -0
- package/dist/types.js +76 -0
- package/dist/webhooks.d.ts +70 -0
- package/dist/webhooks.js +130 -0
- package/package.json +44 -0
package/dist/errors.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errors.
|
|
3
|
+
*
|
|
4
|
+
* Every failure this SDK can produce arrives as a `PosthasteError`, including
|
|
5
|
+
* the ones that never touched the server (a socket that would not open, a
|
|
6
|
+
* request that timed out). One `catch` clause, one shape.
|
|
7
|
+
*/
|
|
8
|
+
export class PosthasteError extends Error {
|
|
9
|
+
name = 'PosthasteError';
|
|
10
|
+
/**
|
|
11
|
+
* The HTTP status. `0` when the request never got a response at all — a DNS
|
|
12
|
+
* failure, a refused connection, an abort. Checking `status >= 500` is
|
|
13
|
+
* therefore not a substitute for checking `type`.
|
|
14
|
+
*/
|
|
15
|
+
status;
|
|
16
|
+
type;
|
|
17
|
+
/**
|
|
18
|
+
* Field-level detail, when the server sent any.
|
|
19
|
+
*
|
|
20
|
+
* `error.fields` appears on the 400s produced by the shared body validator
|
|
21
|
+
* and is ABSENT on the several 400s that are hand-written with a message
|
|
22
|
+
* only. Always treat it as optional; never index into it unchecked.
|
|
23
|
+
*/
|
|
24
|
+
fields;
|
|
25
|
+
/**
|
|
26
|
+
* How long to wait, in seconds, when the server said. Taken from
|
|
27
|
+
* `error.retryAfterSeconds` (rate limiting) or the `Retry-After` header
|
|
28
|
+
* (quota exhaustion), in that order.
|
|
29
|
+
*/
|
|
30
|
+
retryAfterSeconds;
|
|
31
|
+
/** The parsed body. `undefined` when there was nothing parseable. */
|
|
32
|
+
body;
|
|
33
|
+
constructor(init) {
|
|
34
|
+
super(init.message, init.cause === undefined ? undefined : { cause: init.cause });
|
|
35
|
+
this.status = init.status;
|
|
36
|
+
this.type = init.type;
|
|
37
|
+
if (init.fields)
|
|
38
|
+
this.fields = init.fields;
|
|
39
|
+
if (init.retryAfterSeconds !== undefined)
|
|
40
|
+
this.retryAfterSeconds = init.retryAfterSeconds;
|
|
41
|
+
if (init.body !== undefined)
|
|
42
|
+
this.body = init.body;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* True for the two refusals that mean "your allowance is spent", as opposed
|
|
46
|
+
* to "you are going too fast".
|
|
47
|
+
*
|
|
48
|
+
* Both arrive as 429 with a `Retry-After`, and the status alone cannot tell
|
|
49
|
+
* them apart — which is exactly the trap that makes a naive retry loop hammer
|
|
50
|
+
* a wall for the rest of the month. This SDK never retries these in-process.
|
|
51
|
+
*/
|
|
52
|
+
get isQuotaExhausted() {
|
|
53
|
+
return this.type === 'daily_limit_reached' || this.type === 'monthly_limit_reached';
|
|
54
|
+
}
|
|
55
|
+
/** True for the per-key request-rate limiter, which is short and transient. */
|
|
56
|
+
get isRateLimited() {
|
|
57
|
+
return this.type === 'rate_limited';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export function isPosthasteError(value) {
|
|
61
|
+
return value instanceof PosthasteError;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Turn a response body into an error.
|
|
65
|
+
*
|
|
66
|
+
* The API's own refusals use `{ error: { type, message, ... } }`. Two kinds of
|
|
67
|
+
* response do NOT, and both are parsed defensively here rather than assumed
|
|
68
|
+
* away:
|
|
69
|
+
*
|
|
70
|
+
* An UNHANDLED 500. There is no `setErrorHandler` on the API, so a thrown
|
|
71
|
+
* exception is serialised by Fastify's default handler as
|
|
72
|
+
* `{ statusCode, error: "Internal Server Error", message }` — `error` is a
|
|
73
|
+
* STRING. Reading `body.error.type` off that yields `undefined` and reading
|
|
74
|
+
* `body.error.message` throws, which would turn the one response where a
|
|
75
|
+
* caller most needs a clear message into a crash inside the SDK.
|
|
76
|
+
*
|
|
77
|
+
* Anything that never reached the application at all — a proxy's HTML error
|
|
78
|
+
* page, an empty body, a truncated response.
|
|
79
|
+
*
|
|
80
|
+
* Neither invents a `type`: both become `unknown_error`, so nothing downstream
|
|
81
|
+
* can mistake an unclassified failure for a documented one.
|
|
82
|
+
*/
|
|
83
|
+
export function errorFromResponse(status, rawBody, retryAfterHeader) {
|
|
84
|
+
let body;
|
|
85
|
+
try {
|
|
86
|
+
body = rawBody.length > 0 ? JSON.parse(rawBody) : undefined;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
body = undefined;
|
|
90
|
+
}
|
|
91
|
+
const envelope = typeof body === 'object' && body !== null ? body.error : undefined;
|
|
92
|
+
const headerRetry = parseRetryAfter(retryAfterHeader);
|
|
93
|
+
// The documented shape: `error` is an object carrying a machine-readable type.
|
|
94
|
+
if (typeof envelope === 'object' && envelope !== null) {
|
|
95
|
+
const e = envelope;
|
|
96
|
+
const type = typeof e.type === 'string' ? e.type : 'unknown_error';
|
|
97
|
+
const message = typeof e.message === 'string' && e.message.length > 0
|
|
98
|
+
? e.message
|
|
99
|
+
: `Posthaste request failed with status ${status}`;
|
|
100
|
+
const fields = Array.isArray(e.fields)
|
|
101
|
+
? e.fields
|
|
102
|
+
.filter((f) => typeof f === 'object' && f !== null)
|
|
103
|
+
.map((f) => ({
|
|
104
|
+
path: typeof f.path === 'string' ? f.path : '',
|
|
105
|
+
message: typeof f.message === 'string' ? f.message : '',
|
|
106
|
+
}))
|
|
107
|
+
: undefined;
|
|
108
|
+
// The rate limiter puts its wait INSIDE the error object; the quota
|
|
109
|
+
// refusals put theirs in a Retry-After header. Prefer the body, because
|
|
110
|
+
// only it is specific to this refusal.
|
|
111
|
+
const bodyRetry = typeof e.retryAfterSeconds === 'number' ? e.retryAfterSeconds : undefined;
|
|
112
|
+
return new PosthasteError({
|
|
113
|
+
status,
|
|
114
|
+
type,
|
|
115
|
+
message,
|
|
116
|
+
...(fields && fields.length > 0 ? { fields } : {}),
|
|
117
|
+
...(bodyRetry !== undefined
|
|
118
|
+
? { retryAfterSeconds: bodyRetry }
|
|
119
|
+
: headerRetry !== undefined
|
|
120
|
+
? { retryAfterSeconds: headerRetry }
|
|
121
|
+
: {}),
|
|
122
|
+
body,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
// Not the envelope. `error` may be a string ("Internal Server Error"), or
|
|
126
|
+
// there may be no body at all. Use whatever human-readable text exists.
|
|
127
|
+
const record = typeof body === 'object' && body !== null ? body : {};
|
|
128
|
+
const message = (typeof record.message === 'string' && record.message) ||
|
|
129
|
+
(typeof envelope === 'string' && envelope) ||
|
|
130
|
+
(rawBody.trim().length > 0 && rawBody.trim().length <= 300 ? rawBody.trim() : '') ||
|
|
131
|
+
`Posthaste request failed with status ${status}`;
|
|
132
|
+
return new PosthasteError({
|
|
133
|
+
status,
|
|
134
|
+
type: 'unknown_error',
|
|
135
|
+
message,
|
|
136
|
+
...(headerRetry !== undefined ? { retryAfterSeconds: headerRetry } : {}),
|
|
137
|
+
...(body !== undefined ? { body } : {}),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* `Retry-After` is either a number of seconds or an HTTP date. The API sends
|
|
142
|
+
* seconds; a proxy in front of it may not.
|
|
143
|
+
*/
|
|
144
|
+
export function parseRetryAfter(header) {
|
|
145
|
+
if (!header)
|
|
146
|
+
return undefined;
|
|
147
|
+
const trimmed = header.trim();
|
|
148
|
+
if (/^\d+$/.test(trimmed))
|
|
149
|
+
return Number(trimmed);
|
|
150
|
+
const at = Date.parse(trimmed);
|
|
151
|
+
if (Number.isNaN(at))
|
|
152
|
+
return undefined;
|
|
153
|
+
return Math.max(0, Math.ceil((at - Date.now()) / 1000));
|
|
154
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transport.
|
|
3
|
+
*
|
|
4
|
+
* Zero dependencies: global `fetch` and nothing else. Everything interesting
|
|
5
|
+
* lives here — authentication, query building, timeouts, the retry policy and
|
|
6
|
+
* the error mapping — so every resource method below is a one-liner and none
|
|
7
|
+
* of them can quietly disagree about how a failure is handled.
|
|
8
|
+
*/
|
|
9
|
+
import { parseRetryAfter } from './errors.js';
|
|
10
|
+
/**
|
|
11
|
+
* A structural `fetch`.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately minimal rather than `typeof globalThis.fetch`: the global type
|
|
14
|
+
* differs between DOM lib, `@types/node` and whatever a bundler injects, and an
|
|
15
|
+
* SDK that only needs a status, a header and a body should not force a consumer
|
|
16
|
+
* into one of them. The real `fetch` satisfies this; so does a five-line test
|
|
17
|
+
* shim, and so does a proxy-aware wrapper.
|
|
18
|
+
*/
|
|
19
|
+
export interface PosthasteResponse {
|
|
20
|
+
readonly status: number;
|
|
21
|
+
readonly headers: {
|
|
22
|
+
get(name: string): string | null;
|
|
23
|
+
};
|
|
24
|
+
text(): Promise<string>;
|
|
25
|
+
}
|
|
26
|
+
export interface PosthasteRequestInit {
|
|
27
|
+
method: string;
|
|
28
|
+
headers: Record<string, string>;
|
|
29
|
+
body?: string;
|
|
30
|
+
signal?: AbortSignal;
|
|
31
|
+
}
|
|
32
|
+
export type FetchLike = (url: string, init: PosthasteRequestInit) => Promise<PosthasteResponse>;
|
|
33
|
+
export interface PosthasteOptions {
|
|
34
|
+
/** A `ph_live_…` or `ph_test_…` key. Sent as `Authorization: Bearer …`. */
|
|
35
|
+
apiKey: string;
|
|
36
|
+
/** Defaults to `https://api.posthastemail.dev`. No trailing slash needed. */
|
|
37
|
+
baseUrl?: string;
|
|
38
|
+
/**
|
|
39
|
+
* The `fetch` to use. Defaults to the global one.
|
|
40
|
+
*
|
|
41
|
+
* Injectable because it is genuinely useful — a proxy agent, a tracing
|
|
42
|
+
* wrapper, a mock — and because it is how this package's own integration test
|
|
43
|
+
* drives a real in-process Fastify app without opening a socket.
|
|
44
|
+
*/
|
|
45
|
+
fetch?: FetchLike;
|
|
46
|
+
/** Per-attempt, not per-call. Default 30s. `0` disables the timeout. */
|
|
47
|
+
timeoutMs?: number;
|
|
48
|
+
/** Retries AFTER the first attempt. Default 2. `0` disables retrying. */
|
|
49
|
+
maxRetries?: number;
|
|
50
|
+
/**
|
|
51
|
+
* Longest server-requested wait this SDK will actually sit through, in
|
|
52
|
+
* milliseconds. Default 60s.
|
|
53
|
+
*
|
|
54
|
+
* A `Retry-After` beyond this is honoured by NOT retrying: a quota that frees
|
|
55
|
+
* at midnight is not something to block a request handler on, and sleeping
|
|
56
|
+
* through it would look like a hang.
|
|
57
|
+
*/
|
|
58
|
+
maxRetryDelayMs?: number;
|
|
59
|
+
/** Extra headers on every request. Cannot override `authorization`. */
|
|
60
|
+
headers?: Record<string, string>;
|
|
61
|
+
/** Appended to the SDK's own `user-agent`. */
|
|
62
|
+
userAgent?: string;
|
|
63
|
+
/**
|
|
64
|
+
* How the SDK waits between retries. Advanced — exists so tests can assert
|
|
65
|
+
* backoff without spending real seconds on it.
|
|
66
|
+
*/
|
|
67
|
+
sleep?: (ms: number) => Promise<void>;
|
|
68
|
+
/** Injectable randomness for retry jitter. Advanced; testing only. */
|
|
69
|
+
random?: () => number;
|
|
70
|
+
}
|
|
71
|
+
export interface RequestOptions {
|
|
72
|
+
/** Abort this call from outside. Composed with the client's own timeout. */
|
|
73
|
+
signal?: AbortSignal;
|
|
74
|
+
/** Extra headers for one call. */
|
|
75
|
+
headers?: Record<string, string>;
|
|
76
|
+
}
|
|
77
|
+
export interface InternalRequest {
|
|
78
|
+
method: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
|
79
|
+
path: string;
|
|
80
|
+
query?: Record<string, string | number | boolean | undefined>;
|
|
81
|
+
body?: unknown;
|
|
82
|
+
/**
|
|
83
|
+
* Whether repeating this request is harmless.
|
|
84
|
+
*
|
|
85
|
+
* GET and DELETE always are. A POST is only idempotent when the server makes
|
|
86
|
+
* it so, and each call site says which it is — see `emails.send`, where the
|
|
87
|
+
* answer depends on whether the caller supplied an `idempotencyKey`.
|
|
88
|
+
*/
|
|
89
|
+
idempotent: boolean;
|
|
90
|
+
options?: RequestOptions;
|
|
91
|
+
}
|
|
92
|
+
/** Kept in step with package.json — it is the only place it is stated twice. */
|
|
93
|
+
export declare const SDK_VERSION = "0.1.0";
|
|
94
|
+
export declare class HttpClient {
|
|
95
|
+
private readonly apiKey;
|
|
96
|
+
private readonly baseUrl;
|
|
97
|
+
private readonly doFetch;
|
|
98
|
+
private readonly timeoutMs;
|
|
99
|
+
private readonly maxRetries;
|
|
100
|
+
private readonly maxRetryDelayMs;
|
|
101
|
+
private readonly extraHeaders;
|
|
102
|
+
private readonly userAgent;
|
|
103
|
+
private readonly sleep;
|
|
104
|
+
private readonly random;
|
|
105
|
+
constructor(options: PosthasteOptions);
|
|
106
|
+
/** A request whose 204 or empty body is expected. */
|
|
107
|
+
requestVoid(req: InternalRequest): Promise<void>;
|
|
108
|
+
request<T>(req: InternalRequest): Promise<T>;
|
|
109
|
+
/**
|
|
110
|
+
* Send, retry, and map a failure onto a `PosthasteError`.
|
|
111
|
+
*
|
|
112
|
+
* Returns the status alongside the body text because the send path needs the
|
|
113
|
+
* status itself — 202 and 200 are both success there and mean different
|
|
114
|
+
* things — and a caller cannot recover it from the JSON.
|
|
115
|
+
*/
|
|
116
|
+
send(req: InternalRequest): Promise<{
|
|
117
|
+
status: number;
|
|
118
|
+
text: string;
|
|
119
|
+
}>;
|
|
120
|
+
/**
|
|
121
|
+
* How long to wait before repeating this request, or `null` for "do not".
|
|
122
|
+
*
|
|
123
|
+
* The decision branches on `error.type` and NOT on the status, which is the
|
|
124
|
+
* whole point. All three of these are 429:
|
|
125
|
+
*
|
|
126
|
+
* `rate_limited` — the per-key request limiter. Transient, measured in
|
|
127
|
+
* seconds, and exactly what a retry is for.
|
|
128
|
+
* `daily_limit_reached` — the warmup cap. `Retry-After` is the seconds
|
|
129
|
+
* until midnight UTC.
|
|
130
|
+
* `monthly_limit_reached` — the plan allowance. `Retry-After` can be
|
|
131
|
+
* weeks.
|
|
132
|
+
*
|
|
133
|
+
* A retry loop written against the status treats the last two as a hiccup and
|
|
134
|
+
* hammers a wall it cannot get through until the calendar moves — burning the
|
|
135
|
+
* caller's own rate limit on requests that are all going to be refused.
|
|
136
|
+
*/
|
|
137
|
+
private retryDelayFor;
|
|
138
|
+
private backoff;
|
|
139
|
+
/**
|
|
140
|
+
* Exponential with FULL jitter.
|
|
141
|
+
*
|
|
142
|
+
* Without jitter every client that failed on the same upstream blip retries
|
|
143
|
+
* in the same millisecond, and the recovery attempt is itself a thundering
|
|
144
|
+
* herd against a service that has only just come back.
|
|
145
|
+
*/
|
|
146
|
+
private jitteredBackoff;
|
|
147
|
+
private attempt;
|
|
148
|
+
private buildUrl;
|
|
149
|
+
}
|
|
150
|
+
export { parseRetryAfter };
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transport.
|
|
3
|
+
*
|
|
4
|
+
* Zero dependencies: global `fetch` and nothing else. Everything interesting
|
|
5
|
+
* lives here — authentication, query building, timeouts, the retry policy and
|
|
6
|
+
* the error mapping — so every resource method below is a one-liner and none
|
|
7
|
+
* of them can quietly disagree about how a failure is handled.
|
|
8
|
+
*/
|
|
9
|
+
import { PosthasteError, errorFromResponse, parseRetryAfter } from './errors.js';
|
|
10
|
+
const DEFAULT_BASE_URL = 'https://api.posthastemail.dev';
|
|
11
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
12
|
+
const DEFAULT_MAX_RETRIES = 2;
|
|
13
|
+
const DEFAULT_MAX_RETRY_DELAY_MS = 60_000;
|
|
14
|
+
/** First backoff step. Doubles per attempt, with full jitter, capped at 8s. */
|
|
15
|
+
const BASE_BACKOFF_MS = 500;
|
|
16
|
+
const MAX_BACKOFF_MS = 8_000;
|
|
17
|
+
/** Kept in step with package.json — it is the only place it is stated twice. */
|
|
18
|
+
export const SDK_VERSION = '0.1.0';
|
|
19
|
+
const defaultSleep = (ms) => new Promise((resolve) => {
|
|
20
|
+
const timer = setTimeout(resolve, ms);
|
|
21
|
+
// Never hold a process open just to wait out a retry.
|
|
22
|
+
timer.unref?.();
|
|
23
|
+
});
|
|
24
|
+
export class HttpClient {
|
|
25
|
+
apiKey;
|
|
26
|
+
baseUrl;
|
|
27
|
+
doFetch;
|
|
28
|
+
timeoutMs;
|
|
29
|
+
maxRetries;
|
|
30
|
+
maxRetryDelayMs;
|
|
31
|
+
extraHeaders;
|
|
32
|
+
userAgent;
|
|
33
|
+
sleep;
|
|
34
|
+
random;
|
|
35
|
+
constructor(options) {
|
|
36
|
+
if (!options.apiKey || typeof options.apiKey !== 'string') {
|
|
37
|
+
throw new PosthasteError({
|
|
38
|
+
status: 0,
|
|
39
|
+
type: 'invalid_request',
|
|
40
|
+
message: 'A Posthaste API key is required: new Posthaste({ apiKey }).',
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
44
|
+
if (!fetchImpl) {
|
|
45
|
+
throw new PosthasteError({
|
|
46
|
+
status: 0,
|
|
47
|
+
type: 'invalid_request',
|
|
48
|
+
message: 'No global fetch is available in this runtime. Pass one: new Posthaste({ apiKey, fetch }).',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
this.apiKey = options.apiKey;
|
|
52
|
+
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
53
|
+
this.doFetch = fetchImpl;
|
|
54
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
55
|
+
this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
56
|
+
this.maxRetryDelayMs = options.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS;
|
|
57
|
+
this.extraHeaders = options.headers ?? {};
|
|
58
|
+
this.userAgent = options.userAgent
|
|
59
|
+
? `posthaste-sdk-node/${SDK_VERSION} ${options.userAgent}`
|
|
60
|
+
: `posthaste-sdk-node/${SDK_VERSION}`;
|
|
61
|
+
this.sleep = options.sleep ?? defaultSleep;
|
|
62
|
+
this.random = options.random ?? Math.random;
|
|
63
|
+
}
|
|
64
|
+
/** A request whose 204 or empty body is expected. */
|
|
65
|
+
async requestVoid(req) {
|
|
66
|
+
await this.send(req);
|
|
67
|
+
}
|
|
68
|
+
async request(req) {
|
|
69
|
+
const { status, text } = await this.send(req);
|
|
70
|
+
if (status === 204 || text.length === 0)
|
|
71
|
+
return undefined;
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(text);
|
|
74
|
+
}
|
|
75
|
+
catch (cause) {
|
|
76
|
+
throw new PosthasteError({
|
|
77
|
+
status,
|
|
78
|
+
type: 'unknown_error',
|
|
79
|
+
message: `Posthaste returned a ${status} whose body is not JSON.`,
|
|
80
|
+
cause,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Send, retry, and map a failure onto a `PosthasteError`.
|
|
86
|
+
*
|
|
87
|
+
* Returns the status alongside the body text because the send path needs the
|
|
88
|
+
* status itself — 202 and 200 are both success there and mean different
|
|
89
|
+
* things — and a caller cannot recover it from the JSON.
|
|
90
|
+
*/
|
|
91
|
+
async send(req) {
|
|
92
|
+
const url = this.buildUrl(req.path, req.query);
|
|
93
|
+
const headers = {
|
|
94
|
+
...this.extraHeaders,
|
|
95
|
+
...(req.options?.headers ?? {}),
|
|
96
|
+
accept: 'application/json',
|
|
97
|
+
'user-agent': this.userAgent,
|
|
98
|
+
// Last, so neither the per-client nor the per-call headers can replace it.
|
|
99
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
100
|
+
};
|
|
101
|
+
let body;
|
|
102
|
+
if (req.body !== undefined) {
|
|
103
|
+
body = JSON.stringify(req.body);
|
|
104
|
+
headers['content-type'] = 'application/json';
|
|
105
|
+
}
|
|
106
|
+
let attempt = 0;
|
|
107
|
+
for (;;) {
|
|
108
|
+
let response;
|
|
109
|
+
try {
|
|
110
|
+
response = await this.attempt(url, { method: req.method, headers, body }, req.options);
|
|
111
|
+
}
|
|
112
|
+
catch (cause) {
|
|
113
|
+
const error = transportError(cause);
|
|
114
|
+
// A connection that never produced a response is safe to repeat only
|
|
115
|
+
// under the same rule as everything else: the request has to be one
|
|
116
|
+
// that repeating cannot duplicate.
|
|
117
|
+
if (req.idempotent && attempt < this.maxRetries) {
|
|
118
|
+
await this.backoff(attempt, undefined);
|
|
119
|
+
attempt += 1;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
if (response.status < 400) {
|
|
125
|
+
return { status: response.status, text: await response.text() };
|
|
126
|
+
}
|
|
127
|
+
const text = await response.text();
|
|
128
|
+
const error = errorFromResponse(response.status, text, response.headers.get('retry-after') ?? null);
|
|
129
|
+
const wait = this.retryDelayFor(error, req, attempt);
|
|
130
|
+
if (wait === null)
|
|
131
|
+
throw error;
|
|
132
|
+
await this.sleep(wait);
|
|
133
|
+
attempt += 1;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* How long to wait before repeating this request, or `null` for "do not".
|
|
138
|
+
*
|
|
139
|
+
* The decision branches on `error.type` and NOT on the status, which is the
|
|
140
|
+
* whole point. All three of these are 429:
|
|
141
|
+
*
|
|
142
|
+
* `rate_limited` — the per-key request limiter. Transient, measured in
|
|
143
|
+
* seconds, and exactly what a retry is for.
|
|
144
|
+
* `daily_limit_reached` — the warmup cap. `Retry-After` is the seconds
|
|
145
|
+
* until midnight UTC.
|
|
146
|
+
* `monthly_limit_reached` — the plan allowance. `Retry-After` can be
|
|
147
|
+
* weeks.
|
|
148
|
+
*
|
|
149
|
+
* A retry loop written against the status treats the last two as a hiccup and
|
|
150
|
+
* hammers a wall it cannot get through until the calendar moves — burning the
|
|
151
|
+
* caller's own rate limit on requests that are all going to be refused.
|
|
152
|
+
*/
|
|
153
|
+
retryDelayFor(error, req, attempt) {
|
|
154
|
+
if (attempt >= this.maxRetries)
|
|
155
|
+
return null;
|
|
156
|
+
if (!req.idempotent)
|
|
157
|
+
return null;
|
|
158
|
+
// Quota exhaustion. Never in-process, whatever Retry-After says.
|
|
159
|
+
if (error.isQuotaExhausted)
|
|
160
|
+
return null;
|
|
161
|
+
const retryable = error.isRateLimited || error.status === 408 || error.status === 429 || error.status >= 500;
|
|
162
|
+
if (!retryable)
|
|
163
|
+
return null;
|
|
164
|
+
if (error.retryAfterSeconds !== undefined) {
|
|
165
|
+
const requested = error.retryAfterSeconds * 1000;
|
|
166
|
+
// Honour it — unless honouring it would mean blocking for longer than a
|
|
167
|
+
// caller could reasonably want, in which case give them the error back
|
|
168
|
+
// and let them decide.
|
|
169
|
+
if (requested > this.maxRetryDelayMs)
|
|
170
|
+
return null;
|
|
171
|
+
return Math.max(0, requested);
|
|
172
|
+
}
|
|
173
|
+
return this.jitteredBackoff(attempt);
|
|
174
|
+
}
|
|
175
|
+
async backoff(attempt, retryAfterMs) {
|
|
176
|
+
await this.sleep(retryAfterMs ?? this.jitteredBackoff(attempt));
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Exponential with FULL jitter.
|
|
180
|
+
*
|
|
181
|
+
* Without jitter every client that failed on the same upstream blip retries
|
|
182
|
+
* in the same millisecond, and the recovery attempt is itself a thundering
|
|
183
|
+
* herd against a service that has only just come back.
|
|
184
|
+
*/
|
|
185
|
+
jitteredBackoff(attempt) {
|
|
186
|
+
const ceiling = Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** attempt);
|
|
187
|
+
return Math.round(this.random() * ceiling);
|
|
188
|
+
}
|
|
189
|
+
async attempt(url, init, options) {
|
|
190
|
+
if (this.timeoutMs <= 0 && !options?.signal) {
|
|
191
|
+
return this.doFetch(url, init);
|
|
192
|
+
}
|
|
193
|
+
const controller = new AbortController();
|
|
194
|
+
const abort = () => controller.abort(options?.signal?.reason);
|
|
195
|
+
let timer;
|
|
196
|
+
if (options?.signal) {
|
|
197
|
+
if (options.signal.aborted)
|
|
198
|
+
abort();
|
|
199
|
+
else
|
|
200
|
+
options.signal.addEventListener('abort', abort, { once: true });
|
|
201
|
+
}
|
|
202
|
+
if (this.timeoutMs > 0) {
|
|
203
|
+
timer = setTimeout(() => controller.abort(new TimeoutAbort()), this.timeoutMs);
|
|
204
|
+
timer.unref?.();
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
return await this.doFetch(url, { ...init, signal: controller.signal });
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
if (timer)
|
|
211
|
+
clearTimeout(timer);
|
|
212
|
+
options?.signal?.removeEventListener('abort', abort);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
buildUrl(path, query) {
|
|
216
|
+
const search = [];
|
|
217
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
218
|
+
// `undefined` means "not asked for". `null` is never produced by the
|
|
219
|
+
// param builders, and an empty string is a real, if odd, filter.
|
|
220
|
+
if (value === undefined)
|
|
221
|
+
continue;
|
|
222
|
+
search.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
|
223
|
+
}
|
|
224
|
+
return `${this.baseUrl}${path}${search.length > 0 ? `?${search.join('&')}` : ''}`;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** Marker so an abort we caused is distinguishable from one the caller caused. */
|
|
228
|
+
class TimeoutAbort extends Error {
|
|
229
|
+
constructor() {
|
|
230
|
+
super('Posthaste request timed out');
|
|
231
|
+
this.name = 'PosthasteTimeout';
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function transportError(cause) {
|
|
235
|
+
const timedOut = cause instanceof TimeoutAbort ||
|
|
236
|
+
(typeof cause === 'object' &&
|
|
237
|
+
cause !== null &&
|
|
238
|
+
cause.name === 'PosthasteTimeout') ||
|
|
239
|
+
(typeof cause === 'object' &&
|
|
240
|
+
cause !== null &&
|
|
241
|
+
cause.cause instanceof TimeoutAbort);
|
|
242
|
+
if (timedOut) {
|
|
243
|
+
return new PosthasteError({
|
|
244
|
+
status: 0,
|
|
245
|
+
type: 'timeout',
|
|
246
|
+
message: 'The Posthaste request timed out before the server responded.',
|
|
247
|
+
cause,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
251
|
+
return new PosthasteError({
|
|
252
|
+
status: 0,
|
|
253
|
+
type: 'connection_error',
|
|
254
|
+
message: `Could not reach Posthaste: ${message}`,
|
|
255
|
+
cause,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
export { parseRetryAfter };
|
package/dist/ids.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public identifiers.
|
|
3
|
+
*
|
|
4
|
+
* The API never returns a bare UUID. Every id is `<prefix>_<22 chars of
|
|
5
|
+
* base64url>`, which means a value that turns up in a log or a support ticket
|
|
6
|
+
* announces what it is, and passing a domain id where a message id belongs is
|
|
7
|
+
* visible rather than silent.
|
|
8
|
+
*
|
|
9
|
+
* The template-literal types below encode that on the way OUT — a `MessageId`
|
|
10
|
+
* read off a response is known to start `msg_`. They are deliberately NOT used
|
|
11
|
+
* on the way IN: an id loaded from your own database is a `string`, and
|
|
12
|
+
* demanding a cast to pass it back would be a type that punishes correct code.
|
|
13
|
+
* Every method therefore accepts a plain `string` and returns a prefixed one.
|
|
14
|
+
*/
|
|
15
|
+
export type PrefixedId<Prefix extends string> = `${Prefix}_${string}`;
|
|
16
|
+
export type AccountId = PrefixedId<'acct'>;
|
|
17
|
+
export type ApiKeyId = PrefixedId<'key'>;
|
|
18
|
+
export type DomainId = PrefixedId<'dom'>;
|
|
19
|
+
export type MessageId = PrefixedId<'msg'>;
|
|
20
|
+
export type EventId = PrefixedId<'evt'>;
|
|
21
|
+
export type SuppressionId = PrefixedId<'sup'>;
|
|
22
|
+
export type WebhookId = PrefixedId<'whk'>;
|
|
23
|
+
export type SubscriptionId = PrefixedId<'sub'>;
|
|
24
|
+
export type PaymentId = PrefixedId<'pay'>;
|
|
25
|
+
export type InvoiceId = PrefixedId<'inv'>;
|
package/dist/ids.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public identifiers.
|
|
3
|
+
*
|
|
4
|
+
* The API never returns a bare UUID. Every id is `<prefix>_<22 chars of
|
|
5
|
+
* base64url>`, which means a value that turns up in a log or a support ticket
|
|
6
|
+
* announces what it is, and passing a domain id where a message id belongs is
|
|
7
|
+
* visible rather than silent.
|
|
8
|
+
*
|
|
9
|
+
* The template-literal types below encode that on the way OUT — a `MessageId`
|
|
10
|
+
* read off a response is known to start `msg_`. They are deliberately NOT used
|
|
11
|
+
* on the way IN: an id loaded from your own database is a `string`, and
|
|
12
|
+
* demanding a cast to pass it back would be a type that punishes correct code.
|
|
13
|
+
* Every method therefore accepts a plain `string` and returns a prefixed one.
|
|
14
|
+
*/
|
|
15
|
+
export {};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @posthaste/sdk — the official TypeScript SDK for the Posthaste
|
|
3
|
+
* transactional email API.
|
|
4
|
+
*
|
|
5
|
+
* Zero runtime dependencies: global `fetch` and `node:crypto`, nothing else.
|
|
6
|
+
*/
|
|
7
|
+
export { Posthaste } from './client.js';
|
|
8
|
+
export { AccountResource, ApiKeysResource, BillingResource, DomainsResource, EmailsResource, MessagesResource, SuppressionsResource, WebhooksResource, } from './client.js';
|
|
9
|
+
export { PosthasteError, isPosthasteError, type PosthasteErrorType, type KnownErrorType, type FieldError, } from './errors.js';
|
|
10
|
+
export { SDK_VERSION, type PosthasteOptions, type RequestOptions, type FetchLike, type PosthasteRequestInit, type PosthasteResponse, } from './http.js';
|
|
11
|
+
export { autoPaginate, collect, type PageFetcher } from './pagination.js';
|
|
12
|
+
export { verifyWebhook, parseWebhookEvent, SIGNATURE_HEADER, DELIVERY_ID_HEADER, ATTEMPT_HEADER, type VerifyWebhookOptions, type WebhookVerifyResult, type WebhookVerifyFailure, } from './webhooks.js';
|
|
13
|
+
export type { AccountId, ApiKeyId, DomainId, EventId, InvoiceId, MessageId, PaymentId, PrefixedId, SubscriptionId, SuppressionId, WebhookId, } from './ids.js';
|
|
14
|
+
export { EVENT_TYPES, MESSAGE_STATUSES, SCOPES, SUPPRESSION_REASONS, type Account, type AccountPlan, type AccountSending, type AccountStatus, type AccountSubscriptionSummary, type ApiKey, type Billing, type BillingCharge, type BillingEvent, type BillingHistory, type BillingPayment, type BillingPlan, type BillingProfile, type BillingSubscription, type ChainVerification, type CheckStatus, type CloudflarePublishResult, type ConnectCloudflareParams, type CreateDomainParams, type CreateSuppressionParams, type CreateWebhookParams, type CreatedDomain, type CreatedSuppression, type CreatedWebhook, type DnsHost, type DnsRecord, type Domain, type DomainSetup, type DomainStatus, type DomainVerification, type EventType, type Invoice, type InvoiceDetail, type KeyEnvironment, type List, type ListMessagesParams, type ListSuppressionsParams, type Message, type MessageContent, type MessageStats, type MessageStatsParams, type MessageStatus, type MessageSummary, type Page, type PaginationParams, type PaymentStatus, type PlanId, type RecordCheck, type Scope, type SendEmailParams, type SendEmailResult, type StatsDay, type StatsTotals, type SubscriptionStatus, type Suppression, type SuppressionReason, type Usage, type UsageDay, type WaybillEntry, type Webhook, type WebhookEvent, type WebhookStatus, } from './types.js';
|
|
15
|
+
export { Posthaste as default } from './client.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @posthaste/sdk — the official TypeScript SDK for the Posthaste
|
|
3
|
+
* transactional email API.
|
|
4
|
+
*
|
|
5
|
+
* Zero runtime dependencies: global `fetch` and `node:crypto`, nothing else.
|
|
6
|
+
*/
|
|
7
|
+
export { Posthaste } from './client.js';
|
|
8
|
+
export { AccountResource, ApiKeysResource, BillingResource, DomainsResource, EmailsResource, MessagesResource, SuppressionsResource, WebhooksResource, } from './client.js';
|
|
9
|
+
export { PosthasteError, isPosthasteError, } from './errors.js';
|
|
10
|
+
export { SDK_VERSION, } from './http.js';
|
|
11
|
+
export { autoPaginate, collect } from './pagination.js';
|
|
12
|
+
export { verifyWebhook, parseWebhookEvent, SIGNATURE_HEADER, DELIVERY_ID_HEADER, ATTEMPT_HEADER, } from './webhooks.js';
|
|
13
|
+
export { EVENT_TYPES, MESSAGE_STATUSES, SCOPES, SUPPRESSION_REASONS, } from './types.js';
|
|
14
|
+
export { Posthaste as default } from './client.js';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyset pagination.
|
|
3
|
+
*
|
|
4
|
+
* The API pages by cursor rather than by offset: `limit` + `before`, answering
|
|
5
|
+
* with `{ data, hasMore, nextCursor }`. `before` is the id of the last row you
|
|
6
|
+
* received, which is what `nextCursor` hands you.
|
|
7
|
+
*
|
|
8
|
+
* THE TRAP THIS MODULE EXISTS TO CLOSE
|
|
9
|
+
*
|
|
10
|
+
* The obvious loop is `while (nextCursor) { … }`. It is wrong. `nextCursor` is
|
|
11
|
+
* derived from the last row of a page, and at least one endpoint on this API
|
|
12
|
+
* returns a non-null cursor on its final page — the documentation calls it out
|
|
13
|
+
* for `/v1/inbound/messages`. A cursor loop over such an endpoint asks for the
|
|
14
|
+
* page after the last one, gets an empty page with the same cursor back, and
|
|
15
|
+
* spins forever. Nothing about it looks wrong in a log; it just never finishes.
|
|
16
|
+
*
|
|
17
|
+
* `hasMore` is the server's actual answer to "is there another page", computed
|
|
18
|
+
* by fetching one row more than you asked for. It is the only correct
|
|
19
|
+
* condition, so `autoPaginate` is the API this SDK puts in front of people —
|
|
20
|
+
* `list` is still there for anyone who wants a single page.
|
|
21
|
+
*/
|
|
22
|
+
import type { Page, PaginationParams } from './types.js';
|
|
23
|
+
export type PageFetcher<TItem, TParams> = (params: TParams) => Promise<Page<TItem>>;
|
|
24
|
+
/**
|
|
25
|
+
* Walk every page and yield every item.
|
|
26
|
+
*
|
|
27
|
+
* Belt and braces, because an infinite loop in someone's job runner is a bad
|
|
28
|
+
* way to learn about a contract change:
|
|
29
|
+
*
|
|
30
|
+
* - stops on `hasMore === false`, always;
|
|
31
|
+
* - stops if a page comes back empty, because there is nothing to advance to;
|
|
32
|
+
* - stops if the cursor does not MOVE, which is what a non-null terminal
|
|
33
|
+
* cursor looks like from here.
|
|
34
|
+
*/
|
|
35
|
+
export declare function autoPaginate<TItem extends {
|
|
36
|
+
id: string;
|
|
37
|
+
}, TParams extends PaginationParams>(fetchPage: PageFetcher<TItem, TParams>, params: TParams): AsyncGenerator<TItem, void, undefined>;
|
|
38
|
+
/**
|
|
39
|
+
* Collect an auto-paginated stream into an array.
|
|
40
|
+
*
|
|
41
|
+
* `maxItems` is not optional garnish. Draining an unbounded list into memory is
|
|
42
|
+
* how a helper like this turns into an incident on the one account that has
|
|
43
|
+
* four million messages, so there is a ceiling and the caller chooses it.
|
|
44
|
+
*/
|
|
45
|
+
export declare function collect<T>(source: AsyncIterable<T>, maxItems: number): Promise<T[]>;
|