@particle-academy/fancy-connector-core 0.3.1 → 0.5.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/CHANGELOG.md +134 -0
- package/README.md +22 -0
- package/dist/index.cjs +68 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +52 -2
- package/dist/index.d.ts +52 -2
- package/dist/index.js +68 -14
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,140 @@ All notable changes to `fancy-connector-core` are documented here, in
|
|
|
6
6
|
**This package is pre-1.0, so breaking changes land in MINOR releases.** The
|
|
7
7
|
version number is not a promise it can keep yet; the entries below are.
|
|
8
8
|
|
|
9
|
+
## [0.5.0] - 2026-09-13
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **A failed call arrived with no HTTP status, in both runtimes, on every
|
|
14
|
+
host.** `classifyHttp()` / `HttpErrors::classify()` built the right error — a
|
|
15
|
+
`ConnectorAuthError` with `status: 401` — and `deliver()` read its
|
|
16
|
+
classification and dropped it: a `DeliveryOutcome` carried `attempts`,
|
|
17
|
+
`gaveUp` and `kind`, never the error. So `failureFrom()` rebuilt the thrown
|
|
18
|
+
error from the call context alone. The message still quoted the `401`; the
|
|
19
|
+
`status` field was empty.
|
|
20
|
+
|
|
21
|
+
It surfaced in `fancy-connectors`' scheduled Drift workflow, which asks each
|
|
22
|
+
real provider to refuse an impossible credential and reads `error.status` to
|
|
23
|
+
see the refusal. Bluesky, Mastodon and Telegram answered `401` and Discord
|
|
24
|
+
`404` — exactly right — and all four probes reported *"the request failed
|
|
25
|
+
before any status arrived"*, on all 25 runs since the workflow was added.
|
|
26
|
+
|
|
27
|
+
A failed call now keeps what the provider said: **`status`**,
|
|
28
|
+
**`providerCode`** (see Added), and the classified error itself as the
|
|
29
|
+
standard **`cause`** (TS) / **`getPrevious()`** (PHP). Unchanged: the message,
|
|
30
|
+
including the *go and look* wording of an ambiguous refusal, `kind`,
|
|
31
|
+
`retryable`, and, in TypeScript, `attempts` and `idempotent`. Retry decisions
|
|
32
|
+
are untouched — they read `kind`, which was always right.
|
|
33
|
+
|
|
34
|
+
**What a consumer must DO: nothing**, unless you relied on `status` being
|
|
35
|
+
empty (a host that read "no status" as "the provider never answered" now sees
|
|
36
|
+
the number it should always have seen), or on the class — see Changed.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- **The thrown class is now the one the failure was classified as**, in both
|
|
41
|
+
runtimes, and the two runtimes now agree. A `401`/`403` throws
|
|
42
|
+
`ConnectorAuthError` / `ConnectorAuthException`, a `429` throws
|
|
43
|
+
`ConnectorRateLimited` / `ConnectorRateLimitedException` carrying the
|
|
44
|
+
provider's `retryAfter`, and every other failure throws the class its `kind`
|
|
45
|
+
names: `ConnectorTransient`, `ConnectorRequestError`, `ConnectorUnreachable`,
|
|
46
|
+
`ConnectorAmbiguous` (and their `*Exception` twins).
|
|
47
|
+
|
|
48
|
+
Why the specific class rather than status on a generic one: the table at the
|
|
49
|
+
top of `errors.ts` / `ConnectorException.php` is the taxonomy's documented
|
|
50
|
+
promise, and a host catching `ConnectorAuthError` to send someone to
|
|
51
|
+
re-authenticate never received one from a call. `kind` cannot express that
|
|
52
|
+
distinction — a rejected credential and a malformed request are both
|
|
53
|
+
`rejected`, a throttle and a 5xx are both `refused-explicitly` — so it has to
|
|
54
|
+
come from the classified error. And the two runtimes already disagreed:
|
|
55
|
+
TypeScript threw a bare `ConnectorError`, PHP mapped by kind and so threw a
|
|
56
|
+
`ConnectorRequestException` for a rejected credential and a
|
|
57
|
+
`ConnectorTransientException` for a throttle, losing `retryAfter`.
|
|
58
|
+
|
|
59
|
+
- **TypeScript — what to DO: nothing**, unless you compared
|
|
60
|
+
`error.name === "ConnectorError"` or `error.constructor === ConnectorError`.
|
|
61
|
+
Every new class extends `ConnectorError`, so `instanceof ConnectorError`
|
|
62
|
+
still matches; use that. A 2xx whose body is not JSON now throws
|
|
63
|
+
`ConnectorRequestError` (same `kind`, `rejected`), as PHP always did.
|
|
64
|
+
- **PHP — BREAKING for one catch shape.** The exception classes are `final`
|
|
65
|
+
siblings, so `catch (ConnectorRequestException)` written to see a rejected
|
|
66
|
+
credential, or `catch (ConnectorTransientException)` written to see a
|
|
67
|
+
throttle, **no longer catches it**. Add `ConnectorAuthException` /
|
|
68
|
+
`ConnectorRateLimitedException` to that clause, or catch
|
|
69
|
+
`ConnectorException` and branch on `kind()` / `status`. A clause catching
|
|
70
|
+
`ConnectorException` needs nothing.
|
|
71
|
+
|
|
72
|
+
The Fancy-Friends connector packages were checked before release: none
|
|
73
|
+
catches these classes or reads `status`; they surface the message, which is
|
|
74
|
+
unchanged.
|
|
75
|
+
|
|
76
|
+
### Added
|
|
77
|
+
|
|
78
|
+
- **`ServiceDescriptor.providerCodeFrom`** (both runtimes) — where a provider
|
|
79
|
+
puts its OWN error code on a failed response, carried as
|
|
80
|
+
`error.providerCode`. **Declared per service, never guessed**: Bluesky's
|
|
81
|
+
`error` is a code (`"AuthenticationRequired"`), Mastodon's `error` is a
|
|
82
|
+
sentence, Discord's code is an integer under `code`. A generic reader would
|
|
83
|
+
publish Mastodon's sentence as a code. Undeclared, `providerCode` is absent.
|
|
84
|
+
A string or an integer is carried; a blank string, anything else, or a
|
|
85
|
+
**throw** leaves it absent — a reader choking on an HTML error page never
|
|
86
|
+
turns an explicit `401` into an unclassified failure. Optional; nothing a
|
|
87
|
+
connector declares has to change, so `CONNECTOR_API_VERSION` stays `1`.
|
|
88
|
+
|
|
89
|
+
Before this, `providerCode` existed on both error classes and nothing
|
|
90
|
+
anywhere set it.
|
|
91
|
+
- **`DeliveryOutcome.error`** — the last failure as it was thrown, absent when
|
|
92
|
+
the call worked.
|
|
93
|
+
- TypeScript: `ConnectorError` and `ConnectorRateLimited` take a standard
|
|
94
|
+
`ErrorOptions` third argument (`{ cause }`), and `classifyThrown()` now chains
|
|
95
|
+
the original transport error as `cause`, as the PHP twin always passed it as
|
|
96
|
+
`$previous`.
|
|
97
|
+
- PHP: `ConnectorRateLimitedException` takes a trailing `?Throwable $previous`,
|
|
98
|
+
and `HttpErrors::classify()` a trailing `?string $providerCode`. Both are
|
|
99
|
+
appended and optional.
|
|
100
|
+
|
|
101
|
+
## [0.4.0] - 2026-09-12
|
|
102
|
+
|
|
103
|
+
### Fixed
|
|
104
|
+
|
|
105
|
+
- **An idempotency key was fitted to the catalogue CEILING, never to the
|
|
106
|
+
provider's own limit.** `MAX_KEY_LENGTH` / `MAX_IDEMPOTENCY_KEY_LENGTH` is 255
|
|
107
|
+
— the widest any provider in the catalogue accepts. A provider declares its
|
|
108
|
+
own, and the connector index carries it as `idempotencyMaxLength`; Discord's
|
|
109
|
+
`discord_message` declares **25**.
|
|
110
|
+
|
|
111
|
+
So a legitimate engine-derived key — `lane_<16 hex>:subject`, 29 characters —
|
|
112
|
+
came back unshortened and the connector's own validation refused it:
|
|
113
|
+
`message_create: idempotencyKey must be at most 25 characters`. The run failed
|
|
114
|
+
at the node, and the only workaround available to a host was choosing a
|
|
115
|
+
shorter run identity, which is a workflow-authoring decision being forced by a
|
|
116
|
+
string length in a library.
|
|
117
|
+
|
|
118
|
+
The subtle part is that nothing was malformed: the key was correct and the
|
|
119
|
+
validation was correct. This package simply never asked how long the key was
|
|
120
|
+
allowed to be.
|
|
121
|
+
|
|
122
|
+
`keyFor(..., maxLength:)` in PHP and `{ maxLength }` in `IdempotencyOptions`
|
|
123
|
+
now carry the provider's limit, and the smaller of it and the ceiling always
|
|
124
|
+
applies — a descriptor claiming more than any provider accepts is one to
|
|
125
|
+
distrust, not obey. The digest still makes the shortened key **stable across
|
|
126
|
+
attempts**, which is the entire point of one: a key that varied per attempt
|
|
127
|
+
would defeat the dedupe it exists to provide.
|
|
128
|
+
|
|
129
|
+
A limit too small to carry `<head>~<digest>` yields the digest alone. That
|
|
130
|
+
loses the greppable prefix, which is a real cost — but a key that is hard to
|
|
131
|
+
trace still dedupes, while the alternative is a negative slice length.
|
|
132
|
+
|
|
133
|
+
**Both runtimes had this, identically**, so the parity suite comparing them
|
|
134
|
+
stayed green throughout. Agreement is not correctness, and that is the whole
|
|
135
|
+
reason this needed an outside report to surface: it came from the connector
|
|
136
|
+
lab, which hit it on Discord and worked around it by keeping its run key
|
|
137
|
+
artificially short.
|
|
138
|
+
|
|
139
|
+
Not a breaking change — the parameter is optional and omitting it preserves
|
|
140
|
+
the previous behaviour exactly. It lands as a MINOR because callers that
|
|
141
|
+
should pass a limit now can, and the connector emitters will.
|
|
142
|
+
|
|
9
143
|
## [0.3.1] - 2026-08-20
|
|
10
144
|
|
|
11
145
|
### Fixed
|
package/README.md
CHANGED
|
@@ -71,6 +71,28 @@ shouldRetry(classifyError(err).kind, { idempotent: connector.delivery.idempotent
|
|
|
71
71
|
`Idempotency-Key`, or the absence of one — because `idempotent: true` is the one
|
|
72
72
|
claim whose failure is a public duplicate.
|
|
73
73
|
|
|
74
|
+
### What a failed call carries
|
|
75
|
+
|
|
76
|
+
The error a host catches is the class the failure was classified as, and it keeps
|
|
77
|
+
what the provider said:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
try {
|
|
81
|
+
await callConnector(service, options);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (error instanceof ConnectorAuthError) error.status; // 401 or 403
|
|
84
|
+
error.kind; // the retry primitive
|
|
85
|
+
error.providerCode; // only where the service declared `providerCodeFrom`
|
|
86
|
+
error.cause; // the classified error; for a thrown transport, the original below it
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
PHP is the same: `ConnectorAuthException`, `->status`, `->providerCode`,
|
|
91
|
+
`->getPrevious()`. A provider's own error code is read only where its
|
|
92
|
+
`ServiceDescriptor` says where it lives (`providerCodeFrom`) — Bluesky's `error`
|
|
93
|
+
is a code and Mastodon's `error` is a sentence, so a guess would be wrong in the
|
|
94
|
+
most plausible way available.
|
|
95
|
+
|
|
74
96
|
---
|
|
75
97
|
|
|
76
98
|
## Text, and two one-line bugs that ASCII hides
|
package/dist/index.cjs
CHANGED
|
@@ -245,7 +245,8 @@ async function deliver(send, policy = DEFAULT_RETRY, sleep = (ms) => new Promise
|
|
|
245
245
|
ok: false,
|
|
246
246
|
attempts,
|
|
247
247
|
kind: classified.kind,
|
|
248
|
-
gaveUp: classified.kind === "ambiguous" ? `${AMBIGUOUS_REFUSAL} (${classified.detail})` : classified.detail
|
|
248
|
+
gaveUp: classified.kind === "ambiguous" ? `${AMBIGUOUS_REFUSAL} (${classified.detail})` : classified.detail,
|
|
249
|
+
error
|
|
249
250
|
};
|
|
250
251
|
}
|
|
251
252
|
if (attempt >= budget) {
|
|
@@ -254,7 +255,8 @@ async function deliver(send, policy = DEFAULT_RETRY, sleep = (ms) => new Promise
|
|
|
254
255
|
ok: false,
|
|
255
256
|
attempts,
|
|
256
257
|
kind: classified.kind,
|
|
257
|
-
gaveUp: `Gave up after ${attempt} attempts. ${classified.detail}
|
|
258
|
+
gaveUp: `Gave up after ${attempt} attempts. ${classified.detail}`,
|
|
259
|
+
error
|
|
258
260
|
};
|
|
259
261
|
}
|
|
260
262
|
const backoff = Math.min(policy.baseDelayMs * 2 ** (attempt - 1), policy.maxDelayMs);
|
|
@@ -292,8 +294,14 @@ var ConnectorError = class extends Error {
|
|
|
292
294
|
* failure must not be assumed safe.
|
|
293
295
|
*/
|
|
294
296
|
kind = "ambiguous";
|
|
295
|
-
|
|
296
|
-
|
|
297
|
+
/**
|
|
298
|
+
* `options.cause` is the standard `Error` cause — what this error was built
|
|
299
|
+
* from. A failed call's error carries the classified error there, and a thrown
|
|
300
|
+
* transport's carries the original exception, so nothing below is lost when
|
|
301
|
+
* the message is rewritten for a person.
|
|
302
|
+
*/
|
|
303
|
+
constructor(message, ctx, options) {
|
|
304
|
+
super(message, options?.cause === void 0 ? void 0 : { cause: options.cause });
|
|
297
305
|
this.name = new.target.name;
|
|
298
306
|
this.service = ctx.service;
|
|
299
307
|
this.operation = ctx.operation;
|
|
@@ -325,8 +333,8 @@ var ConnectorRateLimited = class extends ConnectorError {
|
|
|
325
333
|
kind = "refused-explicitly";
|
|
326
334
|
/** Seconds to wait, when the provider said. */
|
|
327
335
|
retryAfter;
|
|
328
|
-
constructor(message, ctx) {
|
|
329
|
-
super(message, ctx);
|
|
336
|
+
constructor(message, ctx, options) {
|
|
337
|
+
super(message, ctx, options);
|
|
330
338
|
this.retryAfter = ctx.retryAfter;
|
|
331
339
|
}
|
|
332
340
|
get classified() {
|
|
@@ -373,7 +381,7 @@ function classifyHttp(status, ctx, body, retryAfter) {
|
|
|
373
381
|
function classifyThrown(cause, ctx) {
|
|
374
382
|
const classified = classifyError(cause);
|
|
375
383
|
const message = `${ctx.service}.${ctx.operation}: ${classified.detail}`;
|
|
376
|
-
return classified.kind === "unreachable" ? new ConnectorUnreachable(message, ctx) : new ConnectorAmbiguous(message, ctx);
|
|
384
|
+
return classified.kind === "unreachable" ? new ConnectorUnreachable(message, ctx, { cause }) : new ConnectorAmbiguous(message, ctx, { cause });
|
|
377
385
|
}
|
|
378
386
|
function httpFailure(status, body, retryAfter) {
|
|
379
387
|
const classified = classifyStatus(status, body, retryAfter);
|
|
@@ -604,7 +612,13 @@ async function callConnector(service, options) {
|
|
|
604
612
|
if (response.status < 400) {
|
|
605
613
|
return response.body.trim() === "" ? null : parseJson(response.body, ctx);
|
|
606
614
|
}
|
|
607
|
-
|
|
615
|
+
const providerCode = readProviderCode(service, response);
|
|
616
|
+
throw classifyHttp(
|
|
617
|
+
response.status,
|
|
618
|
+
providerCode === void 0 ? ctx : { ...ctx, providerCode },
|
|
619
|
+
response.body,
|
|
620
|
+
readRetryAfter(response.headers)
|
|
621
|
+
);
|
|
608
622
|
},
|
|
609
623
|
{
|
|
610
624
|
...DEFAULT_RETRY,
|
|
@@ -623,12 +637,39 @@ async function callConnector(service, options) {
|
|
|
623
637
|
function failureFrom(outcome, ctx, idempotent) {
|
|
624
638
|
const last = outcome.attempts.at(-1);
|
|
625
639
|
const kind = outcome.kind ?? last?.kind ?? "ambiguous";
|
|
626
|
-
const
|
|
640
|
+
const message = outcome.gaveUp ?? `${ctx.service}.${ctx.operation} failed.`;
|
|
641
|
+
const cause = outcome.error;
|
|
642
|
+
const said = cause instanceof ConnectorError ? cause : void 0;
|
|
643
|
+
const context = {
|
|
644
|
+
...ctx,
|
|
645
|
+
...said?.status === void 0 ? {} : { status: said.status },
|
|
646
|
+
...said?.providerCode === void 0 ? {} : { providerCode: said.providerCode }
|
|
647
|
+
};
|
|
648
|
+
const error = failureOfClass(kind, said, message, context, cause === void 0 ? void 0 : { cause });
|
|
627
649
|
Object.defineProperty(error, "kind", { value: kind, enumerable: false });
|
|
628
650
|
Object.defineProperty(error, "attempts", { value: outcome.attempts, enumerable: false });
|
|
629
651
|
Object.defineProperty(error, "idempotent", { value: idempotent, enumerable: false });
|
|
630
652
|
return error;
|
|
631
653
|
}
|
|
654
|
+
function failureOfClass(kind, said, message, context, options) {
|
|
655
|
+
if (said?.kind === kind && said instanceof ConnectorAuthError) {
|
|
656
|
+
return new ConnectorAuthError(message, context, options);
|
|
657
|
+
}
|
|
658
|
+
if (said?.kind === kind && said instanceof ConnectorRateLimited) {
|
|
659
|
+
const retryAfter = said.retryAfter === void 0 ? {} : { retryAfter: said.retryAfter };
|
|
660
|
+
return new ConnectorRateLimited(message, { ...context, ...retryAfter }, options);
|
|
661
|
+
}
|
|
662
|
+
switch (kind) {
|
|
663
|
+
case "unreachable":
|
|
664
|
+
return new ConnectorUnreachable(message, context, options);
|
|
665
|
+
case "refused-explicitly":
|
|
666
|
+
return new ConnectorTransient(message, context, options);
|
|
667
|
+
case "rejected":
|
|
668
|
+
return new ConnectorRequestError(message, context, options);
|
|
669
|
+
case "ambiguous":
|
|
670
|
+
return new ConnectorAmbiguous(message, context, options);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
632
673
|
async function prepare(service, connection, options) {
|
|
633
674
|
const base = connection.baseUrl ?? service.baseUrls[connection.mode];
|
|
634
675
|
if (!base) {
|
|
@@ -709,6 +750,17 @@ function parseJson(body, ctx) {
|
|
|
709
750
|
throw error;
|
|
710
751
|
}
|
|
711
752
|
}
|
|
753
|
+
function readProviderCode(service, response) {
|
|
754
|
+
if (!service.providerCodeFrom) return void 0;
|
|
755
|
+
let code;
|
|
756
|
+
try {
|
|
757
|
+
code = service.providerCodeFrom(response);
|
|
758
|
+
} catch {
|
|
759
|
+
return void 0;
|
|
760
|
+
}
|
|
761
|
+
if (typeof code === "number") return Number.isSafeInteger(code) ? String(code) : void 0;
|
|
762
|
+
return typeof code === "string" && code.trim() !== "" ? code : void 0;
|
|
763
|
+
}
|
|
712
764
|
function readRetryAfter(headers) {
|
|
713
765
|
const raw = headers["retry-after"];
|
|
714
766
|
if (!raw) return void 0;
|
|
@@ -937,7 +989,7 @@ function idempotencyKeyFor(ctx, nodeId, options = {}) {
|
|
|
937
989
|
const windowSeconds = options.windowSeconds === void 0 ? DEFAULT_IDEMPOTENCY_WINDOW_SECONDS : options.windowSeconds;
|
|
938
990
|
if (!identity) {
|
|
939
991
|
const runKey = runKeyFrom(ctx);
|
|
940
|
-
return runKey === null ? null : fit(`${runKey}:${nodeId}
|
|
992
|
+
return runKey === null ? null : fit(`${runKey}:${nodeId}`, options.maxLength);
|
|
941
993
|
}
|
|
942
994
|
if (!identity.isReplaySafe(windowSeconds, options.now ?? /* @__PURE__ */ new Date())) {
|
|
943
995
|
throw new ConnectorIdempotencyExpiredError(
|
|
@@ -949,13 +1001,15 @@ function idempotencyKeyFor(ctx, nodeId, options = {}) {
|
|
|
949
1001
|
}
|
|
950
1002
|
);
|
|
951
1003
|
}
|
|
952
|
-
return fit(identity.stepKey(nodeId, options.occurrence));
|
|
1004
|
+
return fit(identity.stepKey(nodeId, options.occurrence), options.maxLength);
|
|
953
1005
|
}
|
|
954
1006
|
var NO_IDEMPOTENCY_KEY_WARNING = "no run identity available, so this write is being sent WITHOUT an idempotency key. A retry would repeat the effect rather than recover from it \u2014 the node is marked unsafe-to-replay, so a durable runner will give it one attempt. Durable runs supply an identity automatically; for a synchronous run, pass `run` in RunOptions.";
|
|
955
|
-
function fit(key) {
|
|
956
|
-
|
|
1007
|
+
function fit(key, maxLength) {
|
|
1008
|
+
const limit = maxLength === void 0 || maxLength === null ? MAX_IDEMPOTENCY_KEY_LENGTH : Math.max(1, Math.min(maxLength, MAX_IDEMPOTENCY_KEY_LENGTH));
|
|
1009
|
+
if (key.length <= limit) return key;
|
|
957
1010
|
const digest = fnv1a(key);
|
|
958
|
-
|
|
1011
|
+
if (limit <= digest.length + 1) return digest.slice(0, limit);
|
|
1012
|
+
const head = key.slice(0, limit - digest.length - 1);
|
|
959
1013
|
return `${head}~${digest}`;
|
|
960
1014
|
}
|
|
961
1015
|
function fnv1a(value) {
|