@particle-academy/fancy-connector-core 0.4.0 → 0.6.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,138 @@ 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.6.0] - 2026-09-13
10
+
11
+ Additive only. **What a consumer must DO: nothing** — see each entry.
12
+
13
+ ### Added
14
+
15
+ - **PHP: the exception a failed call throws now carries `attempts` and
16
+ `idempotent`**, as the TypeScript error has since 0.1.0. `attempts` is every
17
+ failed attempt of the call, in order, as `list<Attempt>`; `idempotent` is
18
+ what the call declared. Without them a PHP host could see *what* failed but
19
+ not whether a timeout went unretried because the connector is not idempotent
20
+ or was retried until the budget ran out — which is the difference between
21
+ *go and look* and *run it again*.
22
+
23
+ Both are `null` on any exception that did not end a call — one you construct
24
+ yourself, one from `HttpErrors::classify()`, and the classified exception
25
+ chained as `getPrevious()`. Deliberately not `[]` / `false`, which would
26
+ claim nothing was tried, or that the connector declared a retry unsafe.
27
+ TypeScript is unchanged: there they stay absent.
28
+
29
+ **What a consumer must DO: nothing.** Both are new trailing, optional
30
+ constructor parameters on `ConnectorException` and
31
+ `ConnectorRateLimitedException`, so existing `new …Exception(...)` calls,
32
+ positional or named, keep working. A subclass of `ConnectorException` that
33
+ declares its own constructor does not need to pass them.
34
+
35
+ ### Changed
36
+
37
+ - **CI now runs `pint --test` on the PHP tree**, and the tree passes it. Pint
38
+ was already a dev dependency and `composer lint` already called it, but no
39
+ workflow did, so three files had drifted (`RenderRules.php`,
40
+ `ServiceDescriptor.php`, `Socialite/SocialiteBridge.php`) — formatting only,
41
+ no behaviour change.
42
+ - **Line endings are pinned to LF in `.gitattributes`.** A Windows clone with
43
+ `core.autocrlf=true` checked every file out as CRLF, which made `pint --test`
44
+ fail on nearly every PHP file locally while CI saw three, and made
45
+ `scripts/vendor.mjs` generate a `_connector/` copy whose bytes depended on
46
+ the machine it ran on. **What a consumer must DO: nothing** — the published
47
+ files were already LF; only a checkout of this repository changes.
48
+
49
+ ## [0.5.0] - 2026-09-13
50
+
51
+ ### Fixed
52
+
53
+ - **A failed call arrived with no HTTP status, in both runtimes, on every
54
+ host.** `classifyHttp()` / `HttpErrors::classify()` built the right error — a
55
+ `ConnectorAuthError` with `status: 401` — and `deliver()` read its
56
+ classification and dropped it: a `DeliveryOutcome` carried `attempts`,
57
+ `gaveUp` and `kind`, never the error. So `failureFrom()` rebuilt the thrown
58
+ error from the call context alone. The message still quoted the `401`; the
59
+ `status` field was empty.
60
+
61
+ It surfaced in `fancy-connectors`' scheduled Drift workflow, which asks each
62
+ real provider to refuse an impossible credential and reads `error.status` to
63
+ see the refusal. Bluesky, Mastodon and Telegram answered `401` and Discord
64
+ `404` — exactly right — and all four probes reported *"the request failed
65
+ before any status arrived"*, on all 25 runs since the workflow was added.
66
+
67
+ A failed call now keeps what the provider said: **`status`**,
68
+ **`providerCode`** (see Added), and the classified error itself as the
69
+ standard **`cause`** (TS) / **`getPrevious()`** (PHP). Unchanged: the message,
70
+ including the *go and look* wording of an ambiguous refusal, `kind`,
71
+ `retryable`, and, in TypeScript, `attempts` and `idempotent`. Retry decisions
72
+ are untouched — they read `kind`, which was always right.
73
+
74
+ **What a consumer must DO: nothing**, unless you relied on `status` being
75
+ empty (a host that read "no status" as "the provider never answered" now sees
76
+ the number it should always have seen), or on the class — see Changed.
77
+
78
+ ### Changed
79
+
80
+ - **The thrown class is now the one the failure was classified as**, in both
81
+ runtimes, and the two runtimes now agree. A `401`/`403` throws
82
+ `ConnectorAuthError` / `ConnectorAuthException`, a `429` throws
83
+ `ConnectorRateLimited` / `ConnectorRateLimitedException` carrying the
84
+ provider's `retryAfter`, and every other failure throws the class its `kind`
85
+ names: `ConnectorTransient`, `ConnectorRequestError`, `ConnectorUnreachable`,
86
+ `ConnectorAmbiguous` (and their `*Exception` twins).
87
+
88
+ Why the specific class rather than status on a generic one: the table at the
89
+ top of `errors.ts` / `ConnectorException.php` is the taxonomy's documented
90
+ promise, and a host catching `ConnectorAuthError` to send someone to
91
+ re-authenticate never received one from a call. `kind` cannot express that
92
+ distinction — a rejected credential and a malformed request are both
93
+ `rejected`, a throttle and a 5xx are both `refused-explicitly` — so it has to
94
+ come from the classified error. And the two runtimes already disagreed:
95
+ TypeScript threw a bare `ConnectorError`, PHP mapped by kind and so threw a
96
+ `ConnectorRequestException` for a rejected credential and a
97
+ `ConnectorTransientException` for a throttle, losing `retryAfter`.
98
+
99
+ - **TypeScript — what to DO: nothing**, unless you compared
100
+ `error.name === "ConnectorError"` or `error.constructor === ConnectorError`.
101
+ Every new class extends `ConnectorError`, so `instanceof ConnectorError`
102
+ still matches; use that. A 2xx whose body is not JSON now throws
103
+ `ConnectorRequestError` (same `kind`, `rejected`), as PHP always did.
104
+ - **PHP — BREAKING for one catch shape.** The exception classes are `final`
105
+ siblings, so `catch (ConnectorRequestException)` written to see a rejected
106
+ credential, or `catch (ConnectorTransientException)` written to see a
107
+ throttle, **no longer catches it**. Add `ConnectorAuthException` /
108
+ `ConnectorRateLimitedException` to that clause, or catch
109
+ `ConnectorException` and branch on `kind()` / `status`. A clause catching
110
+ `ConnectorException` needs nothing.
111
+
112
+ The Fancy-Friends connector packages were checked before release: none
113
+ catches these classes or reads `status`; they surface the message, which is
114
+ unchanged.
115
+
116
+ ### Added
117
+
118
+ - **`ServiceDescriptor.providerCodeFrom`** (both runtimes) — where a provider
119
+ puts its OWN error code on a failed response, carried as
120
+ `error.providerCode`. **Declared per service, never guessed**: Bluesky's
121
+ `error` is a code (`"AuthenticationRequired"`), Mastodon's `error` is a
122
+ sentence, Discord's code is an integer under `code`. A generic reader would
123
+ publish Mastodon's sentence as a code. Undeclared, `providerCode` is absent.
124
+ A string or an integer is carried; a blank string, anything else, or a
125
+ **throw** leaves it absent — a reader choking on an HTML error page never
126
+ turns an explicit `401` into an unclassified failure. Optional; nothing a
127
+ connector declares has to change, so `CONNECTOR_API_VERSION` stays `1`.
128
+
129
+ Before this, `providerCode` existed on both error classes and nothing
130
+ anywhere set it.
131
+ - **`DeliveryOutcome.error`** — the last failure as it was thrown, absent when
132
+ the call worked.
133
+ - TypeScript: `ConnectorError` and `ConnectorRateLimited` take a standard
134
+ `ErrorOptions` third argument (`{ cause }`), and `classifyThrown()` now chains
135
+ the original transport error as `cause`, as the PHP twin always passed it as
136
+ `$previous`.
137
+ - PHP: `ConnectorRateLimitedException` takes a trailing `?Throwable $previous`,
138
+ and `HttpErrors::classify()` a trailing `?string $providerCode`. Both are
139
+ appended and optional.
140
+
9
141
  ## [0.4.0] - 2026-09-12
10
142
 
11
143
  ### Fixed
@@ -279,6 +411,11 @@ clock; this package is the runtime it is written on.
279
411
  satisfies it with no dependency in either direction and a host that has never
280
412
  heard of a workflow engine can implement it.
281
413
 
282
- [Unreleased]: https://github.com/Particle-Academy/fancy-connector-core/compare/v0.2.0...HEAD
414
+ [Unreleased]: https://github.com/Particle-Academy/fancy-connector-core/compare/v0.6.0...HEAD
415
+ [0.6.0]: https://github.com/Particle-Academy/fancy-connector-core/compare/v0.5.0...v0.6.0
416
+ [0.5.0]: https://github.com/Particle-Academy/fancy-connector-core/compare/v0.4.0...v0.5.0
417
+ [0.4.0]: https://github.com/Particle-Academy/fancy-connector-core/compare/v0.3.1...v0.4.0
418
+ [0.3.1]: https://github.com/Particle-Academy/fancy-connector-core/compare/v0.3.0...v0.3.1
419
+ [0.3.0]: https://github.com/Particle-Academy/fancy-connector-core/compare/v0.2.0...v0.3.0
283
420
  [0.2.0]: https://github.com/Particle-Academy/fancy-connector-core/compare/v0.1.0...v0.2.0
284
421
  [0.1.0]: https://github.com/Particle-Academy/fancy-connector-core/releases/tag/v0.1.0
package/README.md CHANGED
@@ -71,6 +71,34 @@ 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
+ // Set at runtime but not declared on the `ConnectorError` type, so read them
88
+ // through a narrowing of your own:
89
+ (error as { attempts?: Attempt[] }).attempts; // every failed attempt of the call, in order
90
+ (error as { idempotent?: boolean }).idempotent; // what the call declared — "never allowed to retry" vs "retries ran out"
91
+ }
92
+ ```
93
+
94
+ PHP is the same: `ConnectorAuthException`, `->status`, `->providerCode`,
95
+ `->getPrevious()`, `->attempts`, `->idempotent`. The last two are set only on the
96
+ error a call throws; anywhere else they are absent (TS) or `null` (PHP), never
97
+ `[]` or `false`. A provider's own error code is read only where its
98
+ `ServiceDescriptor` says where it lives (`providerCodeFrom`) — Bluesky's `error`
99
+ is a code and Mastodon's `error` is a sentence, so a guess would be wrong in the
100
+ most plausible way available.
101
+
74
102
  ---
75
103
 
76
104
  ## 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;