@xemahq/biome-host-internal-api-client 0.3.1 → 0.3.3

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.
@@ -1,24 +1,36 @@
1
1
  /**
2
2
  * Configurable fetch wrapper for Orval-generated clients.
3
3
  *
4
+ * GENERATED by `@xemahq/api-client-generator` — do not edit. Every client in
5
+ * the fleet ships this file byte-for-byte; a local edit is erased by the next
6
+ * `pnpm refresh` and, until then, makes this one client speak to the platform
7
+ * differently from all of its siblings. Change the template instead:
8
+ * `api-client-generator/src/lib/custom-fetch-template.ts`.
9
+ *
4
10
  * Consumers must call `configureClient()` before using any endpoint function.
5
11
  * The baseUrl is prepended to the relative paths generated by Orval.
6
12
  *
7
- * Features:
8
- * - Automatic bearer-token injection via `getAuthToken` callback
9
- * - Per-request header injection via `getHeaders` callback
10
- * - Automatic 401 handling via `onUnauthorized` callback (single retry)
11
- * - Exponential-backoff retry for transient failures (429, 502, 503, 504)
12
- * - Typed `ClientError` for non-2xx responses
13
- * - Per-call header overrides (take precedence over global headers)
14
- *
15
- * @example
16
- * configureClient({
17
- * baseUrl: 'http://governance-api:3400',
18
- * getAuthToken: () => identityBootstrapService.getAccessToken(),
19
- * getHeaders: () => ({ 'X-Xema-Org-Id': orgId, 'X-Correlation-Id': crypto.randomUUID() }),
20
- * });
13
+ * By default this transport issues exactly ONE request and adds no delay of its
14
+ * own, so a slow call is a slow server. Retrying is opt-in, and opting in
15
+ * requires supplying an observer — see `maxRetries` / `onRetry` below.
21
16
  */
17
+ /** What {@link ClientConfig.onRetry} is told before each re-attempt. */
18
+ export interface RetryNotice {
19
+ /** 1-based index of the re-attempt about to be made. */
20
+ attempt: number;
21
+ /** The configured budget this re-attempt is spending from. */
22
+ maxRetries: number;
23
+ /** The response status that made the previous attempt retryable. */
24
+ status: number;
25
+ /** Delay before the re-attempt, in ms — from `Retry-After` when the server sent one. */
26
+ waitMs: number;
27
+ /** Whether {@link waitMs} came from the server's `Retry-After` header. */
28
+ retryAfterHonoured: boolean;
29
+ /** Absolute URL being re-requested. */
30
+ url: string;
31
+ /** HTTP method being re-requested. */
32
+ method: string;
33
+ }
22
34
  export interface ClientConfig {
23
35
  /**
24
36
  * Static base URL (e.g. 'http://localhost:3140') — no trailing slash.
@@ -35,10 +47,28 @@ export interface ClientConfig {
35
47
  getAuthToken?: () => Promise<string>;
36
48
  /** Optional callback returning headers to inject on every request. Per-call headers take precedence. */
37
49
  getHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
38
- /** Optional callback invoked on 401 before a single retry (e.g. force-refresh auth token). */
50
+ /**
51
+ * Optional callback invoked on a 401 before ONE re-attempt. Supplying it is
52
+ * what opts this client into that re-attempt; without it a 401 comes back to
53
+ * the caller as a `ClientError` like any other 4xx.
54
+ */
39
55
  onUnauthorized?: () => Promise<void>;
40
- /** Maximum retry attempts for transient failures (default: 3). Set to 0 to disable retries. */
56
+ /**
57
+ * Re-attempts for a retryable status. **Defaults to 0 — no retry.**
58
+ *
59
+ * Setting it above 0 REQUIRES `onRetry`; `configureClient` throws otherwise.
60
+ * See the block above `customFetch` for why the budget is opt-in and why an
61
+ * observer is mandatory rather than advisory.
62
+ */
41
63
  maxRetries?: number;
64
+ /**
65
+ * Called immediately before every re-attempt, with the reason and the delay.
66
+ *
67
+ * Mandatory whenever `maxRetries` is above 0. It is the difference between a
68
+ * retry and a hidden degradation path: without it a caller cannot tell a slow
69
+ * server from this transport sleeping between attempts.
70
+ */
71
+ onRetry?: (notice: RetryNotice) => void;
42
72
  }
43
73
  export declare class ClientError extends Error {
44
74
  readonly status: number;
@@ -2,23 +2,18 @@
2
2
  /**
3
3
  * Configurable fetch wrapper for Orval-generated clients.
4
4
  *
5
+ * GENERATED by `@xemahq/api-client-generator` — do not edit. Every client in
6
+ * the fleet ships this file byte-for-byte; a local edit is erased by the next
7
+ * `pnpm refresh` and, until then, makes this one client speak to the platform
8
+ * differently from all of its siblings. Change the template instead:
9
+ * `api-client-generator/src/lib/custom-fetch-template.ts`.
10
+ *
5
11
  * Consumers must call `configureClient()` before using any endpoint function.
6
12
  * The baseUrl is prepended to the relative paths generated by Orval.
7
13
  *
8
- * Features:
9
- * - Automatic bearer-token injection via `getAuthToken` callback
10
- * - Per-request header injection via `getHeaders` callback
11
- * - Automatic 401 handling via `onUnauthorized` callback (single retry)
12
- * - Exponential-backoff retry for transient failures (429, 502, 503, 504)
13
- * - Typed `ClientError` for non-2xx responses
14
- * - Per-call header overrides (take precedence over global headers)
15
- *
16
- * @example
17
- * configureClient({
18
- * baseUrl: 'http://governance-api:3400',
19
- * getAuthToken: () => identityBootstrapService.getAccessToken(),
20
- * getHeaders: () => ({ 'X-Xema-Org-Id': orgId, 'X-Correlation-Id': crypto.randomUUID() }),
21
- * });
14
+ * By default this transport issues exactly ONE request and adds no delay of its
15
+ * own, so a slow call is a slow server. Retrying is opt-in, and opting in
16
+ * requires supplying an observer — see `maxRetries` / `onRetry` below.
22
17
  */
23
18
  Object.defineProperty(exports, "__esModule", { value: true });
24
19
  exports.customFetch = exports.ClientError = void 0;
@@ -39,6 +34,14 @@ class ClientError extends Error {
39
34
  exports.ClientError = ClientError;
40
35
  let clientConfig = null;
41
36
  function configureClient(config) {
37
+ // Fail at WIRING time, not on the request that happens to be retried. A
38
+ // client configured to retry without an observer is the exact defect this
39
+ // transport was rebuilt to remove, so it must not be constructible.
40
+ if ((config.maxRetries ?? 0) > 0 && !config.onRetry) {
41
+ throw new Error('configureClient: maxRetries above 0 requires onRetry. A retry nothing ' +
42
+ 'reports is indistinguishable from a slow server, which is how two ' +
43
+ 'silent re-attempts were read as a 3,312ms call.');
44
+ }
42
45
  clientConfig = config;
43
46
  }
44
47
  function getClientConfig() {
@@ -47,7 +50,49 @@ function getClientConfig() {
47
50
  }
48
51
  return clientConfig;
49
52
  }
50
- const RETRYABLE_STATUSES = [429, 502, 503, 504];
53
+ /*
54
+ * RETRYING IS OPT-IN, OBSERVED, AND NARROW. Do not widen any of the three.
55
+ *
56
+ * This transport used to retry up to `maxRetries` (defaulting to THREE) over
57
+ * 429/502/503/504 AND over any thrown network error, backing off from 1000ms,
58
+ * with no log line anywhere. Three properties were wrong:
59
+ *
60
+ * 1. The DEFAULT was on. ~99% of the fleet's client configurations never
61
+ * mention `maxRetries`, so two re-attempts could add ~3s to any call with
62
+ * nothing to distinguish that from a slow peer. Every caller that DID set
63
+ * it set it LOWER — canopy's control plane to 1, "so a hard outage
64
+ * surfaces inside the turn's latency budget rather than after three
65
+ * backoffs"; two llm-registry callers to 1; a test to 0. Nobody ever
66
+ * raised it. A default three separate call sites work around is not a
67
+ * default, and `configureOrvalClientResolved` cannot express the field at
68
+ * all, so most callers could not have opted out if they had wanted to.
69
+ * 2. It retried AMBIGUOUS failures. 502 and 504 mean a gateway did not get a
70
+ * timely answer from upstream — the upstream may well have APPLIED the
71
+ * request. So did a thrown network error mid-flight. Re-sending a POST in
72
+ * either case is a duplicate write, and no amount of logging makes that
73
+ * safe. Only 429 and 503 state positively that the request was NOT
74
+ * processed, so only those are retried; everything else surfaces at once.
75
+ * 3. Nothing reported it. Now `onRetry` is mandatory whenever the budget is
76
+ * above 0, enforced in `configureClient`.
77
+ *
78
+ * Also note `baseUrlResolver` is awaited ONCE, above the loop: a re-attempt
79
+ * returns to the same resolved instance. In a registry-resolved fleet the cure
80
+ * for an unhealthy peer is re-resolution, which lives above this file — which
81
+ * is a further reason not to lean on retrying here.
82
+ *
83
+ * Retry policy a caller genuinely wants belongs in `@xemahq/managed-fetch`,
84
+ * which has backoff, a circuit breaker, a token bucket and health reporting,
85
+ * and reports what it did.
86
+ *
87
+ * Enforced fleet-wide by `check-client-transport-envelope`, which compares
88
+ * every client's transport to this template.
89
+ */
90
+ /** The only statuses that state the request was NOT processed. See above. */
91
+ const RETRYABLE_STATUSES = [429, 503];
92
+ /** Backoff floor, doubling per attempt up to {@link MAX_BACKOFF_MS}. */
93
+ const BASE_BACKOFF_MS = 1000;
94
+ /** Ceiling on a single backoff, however many attempts have elapsed. */
95
+ const MAX_BACKOFF_MS = 30_000;
51
96
  async function buildHeaders(config, callerHeaders) {
52
97
  const headers = new Headers(callerHeaders);
53
98
  // Global headers from config (caller-provided headers take precedence)
@@ -66,6 +111,29 @@ async function buildHeaders(config, callerHeaders) {
66
111
  }
67
112
  return headers;
68
113
  }
114
+ /*
115
+ * THE TRANSPORT RETURNS THE BODY UNCHANGED. Do not reintroduce an unwrap here.
116
+ *
117
+ * Xema services wrap every 2xx payload in a { data: T } envelope via the global
118
+ * ResponseEnvelopeInterceptor (platform-common), and the generator emits types
119
+ * that describe THAT ENVELOPE — every endpoint returns Promise<XDataEnvelope>
120
+ * or Promise<XPaginatedEnvelope>, never a bare inner T. Consumers read the data
121
+ * property themselves.
122
+ *
123
+ * This template used to peel data, justified by a comment claiming generated
124
+ * types describe the inner T. That stopped being true when the generator moved
125
+ * to envelope-typed returns, and the comment outlived the fact — so the peel
126
+ * then contradicted every type in every package it seeded. Four clients shipped
127
+ * that way (resource-governance x3, workload-runtime-api): declared
128
+ * *DataEnvelope, returned the already-peeled inner object, so .data read
129
+ * undefined at runtime on every non-paginated endpoint. Paginated calls hid it,
130
+ * because the old peel deliberately preserved an envelope carrying pagination.
131
+ *
132
+ * Four MORE shipped it in repositories the gate could not see — license-api and
133
+ * license-internal-api in xema-operator, plus their host-web mirrors — because
134
+ * the gate ran in xema-base only. That is why it now runs in every repository
135
+ * that ships a client, and why this file is generator-owned rather than seeded.
136
+ */
69
137
  const customFetch = async (url, options) => {
70
138
  const config = getClientConfig();
71
139
  const base = config.baseUrlResolver
@@ -75,51 +143,57 @@ const customFetch = async (url, options) => {
75
143
  throw new Error('Client not configured: set baseUrl or baseUrlResolver via configureClient().');
76
144
  }
77
145
  const fullUrl = `${base}${url}`;
78
- const maxRetries = config.maxRetries ?? 3;
146
+ const maxRetries = config.maxRetries ?? 0;
147
+ // A caller that supplied its own Authorization header owns that credential.
148
+ // `buildHeaders` lets it win, so refreshing the CLIENT-WIDE token and
149
+ // re-sending would replay the identical failing request with the identical
150
+ // credential: one wasted round trip, plus a global refresh nobody asked for.
151
+ const callerSuppliedAuth = new Headers(options.headers).has('Authorization');
79
152
  const headers = await buildHeaders(config, options.headers);
80
153
  const requestInit = { ...options, headers };
81
- let delay = 1000;
82
- let lastError;
83
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
84
- try {
85
- const response = await fetch(fullUrl, requestInit);
86
- // 401 — invoke onUnauthorized and retry once (outside the transient retry loop)
87
- if (response.status === 401 && config.onUnauthorized && attempt === 0) {
88
- await config.onUnauthorized();
89
- const refreshedHeaders = await buildHeaders(config, options.headers);
90
- const retryResponse = await fetch(fullUrl, { ...options, headers: refreshedHeaders });
91
- const retryBody = await parseBody(retryResponse);
92
- if (retryResponse.status >= 400) {
93
- throw new ClientError(retryResponse.status, fullUrl, retryBody);
94
- }
95
- return retryBody;
96
- }
97
- // Non-retryable status — return or throw
98
- if (!RETRYABLE_STATUSES.includes(response.status) || attempt >= maxRetries) {
99
- const body = await parseBody(response);
100
- if (response.status >= 400) {
101
- throw new ClientError(response.status, fullUrl, body);
102
- }
103
- return body;
154
+ let delay = BASE_BACKOFF_MS;
155
+ for (let attempt = 0;; attempt++) {
156
+ const response = await fetch(fullUrl, requestInit);
157
+ if (response.status === 401 &&
158
+ config.onUnauthorized &&
159
+ !callerSuppliedAuth &&
160
+ attempt === 0) {
161
+ await config.onUnauthorized();
162
+ const refreshedHeaders = await buildHeaders(config, options.headers);
163
+ const retryResponse = await fetch(fullUrl, {
164
+ ...options,
165
+ headers: refreshedHeaders,
166
+ });
167
+ const retryBody = await parseBody(retryResponse);
168
+ if (retryResponse.status >= 400) {
169
+ throw new ClientError(retryResponse.status, fullUrl, retryBody);
104
170
  }
105
- // Retryable — wait and retry
106
- const retryAfter = parseRetryAfter(response.headers.get('Retry-After'));
107
- const waitMs = retryAfter ?? addJitter(delay);
108
- await sleep(waitMs);
109
- delay = Math.min(delay * 2, 30_000);
171
+ return retryBody;
110
172
  }
111
- catch (error) {
112
- if (error instanceof ClientError)
113
- throw error; // Don't retry client errors
114
- lastError = error instanceof Error ? error : new Error(String(error));
115
- if (attempt >= maxRetries)
116
- throw lastError;
117
- const waitMs = addJitter(delay);
118
- await sleep(waitMs);
119
- delay = Math.min(delay * 2, 30_000);
173
+ if (!RETRYABLE_STATUSES.includes(response.status) ||
174
+ attempt >= maxRetries) {
175
+ const body = await parseBody(response);
176
+ if (response.status >= 400) {
177
+ throw new ClientError(response.status, fullUrl, body);
178
+ }
179
+ return body;
120
180
  }
181
+ const retryAfter = parseRetryAfter(response.headers.get('Retry-After'));
182
+ const waitMs = retryAfter ?? addJitter(delay);
183
+ // Non-null: `configureClient` refuses a budget above 0 without an observer,
184
+ // and this line is unreachable unless `maxRetries` is above 0.
185
+ config.onRetry({
186
+ attempt: attempt + 1,
187
+ maxRetries,
188
+ status: response.status,
189
+ waitMs,
190
+ retryAfterHonoured: retryAfter !== undefined,
191
+ url: fullUrl,
192
+ method: options.method ?? 'GET',
193
+ });
194
+ await sleep(waitMs);
195
+ delay = Math.min(delay * 2, MAX_BACKOFF_MS);
121
196
  }
122
- throw lastError ?? new Error(`All retries exhausted for ${fullUrl}`);
123
197
  };
124
198
  exports.customFetch = customFetch;
125
199
  async function parseBody(response) {
@@ -147,6 +221,6 @@ function addJitter(delay) {
147
221
  return delay + (Math.random() * 2 - 1) * delay * 0.25;
148
222
  }
149
223
  function sleep(ms) {
150
- return new Promise(resolve => setTimeout(resolve, ms));
224
+ return new Promise((resolve) => setTimeout(resolve, ms));
151
225
  }
152
226
  exports.default = exports.customFetch;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { configureClient, getClientConfig, ClientError, customFetch, type ClientConfig } from './custom-fetch';
1
+ export { configureClient, getClientConfig, ClientError, customFetch, type ClientConfig, type RetryNotice } from './custom-fetch';
2
2
  export * from './models';
3
3
  export * from './endpoints/describe-objects/describe-objects';
4
4
  export * from './endpoints/runtime-authority-internal/runtime-authority-internal';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xemahq/biome-host-internal-api-client",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/xema-dev/xema-base.git",
@@ -25,7 +25,7 @@
25
25
  "service": "biome-host-api",
26
26
  "biome": "biome-host",
27
27
  "target": "server",
28
- "generator": "@xemahq/api-client-generator@0.5.0",
28
+ "generator": "@xemahq/api-client-generator@0.12.0",
29
29
  "source": "openapi.internal.json"
30
30
  },
31
31
  "scripts": {