@apifuse/provider-sdk 2.1.0-beta.4 → 2.1.0-beta.6

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.
Files changed (42) hide show
  1. package/AUTHORING.md +24 -0
  2. package/CHANGELOG.md +11 -0
  3. package/README.md +23 -2
  4. package/SUBMISSION.md +2 -1
  5. package/bin/apifuse-check.ts +60 -6
  6. package/bin/apifuse-dev.ts +48 -5
  7. package/bin/apifuse-perf.ts +106 -26
  8. package/bin/apifuse-record.ts +142 -52
  9. package/bin/apifuse-submit-check.ts +1489 -3
  10. package/package.json +107 -92
  11. package/src/ceremonies/index.ts +8 -2
  12. package/src/choice-token.ts +1 -0
  13. package/src/cli/commands.ts +10 -8
  14. package/src/cli/create.ts +49 -1
  15. package/src/cli/templates/provider/.dockerignore.tpl +22 -0
  16. package/src/cli/templates/provider/.gitignore.tpl +22 -0
  17. package/src/cli/templates/provider/README.md.tpl +18 -0
  18. package/src/cli/templates/provider/operations/ping.ts.tpl +3 -2
  19. package/src/cli/templates/provider/schemas/ping.ts.tpl +8 -0
  20. package/src/config/loader.ts +19 -1
  21. package/src/contract-json.ts +75 -0
  22. package/src/contract-serialization.ts +89 -0
  23. package/src/contract-types.ts +52 -0
  24. package/src/contract.ts +215 -0
  25. package/src/define.ts +40 -5
  26. package/src/errors.ts +15 -0
  27. package/src/i18n/catalog.ts +156 -0
  28. package/src/index.ts +22 -1
  29. package/src/lint.ts +265 -46
  30. package/src/provider.ts +45 -2
  31. package/src/runtime/browser.ts +685 -30
  32. package/src/runtime/cache.ts +35 -89
  33. package/src/runtime/choice.ts +760 -0
  34. package/src/runtime/executor.ts +19 -2
  35. package/src/runtime/redis.ts +116 -0
  36. package/src/runtime/state.ts +487 -0
  37. package/src/runtime/stealth.ts +8 -1
  38. package/src/runtime/trace.ts +1 -1
  39. package/src/server/serve.ts +361 -46
  40. package/src/server/types.ts +2 -0
  41. package/src/testing/run.ts +16 -3
  42. package/src/types.ts +225 -18
@@ -1,4 +1,4 @@
1
- import { ProviderError } from "../errors";
1
+ import { ProviderError, SessionExpiredError } from "../errors";
2
2
  import { parseSchema } from "../schema";
3
3
  import type { ProviderContext, ProviderDefinition } from "../types";
4
4
 
@@ -51,7 +51,24 @@ export async function executeOperation(
51
51
  Promise.resolve(operation.handler(ctx, validatedInput)),
52
52
  );
53
53
 
54
- const result = await execute();
54
+ let result: unknown;
55
+ try {
56
+ result = await execute();
57
+ } catch (error) {
58
+ // Session expiry is renewed by Credential Service via the /auth/refresh
59
+ // route, NOT in-process here: this executor cannot mutate ctx.credential,
60
+ // so an in-process retry would just repeat the call with the same stale
61
+ // credential (and risk repeating partial side-effects). Instead we surface
62
+ // the expiry so Credential Service refreshes and re-drives the operation
63
+ // with a fresh credential. `retryOnAuthRefresh` declares that this
64
+ // operation is safe to re-drive after refresh, which we signal by marking
65
+ // the surfaced error retryable; non-idempotent operations (the default)
66
+ // stay non-retryable so they are not auto-re-driven. See design.md §4.3 D3.
67
+ if (error instanceof SessionExpiredError && operation.retryOnAuthRefresh) {
68
+ throw new SessionExpiredError(error.message, { retryable: true });
69
+ }
70
+ throw error;
71
+ }
55
72
 
56
73
  if (isStreamingOperation(provider, operationId)) {
57
74
  return result;
@@ -0,0 +1,116 @@
1
+ import Redis from "ioredis";
2
+
3
+ export type ProviderRedisClient = Redis;
4
+
5
+ export type ProviderRedisClientOptions = {
6
+ readonly redisUrl: string;
7
+ readonly timeoutMs: number;
8
+ readonly onError: () => void;
9
+ };
10
+
11
+ type RedisTimeoutOptions<T> = {
12
+ readonly timeoutMs: number;
13
+ readonly onTimeout: () => T;
14
+ readonly onError?: (error: unknown) => T;
15
+ };
16
+
17
+ export function createProviderRedisClient(
18
+ options: ProviderRedisClientOptions,
19
+ ): ProviderRedisClient {
20
+ const redis = new Redis(options.redisUrl, {
21
+ connectTimeout: options.timeoutMs,
22
+ enableOfflineQueue: false,
23
+ lazyConnect: true,
24
+ maxRetriesPerRequest: 0,
25
+ retryStrategy: () => null,
26
+ });
27
+ redis.on("error", options.onError);
28
+ return redis;
29
+ }
30
+
31
+ export async function withRedisTimeout<T>(
32
+ operation: () => Promise<T>,
33
+ options: RedisTimeoutOptions<T>,
34
+ ): Promise<T> {
35
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
36
+ try {
37
+ const timeout = new Promise<T>((resolve, reject) => {
38
+ timeoutId = setTimeout(() => {
39
+ try {
40
+ resolve(options.onTimeout());
41
+ } catch (error) {
42
+ reject(error);
43
+ }
44
+ }, options.timeoutMs);
45
+ });
46
+ const operationResult = options.onError
47
+ ? operation().catch(options.onError)
48
+ : operation();
49
+ return await Promise.race([operationResult, timeout]);
50
+ } finally {
51
+ if (timeoutId) clearTimeout(timeoutId);
52
+ }
53
+ }
54
+
55
+ export function redisStatus(redis: ProviderRedisClient): string {
56
+ return redis.status;
57
+ }
58
+
59
+ async function waitForRedisReady(
60
+ redis: ProviderRedisClient,
61
+ timeoutMs: number,
62
+ ): Promise<boolean> {
63
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
64
+ let settled = false;
65
+
66
+ return await new Promise<boolean>((resolve) => {
67
+ const cleanup = () => {
68
+ if (timeoutId) clearTimeout(timeoutId);
69
+ redis.off("ready", onReady);
70
+ redis.off("close", onUnavailable);
71
+ redis.off("end", onUnavailable);
72
+ redis.off("error", onUnavailable);
73
+ };
74
+ const finish = (ready: boolean) => {
75
+ if (settled) return;
76
+ settled = true;
77
+ cleanup();
78
+ resolve(ready);
79
+ };
80
+ const onReady = () => finish(true);
81
+ const onUnavailable = () => finish(false);
82
+
83
+ timeoutId = setTimeout(
84
+ () => finish(redisStatus(redis) === "ready"),
85
+ timeoutMs,
86
+ );
87
+ redis.once("ready", onReady);
88
+ redis.once("close", onUnavailable);
89
+ redis.once("end", onUnavailable);
90
+ redis.once("error", onUnavailable);
91
+ });
92
+ }
93
+
94
+ export async function ensureRedisReady(
95
+ redis: ProviderRedisClient,
96
+ timeoutMs: number,
97
+ ): Promise<boolean> {
98
+ if (redisStatus(redis) === "ready") return true;
99
+
100
+ if (redisStatus(redis) === "wait" || redisStatus(redis) === "end") {
101
+ const connected = await withRedisTimeout(
102
+ async () => {
103
+ await redis.connect();
104
+ return true;
105
+ },
106
+ {
107
+ timeoutMs,
108
+ onTimeout: () => false,
109
+ onError: () => false,
110
+ },
111
+ );
112
+ return connected && redisStatus(redis) === "ready";
113
+ }
114
+
115
+ return await waitForRedisReady(redis, timeoutMs);
116
+ }
@@ -1,3 +1,4 @@
1
+ import { providerStateRedisUrlFromEnv } from "../config/loader";
1
2
  import { ProviderError } from "../errors";
2
3
  import type {
3
4
  ProviderRuntimeState,
@@ -7,6 +8,340 @@ import type {
7
8
  StateValue,
8
9
  StateWriteOptions,
9
10
  } from "../types";
11
+ import {
12
+ createProviderRedisClient,
13
+ ensureRedisReady,
14
+ type ProviderRedisClient,
15
+ withRedisTimeout,
16
+ } from "./redis";
17
+
18
+ const DEFAULT_REDIS_TIMEOUT_MS = 250;
19
+ const REDIS_STATE_PREFIX = "apifuse:provider-state:v1";
20
+
21
+ type RedisProviderRuntimeStateOptions = {
22
+ readonly redisUrl: string;
23
+ readonly providerId?: string;
24
+ };
25
+
26
+ type RedisStateEnvelope = {
27
+ readonly value: unknown;
28
+ readonly version: number;
29
+ readonly expiresAt: string;
30
+ readonly createdAt: string;
31
+ readonly updatedAt: string;
32
+ };
33
+
34
+ type RedisBackend = {
35
+ readonly redis: ProviderRedisClient;
36
+ };
37
+
38
+ const redisBackends = new Map<string, RedisBackend>();
39
+
40
+ function getRedisBackend(redisUrl: string): RedisBackend {
41
+ const existing = redisBackends.get(redisUrl);
42
+ if (existing) return existing;
43
+ const redis = createProviderRedisClient({
44
+ redisUrl,
45
+ timeoutMs: DEFAULT_REDIS_TIMEOUT_MS,
46
+ onError: () => {
47
+ // Runtime state operations fail closed at their call sites. Avoid noisy
48
+ // unhandled Redis errors from background reconnect attempts.
49
+ },
50
+ });
51
+ const backend = { redis };
52
+ redisBackends.set(redisUrl, backend);
53
+ return backend;
54
+ }
55
+
56
+ async function withRequiredRedis<T>(operation: () => Promise<T>): Promise<T> {
57
+ return await withRedisTimeout(operation, {
58
+ timeoutMs: DEFAULT_REDIS_TIMEOUT_MS,
59
+ onTimeout: () => {
60
+ throw new UnsupportedProviderStateError(
61
+ "Provider runtime state Redis timed out",
62
+ );
63
+ },
64
+ onError: () => {
65
+ throw new UnsupportedProviderStateError(
66
+ "Provider runtime state Redis is unavailable",
67
+ );
68
+ },
69
+ });
70
+ }
71
+
72
+ async function requireRedisReady(redis: ProviderRedisClient): Promise<void> {
73
+ if (await ensureRedisReady(redis, DEFAULT_REDIS_TIMEOUT_MS)) return;
74
+ throw new UnsupportedProviderStateError(
75
+ "Provider runtime state Redis is unavailable",
76
+ );
77
+ }
78
+
79
+ function providerStatePrefix(
80
+ providerId: string | undefined,
81
+ namespace: string,
82
+ ): string {
83
+ return `${REDIS_STATE_PREFIX}:${providerId ?? "default"}:${namespace}`;
84
+ }
85
+
86
+ function providerStateKey(
87
+ providerId: string | undefined,
88
+ namespace: string,
89
+ key: string,
90
+ ): string {
91
+ return `${providerStatePrefix(providerId, namespace)}:${key}`;
92
+ }
93
+
94
+ function publicStateKey(
95
+ providerId: string | undefined,
96
+ namespace: string,
97
+ redisKey: string,
98
+ ): string {
99
+ const prefix = `${providerStatePrefix(providerId, namespace)}:`;
100
+ return redisKey.startsWith(prefix) ? redisKey.slice(prefix.length) : redisKey;
101
+ }
102
+
103
+ function parseStateDurationMs(ttl: StateWriteOptions["ttl"]): number {
104
+ const match = /^(\d+)(ms|s|m|h|d)$/.exec(ttl ?? "1h");
105
+ if (!match) return 3_600_000;
106
+ const amount = Number(match[1]);
107
+ const unit = match[2];
108
+ const multiplier =
109
+ unit === "ms"
110
+ ? 1
111
+ : unit === "s"
112
+ ? 1_000
113
+ : unit === "m"
114
+ ? 60_000
115
+ : unit === "h"
116
+ ? 3_600_000
117
+ : 86_400_000;
118
+ return Math.max(1, amount * multiplier);
119
+ }
120
+
121
+ function resolveExpiresAt(ttl: StateWriteOptions["ttl"]): string {
122
+ return new Date(Date.now() + parseStateDurationMs(ttl)).toISOString();
123
+ }
124
+
125
+ function envelopeFromJson(
126
+ key: string,
127
+ raw: string | null,
128
+ // biome-ignore lint/suspicious/noExplicitAny: state envelopes deserialize caller-owned generic values.
129
+ ): StateValue<any> | null {
130
+ if (!raw) return null;
131
+ const parsed: unknown = JSON.parse(raw);
132
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
133
+ return null;
134
+ }
135
+ const record: Record<string, unknown> = Object.fromEntries(
136
+ Object.entries(parsed),
137
+ );
138
+ if (
139
+ typeof record.version !== "number" ||
140
+ typeof record.expiresAt !== "string" ||
141
+ typeof record.createdAt !== "string" ||
142
+ typeof record.updatedAt !== "string" ||
143
+ !("value" in record)
144
+ ) {
145
+ return null;
146
+ }
147
+ return {
148
+ key,
149
+ value: record.value,
150
+ version: record.version,
151
+ expiresAt: record.expiresAt,
152
+ createdAt: record.createdAt,
153
+ updatedAt: record.updatedAt,
154
+ };
155
+ }
156
+
157
+ function redisEnvelope(
158
+ value: unknown,
159
+ version: number,
160
+ createdAt: string,
161
+ expiresAt: string,
162
+ ): RedisStateEnvelope {
163
+ const updatedAt = new Date().toISOString();
164
+ return { value, version, expiresAt, createdAt, updatedAt };
165
+ }
166
+
167
+ class RedisProviderStateNamespace implements ProviderStateNamespace {
168
+ constructor(
169
+ private readonly backend: RedisBackend,
170
+ private readonly providerId: string | undefined,
171
+ private readonly namespaceName: string,
172
+ private readonly options: StateNamespaceOptions,
173
+ ) {}
174
+
175
+ private redisKey(key: string): string {
176
+ return providerStateKey(this.providerId, this.namespaceName, key);
177
+ }
178
+
179
+ private prefix(): string {
180
+ return `${providerStatePrefix(this.providerId, this.namespaceName)}:`;
181
+ }
182
+
183
+ private async activeKeys(): Promise<string[]> {
184
+ await requireRedisReady(this.backend.redis);
185
+ return await withRequiredRedis(() =>
186
+ this.backend.redis.keys(`${this.prefix()}*`),
187
+ );
188
+ }
189
+
190
+ private enforceValueSize(value: unknown): void {
191
+ const bytes = Buffer.byteLength(JSON.stringify(value), "utf8");
192
+ if (bytes > this.options.maxValueBytes) {
193
+ throw new UnsupportedProviderStateError(
194
+ `Provider runtime state value exceeds maxValueBytes (${bytes} > ${this.options.maxValueBytes})`,
195
+ );
196
+ }
197
+ }
198
+
199
+ private async enforceMaxEntries(key: string): Promise<void> {
200
+ const keys = await this.activeKeys();
201
+ const redisKey = this.redisKey(key);
202
+ const otherKeys = keys.filter((candidate) => candidate !== redisKey);
203
+ if (otherKeys.length >= this.options.maxEntries) {
204
+ throw new UnsupportedProviderStateError(
205
+ `Provider runtime state namespace quota exceeded (${otherKeys.length + 1} > ${this.options.maxEntries})`,
206
+ );
207
+ }
208
+ }
209
+
210
+ async list<T>(options?: {
211
+ limit?: number;
212
+ prefix?: string;
213
+ }): Promise<StateValue<T>[]> {
214
+ const keys = (await this.activeKeys()).filter((key) => {
215
+ const publicKey = publicStateKey(
216
+ this.providerId,
217
+ this.namespaceName,
218
+ key,
219
+ );
220
+ return options?.prefix ? publicKey.startsWith(options.prefix) : true;
221
+ });
222
+ const limited = keys.slice(0, Math.max(0, options?.limit ?? keys.length));
223
+ if (limited.length === 0) return [];
224
+ const values = await withRequiredRedis(() =>
225
+ this.backend.redis.mget(limited),
226
+ );
227
+ return values.flatMap((raw, index) => {
228
+ const key = limited[index];
229
+ if (!key) return [];
230
+ const value = envelopeFromJson(
231
+ publicStateKey(this.providerId, this.namespaceName, key),
232
+ raw,
233
+ );
234
+ return value ? [value] : [];
235
+ });
236
+ }
237
+
238
+ async get<T>(key: string): Promise<StateValue<T> | null> {
239
+ await requireRedisReady(this.backend.redis);
240
+ const raw = await withRequiredRedis(() =>
241
+ this.backend.redis.get(this.redisKey(key)),
242
+ );
243
+ return envelopeFromJson(key, raw);
244
+ }
245
+
246
+ async set<T>(
247
+ key: string,
248
+ value: T,
249
+ options?: StateWriteOptions,
250
+ ): Promise<StateValue<T>> {
251
+ this.enforceValueSize(value);
252
+ await this.enforceMaxEntries(key);
253
+ const current = await this.get<T>(key);
254
+ const createdAt = current?.createdAt ?? new Date().toISOString();
255
+ const version = (current?.version ?? 0) + 1;
256
+ const ttl = options?.ttl ?? this.options.defaultTtl;
257
+ const ttlMs = parseStateDurationMs(ttl);
258
+ const expiresAt = resolveExpiresAt(ttl);
259
+ const envelope = redisEnvelope(value, version, createdAt, expiresAt);
260
+ await withRequiredRedis(() =>
261
+ this.backend.redis.set(
262
+ this.redisKey(key),
263
+ JSON.stringify(envelope),
264
+ "PX",
265
+ ttlMs,
266
+ ),
267
+ );
268
+ return {
269
+ key,
270
+ value,
271
+ version,
272
+ expiresAt,
273
+ createdAt,
274
+ updatedAt: envelope.updatedAt,
275
+ };
276
+ }
277
+
278
+ async patch<T extends Record<string, unknown>>(
279
+ key: string,
280
+ partial: Partial<T>,
281
+ options?: StateWriteOptions,
282
+ ): Promise<StateValue<T>> {
283
+ const current = (await this.get<Record<string, unknown>>(key))?.value ?? {};
284
+ // biome-ignore lint/suspicious/noExplicitAny: patch preserves the caller-provided generic state shape.
285
+ const merged: any = { ...current, ...partial };
286
+ return await this.set<T>(key, merged, options);
287
+ }
288
+
289
+ async compareAndSet<T>(
290
+ key: string,
291
+ expectedVersion: number,
292
+ value: T,
293
+ options?: StateWriteOptions,
294
+ ): Promise<StateCasResult<T>> {
295
+ this.enforceValueSize(value);
296
+ const current = await this.get<T>(key);
297
+ if ((current?.version ?? 0) !== expectedVersion) {
298
+ return { ok: false, current };
299
+ }
300
+ return { ok: true, value: await this.set(key, value, options) };
301
+ }
302
+
303
+ async delete(key: string): Promise<void> {
304
+ await requireRedisReady(this.backend.redis);
305
+ await withRequiredRedis(() => this.backend.redis.del(this.redisKey(key)));
306
+ }
307
+
308
+ async increment(
309
+ key: string,
310
+ field: string,
311
+ delta = 1,
312
+ options?: StateWriteOptions,
313
+ ): Promise<StateValue<Record<string, unknown>>> {
314
+ const current = (await this.get<Record<string, unknown>>(key))?.value ?? {};
315
+ const previous = typeof current[field] === "number" ? current[field] : 0;
316
+ return await this.set(
317
+ key,
318
+ { ...current, [field]: previous + delta },
319
+ options,
320
+ );
321
+ }
322
+ }
323
+
324
+ class RedisProviderRuntimeState implements ProviderRuntimeState {
325
+ readonly backend: RedisBackend;
326
+ readonly providerId?: string;
327
+
328
+ constructor(options: RedisProviderRuntimeStateOptions) {
329
+ this.backend = getRedisBackend(options.redisUrl);
330
+ this.providerId = options.providerId;
331
+ }
332
+
333
+ namespace(
334
+ name: string,
335
+ options: StateNamespaceOptions,
336
+ ): ProviderStateNamespace {
337
+ return new RedisProviderStateNamespace(
338
+ this.backend,
339
+ this.providerId,
340
+ name,
341
+ options,
342
+ );
343
+ }
344
+ }
10
345
 
11
346
  export class UnsupportedProviderStateError extends ProviderError {
12
347
  constructor(
@@ -71,6 +406,158 @@ class UnsupportedProviderRuntimeState implements ProviderRuntimeState {
71
406
  }
72
407
  }
73
408
 
409
+ class MemoryProviderStateNamespace implements ProviderStateNamespace {
410
+ // biome-ignore lint/suspicious/noExplicitAny: in-memory state stores heterogeneous generic values by key.
411
+ readonly values = new Map<string, StateValue<any>>();
412
+
413
+ constructor(private readonly options: StateNamespaceOptions) {}
414
+
415
+ private pruneExpired(nowMs = Date.now()): void {
416
+ for (const [key, row] of this.values.entries()) {
417
+ if (row.expiresAt && Date.parse(row.expiresAt) <= nowMs) {
418
+ this.values.delete(key);
419
+ }
420
+ }
421
+ }
422
+
423
+ async list<T>(_options?: {
424
+ limit?: number;
425
+ prefix?: string;
426
+ }): Promise<StateValue<T>[]> {
427
+ this.pruneExpired();
428
+ const rows = Array.from(this.values.values()).filter((value) =>
429
+ _options?.prefix ? value.key.startsWith(_options.prefix) : true,
430
+ );
431
+ return rows.slice(0, _options?.limit);
432
+ }
433
+
434
+ async get<T>(key: string): Promise<StateValue<T> | null> {
435
+ this.pruneExpired();
436
+ return this.values.get(key) ?? null;
437
+ }
438
+
439
+ async set<T>(
440
+ key: string,
441
+ value: T,
442
+ options?: StateWriteOptions,
443
+ ): Promise<StateValue<T>> {
444
+ this.pruneExpired();
445
+ const now = new Date().toISOString();
446
+ const current = this.values.get(key);
447
+ const expiresAt = resolveMemoryStateExpiresAt(
448
+ options?.ttl ?? this.options.defaultTtl,
449
+ );
450
+ const row = {
451
+ key,
452
+ value,
453
+ version: (current?.version ?? 0) + 1,
454
+ expiresAt,
455
+ createdAt: current?.createdAt ?? now,
456
+ updatedAt: now,
457
+ } satisfies StateValue<T>;
458
+ this.values.set(key, row);
459
+ return row;
460
+ }
461
+
462
+ async patch<T extends Record<string, unknown>>(
463
+ _key: string,
464
+ _partial: Partial<T>,
465
+ _options?: StateWriteOptions,
466
+ ): Promise<StateValue<T>> {
467
+ throw new UnsupportedProviderStateError(
468
+ "In-memory provider runtime state does not support patch",
469
+ );
470
+ }
471
+
472
+ async compareAndSet<T>(
473
+ _key: string,
474
+ _expectedVersion: number,
475
+ _value: T,
476
+ _options?: StateWriteOptions,
477
+ ): Promise<StateCasResult<T>> {
478
+ throw new UnsupportedProviderStateError(
479
+ "In-memory provider runtime state does not support compareAndSet",
480
+ );
481
+ }
482
+
483
+ async delete(key: string): Promise<void> {
484
+ this.values.delete(key);
485
+ }
486
+
487
+ async increment(
488
+ _key: string,
489
+ _field: string,
490
+ _delta = 1,
491
+ _options?: StateWriteOptions,
492
+ ): Promise<StateValue<Record<string, unknown>>> {
493
+ throw new UnsupportedProviderStateError(
494
+ "In-memory provider runtime state does not support increment",
495
+ );
496
+ }
497
+ }
498
+
499
+ class MemoryProviderRuntimeState implements ProviderRuntimeState {
500
+ readonly namespaces = new Map<string, MemoryProviderStateNamespace>();
501
+
502
+ namespace(
503
+ name: string,
504
+ _options: StateNamespaceOptions,
505
+ ): ProviderStateNamespace {
506
+ const existing = this.namespaces.get(name);
507
+ if (existing) return existing;
508
+ const created = new MemoryProviderStateNamespace(_options);
509
+ this.namespaces.set(name, created);
510
+ return created;
511
+ }
512
+ }
513
+
514
+ function resolveMemoryStateExpiresAt(ttl: StateWriteOptions["ttl"]): string {
515
+ const match = /^(\d+)(ms|s|m|h|d)$/.exec(ttl ?? "1h");
516
+ if (!match) return new Date(Date.now() + 3_600_000).toISOString();
517
+ const amount = Number(match[1]);
518
+ const unit = match[2];
519
+ const multiplier =
520
+ unit === "ms"
521
+ ? 1
522
+ : unit === "s"
523
+ ? 1_000
524
+ : unit === "m"
525
+ ? 60_000
526
+ : unit === "h"
527
+ ? 3_600_000
528
+ : 86_400_000;
529
+ return new Date(Date.now() + amount * multiplier).toISOString();
530
+ }
531
+
532
+ export function createRedisProviderRuntimeState(
533
+ options: RedisProviderRuntimeStateOptions,
534
+ ): ProviderRuntimeState {
535
+ return new RedisProviderRuntimeState(options);
536
+ }
537
+
538
+ export function createProviderRuntimeStateFromEnv(
539
+ options: {
540
+ readonly providerId?: string;
541
+ readonly allowMemoryFallback?: boolean;
542
+ } = {},
543
+ ): ProviderRuntimeState {
544
+ const redisUrl = providerStateRedisUrlFromEnv();
545
+ if (redisUrl) {
546
+ return createRedisProviderRuntimeState({
547
+ redisUrl,
548
+ providerId: options.providerId,
549
+ });
550
+ }
551
+ if (options.allowMemoryFallback === true) {
552
+ return new MemoryProviderRuntimeState();
553
+ }
554
+ return createUnsupportedProviderRuntimeState();
555
+ }
556
+
557
+ export function createMemoryProviderRuntimeState(): ProviderRuntimeState {
558
+ return new MemoryProviderRuntimeState();
559
+ }
560
+
74
561
  export function createUnsupportedProviderRuntimeState(): ProviderRuntimeState {
75
562
  return new UnsupportedProviderRuntimeState();
76
563
  }
@@ -776,12 +776,19 @@ function createSessionFetcher(
776
776
  options?: StealthFetchOptions,
777
777
  proxyAttempt?: number,
778
778
  ): Promise<ResolvedAttemptProxy> {
779
+ const rawProxyAttemptOffset = options?.proxyAttemptOffset ?? 0;
780
+ const proxyAttemptOffset = Number.isFinite(rawProxyAttemptOffset)
781
+ ? Math.max(0, Math.floor(rawProxyAttemptOffset))
782
+ : 0;
779
783
  const resolvedProxy = await resolveProxyConfigAsync({
780
784
  proxy: options?.proxy ?? clientOptions.proxy,
781
785
  upstream: clientOptions.upstream,
782
786
  apifuseConfig: clientOptions.apifuseConfig,
783
787
  affinityKey: clientOptions.affinityKey,
784
- proxyAttempt,
788
+ proxyAttempt:
789
+ proxyAttempt === undefined
790
+ ? proxyAttemptOffset
791
+ : proxyAttemptOffset + proxyAttempt,
785
792
  telemetry: clientOptions.telemetry,
786
793
  });
787
794
 
@@ -64,7 +64,7 @@ type InternalTraceContext = TraceContext & {
64
64
  function buildOTLPExportOptions(
65
65
  config?: TraceConfig,
66
66
  ): OTLPExportOptions | undefined {
67
- if (!config || config.exporter !== "otlp") {
67
+ if (config?.exporter !== "otlp") {
68
68
  return undefined;
69
69
  }
70
70