Coverage for notion_client / errors.py: 99%

132 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-17 21:35 +0000

1"""Custom exceptions for notion-sdk-py. 

2 

3This module defines the exceptions that can be raised when an error occurs. 

4""" 

5 

6import asyncio 

7import json 

8from enum import Enum 

9from typing import Any, Dict, Optional, Union, Set 

10import sys 

11import httpx 

12from urllib.parse import unquote 

13 

14if sys.version_info >= (3, 10): 

15 from typing import TypeGuard 

16else: 

17 from typing_extensions import TypeGuard 

18 

19 

20class APIErrorCode(str, Enum): 

21 """Error codes returned in responses from the API.""" 

22 

23 Unauthorized = "unauthorized" 

24 """The bearer token is not valid.""" 

25 

26 RestrictedResource = "restricted_resource" 

27 """Given the bearer token used, the client doesn't have permission to 

28 perform this operation.""" 

29 

30 ObjectNotFound = "object_not_found" 

31 """Given the bearer token used, the resource does not exist. 

32 This error can also indicate that the resource has not been shared with owner 

33 of the bearer token.""" 

34 

35 RateLimited = "rate_limited" 

36 """This request exceeds the number of requests allowed. Slow down and try again.""" 

37 

38 InvalidJSON = "invalid_json" 

39 """The request body could not be decoded as JSON.""" 

40 

41 InvalidRequestURL = "invalid_request_url" 

42 """The request URL is not valid.""" 

43 

44 InvalidRequest = "invalid_request" 

45 """This request is not supported.""" 

46 

47 ValidationError = "validation_error" 

48 """The request body does not match the schema for the expected parameters.""" 

49 

50 ConflictError = "conflict_error" 

51 """The transaction could not be completed, potentially due to a data collision. 

52 Make sure the parameters are up to date and try again.""" 

53 

54 InternalServerError = "internal_server_error" 

55 """An unexpected error occurred. Reach out to Notion support.""" 

56 

57 ServiceUnavailable = "service_unavailable" 

58 """Notion is unavailable. Try again later. 

59 This can occur when the time to respond to a request takes longer than 60 seconds, 

60 the maximum request timeout.""" 

61 

62 

63class ClientErrorCode(str, Enum): 

64 """Error codes generated for client errors.""" 

65 

66 RequestTimeout = "notionhq_client_request_timeout" 

67 ResponseError = "notionhq_client_response_error" 

68 InvalidPathParameter = "notionhq_client_invalid_path_parameter" 

69 

70 

71# Error codes on errors thrown by the `Client`. 

72NotionErrorCode = Union[APIErrorCode, ClientErrorCode] 

73 

74 

75class NotionClientErrorBase(Exception): 

76 """Base error type for all Notion client errors.""" 

77 

78 code: Union[str, NotionErrorCode] 

79 

80 def __init__(self, message: str = "") -> None: 

81 super().__init__(message) 

82 

83 

84def is_notion_client_error(error: Any) -> TypeGuard["NotionClientError"]: 

85 return isinstance(error, NotionClientErrorBase) 

86 

87 

88def _is_notion_client_error_with_code(error: Any, codes: Set[str]) -> bool: 

89 if not is_notion_client_error(error): 

90 return False 

91 error_code = error.code 

92 if isinstance(error_code, Enum): 

93 error_code = error_code.value 

94 

95 return error_code in codes 

96 

97 

98class RequestTimeoutError(NotionClientErrorBase): 

99 """Error thrown by the client if a request times out.""" 

100 

101 code: ClientErrorCode = ClientErrorCode.RequestTimeout 

102 

103 def __init__(self, message: str = "Request to Notion API has timed out") -> None: 

104 super().__init__(message) 

105 

106 @staticmethod 

107 def is_request_timeout_error(error: Any) -> bool: 

108 return _is_notion_client_error_with_code( 

109 error, 

110 {ClientErrorCode.RequestTimeout.value}, 

111 ) 

112 

113 @staticmethod 

114 async def reject_after_timeout(coro: Any, timeout_ms: int) -> Any: 

115 try: 

116 return await asyncio.wait_for(coro, timeout=timeout_ms / 1000.0) 

117 except asyncio.TimeoutError: 

118 raise RequestTimeoutError() 

119 

120 

121class InvalidPathParameterError(NotionClientErrorBase): 

122 """Error thrown when a path parameter contains invalid characters such as 

123 path traversal sequences (..) that could alter the intended API endpoint.""" 

124 

125 code: ClientErrorCode = ClientErrorCode.InvalidPathParameter 

126 

127 def __init__( 

128 self, 

129 message: str = ( 

130 "Path parameter contains invalid characters that could alter the request path" 

131 ), 

132 ) -> None: 

133 super().__init__(message) 

134 

135 @staticmethod 

136 def is_invalid_path_parameter_error(error: object) -> bool: 

137 return _is_notion_client_error_with_code( 

138 error, 

139 {ClientErrorCode.InvalidPathParameter.value}, 

140 ) 

141 

142 

143def validate_request_path(path: str) -> None: 

144 """Validates that a request path does not contain path traversal sequences. 

145 Raises InvalidPathParameterError if the path contains ".." segments, 

146 including URL-encoded variants like %2e%2e.""" 

147 

148 # Check for literal path traversal 

149 if ".." in path: 

150 raise InvalidPathParameterError( 

151 f'Request path "{path}" contains path traversal sequence ".."' 

152 ) 

153 

154 # Check for URL-encoded path traversal (%2e = '.') 

155 # Only decode if path contains potential encoded dots 

156 if "%2e" in path.lower(): 

157 decoded = unquote(path) 

158 if ".." in decoded: 

159 raise InvalidPathParameterError( 

160 f'Request path "{path}" contains encoded path traversal sequence' 

161 ) 

162 

163 

164HTTPResponseErrorCode = Union[ClientErrorCode, APIErrorCode] 

165 

166 

167class HTTPResponseError(NotionClientErrorBase): 

168 code: Union[str, APIErrorCode] 

169 status: int 

170 headers: httpx.Headers 

171 body: str 

172 additional_data: Optional[Dict[str, Any]] 

173 request_id: Optional[str] 

174 

175 def __init__( 

176 self, 

177 code: Union[str, APIErrorCode], 

178 status: int, 

179 message: str, 

180 headers: httpx.Headers, 

181 raw_body_text: str, 

182 additional_data: Optional[Dict[str, Any]] = None, 

183 request_id: Optional[str] = None, 

184 ) -> None: 

185 super().__init__(message) 

186 self.code = code 

187 self.status = status 

188 self.headers = headers 

189 self.body = raw_body_text 

190 self.additional_data = additional_data 

191 self.request_id = request_id 

192 

193 

194_http_response_error_codes: Set[str] = { 

195 ClientErrorCode.ResponseError.value, 

196 APIErrorCode.Unauthorized.value, 

197 APIErrorCode.RestrictedResource.value, 

198 APIErrorCode.ObjectNotFound.value, 

199 APIErrorCode.RateLimited.value, 

200 APIErrorCode.InvalidJSON.value, 

201 APIErrorCode.InvalidRequestURL.value, 

202 APIErrorCode.InvalidRequest.value, 

203 APIErrorCode.ValidationError.value, 

204 APIErrorCode.ConflictError.value, 

205 APIErrorCode.InternalServerError.value, 

206 APIErrorCode.ServiceUnavailable.value, 

207} 

208 

209 

210def is_http_response_error(error: Any) -> TypeGuard[HTTPResponseError]: 

211 return _is_notion_client_error_with_code(error, _http_response_error_codes) 

212 

213 

214class UnknownHTTPResponseError(HTTPResponseError): 

215 """Error thrown if an API call responds with an unknown error code, or does not respond with 

216 a properly-formatted error. 

217 """ 

218 

219 def __init__( 

220 self, 

221 status: int, 

222 message: Optional[str] = None, 

223 headers: Optional[httpx.Headers] = None, 

224 raw_body_text: str = "", 

225 ) -> None: 

226 if message is None: 

227 message = f"Request to Notion API failed with status: {status}" 

228 if headers is None: 

229 headers = httpx.Headers() 

230 

231 super().__init__( 

232 code=ClientErrorCode.ResponseError.value, 

233 status=status, 

234 message=message, 

235 headers=headers, 

236 raw_body_text=raw_body_text, 

237 additional_data=None, 

238 request_id=None, 

239 ) 

240 

241 @staticmethod 

242 def is_unknown_http_response_error(error: Any) -> bool: 

243 return _is_notion_client_error_with_code( 

244 error, 

245 {ClientErrorCode.ResponseError.value}, 

246 ) 

247 

248 

249_api_error_codes: Set[str] = { 

250 APIErrorCode.Unauthorized.value, 

251 APIErrorCode.RestrictedResource.value, 

252 APIErrorCode.ObjectNotFound.value, 

253 APIErrorCode.RateLimited.value, 

254 APIErrorCode.InvalidJSON.value, 

255 APIErrorCode.InvalidRequestURL.value, 

256 APIErrorCode.InvalidRequest.value, 

257 APIErrorCode.ValidationError.value, 

258 APIErrorCode.ConflictError.value, 

259 APIErrorCode.InternalServerError.value, 

260 APIErrorCode.ServiceUnavailable.value, 

261} 

262 

263 

264class APIResponseError(HTTPResponseError): 

265 code: APIErrorCode 

266 request_id: Optional[str] 

267 

268 @staticmethod 

269 def is_api_response_error(error: Any) -> TypeGuard["APIResponseError"]: 

270 return _is_notion_client_error_with_code(error, _api_error_codes) 

271 

272 

273# Type alias for all Notion client errors 

274NotionClientError = Union[ 

275 RequestTimeoutError, 

276 UnknownHTTPResponseError, 

277 APIResponseError, 

278 InvalidPathParameterError, 

279] 

280 

281 

282def build_request_error( 

283 response: httpx.Response, 

284 body_text: str, 

285) -> Union[APIResponseError, UnknownHTTPResponseError]: 

286 api_error_response_body = _parse_api_error_response_body(body_text) 

287 

288 if api_error_response_body is not None: 

289 return APIResponseError( 

290 code=api_error_response_body["code"], 

291 message=api_error_response_body["message"], 

292 headers=response.headers, 

293 status=response.status_code, 

294 raw_body_text=body_text, 

295 additional_data=api_error_response_body.get("additional_data"), 

296 request_id=api_error_response_body.get("request_id"), 

297 ) 

298 

299 return UnknownHTTPResponseError( 

300 message=None, 

301 headers=response.headers, 

302 status=response.status_code, 

303 raw_body_text=body_text, 

304 ) 

305 

306 

307def _parse_api_error_response_body(body: str) -> Optional[Dict[str, Any]]: 

308 if not isinstance(body, str): 

309 return None 

310 

311 try: 

312 parsed = json.loads(body) 

313 except (json.JSONDecodeError, ValueError): 

314 return None 

315 

316 if not isinstance(parsed, dict): 

317 return None 

318 

319 message = parsed.get("message") 

320 code = parsed.get("code") 

321 

322 if not isinstance(message, str) or not _is_api_error_code(code): 

323 return None 

324 

325 result: Dict[str, Any] = { 

326 "code": APIErrorCode(code), 

327 "message": message, 

328 } 

329 

330 if "additional_data" in parsed: 

331 result["additional_data"] = parsed["additional_data"] 

332 

333 if "request_id" in parsed: 

334 result["request_id"] = parsed["request_id"] 

335 

336 return result 

337 

338 

339def _is_api_error_code(code: Any) -> bool: 

340 return isinstance(code, str) and code in _api_error_codes