@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/dist/index.d.cts CHANGED
@@ -346,6 +346,19 @@ type DeliveryOutcome<T> = {
346
346
  gaveUp?: string;
347
347
  /** The classification of the last failure, so a host can route on it. */
348
348
  kind?: FailureKind;
349
+ /**
350
+ * The last failure **as it was thrown** — absent when the call worked.
351
+ *
352
+ * `kind` and `gaveUp` are what `deliver()` concluded; this is what it
353
+ * concluded it FROM. Until 0.5.0 an outcome carried only the conclusions, so
354
+ * everything the error knew beyond them — the HTTP status, the provider's own
355
+ * code, the class it was raised as — stopped here, and every failed
356
+ * `callConnector` reached its host with none of it. See `failureFrom()` in
357
+ * `client.ts`.
358
+ *
359
+ * `unknown`, because `send` may throw anything.
360
+ */
361
+ error?: unknown;
349
362
  };
350
363
  /**
351
364
  * The message a person reads when an ambiguous failure could not be retried.
@@ -774,6 +787,26 @@ type ServiceDescriptor = {
774
787
  faker: ConnectorFaker;
775
788
  /** Header the provider uses for idempotency, when it has one. */
776
789
  idempotencyHeader?: string;
790
+ /**
791
+ * Where this provider puts its OWN error code on a failed response, when it
792
+ * publishes one — read only for a status of 400 or above, and carried as
793
+ * `error.providerCode`.
794
+ *
795
+ * **Declared per service, never guessed.** The shapes do not agree, and the
796
+ * near-misses are the dangerous part: Bluesky's `error` is a code
797
+ * (`"AuthenticationRequired"`), Mastodon's `error` is a sentence
798
+ * (`"The access token is invalid"`), Discord's code is a number under `code`,
799
+ * and some providers use a header. A generic reader would publish Mastodon's
800
+ * sentence as a code — the plausible answer that is wrong. With no
801
+ * declaration the field is absent, which says *nobody said where to look*.
802
+ *
803
+ * Return a string or an integer (carried as its decimal string); anything
804
+ * else, a blank string, or a throw, leaves the code absent. **A throw never
805
+ * reaches the call**: a code is a detail on an answer the status already gave,
806
+ * and a reader choking on an HTML error page must not turn an explicit `401`
807
+ * into an unclassified failure.
808
+ */
809
+ providerCodeFrom?: (response: TransportResponse) => string | number | null | undefined;
777
810
  };
778
811
  type ConnectorRequest = {
779
812
  method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
@@ -1108,7 +1141,13 @@ declare class ConnectorError extends Error {
1108
1141
  * failure must not be assumed safe.
1109
1142
  */
1110
1143
  readonly kind: FailureKind;
1111
- constructor(message: string, ctx: ConnectorErrorContext);
1144
+ /**
1145
+ * `options.cause` is the standard `Error` cause — what this error was built
1146
+ * from. A failed call's error carries the classified error there, and a thrown
1147
+ * transport's carries the original exception, so nothing below is lost when
1148
+ * the message is rewritten for a person.
1149
+ */
1150
+ constructor(message: string, ctx: ConnectorErrorContext, options?: ErrorOptions);
1112
1151
  /**
1113
1152
  * Safe to retry **without knowing anything about the connector**.
1114
1153
  *
@@ -1141,7 +1180,7 @@ declare class ConnectorRateLimited extends ConnectorError {
1141
1180
  readonly retryAfter?: number;
1142
1181
  constructor(message: string, ctx: ConnectorErrorContext & {
1143
1182
  retryAfter?: number;
1144
- });
1183
+ }, options?: ErrorOptions);
1145
1184
  get classified(): Classified;
1146
1185
  }
1147
1186
  /**
package/dist/index.d.ts CHANGED
@@ -346,6 +346,19 @@ type DeliveryOutcome<T> = {
346
346
  gaveUp?: string;
347
347
  /** The classification of the last failure, so a host can route on it. */
348
348
  kind?: FailureKind;
349
+ /**
350
+ * The last failure **as it was thrown** — absent when the call worked.
351
+ *
352
+ * `kind` and `gaveUp` are what `deliver()` concluded; this is what it
353
+ * concluded it FROM. Until 0.5.0 an outcome carried only the conclusions, so
354
+ * everything the error knew beyond them — the HTTP status, the provider's own
355
+ * code, the class it was raised as — stopped here, and every failed
356
+ * `callConnector` reached its host with none of it. See `failureFrom()` in
357
+ * `client.ts`.
358
+ *
359
+ * `unknown`, because `send` may throw anything.
360
+ */
361
+ error?: unknown;
349
362
  };
350
363
  /**
351
364
  * The message a person reads when an ambiguous failure could not be retried.
@@ -774,6 +787,26 @@ type ServiceDescriptor = {
774
787
  faker: ConnectorFaker;
775
788
  /** Header the provider uses for idempotency, when it has one. */
776
789
  idempotencyHeader?: string;
790
+ /**
791
+ * Where this provider puts its OWN error code on a failed response, when it
792
+ * publishes one — read only for a status of 400 or above, and carried as
793
+ * `error.providerCode`.
794
+ *
795
+ * **Declared per service, never guessed.** The shapes do not agree, and the
796
+ * near-misses are the dangerous part: Bluesky's `error` is a code
797
+ * (`"AuthenticationRequired"`), Mastodon's `error` is a sentence
798
+ * (`"The access token is invalid"`), Discord's code is a number under `code`,
799
+ * and some providers use a header. A generic reader would publish Mastodon's
800
+ * sentence as a code — the plausible answer that is wrong. With no
801
+ * declaration the field is absent, which says *nobody said where to look*.
802
+ *
803
+ * Return a string or an integer (carried as its decimal string); anything
804
+ * else, a blank string, or a throw, leaves the code absent. **A throw never
805
+ * reaches the call**: a code is a detail on an answer the status already gave,
806
+ * and a reader choking on an HTML error page must not turn an explicit `401`
807
+ * into an unclassified failure.
808
+ */
809
+ providerCodeFrom?: (response: TransportResponse) => string | number | null | undefined;
777
810
  };
778
811
  type ConnectorRequest = {
779
812
  method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
@@ -1108,7 +1141,13 @@ declare class ConnectorError extends Error {
1108
1141
  * failure must not be assumed safe.
1109
1142
  */
1110
1143
  readonly kind: FailureKind;
1111
- constructor(message: string, ctx: ConnectorErrorContext);
1144
+ /**
1145
+ * `options.cause` is the standard `Error` cause — what this error was built
1146
+ * from. A failed call's error carries the classified error there, and a thrown
1147
+ * transport's carries the original exception, so nothing below is lost when
1148
+ * the message is rewritten for a person.
1149
+ */
1150
+ constructor(message: string, ctx: ConnectorErrorContext, options?: ErrorOptions);
1112
1151
  /**
1113
1152
  * Safe to retry **without knowing anything about the connector**.
1114
1153
  *
@@ -1141,7 +1180,7 @@ declare class ConnectorRateLimited extends ConnectorError {
1141
1180
  readonly retryAfter?: number;
1142
1181
  constructor(message: string, ctx: ConnectorErrorContext & {
1143
1182
  retryAfter?: number;
1144
- });
1183
+ }, options?: ErrorOptions);
1145
1184
  get classified(): Classified;
1146
1185
  }
1147
1186
  /**
package/dist/index.js CHANGED
@@ -137,7 +137,8 @@ async function deliver(send, policy = DEFAULT_RETRY, sleep = (ms) => new Promise
137
137
  ok: false,
138
138
  attempts,
139
139
  kind: classified.kind,
140
- gaveUp: classified.kind === "ambiguous" ? `${AMBIGUOUS_REFUSAL} (${classified.detail})` : classified.detail
140
+ gaveUp: classified.kind === "ambiguous" ? `${AMBIGUOUS_REFUSAL} (${classified.detail})` : classified.detail,
141
+ error
141
142
  };
142
143
  }
143
144
  if (attempt >= budget) {
@@ -146,7 +147,8 @@ async function deliver(send, policy = DEFAULT_RETRY, sleep = (ms) => new Promise
146
147
  ok: false,
147
148
  attempts,
148
149
  kind: classified.kind,
149
- gaveUp: `Gave up after ${attempt} attempts. ${classified.detail}`
150
+ gaveUp: `Gave up after ${attempt} attempts. ${classified.detail}`,
151
+ error
150
152
  };
151
153
  }
152
154
  const backoff = Math.min(policy.baseDelayMs * 2 ** (attempt - 1), policy.maxDelayMs);
@@ -184,8 +186,14 @@ var ConnectorError = class extends Error {
184
186
  * failure must not be assumed safe.
185
187
  */
186
188
  kind = "ambiguous";
187
- constructor(message, ctx) {
188
- super(message);
189
+ /**
190
+ * `options.cause` is the standard `Error` cause — what this error was built
191
+ * from. A failed call's error carries the classified error there, and a thrown
192
+ * transport's carries the original exception, so nothing below is lost when
193
+ * the message is rewritten for a person.
194
+ */
195
+ constructor(message, ctx, options) {
196
+ super(message, options?.cause === void 0 ? void 0 : { cause: options.cause });
189
197
  this.name = new.target.name;
190
198
  this.service = ctx.service;
191
199
  this.operation = ctx.operation;
@@ -217,8 +225,8 @@ var ConnectorRateLimited = class extends ConnectorError {
217
225
  kind = "refused-explicitly";
218
226
  /** Seconds to wait, when the provider said. */
219
227
  retryAfter;
220
- constructor(message, ctx) {
221
- super(message, ctx);
228
+ constructor(message, ctx, options) {
229
+ super(message, ctx, options);
222
230
  this.retryAfter = ctx.retryAfter;
223
231
  }
224
232
  get classified() {
@@ -265,7 +273,7 @@ function classifyHttp(status, ctx, body, retryAfter) {
265
273
  function classifyThrown(cause, ctx) {
266
274
  const classified = classifyError(cause);
267
275
  const message = `${ctx.service}.${ctx.operation}: ${classified.detail}`;
268
- return classified.kind === "unreachable" ? new ConnectorUnreachable(message, ctx) : new ConnectorAmbiguous(message, ctx);
276
+ return classified.kind === "unreachable" ? new ConnectorUnreachable(message, ctx, { cause }) : new ConnectorAmbiguous(message, ctx, { cause });
269
277
  }
270
278
  function httpFailure(status, body, retryAfter) {
271
279
  const classified = classifyStatus(status, body, retryAfter);
@@ -496,7 +504,13 @@ async function callConnector(service, options) {
496
504
  if (response.status < 400) {
497
505
  return response.body.trim() === "" ? null : parseJson(response.body, ctx);
498
506
  }
499
- throw classifyHttp(response.status, ctx, response.body, readRetryAfter(response.headers));
507
+ const providerCode = readProviderCode(service, response);
508
+ throw classifyHttp(
509
+ response.status,
510
+ providerCode === void 0 ? ctx : { ...ctx, providerCode },
511
+ response.body,
512
+ readRetryAfter(response.headers)
513
+ );
500
514
  },
501
515
  {
502
516
  ...DEFAULT_RETRY,
@@ -515,12 +529,39 @@ async function callConnector(service, options) {
515
529
  function failureFrom(outcome, ctx, idempotent) {
516
530
  const last = outcome.attempts.at(-1);
517
531
  const kind = outcome.kind ?? last?.kind ?? "ambiguous";
518
- const error = new ConnectorError(outcome.gaveUp ?? `${ctx.service}.${ctx.operation} failed.`, ctx);
532
+ const message = outcome.gaveUp ?? `${ctx.service}.${ctx.operation} failed.`;
533
+ const cause = outcome.error;
534
+ const said = cause instanceof ConnectorError ? cause : void 0;
535
+ const context = {
536
+ ...ctx,
537
+ ...said?.status === void 0 ? {} : { status: said.status },
538
+ ...said?.providerCode === void 0 ? {} : { providerCode: said.providerCode }
539
+ };
540
+ const error = failureOfClass(kind, said, message, context, cause === void 0 ? void 0 : { cause });
519
541
  Object.defineProperty(error, "kind", { value: kind, enumerable: false });
520
542
  Object.defineProperty(error, "attempts", { value: outcome.attempts, enumerable: false });
521
543
  Object.defineProperty(error, "idempotent", { value: idempotent, enumerable: false });
522
544
  return error;
523
545
  }
546
+ function failureOfClass(kind, said, message, context, options) {
547
+ if (said?.kind === kind && said instanceof ConnectorAuthError) {
548
+ return new ConnectorAuthError(message, context, options);
549
+ }
550
+ if (said?.kind === kind && said instanceof ConnectorRateLimited) {
551
+ const retryAfter = said.retryAfter === void 0 ? {} : { retryAfter: said.retryAfter };
552
+ return new ConnectorRateLimited(message, { ...context, ...retryAfter }, options);
553
+ }
554
+ switch (kind) {
555
+ case "unreachable":
556
+ return new ConnectorUnreachable(message, context, options);
557
+ case "refused-explicitly":
558
+ return new ConnectorTransient(message, context, options);
559
+ case "rejected":
560
+ return new ConnectorRequestError(message, context, options);
561
+ case "ambiguous":
562
+ return new ConnectorAmbiguous(message, context, options);
563
+ }
564
+ }
524
565
  async function prepare(service, connection, options) {
525
566
  const base = connection.baseUrl ?? service.baseUrls[connection.mode];
526
567
  if (!base) {
@@ -601,6 +642,17 @@ function parseJson(body, ctx) {
601
642
  throw error;
602
643
  }
603
644
  }
645
+ function readProviderCode(service, response) {
646
+ if (!service.providerCodeFrom) return void 0;
647
+ let code;
648
+ try {
649
+ code = service.providerCodeFrom(response);
650
+ } catch {
651
+ return void 0;
652
+ }
653
+ if (typeof code === "number") return Number.isSafeInteger(code) ? String(code) : void 0;
654
+ return typeof code === "string" && code.trim() !== "" ? code : void 0;
655
+ }
604
656
  function readRetryAfter(headers) {
605
657
  const raw = headers["retry-after"];
606
658
  if (!raw) return void 0;