@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 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
- constructor(message, ctx) {
296
- super(message);
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
- throw classifyHttp(response.status, ctx, response.body, readRetryAfter(response.headers));
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 error = new ConnectorError(outcome.gaveUp ?? `${ctx.service}.${ctx.operation} failed.`, ctx);
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
- if (key.length <= MAX_IDEMPOTENCY_KEY_LENGTH) return key;
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
- const head = key.slice(0, MAX_IDEMPOTENCY_KEY_LENGTH - digest.length - 1);
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) {