@to11ai/sdk 0.1.0 → 0.1.2

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.
@@ -10,7 +10,12 @@ export interface TransportConfig {
10
10
  readonly auth: AuthConfig;
11
11
  /** Request timeout in milliseconds. Default: 30_000 */
12
12
  readonly timeoutMs?: number;
13
- /** Max number of retries for network errors / 5xx (not counting the initial attempt). Default: 3 */
13
+ /**
14
+ * Max number of retries for network errors / 5xx on idempotent methods
15
+ * (GET/HEAD/OPTIONS/PUT/DELETE/TRACE per RFC 9110 §9.2.2). POST/PATCH are
16
+ * never auto-retried regardless of this value. Not counting the initial
17
+ * attempt. Default: 3
18
+ */
14
19
  readonly maxRetries?: number;
15
20
  }
16
21
  /** Standard API envelope returned by the to11ai server. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@to11ai/sdk",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "license": "MPL-2.0",
5
5
  "type": "module",
6
6
  "main": "./dist/client.cjs",
package/src/transport.ts CHANGED
@@ -37,7 +37,12 @@ export interface TransportConfig {
37
37
  readonly auth: AuthConfig;
38
38
  /** Request timeout in milliseconds. Default: 30_000 */
39
39
  readonly timeoutMs?: number;
40
- /** Max number of retries for network errors / 5xx (not counting the initial attempt). Default: 3 */
40
+ /**
41
+ * Max number of retries for network errors / 5xx on idempotent methods
42
+ * (GET/HEAD/OPTIONS/PUT/DELETE/TRACE per RFC 9110 §9.2.2). POST/PATCH are
43
+ * never auto-retried regardless of this value. Not counting the initial
44
+ * attempt. Default: 3
45
+ */
41
46
  readonly maxRetries?: number;
42
47
  }
43
48
 
@@ -58,6 +63,22 @@ export interface ApiEnvelope<T = unknown> {
58
63
  const DEFAULT_TIMEOUT_MS = 30_000;
59
64
  const DEFAULT_MAX_RETRIES = 3;
60
65
 
66
+ /**
67
+ * HTTP methods classified as idempotent by RFC 9110 §9.2.2. The SDK only
68
+ * auto-retries 5xx, network failures, and timeouts on these methods because
69
+ * a write that timed out or returned 5xx may have already committed on the
70
+ * server — replaying a non-idempotent method (POST/PATCH) can create
71
+ * duplicates or double-apply state.
72
+ */
73
+ const IDEMPOTENT_METHODS = new Set([
74
+ "GET",
75
+ "HEAD",
76
+ "OPTIONS",
77
+ "PUT",
78
+ "DELETE",
79
+ "TRACE",
80
+ ]);
81
+
61
82
  /** Exponential backoff with jitter: 100ms * 2^attempt + random(0,100ms) */
62
83
  const backoffMs = (attempt: number): number =>
63
84
  100 * 2 ** attempt + Math.random() * 100;
@@ -271,6 +292,7 @@ export class Transport {
271
292
  },
272
293
  ): Promise<ApiEnvelope<T>> {
273
294
  const url = new URL(this.baseUrl + path);
295
+ const isIdempotent = IDEMPOTENT_METHODS.has(method.toUpperCase());
274
296
 
275
297
  if (options?.query) {
276
298
  for (const [key, value] of Object.entries(options.query)) {
@@ -300,8 +322,14 @@ export class Transport {
300
322
 
301
323
  clearTimeout(timeoutId);
302
324
 
303
- // 5xx — retry if we have retries left
304
- if (response.status >= 500 && attempt < this.maxRetries) {
325
+ // 5xx — retry only if the method is idempotent (RFC 9110 §9.2.2).
326
+ // POST/PATCH may have already committed server-side, so replaying
327
+ // them can create duplicates.
328
+ if (
329
+ response.status >= 500 &&
330
+ isIdempotent &&
331
+ attempt < this.maxRetries
332
+ ) {
305
333
  // Drain the body to free the underlying connection before sleeping
306
334
  await response.body?.cancel().catch(() => undefined);
307
335
  attempt++;
@@ -342,14 +370,21 @@ export class Transport {
342
370
  throw err;
343
371
  }
344
372
 
345
- // Timeout / network errors — retry if retries remain
373
+ // Timeout / network errors — retry only if the method is idempotent
374
+ // (RFC 9110 §9.2.2). A POST/PATCH whose request body was fully sent
375
+ // before a connection drop or timeout may have committed server-side,
376
+ // so a replay can duplicate the write.
346
377
  const isTimeout =
347
378
  err instanceof DOMException && err.name === "AbortError";
348
379
  // Only retry on genuine network failures (fetch throws TypeError) or timeouts.
349
380
  // Unexpected errors (e.g. getAccessToken rejections) propagate immediately.
350
381
  const isNetworkError = err instanceof TypeError;
351
382
 
352
- if ((isTimeout || isNetworkError) && attempt < this.maxRetries) {
383
+ if (
384
+ (isTimeout || isNetworkError) &&
385
+ isIdempotent &&
386
+ attempt < this.maxRetries
387
+ ) {
353
388
  attempt++;
354
389
  await sleep(backoffMs(attempt));
355
390
  continue;