@visus-io/notion-sdk-ts 3.2.1 → 3.3.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/dist/api/pages.api.d.ts +4 -0
- package/dist/api/pages.api.js +5 -0
- package/dist/client.d.ts +33 -2
- package/dist/client.js +66 -19
- package/dist/errors.d.ts +36 -3
- package/dist/errors.js +59 -4
- package/package.json +2 -2
package/dist/api/pages.api.d.ts
CHANGED
|
@@ -1130,6 +1130,10 @@ export declare class PagesAPI extends BaseAPI<NotionPage, Page> {
|
|
|
1130
1130
|
* In that case, the API returns an `async_task` handle instead of the completed
|
|
1131
1131
|
* content. Poll the task with `notion.asyncTasks.poll(task.id)`.
|
|
1132
1132
|
*
|
|
1133
|
+
* A `504 gateway_timeout` response does not guarantee the write failed. The SDK
|
|
1134
|
+
* does not retry this call automatically on a `504`. Verify the page content before
|
|
1135
|
+
* you retry.
|
|
1136
|
+
*
|
|
1133
1137
|
* @param pageId - The ID of the page to update
|
|
1134
1138
|
* @param options - The markdown update to apply
|
|
1135
1139
|
* @returns The updated markdown content, or an async task handle if processed asynchronously
|
package/dist/api/pages.api.js
CHANGED
|
@@ -87,6 +87,10 @@ class PagesAPI extends base_api_1.BaseAPI {
|
|
|
87
87
|
* In that case, the API returns an `async_task` handle instead of the completed
|
|
88
88
|
* content. Poll the task with `notion.asyncTasks.poll(task.id)`.
|
|
89
89
|
*
|
|
90
|
+
* A `504 gateway_timeout` response does not guarantee the write failed. The SDK
|
|
91
|
+
* does not retry this call automatically on a `504`. Verify the page content before
|
|
92
|
+
* you retry.
|
|
93
|
+
*
|
|
90
94
|
* @param pageId - The ID of the page to update
|
|
91
95
|
* @param options - The markdown update to apply
|
|
92
96
|
* @returns The updated markdown content, or an async task handle if processed asynchronously
|
|
@@ -98,6 +102,7 @@ class PagesAPI extends base_api_1.BaseAPI {
|
|
|
98
102
|
method: 'PATCH',
|
|
99
103
|
path: `/pages/${pageId}/markdown`,
|
|
100
104
|
body: options,
|
|
105
|
+
retryOnGatewayTimeout: false,
|
|
101
106
|
});
|
|
102
107
|
return schemas_1.markdownContentResponseSchema.parse(response);
|
|
103
108
|
}
|
package/dist/client.d.ts
CHANGED
|
@@ -32,6 +32,12 @@ export interface RequestOptions {
|
|
|
32
32
|
path: string;
|
|
33
33
|
query?: Record<string, string | number | boolean | string[] | undefined>;
|
|
34
34
|
body?: unknown;
|
|
35
|
+
/**
|
|
36
|
+
* Whether to retry a `504 gateway_timeout` response automatically (default: `true`).
|
|
37
|
+
* A `504` does not guarantee the original request failed. Set this to `false` for
|
|
38
|
+
* a write that is not safe to repeat blindly, such as a non-idempotent content update.
|
|
39
|
+
*/
|
|
40
|
+
retryOnGatewayTimeout?: boolean;
|
|
35
41
|
}
|
|
36
42
|
/**
|
|
37
43
|
* Base HTTP client for Notion API requests.
|
|
@@ -97,12 +103,37 @@ export declare class NotionClient {
|
|
|
97
103
|
* Build the full URL with query parameters.
|
|
98
104
|
*/
|
|
99
105
|
private buildUrl;
|
|
106
|
+
/**
|
|
107
|
+
* Convert a whole-seconds string into milliseconds, clamped to
|
|
108
|
+
* {@link MAX_RETRY_DELAY_MS}. `value` comes from an unvalidated header or
|
|
109
|
+
* JSON body, so this accepts `unknown`. Return `undefined` if it is not a
|
|
110
|
+
* non-blank string, or not a valid non-negative number.
|
|
111
|
+
*/
|
|
112
|
+
private secondsToClampedMs;
|
|
100
113
|
/**
|
|
101
114
|
* Parse the `Retry-After` response header into milliseconds, clamped to
|
|
102
|
-
* {@link MAX_RETRY_DELAY_MS}. Return `undefined` if the header is missing
|
|
103
|
-
* not a valid non-negative number.
|
|
115
|
+
* {@link MAX_RETRY_DELAY_MS}. Return `undefined` if the header is missing,
|
|
116
|
+
* blank, or not a valid non-negative number.
|
|
104
117
|
*/
|
|
105
118
|
private parseRetryAfterHeader;
|
|
119
|
+
/**
|
|
120
|
+
* Parse `additional_data.retry_after` from the error body into milliseconds,
|
|
121
|
+
* clamped to {@link MAX_RETRY_DELAY_MS}. The Notion API repeats the
|
|
122
|
+
* `Retry-After` value here for clients that cannot read response headers.
|
|
123
|
+
* Return `undefined` if the field is missing, blank, or not a valid
|
|
124
|
+
* non-negative number.
|
|
125
|
+
*/
|
|
126
|
+
private parseRetryAfterBody;
|
|
127
|
+
/**
|
|
128
|
+
* Check if `value` is a plain, non-null, non-array object. Use this to
|
|
129
|
+
* guard a cast from unvalidated JSON before treating it as a record.
|
|
130
|
+
*/
|
|
131
|
+
private isRecord;
|
|
132
|
+
/**
|
|
133
|
+
* Build a generic error body for a response the SDK cannot parse into a
|
|
134
|
+
* {@link NotionErrorResponse}.
|
|
135
|
+
*/
|
|
136
|
+
private genericErrorBody;
|
|
106
137
|
/**
|
|
107
138
|
* Handle an error response from the API.
|
|
108
139
|
*/
|
package/dist/client.js
CHANGED
|
@@ -39,13 +39,18 @@ class NotionClient {
|
|
|
39
39
|
return await this.makeRequest(options);
|
|
40
40
|
}
|
|
41
41
|
catch (error) {
|
|
42
|
-
// Retry per isRetryable(); retryOnRateLimit:false suppresses only
|
|
42
|
+
// Retry per isRetryable(); retryOnRateLimit:false suppresses only HTTP 429
|
|
43
|
+
// (isRateLimited() is status-based, so this holds regardless of a
|
|
44
|
+
// malformed body's `code`). options.retryOnGatewayTimeout:false suppresses
|
|
45
|
+
// only HTTP 504; `status` is always authoritative there too (see
|
|
46
|
+
// handleErrorResponse()).
|
|
43
47
|
if (error instanceof errors_1.NotionAPIError &&
|
|
44
48
|
error.isRetryable() &&
|
|
45
49
|
!(error.isRateLimited() && !this.retryOnRateLimit) &&
|
|
50
|
+
!(error.status === 504 && options.retryOnGatewayTimeout === false) &&
|
|
46
51
|
attempt < this.maxRetries) {
|
|
47
|
-
// Prefer the server-supplied Retry-After value
|
|
48
|
-
// exponential backoff when
|
|
52
|
+
// Prefer the server-supplied Retry-After value (header or body fallback);
|
|
53
|
+
// fall back to exponential backoff when neither is present or valid.
|
|
49
54
|
const retryAfter = error.retryAfterMs ?? this.getRetryAfter(attempt);
|
|
50
55
|
await this.sleep(retryAfter);
|
|
51
56
|
lastError = error;
|
|
@@ -196,39 +201,81 @@ class NotionClient {
|
|
|
196
201
|
return url.toString();
|
|
197
202
|
}
|
|
198
203
|
/**
|
|
199
|
-
*
|
|
200
|
-
* {@link MAX_RETRY_DELAY_MS}.
|
|
201
|
-
*
|
|
204
|
+
* Convert a whole-seconds string into milliseconds, clamped to
|
|
205
|
+
* {@link MAX_RETRY_DELAY_MS}. `value` comes from an unvalidated header or
|
|
206
|
+
* JSON body, so this accepts `unknown`. Return `undefined` if it is not a
|
|
207
|
+
* non-blank string, or not a valid non-negative number.
|
|
202
208
|
*/
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (header === null) {
|
|
209
|
+
secondsToClampedMs(value) {
|
|
210
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
206
211
|
return undefined;
|
|
207
212
|
}
|
|
208
|
-
const seconds = Number(
|
|
213
|
+
const seconds = Number(value);
|
|
209
214
|
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
210
215
|
return undefined;
|
|
211
216
|
}
|
|
212
217
|
return Math.min(Math.ceil(seconds) * 1000, MAX_RETRY_DELAY_MS);
|
|
213
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* Parse the `Retry-After` response header into milliseconds, clamped to
|
|
221
|
+
* {@link MAX_RETRY_DELAY_MS}. Return `undefined` if the header is missing,
|
|
222
|
+
* blank, or not a valid non-negative number.
|
|
223
|
+
*/
|
|
224
|
+
parseRetryAfterHeader(response) {
|
|
225
|
+
return this.secondsToClampedMs(response.headers.get('Retry-After'));
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Parse `additional_data.retry_after` from the error body into milliseconds,
|
|
229
|
+
* clamped to {@link MAX_RETRY_DELAY_MS}. The Notion API repeats the
|
|
230
|
+
* `Retry-After` value here for clients that cannot read response headers.
|
|
231
|
+
* Return `undefined` if the field is missing, blank, or not a valid
|
|
232
|
+
* non-negative number.
|
|
233
|
+
*/
|
|
234
|
+
parseRetryAfterBody(errorBody) {
|
|
235
|
+
return this.secondsToClampedMs(errorBody.additional_data?.retry_after);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Check if `value` is a plain, non-null, non-array object. Use this to
|
|
239
|
+
* guard a cast from unvalidated JSON before treating it as a record.
|
|
240
|
+
*/
|
|
241
|
+
isRecord(value) {
|
|
242
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Build a generic error body for a response the SDK cannot parse into a
|
|
246
|
+
* {@link NotionErrorResponse}.
|
|
247
|
+
*/
|
|
248
|
+
genericErrorBody(response) {
|
|
249
|
+
return {
|
|
250
|
+
object: 'error',
|
|
251
|
+
status: response.status,
|
|
252
|
+
code: 'internal_server_error',
|
|
253
|
+
message: response.statusText || 'Unknown error occurred',
|
|
254
|
+
};
|
|
255
|
+
}
|
|
214
256
|
/**
|
|
215
257
|
* Handle an error response from the API.
|
|
216
258
|
*/
|
|
217
259
|
async handleErrorResponse(response) {
|
|
218
|
-
const retryAfterMs = this.parseRetryAfterHeader(response);
|
|
219
260
|
let errorBody;
|
|
220
261
|
try {
|
|
221
|
-
|
|
262
|
+
const parsed = await response.json();
|
|
263
|
+
// A syntactically valid body can still be non-object JSON, such as `null`
|
|
264
|
+
// or a bare string. Fall back to a generic error body in that case too;
|
|
265
|
+
// otherwise assigning `status` below would throw.
|
|
266
|
+
errorBody = this.isRecord(parsed)
|
|
267
|
+
? parsed
|
|
268
|
+
: this.genericErrorBody(response);
|
|
222
269
|
}
|
|
223
270
|
catch {
|
|
224
|
-
// If
|
|
225
|
-
errorBody =
|
|
226
|
-
object: 'error',
|
|
227
|
-
status: response.status,
|
|
228
|
-
code: 'internal_server_error',
|
|
229
|
-
message: response.statusText || 'Unknown error occurred',
|
|
230
|
-
};
|
|
271
|
+
// If the SDK cannot parse the error body, create a generic error
|
|
272
|
+
errorBody = this.genericErrorBody(response);
|
|
231
273
|
}
|
|
274
|
+
// The HTTP response status is authoritative. A proxy or a malformed body
|
|
275
|
+
// can report a `status` field that disagrees with it, or omit it.
|
|
276
|
+
errorBody.status = response.status;
|
|
277
|
+
// Prefer the header. Use the body only when the header is missing or invalid.
|
|
278
|
+
const retryAfterMs = this.parseRetryAfterHeader(response) ?? this.parseRetryAfterBody(errorBody);
|
|
232
279
|
throw new errors_1.NotionAPIError(errorBody, retryAfterMs);
|
|
233
280
|
}
|
|
234
281
|
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -3,7 +3,28 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @category Errors
|
|
5
5
|
*/
|
|
6
|
-
export
|
|
6
|
+
export declare const NOTION_ERROR_CODES: readonly ["invalid_json", "invalid_request_url", "invalid_request", "validation_error", "missing_version", "unauthorized", "restricted_resource", "object_not_found", "conflict_error", "rate_limited", "internal_server_error", "service_unavailable", "service_overload", "database_connection_unavailable", "gateway_timeout"];
|
|
7
|
+
/**
|
|
8
|
+
* Notion may add new error codes over time. Treat an unrecognized code the
|
|
9
|
+
* same as any other error response; read `status` to classify it.
|
|
10
|
+
*
|
|
11
|
+
* @category Errors
|
|
12
|
+
*/
|
|
13
|
+
export type NotionErrorCode = (typeof NOTION_ERROR_CODES)[number] | (string & Record<never, never>);
|
|
14
|
+
/**
|
|
15
|
+
* Reasons the Notion API gives for a `rate_limited` response.
|
|
16
|
+
* Appears in `NotionErrorResponse.additional_data.rate_limit_reason`.
|
|
17
|
+
*
|
|
18
|
+
* @category Errors
|
|
19
|
+
*/
|
|
20
|
+
export declare const RATE_LIMIT_REASONS: readonly ["public_api_request_rate_limit", "public_api_space_request_rate_limit", "public_api_endpoint_rate_limit", "mcp_tool_rate_limit", "collection_router_upstream_429", "public_api_request_blocked"];
|
|
21
|
+
/**
|
|
22
|
+
* Notion may add new reason strings over time. Treat an unrecognized value the
|
|
23
|
+
* same as any other `rate_limited` response.
|
|
24
|
+
*
|
|
25
|
+
* @category Errors
|
|
26
|
+
*/
|
|
27
|
+
export type RateLimitReason = (typeof RATE_LIMIT_REASONS)[number] | (string & Record<never, never>);
|
|
7
28
|
/**
|
|
8
29
|
* Notion API error response structure.
|
|
9
30
|
*
|
|
@@ -15,7 +36,12 @@ export interface NotionErrorResponse {
|
|
|
15
36
|
code: NotionErrorCode;
|
|
16
37
|
message: string;
|
|
17
38
|
/** Extra machine-readable context for some error codes, for example `restricted_resource`. */
|
|
18
|
-
additional_data?:
|
|
39
|
+
additional_data?: {
|
|
40
|
+
/** Present on `rate_limited` responses. Duplicates `Retry-After` as whole seconds. */
|
|
41
|
+
retry_after?: string;
|
|
42
|
+
/** Present on `rate_limited` responses. Identifies why the API rate-limited the request. */
|
|
43
|
+
rate_limit_reason?: RateLimitReason;
|
|
44
|
+
} & Record<string, unknown>;
|
|
19
45
|
}
|
|
20
46
|
/**
|
|
21
47
|
* Thrown when the Notion API returns an error response.
|
|
@@ -27,9 +53,14 @@ export declare class NotionAPIError extends Error {
|
|
|
27
53
|
readonly code: NotionErrorCode;
|
|
28
54
|
readonly body: NotionErrorResponse;
|
|
29
55
|
readonly retryAfterMs?: number;
|
|
56
|
+
readonly rateLimitReason?: RateLimitReason;
|
|
30
57
|
constructor(response: NotionErrorResponse, retryAfterMs?: number);
|
|
31
58
|
/**
|
|
32
|
-
* Check if the error is a rate limit error.
|
|
59
|
+
* Check if the error is a rate limit error (HTTP 429).
|
|
60
|
+
* Check `status`, not `code`. A malformed body could report the wrong
|
|
61
|
+
* `code` for a real 429, or report `rate_limited` for a real non-429
|
|
62
|
+
* status. `status` always matches the real HTTP response. See
|
|
63
|
+
* `NotionClient.handleErrorResponse()`.
|
|
33
64
|
*/
|
|
34
65
|
isRateLimited(): boolean;
|
|
35
66
|
/**
|
|
@@ -59,6 +90,8 @@ export declare class NotionAPIError extends Error {
|
|
|
59
90
|
isServerError(): boolean;
|
|
60
91
|
/**
|
|
61
92
|
* Check if the error is retryable (rate limit or server error).
|
|
93
|
+
* A `429` is not retryable when `rateLimitReason` is `public_api_request_blocked`,
|
|
94
|
+
* since the request cannot succeed.
|
|
62
95
|
*/
|
|
63
96
|
isRetryable(): boolean;
|
|
64
97
|
}
|
package/dist/errors.js
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.NotionNetworkError = exports.NotionRequestTimeoutError = exports.NotionAPIError = void 0;
|
|
3
|
+
exports.NotionNetworkError = exports.NotionRequestTimeoutError = exports.NotionAPIError = exports.RATE_LIMIT_REASONS = exports.NOTION_ERROR_CODES = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Notion API error codes based on official documentation.
|
|
6
|
+
*
|
|
7
|
+
* @category Errors
|
|
8
|
+
*/
|
|
9
|
+
exports.NOTION_ERROR_CODES = [
|
|
10
|
+
'invalid_json',
|
|
11
|
+
'invalid_request_url',
|
|
12
|
+
'invalid_request',
|
|
13
|
+
'validation_error',
|
|
14
|
+
'missing_version',
|
|
15
|
+
'unauthorized',
|
|
16
|
+
'restricted_resource',
|
|
17
|
+
'object_not_found',
|
|
18
|
+
'conflict_error',
|
|
19
|
+
'rate_limited',
|
|
20
|
+
'internal_server_error',
|
|
21
|
+
'service_unavailable',
|
|
22
|
+
'service_overload',
|
|
23
|
+
'database_connection_unavailable',
|
|
24
|
+
'gateway_timeout',
|
|
25
|
+
];
|
|
26
|
+
/**
|
|
27
|
+
* Reasons the Notion API gives for a `rate_limited` response.
|
|
28
|
+
* Appears in `NotionErrorResponse.additional_data.rate_limit_reason`.
|
|
29
|
+
*
|
|
30
|
+
* @category Errors
|
|
31
|
+
*/
|
|
32
|
+
exports.RATE_LIMIT_REASONS = [
|
|
33
|
+
'public_api_request_rate_limit',
|
|
34
|
+
'public_api_space_request_rate_limit',
|
|
35
|
+
'public_api_endpoint_rate_limit',
|
|
36
|
+
'mcp_tool_rate_limit',
|
|
37
|
+
'collection_router_upstream_429',
|
|
38
|
+
'public_api_request_blocked',
|
|
39
|
+
];
|
|
4
40
|
/**
|
|
5
41
|
* Thrown when the Notion API returns an error response.
|
|
6
42
|
*
|
|
@@ -11,19 +47,33 @@ class NotionAPIError extends Error {
|
|
|
11
47
|
super(response.message);
|
|
12
48
|
this.name = 'NotionAPIError';
|
|
13
49
|
this.status = response.status;
|
|
14
|
-
this.code = response.code;
|
|
15
50
|
this.body = response;
|
|
16
51
|
this.retryAfterMs = retryAfterMs;
|
|
52
|
+
// response comes from an unvalidated JSON body cast to NotionErrorResponse, so
|
|
53
|
+
// `code` could be any JSON type at runtime despite its string type. Check the
|
|
54
|
+
// runtime type before exposing it. Fall back to a generic code; `status` stays
|
|
55
|
+
// the authoritative field for classifying the error.
|
|
56
|
+
const rawCode = response.code;
|
|
57
|
+
this.code = typeof rawCode === 'string' ? rawCode : 'internal_server_error';
|
|
58
|
+
// additional_data comes from the same unvalidated body, so rate_limit_reason
|
|
59
|
+
// could likewise be any JSON type at runtime despite its string type. Check
|
|
60
|
+
// the runtime type before exposing it on this public property.
|
|
61
|
+
const rawRateLimitReason = response.additional_data?.rate_limit_reason;
|
|
62
|
+
this.rateLimitReason = typeof rawRateLimitReason === 'string' ? rawRateLimitReason : undefined;
|
|
17
63
|
// Maintain proper stack trace for V8 engines
|
|
18
64
|
if ('captureStackTrace' in Error) {
|
|
19
65
|
Error.captureStackTrace(this, NotionAPIError);
|
|
20
66
|
}
|
|
21
67
|
}
|
|
22
68
|
/**
|
|
23
|
-
* Check if the error is a rate limit error.
|
|
69
|
+
* Check if the error is a rate limit error (HTTP 429).
|
|
70
|
+
* Check `status`, not `code`. A malformed body could report the wrong
|
|
71
|
+
* `code` for a real 429, or report `rate_limited` for a real non-429
|
|
72
|
+
* status. `status` always matches the real HTTP response. See
|
|
73
|
+
* `NotionClient.handleErrorResponse()`.
|
|
24
74
|
*/
|
|
25
75
|
isRateLimited() {
|
|
26
|
-
return this.
|
|
76
|
+
return this.status === 429;
|
|
27
77
|
}
|
|
28
78
|
/**
|
|
29
79
|
* Check if the error is a service overload error (HTTP 529).
|
|
@@ -64,8 +114,13 @@ class NotionAPIError extends Error {
|
|
|
64
114
|
}
|
|
65
115
|
/**
|
|
66
116
|
* Check if the error is retryable (rate limit or server error).
|
|
117
|
+
* A `429` is not retryable when `rateLimitReason` is `public_api_request_blocked`,
|
|
118
|
+
* since the request cannot succeed.
|
|
67
119
|
*/
|
|
68
120
|
isRetryable() {
|
|
121
|
+
if (this.isRateLimited() && this.rateLimitReason === 'public_api_request_blocked') {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
69
124
|
return this.isRateLimited() || this.isServerError();
|
|
70
125
|
}
|
|
71
126
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@visus-io/notion-sdk-ts",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "TypeScript SDK for the Notion API",
|
|
6
6
|
"keywords": [
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"@types/node": "^25.9.5",
|
|
73
73
|
"@vitest/coverage-v8": "^4.1.11",
|
|
74
74
|
"eslint": "^10.9.0",
|
|
75
|
-
"eslint-plugin-zod": "4.
|
|
75
|
+
"eslint-plugin-zod": "4.14.2",
|
|
76
76
|
"husky": "^9.1.7",
|
|
77
77
|
"lint-staged": "^16.4.0",
|
|
78
78
|
"msw": "2.15.0",
|