Coverage for notion_client / client.py: 100%
245 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-02 18:32 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-02 18:32 +0000
1"""Synchronous and asynchronous clients for Notion's API."""
3import asyncio
4import base64
5import logging
6import math
7import random
8import time
9from abc import abstractmethod
10from dataclasses import dataclass, field
11from email.utils import parsedate_to_datetime
12from types import TracebackType
13from typing import Any, Dict, List, Optional, Type, Union
15import httpx
16from httpx import Request, Response
18from notion_client.constants import (
19 DEFAULT_BASE_URL,
20 DEFAULT_TIMEOUT_MS,
21 DEFAULT_MAX_RETRIES,
22 DEFAULT_INITIAL_RETRY_DELAY_MS,
23 DEFAULT_MAX_RETRY_DELAY_MS,
24)
25from notion_client.api_endpoints import (
26 BlocksEndpoint,
27 CommentsEndpoint,
28 CustomEmojisEndpoint,
29 DatabasesEndpoint,
30 DataSourcesEndpoint,
31 PagesEndpoint,
32 SearchEndpoint,
33 UsersEndpoint,
34 ViewsEndpoint,
35 FileUploadsEndpoint,
36 OAuthEndpoint,
37)
38from notion_client.errors import (
39 APIErrorCode,
40 APIResponseError,
41 build_request_error,
42 is_http_response_error,
43 is_notion_client_error,
44 NotionClientError,
45 RequestTimeoutError,
46 validate_request_path,
47)
48from notion_client.logging import make_console_logger
49from notion_client.typing import SyncAsync
52@dataclass
53class RetryOptions:
54 """Configuration for automatic retries on rate limit (429) and server errors.
56 Attributes:
57 max_retries: Maximum number of retry attempts. Set to 0 to disable retries.
58 initial_retry_delay_ms: Initial delay between retries in milliseconds.
59 Used as base for exponential back-off when retry-after header is absent.
60 max_retry_delay_ms: Maximum delay between retries in milliseconds.
61 """
63 max_retries: int = DEFAULT_MAX_RETRIES
64 initial_retry_delay_ms: int = DEFAULT_INITIAL_RETRY_DELAY_MS
65 max_retry_delay_ms: int = DEFAULT_MAX_RETRY_DELAY_MS
68@dataclass
69class ClientOptions:
70 """Options to configure the client.
72 Attributes:
73 auth: Bearer token for authentication. If left undefined, the `auth` parameter
74 should be set on each request.
75 timeout_ms: Number of milliseconds to wait before emitting a
76 `RequestTimeoutError`.
77 base_url: The root URL for sending API requests. This can be changed to test
78 with a mock server.
79 log_level: Verbosity of logs the instance will produce. By default, logs are
80 written to `stdout`.
81 logger: A custom logger.
82 notion_version: Notion version to use.
83 retry: Configuration for automatic retries on rate limit (429) and server errors.
84 Set to False to disable retries entirely.
85 """
87 auth: Optional[str] = None
88 timeout_ms: int = DEFAULT_TIMEOUT_MS
89 base_url: str = DEFAULT_BASE_URL
90 log_level: int = logging.WARNING
91 logger: Optional[logging.Logger] = None
92 notion_version: str = "2025-09-03"
93 retry: Union[RetryOptions, bool] = field(default_factory=RetryOptions)
96class BaseClient:
97 def __init__(
98 self,
99 client: Union[httpx.Client, httpx.AsyncClient],
100 options: Optional[Union[Dict[str, Any], ClientOptions]] = None,
101 **kwargs: Any,
102 ) -> None:
103 if options is None:
104 options = ClientOptions(**kwargs)
105 elif isinstance(options, dict):
106 options = ClientOptions(**options)
108 self.logger = options.logger or make_console_logger()
109 self.logger.setLevel(options.log_level)
110 self.options = options
112 if options.retry is False:
113 self._max_retries = 0
114 self._initial_retry_delay_ms = 0
115 self._max_retry_delay_ms = 0
116 elif isinstance(options.retry, RetryOptions):
117 self._max_retries = options.retry.max_retries
118 self._initial_retry_delay_ms = options.retry.initial_retry_delay_ms
119 self._max_retry_delay_ms = options.retry.max_retry_delay_ms
120 else:
121 retry_opts = RetryOptions()
122 self._max_retries = retry_opts.max_retries
123 self._initial_retry_delay_ms = retry_opts.initial_retry_delay_ms
124 self._max_retry_delay_ms = retry_opts.max_retry_delay_ms
126 self._clients: List[Union[httpx.Client, httpx.AsyncClient]] = []
127 self.client = client
129 self.blocks = BlocksEndpoint(self)
130 self.databases = DatabasesEndpoint(self)
131 self.data_sources = DataSourcesEndpoint(self)
132 self.custom_emojis = CustomEmojisEndpoint(self)
133 self.users = UsersEndpoint(self)
134 self.pages = PagesEndpoint(self)
135 self.views = ViewsEndpoint(self)
136 self.search = SearchEndpoint(self)
137 self.comments = CommentsEndpoint(self)
138 self.file_uploads = FileUploadsEndpoint(self)
139 self.oauth = OAuthEndpoint(self)
141 @property
142 def client(self) -> Union[httpx.Client, httpx.AsyncClient]:
143 return self._clients[-1]
145 @client.setter
146 def client(self, client: Union[httpx.Client, httpx.AsyncClient]) -> None:
147 client.base_url = httpx.URL(f"{self.options.base_url}/v1/")
148 client.timeout = httpx.Timeout(timeout=self.options.timeout_ms / 1_000)
149 client.headers = httpx.Headers(
150 {
151 "Notion-Version": self.options.notion_version,
152 "User-Agent": "ramnes/notion-sdk-py@3.0.0",
153 }
154 )
155 if self.options.auth:
156 client.headers["Authorization"] = f"Bearer {self.options.auth}"
157 self._clients.append(client)
159 def _build_request(
160 self,
161 method: str,
162 path: str,
163 query: Optional[Dict[Any, Any]] = None,
164 body: Optional[Dict[Any, Any]] = None,
165 form_data: Optional[Dict[Any, Any]] = None,
166 auth: Optional[Union[str, Dict[str, str]]] = None,
167 ) -> Request:
168 headers = httpx.Headers()
169 if auth:
170 if isinstance(auth, dict):
171 client_id = auth.get("client_id", "")
172 client_secret = auth.get("client_secret", "")
173 credentials = f"{client_id}:{client_secret}"
174 encoded_credentials = base64.b64encode(credentials.encode()).decode()
175 headers["Authorization"] = f"Basic {encoded_credentials}"
176 else:
177 headers["Authorization"] = f"Bearer {auth}"
179 if not form_data:
180 return self.client.build_request(
181 method,
182 path,
183 params=query,
184 json=body,
185 headers=headers,
186 )
188 files: Dict[str, Any] = {}
189 data: Dict[str, Any] = {}
190 for key, value in form_data.items():
191 if isinstance(value, tuple) and len(value) >= 2:
192 files[key] = value
193 elif hasattr(value, "read"):
194 files[key] = value
195 elif isinstance(value, str):
196 data[key] = value
197 else:
198 data[key] = str(value)
200 return self.client.build_request(
201 method,
202 path,
203 params=query,
204 files=files,
205 data=data,
206 headers=headers,
207 )
209 def _parse_response(self, response: Response) -> Any:
210 try:
211 response.raise_for_status()
212 except httpx.HTTPStatusError as error:
213 body_text = error.response.text
214 raise build_request_error(error.response, body_text)
216 return response.json()
218 def _extract_request_id(self, obj: Any) -> Optional[str]:
219 """Extracts request_id from an object if present."""
220 if isinstance(obj, dict):
221 return obj.get("request_id")
222 else:
223 request_id = getattr(obj, "request_id", None)
224 return request_id if isinstance(request_id, str) else None
226 def _log_request_success(self, method: str, path: str, response_body: Any) -> None:
227 """Logs a successful request."""
228 request_id = self._extract_request_id(response_body)
229 msg = f"request success: method={method}, path={path}"
230 if request_id:
231 msg += f", request_id={request_id}"
232 self.logger.info(msg)
234 def _log_request_error(self, error: NotionClientError, attempt: int = 0) -> None:
235 """Logs a request error with appropriate detail level."""
236 request_id = self._extract_request_id(error)
237 msg = f"request fail: code={error.code}, message={error}, attempt={attempt}"
238 if request_id:
239 msg += f", request_id={request_id}"
240 self.logger.warning(msg)
241 if is_http_response_error(error):
242 self.logger.debug(f"failed response body: {error.body}")
244 def _can_retry(self, error: Exception, method: str) -> bool:
245 """Determines if an error can be retried based on its error code and method.
247 Rate limits (429) are always retryable since the server explicitly asks us to retry.
248 Server errors (500, 503) are only retried for idempotent methods
249 (GET, DELETE) to avoid duplicate side effects.
250 """
251 if not APIResponseError.is_api_response_error(error):
252 return False
254 # Rate limits are always retryable - server says "try again later"
255 if error.code == APIErrorCode.RateLimited:
256 return True
258 # Server errors only retry for idempotent methods
259 is_idempotent = method.upper() in ("GET", "DELETE")
260 if is_idempotent:
261 return error.code in (
262 APIErrorCode.InternalServerError,
263 APIErrorCode.ServiceUnavailable,
264 )
266 return False
268 def _calculate_retry_delay(self, error: Exception, attempt: int) -> float:
269 """Calculates the delay before the next retry attempt.
271 Uses retry-after header if present, otherwise exponential back-off with jitter.
272 Returns delay in seconds.
273 """
274 if APIResponseError.is_api_response_error(error):
275 retry_after_ms = self._parse_retry_after_header(error.headers)
276 if retry_after_ms is not None:
277 return min(retry_after_ms, self._max_retry_delay_ms) / 1000.0
279 # Exponential back-off with full jitter
280 base_delay = self._initial_retry_delay_ms * math.pow(2, attempt)
281 jitter = random.random()
282 delay = (
283 min(base_delay * jitter + base_delay / 2, self._max_retry_delay_ms) / 1000.0
284 )
285 return delay
287 def _parse_retry_after_header(self, headers: httpx.Headers) -> Optional[float]:
288 """Parses the retry-after header value.
290 Supports both delta-seconds (e.g., "120") and HTTP-date formats.
291 Returns the delay in milliseconds, or None if not present or invalid.
292 """
293 retry_after_value = headers.get("retry-after")
294 if not retry_after_value:
295 return None
297 # Try parsing as delta-seconds (integer)
298 try:
299 seconds = int(retry_after_value)
300 if seconds >= 0:
301 return seconds * 1000.0
302 except ValueError:
303 pass
305 # Try parsing as HTTP-date
306 try:
307 retry_date = parsedate_to_datetime(retry_after_value)
308 delay_ms = (retry_date.timestamp() - time.time()) * 1000.0
309 return delay_ms if delay_ms > 0 else 0.0
310 except (ValueError, TypeError):
311 pass
313 return None
315 @abstractmethod
316 def request(
317 self,
318 path: str,
319 method: str,
320 query: Optional[Dict[Any, Any]] = None,
321 body: Optional[Dict[Any, Any]] = None,
322 form_data: Optional[Dict[Any, Any]] = None,
323 auth: Optional[Union[str, Dict[str, str]]] = None,
324 ) -> SyncAsync[Any]:
325 # noqa
326 pass
329class Client(BaseClient):
330 """Synchronous client for Notion's API."""
332 client: httpx.Client
334 def __init__(
335 self,
336 options: Optional[Union[Dict[Any, Any], ClientOptions]] = None,
337 client: Optional[httpx.Client] = None,
338 **kwargs: Any,
339 ) -> None:
340 if client is None:
341 client = httpx.Client()
342 super().__init__(client, options, **kwargs)
344 def __enter__(self) -> "Client":
345 self.client = httpx.Client()
346 self.client.__enter__()
347 return self
349 def __exit__(
350 self,
351 exc_type: Type[BaseException],
352 exc_value: BaseException,
353 traceback: TracebackType,
354 ) -> None:
355 self.client.__exit__(exc_type, exc_value, traceback)
356 del self._clients[-1]
358 def close(self) -> None:
359 """Close the connection pool of the current inner client."""
360 self.client.close()
362 def request(
363 self,
364 path: str,
365 method: str,
366 query: Optional[Dict[Any, Any]] = None,
367 body: Optional[Dict[Any, Any]] = None,
368 form_data: Optional[Dict[Any, Any]] = None,
369 auth: Optional[Union[str, Dict[str, str]]] = None,
370 ) -> Any:
371 """Send an HTTP request."""
372 validate_request_path(path)
373 self.logger.info(f"{method} {self.client.base_url}{path}")
374 return self._execute_with_retry(method, path, query, body, form_data, auth)
376 def _execute_with_retry(
377 self,
378 method: str,
379 path: str,
380 query: Optional[Dict[Any, Any]],
381 body: Optional[Dict[Any, Any]],
382 form_data: Optional[Dict[Any, Any]],
383 auth: Optional[Union[str, Dict[str, str]]],
384 ) -> Any:
385 """Executes the request with retry logic."""
386 attempt = 0
387 while True:
388 request = self._build_request(method, path, query, body, form_data, auth)
389 try:
390 return self._execute_single_request(request, method, path)
391 except Exception as error:
392 if not is_notion_client_error(error):
393 raise error
395 self._log_request_error(error, attempt)
397 if attempt >= self._max_retries or not self._can_retry(error, method):
398 raise error
400 delay = self._calculate_retry_delay(error, attempt)
401 self.logger.info(
402 f"retrying request: method={method}, path={path}, attempt={attempt + 1}, delay_ms={delay * 1000:.0f}"
403 )
404 time.sleep(delay)
405 attempt += 1
407 def _execute_single_request(self, request: Request, method: str, path: str) -> Any:
408 """Executes a single HTTP request (no retry)."""
409 try:
410 response = self.client.send(request)
411 except httpx.TimeoutException:
412 raise RequestTimeoutError()
413 response_body = self._parse_response(response)
414 self._log_request_success(method, path, response_body)
415 return response_body
418class AsyncClient(BaseClient):
419 """Asynchronous client for Notion's API."""
421 client: httpx.AsyncClient
423 def __init__(
424 self,
425 options: Optional[Union[Dict[str, Any], ClientOptions]] = None,
426 client: Optional[httpx.AsyncClient] = None,
427 **kwargs: Any,
428 ) -> None:
429 if client is None:
430 client = httpx.AsyncClient()
431 super().__init__(client, options, **kwargs)
433 async def __aenter__(self) -> "AsyncClient":
434 self.client = httpx.AsyncClient()
435 await self.client.__aenter__()
436 return self
438 async def __aexit__(
439 self,
440 exc_type: Type[BaseException],
441 exc_value: BaseException,
442 traceback: TracebackType,
443 ) -> None:
444 await self.client.__aexit__(exc_type, exc_value, traceback)
445 del self._clients[-1]
447 async def aclose(self) -> None:
448 """Close the connection pool of the current inner client."""
449 await self.client.aclose()
451 async def request(
452 self,
453 path: str,
454 method: str,
455 query: Optional[Dict[Any, Any]] = None,
456 body: Optional[Dict[Any, Any]] = None,
457 form_data: Optional[Dict[Any, Any]] = None,
458 auth: Optional[Union[str, Dict[str, str]]] = None,
459 ) -> Any:
460 """Send an HTTP request asynchronously."""
461 validate_request_path(path)
462 self.logger.info(f"{method} {self.client.base_url}{path}")
463 return await self._execute_with_retry(
464 method, path, query, body, form_data, auth
465 )
467 async def _execute_with_retry(
468 self,
469 method: str,
470 path: str,
471 query: Optional[Dict[Any, Any]],
472 body: Optional[Dict[Any, Any]],
473 form_data: Optional[Dict[Any, Any]],
474 auth: Optional[Union[str, Dict[str, str]]],
475 ) -> Any:
476 """Executes the request with retry logic."""
477 attempt = 0
478 while True:
479 request = self._build_request(method, path, query, body, form_data, auth)
480 try:
481 return await self._execute_single_request(request, method, path)
482 except Exception as error:
483 if not is_notion_client_error(error):
484 raise error
486 self._log_request_error(error, attempt)
488 if attempt >= self._max_retries or not self._can_retry(error, method):
489 raise error
491 delay = self._calculate_retry_delay(error, attempt)
492 self.logger.info(
493 f"retrying request: method={method}, path={path}, attempt={attempt + 1}, delay_ms={delay * 1000:.0f}"
494 )
495 await asyncio.sleep(delay)
496 attempt += 1
498 async def _execute_single_request(
499 self, request: Request, method: str, path: str
500 ) -> Any:
501 """Executes a single HTTP request (no retry)."""
502 try:
503 response = await self.client.send(request)
504 except httpx.TimeoutException:
505 raise RequestTimeoutError()
506 response_body = self._parse_response(response)
507 self._log_request_success(method, path, response_body)
508 return response_body