@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 +138 -1
- package/README.md +28 -0
- package/dist/index.cjs +61 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +41 -2
- package/dist/index.d.ts +41 -2
- package/dist/index.js +61 -9
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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;
|