@to11ai/sdk 0.0.2 → 0.1.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/src/transport.ts CHANGED
@@ -1,10 +1,32 @@
1
1
  import {
2
+ EnvNotEnabledForProviderError,
3
+ NoApplicableRouteError,
4
+ PromptLabelNotRegisteredError,
5
+ RuleNotFoundError,
6
+ RuleVersionInvalidSnapshotError,
2
7
  To11aiApiError,
3
8
  To11aiAuthError,
4
9
  To11aiNetworkError,
5
10
  To11aiRateLimitError,
6
11
  To11aiValidationError,
7
12
  } from "./errors";
13
+ import { activeTraceContext } from "./traceparent";
14
+
15
+ const INVALID_SNAPSHOT_ERROR_CODES = new Set([
16
+ "INVALID_VARIANT_PARAMS",
17
+ "INVALID_VARIANT_WEIGHTS",
18
+ "INVALID_RULE_STRATEGY",
19
+ "INVALID_STEP_COUNT",
20
+ "RULE_ENDPOINT_MISMATCH",
21
+ "INVALID_WHEN_CLAUSE",
22
+ "INVALID_STRATEGY_VARIANTS",
23
+ "INVALID_RETRY_POLICY",
24
+ ]);
25
+
26
+ const RULE_NOT_FOUND_DETAILS_ERRORS = new Set([
27
+ "rule_not_found",
28
+ "rule_not_found_in_env",
29
+ ]);
8
30
 
9
31
  export type AuthConfig =
10
32
  | { readonly apiKey: string; readonly getAccessToken?: never }
@@ -15,7 +37,12 @@ export interface TransportConfig {
15
37
  readonly auth: AuthConfig;
16
38
  /** Request timeout in milliseconds. Default: 30_000 */
17
39
  readonly timeoutMs?: number;
18
- /** 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
+ */
19
46
  readonly maxRetries?: number;
20
47
  }
21
48
 
@@ -36,6 +63,22 @@ export interface ApiEnvelope<T = unknown> {
36
63
  const DEFAULT_TIMEOUT_MS = 30_000;
37
64
  const DEFAULT_MAX_RETRIES = 3;
38
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
+
39
82
  /** Exponential backoff with jitter: 100ms * 2^attempt + random(0,100ms) */
40
83
  const backoffMs = (attempt: number): number =>
41
84
  100 * 2 ** attempt + Math.random() * 100;
@@ -76,9 +119,132 @@ function mapHttpError(
76
119
  details,
77
120
  );
78
121
  }
122
+ if (
123
+ (status === 400 || status === 422) &&
124
+ errorCode &&
125
+ INVALID_SNAPSHOT_ERROR_CODES.has(errorCode)
126
+ ) {
127
+ const d = (details && typeof details === "object" ? details : {}) as Record<
128
+ string,
129
+ unknown
130
+ >;
131
+ return new RuleVersionInvalidSnapshotError(
132
+ message,
133
+ errorCode,
134
+ {
135
+ field: typeof d.field === "string" ? d.field : undefined,
136
+ reason: typeof d.reason === "string" ? d.reason : undefined,
137
+ variantName:
138
+ typeof d.variantName === "string" ? d.variantName : undefined,
139
+ },
140
+ details,
141
+ status,
142
+ );
143
+ }
79
144
  if (status === 400) {
80
145
  return new To11aiValidationError(message, errorCode, details);
81
146
  }
147
+ if (status === 404) {
148
+ const d = (details && typeof details === "object" ? details : {}) as Record<
149
+ string,
150
+ unknown
151
+ >;
152
+ const detailsError = typeof d.error === "string" ? d.error : undefined;
153
+ const isRuleNotFound =
154
+ errorCode === "RULE_NOT_FOUND" ||
155
+ errorCode === "RULE_NOT_FOUND_IN_ENV" ||
156
+ (detailsError !== undefined &&
157
+ RULE_NOT_FOUND_DETAILS_ERRORS.has(detailsError));
158
+ if (isRuleNotFound) {
159
+ const resolvedCode =
160
+ errorCode === "RULE_NOT_FOUND" || errorCode === "RULE_NOT_FOUND_IN_ENV"
161
+ ? errorCode
162
+ : detailsError === "rule_not_found_in_env"
163
+ ? "RULE_NOT_FOUND_IN_ENV"
164
+ : "RULE_NOT_FOUND";
165
+ return new RuleNotFoundError(
166
+ message,
167
+ resolvedCode,
168
+ {
169
+ ruleId: typeof d.ruleId === "string" ? d.ruleId : undefined,
170
+ model: typeof d.model === "string" ? d.model : undefined,
171
+ env: typeof d.env === "string" ? d.env : undefined,
172
+ },
173
+ details,
174
+ );
175
+ }
176
+ }
177
+ if (status === 422 && errorCode === "NO_APPLICABLE_ROUTE") {
178
+ const d = (details && typeof details === "object" ? details : {}) as Record<
179
+ string,
180
+ unknown
181
+ >;
182
+ const ruleId =
183
+ typeof d.rule === "string"
184
+ ? d.rule
185
+ : typeof d.ruleId === "string"
186
+ ? d.ruleId
187
+ : "";
188
+ const versionId =
189
+ typeof d.version === "string"
190
+ ? d.version
191
+ : typeof d.versionId === "string"
192
+ ? d.versionId
193
+ : "";
194
+ return new NoApplicableRouteError(
195
+ message,
196
+ {
197
+ ruleId,
198
+ versionId,
199
+ reason: typeof d.reason === "string" ? d.reason : "",
200
+ },
201
+ details,
202
+ );
203
+ }
204
+ if (status === 422 && errorCode === "ENV_NOT_ENABLED_FOR_PROVIDER") {
205
+ const d = (details && typeof details === "object" ? details : {}) as Record<
206
+ string,
207
+ unknown
208
+ >;
209
+ const ruleId =
210
+ typeof d.rule === "string"
211
+ ? d.rule
212
+ : typeof d.ruleId === "string"
213
+ ? d.ruleId
214
+ : "";
215
+ const envSlug =
216
+ typeof d.env === "string"
217
+ ? d.env
218
+ : typeof d.envSlug === "string"
219
+ ? d.envSlug
220
+ : "";
221
+ return new EnvNotEnabledForProviderError(
222
+ message,
223
+ {
224
+ ruleId,
225
+ envSlug,
226
+ providerId: typeof d.providerId === "string" ? d.providerId : "",
227
+ },
228
+ details,
229
+ );
230
+ }
231
+ if (status === 422 && errorCode === "PROMPT_LABEL_NOT_REGISTERED") {
232
+ // TO11-664: strict-mode rejection. Pull projectId / label out of the
233
+ // structured `details` payload the API attaches so call sites can use
234
+ // the typed attributes without re-parsing.
235
+ const d = (details && typeof details === "object" ? details : {}) as Record<
236
+ string,
237
+ unknown
238
+ >;
239
+ const projectId = typeof d.projectId === "string" ? d.projectId : "";
240
+ const label = typeof d.label === "string" ? d.label : "";
241
+ return new PromptLabelNotRegisteredError(
242
+ message,
243
+ projectId,
244
+ label,
245
+ details,
246
+ );
247
+ }
82
248
  return new To11aiApiError(message, status, errorCode, details);
83
249
  }
84
250
 
@@ -108,6 +274,12 @@ export class Transport {
108
274
  headers.authorization = `Bearer ${token}`;
109
275
  }
110
276
 
277
+ const tp = activeTraceContext();
278
+ if (tp) {
279
+ headers.traceparent = tp.traceparent;
280
+ if (tp.tracestate) headers.tracestate = tp.tracestate;
281
+ }
282
+
111
283
  return headers;
112
284
  }
113
285
 
@@ -120,6 +292,7 @@ export class Transport {
120
292
  },
121
293
  ): Promise<ApiEnvelope<T>> {
122
294
  const url = new URL(this.baseUrl + path);
295
+ const isIdempotent = IDEMPOTENT_METHODS.has(method.toUpperCase());
123
296
 
124
297
  if (options?.query) {
125
298
  for (const [key, value] of Object.entries(options.query)) {
@@ -149,8 +322,14 @@ export class Transport {
149
322
 
150
323
  clearTimeout(timeoutId);
151
324
 
152
- // 5xx — retry if we have retries left
153
- 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
+ ) {
154
333
  // Drain the body to free the underlying connection before sleeping
155
334
  await response.body?.cancel().catch(() => undefined);
156
335
  attempt++;
@@ -191,14 +370,21 @@ export class Transport {
191
370
  throw err;
192
371
  }
193
372
 
194
- // 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.
195
377
  const isTimeout =
196
378
  err instanceof DOMException && err.name === "AbortError";
197
379
  // Only retry on genuine network failures (fetch throws TypeError) or timeouts.
198
380
  // Unexpected errors (e.g. getAccessToken rejections) propagate immediately.
199
381
  const isNetworkError = err instanceof TypeError;
200
382
 
201
- if ((isTimeout || isNetworkError) && attempt < this.maxRetries) {
383
+ if (
384
+ (isTimeout || isNetworkError) &&
385
+ isIdempotent &&
386
+ attempt < this.maxRetries
387
+ ) {
202
388
  attempt++;
203
389
  await sleep(backoffMs(attempt));
204
390
  continue;