Coverage for notion_client/client.py: 100%

251 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-30 15:22 +0000

1"""Synchronous and asynchronous clients for Notion's API.""" 

2 

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 

14 

15import httpx 

16from httpx import Request, Response 

17 

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 AsyncTasksEndpoint, 

27 BlocksEndpoint, 

28 CommentsEndpoint, 

29 CustomEmojisEndpoint, 

30 DatabasesEndpoint, 

31 DataSourcesEndpoint, 

32 PagesEndpoint, 

33 SearchEndpoint, 

34 UsersEndpoint, 

35 ViewsEndpoint, 

36 FileUploadsEndpoint, 

37 OAuthEndpoint, 

38) 

39from notion_client.errors import ( 

40 APIErrorCode, 

41 APIResponseError, 

42 build_request_error, 

43 is_http_response_error, 

44 is_notion_client_error, 

45 NotionClientError, 

46 RequestTimeoutError, 

47 validate_request_path, 

48) 

49from notion_client.logging import make_console_logger 

50from notion_client.typing import SyncAsync 

51 

52 

53@dataclass 

54class RetryOptions: 

55 """Configuration for automatic retries on rate limit (429) and server errors. 

56 

57 Attributes: 

58 max_retries: Maximum number of retry attempts. Set to 0 to disable retries. 

59 initial_retry_delay_ms: Initial delay between retries in milliseconds. 

60 Used as base for exponential back-off when retry-after header is absent. 

61 max_retry_delay_ms: Maximum delay between retries in milliseconds. 

62 """ 

63 

64 max_retries: int = DEFAULT_MAX_RETRIES 

65 initial_retry_delay_ms: int = DEFAULT_INITIAL_RETRY_DELAY_MS 

66 max_retry_delay_ms: int = DEFAULT_MAX_RETRY_DELAY_MS 

67 

68 

69@dataclass 

70class ClientOptions: 

71 """Options to configure the client. 

72 

73 Attributes: 

74 auth: Bearer token for authentication. If left undefined, the `auth` parameter 

75 should be set on each request. 

76 timeout_ms: Number of milliseconds to wait before emitting a 

77 `RequestTimeoutError`. 

78 base_url: The root URL for sending API requests. This can be changed to test 

79 with a mock server. 

80 log_level: Verbosity of logs the instance will produce. By default, logs are 

81 written to `stdout`. 

82 logger: A custom logger. 

83 notion_version: Notion version to use. 

84 retry: Configuration for automatic retries on rate limit (429) and server errors. 

85 Set to False to disable retries entirely. 

86 """ 

87 

88 auth: Optional[str] = None 

89 timeout_ms: int = DEFAULT_TIMEOUT_MS 

90 base_url: str = DEFAULT_BASE_URL 

91 log_level: int = logging.WARNING 

92 logger: Optional[logging.Logger] = None 

93 notion_version: str = "2025-09-03" 

94 retry: Union[RetryOptions, bool] = field(default_factory=RetryOptions) 

95 

96 

97class BaseClient: 

98 def __init__( 

99 self, 

100 client: Union[httpx.Client, httpx.AsyncClient], 

101 options: Optional[Union[Dict[str, Any], ClientOptions]] = None, 

102 **kwargs: Any, 

103 ) -> None: 

104 if options is None: 

105 options = ClientOptions(**kwargs) 

106 elif isinstance(options, dict): 

107 options = ClientOptions(**options) 

108 

109 self.logger = options.logger or make_console_logger() 

110 self.logger.setLevel(options.log_level) 

111 self.options = options 

112 

113 if options.retry is False: 

114 self._max_retries = 0 

115 self._initial_retry_delay_ms = 0 

116 self._max_retry_delay_ms = 0 

117 elif isinstance(options.retry, RetryOptions): 

118 self._max_retries = options.retry.max_retries 

119 self._initial_retry_delay_ms = options.retry.initial_retry_delay_ms 

120 self._max_retry_delay_ms = options.retry.max_retry_delay_ms 

121 else: 

122 retry_opts = RetryOptions() 

123 self._max_retries = retry_opts.max_retries 

124 self._initial_retry_delay_ms = retry_opts.initial_retry_delay_ms 

125 self._max_retry_delay_ms = retry_opts.max_retry_delay_ms 

126 

127 self._clients: List[Union[httpx.Client, httpx.AsyncClient]] = [] 

128 self.client = client 

129 

130 self.blocks = BlocksEndpoint(self) 

131 self.databases = DatabasesEndpoint(self) 

132 self.data_sources = DataSourcesEndpoint(self) 

133 self.custom_emojis = CustomEmojisEndpoint(self) 

134 self.users = UsersEndpoint(self) 

135 self.pages = PagesEndpoint(self) 

136 self.views = ViewsEndpoint(self) 

137 self.search = SearchEndpoint(self) 

138 self.comments = CommentsEndpoint(self) 

139 self.async_tasks = AsyncTasksEndpoint(self) 

140 self.file_uploads = FileUploadsEndpoint(self) 

141 self.oauth = OAuthEndpoint(self) 

142 

143 @property 

144 def client(self) -> Union[httpx.Client, httpx.AsyncClient]: 

145 return self._clients[-1] 

146 

147 @client.setter 

148 def client(self, client: Union[httpx.Client, httpx.AsyncClient]) -> None: 

149 client.base_url = httpx.URL(f"{self.options.base_url}/v1/") 

150 client.timeout = httpx.Timeout(timeout=self.options.timeout_ms / 1_000) 

151 client.headers = httpx.Headers( 

152 { 

153 "Notion-Version": self.options.notion_version, 

154 "User-Agent": "ramnes/notion-sdk-py@3.1.0", 

155 } 

156 ) 

157 if self.options.auth: 

158 client.headers["Authorization"] = f"Bearer {self.options.auth}" 

159 self._clients.append(client) 

160 

161 def _build_request( 

162 self, 

163 method: str, 

164 path: str, 

165 query: Optional[Dict[Any, Any]] = None, 

166 body: Optional[Dict[Any, Any]] = None, 

167 form_data: Optional[Dict[Any, Any]] = None, 

168 auth: Optional[Union[str, Dict[str, str]]] = None, 

169 ) -> Request: 

170 headers = httpx.Headers() 

171 if auth: 

172 if isinstance(auth, dict): 

173 client_id = auth.get("client_id", "") 

174 client_secret = auth.get("client_secret", "") 

175 credentials = f"{client_id}:{client_secret}" 

176 encoded_credentials = base64.b64encode(credentials.encode()).decode() 

177 headers["Authorization"] = f"Basic {encoded_credentials}" 

178 else: 

179 headers["Authorization"] = f"Bearer {auth}" 

180 

181 if not form_data: 

182 return self.client.build_request( 

183 method, 

184 path, 

185 params=query, 

186 json=body, 

187 headers=headers, 

188 ) 

189 

190 files: Dict[str, Any] = {} 

191 data: Dict[str, Any] = {} 

192 for key, value in form_data.items(): 

193 if isinstance(value, tuple) and len(value) >= 2: 

194 files[key] = value 

195 elif hasattr(value, "read"): 

196 files[key] = value 

197 elif isinstance(value, str): 

198 data[key] = value 

199 else: 

200 data[key] = str(value) 

201 

202 return self.client.build_request( 

203 method, 

204 path, 

205 params=query, 

206 files=files, 

207 data=data, 

208 headers=headers, 

209 ) 

210 

211 def _parse_response(self, response: Response) -> Any: 

212 try: 

213 response.raise_for_status() 

214 except httpx.HTTPStatusError as error: 

215 body_text = error.response.text 

216 raise build_request_error(error.response, body_text) 

217 

218 return response.json() 

219 

220 def _extract_str_field(self, obj: Any, name: str) -> Optional[str]: 

221 """Extracts a string field from an object or dict if present.""" 

222 if isinstance(obj, dict): 

223 value = obj.get(name) 

224 else: 

225 value = getattr(obj, name, None) 

226 return value if isinstance(value, str) else None 

227 

228 def _log_request_success(self, method: str, path: str, response_body: Any) -> None: 

229 """Logs a successful request.""" 

230 request_id = self._extract_str_field(response_body, "request_id") 

231 msg = f"request success: method={method}, path={path}" 

232 if request_id: 

233 msg += f", request_id={request_id}" 

234 self.logger.info(msg) 

235 

236 def _log_request_error(self, error: NotionClientError, attempt: int = 0) -> None: 

237 """Logs a request error with appropriate detail level.""" 

238 request_id = self._extract_str_field(error, "request_id") 

239 ray_id = self._extract_str_field(error, "ray_id") 

240 msg = f"request fail: code={error.code}, message={error}, attempt={attempt}" 

241 if request_id: 

242 msg += f", request_id={request_id}" 

243 if ray_id: 

244 msg += f", ray_id={ray_id}" 

245 self.logger.warning(msg) 

246 if is_http_response_error(error): 

247 self.logger.debug(f"failed response body: {error.body}") 

248 

249 def _can_retry(self, error: Exception, method: str) -> bool: 

250 """Determines if an error can be retried based on its error code and method. 

251 

252 Rate limits (429) and service overloads (529) are always retryable since 

253 the server explicitly asks us to retry. Server errors (500, 503) are only 

254 retried for idempotent methods (GET, DELETE) to avoid duplicate side effects. 

255 """ 

256 if not APIResponseError.is_api_response_error(error): 

257 return False 

258 

259 # Server says "try again later"; retry these for every HTTP method. 

260 if error.code in (APIErrorCode.RateLimited, APIErrorCode.ServiceOverload): 

261 return True 

262 

263 # Server errors only retry for idempotent methods 

264 is_idempotent = method.upper() in ("GET", "DELETE") 

265 if is_idempotent: 

266 return error.code in ( 

267 APIErrorCode.InternalServerError, 

268 APIErrorCode.ServiceUnavailable, 

269 ) 

270 

271 return False 

272 

273 def _calculate_retry_delay(self, error: Exception, attempt: int) -> float: 

274 """Calculates the delay before the next retry attempt. 

275 

276 Uses retry-after header if present, otherwise exponential back-off with jitter. 

277 Returns delay in seconds. 

278 """ 

279 if APIResponseError.is_api_response_error(error): 

280 retry_after_ms = self._parse_retry_after_header(error.headers) 

281 if retry_after_ms is not None: 

282 return min(retry_after_ms, self._max_retry_delay_ms) / 1000.0 

283 

284 # Exponential back-off with full jitter 

285 base_delay = self._initial_retry_delay_ms * math.pow(2, attempt) 

286 jitter = random.random() 

287 delay = ( 

288 min(base_delay * jitter + base_delay / 2, self._max_retry_delay_ms) / 1000.0 

289 ) 

290 return delay 

291 

292 def _parse_retry_after_header(self, headers: httpx.Headers) -> Optional[float]: 

293 """Parses the retry-after header value. 

294 

295 Supports both delta-seconds (e.g., "120") and HTTP-date formats. 

296 Returns the delay in milliseconds, or None if not present or invalid. 

297 """ 

298 retry_after_value = headers.get("retry-after") 

299 if not retry_after_value: 

300 return None 

301 

302 # Try parsing as delta-seconds (integer) 

303 try: 

304 seconds = int(retry_after_value) 

305 if seconds >= 0: 

306 return seconds * 1000.0 

307 except ValueError: 

308 pass 

309 

310 # Try parsing as HTTP-date 

311 try: 

312 retry_date = parsedate_to_datetime(retry_after_value) 

313 delay_ms = (retry_date.timestamp() - time.time()) * 1000.0 

314 return delay_ms if delay_ms > 0 else 0.0 

315 except (ValueError, TypeError): 

316 pass 

317 

318 return None 

319 

320 @abstractmethod 

321 def request( 

322 self, 

323 path: str, 

324 method: str, 

325 query: Optional[Dict[Any, Any]] = None, 

326 body: Optional[Dict[Any, Any]] = None, 

327 form_data: Optional[Dict[Any, Any]] = None, 

328 auth: Optional[Union[str, Dict[str, str]]] = None, 

329 ) -> SyncAsync[Any]: 

330 # noqa 

331 pass 

332 

333 

334class Client(BaseClient): 

335 """Synchronous client for Notion's API.""" 

336 

337 client: httpx.Client 

338 

339 def __init__( 

340 self, 

341 options: Optional[Union[Dict[Any, Any], ClientOptions]] = None, 

342 client: Optional[httpx.Client] = None, 

343 **kwargs: Any, 

344 ) -> None: 

345 if client is None: 

346 client = httpx.Client() 

347 super().__init__(client, options, **kwargs) 

348 

349 def __enter__(self) -> "Client": 

350 self.client = httpx.Client() 

351 self.client.__enter__() 

352 return self 

353 

354 def __exit__( 

355 self, 

356 exc_type: Type[BaseException], 

357 exc_value: BaseException, 

358 traceback: TracebackType, 

359 ) -> None: 

360 self.client.__exit__(exc_type, exc_value, traceback) 

361 del self._clients[-1] 

362 

363 def close(self) -> None: 

364 """Close the connection pool of the current inner client.""" 

365 self.client.close() 

366 

367 def request( 

368 self, 

369 path: str, 

370 method: str, 

371 query: Optional[Dict[Any, Any]] = None, 

372 body: Optional[Dict[Any, Any]] = None, 

373 form_data: Optional[Dict[Any, Any]] = None, 

374 auth: Optional[Union[str, Dict[str, str]]] = None, 

375 ) -> Any: 

376 """Send an HTTP request.""" 

377 validate_request_path(path) 

378 self.logger.info(f"{method} {self.client.base_url}{path}") 

379 return self._execute_with_retry(method, path, query, body, form_data, auth) 

380 

381 def _execute_with_retry( 

382 self, 

383 method: str, 

384 path: str, 

385 query: Optional[Dict[Any, Any]], 

386 body: Optional[Dict[Any, Any]], 

387 form_data: Optional[Dict[Any, Any]], 

388 auth: Optional[Union[str, Dict[str, str]]], 

389 ) -> Any: 

390 """Executes the request with retry logic.""" 

391 attempt = 0 

392 while True: 

393 request = self._build_request(method, path, query, body, form_data, auth) 

394 try: 

395 return self._execute_single_request(request, method, path) 

396 except Exception as error: 

397 if not is_notion_client_error(error): 

398 raise error 

399 

400 self._log_request_error(error, attempt) 

401 

402 if attempt >= self._max_retries or not self._can_retry(error, method): 

403 raise error 

404 

405 delay = self._calculate_retry_delay(error, attempt) 

406 self.logger.info( 

407 f"retrying request: method={method}, path={path}, attempt={attempt + 1}, delay_ms={delay * 1000:.0f}" 

408 ) 

409 time.sleep(delay) 

410 attempt += 1 

411 

412 def _execute_single_request(self, request: Request, method: str, path: str) -> Any: 

413 """Executes a single HTTP request (no retry).""" 

414 try: 

415 response = self.client.send(request) 

416 except httpx.TimeoutException: 

417 raise RequestTimeoutError() 

418 response_body = self._parse_response(response) 

419 self._log_request_success(method, path, response_body) 

420 return response_body 

421 

422 

423class AsyncClient(BaseClient): 

424 """Asynchronous client for Notion's API.""" 

425 

426 client: httpx.AsyncClient 

427 

428 def __init__( 

429 self, 

430 options: Optional[Union[Dict[str, Any], ClientOptions]] = None, 

431 client: Optional[httpx.AsyncClient] = None, 

432 **kwargs: Any, 

433 ) -> None: 

434 if client is None: 

435 client = httpx.AsyncClient() 

436 super().__init__(client, options, **kwargs) 

437 

438 async def __aenter__(self) -> "AsyncClient": 

439 self.client = httpx.AsyncClient() 

440 await self.client.__aenter__() 

441 return self 

442 

443 async def __aexit__( 

444 self, 

445 exc_type: Type[BaseException], 

446 exc_value: BaseException, 

447 traceback: TracebackType, 

448 ) -> None: 

449 await self.client.__aexit__(exc_type, exc_value, traceback) 

450 del self._clients[-1] 

451 

452 async def aclose(self) -> None: 

453 """Close the connection pool of the current inner client.""" 

454 await self.client.aclose() 

455 

456 async def request( 

457 self, 

458 path: str, 

459 method: str, 

460 query: Optional[Dict[Any, Any]] = None, 

461 body: Optional[Dict[Any, Any]] = None, 

462 form_data: Optional[Dict[Any, Any]] = None, 

463 auth: Optional[Union[str, Dict[str, str]]] = None, 

464 ) -> Any: 

465 """Send an HTTP request asynchronously.""" 

466 validate_request_path(path) 

467 self.logger.info(f"{method} {self.client.base_url}{path}") 

468 return await self._execute_with_retry( 

469 method, path, query, body, form_data, auth 

470 ) 

471 

472 async def _execute_with_retry( 

473 self, 

474 method: str, 

475 path: str, 

476 query: Optional[Dict[Any, Any]], 

477 body: Optional[Dict[Any, Any]], 

478 form_data: Optional[Dict[Any, Any]], 

479 auth: Optional[Union[str, Dict[str, str]]], 

480 ) -> Any: 

481 """Executes the request with retry logic.""" 

482 attempt = 0 

483 while True: 

484 request = self._build_request(method, path, query, body, form_data, auth) 

485 try: 

486 return await self._execute_single_request(request, method, path) 

487 except Exception as error: 

488 if not is_notion_client_error(error): 

489 raise error 

490 

491 self._log_request_error(error, attempt) 

492 

493 if attempt >= self._max_retries or not self._can_retry(error, method): 

494 raise error 

495 

496 delay = self._calculate_retry_delay(error, attempt) 

497 self.logger.info( 

498 f"retrying request: method={method}, path={path}, attempt={attempt + 1}, delay_ms={delay * 1000:.0f}" 

499 ) 

500 await asyncio.sleep(delay) 

501 attempt += 1 

502 

503 async def _execute_single_request( 

504 self, request: Request, method: str, path: str 

505 ) -> Any: 

506 """Executes a single HTTP request (no retry).""" 

507 try: 

508 response = await self.client.send(request) 

509 except httpx.TimeoutException: 

510 raise RequestTimeoutError() 

511 response_body = self._parse_response(response) 

512 self._log_request_success(method, path, response_body) 

513 return response_body