@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 +3 -3
- package/dist/index.js +68 -3
- package/dist/types/core/backoff.d.ts +30 -0
- package/dist/types/core/metering.d.ts +13 -0
- package/dist/types/core/tunnel.d.ts +11 -0
- package/dist/types/response-metering.d.ts +11 -2
- package/dist/types/runtime-types.d.ts +65 -0
- package/package.json +1 -1
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
|
|
8
|
-
> `@farthershore/farthershore-js` and `@farthershore/
|
|
9
|
-
>
|
|
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.
|
|
1374
|
-
var CONTRACTS_FP = "
|
|
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?:
|
|
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?:
|
|
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.
|
|
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",
|