@bedrock-rbx/ocale 0.1.0-beta.9 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +182 -0
  2. package/dist/badges.d.mts +33 -2
  3. package/dist/badges.d.mts.map +1 -1
  4. package/dist/badges.mjs +16 -4
  5. package/dist/badges.mjs.map +1 -1
  6. package/dist/{data.generated-Cb6g6asv.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 +32 -8
  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 +30 -6
  15. package/dist/game-passes.mjs.map +1 -1
  16. package/dist/index.d.mts +21 -12
  17. package/dist/index.d.mts.map +1 -1
  18. package/dist/index.mjs +5 -5
  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 +22 -3
  23. package/dist/luau-execution.d.mts.map +1 -1
  24. package/dist/luau-execution.mjs +29 -13
  25. package/dist/luau-execution.mjs.map +1 -1
  26. package/dist/places.d.mts +20 -2
  27. package/dist/places.d.mts.map +1 -1
  28. package/dist/places.mjs +34 -14
  29. package/dist/places.mjs.map +1 -1
  30. package/dist/{poll-timeout-Dg_QFEqi.mjs → poll-timeout-C0nmJzOd.mjs} +6 -2
  31. package/dist/poll-timeout-C0nmJzOd.mjs.map +1 -0
  32. package/dist/{polling-helpers-CS00apdU.mjs → polling-helpers-BcycDhDm.mjs} +225 -78
  33. package/dist/polling-helpers-BcycDhDm.mjs.map +1 -0
  34. package/dist/{polling-BMrYajok.d.mts → polling-rj0dGREg.d.mts} +73 -14
  35. package/dist/polling-rj0dGREg.d.mts.map +1 -0
  36. package/dist/{price-information-DIrvwCmd.mjs → price-information-DK83Wul1.mjs} +2 -2
  37. package/dist/{price-information-DIrvwCmd.mjs.map → price-information-DK83Wul1.mjs.map} +1 -1
  38. package/dist/{rate-limit-D1q2Js-z.mjs → rate-limit-Co9i28qi.mjs} +30 -2
  39. package/dist/rate-limit-Co9i28qi.mjs.map +1 -0
  40. package/dist/{resource-client-D6Efj9fU.mjs → resource-client-C_D--PYX.mjs} +328 -243
  41. package/dist/resource-client-C_D--PYX.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-B5Fojgn0.mjs → signatures-JF-7Psce.mjs} +1 -1
  47. package/dist/{signatures-B5Fojgn0.mjs.map → signatures-JF-7Psce.mjs.map} +1 -1
  48. package/dist/storage.d.mts +30 -27
  49. package/dist/storage.d.mts.map +1 -1
  50. package/dist/storage.mjs +46 -31
  51. package/dist/storage.mjs.map +1 -1
  52. package/dist/testing.d.mts +24 -9
  53. package/dist/testing.d.mts.map +1 -1
  54. package/dist/testing.mjs +2 -2
  55. package/dist/testing.mjs.map +1 -1
  56. package/dist/{to-blob-B27VhoRp.mjs → to-blob-RPqMBuft.mjs} +1 -1
  57. package/dist/{to-blob-B27VhoRp.mjs.map → to-blob-RPqMBuft.mjs.map} +1 -1
  58. package/dist/{types-CwtZT1ek.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 -5
  61. package/dist/universes.d.mts.map +1 -1
  62. package/dist/universes.mjs +31 -15
  63. package/dist/universes.mjs.map +1 -1
  64. package/dist/{validation-DkL5KQqz.mjs → validation-9oU6qNNQ.mjs} +4 -2
  65. package/dist/validation-9oU6qNNQ.mjs.map +1 -0
  66. package/dist/{wire-CeL6W_V7.d.mts → wire-D3K-a-UP.d.mts} +3 -1
  67. package/dist/wire-D3K-a-UP.d.mts.map +1 -0
  68. package/package.json +7 -6
  69. package/dist/data.generated-Cb6g6asv.d.mts.map +0 -1
  70. package/dist/permission-error-DOVtNq3A.mjs +0 -46
  71. package/dist/permission-error-DOVtNq3A.mjs.map +0 -1
  72. package/dist/poll-timeout-Dg_QFEqi.mjs.map +0 -1
  73. package/dist/polling-BMrYajok.d.mts.map +0 -1
  74. package/dist/polling-helpers-CS00apdU.mjs.map +0 -1
  75. package/dist/rate-limit-BYuizHoD.d.mts +0 -92
  76. package/dist/rate-limit-BYuizHoD.d.mts.map +0 -1
  77. package/dist/rate-limit-D1q2Js-z.mjs.map +0 -1
  78. package/dist/resource-client-D6Efj9fU.mjs.map +0 -1
  79. package/dist/types-CwtZT1ek.d.mts.map +0 -1
  80. package/dist/validation-DkL5KQqz.mjs.map +0 -1
  81. package/dist/wire-CeL6W_V7.d.mts.map +0 -1
@@ -1,5 +1,5 @@
1
- import { n as NetworkError, r as ApiError, t as RateLimitError } from "./rate-limit-D1q2Js-z.mjs";
2
- import { t as PermissionError } from "./permission-error-DOVtNq3A.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";
3
3
  import { setTimeout } from "node:timers/promises";
4
4
  //#region src/internal/utils/is-date-time-string.ts
5
5
  /**
@@ -14,7 +14,8 @@ import { setTimeout } from "node:timers/promises";
14
14
  */
15
15
  function isDateTimeString(value) {
16
16
  if (typeof value !== "string") return false;
17
- return !Number.isNaN(new Date(value).getTime());
17
+ const parsed = new Date(value);
18
+ return !Number.isNaN(parsed.getTime());
18
19
  }
19
20
  //#endregion
20
21
  //#region src/internal/utils/is-record.ts
@@ -31,224 +32,138 @@ function isRecord(value) {
31
32
  return Object.prototype.toString.call(value) === "[object Object]";
32
33
  }
33
34
  //#endregion
34
- //#region src/internal/http/retry.ts
35
+ //#region src/internal/http/budget-tracker.ts
36
+ const MS_PER_SECOND = 1e3;
35
37
  /**
36
- * Default retry status codes for idempotent operations (read, list, update,
37
- * delete). Safe to retry on both rate limits and transient server errors.
38
+ * Tracks the live rate-limit budget for a single scope. Primed by `observe`
39
+ * from response headers and drawn down by `reserve` as requests leave, so
40
+ * `waitMs` can pace requests across the window.
41
+ *
42
+ * Pacing has two regimes. While budget remains, requests are spread evenly over
43
+ * the time left in the window (`timeLeft / remaining`), so a burst does not
44
+ * spend the whole window's budget up front and then stall. Once the budget is
45
+ * spent, requests hold until the window resets. Budget and reset time move
46
+ * together as one window, so the tracker is either unprimed or fully primed,
47
+ * never half-known.
38
48
  */
39
- const IDEMPOTENT_METHOD_DEFAULTS = Object.freeze({ retryableStatuses: Object.freeze([
40
- 429,
41
- 500,
42
- 502,
43
- 503,
44
- 504
45
- ]) });
46
- /**
47
- * Default retry status codes for create operations. Retries rate limits only,
48
- * to prevent duplicate resources on 5xx (Roblox Open Cloud has no
49
- * idempotency-key support).
50
- */
51
- const CREATE_METHOD_DEFAULTS = Object.freeze({ retryableStatuses: Object.freeze([429]) });
52
- /**
53
- * Default exponential backoff: 1s → 2s → 4s → 8s → 16s → 30s (capped).
54
- *
55
- * @example
56
- *
57
- * ```ts
58
- * import { defaultRetryDelay } from "./retry";
59
- *
60
- * expect(defaultRetryDelay(0)).toBe(1000);
61
- * expect(defaultRetryDelay(4)).toBe(16_000);
62
- * expect(defaultRetryDelay(10)).toBe(30_000);
63
- * ```
64
- *
65
- * @param attempt - Zero-indexed retry attempt number.
66
- * @returns Wait duration in milliseconds.
67
- */
68
- function defaultRetryDelay(attempt) {
69
- return Math.min(1e3 * 2 ** attempt, 3e4);
70
- }
71
- /**
72
- * Computes how long to wait before the next retry. Prefers the server's
73
- * suggested delay when the error is a {@link RateLimitError} with a positive
74
- * `retryAfterSeconds`; otherwise falls through to `retryDelay(attempt)`.
75
- *
76
- * @example
77
- *
78
- * ```ts
79
- * import { RateLimitError } from "../../errors/rate-limit.ts";
80
- * import { computeRetryWaitMs, defaultRetryDelay } from "./retry";
81
- *
82
- * const error = new RateLimitError("slow down", { retryAfterSeconds: 3 });
83
- *
84
- * expect(computeRetryWaitMs(error, { attempt: 0, retryDelay: defaultRetryDelay })).toBe(
85
- * 3000,
86
- * );
87
- * ```
88
- *
89
- * @example
90
- *
91
- * ```ts
92
- * import { ApiError } from "../../errors/api-error.ts";
93
- * import { computeRetryWaitMs, defaultRetryDelay } from "./retry";
94
- *
95
- * const error = new ApiError("server error", { statusCode: 503 });
96
- *
97
- * expect(computeRetryWaitMs(error, { attempt: 2, retryDelay: defaultRetryDelay })).toBe(
98
- * 4000,
99
- * );
100
- * ```
101
- *
102
- * @param error - The error returned by the failing request.
103
- * @param options - Retry attempt index and fallback delay function.
104
- * @returns Wait duration in milliseconds before the next attempt.
105
- */
106
- function computeRetryWaitMs(error, options) {
107
- if (error instanceof RateLimitError && error.retryAfterSeconds > 0) return error.retryAfterSeconds * 1e3;
108
- return options.retryDelay(options.attempt);
109
- }
110
- /**
111
- * Decides whether a failed request is eligible for retry under the given
112
- * `retryableStatuses`. Only {@link RateLimitError} (checked against 429) and
113
- * {@link ApiError} (checked against its `statusCode`) are retryable — network
114
- * errors and other failures always return `false`.
115
- *
116
- * @example
117
- *
118
- * ```ts
119
- * import { RateLimitError } from "../../errors/rate-limit.ts";
120
- * import { shouldRetry } from "./retry";
121
- *
122
- * const error = new RateLimitError("", { retryAfterSeconds: 1 });
123
- *
124
- * expect(shouldRetry(error, { retryableStatuses: [429] })).toBe(true);
125
- * ```
126
- *
127
- * @example
128
- *
129
- * ```ts
130
- * import { ApiError } from "../../errors/api-error.ts";
131
- * import { shouldRetry } from "./retry";
132
- *
133
- * const error = new ApiError("", { statusCode: 503 });
134
- *
135
- * expect(shouldRetry(error, { retryableStatuses: [429, 500, 502, 503, 504] })).toBe(
136
- * true,
137
- * );
138
- * ```
139
- *
140
- * @example
141
- *
142
- * ```ts
143
- * import { NetworkError } from "../../errors/network-error.ts";
144
- * import { shouldRetry } from "./retry";
145
- *
146
- * const error = new NetworkError("offline");
147
- *
148
- * expect(shouldRetry(error, { retryableStatuses: [429] })).toBe(false);
149
- * ```
150
- *
151
- * @param error - The error returned by the failing request.
152
- * @param config - Object carrying the retry-eligible status list.
153
- * @returns `true` if the error should be retried, `false` otherwise.
154
- */
155
- function shouldRetry(error, config) {
156
- if (error instanceof RateLimitError) return config.retryableStatuses.includes(429);
157
- if (error instanceof ApiError) return config.retryableStatuses.includes(error.statusCode);
158
- return false;
159
- }
160
- /**
161
- * Resolves the effective config for a single request by shallow-merging the
162
- * client config, method defaults, and per-request options. Precedence depends
163
- * on `methodKind`:
164
- *
165
- * - `"create"`: method defaults override client config, so client-level
166
- * settings cannot silently relax create-method safety. Only explicit
167
- * per-request `requestOptions` can.
168
- * - `"idempotent"`: client config overrides method defaults, so consumers
169
- * can loosen or tighten retry policy globally. `requestOptions` still wins
170
- * when provided.
171
- *
172
- * Array-valued fields like `retryableStatuses` are *replaced*, not extended.
173
- *
174
- * @template T - Concrete `RetryResolvable` subtype being merged.
175
- *
176
- * @example
177
- *
178
- * ```ts
179
- * import {
180
- * CREATE_METHOD_DEFAULTS,
181
- * defaultRetryDelay,
182
- * mergeConfig,
183
- * type RetryResolvable,
184
- * } from "./retry";
185
- *
186
- * const clientConfig: RetryResolvable = {
187
- * apiKey: "k",
188
- * baseUrl: "https://apis.roblox.com",
189
- * maxRetries: 3,
190
- * retryableStatuses: [429, 500],
191
- * retryDelay: defaultRetryDelay,
192
- * timeout: 30_000,
193
- * };
194
- *
195
- * const merged = mergeConfig(clientConfig, {
196
- * methodDefaults: CREATE_METHOD_DEFAULTS,
197
- * methodKind: "create",
198
- * });
199
- *
200
- * expect(merged.retryableStatuses).toStrictEqual([429]);
201
- * ```
202
- *
203
- * @example
204
- *
205
- * ```ts
206
- * import {
207
- * defaultRetryDelay,
208
- * IDEMPOTENT_METHOD_DEFAULTS,
209
- * mergeConfig,
210
- * type RetryResolvable,
211
- * } from "./retry";
212
- *
213
- * const clientConfig: RetryResolvable = {
214
- * apiKey: "k",
215
- * baseUrl: "https://apis.roblox.com",
216
- * maxRetries: 3,
217
- * retryableStatuses: [429],
218
- * retryDelay: defaultRetryDelay,
219
- * timeout: 30_000,
220
- * };
221
- *
222
- * const merged = mergeConfig(clientConfig, {
223
- * methodDefaults: IDEMPOTENT_METHOD_DEFAULTS,
224
- * methodKind: "idempotent",
225
- * requestOptions: { timeout: 10_000 },
226
- * });
227
- *
228
- * expect(merged.retryableStatuses).toStrictEqual([429]);
229
- * expect(merged.timeout).toBe(10_000);
230
- * ```
231
- *
232
- * @param clientConfig - Config frozen at client construction.
233
- * @param options - Method defaults, method kind, and optional per-request overrides.
234
- * @returns A new merged config object. Inputs are not mutated.
235
- */
236
- function mergeConfig(clientConfig, options) {
237
- const { methodDefaults, methodKind, requestOptions } = options;
238
- switch (methodKind) {
239
- case "create": return {
240
- ...clientConfig,
241
- ...methodDefaults,
242
- ...requestOptions
49
+ var BudgetTracker = class {
50
+ /** Time (ms) the most recent request was allowed out, for spacing. */
51
+ #lastAllowedAt = void 0;
52
+ #window = void 0;
53
+ /**
54
+ * Folds a fresh server reading in, replacing any prior window. The latest
55
+ * reading wins: observe time is monotonic, so the most recently resolved
56
+ * response is the best current estimate. The spacing reference is left
57
+ * untouched so a window refresh does not reset pacing mid-stream.
58
+ *
59
+ * @param sample - Parsed `remaining`/`resetSeconds` from a response.
60
+ * @param now - The current time in ms.
61
+ */
62
+ observe(sample, now) {
63
+ this.#window = {
64
+ predictedRemaining: sample.remaining,
65
+ resetAt: now + sample.resetSeconds * MS_PER_SECOND
243
66
  };
244
- case "idempotent": return {
245
- ...methodDefaults,
246
- ...clientConfig,
247
- ...requestOptions
67
+ }
68
+ /**
69
+ * Accounts for one request leaving at `now`: records the spacing reference
70
+ * and decrements the prediction. A no-op on the prediction while unprimed.
71
+ *
72
+ * @param now - The time the request was allowed out, in ms.
73
+ */
74
+ reserve(now) {
75
+ this.#lastAllowedAt = now;
76
+ if (this.#window !== void 0) this.#window = {
77
+ ...this.#window,
78
+ predictedRemaining: this.#window.predictedRemaining - 1
248
79
  };
249
- default: throw new Error(`Unexpected methodKind: ${String(methodKind)}`);
250
80
  }
251
- }
81
+ /**
82
+ * Milliseconds to wait before the next request is allowed.
83
+ *
84
+ * @param now - The current time in ms.
85
+ * @returns `0` when a request may go now (unprimed, or the first paced send);
86
+ * the time until reset when the budget is spent; otherwise the time until
87
+ * this request's evenly-spaced slot.
88
+ */
89
+ waitMs(now) {
90
+ if (this.#window === void 0) return 0;
91
+ const { predictedRemaining, resetAt } = this.#window;
92
+ if (predictedRemaining <= 0) return Math.max(0, resetAt - now);
93
+ if (this.#lastAllowedAt === void 0) return 0;
94
+ const interval = (resetAt - now) / predictedRemaining;
95
+ return Math.max(0, this.#lastAllowedAt + interval - now);
96
+ }
97
+ };
98
+ //#endregion
99
+ //#region src/internal/http/budget-gate.ts
100
+ /**
101
+ * Header-primed rate-limit gate shared across a client. Holds one
102
+ * {@link BudgetTracker} per API key, since the tightest Roblox window is the
103
+ * per-key one shared across every operation. Before each request the caller
104
+ * gates on the request's key (sleeping if its budget is spent), and after each
105
+ * response folds the parsed sample back in, so a sibling operation on the same
106
+ * key can head off a 429 the static per-operation token bucket cannot foresee.
107
+ * A per-operation tracker is deliberately not kept: every operation reports the
108
+ * same most-constrained `remaining`, so a per-key tracker (drawn down by all
109
+ * operations) is always the binding constraint.
110
+ *
111
+ * Gating is serialized per scope through a promise chain so concurrent
112
+ * requests on one key cannot read the same budget and reserve the same slot;
113
+ * each waits for the prior gate's reserve before computing its own.
114
+ */
115
+ var BudgetGate = class {
116
+ #chains = /* @__PURE__ */ new Map();
117
+ #sleep;
118
+ #trackers = /* @__PURE__ */ new Map();
119
+ /**
120
+ * Creates a gate bound to an injectable sleep.
121
+ *
122
+ * @param sleep - Injectable sleep (tests pass a fake clock).
123
+ */
124
+ constructor(sleep) {
125
+ this.#sleep = sleep;
126
+ }
127
+ /**
128
+ * Holds until the scope's budget permits a send, then reserves one slot.
129
+ * Runs after the prior gate on the same scope settles, whether it resolved
130
+ * or rejected, so one failed attempt cannot poison later gates on the key.
131
+ *
132
+ * @param scope - The scope key to gate on (the effective API key).
133
+ */
134
+ async gate(scope) {
135
+ const previous = this.#chains.get(scope) ?? Promise.resolve();
136
+ const runGate = async () => this.#gateOnce(scope);
137
+ const mine = previous.then(runGate, runGate);
138
+ this.#chains.set(scope, mine);
139
+ await mine;
140
+ }
141
+ /**
142
+ * Folds a response's parsed budget back onto the scope. A `undefined`
143
+ * sample (headers absent or non-numeric) is ignored, leaving the scope on
144
+ * static pacing.
145
+ *
146
+ * @param scope - The same scope key passed to {@link gate}.
147
+ * @param sample - Parsed sample, or `undefined` when none was reported.
148
+ */
149
+ observe(scope, sample) {
150
+ if (sample === void 0) return;
151
+ this.#tracker(scope).observe(sample, Date.now());
152
+ }
153
+ async #gateOnce(scope) {
154
+ const tracker = this.#tracker(scope);
155
+ const waitMs = tracker.waitMs(Date.now());
156
+ if (waitMs > 0) await this.#sleep(waitMs);
157
+ tracker.reserve(Date.now());
158
+ }
159
+ #tracker(scope) {
160
+ const existing = this.#trackers.get(scope);
161
+ if (existing !== void 0) return existing;
162
+ const tracker = new BudgetTracker();
163
+ this.#trackers.set(scope, tracker);
164
+ return tracker;
165
+ }
166
+ };
252
167
  //#endregion
253
168
  //#region src/internal/http/execute.ts
254
169
  /**
@@ -284,6 +199,65 @@ async function executeWithRetry(request, options) {
284
199
  return result;
285
200
  }
286
201
  //#endregion
202
+ //#region src/internal/http/rate-limit-sample.ts
203
+ /**
204
+ * Reduces a comma-separated rate-limit header value (e.g. `"0, 70000"`) to a
205
+ * single non-negative integer via `combine`. Tokens are trimmed; blank and
206
+ * non-finite tokens (`""`, `"Infinity"`, `"abc"`) are dropped so a stray value
207
+ * cannot corrupt the result. Returns `undefined` when the header is absent or
208
+ * has no finite tokens.
209
+ *
210
+ * @param headerValue - The raw header value, or `undefined` if missing.
211
+ * @param combine - Pairwise reducer, `Math.min` for remaining, `Math.max` for reset.
212
+ * @returns The reduced, floored, clamped value, or `undefined`.
213
+ */
214
+ function reduceRateLimitTokens(headerValue, combine) {
215
+ if (headerValue === void 0) return;
216
+ const tokens = headerValue.split(",").map((part) => part.trim()).filter((part) => part !== "").map((part) => Number(part)).filter((value) => Number.isFinite(value));
217
+ if (tokens.length === 0) return;
218
+ return Math.max(0, Math.floor(tokens.reduce(combine)));
219
+ }
220
+ /**
221
+ * Parses the `x-ratelimit-remaining` and `x-ratelimit-reset` response headers
222
+ * into a {@link RateLimitSample}. Each header may carry a comma-separated list
223
+ * of per-window values; `remaining` takes the smallest (most constrained) and
224
+ * `resetSeconds` takes the largest (longest wait), symmetric to how a 429's
225
+ * retry delay is reduced. Returns `undefined` when either header is missing or
226
+ * has no finite numeric tokens, so a caller can fall back to static pacing.
227
+ *
228
+ * @param headers - Response headers with lowercased keys.
229
+ * @returns The parsed sample, or `undefined` when the budget cannot be read.
230
+ */
231
+ function parseRateLimitHeaders(headers) {
232
+ const remaining = reduceRateLimitTokens(headers["x-ratelimit-remaining"], (a, b) => Math.min(a, b));
233
+ const resetSeconds = reduceRateLimitTokens(headers["x-ratelimit-reset"], (a, b) => Math.max(a, b));
234
+ if (remaining === void 0 || resetSeconds === void 0) return;
235
+ return {
236
+ remaining,
237
+ resetSeconds
238
+ };
239
+ }
240
+ //#endregion
241
+ //#region src/internal/http/rate-limit-observation.ts
242
+ /**
243
+ * Extracts a {@link RateLimitSample} from a transport result so the budget gate
244
+ * can be fed from every attempt. A 2xx carries the budget in its headers; a 429
245
+ * carries it on the {@link RateLimitError} (the raw headers are dropped before
246
+ * this point). Any other error, or a response that reported no budget, yields
247
+ * `undefined` and leaves the gate on static pacing.
248
+ *
249
+ * @param result - The classified transport result for one attempt.
250
+ * @returns The parsed sample, or `undefined` when none was reported.
251
+ */
252
+ function rateLimitSampleFromResult(result) {
253
+ if (result.success) return parseRateLimitHeaders(result.data.headers);
254
+ const { err } = result;
255
+ if (err instanceof RateLimitError && err.remaining !== void 0) return {
256
+ remaining: err.remaining,
257
+ resetSeconds: err.retryAfterSeconds
258
+ };
259
+ }
260
+ //#endregion
287
261
  //#region src/internal/http/rate-limit-queue.ts
288
262
  /**
289
263
  * Token-bucket rate limiter for a single `(apiKey, operation)` pair. Every
@@ -371,6 +345,8 @@ async function tryCatch(promise) {
371
345
  }
372
346
  //#endregion
373
347
  //#region src/internal/http/fetch-client.ts
348
+ const MAX_DETAIL_LENGTH = 500;
349
+ const CONTENT_TYPE_HEADER = "content-type";
374
350
  /**
375
351
  * Converts a `Headers` object to a plain record with lowercased keys.
376
352
  *
@@ -414,15 +390,17 @@ function extractErrorMessage(body) {
414
390
  return extractLegacyMessage(body);
415
391
  }
416
392
  /**
417
- * Parses the `x-ratelimit-reset` header value into seconds.
393
+ * Parses the `x-ratelimit-reset` header value into seconds. On a 429 the header
394
+ * is a comma-separated list of per-window reset times (e.g. `"22, 0"`, one entry
395
+ * per rate-limit window); the largest value is the longest-resetting window and
396
+ * the only safe wait that won't retry into a still-exhausted window. A single
397
+ * value is treated as a one-element list.
418
398
  *
419
399
  * @param headerValue - The raw header value, or `undefined` if missing.
420
400
  * @returns The number of seconds to wait, or 0 if missing/invalid.
421
401
  */
422
402
  function parseRetryAfterSeconds(headerValue) {
423
- const parsed = Number(headerValue);
424
- if (Number.isNaN(parsed)) return 0;
425
- return Math.max(0, Math.floor(parsed));
403
+ return reduceRateLimitTokens(headerValue, (a, b) => Math.max(a, b)) ?? 0;
426
404
  }
427
405
  /**
428
406
  * Joins the base URL from config with the relative path from the request.
@@ -449,10 +427,10 @@ function buildFetchOptions(request, config) {
449
427
  };
450
428
  if (request.body instanceof FormData) options.body = request.body;
451
429
  else if (request.body instanceof Uint8Array) {
452
- headers.set("content-type", "application/octet-stream");
430
+ headers.set(CONTENT_TYPE_HEADER, "application/octet-stream");
453
431
  options.body = request.body;
454
432
  } else if (request.body !== void 0) {
455
- headers.set("content-type", "application/json");
433
+ headers.set(CONTENT_TYPE_HEADER, "application/json");
456
434
  options.body = JSON.stringify(request.body);
457
435
  }
458
436
  if (request.headers !== void 0) for (const [name, value] of Object.entries(request.headers)) {
@@ -470,9 +448,14 @@ function buildFetchOptions(request, config) {
470
448
  */
471
449
  function createFetchHttpClient(fetchFunc = globalThis.fetch) {
472
450
  return { async request(httpRequest, config) {
473
- const fetchResult = await tryCatch(fetchFunc(buildUrl(httpRequest, config), buildFetchOptions(httpRequest, config)));
451
+ const url = buildUrl(httpRequest, config);
452
+ const fetchResult = await tryCatch(fetchFunc(url, buildFetchOptions(httpRequest, config)));
474
453
  if (!fetchResult.success) return {
475
- err: new NetworkError("Network request failed", { cause: fetchResult.err }),
454
+ err: new NetworkError("Network request failed", {
455
+ cause: fetchResult.err,
456
+ method: httpRequest.method,
457
+ url
458
+ }),
476
459
  success: false
477
460
  };
478
461
  return classifyResponse(fetchResult.data);
@@ -519,25 +502,56 @@ function createApiError(status, body) {
519
502
  });
520
503
  }
521
504
  function createRateLimitError(response) {
522
- return new RateLimitError("Rate limited", { retryAfterSeconds: parseRetryAfterSeconds(response.headers.get("x-ratelimit-reset") ?? void 0) });
505
+ const headers = headersToRecord(response.headers);
506
+ return new RateLimitError("Rate limited", {
507
+ remaining: reduceRateLimitTokens(headers["x-ratelimit-remaining"], (a, b) => Math.min(a, b)),
508
+ retryAfterSeconds: parseRetryAfterSeconds(headers["x-ratelimit-reset"])
509
+ });
523
510
  }
524
- async function readResponseBody(response) {
511
+ /**
512
+ * Parses response text as JSON, returning the underlying `SyntaxError` on
513
+ * failure rather than throwing. The synchronous sibling of {@link tryCatch}.
514
+ *
515
+ * @param text - The raw response body text.
516
+ * @returns A Result wrapping the parsed value, or the parse error.
517
+ */
518
+ function parseJson(text) {
525
519
  try {
526
- const text = await response.text();
527
520
  return {
528
- data: text === "" ? void 0 : JSON.parse(text),
521
+ data: JSON.parse(text),
529
522
  success: true
530
523
  };
531
- } catch {
524
+ } catch (err) {
532
525
  return {
533
- err: new ApiError("Failed to parse response body", { statusCode: response.status }),
526
+ err: err instanceof Error ? err : new Error(String(err)),
534
527
  success: false
535
528
  };
536
529
  }
537
530
  }
538
531
  /**
532
+ * Builds the error for a 2xx response whose body could not be parsed as JSON,
533
+ * preserving the parse `cause`, the (truncated) raw body, and the declared
534
+ * content-type so the failure can be diagnosed after the fact.
535
+ *
536
+ * @param args - The Response, raw body text, and underlying parse error.
537
+ * @returns An ApiError carrying the diagnostic context.
538
+ */
539
+ function parseFailureError({ cause, response, text }) {
540
+ return new ApiError(`Failed to parse response body (content-type: ${response.headers.get(CONTENT_TYPE_HEADER) ?? "unknown"})`, {
541
+ cause,
542
+ details: text.slice(0, MAX_DETAIL_LENGTH),
543
+ statusCode: response.status
544
+ });
545
+ }
546
+ /**
539
547
  * Classifies a fetch `Response` into a typed `Result`.
540
548
  *
549
+ * The body is read once and parsed best-effort. Error responses (status >= 300)
550
+ * never require valid JSON: an error body that is not valid JSON (for example
551
+ * an HTML gateway page) degrades to a status-based {@link ApiError} carrying
552
+ * the raw text. A parse failure is only fatal on a 2xx, where a parseable body is part
553
+ * of the contract.
554
+ *
541
555
  * @param response - The raw fetch Response to classify.
542
556
  * @returns A Result containing an HttpResponse on success or an OpenCloudError on failure.
543
557
  */
@@ -546,15 +560,29 @@ async function classifyResponse(response) {
546
560
  err: createRateLimitError(response),
547
561
  success: false
548
562
  };
549
- const bodyResult = await readResponseBody(response);
550
- if (!bodyResult.success) return bodyResult;
551
- if (response.status >= 300) return {
552
- err: createApiError(response.status, bodyResult.data),
563
+ const text = await response.text();
564
+ const parsed = text === "" ? {
565
+ data: void 0,
566
+ success: true
567
+ } : parseJson(text);
568
+ if (response.status >= 300) {
569
+ const body = parsed.success ? parsed.data : text.slice(0, MAX_DETAIL_LENGTH);
570
+ return {
571
+ err: createApiError(response.status, body),
572
+ success: false
573
+ };
574
+ }
575
+ if (!parsed.success) return {
576
+ err: parseFailureError({
577
+ cause: parsed.err,
578
+ response,
579
+ text
580
+ }),
553
581
  success: false
554
582
  };
555
583
  return {
556
584
  data: {
557
- body: bodyResult.data,
585
+ body: parsed.data,
558
586
  headers: headersToRecord(response.headers),
559
587
  status: response.status
560
588
  },
@@ -582,6 +610,21 @@ function resolveDependencies(options) {
582
610
  };
583
611
  }
584
612
  //#endregion
613
+ //#region src/internal/http/upload-request.ts
614
+ /**
615
+ * Reports whether a request is an upload: its body is `FormData`
616
+ * (multipart) or `Uint8Array` (raw binary). Upload latency is
617
+ * bandwidth-bound rather than compute-bound, so the SDK applies no default
618
+ * request timeout to these requests; a sensible wall-clock budget depends on
619
+ * payload size and link quality the SDK cannot know.
620
+ *
621
+ * @param request - The built request to classify.
622
+ * @returns `true` when the body is `FormData` or `Uint8Array`.
623
+ */
624
+ function isUploadRequest(request) {
625
+ return request.body instanceof FormData || request.body instanceof Uint8Array;
626
+ }
627
+ //#endregion
585
628
  //#region src/internal/resource-client.ts
586
629
  /**
587
630
  * Wraps an infallible request build as a {@link Result}-returning
@@ -615,6 +658,7 @@ const CLIENT_DEFAULTS = Object.freeze({
615
658
  baseUrl: "https://apis.roblox.com",
616
659
  maxRetries: 3,
617
660
  retryableStatuses: IDEMPOTENT_METHOD_DEFAULTS.retryableStatuses,
661
+ retryableTransportCodes: IDEMPOTENT_METHOD_DEFAULTS.retryableTransportCodes,
618
662
  retryDelay: defaultRetryDelay,
619
663
  timeout: 3e4
620
664
  });
@@ -628,6 +672,7 @@ const CLIENT_DEFAULTS = Object.freeze({
628
672
  * `src/resources/**` modules in this package.
629
673
  */
630
674
  var ResourceClient = class {
675
+ #budgets;
631
676
  #config;
632
677
  #hooks;
633
678
  #httpClient;
@@ -649,6 +694,7 @@ var ResourceClient = class {
649
694
  });
650
695
  this.#httpClient = resolved.httpClient;
651
696
  this.#sleep = resolved.sleep;
697
+ this.#budgets = new BudgetGate(this.#sleep);
652
698
  this.#hooks = hooks ?? {};
653
699
  this.#config = Object.freeze({
654
700
  ...CLIENT_DEFAULTS,
@@ -677,16 +723,16 @@ var ResourceClient = class {
677
723
  });
678
724
  const requestResult = spec.buildRequest(parameters);
679
725
  if (!requestResult.success) return requestResult;
680
- const requestConfig = {
681
- apiKey: merged.apiKey,
682
- baseUrl: merged.baseUrl,
683
- timeout: merged.timeout
684
- };
726
+ const requestConfig = buildRequestConfig({
727
+ merged,
728
+ options,
729
+ request: requestResult.data
730
+ });
685
731
  const httpResult = await this.#getQueue(merged.apiKey, spec.operationLimit).acquire(async () => {
686
732
  return executeWithRetry(requestResult.data, {
687
733
  config: merged,
688
734
  hooks: this.#hooks,
689
- send: async (toSend) => this.#httpClient.request(toSend, requestConfig),
735
+ send: this.#gatedSend(merged.apiKey, requestConfig),
690
736
  sleep: this.#sleep
691
737
  });
692
738
  });
@@ -704,6 +750,24 @@ var ResourceClient = class {
704
750
  get sleep() {
705
751
  return this.#sleep;
706
752
  }
753
+ /**
754
+ * Builds the transport callback for one logical call, wrapping the HTTP
755
+ * client with the budget gate: each attempt waits on the API key's budget
756
+ * before sending, then folds the response's reported budget back in so the
757
+ * next attempt (or a sibling operation on the same key) can head off a 429.
758
+ *
759
+ * @param apiKey - The effective API key to gate on.
760
+ * @param requestConfig - The resolved per-request transport config.
761
+ * @returns A send callback for {@link executeWithRetry}.
762
+ */
763
+ #gatedSend(apiKey, requestConfig) {
764
+ return async (toSend) => {
765
+ await this.#budgets.gate(apiKey);
766
+ const sendResult = await this.#httpClient.request(toSend, requestConfig);
767
+ this.#budgets.observe(apiKey, rateLimitSampleFromResult(sendResult));
768
+ return sendResult;
769
+ };
770
+ }
707
771
  #getQueue(apiKey, limit) {
708
772
  const key = `${apiKey}::${limit.operationKey}`;
709
773
  const existing = this.#queues.get(key);
@@ -713,6 +777,26 @@ var ResourceClient = class {
713
777
  return queue;
714
778
  }
715
779
  };
780
+ /**
781
+ * Resolves the per-request {@link RequestConfig}. Upload requests
782
+ * ({@link isUploadRequest}) carry no default timeout: a multi-megabyte place
783
+ * file over a slow link is bandwidth-bound, so a client-side deadline only
784
+ * fires spuriously. An explicit `options.timeout` still applies to any
785
+ * request; every non-upload request keeps the merged default.
786
+ *
787
+ * @param inputs - The merged config, the built request, and per-request overrides.
788
+ * @returns The config to hand to the transport, with `timeout` omitted when
789
+ * no client-side deadline should apply.
790
+ */
791
+ function buildRequestConfig(inputs) {
792
+ const { merged, options, request } = inputs;
793
+ const shouldOmitDefaultTimeout = options?.timeout === void 0 && isUploadRequest(request);
794
+ return {
795
+ apiKey: merged.apiKey,
796
+ baseUrl: merged.baseUrl,
797
+ ...shouldOmitDefaultTimeout ? {} : { timeout: merged.timeout }
798
+ };
799
+ }
716
800
  function enrichPermissionError(err, spec) {
717
801
  if (spec.requiredScopes === void 0) return err;
718
802
  if (err instanceof PermissionError) return err;
@@ -721,12 +805,13 @@ function enrichPermissionError(err, spec) {
721
805
  return new PermissionError(err.message, {
722
806
  cause: err.cause,
723
807
  code: err.code,
808
+ details: err.details,
724
809
  operationKey: spec.operationLimit.operationKey,
725
810
  requiredScopes: spec.requiredScopes,
726
811
  statusCode: err.statusCode
727
812
  });
728
813
  }
729
814
  //#endregion
730
- export { IDEMPOTENT_METHOD_DEFAULTS as a, isDateTimeString as c, CREATE_METHOD_DEFAULTS as i, okRequest as n, defaultRetryDelay as o, parseEmptyResponse as r, isRecord as s, ResourceClient as t };
815
+ export { isDateTimeString as a, isRecord as i, okRequest as n, parseEmptyResponse as r, ResourceClient as t };
731
816
 
732
- //# sourceMappingURL=resource-client-D6Efj9fU.mjs.map
817
+ //# sourceMappingURL=resource-client-C_D--PYX.mjs.map