@farthershore/backend 0.8.2 → 0.9.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/README.md CHANGED
@@ -4,9 +4,9 @@ Runtime metering and gateway-verification SDK for builder upstreams. Install one
4
4
  package, set one token (`FS_RUNTIME_TOKEN`), and Farther Shore handles signed
5
5
  gateway-to-upstream request verification plus response-bound usage reporting.
6
6
 
7
- > **Status: `0.8.2` (lockstep SDK family).** Published at the SAME version as
8
- > `@farthershore/farthershore-js` and `@farthershore/product` pin the three
9
- > together. Pre-1.0: minor bumps may break.
7
+ > **Status: `0.8.2`.** Versions independently from
8
+ > `@farthershore/farthershore-js` and `@farthershore/business`. Pre-1.0: minor
9
+ > bumps may break, so pin this package exactly or use a patch-only range.
10
10
 
11
11
  ## Install
12
12
 
package/dist/index.js CHANGED
@@ -719,8 +719,37 @@ function stringifyCause(cause) {
719
719
  return String(cause);
720
720
  }
721
721
 
722
+ // src/core/backoff.ts
723
+ function computeBackoff(attempt, options) {
724
+ const { baseMs, maxMs, jitter = "equal", random = Math.random } = options;
725
+ const exponent = Math.max(0, attempt - 1);
726
+ const cap = Math.min(baseMs * 2 ** exponent, maxMs);
727
+ switch (jitter) {
728
+ case "none":
729
+ return cap;
730
+ case "full":
731
+ return random() * cap;
732
+ case "equal":
733
+ default:
734
+ return cap / 2 + random() * (cap / 2);
735
+ }
736
+ }
737
+
722
738
  // src/core/metering.ts
723
739
  var METER_KEY_RE = /^[a-z0-9_]{1,64}$/;
740
+ var DEFAULT_BASE_DELAY_MS = 200;
741
+ var DEFAULT_MAX_DELAY_MS = 1e4;
742
+ function isTransientStatus(status) {
743
+ return status === 429 || status >= 500;
744
+ }
745
+ function retryAfterMs(headers) {
746
+ const raw = headers.get("retry-after");
747
+ if (raw === null) return null;
748
+ const trimmed = raw.trim();
749
+ if (!/^\d+$/.test(trimmed)) return null;
750
+ const secs = Number(trimmed);
751
+ return Number.isFinite(secs) ? secs * 1e3 : null;
752
+ }
724
753
  var DEFAULT_MAX_RETRIES = 3;
725
754
  var MeteringClient = class {
726
755
  config;
@@ -729,6 +758,10 @@ var MeteringClient = class {
729
758
  backendId;
730
759
  fetchImpl;
731
760
  maxRetries;
761
+ baseDelayMs;
762
+ maxDelayMs;
763
+ sleep;
764
+ random;
732
765
  newId;
733
766
  now;
734
767
  buffer = [];
@@ -739,6 +772,10 @@ var MeteringClient = class {
739
772
  this.backendId = options.backendId;
740
773
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
741
774
  this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
775
+ this.baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
776
+ this.maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
777
+ this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
778
+ this.random = options.random ?? Math.random;
742
779
  this.newId = options.newId ?? (() => crypto.randomUUID());
743
780
  this.now = options.now ?? (() => /* @__PURE__ */ new Date());
744
781
  }
@@ -821,6 +858,7 @@ var MeteringClient = class {
821
858
  }
822
859
  async sendWithRetry(event) {
823
860
  for (let attempt = 0; attempt < this.maxRetries; attempt += 1) {
861
+ let retryAfter = null;
824
862
  try {
825
863
  const response = await this.fetchImpl(this.endpoint, {
826
864
  method: "POST",
@@ -832,8 +870,18 @@ var MeteringClient = class {
832
870
  body: JSON.stringify(event)
833
871
  });
834
872
  if (response.ok) return true;
873
+ if (!isTransientStatus(response.status)) return false;
874
+ retryAfter = retryAfterMs(response.headers);
835
875
  } catch {
836
876
  }
877
+ const isLast = attempt === this.maxRetries - 1;
878
+ if (isLast) break;
879
+ const delay = retryAfter !== null ? Math.min(retryAfter, this.maxDelayMs) : computeBackoff(attempt + 1, {
880
+ baseMs: this.baseDelayMs,
881
+ maxMs: this.maxDelayMs,
882
+ random: this.random
883
+ });
884
+ await this.sleep(delay);
837
885
  }
838
886
  return false;
839
887
  }
@@ -946,6 +994,8 @@ var CloudflaredSupervisor = class {
946
994
  failClosed;
947
995
  baseBackoffMs;
948
996
  maxBackoffMs;
997
+ backoffJitter;
998
+ random;
949
999
  setTimeoutFn;
950
1000
  clearTimeoutFn;
951
1001
  childEnv;
@@ -973,6 +1023,8 @@ var CloudflaredSupervisor = class {
973
1023
  this.failClosed = options.failClosed ?? false;
974
1024
  this.baseBackoffMs = options.baseBackoffMs ?? DEFAULT_BASE_BACKOFF_MS;
975
1025
  this.maxBackoffMs = options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
1026
+ this.backoffJitter = options.backoffJitter ?? "equal";
1027
+ this.random = options.random ?? Math.random;
976
1028
  this.setTimeoutFn = options.setTimeoutFn ?? ((cb, ms) => setTimeout(cb, ms));
977
1029
  this.clearTimeoutFn = options.clearTimeoutFn ?? ((h) => clearTimeout(h));
978
1030
  this.childEnv = options.childEnv;
@@ -1370,8 +1422,8 @@ function headerGetter(headers) {
1370
1422
 
1371
1423
  // src/core/runtime.ts
1372
1424
  var DEFAULT_CORE_URL = "https://core.farthershore.com";
1373
- var SDK_VERSION = "0.8.2".length > 0 ? "0.8.2" : "0.0.0-dev";
1374
- var CONTRACTS_FP = "5beebf042c7ce96d".length > 0 ? "5beebf042c7ce96d" : "0000000000000000";
1425
+ var SDK_VERSION = "0.9.0".length > 0 ? "0.9.0" : "0.0.0-dev";
1426
+ var CONTRACTS_FP = "bd767c2d91739744".length > 0 ? "bd767c2d91739744" : "0000000000000000";
1375
1427
  var FartherShore = class {
1376
1428
  bootstrapClient;
1377
1429
  fetchImpl;
@@ -1727,6 +1779,8 @@ function buildPayload(request, usage, options, wrapOptions) {
1727
1779
  const url = new URL(request.url);
1728
1780
  const measureContext = wrapOptions.measureContext ?? options.measureContext;
1729
1781
  const creditUnitsConsumed = wrapOptions.creditUnitsConsumed ?? options.creditUnitsConsumed;
1782
+ const operationKey = wrapOptions.operationKey ?? options.operationKey;
1783
+ const usagePolicyId = wrapOptions.usagePolicyId ?? options.usagePolicyId;
1730
1784
  const payload = {
1731
1785
  method: request.method.toUpperCase(),
1732
1786
  path: url.pathname,
@@ -1736,7 +1790,9 @@ function buildPayload(request, usage, options, wrapOptions) {
1736
1790
  creditUnitsConsumed: sortUsage(
1737
1791
  validateUsageMap(creditUnitsConsumed, "creditUnitsConsumed")
1738
1792
  )
1739
- } : {}
1793
+ } : {},
1794
+ ...operationKey ? { operationKey: assertIdentifier(operationKey) } : {},
1795
+ ...usagePolicyId ? { usagePolicyId: assertIdentifier(usagePolicyId) } : {}
1740
1796
  };
1741
1797
  return JSON.stringify(payload);
1742
1798
  }
@@ -1771,6 +1827,15 @@ function assertMeterValue(meter, value) {
1771
1827
  }
1772
1828
  return value;
1773
1829
  }
1830
+ function assertIdentifier(value) {
1831
+ if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(value)) {
1832
+ throw new MeteringError(
1833
+ RESPONSE_METERING_ERROR_CODES.invalidMeterKey,
1834
+ `operation and usage policy identifiers must be 1-128 URL-safe characters`
1835
+ );
1836
+ }
1837
+ return value;
1838
+ }
1774
1839
  function resolveToken(options) {
1775
1840
  const token = options.token ?? options.env?.[DEFAULT_TOKEN_ENV] ?? processEnv(DEFAULT_TOKEN_ENV);
1776
1841
  if (!token) {
@@ -0,0 +1,30 @@
1
+ /** The jitter strategy applied to the capped exponential delay. */
2
+ export type JitterStrategy =
3
+ /** No jitter — the raw capped exponential (deterministic). */
4
+ "none"
5
+ /** Equal jitter — `cap/2 + random()*cap/2` (the DEFAULT). */
6
+ | "equal"
7
+ /** Full jitter — `random()*cap` (max spread, no minimum floor). */
8
+ | "full";
9
+ export interface BackoffOptions {
10
+ /** The base delay for attempt 1 (ms). Doubles each subsequent attempt. */
11
+ baseMs: number;
12
+ /** The delay ceiling (ms) — the exponential is capped here BEFORE jitter. */
13
+ maxMs: number;
14
+ /** The jitter strategy. Defaults to `equal`. */
15
+ jitter?: JitterStrategy;
16
+ /** Injectable uniform random in [0, 1). Defaults to Math.random. */
17
+ random?: () => number;
18
+ }
19
+ /**
20
+ * Compute the backoff delay (ms) for `attempt` (1-based: attempt 1 is the first
21
+ * retry/restart). The capped exponential is `min(baseMs * 2^(attempt-1), maxMs)`;
22
+ * the chosen {@link JitterStrategy} (default `equal`) is then applied. The result
23
+ * is always in `[0, maxMs]`.
24
+ *
25
+ * - `none` → the raw capped exponential.
26
+ * - `equal` → `cap/2 + random()*cap/2` — a guaranteed half-cap floor plus a
27
+ * randomized half (the default; avoids lockstep re-collision).
28
+ * - `full` → `random()*cap` — maximum spread, no floor.
29
+ */
30
+ export declare function computeBackoff(attempt: number, options: BackoffOptions): number;
@@ -16,6 +16,15 @@ export type MeteringClientOptions = {
16
16
  fetchImpl?: typeof fetch;
17
17
  /** Max retry attempts per flush before re-buffering. */
18
18
  maxRetries?: number;
19
+ /** Base for the exponential inter-attempt backoff (ms). Default 200. */
20
+ baseDelayMs?: number;
21
+ /** Ceiling on any single inter-attempt wait (ms) — caps both backoff and a
22
+ * `Retry-After` hint. Default 10000. */
23
+ maxDelayMs?: number;
24
+ /** Injectable delay primitive (tests pass a no-op; default is a timer). */
25
+ sleep?: (ms: number) => Promise<void>;
26
+ /** Injectable uniform random in [0,1) for the backoff jitter (tests pin it). */
27
+ random?: () => number;
19
28
  /** Injectable id generator (tests). */
20
29
  newId?: () => string;
21
30
  now?: () => Date;
@@ -31,6 +40,10 @@ export declare class MeteringClient {
31
40
  private readonly backendId;
32
41
  private readonly fetchImpl;
33
42
  private readonly maxRetries;
43
+ private readonly baseDelayMs;
44
+ private readonly maxDelayMs;
45
+ private readonly sleep;
46
+ private readonly random;
34
47
  private readonly newId;
35
48
  private readonly now;
36
49
  private readonly buffer;
@@ -1,4 +1,5 @@
1
1
  import type { EventEmitter } from "node:events";
2
+ import type { JitterStrategy } from "./backoff.js";
2
3
  /** A line emitter — the subset of a child stdio stream we consume. */
3
4
  type StdioStream = Pick<EventEmitter, "on">;
4
5
  /**
@@ -58,6 +59,14 @@ export type CloudflaredSupervisorOptions = {
58
59
  baseBackoffMs?: number;
59
60
  /** Backoff ceiling. */
60
61
  maxBackoffMs?: number;
62
+ /** Jitter strategy for the restart backoff. Defaults to `equal` (the shared
63
+ * backoff default) — half the capped exponential is fixed, half randomized,
64
+ * so multiple supervisors don't re-collide in lockstep after a shared
65
+ * outage. Pass `none` for a deterministic schedule. */
66
+ backoffJitter?: JitterStrategy;
67
+ /** Injectable uniform random in [0, 1) for the jitter (tests pin it). Defaults
68
+ * to Math.random. */
69
+ random?: () => number;
61
70
  /** Injectable timer (tests use fake timers / a custom scheduler). */
62
71
  setTimeoutFn?: (cb: () => void, ms: number) => unknown;
63
72
  clearTimeoutFn?: (handle: unknown) => void;
@@ -86,6 +95,8 @@ export declare class CloudflaredSupervisor {
86
95
  private readonly failClosed;
87
96
  private readonly baseBackoffMs;
88
97
  private readonly maxBackoffMs;
98
+ private readonly backoffJitter;
99
+ private readonly random;
89
100
  private readonly setTimeoutFn;
90
101
  private readonly clearTimeoutFn;
91
102
  private readonly childEnv;
@@ -9,15 +9,24 @@ export declare const METERING_SIGNATURE_HEADER: "x-fs-metering-sig";
9
9
  export declare const METERING_TOKEN_HEADER: "x-fs-metering-token";
10
10
  export declare const DEFAULT_TOKEN_ENV: "FS_RUNTIME_TOKEN";
11
11
  export type UsageMap = Record<string, number>;
12
+ export type BillableUsageMap = UsageMap;
12
13
  export type MeteringOptions = {
13
14
  token?: string;
14
15
  env?: Record<string, string | undefined>;
15
16
  measureContext?: Record<string, unknown>;
16
- creditUnitsConsumed?: UsageMap;
17
+ creditUnitsConsumed?: BillableUsageMap;
18
+ /** Gateway-validated operation identity hint. The SDK signs and transports it
19
+ * but never decides billing or policy from it. */
20
+ operationKey?: string;
21
+ /** Gateway-validated policy hint. Advisory identity only; the gateway remains
22
+ * authoritative for customerBillable/provider-cost decisions. */
23
+ usagePolicyId?: string;
17
24
  };
18
25
  export type UsageWrapOptions = {
19
26
  measureContext?: Record<string, unknown>;
20
- creditUnitsConsumed?: UsageMap;
27
+ creditUnitsConsumed?: BillableUsageMap;
28
+ operationKey?: string;
29
+ usagePolicyId?: string;
21
30
  };
22
31
  export type UsageReporter = {
23
32
  report(meter: string, value: number): UsageReporter;
@@ -27,6 +27,71 @@ export declare const LIMIT_DESCRIPTOR_FIELDS: {
27
27
  dimension: true;
28
28
  currentCapacity: true;
29
29
  };
30
+ /**
31
+ * F1 — the closed usage-limit class set. Structurally identical to the contracts
32
+ * `LimitClass`. A backend that surfaces a usage-limit deny carries this so SDKs
33
+ * can branch on the limit's semantic class.
34
+ */
35
+ export type LimitClass = "quota" | "rate" | "concurrency" | "capacity" | "spend" | "adaptive";
36
+ /** Recommended client reaction to a limit deny. Mirrors contracts
37
+ * `LimitReaction`. */
38
+ export type LimitReaction = "none" | "backoff_retry" | "wait_then_retry" | "queue" | "reduce_then_retry" | "fallback" | "upgrade";
39
+ /** Where a limit was decided. Mirrors contracts `LimitOrigin`. */
40
+ export type LimitOrigin = "platform" | "provider";
41
+ /**
42
+ * F1 — the `_fs` deny envelope a backend stamps on a usage-limit deny body.
43
+ * Structurally identical to the contracts `FsDenyEnvelope`.
44
+ */
45
+ export interface FsDenyEnvelope {
46
+ limitClass: LimitClass;
47
+ scope?: string;
48
+ metric?: string;
49
+ reset?: number;
50
+ remaining?: number;
51
+ used?: number;
52
+ limit?: number;
53
+ retrySafe: boolean;
54
+ mustModify: boolean;
55
+ providerReason?: string;
56
+ limitOrigin: LimitOrigin;
57
+ userAction?: string;
58
+ devAction?: string;
59
+ requestId: string;
60
+ decisionId: string;
61
+ /** Which exact constraint denied (projects from `LimitDecision.blockingConstraintId`). */
62
+ blockingConstraintId?: string;
63
+ reaction: LimitReaction;
64
+ envelopeVersion: number;
65
+ }
66
+ /**
67
+ * F1 — the RUNTIME field set of the SDK-local {@link FsDenyEnvelope} mirror.
68
+ * `satisfies Record<keyof FsDenyEnvelope, true>` makes the compiler reject this
69
+ * if it drifts from the local interface; the drift guard then asserts it
70
+ * deep-equals the canonical contracts `DENY_ENVELOPE_FIELDS` at RUNTIME — so a
71
+ * hand-copy that adds or drops a field fails a test that actually runs.
72
+ * (Contracts-free: a plain local constant, never the published path to
73
+ * contracts.)
74
+ */
75
+ export declare const DENY_ENVELOPE_FIELDS: {
76
+ limitClass: true;
77
+ scope: true;
78
+ metric: true;
79
+ reset: true;
80
+ remaining: true;
81
+ used: true;
82
+ limit: true;
83
+ retrySafe: true;
84
+ mustModify: true;
85
+ providerReason: true;
86
+ limitOrigin: true;
87
+ userAction: true;
88
+ devAction: true;
89
+ requestId: true;
90
+ decisionId: true;
91
+ blockingConstraintId: true;
92
+ reaction: true;
93
+ envelopeVersion: true;
94
+ };
30
95
  /**
31
96
  * C-2 — the canonical core `ErrorCode` VALUES this backend can map a
32
97
  * `RuntimeErrorCode` onto (the codomain of {@link RUNTIME_ERROR_CODE_TO_ERROR_CODE}).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farthershore/backend",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "Farther Shore backend SDK for builder upstreams: signed response usage, fail-closed gateway request verification, health, and lifecycle from FS_RUNTIME_TOKEN",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",