@bedrock-rbx/ocale 0.1.0-beta.2 → 0.1.0-beta.20

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 (81) hide show
  1. package/README.md +129 -0
  2. package/dist/badges.d.mts +33 -2
  3. package/dist/badges.d.mts.map +1 -1
  4. package/dist/badges.mjs +12 -4
  5. package/dist/badges.mjs.map +1 -1
  6. package/dist/{data.generated-BtkDGH8C.d.mts → data.generated-B2ceLfSn.d.mts} +9 -1
  7. package/dist/data.generated-B2ceLfSn.d.mts.map +1 -0
  8. package/dist/developer-products.d.mts +38 -2
  9. package/dist/developer-products.d.mts.map +1 -1
  10. package/dist/developer-products.mjs +22 -5
  11. package/dist/developer-products.mjs.map +1 -1
  12. package/dist/game-passes.d.mts +40 -2
  13. package/dist/game-passes.d.mts.map +1 -1
  14. package/dist/game-passes.mjs +22 -5
  15. package/dist/game-passes.mjs.map +1 -1
  16. package/dist/index.d.mts +110 -12
  17. package/dist/index.d.mts.map +1 -1
  18. package/dist/index.mjs +5 -3
  19. package/dist/locales.d.mts +1 -1
  20. package/dist/locales.mjs +2 -0
  21. package/dist/locales.mjs.map +1 -1
  22. package/dist/luau-execution.d.mts +112 -6
  23. package/dist/luau-execution.d.mts.map +1 -1
  24. package/dist/luau-execution.mjs +129 -4
  25. package/dist/luau-execution.mjs.map +1 -1
  26. package/dist/places.d.mts +64 -6
  27. package/dist/places.d.mts.map +1 -1
  28. package/dist/places.mjs +33 -61
  29. package/dist/places.mjs.map +1 -1
  30. package/dist/poll-timeout-C0nmJzOd.mjs +83 -0
  31. package/dist/poll-timeout-C0nmJzOd.mjs.map +1 -0
  32. package/dist/polling-helpers-Cb4j4cq7.mjs +787 -0
  33. package/dist/polling-helpers-Cb4j4cq7.mjs.map +1 -0
  34. package/dist/polling-rj0dGREg.d.mts +312 -0
  35. package/dist/polling-rj0dGREg.d.mts.map +1 -0
  36. package/dist/{price-information-s7DY0GV2.mjs → price-information-Bu87sPyh.mjs} +2 -2
  37. package/dist/{price-information-s7DY0GV2.mjs.map → price-information-Bu87sPyh.mjs.map} +1 -1
  38. package/dist/{rate-limit-CKfuhxT1.mjs → rate-limit-Co9i28qi.mjs} +24 -38
  39. package/dist/rate-limit-Co9i28qi.mjs.map +1 -0
  40. package/dist/{resource-client-Wi4Mwqy5.mjs → resource-client-lE7Tg3BK.mjs} +349 -241
  41. package/dist/resource-client-lE7Tg3BK.mjs.map +1 -0
  42. package/dist/retry-CbHBw60o.mjs +380 -0
  43. package/dist/retry-CbHBw60o.mjs.map +1 -0
  44. package/dist/retry-r1TXe5Zd.d.mts +189 -0
  45. package/dist/retry-r1TXe5Zd.d.mts.map +1 -0
  46. package/dist/signatures-JF-7Psce.mjs +59 -0
  47. package/dist/signatures-JF-7Psce.mjs.map +1 -0
  48. package/dist/storage.d.mts +322 -31
  49. package/dist/storage.d.mts.map +1 -1
  50. package/dist/storage.mjs +506 -18
  51. package/dist/storage.mjs.map +1 -1
  52. package/dist/testing.d.mts +727 -0
  53. package/dist/testing.d.mts.map +1 -0
  54. package/dist/testing.mjs +457 -0
  55. package/dist/testing.mjs.map +1 -0
  56. package/dist/{to-blob-1BtHsDGK.mjs → to-blob-RPqMBuft.mjs} +1 -1
  57. package/dist/{to-blob-1BtHsDGK.mjs.map → to-blob-RPqMBuft.mjs.map} +1 -1
  58. package/dist/{types-Cp8w8uwA.d.mts → types-CRiJg5EQ.d.mts} +46 -4
  59. package/dist/types-CRiJg5EQ.d.mts.map +1 -0
  60. package/dist/universes.d.mts +47 -10
  61. package/dist/universes.d.mts.map +1 -1
  62. package/dist/universes.mjs +7 -5
  63. package/dist/universes.mjs.map +1 -1
  64. package/dist/{validation-b7KAoEio.mjs → validation-9oU6qNNQ.mjs} +4 -2
  65. package/dist/validation-9oU6qNNQ.mjs.map +1 -0
  66. package/dist/wire-D3K-a-UP.d.mts +37 -0
  67. package/dist/wire-D3K-a-UP.d.mts.map +1 -0
  68. package/package.json +9 -7
  69. package/dist/data.generated-BtkDGH8C.d.mts.map +0 -1
  70. package/dist/is-date-time-string-Cuf1TaSC.mjs +0 -19
  71. package/dist/is-date-time-string-Cuf1TaSC.mjs.map +0 -1
  72. package/dist/rate-limit-CKfuhxT1.mjs.map +0 -1
  73. package/dist/rate-limit-DzHBFwps.d.mts +0 -92
  74. package/dist/rate-limit-DzHBFwps.d.mts.map +0 -1
  75. package/dist/resource-client-Wi4Mwqy5.mjs.map +0 -1
  76. package/dist/specs-Co6qYp_E.mjs +0 -309
  77. package/dist/specs-Co6qYp_E.mjs.map +0 -1
  78. package/dist/types-BZ0959rh.d.mts +0 -149
  79. package/dist/types-BZ0959rh.d.mts.map +0 -1
  80. package/dist/types-Cp8w8uwA.d.mts.map +0 -1
  81. package/dist/validation-b7KAoEio.mjs.map +0 -1
@@ -1,5 +1,22 @@
1
- import { i as ApiError, n as PermissionError, r as NetworkError, t as RateLimitError } from "./rate-limit-CKfuhxT1.mjs";
1
+ import { n as NetworkError, r as ApiError, t as RateLimitError } from "./rate-limit-Co9i28qi.mjs";
2
+ import { a as defaultRetryDelay, i as computeRetryWaitMs, l as PermissionError, n as IDEMPOTENT_METHOD_DEFAULTS, o as mergeConfig, s as shouldRetry } from "./retry-CbHBw60o.mjs";
2
3
  import { setTimeout } from "node:timers/promises";
4
+ //#region src/internal/utils/is-date-time-string.ts
5
+ /**
6
+ * Narrows `value` to a string that parses to a real {@link Date} via the
7
+ * `Date(string)` constructor. Used by resource parsers to gate
8
+ * `format: date-time` wire fields before handing them to `new Date(...)`,
9
+ * which silently produces an `Invalid Date` for invalid input.
10
+ *
11
+ * @param value - The unknown wire value to validate.
12
+ * @returns `true` when `value` is a string and `new Date(value).getTime()`
13
+ * is not `NaN`.
14
+ */
15
+ function isDateTimeString(value) {
16
+ if (typeof value !== "string") return false;
17
+ return !Number.isNaN(new Date(value).getTime());
18
+ }
19
+ //#endregion
3
20
  //#region src/internal/utils/is-record.ts
4
21
  /**
5
22
  * Narrows `value` to a plain JSON-style record. Excludes arrays, class
@@ -14,224 +31,138 @@ function isRecord(value) {
14
31
  return Object.prototype.toString.call(value) === "[object Object]";
15
32
  }
16
33
  //#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
- ]) });
34
+ //#region src/internal/http/budget-tracker.ts
35
+ const MS_PER_SECOND = 1e3;
29
36
  /**
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).
37
+ * Tracks the live rate-limit budget for a single scope. Primed by `observe`
38
+ * from response headers and drawn down by `reserve` as requests leave, so
39
+ * `waitMs` can pace requests across the window.
40
+ *
41
+ * Pacing has two regimes. While budget remains, requests are spread evenly over
42
+ * the time left in the window (`timeLeft / remaining`), so a burst does not
43
+ * spend the whole window's budget up front and then stall. Once the budget is
44
+ * spent, requests hold until the window resets. Budget and reset time move
45
+ * together as one window, so the tracker is either unprimed or fully primed,
46
+ * never half-known.
33
47
  */
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
48
+ var BudgetTracker = class {
49
+ /** Time (ms) the most recent request was allowed out, for spacing. */
50
+ #lastAllowedAt = void 0;
51
+ #window = void 0;
52
+ /**
53
+ * Folds a fresh server reading in, replacing any prior window. The latest
54
+ * reading wins: observe time is monotonic, so the most recently resolved
55
+ * response is the best current estimate. The spacing reference is left
56
+ * untouched so a window refresh does not reset pacing mid-stream.
57
+ *
58
+ * @param sample - Parsed `remaining`/`resetSeconds` from a response.
59
+ * @param now - The current time in ms.
60
+ */
61
+ observe(sample, now) {
62
+ this.#window = {
63
+ predictedRemaining: sample.remaining,
64
+ resetAt: now + sample.resetSeconds * MS_PER_SECOND
226
65
  };
227
- case "idempotent": return {
228
- ...methodDefaults,
229
- ...clientConfig,
230
- ...requestOptions
66
+ }
67
+ /**
68
+ * Accounts for one request leaving at `now`: records the spacing reference
69
+ * and decrements the prediction. A no-op on the prediction while unprimed.
70
+ *
71
+ * @param now - The time the request was allowed out, in ms.
72
+ */
73
+ reserve(now) {
74
+ this.#lastAllowedAt = now;
75
+ if (this.#window !== void 0) this.#window = {
76
+ ...this.#window,
77
+ predictedRemaining: this.#window.predictedRemaining - 1
231
78
  };
232
- default: throw new Error(`Unexpected methodKind: ${String(methodKind)}`);
233
79
  }
234
- }
80
+ /**
81
+ * Milliseconds to wait before the next request is allowed.
82
+ *
83
+ * @param now - The current time in ms.
84
+ * @returns `0` when a request may go now (unprimed, or the first paced send);
85
+ * the time until reset when the budget is spent; otherwise the time until
86
+ * this request's evenly-spaced slot.
87
+ */
88
+ waitMs(now) {
89
+ if (this.#window === void 0) return 0;
90
+ const { predictedRemaining, resetAt } = this.#window;
91
+ if (predictedRemaining <= 0) return Math.max(0, resetAt - now);
92
+ if (this.#lastAllowedAt === void 0) return 0;
93
+ const interval = (resetAt - now) / predictedRemaining;
94
+ return Math.max(0, this.#lastAllowedAt + interval - now);
95
+ }
96
+ };
97
+ //#endregion
98
+ //#region src/internal/http/budget-gate.ts
99
+ /**
100
+ * Header-primed rate-limit gate shared across a client. Holds one
101
+ * {@link BudgetTracker} per API key, since the tightest Roblox window is the
102
+ * per-key one shared across every operation. Before each request the caller
103
+ * gates on the request's key (sleeping if its budget is spent), and after each
104
+ * response folds the parsed sample back in, so a sibling operation on the same
105
+ * key can head off a 429 the static per-operation token bucket cannot foresee.
106
+ * A per-operation tracker is deliberately not kept: every operation reports the
107
+ * same most-constrained `remaining`, so a per-key tracker (drawn down by all
108
+ * operations) is always the binding constraint.
109
+ *
110
+ * Gating is serialized per scope through a promise chain so concurrent
111
+ * requests on one key cannot read the same budget and reserve the same slot;
112
+ * each waits for the prior gate's reserve before computing its own.
113
+ */
114
+ var BudgetGate = class {
115
+ #chains = /* @__PURE__ */ new Map();
116
+ #sleep;
117
+ #trackers = /* @__PURE__ */ new Map();
118
+ /**
119
+ * Creates a gate bound to an injectable sleep.
120
+ *
121
+ * @param sleep - Injectable sleep (tests pass a fake clock).
122
+ */
123
+ constructor(sleep) {
124
+ this.#sleep = sleep;
125
+ }
126
+ /**
127
+ * Holds until the scope's budget permits a send, then reserves one slot.
128
+ * Runs after the prior gate on the same scope settles, whether it resolved
129
+ * or rejected, so one failed attempt cannot poison later gates on the key.
130
+ *
131
+ * @param scope - The scope key to gate on (the effective API key).
132
+ */
133
+ async gate(scope) {
134
+ const previous = this.#chains.get(scope) ?? Promise.resolve();
135
+ const runGate = async () => this.#gateOnce(scope);
136
+ const mine = previous.then(runGate, runGate);
137
+ this.#chains.set(scope, mine);
138
+ await mine;
139
+ }
140
+ /**
141
+ * Folds a response's parsed budget back onto the scope. A `undefined`
142
+ * sample (headers absent or non-numeric) is ignored, leaving the scope on
143
+ * static pacing.
144
+ *
145
+ * @param scope - The same scope key passed to {@link gate}.
146
+ * @param sample - Parsed sample, or `undefined` when none was reported.
147
+ */
148
+ observe(scope, sample) {
149
+ if (sample === void 0) return;
150
+ this.#tracker(scope).observe(sample, Date.now());
151
+ }
152
+ async #gateOnce(scope) {
153
+ const tracker = this.#tracker(scope);
154
+ const waitMs = tracker.waitMs(Date.now());
155
+ if (waitMs > 0) await this.#sleep(waitMs);
156
+ tracker.reserve(Date.now());
157
+ }
158
+ #tracker(scope) {
159
+ const existing = this.#trackers.get(scope);
160
+ if (existing !== void 0) return existing;
161
+ const tracker = new BudgetTracker();
162
+ this.#trackers.set(scope, tracker);
163
+ return tracker;
164
+ }
165
+ };
235
166
  //#endregion
236
167
  //#region src/internal/http/execute.ts
237
168
  /**
@@ -267,6 +198,65 @@ async function executeWithRetry(request, options) {
267
198
  return result;
268
199
  }
269
200
  //#endregion
201
+ //#region src/internal/http/rate-limit-sample.ts
202
+ /**
203
+ * Reduces a comma-separated rate-limit header value (e.g. `"0, 70000"`) to a
204
+ * single non-negative integer via `combine`. Tokens are trimmed; blank and
205
+ * non-finite tokens (`""`, `"Infinity"`, `"abc"`) are dropped so a stray value
206
+ * cannot corrupt the result. Returns `undefined` when the header is absent or
207
+ * has no finite tokens.
208
+ *
209
+ * @param headerValue - The raw header value, or `undefined` if missing.
210
+ * @param combine - Pairwise reducer, `Math.min` for remaining, `Math.max` for reset.
211
+ * @returns The reduced, floored, clamped value, or `undefined`.
212
+ */
213
+ function reduceRateLimitTokens(headerValue, combine) {
214
+ if (headerValue === void 0) return;
215
+ const tokens = headerValue.split(",").map((part) => part.trim()).filter((part) => part !== "").map((part) => Number(part)).filter((value) => Number.isFinite(value));
216
+ if (tokens.length === 0) return;
217
+ return Math.max(0, Math.floor(tokens.reduce(combine)));
218
+ }
219
+ /**
220
+ * Parses the `x-ratelimit-remaining` and `x-ratelimit-reset` response headers
221
+ * into a {@link RateLimitSample}. Each header may carry a comma-separated list
222
+ * of per-window values; `remaining` takes the smallest (most constrained) and
223
+ * `resetSeconds` takes the largest (longest wait), symmetric to how a 429's
224
+ * retry delay is reduced. Returns `undefined` when either header is missing or
225
+ * has no finite numeric tokens, so a caller can fall back to static pacing.
226
+ *
227
+ * @param headers - Response headers with lowercased keys.
228
+ * @returns The parsed sample, or `undefined` when the budget cannot be read.
229
+ */
230
+ function parseRateLimitHeaders(headers) {
231
+ const remaining = reduceRateLimitTokens(headers["x-ratelimit-remaining"], (a, b) => Math.min(a, b));
232
+ const resetSeconds = reduceRateLimitTokens(headers["x-ratelimit-reset"], (a, b) => Math.max(a, b));
233
+ if (remaining === void 0 || resetSeconds === void 0) return;
234
+ return {
235
+ remaining,
236
+ resetSeconds
237
+ };
238
+ }
239
+ //#endregion
240
+ //#region src/internal/http/rate-limit-observation.ts
241
+ /**
242
+ * Extracts a {@link RateLimitSample} from a transport result so the budget gate
243
+ * can be fed from every attempt. A 2xx carries the budget in its headers; a 429
244
+ * carries it on the {@link RateLimitError} (the raw headers are dropped before
245
+ * this point). Any other error, or a response that reported no budget, yields
246
+ * `undefined` and leaves the gate on static pacing.
247
+ *
248
+ * @param result - The classified transport result for one attempt.
249
+ * @returns The parsed sample, or `undefined` when none was reported.
250
+ */
251
+ function rateLimitSampleFromResult(result) {
252
+ if (result.success) return parseRateLimitHeaders(result.data.headers);
253
+ const { err } = result;
254
+ if (err instanceof RateLimitError && err.remaining !== void 0) return {
255
+ remaining: err.remaining,
256
+ resetSeconds: err.retryAfterSeconds
257
+ };
258
+ }
259
+ //#endregion
270
260
  //#region src/internal/http/rate-limit-queue.ts
271
261
  /**
272
262
  * Token-bucket rate limiter for a single `(apiKey, operation)` pair. Every
@@ -354,6 +344,8 @@ async function tryCatch(promise) {
354
344
  }
355
345
  //#endregion
356
346
  //#region src/internal/http/fetch-client.ts
347
+ const MAX_DETAIL_LENGTH = 500;
348
+ const CONTENT_TYPE_HEADER = "content-type";
357
349
  /**
358
350
  * Converts a `Headers` object to a plain record with lowercased keys.
359
351
  *
@@ -397,15 +389,17 @@ function extractErrorMessage(body) {
397
389
  return extractLegacyMessage(body);
398
390
  }
399
391
  /**
400
- * Parses the `x-ratelimit-reset` header value into seconds.
392
+ * Parses the `x-ratelimit-reset` header value into seconds. On a 429 the header
393
+ * is a comma-separated list of per-window reset times (e.g. `"22, 0"`, one entry
394
+ * per rate-limit window); the largest value is the longest-resetting window and
395
+ * the only safe wait that won't retry into a still-exhausted window. A single
396
+ * value is treated as a one-element list.
401
397
  *
402
398
  * @param headerValue - The raw header value, or `undefined` if missing.
403
399
  * @returns The number of seconds to wait, or 0 if missing/invalid.
404
400
  */
405
401
  function parseRetryAfterSeconds(headerValue) {
406
- const parsed = Number(headerValue);
407
- if (Number.isNaN(parsed)) return 0;
408
- return Math.max(0, Math.floor(parsed));
402
+ return reduceRateLimitTokens(headerValue, (a, b) => Math.max(a, b)) ?? 0;
409
403
  }
410
404
  /**
411
405
  * Joins the base URL from config with the relative path from the request.
@@ -432,10 +426,10 @@ function buildFetchOptions(request, config) {
432
426
  };
433
427
  if (request.body instanceof FormData) options.body = request.body;
434
428
  else if (request.body instanceof Uint8Array) {
435
- headers.set("content-type", "application/octet-stream");
429
+ headers.set(CONTENT_TYPE_HEADER, "application/octet-stream");
436
430
  options.body = request.body;
437
431
  } else if (request.body !== void 0) {
438
- headers.set("content-type", "application/json");
432
+ headers.set(CONTENT_TYPE_HEADER, "application/json");
439
433
  options.body = JSON.stringify(request.body);
440
434
  }
441
435
  if (request.headers !== void 0) for (const [name, value] of Object.entries(request.headers)) {
@@ -453,9 +447,14 @@ function buildFetchOptions(request, config) {
453
447
  */
454
448
  function createFetchHttpClient(fetchFunc = globalThis.fetch) {
455
449
  return { async request(httpRequest, config) {
456
- const fetchResult = await tryCatch(fetchFunc(buildUrl(httpRequest, config), buildFetchOptions(httpRequest, config)));
450
+ const url = buildUrl(httpRequest, config);
451
+ const fetchResult = await tryCatch(fetchFunc(url, buildFetchOptions(httpRequest, config)));
457
452
  if (!fetchResult.success) return {
458
- err: new NetworkError("Network request failed", { cause: fetchResult.err }),
453
+ err: new NetworkError("Network request failed", {
454
+ cause: fetchResult.err,
455
+ method: httpRequest.method,
456
+ url
457
+ }),
459
458
  success: false
460
459
  };
461
460
  return classifyResponse(fetchResult.data);
@@ -502,25 +501,56 @@ function createApiError(status, body) {
502
501
  });
503
502
  }
504
503
  function createRateLimitError(response) {
505
- return new RateLimitError("Rate limited", { retryAfterSeconds: parseRetryAfterSeconds(response.headers.get("x-ratelimit-reset") ?? void 0) });
504
+ const headers = headersToRecord(response.headers);
505
+ return new RateLimitError("Rate limited", {
506
+ remaining: reduceRateLimitTokens(headers["x-ratelimit-remaining"], (a, b) => Math.min(a, b)),
507
+ retryAfterSeconds: parseRetryAfterSeconds(headers["x-ratelimit-reset"])
508
+ });
506
509
  }
507
- async function readResponseBody(response) {
510
+ /**
511
+ * Parses response text as JSON, returning the underlying `SyntaxError` on
512
+ * failure rather than throwing. The synchronous sibling of {@link tryCatch}.
513
+ *
514
+ * @param text - The raw response body text.
515
+ * @returns A Result wrapping the parsed value, or the parse error.
516
+ */
517
+ function parseJson(text) {
508
518
  try {
509
- const text = await response.text();
510
519
  return {
511
- data: text === "" ? void 0 : JSON.parse(text),
520
+ data: JSON.parse(text),
512
521
  success: true
513
522
  };
514
- } catch {
523
+ } catch (err) {
515
524
  return {
516
- err: new ApiError("Failed to parse response body", { statusCode: response.status }),
525
+ err: err instanceof Error ? err : new Error(String(err)),
517
526
  success: false
518
527
  };
519
528
  }
520
529
  }
521
530
  /**
531
+ * Builds the error for a 2xx response whose body could not be parsed as JSON,
532
+ * preserving the parse `cause`, the (truncated) raw body, and the declared
533
+ * content-type so the failure can be diagnosed after the fact.
534
+ *
535
+ * @param args - The Response, raw body text, and underlying parse error.
536
+ * @returns An ApiError carrying the diagnostic context.
537
+ */
538
+ function parseFailureError({ cause, response, text }) {
539
+ return new ApiError(`Failed to parse response body (content-type: ${response.headers.get(CONTENT_TYPE_HEADER) ?? "unknown"})`, {
540
+ cause,
541
+ details: text.slice(0, MAX_DETAIL_LENGTH),
542
+ statusCode: response.status
543
+ });
544
+ }
545
+ /**
522
546
  * Classifies a fetch `Response` into a typed `Result`.
523
547
  *
548
+ * The body is read once and parsed best-effort. Error responses (status >= 300)
549
+ * never require valid JSON: an error body that is not valid JSON (for example
550
+ * an HTML gateway page) degrades to a status-based {@link ApiError} carrying
551
+ * the raw text. A parse failure is only fatal on a 2xx, where a parseable body is part
552
+ * of the contract.
553
+ *
524
554
  * @param response - The raw fetch Response to classify.
525
555
  * @returns A Result containing an HttpResponse on success or an OpenCloudError on failure.
526
556
  */
@@ -529,15 +559,29 @@ async function classifyResponse(response) {
529
559
  err: createRateLimitError(response),
530
560
  success: false
531
561
  };
532
- const bodyResult = await readResponseBody(response);
533
- if (!bodyResult.success) return bodyResult;
534
- if (response.status >= 300) return {
535
- err: createApiError(response.status, bodyResult.data),
562
+ const text = await response.text();
563
+ const parsed = text === "" ? {
564
+ data: void 0,
565
+ success: true
566
+ } : parseJson(text);
567
+ if (response.status >= 300) {
568
+ const body = parsed.success ? parsed.data : text.slice(0, MAX_DETAIL_LENGTH);
569
+ return {
570
+ err: createApiError(response.status, body),
571
+ success: false
572
+ };
573
+ }
574
+ if (!parsed.success) return {
575
+ err: parseFailureError({
576
+ cause: parsed.err,
577
+ response,
578
+ text
579
+ }),
536
580
  success: false
537
581
  };
538
582
  return {
539
583
  data: {
540
- body: bodyResult.data,
584
+ body: parsed.data,
541
585
  headers: headersToRecord(response.headers),
542
586
  status: response.status
543
587
  },
@@ -565,6 +609,21 @@ function resolveDependencies(options) {
565
609
  };
566
610
  }
567
611
  //#endregion
612
+ //#region src/internal/http/upload-request.ts
613
+ /**
614
+ * Reports whether a request is an upload: its body is `FormData`
615
+ * (multipart) or `Uint8Array` (raw binary). Upload latency is
616
+ * bandwidth-bound rather than compute-bound, so the SDK applies no default
617
+ * request timeout to these requests; a sensible wall-clock budget depends on
618
+ * payload size and link quality the SDK cannot know.
619
+ *
620
+ * @param request - The built request to classify.
621
+ * @returns `true` when the body is `FormData` or `Uint8Array`.
622
+ */
623
+ function isUploadRequest(request) {
624
+ return request.body instanceof FormData || request.body instanceof Uint8Array;
625
+ }
626
+ //#endregion
568
627
  //#region src/internal/resource-client.ts
569
628
  /**
570
629
  * Wraps an infallible request build as a {@link Result}-returning
@@ -598,6 +657,7 @@ const CLIENT_DEFAULTS = Object.freeze({
598
657
  baseUrl: "https://apis.roblox.com",
599
658
  maxRetries: 3,
600
659
  retryableStatuses: IDEMPOTENT_METHOD_DEFAULTS.retryableStatuses,
660
+ retryableTransportCodes: IDEMPOTENT_METHOD_DEFAULTS.retryableTransportCodes,
601
661
  retryDelay: defaultRetryDelay,
602
662
  timeout: 3e4
603
663
  });
@@ -611,6 +671,7 @@ const CLIENT_DEFAULTS = Object.freeze({
611
671
  * `src/resources/**` modules in this package.
612
672
  */
613
673
  var ResourceClient = class {
674
+ #budgets;
614
675
  #config;
615
676
  #hooks;
616
677
  #httpClient;
@@ -632,6 +693,7 @@ var ResourceClient = class {
632
693
  });
633
694
  this.#httpClient = resolved.httpClient;
634
695
  this.#sleep = resolved.sleep;
696
+ this.#budgets = new BudgetGate(this.#sleep);
635
697
  this.#hooks = hooks ?? {};
636
698
  this.#config = Object.freeze({
637
699
  ...CLIENT_DEFAULTS,
@@ -660,16 +722,16 @@ var ResourceClient = class {
660
722
  });
661
723
  const requestResult = spec.buildRequest(parameters);
662
724
  if (!requestResult.success) return requestResult;
663
- const requestConfig = {
664
- apiKey: merged.apiKey,
665
- baseUrl: merged.baseUrl,
666
- timeout: merged.timeout
667
- };
725
+ const requestConfig = buildRequestConfig({
726
+ merged,
727
+ options,
728
+ request: requestResult.data
729
+ });
668
730
  const httpResult = await this.#getQueue(merged.apiKey, spec.operationLimit).acquire(async () => {
669
731
  return executeWithRetry(requestResult.data, {
670
732
  config: merged,
671
733
  hooks: this.#hooks,
672
- send: async (toSend) => this.#httpClient.request(toSend, requestConfig),
734
+ send: this.#gatedSend(merged.apiKey, requestConfig),
673
735
  sleep: this.#sleep
674
736
  });
675
737
  });
@@ -679,6 +741,32 @@ var ResourceClient = class {
679
741
  };
680
742
  return spec.parse(httpResult.data);
681
743
  }
744
+ /**
745
+ * Returns the sleep function used by this client instance.
746
+ *
747
+ * @returns The sleep function injected at construction time.
748
+ */
749
+ get sleep() {
750
+ return this.#sleep;
751
+ }
752
+ /**
753
+ * Builds the transport callback for one logical call, wrapping the HTTP
754
+ * client with the budget gate: each attempt waits on the API key's budget
755
+ * before sending, then folds the response's reported budget back in so the
756
+ * next attempt (or a sibling operation on the same key) can head off a 429.
757
+ *
758
+ * @param apiKey - The effective API key to gate on.
759
+ * @param requestConfig - The resolved per-request transport config.
760
+ * @returns A send callback for {@link executeWithRetry}.
761
+ */
762
+ #gatedSend(apiKey, requestConfig) {
763
+ return async (toSend) => {
764
+ await this.#budgets.gate(apiKey);
765
+ const sendResult = await this.#httpClient.request(toSend, requestConfig);
766
+ this.#budgets.observe(apiKey, rateLimitSampleFromResult(sendResult));
767
+ return sendResult;
768
+ };
769
+ }
682
770
  #getQueue(apiKey, limit) {
683
771
  const key = `${apiKey}::${limit.operationKey}`;
684
772
  const existing = this.#queues.get(key);
@@ -688,6 +776,26 @@ var ResourceClient = class {
688
776
  return queue;
689
777
  }
690
778
  };
779
+ /**
780
+ * Resolves the per-request {@link RequestConfig}. Upload requests
781
+ * ({@link isUploadRequest}) carry no default timeout: a multi-megabyte place
782
+ * file over a slow link is bandwidth-bound, so a client-side deadline only
783
+ * fires spuriously. An explicit `options.timeout` still applies to any
784
+ * request; every non-upload request keeps the merged default.
785
+ *
786
+ * @param inputs - The merged config, the built request, and per-request overrides.
787
+ * @returns The config to hand to the transport, with `timeout` omitted when
788
+ * no client-side deadline should apply.
789
+ */
790
+ function buildRequestConfig(inputs) {
791
+ const { merged, options, request } = inputs;
792
+ const shouldOmitDefaultTimeout = options?.timeout === void 0 && isUploadRequest(request);
793
+ return {
794
+ apiKey: merged.apiKey,
795
+ baseUrl: merged.baseUrl,
796
+ ...shouldOmitDefaultTimeout ? {} : { timeout: merged.timeout }
797
+ };
798
+ }
691
799
  function enrichPermissionError(err, spec) {
692
800
  if (spec.requiredScopes === void 0) return err;
693
801
  if (err instanceof PermissionError) return err;
@@ -702,6 +810,6 @@ function enrichPermissionError(err, spec) {
702
810
  });
703
811
  }
704
812
  //#endregion
705
- export { IDEMPOTENT_METHOD_DEFAULTS as a, CREATE_METHOD_DEFAULTS as i, okRequest as n, isRecord as o, parseEmptyResponse as r, ResourceClient as t };
813
+ export { isDateTimeString as a, isRecord as i, okRequest as n, parseEmptyResponse as r, ResourceClient as t };
706
814
 
707
- //# sourceMappingURL=resource-client-Wi4Mwqy5.mjs.map
815
+ //# sourceMappingURL=resource-client-lE7Tg3BK.mjs.map