@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/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
  /**
@@ -1305,6 +1344,17 @@ type IdempotencyOptions = {
1305
1344
  now?: Date;
1306
1345
  /** For the error message when a key cannot be made safe. */
1307
1346
  context?: Partial<ConnectorErrorContext>;
1347
+ /**
1348
+ * The PROVIDER'S own limit, when it declares one — `idempotencyMaxLength` in
1349
+ * the connector index. Omitted uses {@link MAX_IDEMPOTENCY_KEY_LENGTH}, which
1350
+ * is the catalogue CEILING rather than any one provider's limit.
1351
+ *
1352
+ * Discord's `discord_message` declares 25, and for years only the ceiling was
1353
+ * applied — so a legitimate engine-derived key came back at 29 characters and
1354
+ * the connector's own validation refused it, failing the run at a node the
1355
+ * host had no way to fix.
1356
+ */
1357
+ maxLength?: number | null;
1308
1358
  };
1309
1359
  /**
1310
1360
  * The idempotency key for this execution of this node, or `null`.
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
  /**
@@ -1305,6 +1344,17 @@ type IdempotencyOptions = {
1305
1344
  now?: Date;
1306
1345
  /** For the error message when a key cannot be made safe. */
1307
1346
  context?: Partial<ConnectorErrorContext>;
1347
+ /**
1348
+ * The PROVIDER'S own limit, when it declares one — `idempotencyMaxLength` in
1349
+ * the connector index. Omitted uses {@link MAX_IDEMPOTENCY_KEY_LENGTH}, which
1350
+ * is the catalogue CEILING rather than any one provider's limit.
1351
+ *
1352
+ * Discord's `discord_message` declares 25, and for years only the ceiling was
1353
+ * applied — so a legitimate engine-derived key came back at 29 characters and
1354
+ * the connector's own validation refused it, failing the run at a node the
1355
+ * host had no way to fix.
1356
+ */
1357
+ maxLength?: number | null;
1308
1358
  };
1309
1359
  /**
1310
1360
  * The idempotency key for this execution of this node, or `null`.
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;
@@ -829,7 +881,7 @@ function idempotencyKeyFor(ctx, nodeId, options = {}) {
829
881
  const windowSeconds = options.windowSeconds === void 0 ? DEFAULT_IDEMPOTENCY_WINDOW_SECONDS : options.windowSeconds;
830
882
  if (!identity) {
831
883
  const runKey = runKeyFrom(ctx);
832
- return runKey === null ? null : fit(`${runKey}:${nodeId}`);
884
+ return runKey === null ? null : fit(`${runKey}:${nodeId}`, options.maxLength);
833
885
  }
834
886
  if (!identity.isReplaySafe(windowSeconds, options.now ?? /* @__PURE__ */ new Date())) {
835
887
  throw new ConnectorIdempotencyExpiredError(
@@ -841,13 +893,15 @@ function idempotencyKeyFor(ctx, nodeId, options = {}) {
841
893
  }
842
894
  );
843
895
  }
844
- return fit(identity.stepKey(nodeId, options.occurrence));
896
+ return fit(identity.stepKey(nodeId, options.occurrence), options.maxLength);
845
897
  }
846
898
  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.";
847
- function fit(key) {
848
- if (key.length <= MAX_IDEMPOTENCY_KEY_LENGTH) return key;
899
+ function fit(key, maxLength) {
900
+ const limit = maxLength === void 0 || maxLength === null ? MAX_IDEMPOTENCY_KEY_LENGTH : Math.max(1, Math.min(maxLength, MAX_IDEMPOTENCY_KEY_LENGTH));
901
+ if (key.length <= limit) return key;
849
902
  const digest = fnv1a(key);
850
- const head = key.slice(0, MAX_IDEMPOTENCY_KEY_LENGTH - digest.length - 1);
903
+ if (limit <= digest.length + 1) return digest.slice(0, limit);
904
+ const head = key.slice(0, limit - digest.length - 1);
851
905
  return `${head}~${digest}`;
852
906
  }
853
907
  function fnv1a(value) {