@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.
@@ -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
@@ -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 or
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 rate_limited.
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; fall back to
48
- // exponential backoff when the header is absent.
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
- * Parse the `Retry-After` response header into milliseconds, clamped to
200
- * {@link MAX_RETRY_DELAY_MS}. Return `undefined` if the header is missing or
201
- * not a valid non-negative number.
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
- parseRetryAfterHeader(response) {
204
- const header = response.headers.get('Retry-After');
205
- if (header === null) {
209
+ secondsToClampedMs(value) {
210
+ if (typeof value !== 'string' || value.trim() === '') {
206
211
  return undefined;
207
212
  }
208
- const seconds = Number(header);
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
- errorBody = (await response.json());
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 we can't parse the error body, create a generic error
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 type NotionErrorCode = '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';
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?: Record<string, unknown>;
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.code === 'rate_limited';
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.2.1",
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.12.0",
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",