@bedrock-rbx/ocale 0.1.0-beta.1

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.
@@ -0,0 +1,652 @@
1
+ import { i as ApiError, n as PermissionError, r as NetworkError, t as RateLimitError } from "./rate-limit-BBU_4xnZ.mjs";
2
+ import { setTimeout } from "node:timers/promises";
3
+ //#region src/internal/utils/is-record.ts
4
+ /**
5
+ * Narrows `value` to a plain JSON-style record. Excludes arrays, class
6
+ * instances, primitives, and `null`/`undefined`. Used by resource
7
+ * parsers to gate property access on wire bodies whose shape isn't
8
+ * known at compile time.
9
+ *
10
+ * @param value - The unknown value to narrow.
11
+ * @returns `true` when `value` is a plain `[object Object]`.
12
+ */
13
+ function isRecord(value) {
14
+ return Object.prototype.toString.call(value) === "[object Object]";
15
+ }
16
+ //#endregion
17
+ //#region src/internal/http/retry.ts
18
+ /**
19
+ * Default retry status codes for idempotent operations (read, list, update,
20
+ * delete). Safe to retry on both rate limits and transient server errors.
21
+ */
22
+ const IDEMPOTENT_METHOD_DEFAULTS = Object.freeze({ retryableStatuses: Object.freeze([
23
+ 429,
24
+ 500,
25
+ 502,
26
+ 503,
27
+ 504
28
+ ]) });
29
+ /**
30
+ * Default retry status codes for create operations. Retries rate limits only,
31
+ * to prevent duplicate resources on 5xx (Roblox Open Cloud has no
32
+ * idempotency-key support).
33
+ */
34
+ const CREATE_METHOD_DEFAULTS = Object.freeze({ retryableStatuses: Object.freeze([429]) });
35
+ /**
36
+ * Default exponential backoff: 1s → 2s → 4s → 8s → 16s → 30s (capped).
37
+ *
38
+ * @example
39
+ *
40
+ * ```ts
41
+ * import { defaultRetryDelay } from "./retry";
42
+ *
43
+ * expect(defaultRetryDelay(0)).toBe(1000);
44
+ * expect(defaultRetryDelay(4)).toBe(16_000);
45
+ * expect(defaultRetryDelay(10)).toBe(30_000);
46
+ * ```
47
+ *
48
+ * @param attempt - Zero-indexed retry attempt number.
49
+ * @returns Wait duration in milliseconds.
50
+ */
51
+ function defaultRetryDelay(attempt) {
52
+ return Math.min(1e3 * 2 ** attempt, 3e4);
53
+ }
54
+ /**
55
+ * Computes how long to wait before the next retry. Prefers the server's
56
+ * suggested delay when the error is a {@link RateLimitError} with a positive
57
+ * `retryAfterSeconds`; otherwise falls through to `retryDelay(attempt)`.
58
+ *
59
+ * @example
60
+ *
61
+ * ```ts
62
+ * import { RateLimitError } from "../../errors/rate-limit.ts";
63
+ * import { computeRetryWaitMs, defaultRetryDelay } from "./retry";
64
+ *
65
+ * const error = new RateLimitError("slow down", { retryAfterSeconds: 3 });
66
+ *
67
+ * expect(computeRetryWaitMs(error, { attempt: 0, retryDelay: defaultRetryDelay })).toBe(
68
+ * 3000,
69
+ * );
70
+ * ```
71
+ *
72
+ * @example
73
+ *
74
+ * ```ts
75
+ * import { ApiError } from "../../errors/api-error.ts";
76
+ * import { computeRetryWaitMs, defaultRetryDelay } from "./retry";
77
+ *
78
+ * const error = new ApiError("server error", { statusCode: 503 });
79
+ *
80
+ * expect(computeRetryWaitMs(error, { attempt: 2, retryDelay: defaultRetryDelay })).toBe(
81
+ * 4000,
82
+ * );
83
+ * ```
84
+ *
85
+ * @param error - The error returned by the failing request.
86
+ * @param options - Retry attempt index and fallback delay function.
87
+ * @returns Wait duration in milliseconds before the next attempt.
88
+ */
89
+ function computeRetryWaitMs(error, options) {
90
+ if (error instanceof RateLimitError && error.retryAfterSeconds > 0) return error.retryAfterSeconds * 1e3;
91
+ return options.retryDelay(options.attempt);
92
+ }
93
+ /**
94
+ * Decides whether a failed request is eligible for retry under the given
95
+ * `retryableStatuses`. Only {@link RateLimitError} (checked against 429) and
96
+ * {@link ApiError} (checked against its `statusCode`) are retryable — network
97
+ * errors and other failures always return `false`.
98
+ *
99
+ * @example
100
+ *
101
+ * ```ts
102
+ * import { RateLimitError } from "../../errors/rate-limit.ts";
103
+ * import { shouldRetry } from "./retry";
104
+ *
105
+ * const error = new RateLimitError("", { retryAfterSeconds: 1 });
106
+ *
107
+ * expect(shouldRetry(error, { retryableStatuses: [429] })).toBe(true);
108
+ * ```
109
+ *
110
+ * @example
111
+ *
112
+ * ```ts
113
+ * import { ApiError } from "../../errors/api-error.ts";
114
+ * import { shouldRetry } from "./retry";
115
+ *
116
+ * const error = new ApiError("", { statusCode: 503 });
117
+ *
118
+ * expect(shouldRetry(error, { retryableStatuses: [429, 500, 502, 503, 504] })).toBe(
119
+ * true,
120
+ * );
121
+ * ```
122
+ *
123
+ * @example
124
+ *
125
+ * ```ts
126
+ * import { NetworkError } from "../../errors/network-error.ts";
127
+ * import { shouldRetry } from "./retry";
128
+ *
129
+ * const error = new NetworkError("offline");
130
+ *
131
+ * expect(shouldRetry(error, { retryableStatuses: [429] })).toBe(false);
132
+ * ```
133
+ *
134
+ * @param error - The error returned by the failing request.
135
+ * @param config - Object carrying the retry-eligible status list.
136
+ * @returns `true` if the error should be retried, `false` otherwise.
137
+ */
138
+ function shouldRetry(error, config) {
139
+ if (error instanceof RateLimitError) return config.retryableStatuses.includes(429);
140
+ if (error instanceof ApiError) return config.retryableStatuses.includes(error.statusCode);
141
+ return false;
142
+ }
143
+ /**
144
+ * Resolves the effective config for a single request by shallow-merging the
145
+ * client config, method defaults, and per-request options. Precedence depends
146
+ * on `methodKind`:
147
+ *
148
+ * - `"create"`: method defaults override client config, so client-level
149
+ * settings cannot silently relax create-method safety. Only explicit
150
+ * per-request `requestOptions` can.
151
+ * - `"idempotent"`: client config overrides method defaults, so consumers
152
+ * can loosen or tighten retry policy globally. `requestOptions` still wins
153
+ * when provided.
154
+ *
155
+ * Array-valued fields like `retryableStatuses` are *replaced*, not extended.
156
+ *
157
+ * @template T - Concrete `RetryResolvable` subtype being merged.
158
+ *
159
+ * @example
160
+ *
161
+ * ```ts
162
+ * import {
163
+ * CREATE_METHOD_DEFAULTS,
164
+ * defaultRetryDelay,
165
+ * mergeConfig,
166
+ * type RetryResolvable,
167
+ * } from "./retry";
168
+ *
169
+ * const clientConfig: RetryResolvable = {
170
+ * apiKey: "k",
171
+ * baseUrl: "https://apis.roblox.com",
172
+ * maxRetries: 3,
173
+ * retryableStatuses: [429, 500],
174
+ * retryDelay: defaultRetryDelay,
175
+ * timeout: 30_000,
176
+ * };
177
+ *
178
+ * const merged = mergeConfig(clientConfig, {
179
+ * methodDefaults: CREATE_METHOD_DEFAULTS,
180
+ * methodKind: "create",
181
+ * });
182
+ *
183
+ * expect(merged.retryableStatuses).toStrictEqual([429]);
184
+ * ```
185
+ *
186
+ * @example
187
+ *
188
+ * ```ts
189
+ * import {
190
+ * defaultRetryDelay,
191
+ * IDEMPOTENT_METHOD_DEFAULTS,
192
+ * mergeConfig,
193
+ * type RetryResolvable,
194
+ * } from "./retry";
195
+ *
196
+ * const clientConfig: RetryResolvable = {
197
+ * apiKey: "k",
198
+ * baseUrl: "https://apis.roblox.com",
199
+ * maxRetries: 3,
200
+ * retryableStatuses: [429],
201
+ * retryDelay: defaultRetryDelay,
202
+ * timeout: 30_000,
203
+ * };
204
+ *
205
+ * const merged = mergeConfig(clientConfig, {
206
+ * methodDefaults: IDEMPOTENT_METHOD_DEFAULTS,
207
+ * methodKind: "idempotent",
208
+ * requestOptions: { timeout: 10_000 },
209
+ * });
210
+ *
211
+ * expect(merged.retryableStatuses).toStrictEqual([429]);
212
+ * expect(merged.timeout).toBe(10_000);
213
+ * ```
214
+ *
215
+ * @param clientConfig - Config frozen at client construction.
216
+ * @param options - Method defaults, method kind, and optional per-request overrides.
217
+ * @returns A new merged config object. Inputs are not mutated.
218
+ */
219
+ function mergeConfig(clientConfig, options) {
220
+ const { methodDefaults, methodKind, requestOptions } = options;
221
+ switch (methodKind) {
222
+ case "create": return {
223
+ ...clientConfig,
224
+ ...methodDefaults,
225
+ ...requestOptions
226
+ };
227
+ case "idempotent": return {
228
+ ...methodDefaults,
229
+ ...clientConfig,
230
+ ...requestOptions
231
+ };
232
+ default: throw new Error(`Unexpected methodKind: ${String(methodKind)}`);
233
+ }
234
+ }
235
+ //#endregion
236
+ //#region src/internal/http/execute.ts
237
+ /**
238
+ * Retry-aware orchestration loop. Coordinates a single logical request,
239
+ * looping over `options.send` until it succeeds, the error is non-retryable,
240
+ * or `options.config.maxRetries` is exhausted. Fires observability hooks
241
+ * at each transition. Domain- and queue-agnostic: `send` may be any
242
+ * callback, including one wrapped by a rate-limit queue.
243
+ *
244
+ * @param request - The immutable request to send.
245
+ * @param options - The transport callback, resolved config, hooks, and sleep.
246
+ * @returns The first success, or the final error after retries are exhausted.
247
+ */
248
+ async function executeWithRetry(request, options) {
249
+ const { config, hooks, send, sleep } = options;
250
+ async function attempt() {
251
+ hooks.onRequest?.(request);
252
+ return send(request);
253
+ }
254
+ let result = await attempt();
255
+ for (let retry = 0; retry < config.maxRetries; retry++) {
256
+ if (result.success || !shouldRetry(result.err, config)) return result;
257
+ const { err } = result;
258
+ hooks.onRetry?.(retry + 1, err);
259
+ const waitMs = computeRetryWaitMs(err, {
260
+ attempt: retry,
261
+ retryDelay: config.retryDelay
262
+ });
263
+ hooks.onRateLimit?.(waitMs);
264
+ await sleep(waitMs);
265
+ result = await attempt();
266
+ }
267
+ return result;
268
+ }
269
+ //#endregion
270
+ //#region src/internal/http/rate-limit-queue.ts
271
+ /**
272
+ * Token-bucket rate limiter for a single `(apiKey, operation)` pair. Every
273
+ * call to `acquire` consumes one token; when the bucket is empty the call
274
+ * waits until a token regenerates before invoking the task. Burst capacity
275
+ * equals `maxPerSecond`, refilling at `maxPerSecond` tokens per second.
276
+ *
277
+ * Implemented as a leaky bucket tracking drain debt in ms. `#lastCheck`
278
+ * advances by `waitMs` after every sleep so the algorithm stays correct
279
+ * whether or not the injected sleep moves `Date.now()` forward.
280
+ */
281
+ var RateLimitQueue = class {
282
+ #hooks;
283
+ #intervalMs;
284
+ #maxBucketLevel;
285
+ #sleep;
286
+ #bucketLevel = 0;
287
+ #chain = Promise.resolve();
288
+ #lastCheck = Date.now();
289
+ /**
290
+ * Creates a rate-limit queue bound to a single operation.
291
+ *
292
+ * @param limit - The operation key and its per-second request ceiling.
293
+ * @param hooks - Observability callbacks; `onRateLimit` fires when the
294
+ * bucket is empty and a sleep is about to start.
295
+ * @param sleep - Injectable sleep (tests pass a fake).
296
+ */
297
+ constructor(limit, hooks, sleep) {
298
+ this.#intervalMs = 1e3 / limit.maxPerSecond;
299
+ this.#maxBucketLevel = limit.maxPerSecond * this.#intervalMs;
300
+ this.#hooks = hooks;
301
+ this.#sleep = sleep;
302
+ }
303
+ /**
304
+ * Waits for a token — sleeping and firing `hooks.onRateLimit` if the
305
+ * bucket is empty — then executes `task`. Concurrent callers are
306
+ * serialized at token acquisition; tasks themselves run independently
307
+ * once their token is secured.
308
+ *
309
+ * @param task - The request to run once a token is available.
310
+ * @returns The value produced by `task`.
311
+ */
312
+ async acquire(task) {
313
+ const myTurn = this.#chain.then(async () => this.#waitForToken());
314
+ this.#chain = myTurn;
315
+ await myTurn;
316
+ return task();
317
+ }
318
+ async #waitForToken() {
319
+ const now = Math.max(Date.now(), this.#lastCheck);
320
+ const drained = Math.max(0, this.#bucketLevel - (now - this.#lastCheck));
321
+ this.#lastCheck = now;
322
+ if (drained + this.#intervalMs <= this.#maxBucketLevel) {
323
+ this.#bucketLevel = drained + this.#intervalMs;
324
+ return;
325
+ }
326
+ const waitMs = drained + this.#intervalMs - this.#maxBucketLevel;
327
+ this.#hooks.onRateLimit?.(waitMs);
328
+ await this.#sleep(waitMs);
329
+ this.#bucketLevel = this.#maxBucketLevel;
330
+ this.#lastCheck = now + waitMs;
331
+ }
332
+ };
333
+ //#endregion
334
+ //#region src/internal/utils/try-catch.ts
335
+ /**
336
+ * Wraps a promise into a {@link Result}, catching rejections.
337
+ *
338
+ * @template T - The resolved value type.
339
+ * @param promise - The promise to wrap.
340
+ * @returns A Result containing the resolved value or the rejection error.
341
+ */
342
+ async function tryCatch(promise) {
343
+ try {
344
+ return {
345
+ data: await promise,
346
+ success: true
347
+ };
348
+ } catch (err) {
349
+ return {
350
+ err: err instanceof Error ? err : new Error(String(err)),
351
+ success: false
352
+ };
353
+ }
354
+ }
355
+ //#endregion
356
+ //#region src/internal/http/fetch-client.ts
357
+ /**
358
+ * Converts a `Headers` object to a plain record with lowercased keys.
359
+ *
360
+ * @param headers - The `Headers` instance to convert.
361
+ * @returns A record mapping lowercased header names to their values.
362
+ */
363
+ function headersToRecord(headers) {
364
+ return Object.fromEntries(headers);
365
+ }
366
+ /**
367
+ * Permissively extracts a top-level `errorCode` string field from a
368
+ * response body.
369
+ *
370
+ * @param body - The parsed response body (unknown shape).
371
+ * @returns The `errorCode` string if present, otherwise `undefined`.
372
+ */
373
+ function extractErrorCode(body) {
374
+ if (body === null || typeof body !== "object") return;
375
+ const errorCode = Reflect.get(body, "errorCode");
376
+ return typeof errorCode === "string" ? errorCode : void 0;
377
+ }
378
+ /**
379
+ * Parses the `x-ratelimit-reset` header value into seconds.
380
+ *
381
+ * @param headerValue - The raw header value, or `undefined` if missing.
382
+ * @returns The number of seconds to wait, or 0 if missing/invalid.
383
+ */
384
+ function parseRetryAfterSeconds(headerValue) {
385
+ const parsed = Number(headerValue);
386
+ if (Number.isNaN(parsed)) return 0;
387
+ return Math.max(0, Math.floor(parsed));
388
+ }
389
+ /**
390
+ * Joins the base URL from config with the relative path from the request.
391
+ *
392
+ * @param request - The HTTP request containing the relative URL.
393
+ * @param config - The request config containing the base URL.
394
+ * @returns The fully-qualified URL string.
395
+ */
396
+ function buildUrl(request, config) {
397
+ return `${config.baseUrl.endsWith("/") ? config.baseUrl.slice(0, -1) : config.baseUrl}${request.url}`;
398
+ }
399
+ /**
400
+ * Constructs the `RequestInit` options for a `fetch` call.
401
+ *
402
+ * @param request - The HTTP request to build options for.
403
+ * @param config - The request config containing API key and timeout.
404
+ * @returns A `RequestInit` object ready for `fetch`.
405
+ */
406
+ function buildFetchOptions(request, config) {
407
+ const headers = new Headers({ "x-api-key": config.apiKey });
408
+ const options = {
409
+ headers,
410
+ method: request.method
411
+ };
412
+ if (request.body instanceof FormData) options.body = request.body;
413
+ else if (request.body instanceof Uint8Array) {
414
+ headers.set("content-type", "application/octet-stream");
415
+ options.body = request.body;
416
+ } else if (request.body !== void 0) {
417
+ headers.set("content-type", "application/json");
418
+ options.body = JSON.stringify(request.body);
419
+ }
420
+ if (request.headers !== void 0) for (const [name, value] of Object.entries(request.headers)) {
421
+ if (name.toLowerCase() === "x-api-key") continue;
422
+ headers.set(name, value);
423
+ }
424
+ if (config.timeout !== void 0) options.signal = AbortSignal.timeout(config.timeout);
425
+ return options;
426
+ }
427
+ /**
428
+ * Creates an {@link HttpClient} backed by the Fetch API.
429
+ *
430
+ * @param fetchFunc - The fetch implementation to use. Defaults to `globalThis.fetch`.
431
+ * @returns An HttpClient that classifies responses into typed Results.
432
+ */
433
+ function createFetchHttpClient(fetchFunc = globalThis.fetch) {
434
+ return { async request(httpRequest, config) {
435
+ const fetchResult = await tryCatch(fetchFunc(buildUrl(httpRequest, config), buildFetchOptions(httpRequest, config)));
436
+ if (!fetchResult.success) return {
437
+ err: new NetworkError("Network request failed", { cause: fetchResult.err }),
438
+ success: false
439
+ };
440
+ return classifyResponse(fetchResult.data);
441
+ } };
442
+ }
443
+ function createApiError(status, body) {
444
+ return new ApiError(`HTTP ${status}`, {
445
+ code: extractErrorCode(body),
446
+ statusCode: status
447
+ });
448
+ }
449
+ /**
450
+ * Classifies a fetch `Response` into a typed `Result`.
451
+ *
452
+ * @param response - The raw fetch Response to classify.
453
+ * @returns A Result containing an HttpResponse on success or an OpenCloudError on failure.
454
+ */
455
+ function createRateLimitError(response) {
456
+ return new RateLimitError("Rate limited", { retryAfterSeconds: parseRetryAfterSeconds(response.headers.get("x-ratelimit-reset") ?? void 0) });
457
+ }
458
+ async function readResponseBody(response) {
459
+ try {
460
+ const text = await response.text();
461
+ return {
462
+ data: text === "" ? void 0 : JSON.parse(text),
463
+ success: true
464
+ };
465
+ } catch {
466
+ return {
467
+ err: new ApiError("Failed to parse response body", { statusCode: response.status }),
468
+ success: false
469
+ };
470
+ }
471
+ }
472
+ async function classifyResponse(response) {
473
+ if (response.status === 429) return {
474
+ err: createRateLimitError(response),
475
+ success: false
476
+ };
477
+ const bodyResult = await readResponseBody(response);
478
+ if (!bodyResult.success) return bodyResult;
479
+ if (response.status >= 300) return {
480
+ err: createApiError(response.status, bodyResult.data),
481
+ success: false
482
+ };
483
+ return {
484
+ data: {
485
+ body: bodyResult.data,
486
+ headers: headersToRecord(response.headers),
487
+ status: response.status
488
+ },
489
+ success: true
490
+ };
491
+ }
492
+ //#endregion
493
+ //#region src/internal/http/resolve-dependencies.ts
494
+ /**
495
+ * Resolves the concrete HTTP client and sleep implementation a resource
496
+ * client should use. Falls back to the fetch-backed HTTP client and the
497
+ * default `setTimeout`-based sleep when the caller omits the test seams.
498
+ *
499
+ * Extracted so resource client constructors can keep their dependency
500
+ * resolution logic in a single, unit-testable place; this makes the
501
+ * default branches easy to cover without stubbing globals like `fetch`.
502
+ *
503
+ * @param options - Optional {@link HttpClient} and {@link SleepFunc} test seams.
504
+ * @returns A {@link ResolvedDependencies} with defaults applied.
505
+ */
506
+ function resolveDependencies(options) {
507
+ return {
508
+ httpClient: options.httpClient ?? createFetchHttpClient(),
509
+ sleep: options.sleep ?? setTimeout
510
+ };
511
+ }
512
+ //#endregion
513
+ //#region src/internal/resource-client.ts
514
+ /**
515
+ * Wraps an infallible request build as a {@link Result}-returning
516
+ * `buildRequest` callback compatible with {@link ResourceMethodSpec}.
517
+ * Use from a resource client whose builder cannot fail; resource clients
518
+ * with local validation should construct the {@link Result} directly.
519
+ *
520
+ * @param request - The pre-built {@link HttpRequest}.
521
+ * @returns A success Result wrapping the request.
522
+ */
523
+ function okRequest(request) {
524
+ return {
525
+ data: request,
526
+ success: true
527
+ };
528
+ }
529
+ /**
530
+ * A {@link ResourceMethodSpec.parse} implementation for endpoints that return
531
+ * no business payload on success (such as `DELETE` and reorder operations).
532
+ * Surfaces `undefined` data and never inspects the response body.
533
+ *
534
+ * @returns A success Result with `undefined` data.
535
+ */
536
+ function parseEmptyResponse() {
537
+ return {
538
+ data: void 0,
539
+ success: true
540
+ };
541
+ }
542
+ const CLIENT_DEFAULTS = Object.freeze({
543
+ baseUrl: "https://apis.roblox.com",
544
+ maxRetries: 3,
545
+ retryableStatuses: IDEMPOTENT_METHOD_DEFAULTS.retryableStatuses,
546
+ retryDelay: defaultRetryDelay,
547
+ timeout: 3e4
548
+ });
549
+ /**
550
+ * Internal orchestrator shared by every Open Cloud resource client. Holds
551
+ * the frozen client config, observability hooks, injected HTTP client and
552
+ * sleep, and the per-effective-key rate-limit queue registry. Resource
553
+ * classes compose one instance and dispatch every public method through
554
+ * {@link ResourceClient.execute} with a per-method {@link ResourceMethodSpec}.
555
+ * Not exported from any package subpath; reachable only via sibling
556
+ * `src/resources/**` modules in this package.
557
+ */
558
+ var ResourceClient = class {
559
+ #config;
560
+ #hooks;
561
+ #httpClient;
562
+ #queues = /* @__PURE__ */ new Map();
563
+ #sleep;
564
+ /**
565
+ * Creates a new {@link ResourceClient}. Resolves the injected HTTP
566
+ * client and sleep (defaulting to fetch + `setTimeout`) and freezes the
567
+ * merged client config so subsequent calls cannot mutate it.
568
+ *
569
+ * @param options - Client-level configuration including the API key
570
+ * and optional construction-time test seams.
571
+ */
572
+ constructor(options) {
573
+ const { apiKey, hooks, httpClient, sleep, ...overrides } = options;
574
+ const resolved = resolveDependencies({
575
+ httpClient,
576
+ sleep
577
+ });
578
+ this.#httpClient = resolved.httpClient;
579
+ this.#sleep = resolved.sleep;
580
+ this.#hooks = hooks ?? {};
581
+ this.#config = Object.freeze({
582
+ ...CLIENT_DEFAULTS,
583
+ apiKey,
584
+ ...overrides
585
+ });
586
+ }
587
+ /**
588
+ * Dispatches a single resource-method call. Merges the frozen client
589
+ * config with the method's `methodDefaults` and the caller's optional
590
+ * per-request `options`, routes through the effective-apiKey rate-limit
591
+ * queue, runs the retry loop, and finally parses the response with the
592
+ * spec's parser.
593
+ *
594
+ * @param call - The per-method spec, resource-specific parameters, and
595
+ * optional per-request overrides.
596
+ * @returns The parsed success payload or the {@link OpenCloudError} that
597
+ * caused the request to fail. Never throws.
598
+ */
599
+ async execute(call) {
600
+ const { options, parameters, spec } = call;
601
+ const merged = mergeConfig(this.#config, {
602
+ methodDefaults: spec.methodDefaults,
603
+ methodKind: spec.methodKind,
604
+ requestOptions: options ?? {}
605
+ });
606
+ const requestResult = spec.buildRequest(parameters);
607
+ if (!requestResult.success) return requestResult;
608
+ const requestConfig = {
609
+ apiKey: merged.apiKey,
610
+ baseUrl: merged.baseUrl,
611
+ timeout: merged.timeout
612
+ };
613
+ const httpResult = await this.#getQueue(merged.apiKey, spec.operationLimit).acquire(async () => {
614
+ return executeWithRetry(requestResult.data, {
615
+ config: merged,
616
+ hooks: this.#hooks,
617
+ send: async (toSend) => this.#httpClient.request(toSend, requestConfig),
618
+ sleep: this.#sleep
619
+ });
620
+ });
621
+ if (!httpResult.success) return {
622
+ err: enrichPermissionError(httpResult.err, spec),
623
+ success: false
624
+ };
625
+ return spec.parse(httpResult.data);
626
+ }
627
+ #getQueue(apiKey, limit) {
628
+ const key = `${apiKey}::${limit.operationKey}`;
629
+ const existing = this.#queues.get(key);
630
+ if (existing !== void 0) return existing;
631
+ const queue = new RateLimitQueue(limit, this.#hooks, this.#sleep);
632
+ this.#queues.set(key, queue);
633
+ return queue;
634
+ }
635
+ };
636
+ function enrichPermissionError(err, spec) {
637
+ if (spec.requiredScopes === void 0) return err;
638
+ if (err instanceof PermissionError) return err;
639
+ if (!(err instanceof ApiError)) return err;
640
+ if (err.statusCode !== 401 && err.statusCode !== 403) return err;
641
+ return new PermissionError(err.message, {
642
+ cause: err.cause,
643
+ code: err.code,
644
+ operationKey: spec.operationLimit.operationKey,
645
+ requiredScopes: spec.requiredScopes,
646
+ statusCode: err.statusCode
647
+ });
648
+ }
649
+ //#endregion
650
+ export { IDEMPOTENT_METHOD_DEFAULTS as a, CREATE_METHOD_DEFAULTS as i, okRequest as n, isRecord as o, parseEmptyResponse as r, ResourceClient as t };
651
+
652
+ //# sourceMappingURL=resource-client-CaS_j3yg.mjs.map