@particle-academy/fancy-connector-core 0.4.0 → 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 +92 -0
- package/README.md +22 -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,98 @@ 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
|
+
|
|
9
101
|
## [0.4.0] - 2026-09-12
|
|
10
102
|
|
|
11
103
|
### 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;
|