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

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

50 

51 

52@dataclass 

53class RetryOptions: 

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

55 

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 """ 

62 

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 

66 

67 

68@dataclass 

69class ClientOptions: 

70 """Options to configure the client. 

71 

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 """ 

86 

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) 

94 

95 

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) 

107 

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

109 self.logger.setLevel(options.log_level) 

110 self.options = options 

111 

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 

125 

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

127 self.client = client 

128 

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) 

140 

141 @property 

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

143 return self._clients[-1] 

144 

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) 

158 

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}" 

178 

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 ) 

187 

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) 

199 

200 return self.client.build_request( 

201 method, 

202 path, 

203 params=query, 

204 files=files, 

205 data=data, 

206 headers=headers, 

207 ) 

208 

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) 

215 

216 return response.json() 

217 

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 

225 

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) 

233 

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}") 

243 

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. 

246 

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 

253 

254 # Rate limits are always retryable - server says "try again later" 

255 if error.code == APIErrorCode.RateLimited: 

256 return True 

257 

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 ) 

265 

266 return False 

267 

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

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

270 

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 

278 

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 

286 

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

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

289 

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 

296 

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 

304 

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 

312 

313 return None 

314 

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 

327 

328 

329class Client(BaseClient): 

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

331 

332 client: httpx.Client 

333 

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) 

343 

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

345 self.client = httpx.Client() 

346 self.client.__enter__() 

347 return self 

348 

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] 

357 

358 def close(self) -> None: 

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

360 self.client.close() 

361 

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) 

375 

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 

394 

395 self._log_request_error(error, attempt) 

396 

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

398 raise error 

399 

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 

406 

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 

416 

417 

418class AsyncClient(BaseClient): 

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

420 

421 client: httpx.AsyncClient 

422 

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) 

432 

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

434 self.client = httpx.AsyncClient() 

435 await self.client.__aenter__() 

436 return self 

437 

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] 

446 

447 async def aclose(self) -> None: 

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

449 await self.client.aclose() 

450 

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 ) 

466 

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 

485 

486 self._log_request_error(error, attempt) 

487 

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

489 raise error 

490 

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 

497 

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