Coverage for notion_client / errors.py: 99%

134 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-02 18:32 +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 GatewayTimeout = "gateway_timeout" 

63 """The request timed out on the server side.""" 

64 

65 

66class ClientErrorCode(str, Enum): 

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

68 

69 RequestTimeout = "notionhq_client_request_timeout" 

70 ResponseError = "notionhq_client_response_error" 

71 InvalidPathParameter = "notionhq_client_invalid_path_parameter" 

72 

73 

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

75NotionErrorCode = Union[APIErrorCode, ClientErrorCode] 

76 

77 

78class NotionClientErrorBase(Exception): 

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

80 

81 code: Union[str, NotionErrorCode] 

82 

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

84 super().__init__(message) 

85 

86 

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

88 return isinstance(error, NotionClientErrorBase) 

89 

90 

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

92 if not is_notion_client_error(error): 

93 return False 

94 error_code = error.code 

95 if isinstance(error_code, Enum): 

96 error_code = error_code.value 

97 

98 return error_code in codes 

99 

100 

101class RequestTimeoutError(NotionClientErrorBase): 

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

103 

104 code: ClientErrorCode = ClientErrorCode.RequestTimeout 

105 

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

107 super().__init__(message) 

108 

109 @staticmethod 

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

111 return _is_notion_client_error_with_code( 

112 error, 

113 {ClientErrorCode.RequestTimeout.value}, 

114 ) 

115 

116 @staticmethod 

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

118 try: 

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

120 except asyncio.TimeoutError: 

121 raise RequestTimeoutError() 

122 

123 

124class InvalidPathParameterError(NotionClientErrorBase): 

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

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

127 

128 code: ClientErrorCode = ClientErrorCode.InvalidPathParameter 

129 

130 def __init__( 

131 self, 

132 message: str = ( 

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

134 ), 

135 ) -> None: 

136 super().__init__(message) 

137 

138 @staticmethod 

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

140 return _is_notion_client_error_with_code( 

141 error, 

142 {ClientErrorCode.InvalidPathParameter.value}, 

143 ) 

144 

145 

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

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

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

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

150 

151 # Check for literal path traversal 

152 if ".." in path: 

153 raise InvalidPathParameterError( 

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

155 ) 

156 

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

158 # Only decode if path contains potential encoded dots 

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

160 decoded = unquote(path) 

161 if ".." in decoded: 

162 raise InvalidPathParameterError( 

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

164 ) 

165 

166 

167HTTPResponseErrorCode = Union[ClientErrorCode, APIErrorCode] 

168 

169 

170class HTTPResponseError(NotionClientErrorBase): 

171 code: Union[str, APIErrorCode] 

172 status: int 

173 headers: httpx.Headers 

174 body: str 

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

176 request_id: Optional[str] 

177 

178 def __init__( 

179 self, 

180 code: Union[str, APIErrorCode], 

181 status: int, 

182 message: str, 

183 headers: httpx.Headers, 

184 raw_body_text: str, 

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

186 request_id: Optional[str] = None, 

187 ) -> None: 

188 super().__init__(message) 

189 self.code = code 

190 self.status = status 

191 self.headers = headers 

192 self.body = raw_body_text 

193 self.additional_data = additional_data 

194 self.request_id = request_id 

195 

196 

197_http_response_error_codes: Set[str] = { 

198 ClientErrorCode.ResponseError.value, 

199 APIErrorCode.Unauthorized.value, 

200 APIErrorCode.RestrictedResource.value, 

201 APIErrorCode.ObjectNotFound.value, 

202 APIErrorCode.RateLimited.value, 

203 APIErrorCode.InvalidJSON.value, 

204 APIErrorCode.InvalidRequestURL.value, 

205 APIErrorCode.InvalidRequest.value, 

206 APIErrorCode.ValidationError.value, 

207 APIErrorCode.ConflictError.value, 

208 APIErrorCode.InternalServerError.value, 

209 APIErrorCode.ServiceUnavailable.value, 

210 APIErrorCode.GatewayTimeout.value, 

211} 

212 

213 

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

215 return _is_notion_client_error_with_code(error, _http_response_error_codes) 

216 

217 

218class UnknownHTTPResponseError(HTTPResponseError): 

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

220 a properly-formatted error. 

221 """ 

222 

223 def __init__( 

224 self, 

225 status: int, 

226 message: Optional[str] = None, 

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

228 raw_body_text: str = "", 

229 ) -> None: 

230 if message is None: 

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

232 if headers is None: 

233 headers = httpx.Headers() 

234 

235 super().__init__( 

236 code=ClientErrorCode.ResponseError.value, 

237 status=status, 

238 message=message, 

239 headers=headers, 

240 raw_body_text=raw_body_text, 

241 additional_data=None, 

242 request_id=None, 

243 ) 

244 

245 @staticmethod 

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

247 return _is_notion_client_error_with_code( 

248 error, 

249 {ClientErrorCode.ResponseError.value}, 

250 ) 

251 

252 

253_api_error_codes: Set[str] = { 

254 APIErrorCode.Unauthorized.value, 

255 APIErrorCode.RestrictedResource.value, 

256 APIErrorCode.ObjectNotFound.value, 

257 APIErrorCode.RateLimited.value, 

258 APIErrorCode.InvalidJSON.value, 

259 APIErrorCode.InvalidRequestURL.value, 

260 APIErrorCode.InvalidRequest.value, 

261 APIErrorCode.ValidationError.value, 

262 APIErrorCode.ConflictError.value, 

263 APIErrorCode.InternalServerError.value, 

264 APIErrorCode.ServiceUnavailable.value, 

265 APIErrorCode.GatewayTimeout.value, 

266} 

267 

268 

269class APIResponseError(HTTPResponseError): 

270 code: APIErrorCode 

271 request_id: Optional[str] 

272 

273 @staticmethod 

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

275 return _is_notion_client_error_with_code(error, _api_error_codes) 

276 

277 

278# Type alias for all Notion client errors 

279NotionClientError = Union[ 

280 RequestTimeoutError, 

281 UnknownHTTPResponseError, 

282 APIResponseError, 

283 InvalidPathParameterError, 

284] 

285 

286 

287def build_request_error( 

288 response: httpx.Response, 

289 body_text: str, 

290) -> Union[APIResponseError, UnknownHTTPResponseError]: 

291 api_error_response_body = _parse_api_error_response_body(body_text) 

292 

293 if api_error_response_body is not None: 

294 return APIResponseError( 

295 code=api_error_response_body["code"], 

296 message=api_error_response_body["message"], 

297 headers=response.headers, 

298 status=response.status_code, 

299 raw_body_text=body_text, 

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

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

302 ) 

303 

304 return UnknownHTTPResponseError( 

305 message=None, 

306 headers=response.headers, 

307 status=response.status_code, 

308 raw_body_text=body_text, 

309 ) 

310 

311 

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

313 if not isinstance(body, str): 

314 return None 

315 

316 try: 

317 parsed = json.loads(body) 

318 except (json.JSONDecodeError, ValueError): 

319 return None 

320 

321 if not isinstance(parsed, dict): 

322 return None 

323 

324 message = parsed.get("message") 

325 code = parsed.get("code") 

326 

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

328 return None 

329 

330 result: Dict[str, Any] = { 

331 "code": APIErrorCode(code), 

332 "message": message, 

333 } 

334 

335 if "additional_data" in parsed: 

336 result["additional_data"] = parsed["additional_data"] 

337 

338 if "request_id" in parsed: 

339 result["request_id"] = parsed["request_id"] 

340 

341 return result 

342 

343 

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

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