extractia-sdk 1.3.0 → 1.5.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.
package/src/apiClient.js CHANGED
@@ -1,46 +1,215 @@
1
1
  import axios from "axios";
2
- import { mapAxiosError } from "./errors.js";
2
+ import {
3
+ mapAxiosError,
4
+ ExtractiaError,
5
+ NetworkError,
6
+ TimeoutError,
7
+ } from "./errors.js";
3
8
 
4
- let token = null;
9
+ // ─── Internal state ───────────────────────────────────────────────────────────
5
10
 
6
- const DEFAULT_BASE_URL = "https://api.extractia.info/api/public";
11
+ let _token = null;
7
12
 
8
- const api = axios.create({
9
- baseURL: DEFAULT_BASE_URL,
10
- timeout: 60000, // 60s — AI processing can take 10–30s
13
+ /** @type {SDKConfig} */
14
+ const _config = {
15
+ baseURL: "https://api.extractia.info/api/public",
16
+ timeout: 60_000,
17
+ retries: 1,
18
+ retryDelay: 1_000,
19
+ debug: false,
20
+ defaultHeaders: {},
21
+ onBeforeRequest: null,
22
+ onAfterResponse: null,
23
+ onError: null,
24
+ };
25
+
26
+ // ─── Axios instance ───────────────────────────────────────────────────────────
27
+
28
+ export const _api = axios.create({
29
+ baseURL: _config.baseURL,
30
+ timeout: _config.timeout,
11
31
  });
12
32
 
13
- api.interceptors.request.use((config) => {
14
- if (!token) {
15
- throw new Error(
16
- "API token is required. Call setToken(yourApiToken) before making requests.",
33
+ // ── Request interceptor ──────────────────────────────────────────────────────
34
+ _api.interceptors.request.use((config) => {
35
+ if (!_token) {
36
+ const err = new ExtractiaError(
37
+ "API token not set. Call setToken(token) before making requests.",
38
+ 0,
39
+ "API token not set. Call setToken(token) before making requests.",
40
+ "TOKEN_MISSING",
41
+ );
42
+ return Promise.reject(err);
43
+ }
44
+
45
+ config.headers = config.headers ?? {};
46
+ config.headers["Authorization"] = `Bearer ${_token}`;
47
+
48
+ // Apply default headers
49
+ Object.assign(config.headers, _config.defaultHeaders);
50
+
51
+ if (_config.debug) {
52
+ console.debug(
53
+ `[ExtractIA SDK] → ${(config.method ?? "GET").toUpperCase()} ${config.baseURL ?? ""}${config.url ?? ""}`,
54
+ config.params ?? "",
17
55
  );
18
56
  }
19
- config.headers.Authorization = `Bearer ${token}`;
57
+
58
+ if (typeof _config.onBeforeRequest === "function") {
59
+ const modified = _config.onBeforeRequest(config);
60
+ return modified ?? config;
61
+ }
62
+
20
63
  return config;
21
64
  });
22
65
 
23
- api.interceptors.response.use(
24
- (response) => response,
25
- (err) => Promise.reject(mapAxiosError(err)),
66
+ // ── Response interceptor ─────────────────────────────────────────────────────
67
+ _api.interceptors.response.use(
68
+ (response) => {
69
+ if (_config.debug) {
70
+ console.debug(
71
+ `[ExtractIA SDK] ← ${response.status} ${response.config?.url ?? ""}`,
72
+ );
73
+ }
74
+ if (typeof _config.onAfterResponse === "function") {
75
+ _config.onAfterResponse(response);
76
+ }
77
+ return response;
78
+ },
79
+ async (err) => {
80
+ // If already a typed ExtractiaError (e.g. from request interceptor), pass through
81
+ if (err instanceof ExtractiaError) {
82
+ if (typeof _config.onError === "function") _config.onError(err);
83
+ return Promise.reject(err);
84
+ }
85
+
86
+ const mapped = mapAxiosError(err);
87
+ const cfg = err.config;
88
+
89
+ // Automatic retry for retryable errors (429 / 5xx / network / timeout)
90
+ if (
91
+ cfg &&
92
+ mapped.isRetryable() &&
93
+ (cfg._retryCount ?? 0) < _config.retries
94
+ ) {
95
+ cfg._retryCount = (cfg._retryCount ?? 0) + 1;
96
+
97
+ // Honour Retry-After header for rate limits, otherwise exponential back-off
98
+ const delayMs =
99
+ mapped.retryAfter != null
100
+ ? mapped.retryAfter * 1_000
101
+ : _config.retryDelay * cfg._retryCount;
102
+
103
+ if (_config.debug) {
104
+ console.debug(
105
+ `[ExtractIA SDK] retrying (${cfg._retryCount}/${_config.retries}) in ${delayMs}ms…`,
106
+ );
107
+ }
108
+
109
+ await new Promise((r) => setTimeout(r, delayMs));
110
+ return _api(cfg);
111
+ }
112
+
113
+ if (typeof _config.onError === "function") _config.onError(mapped);
114
+ return Promise.reject(mapped);
115
+ },
26
116
  );
27
117
 
118
+ // ─── Public API ───────────────────────────────────────────────────────────────
119
+
28
120
  /**
29
121
  * Sets the API token used for all subsequent requests.
30
122
  * Must be called before any SDK method.
31
- * @param {string} newToken - Your Extractia API token.
123
+ *
124
+ * @param {string} token - Your Extractia API key.
125
+ * @throws {Error} If the token is empty or not a string.
32
126
  */
33
- export function setToken(newToken) {
34
- token = newToken;
127
+ export function setToken(token) {
128
+ if (!token || typeof token !== "string" || !token.trim()) {
129
+ throw new Error("setToken: token must be a non-empty string.");
130
+ }
131
+ _token = token.trim();
132
+ }
133
+
134
+ /**
135
+ * Returns the currently configured API token, or `null` if not set.
136
+ * @returns {string|null}
137
+ */
138
+ export function getToken() {
139
+ return _token;
140
+ }
141
+
142
+ /**
143
+ * Returns `true` if an API token has been set.
144
+ * @returns {boolean}
145
+ */
146
+ export function hasToken() {
147
+ return Boolean(_token);
148
+ }
149
+
150
+ /**
151
+ * Clears the stored API token.
152
+ * Useful for logout flows or test teardown.
153
+ */
154
+ export function clearToken() {
155
+ _token = null;
156
+ }
157
+
158
+ /**
159
+ * Configures SDK behaviour. All properties are optional; unspecified properties
160
+ * retain their current value.
161
+ *
162
+ * @param {object} opts
163
+ * @param {string} [opts.baseURL]
164
+ * Override the API base URL (useful for staging environments or proxies).
165
+ * @param {number} [opts.timeout]
166
+ * Request timeout in milliseconds. Default: 60 000 (60 s).
167
+ * @param {number} [opts.retries]
168
+ * Number of automatic retries on retryable errors (429 / 5xx / network). Default: 1.
169
+ * @param {number} [opts.retryDelay]
170
+ * Base delay (ms) between retries; multiplied by the attempt number. Default: 1 000.
171
+ * @param {boolean} [opts.debug]
172
+ * Log request/response/retry info to `console.debug`. Default: false.
173
+ * @param {Record<string,string>} [opts.defaultHeaders]
174
+ * Extra HTTP headers merged into every request (e.g. `{ "X-App-Version": "2.0" }`).
175
+ * @param {(config: import('axios').InternalAxiosRequestConfig) => import('axios').InternalAxiosRequestConfig|void} [opts.onBeforeRequest]
176
+ * Hook called with the Axios request config before each request.
177
+ * Return a modified config or `void` to keep the original.
178
+ * @param {(response: import('axios').AxiosResponse) => void} [opts.onAfterResponse]
179
+ * Hook called after each successful response. Useful for logging or metrics.
180
+ * @param {(error: import('./errors.js').ExtractiaError) => void} [opts.onError]
181
+ * Hook called with the mapped SDK error whenever a request fails.
182
+ * Called after retries are exhausted.
183
+ */
184
+ export function configure(opts = {}) {
185
+ if (opts.baseURL) {
186
+ _config.baseURL = opts.baseURL;
187
+ _api.defaults.baseURL = opts.baseURL;
188
+ }
189
+ if (opts.timeout != null) {
190
+ _config.timeout = opts.timeout;
191
+ _api.defaults.timeout = opts.timeout;
192
+ }
193
+ if (opts.retries != null) _config.retries = opts.retries;
194
+ if (opts.retryDelay != null) _config.retryDelay = opts.retryDelay;
195
+ if (opts.debug != null) _config.debug = Boolean(opts.debug);
196
+ if (opts.defaultHeaders) {
197
+ _config.defaultHeaders = {
198
+ ..._config.defaultHeaders,
199
+ ...opts.defaultHeaders,
200
+ };
201
+ }
202
+ if (opts.onBeforeRequest) _config.onBeforeRequest = opts.onBeforeRequest;
203
+ if (opts.onAfterResponse) _config.onAfterResponse = opts.onAfterResponse;
204
+ if (opts.onError) _config.onError = opts.onError;
35
205
  }
36
206
 
37
207
  /**
38
- * Configures SDK options.
39
- * @param {object} opts
40
- * @param {string} [opts.baseURL] - Override the default API base URL.
208
+ * Returns a snapshot of the current SDK configuration (token excluded).
209
+ * @returns {Readonly<typeof _config>}
41
210
  */
42
- export function configure({ baseURL } = {}) {
43
- if (baseURL) api.defaults.baseURL = baseURL;
211
+ export function getConfig() {
212
+ return { ..._config };
44
213
  }
45
214
 
46
- export default api;
215
+ export default _api;
@@ -3,6 +3,23 @@ import * as templates from "./templates.js";
3
3
  import * as documents from "./documents.js";
4
4
  import * as analytics from "./analytics.js";
5
5
  import * as ocrTools from "./ocrTools.js";
6
+ import * as subusers from "./subusers.js";
7
+ import * as utils from "./utils.js";
8
+ import { getToken, hasToken, clearToken, getConfig } from "./apiClient.js";
9
+ import {
10
+ ExtractiaError,
11
+ AuthError,
12
+ ForbiddenError,
13
+ TierError,
14
+ QuotaError,
15
+ RateLimitError,
16
+ NotFoundError,
17
+ ValidationError,
18
+ ConflictError,
19
+ ServerError,
20
+ NetworkError,
21
+ TimeoutError,
22
+ } from "./errors.js";
6
23
 
7
24
  const extractia = {
8
25
  ...auth,
@@ -10,6 +27,24 @@ const extractia = {
10
27
  ...documents,
11
28
  ...analytics,
12
29
  ...ocrTools,
30
+ ...subusers,
31
+ ...utils,
32
+ getToken,
33
+ hasToken,
34
+ clearToken,
35
+ getConfig,
36
+ ExtractiaError,
37
+ AuthError,
38
+ ForbiddenError,
39
+ TierError,
40
+ QuotaError,
41
+ RateLimitError,
42
+ NotFoundError,
43
+ ValidationError,
44
+ ConflictError,
45
+ ServerError,
46
+ NetworkError,
47
+ TimeoutError,
13
48
  };
14
49
 
15
50
  export default extractia;
package/src/errors.js CHANGED
@@ -1,95 +1,336 @@
1
1
  /**
2
- * Base error for all Extractia SDK errors.
3
- * Always carries the HTTP `status` code and a human-readable `message`.
2
+ * SDK error classes every error is a typed class that extends `ExtractiaError`.
3
+ *
4
+ * Every instance carries:
5
+ * • `status` — HTTP status code (0 for network / timeout errors)
6
+ * • `message` — Technical detail (may be a raw server string)
7
+ * • `userMessage` — Always a polished, user-facing English sentence
8
+ * • `code` — Short machine-readable string (e.g. "AUTH_ERROR")
9
+ * • `requestId` — X-Request-Id / X-Correlation-Id header when present
4
10
  */
11
+
12
+ // ─── Human-readable defaults per status ──────────────────────────────────────
13
+ const STATUS_MESSAGES = {
14
+ 400: "The request contains invalid data. Please check your input.",
15
+ 401: "Your API token is invalid or has expired. Please check your credentials.",
16
+ 402: "Your current plan does not support this feature or your document quota is exhausted.",
17
+ 403: "You do not have permission to perform this action.",
18
+ 404: "The requested resource could not be found.",
19
+ 409: "A resource with this identifier already exists.",
20
+ 429: "Too many requests. Please wait a moment and try again.",
21
+ 500: "The server encountered an unexpected error. Please try again in a moment.",
22
+ 502: "The server received an unexpected response. Please try again.",
23
+ 503: "The service is temporarily unavailable. Please try again in a few minutes.",
24
+ 504: "The server timed out while processing the request. Please try again.",
25
+ };
26
+
27
+ // ─── Base class ───────────────────────────────────────────────────────────────
5
28
  export class ExtractiaError extends Error {
6
- /** @param {string} message @param {number} status */
7
- constructor(message, status) {
29
+ /**
30
+ * @param {string} message Technical detail string.
31
+ * @param {number} [status] — HTTP status code (0 = no response).
32
+ * @param {string} [userMessage] — Human-friendly sentence shown to end users.
33
+ * @param {string} [code] — Machine-readable error code.
34
+ */
35
+ constructor(message, status = 0, userMessage = null, code = "SDK_ERROR") {
8
36
  super(message);
9
37
  this.name = "ExtractiaError";
10
38
  this.status = status;
39
+ this.userMessage = userMessage ?? message;
40
+ this.code = code;
41
+ /** @type {string|null} Populated from X-Request-Id / X-Correlation-Id response header. */
42
+ this.requestId = null;
43
+ }
44
+
45
+ /** Returns true if automatically retrying the same request may succeed. */
46
+ isRetryable() {
47
+ return this.status === 429 || this.status >= 500;
48
+ }
49
+
50
+ toJSON() {
51
+ return {
52
+ name: this.name,
53
+ code: this.code,
54
+ status: this.status,
55
+ message: this.message,
56
+ userMessage: this.userMessage,
57
+ requestId: this.requestId,
58
+ };
11
59
  }
12
60
  }
13
61
 
14
- /**
15
- * Thrown when the API token is missing or invalid (HTTP 401).
16
- */
62
+ // ─── Typed subclasses ─────────────────────────────────────────────────────────
63
+
64
+ /** HTTP 401 — token missing, expired, or malformed. */
17
65
  export class AuthError extends ExtractiaError {
18
- constructor(message = "Unauthorized. Check your API token.") {
19
- super(message, 401);
66
+ constructor(message = STATUS_MESSAGES[401]) {
67
+ super(message, 401, STATUS_MESSAGES[401], "AUTH_ERROR");
20
68
  this.name = "AuthError";
21
69
  }
22
70
  }
23
71
 
24
72
  /**
25
- * Thrown when the account has no permission to perform the action (HTTP 403).
26
- * Typically means the email is unconfirmed or a sub-user lacks the required permission.
73
+ * HTTP 403 authenticated but lacking required permission.
74
+ * Common causes: unconfirmed email, sub-user permission not granted, plan restriction.
27
75
  */
28
76
  export class ForbiddenError extends ExtractiaError {
29
- constructor(message = "Forbidden. Insufficient permissions.") {
30
- super(message, 403);
77
+ constructor(message = STATUS_MESSAGES[403]) {
78
+ super(message, 403, STATUS_MESSAGES[403], "FORBIDDEN");
31
79
  this.name = "ForbiddenError";
32
80
  }
33
81
  }
34
82
 
35
- /**
36
- * Thrown when the active plan does not allow the requested operation (HTTP 402 / 429 tier).
37
- * Check `error.status` to distinguish payment-required (402) from rate-limit (429).
38
- */
83
+ /** HTTP 402 — plan tier does not include this feature. */
39
84
  export class TierError extends ExtractiaError {
85
+ constructor(message = STATUS_MESSAGES[402], status = 402) {
86
+ super(message, status, STATUS_MESSAGES[402], "TIER_LIMIT");
87
+ this.name = "TierError";
88
+ }
89
+ }
90
+
91
+ /** HTTP 402 — document processing quota exhausted for this billing period. */
92
+ export class QuotaError extends ExtractiaError {
40
93
  constructor(
41
- message = "Tier limit reached. Upgrade your plan.",
42
- status = 402,
94
+ message = "You have reached your document processing quota for this billing period. Please upgrade or wait for the next cycle.",
43
95
  ) {
44
- super(message, status);
45
- this.name = "TierError";
96
+ super(message, 402, message, "QUOTA_EXCEEDED");
97
+ this.name = "QuotaError";
46
98
  }
47
99
  }
48
100
 
49
101
  /**
50
- * Thrown when the API rate limit is exceeded (HTTP 429).
102
+ * HTTP 429 requests sent too fast.
103
+ * Check `error.retryAfter` (seconds) from the `Retry-After` header when available.
51
104
  */
52
105
  export class RateLimitError extends ExtractiaError {
53
- constructor(message = "Too many requests. Please slow down.") {
54
- super(message, 429);
106
+ /**
107
+ * @param {string} [message]
108
+ * @param {number|null} [retryAfter] — Seconds to wait before retrying (from Retry-After header).
109
+ */
110
+ constructor(message = STATUS_MESSAGES[429], retryAfter = null) {
111
+ super(message, 429, STATUS_MESSAGES[429], "RATE_LIMITED");
55
112
  this.name = "RateLimitError";
113
+ /** @type {number|null} */
114
+ this.retryAfter = retryAfter;
115
+ }
116
+ isRetryable() {
117
+ return true;
56
118
  }
57
119
  }
58
120
 
59
- /**
60
- * Thrown when a requested resource was not found (HTTP 404).
61
- */
121
+ /** HTTP 404 — the requested resource does not exist. */
62
122
  export class NotFoundError extends ExtractiaError {
63
- constructor(message = "Resource not found.") {
64
- super(message, 404);
123
+ constructor(message = STATUS_MESSAGES[404]) {
124
+ super(message, 404, STATUS_MESSAGES[404], "NOT_FOUND");
65
125
  this.name = "NotFoundError";
66
126
  }
67
127
  }
68
128
 
129
+ /**
130
+ * HTTP 400 — request body / parameters failed server-side validation.
131
+ * May include per-field errors in `error.fields`.
132
+ */
133
+ export class ValidationError extends ExtractiaError {
134
+ /**
135
+ * @param {string} [message]
136
+ * @param {Record<string,string>|null} [fields] — Field-level errors, if the server provides them.
137
+ */
138
+ constructor(message = STATUS_MESSAGES[400], fields = null) {
139
+ super(message, 400, STATUS_MESSAGES[400], "VALIDATION_ERROR");
140
+ this.name = "ValidationError";
141
+ /** @type {Record<string,string>|null} */
142
+ this.fields = fields;
143
+ }
144
+ }
145
+
146
+ /** HTTP 409 — a resource with the same unique identifier already exists. */
147
+ export class ConflictError extends ExtractiaError {
148
+ constructor(message = STATUS_MESSAGES[409]) {
149
+ super(message, 409, STATUS_MESSAGES[409], "CONFLICT");
150
+ this.name = "ConflictError";
151
+ }
152
+ }
153
+
154
+ /** HTTP 5xx — unexpected server-side failure. Always retryable. */
155
+ export class ServerError extends ExtractiaError {
156
+ constructor(message = STATUS_MESSAGES[500], status = 500) {
157
+ super(
158
+ message,
159
+ status,
160
+ STATUS_MESSAGES[status] ?? STATUS_MESSAGES[500],
161
+ "SERVER_ERROR",
162
+ );
163
+ this.name = "ServerError";
164
+ }
165
+ isRetryable() {
166
+ return true;
167
+ }
168
+ }
169
+
170
+ /** No HTTP response — connection refused, DNS failure, etc. */
171
+ export class NetworkError extends ExtractiaError {
172
+ constructor(
173
+ message = "Unable to reach the Extractia API. Please check your network connection and try again.",
174
+ ) {
175
+ super(message, 0, message, "NETWORK_ERROR");
176
+ this.name = "NetworkError";
177
+ }
178
+ isRetryable() {
179
+ return true;
180
+ }
181
+ }
182
+
183
+ /** Request timed out before the server responded. */
184
+ export class TimeoutError extends ExtractiaError {
185
+ constructor(
186
+ message = "The request timed out. The server may be under heavy load — please try again in a moment.",
187
+ ) {
188
+ super(message, 0, message, "TIMEOUT");
189
+ this.name = "TimeoutError";
190
+ }
191
+ isRetryable() {
192
+ return true;
193
+ }
194
+ }
195
+
196
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
197
+
198
+ /**
199
+ * Extracts a developer-friendly detail string from any server response body.
200
+ * Handles plain strings, `{ message }`, `{ error }`, `{ detail }`, `{ title }`,
201
+ * `{ errors[] }`, and Spring `{ fieldErrors[] }`.
202
+ *
203
+ * @param {any} data
204
+ * @returns {string|null}
205
+ */
206
+ function extractServerDetail(data) {
207
+ if (!data) return null;
208
+ if (typeof data === "string" && data.trim()) return data.trim();
209
+ if (typeof data === "object") {
210
+ const msg = data.message ?? data.error ?? data.detail ?? data.title;
211
+ if (msg && typeof msg === "string") return msg.trim();
212
+ if (Array.isArray(data.errors) && data.errors.length > 0) {
213
+ const first = data.errors[0];
214
+ return typeof first === "string"
215
+ ? first
216
+ : (first.message ?? first.msg ?? JSON.stringify(first));
217
+ }
218
+ if (Array.isArray(data.fieldErrors) && data.fieldErrors.length > 0) {
219
+ return data.fieldErrors
220
+ .map((e) => `${e.field ?? "field"}: ${e.message ?? e.defaultMessage}`)
221
+ .join("; ");
222
+ }
223
+ }
224
+ return null;
225
+ }
226
+
69
227
  /**
70
228
  * Maps an Axios error to the appropriate typed SDK error.
71
- * Falls back to a generic `ExtractiaError` for unexpected status codes.
229
+ * Always returns an `ExtractiaError` subclass never re-throws.
72
230
  *
73
231
  * @param {import('axios').AxiosError} err
74
232
  * @returns {ExtractiaError}
75
233
  */
76
234
  export function mapAxiosError(err) {
77
- const status = err.response?.status;
78
- const serverMessage = err.response?.data;
79
- const detail = typeof serverMessage === "string" ? serverMessage : undefined;
235
+ // Network / DNS / connection error — no HTTP response at all
236
+ if (!err.response) {
237
+ if (
238
+ err.code === "ECONNABORTED" ||
239
+ err.code === "ETIMEDOUT" ||
240
+ (err.message && err.message.toLowerCase().includes("timeout"))
241
+ ) {
242
+ return new TimeoutError();
243
+ }
244
+ return new NetworkError(err.message || undefined);
245
+ }
246
+
247
+ const status = err.response.status;
248
+ const detail = extractServerDetail(err.response.data);
249
+ const userMessage =
250
+ STATUS_MESSAGES[status] ??
251
+ (status >= 500
252
+ ? STATUS_MESSAGES[500]
253
+ : "Something went wrong. Please try again.");
254
+
255
+ let error;
80
256
 
81
257
  switch (status) {
258
+ case 400: {
259
+ // Collect field-level errors if available
260
+ const body = err.response.data;
261
+ const fields =
262
+ body?.fields ??
263
+ (Array.isArray(body?.fieldErrors)
264
+ ? Object.fromEntries(
265
+ body.fieldErrors.map((f) => [
266
+ f.field,
267
+ f.message ?? f.defaultMessage,
268
+ ]),
269
+ )
270
+ : null);
271
+ error = new ValidationError(detail ?? STATUS_MESSAGES[400], fields);
272
+ break;
273
+ }
82
274
  case 401:
83
- return new AuthError(detail);
275
+ error = new AuthError(detail ?? undefined);
276
+ break;
84
277
  case 402:
85
- return new TierError(detail);
278
+ // Distinguish document quota exhaustion from a plan-tier restriction
279
+ if (
280
+ detail &&
281
+ (detail.toLowerCase().includes("quota") ||
282
+ detail.toLowerCase().includes("document") ||
283
+ detail.toLowerCase().includes("limit reached"))
284
+ ) {
285
+ error = new QuotaError(detail);
286
+ } else {
287
+ error = new TierError(detail ?? undefined);
288
+ }
289
+ break;
86
290
  case 403:
87
- return new ForbiddenError(detail);
291
+ error = new ForbiddenError(detail ?? undefined);
292
+ break;
88
293
  case 404:
89
- return new NotFoundError(detail);
90
- case 429:
91
- return new RateLimitError(detail);
294
+ error = new NotFoundError(detail ?? undefined);
295
+ break;
296
+ case 409:
297
+ error = new ConflictError(detail ?? undefined);
298
+ break;
299
+ case 429: {
300
+ const retryAfterHeader = err.response.headers?.["retry-after"];
301
+ const retryAfter =
302
+ retryAfterHeader != null ? parseInt(retryAfterHeader, 10) : null;
303
+ error = new RateLimitError(
304
+ detail ?? undefined,
305
+ isNaN(retryAfter) ? null : retryAfter,
306
+ );
307
+ break;
308
+ }
92
309
  default:
93
- return new ExtractiaError(detail ?? err.message, status ?? 0);
310
+ if (status >= 500) {
311
+ error = new ServerError(
312
+ detail ?? STATUS_MESSAGES[status] ?? STATUS_MESSAGES[500],
313
+ status,
314
+ );
315
+ } else {
316
+ error = new ExtractiaError(
317
+ detail ?? err.message,
318
+ status,
319
+ userMessage,
320
+ `HTTP_${status}`,
321
+ );
322
+ }
94
323
  }
324
+
325
+ // Always set the polished user message
326
+ error.userMessage = userMessage;
327
+
328
+ // Attach request correlation ID if the server provided one
329
+ const reqId =
330
+ err.response.headers?.["x-request-id"] ??
331
+ err.response.headers?.["x-correlation-id"] ??
332
+ null;
333
+ if (reqId) error.requestId = reqId;
334
+
335
+ return error;
95
336
  }