Coverage for notion_client / errors.py: 100%

128 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-02 12:05 +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 

10 

11import httpx 

12from urllib.parse import unquote 

13 

14 

15class APIErrorCode(str, Enum): 

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

17 

18 Unauthorized = "unauthorized" 

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

20 

21 RestrictedResource = "restricted_resource" 

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

23 perform this operation.""" 

24 

25 ObjectNotFound = "object_not_found" 

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

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

28 of the bearer token.""" 

29 

30 RateLimited = "rate_limited" 

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

32 

33 InvalidJSON = "invalid_json" 

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

35 

36 InvalidRequestURL = "invalid_request_url" 

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

38 

39 InvalidRequest = "invalid_request" 

40 """This request is not supported.""" 

41 

42 ValidationError = "validation_error" 

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

44 

45 ConflictError = "conflict_error" 

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

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

48 

49 InternalServerError = "internal_server_error" 

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

51 

52 ServiceUnavailable = "service_unavailable" 

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

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

55 the maximum request timeout.""" 

56 

57 

58class ClientErrorCode(str, Enum): 

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

60 

61 RequestTimeout = "notionhq_client_request_timeout" 

62 ResponseError = "notionhq_client_response_error" 

63 InvalidPathParameter = "notionhq_client_invalid_path_parameter" 

64 

65 

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

67NotionErrorCode = Union[APIErrorCode, ClientErrorCode] 

68 

69 

70class NotionClientErrorBase(Exception): 

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

72 

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

74 super().__init__(message) 

75 

76 

77def is_notion_client_error(error: Any) -> bool: 

78 return isinstance(error, NotionClientErrorBase) 

79 

80 

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

82 if not is_notion_client_error(error): 

83 return False 

84 error_code = error.code 

85 if isinstance(error_code, Enum): 

86 error_code = error_code.value 

87 

88 return error_code in codes 

89 

90 

91class RequestTimeoutError(NotionClientErrorBase): 

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

93 

94 code: ClientErrorCode = ClientErrorCode.RequestTimeout 

95 

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

97 super().__init__(message) 

98 

99 @staticmethod 

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

101 return _is_notion_client_error_with_code( 

102 error, 

103 {ClientErrorCode.RequestTimeout.value}, 

104 ) 

105 

106 @staticmethod 

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

108 try: 

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

110 except asyncio.TimeoutError: 

111 raise RequestTimeoutError() 

112 

113 

114class InvalidPathParameterError(NotionClientErrorBase): 

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

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

117 

118 code: ClientErrorCode = ClientErrorCode.InvalidPathParameter 

119 

120 def __init__( 

121 self, 

122 message: str = ( 

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

124 ), 

125 ) -> None: 

126 super().__init__(message) 

127 

128 @staticmethod 

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

130 return _is_notion_client_error_with_code( 

131 error, 

132 {ClientErrorCode.InvalidPathParameter.value}, 

133 ) 

134 

135 

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

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

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

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

140 

141 # Check for literal path traversal 

142 if ".." in path: 

143 raise InvalidPathParameterError( 

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

145 ) 

146 

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

148 # Only decode if path contains potential encoded dots 

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

150 decoded = unquote(path) 

151 if ".." in decoded: 

152 raise InvalidPathParameterError( 

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

154 ) 

155 

156 

157HTTPResponseErrorCode = Union[ClientErrorCode, APIErrorCode] 

158 

159 

160class HTTPResponseError(NotionClientErrorBase): 

161 code: Union[str, APIErrorCode] 

162 status: int 

163 headers: httpx.Headers 

164 body: str 

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

166 request_id: Optional[str] 

167 

168 def __init__( 

169 self, 

170 code: Union[str, APIErrorCode], 

171 status: int, 

172 message: str, 

173 headers: httpx.Headers, 

174 raw_body_text: str, 

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

176 request_id: Optional[str] = None, 

177 ) -> None: 

178 super().__init__(message) 

179 self.code = code 

180 self.status = status 

181 self.headers = headers 

182 self.body = raw_body_text 

183 self.additional_data = additional_data 

184 self.request_id = request_id 

185 

186 

187_http_response_error_codes: Set[str] = { 

188 ClientErrorCode.ResponseError.value, 

189 APIErrorCode.Unauthorized.value, 

190 APIErrorCode.RestrictedResource.value, 

191 APIErrorCode.ObjectNotFound.value, 

192 APIErrorCode.RateLimited.value, 

193 APIErrorCode.InvalidJSON.value, 

194 APIErrorCode.InvalidRequestURL.value, 

195 APIErrorCode.InvalidRequest.value, 

196 APIErrorCode.ValidationError.value, 

197 APIErrorCode.ConflictError.value, 

198 APIErrorCode.InternalServerError.value, 

199 APIErrorCode.ServiceUnavailable.value, 

200} 

201 

202 

203def is_http_response_error(error: Any) -> bool: 

204 return _is_notion_client_error_with_code(error, _http_response_error_codes) 

205 

206 

207class UnknownHTTPResponseError(HTTPResponseError): 

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

209 a properly-formatted error. 

210 """ 

211 

212 def __init__( 

213 self, 

214 status: int, 

215 message: Optional[str] = None, 

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

217 raw_body_text: str = "", 

218 ) -> None: 

219 if message is None: 

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

221 if headers is None: 

222 headers = httpx.Headers() 

223 

224 super().__init__( 

225 code=ClientErrorCode.ResponseError.value, 

226 status=status, 

227 message=message, 

228 headers=headers, 

229 raw_body_text=raw_body_text, 

230 additional_data=None, 

231 request_id=None, 

232 ) 

233 

234 @staticmethod 

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

236 return _is_notion_client_error_with_code( 

237 error, 

238 {ClientErrorCode.ResponseError.value}, 

239 ) 

240 

241 

242_api_error_codes: Set[str] = { 

243 APIErrorCode.Unauthorized.value, 

244 APIErrorCode.RestrictedResource.value, 

245 APIErrorCode.ObjectNotFound.value, 

246 APIErrorCode.RateLimited.value, 

247 APIErrorCode.InvalidJSON.value, 

248 APIErrorCode.InvalidRequestURL.value, 

249 APIErrorCode.InvalidRequest.value, 

250 APIErrorCode.ValidationError.value, 

251 APIErrorCode.ConflictError.value, 

252 APIErrorCode.InternalServerError.value, 

253 APIErrorCode.ServiceUnavailable.value, 

254} 

255 

256 

257class APIResponseError(HTTPResponseError): 

258 code: APIErrorCode 

259 request_id: Optional[str] 

260 

261 @staticmethod 

262 def is_api_response_error(error: Any) -> bool: 

263 return _is_notion_client_error_with_code(error, _api_error_codes) 

264 

265 

266# Type alias for all Notion client errors 

267NotionClientError = Union[ 

268 RequestTimeoutError, 

269 UnknownHTTPResponseError, 

270 APIResponseError, 

271 InvalidPathParameterError, 

272] 

273 

274 

275def build_request_error( 

276 response: httpx.Response, 

277 body_text: str, 

278) -> Union[APIResponseError, UnknownHTTPResponseError]: 

279 api_error_response_body = _parse_api_error_response_body(body_text) 

280 

281 if api_error_response_body is not None: 

282 return APIResponseError( 

283 code=api_error_response_body["code"], 

284 message=api_error_response_body["message"], 

285 headers=response.headers, 

286 status=response.status_code, 

287 raw_body_text=body_text, 

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

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

290 ) 

291 

292 return UnknownHTTPResponseError( 

293 message=None, 

294 headers=response.headers, 

295 status=response.status_code, 

296 raw_body_text=body_text, 

297 ) 

298 

299 

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

301 if not isinstance(body, str): 

302 return None 

303 

304 try: 

305 parsed = json.loads(body) 

306 except (json.JSONDecodeError, ValueError): 

307 return None 

308 

309 if not isinstance(parsed, dict): 

310 return None 

311 

312 message = parsed.get("message") 

313 code = parsed.get("code") 

314 

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

316 return None 

317 

318 result: Dict[str, Any] = { 

319 "code": APIErrorCode(code), 

320 "message": message, 

321 } 

322 

323 if "additional_data" in parsed: 

324 result["additional_data"] = parsed["additional_data"] 

325 

326 if "request_id" in parsed: 

327 result["request_id"] = parsed["request_id"] 

328 

329 return result 

330 

331 

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

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