@rendobar/sdk 3.0.0 → 3.0.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.
package/dist/index.cjs CHANGED
@@ -307,22 +307,53 @@ function toBlob(file) {
307
307
  return file instanceof Blob ? file : new Blob([file]);
308
308
  }
309
309
  /**
310
+ * Per-attempt upload time budget. fetch() exposes no upload progress, so a
311
+ * stalled socket and a slow link are indistinguishable from the outside; a
312
+ * hard per-attempt budget is the only portable stall cap. The ladder escalates
313
+ * (healthy 500 KiB/s assumption first, then 100, then 50) so a genuinely slow
314
+ * connection that times out on an early attempt still completes on a later,
315
+ * more patient one — while a hung socket gets cut and retried on a fresh
316
+ * connection instead of waiting on the OS TCP timeout.
317
+ *
318
+ * @internal exported for tests
319
+ */
320
+ function attemptTimeoutMs(sizeBytes, attempt) {
321
+ const GRACE_MS = [
322
+ 1e4,
323
+ 3e4,
324
+ 6e4
325
+ ];
326
+ const RATE_BPS = [
327
+ 500 * 1024,
328
+ 100 * 1024,
329
+ 50 * 1024
330
+ ];
331
+ const i = Math.min(attempt, GRACE_MS.length - 1);
332
+ return GRACE_MS[i] + Math.ceil(sizeBytes / RATE_BPS[i] * 1e3);
333
+ }
334
+ /**
310
335
  * PUT a body to a presigned R2 url with bounded retry.
311
336
  *
312
337
  * Returns the ETag string when the bucket exposes it, or `null` when the
313
- * response header is absent (e.g. bucket CORS not exposing ETag). Only
314
- * network/HTTP failures trigger retries; a missing ETag is not retried because
315
- * it is a deterministic misconfiguration, not a transient error.
338
+ * response header is absent (e.g. bucket CORS not exposing ETag). Network/HTTP
339
+ * failures and per-attempt timeouts trigger retries; a caller abort and a
340
+ * missing ETag do not (the latter is a deterministic misconfiguration, not a
341
+ * transient error).
342
+ *
343
+ * @internal exported for tests
316
344
  */
317
- async function putWithRetry(url, body, signal, attempts = 3) {
345
+ async function putWithRetry(url, body, signal, sizeBytes, attempts = 3, attemptTimeouts) {
318
346
  let lastErr;
319
347
  for (let i = 0; i < attempts; i++) {
320
348
  if (signal?.aborted) throw new Error("Upload aborted");
349
+ const timeoutMs = attemptTimeouts?.[i] ?? attemptTimeoutMs(sizeBytes, i);
350
+ const timeout = AbortSignal.timeout(timeoutMs);
351
+ const attemptSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
321
352
  try {
322
353
  const res = await fetch(url, {
323
354
  method: "PUT",
324
355
  body,
325
- signal
356
+ signal: attemptSignal
326
357
  });
327
358
  if (!res.ok) throw new Error(`Upload PUT failed: ${res.status}`);
328
359
  return res.headers.get("etag");
@@ -378,7 +409,7 @@ function createUploadsResource(request) {
378
409
  return init.data;
379
410
  }
380
411
  if (init.status === "presigned") {
381
- await putWithRetry(init.upload.url, blob, options.signal);
412
+ await putWithRetry(init.upload.url, blob, options.signal, size);
382
413
  options.onProgress?.({
383
414
  loaded: size,
384
415
  total: size
@@ -399,7 +430,7 @@ function createUploadsResource(request) {
399
430
  const chunk = blob.slice(start, Math.min(start + partSize, size));
400
431
  let etag;
401
432
  try {
402
- etag = await putWithRetry(p.url, chunk, combinedSignal);
433
+ etag = await putWithRetry(p.url, chunk, combinedSignal, chunk.size);
403
434
  } catch (e) {
404
435
  internal.abort();
405
436
  partError = e instanceof Error ? e : new Error(String(e));
package/dist/index.mjs CHANGED
@@ -306,22 +306,53 @@ function toBlob(file) {
306
306
  return file instanceof Blob ? file : new Blob([file]);
307
307
  }
308
308
  /**
309
+ * Per-attempt upload time budget. fetch() exposes no upload progress, so a
310
+ * stalled socket and a slow link are indistinguishable from the outside; a
311
+ * hard per-attempt budget is the only portable stall cap. The ladder escalates
312
+ * (healthy 500 KiB/s assumption first, then 100, then 50) so a genuinely slow
313
+ * connection that times out on an early attempt still completes on a later,
314
+ * more patient one — while a hung socket gets cut and retried on a fresh
315
+ * connection instead of waiting on the OS TCP timeout.
316
+ *
317
+ * @internal exported for tests
318
+ */
319
+ function attemptTimeoutMs(sizeBytes, attempt) {
320
+ const GRACE_MS = [
321
+ 1e4,
322
+ 3e4,
323
+ 6e4
324
+ ];
325
+ const RATE_BPS = [
326
+ 500 * 1024,
327
+ 100 * 1024,
328
+ 50 * 1024
329
+ ];
330
+ const i = Math.min(attempt, GRACE_MS.length - 1);
331
+ return GRACE_MS[i] + Math.ceil(sizeBytes / RATE_BPS[i] * 1e3);
332
+ }
333
+ /**
309
334
  * PUT a body to a presigned R2 url with bounded retry.
310
335
  *
311
336
  * Returns the ETag string when the bucket exposes it, or `null` when the
312
- * response header is absent (e.g. bucket CORS not exposing ETag). Only
313
- * network/HTTP failures trigger retries; a missing ETag is not retried because
314
- * it is a deterministic misconfiguration, not a transient error.
337
+ * response header is absent (e.g. bucket CORS not exposing ETag). Network/HTTP
338
+ * failures and per-attempt timeouts trigger retries; a caller abort and a
339
+ * missing ETag do not (the latter is a deterministic misconfiguration, not a
340
+ * transient error).
341
+ *
342
+ * @internal exported for tests
315
343
  */
316
- async function putWithRetry(url, body, signal, attempts = 3) {
344
+ async function putWithRetry(url, body, signal, sizeBytes, attempts = 3, attemptTimeouts) {
317
345
  let lastErr;
318
346
  for (let i = 0; i < attempts; i++) {
319
347
  if (signal?.aborted) throw new Error("Upload aborted");
348
+ const timeoutMs = attemptTimeouts?.[i] ?? attemptTimeoutMs(sizeBytes, i);
349
+ const timeout = AbortSignal.timeout(timeoutMs);
350
+ const attemptSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
320
351
  try {
321
352
  const res = await fetch(url, {
322
353
  method: "PUT",
323
354
  body,
324
- signal
355
+ signal: attemptSignal
325
356
  });
326
357
  if (!res.ok) throw new Error(`Upload PUT failed: ${res.status}`);
327
358
  return res.headers.get("etag");
@@ -377,7 +408,7 @@ function createUploadsResource(request) {
377
408
  return init.data;
378
409
  }
379
410
  if (init.status === "presigned") {
380
- await putWithRetry(init.upload.url, blob, options.signal);
411
+ await putWithRetry(init.upload.url, blob, options.signal, size);
381
412
  options.onProgress?.({
382
413
  loaded: size,
383
414
  total: size
@@ -398,7 +429,7 @@ function createUploadsResource(request) {
398
429
  const chunk = blob.slice(start, Math.min(start + partSize, size));
399
430
  let etag;
400
431
  try {
401
- etag = await putWithRetry(p.url, chunk, combinedSignal);
432
+ etag = await putWithRetry(p.url, chunk, combinedSignal, chunk.size);
402
433
  } catch (e) {
403
434
  internal.abort();
404
435
  partError = e instanceof Error ? e : new Error(String(e));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rendobar/sdk",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "type": "module",
5
5
  "description": "TypeScript client for the Rendobar media processing API",
6
6
  "main": "./dist/index.cjs",