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/README.md +163 -6
- package/dist/extractia-sdk.browser.js +538 -82
- package/dist/extractia-sdk.cjs.js +522 -87
- package/dist/extractia-sdk.esm.js +522 -87
- package/dist/index.d.ts +279 -22
- package/package.json +23 -4
- package/src/apiClient.js +192 -23
- package/src/browser-entry.js +35 -0
- package/src/errors.js +281 -40
- package/src/index.d.ts +279 -22
- package/src/index.js +16 -0
- package/src/ocrTools.js +48 -0
- package/src/subusers.js +1 -1
- package/src/utils.js +223 -0
- package/vitest.integration.config.js +27 -0
- package/dist/extractia-sdk.browser.js.map +0 -7
- package/dist/extractia-sdk.cjs.js.map +0 -7
- package/dist/extractia-sdk.esm.js.map +0 -7
- package/dist/extractia-sdk.js.map +0 -7
package/src/apiClient.js
CHANGED
|
@@ -1,46 +1,215 @@
|
|
|
1
1
|
import axios from "axios";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
mapAxiosError,
|
|
4
|
+
ExtractiaError,
|
|
5
|
+
NetworkError,
|
|
6
|
+
TimeoutError,
|
|
7
|
+
} from "./errors.js";
|
|
3
8
|
|
|
4
|
-
|
|
9
|
+
// ─── Internal state ───────────────────────────────────────────────────────────
|
|
5
10
|
|
|
6
|
-
|
|
11
|
+
let _token = null;
|
|
7
12
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
(
|
|
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
|
-
*
|
|
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(
|
|
34
|
-
token
|
|
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
|
-
*
|
|
39
|
-
* @
|
|
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
|
|
43
|
-
|
|
211
|
+
export function getConfig() {
|
|
212
|
+
return { ..._config };
|
|
44
213
|
}
|
|
45
214
|
|
|
46
|
-
export default
|
|
215
|
+
export default _api;
|
package/src/browser-entry.js
CHANGED
|
@@ -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
|
-
*
|
|
3
|
-
*
|
|
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
|
-
/**
|
|
7
|
-
|
|
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
|
-
|
|
16
|
-
*/
|
|
62
|
+
// ─── Typed subclasses ─────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/** HTTP 401 — token missing, expired, or malformed. */
|
|
17
65
|
export class AuthError extends ExtractiaError {
|
|
18
|
-
constructor(message =
|
|
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
|
-
*
|
|
26
|
-
*
|
|
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 =
|
|
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 = "
|
|
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,
|
|
45
|
-
this.name = "
|
|
96
|
+
super(message, 402, message, "QUOTA_EXCEEDED");
|
|
97
|
+
this.name = "QuotaError";
|
|
46
98
|
}
|
|
47
99
|
}
|
|
48
100
|
|
|
49
101
|
/**
|
|
50
|
-
*
|
|
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
|
-
|
|
54
|
-
|
|
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 =
|
|
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
|
-
*
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
275
|
+
error = new AuthError(detail ?? undefined);
|
|
276
|
+
break;
|
|
84
277
|
case 402:
|
|
85
|
-
|
|
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
|
-
|
|
291
|
+
error = new ForbiddenError(detail ?? undefined);
|
|
292
|
+
break;
|
|
88
293
|
case 404:
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
}
|