Coverage for pyTooling/REST/__init__.py: 98%

237 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-25 13:06 +0000

1# ==================================================================================================================== # 

2# _____ _ _ ____ _____ ____ _____ # 

3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ | _ \| ____/ ___|_ _| # 

4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | |_) | _| \___ \ | | # 

5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| _ <| |___ ___) || | # 

6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)_| \_\_____|____/ |_| # 

7# |_| |___/ |___/ # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany # 

15# # 

16# Licensed under the Apache License, Version 2.0 (the "License"); # 

17# you may not use this file except in compliance with the License. # 

18# You may obtain a copy of the License at # 

19# # 

20# http://www.apache.org/licenses/LICENSE-2.0 # 

21# # 

22# Unless required by applicable law or agreed to in writing, software # 

23# distributed under the License is distributed on an "AS IS" BASIS, # 

24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

25# See the License for the specific language governing permissions and # 

26# limitations under the License. # 

27# # 

28# SPDX-License-Identifier: Apache-2.0 # 

29# ==================================================================================================================== # 

30# 

31""" 

32A small client for JSON REST APIs, built on the standard library. 

33 

34Every REST API is read the same way: a request carrying a bearer token, an answer that has to be a JSON object, a 

35transient failure that is worth another attempt, and a collection that arrives one page at a time. None of that is 

36knowledge about a particular service, so it lives here rather than in each reader. 

37 

38.. hint:: 

39 

40 See :ref:`high-level help <REST>` for explanations and usage examples. 

41 

42.. seealso:: 

43 

44 `pyTooling.GitHub <https://pyTooling.github.io/pyTooling.GitHub/>`__ 

45 |rarr| A data model read from a REST API through this client. 

46""" 

47from http import HTTPMethod 

48from json import dumps as json_dumps, loads as json_loads 

49from re import compile as re_compile 

50from time import sleep 

51from typing import Any, Optional as Nullable, Union 

52from urllib.error import HTTPError, URLError 

53from urllib.request import Request, urlopen 

54 

55from pyTooling.Common import getFullyQualifiedName, StringEnum 

56from pyTooling.Decorators import export, readonly 

57from pyTooling.Exceptions import ToolingException 

58from pyTooling.GenericPath.URL import URL, URLError as URLParseError 

59from pyTooling.MetaClasses import ExtendedType 

60 

61 

62__all__ = ["JSONObject"] 

63 

64JSONObject = dict[str, Any] 

65"""A JSON object, as :func:`json.loads` returns it.""" 

66 

67TRANSIENT_HTTP_STATUS = (429, 500, 502, 503, 504) 

68"""HTTP status codes of a transient failure, after which a request is tried again.""" 

69 

70MAXIMUM_RETRY_AFTER = 60.0 

71"""The longest pause in seconds a ``Retry-After`` header can demand before a request is tried again.""" 

72 

73_NEXT_LINK = re_compile(r'<([^>]+)>;\s*rel="next"') 

74"""Pattern extracting the URL of the next page from a :rfc:`8288` ``Link`` header.""" 

75 

76 

77@export 

78class MediaType(StringEnum): 

79 """ 

80 Media types a REST API sends and receives, as :rfc:`9110` calls them. 

81 

82 A member is its own media type, so it is written where a header's value is expected. 

83 """ 

84 

85 JSON = "application/json" #: A JSON document. 

86 PlainText = "text/plain" #: Plain text. 

87 HTML = "text/html" #: An HTML document. 

88 Binary = "application/octet-stream" #: Bytes of an unnamed type. 

89 

90 def Matches(self, contentType: str) -> bool: 

91 """ 

92 Check whether a ``Content-Type`` header names this media type. 

93 

94 The header's parameters are ignored, the name is compared case-insensitively - :rfc:`9110` writes a media type 

95 that way - and the structured syntax suffix of :rfc:`6839` counts, so ``application/vnd.github+json`` is 

96 ``application/json``. 

97 

98 :param contentType: The value of a ``Content-Type`` header. 

99 :returns: ``True``, if the header names this media type. 

100 :raises ValueError: If parameter 'contentType' is ``None``. 

101 :raises TypeError: If parameter 'contentType' is not of type :class:`str`. 

102 """ 

103 if contentType is None: 

104 raise ValueError("Parameter 'contentType' is None.") 

105 elif not isinstance(contentType, str): 

106 ex = TypeError("Parameter 'contentType' is not of type 'str'.") 

107 ex.add_note(f"Got type '{getFullyQualifiedName(contentType)}'.") 

108 raise ex 

109 

110 mediaType = contentType.split(";", 1)[0].strip().lower() 

111 

112 return mediaType == self or mediaType.endswith(f"+{self.partition('/')[2]}") 

113 

114 

115@export 

116class RESTError(ToolingException): 

117 """A request to a REST API failed, or its answer wasn't the JSON the caller asked for.""" 

118 

119 

120@export 

121class RESTClient(metaclass=ExtendedType, slots=True): 

122 """ 

123 Reads and writes JSON resources of a REST API. 

124 

125 The requests use only the standard library, so a package building on this client doesn't drag an HTTP stack into 

126 every consumer. A resource is addressed by its path below :attr:`APIURL`, not by a URL, so a client says 

127 ``repos/owner/name`` and the API it belongs to is stated once. 

128 

129 A token, when given, is sent as a bearer token - and only to the API it was given for, which is why a paginated 

130 answer pointing outside that API is rejected. An API authorizing differently overrides :meth:`_RequestHeaders`, 

131 which is where the header is built. 

132 

133 A request failing transiently - a status in :data:`TRANSIENT_HTTP_STATUS`, a timeout, or an unreachable API - is 

134 tried again after an exponentially growing pause, or one as long as a ``Retry-After`` header demands. A request 

135 failing with any other HTTP status, like 401, 403 or 404, isn't tried again, because another attempt can't 

136 succeed. **A request that isn't idempotent is never tried again**: repeating a ``POST`` that the API did carry 

137 out, but whose answer was lost, creates the resource twice. 

138 

139 An instance holds no state beyond what it was constructed with, and every request builds its own headers, so one 

140 client can serve several threads. The pause before a retry blocks only the thread waiting for that answer. 

141 """ 

142 _apiURL: URL #: Base URL of the REST API, without a trailing slash. 

143 _token: Nullable[str] #: Token authorizing the requests, or ``None`` for anonymous requests. 

144 _headers: dict[str, str] #: Headers sent with every request, beside the authorization. 

145 _timeout: float #: Timeout of a single request in seconds. 

146 _retries: int #: How often a transiently failing idempotent request is tried again. 

147 _retryDelay: float #: Pause in seconds before a request is tried again the first time. 

148 

149 def __init__( 

150 self, 

151 apiURL: Union[str, URL], 

152 token: Nullable[str] = None, 

153 *, 

154 headers: Nullable[dict[str, str]] = None, 

155 timeout: float = 30.0, 

156 retries: int = 3, 

157 retryDelay: float = 2.0 

158 ) -> None: 

159 """ 

160 Initializes a client for one REST API. 

161 

162 :param apiURL: Base URL of the REST API, as a string to parse or as a :class:`~pyTooling.GenericPath.URL.URL`. 

163 :param token: Optional, token authorizing the requests. Default: anonymous requests. 

164 :param headers: Optional, headers sent with every request. Default: no headers beside the authorization. 

165 :param timeout: Optional, timeout of a single request in seconds. Default: ``30.0``. 

166 :param retries: Optional, how often a transiently failing idempotent request is tried again. ``0`` tries 

167 once. Default: ``3``. 

168 :param retryDelay: Optional, pause in seconds before a request is tried again the first time. The pause doubles 

169 with every further attempt. Default: ``2.0``. 

170 :raises ValueError: If parameter 'apiURL' is ``None``. 

171 :raises TypeError: If parameter 'apiURL' is neither of type :class:`str` nor of type 

172 :class:`~pyTooling.GenericPath.URL.URL`. 

173 :raises ValueError: If parameter 'apiURL' is a string :meth:`URL.Parse <pyTooling.GenericPath.URL.URL.Parse>` 

174 rejects. 

175 :raises ValueError: If parameter 'apiURL' names no scheme or no host. 

176 :raises ValueError: If parameter 'apiURL' ends in an empty path element, e.g. ``'https://example.org/api//'``. 

177 :raises TypeError: If parameter 'token' is not of type :class:`str`. 

178 :raises TypeError: If parameter 'headers' is not of type :class:`dict`. 

179 :raises ValueError: If parameter 'timeout' is ``None``. 

180 :raises TypeError: If parameter 'timeout' is not a number. 

181 :raises ValueError: If parameter 'timeout' isn't positive. 

182 :raises ValueError: If parameter 'retries' is ``None``. 

183 :raises TypeError: If parameter 'retries' is not of type :class:`int`. 

184 :raises ValueError: If parameter 'retries' is negative. 

185 :raises ValueError: If parameter 'retryDelay' is ``None``. 

186 :raises TypeError: If parameter 'retryDelay' is not a number. 

187 :raises ValueError: If parameter 'retryDelay' is negative. 

188 """ 

189 if apiURL is None: 

190 raise ValueError("Parameter 'apiURL' is None.") 

191 elif isinstance(apiURL, str): 

192 try: 

193 parsedURL = URL.Parse(apiURL).WithoutTrailingSlash() 

194 except URLParseError as cause: 

195 ex = ValueError("Parameter 'apiURL' is not a URL.") 

196 ex.add_note(f"Got value '{apiURL}'.") 

197 raise ex from cause 

198 elif isinstance(apiURL, URL): 

199 parsedURL = apiURL.WithoutTrailingSlash() 

200 else: 

201 ex = TypeError("Parameter 'apiURL' is neither of type 'str' nor of type 'URL'.") 

202 ex.add_note(f"Got type '{getFullyQualifiedName(apiURL)}'.") 

203 raise ex 

204 

205 if parsedURL.Scheme is None or parsedURL.Host is None: 

206 ex = ValueError("Parameter 'apiURL' names no scheme or no host.") 

207 ex.add_note(f"Got value '{apiURL}'.") 

208 raise ex 

209 

210 # One trailing slash is what a base URL is usually written with, and it was just removed. A path still ending 

211 # in one named an empty element, which every request would carry as a double slash. 

212 if str(parsedURL.Path).endswith("/"): 

213 ex = ValueError("Parameter 'apiURL' ends in an empty path element.") 

214 ex.add_note(f"Got value '{apiURL}'.") 

215 raise ex 

216 

217 if token is not None and not isinstance(token, str): 

218 ex = TypeError("Parameter 'token' is not of type 'str'.") 

219 ex.add_note(f"Got type '{getFullyQualifiedName(token)}'.") 

220 raise ex 

221 

222 if headers is not None and not isinstance(headers, dict): 

223 ex = TypeError("Parameter 'headers' is not of type 'dict'.") 

224 ex.add_note(f"Got type '{getFullyQualifiedName(headers)}'.") 

225 raise ex 

226 

227 if timeout is None: 

228 raise ValueError("Parameter 'timeout' is None.") 

229 elif isinstance(timeout, bool) or not isinstance(timeout, (int, float)): 

230 ex = TypeError("Parameter 'timeout' is not a number.") 

231 ex.add_note(f"Got type '{getFullyQualifiedName(timeout)}'.") 

232 raise ex 

233 elif timeout <= 0: 

234 ex = ValueError("Parameter 'timeout' isn't positive.") 

235 ex.add_note(f"Got value '{timeout}'.") 

236 raise ex 

237 

238 if retries is None: 

239 raise ValueError("Parameter 'retries' is None.") 

240 elif isinstance(retries, bool) or not isinstance(retries, int): 

241 ex = TypeError("Parameter 'retries' is not of type 'int'.") 

242 ex.add_note(f"Got type '{getFullyQualifiedName(retries)}'.") 

243 raise ex 

244 elif retries < 0: 

245 ex = ValueError("Parameter 'retries' is negative.") 

246 ex.add_note(f"Got value '{retries}'.") 

247 raise ex 

248 

249 if retryDelay is None: 

250 raise ValueError("Parameter 'retryDelay' is None.") 

251 elif isinstance(retryDelay, bool) or not isinstance(retryDelay, (int, float)): 

252 ex = TypeError("Parameter 'retryDelay' is not a number.") 

253 ex.add_note(f"Got type '{getFullyQualifiedName(retryDelay)}'.") 

254 raise ex 

255 elif retryDelay < 0: 

256 ex = ValueError("Parameter 'retryDelay' is negative.") 

257 ex.add_note(f"Got value '{retryDelay}'.") 

258 raise ex 

259 

260 self._apiURL = parsedURL 

261 self._token = token 

262 self._headers = {} if headers is None else dict(headers) 

263 self._timeout = float(timeout) 

264 self._retries = retries 

265 self._retryDelay = float(retryDelay) 

266 

267 @readonly 

268 def APIURL(self) -> URL: 

269 """ 

270 Read-only property to access the base URL of the REST API (:attr:`_apiURL`). 

271 

272 :returns: The base URL, without a trailing slash. 

273 """ 

274 return self._apiURL 

275 

276 @readonly 

277 def Headers(self) -> dict[str, str]: 

278 """ 

279 Read-only property to return the headers sent with every request (:attr:`_headers`). 

280 

281 :returns: A copy of the headers, so changing it doesn't change what the client sends. 

282 """ 

283 return dict(self._headers) 

284 

285 @readonly 

286 def Timeout(self) -> float: 

287 """ 

288 Read-only property to access the timeout of a single request (:attr:`_timeout`). 

289 

290 :returns: The timeout in seconds. 

291 """ 

292 return self._timeout 

293 

294 @readonly 

295 def Retries(self) -> int: 

296 """ 

297 Read-only property to access how often a transiently failing request is tried again (:attr:`_retries`). 

298 

299 :returns: The number of further attempts. 

300 """ 

301 return self._retries 

302 

303 @readonly 

304 def RetryDelay(self) -> float: 

305 """ 

306 Read-only property to access the pause before a request is tried again the first time (:attr:`_retryDelay`). 

307 

308 :returns: The pause in seconds. 

309 """ 

310 return self._retryDelay 

311 

312 def GetJSONObject( 

313 self, 

314 resourcePath: str, 

315 headers: Nullable[dict[str, str]] = None 

316 ) -> tuple[JSONObject, Nullable[str]]: 

317 """ 

318 Read a resource as a JSON object. 

319 

320 :param resourcePath: Path of the resource below :attr:`APIURL`, e.g. ``'repos/owner/name'``. 

321 :param headers: Optional, headers for this request, added to and overriding :attr:`Headers`. 

322 :returns: The JSON object, and the path of the next page, or ``None`` if the answer names none. 

323 :raises ValueError: If parameter 'resourcePath' is ``None``. 

324 :raises TypeError: If parameter 'resourcePath' is not of type :class:`str`. 

325 :raises TypeError: If parameter 'headers' is not of type :class:`dict`. 

326 :raises RESTError: If the request fails, or the answer isn't a JSON object. 

327 """ 

328 document, nextResourcePath = self._Request(HTTPMethod.GET, resourcePath, headers=headers) 

329 if document is None: 

330 raise RESTError(f"API answered with an empty body: {self._apiURL / resourcePath.lstrip('/')}") 

331 

332 return document, nextResourcePath 

333 

334 def PostJSONObject( 

335 self, 

336 resourcePath: str, 

337 document: JSONObject, 

338 headers: Nullable[dict[str, str]] = None 

339 ) -> Nullable[JSONObject]: 

340 """ 

341 Create a resource from a JSON object. 

342 

343 A ``POST`` isn't idempotent, so a transient failure isn't tried again - the API may have created the resource 

344 and lost only the answer. 

345 

346 :param resourcePath: Path of the collection below :attr:`APIURL`, e.g. ``'repos/owner/name/issues'``. 

347 :param document: The JSON object to send. 

348 :param headers: Optional, headers for this request, added to and overriding :attr:`Headers`. 

349 :returns: The answer's JSON object, or ``None`` if the API answered with no body. 

350 :raises ValueError: If parameter 'resourcePath' is ``None``. 

351 :raises TypeError: If parameter 'resourcePath' is not of type :class:`str`. 

352 :raises ValueError: If parameter 'document' is ``None``. 

353 :raises TypeError: If parameter 'document' is not of type :class:`dict`. 

354 :raises TypeError: If parameter 'headers' is not of type :class:`dict`. 

355 :raises RESTError: If the request fails, or the answer is neither empty nor a JSON object. 

356 """ 

357 if document is None: 

358 raise ValueError("Parameter 'document' is None.") 

359 

360 return self._Request(HTTPMethod.POST, resourcePath, document, headers, idempotent=False)[0] 

361 

362 def PutJSONObject( 

363 self, 

364 resourcePath: str, 

365 document: JSONObject, 

366 headers: Nullable[dict[str, str]] = None 

367 ) -> Nullable[JSONObject]: 

368 """ 

369 Replace a resource by a JSON object. 

370 

371 :param resourcePath: Path of the resource below :attr:`APIURL`, e.g. ``'repos/owner/name/issues/1'``. 

372 :param document: The JSON object to send. 

373 :param headers: Optional, headers for this request, added to and overriding :attr:`Headers`. 

374 :returns: The answer's JSON object, or ``None`` if the API answered with no body. 

375 :raises ValueError: If parameter 'resourcePath' is ``None``. 

376 :raises TypeError: If parameter 'resourcePath' is not of type :class:`str`. 

377 :raises ValueError: If parameter 'document' is ``None``. 

378 :raises TypeError: If parameter 'document' is not of type :class:`dict`. 

379 :raises TypeError: If parameter 'headers' is not of type :class:`dict`. 

380 :raises RESTError: If the request fails, or the answer is neither empty nor a JSON object. 

381 """ 

382 if document is None: 

383 raise ValueError("Parameter 'document' is None.") 

384 

385 return self._Request(HTTPMethod.PUT, resourcePath, document, headers)[0] 

386 

387 def PatchJSONObject( 

388 self, 

389 resourcePath: str, 

390 document: JSONObject, 

391 headers: Nullable[dict[str, str]] = None 

392 ) -> Nullable[JSONObject]: 

393 """ 

394 Alter a resource by a JSON object holding the fields to change. 

395 

396 :rfc:`9110` doesn't call ``PATCH`` idempotent - whether applying the same change twice is the same as applying 

397 it once depends on the change - so a transient failure isn't tried again. 

398 

399 :param resourcePath: Path of the resource below :attr:`APIURL`, e.g. ``'repos/owner/name/issues/1'``. 

400 :param document: The JSON object holding the fields to change. 

401 :param headers: Optional, headers for this request, added to and overriding :attr:`Headers`. 

402 :returns: The answer's JSON object, or ``None`` if the API answered with no body. 

403 :raises ValueError: If parameter 'resourcePath' is ``None``. 

404 :raises TypeError: If parameter 'resourcePath' is not of type :class:`str`. 

405 :raises ValueError: If parameter 'document' is ``None``. 

406 :raises TypeError: If parameter 'document' is not of type :class:`dict`. 

407 :raises TypeError: If parameter 'headers' is not of type :class:`dict`. 

408 :raises RESTError: If the request fails, or the answer is neither empty nor a JSON object. 

409 """ 

410 if document is None: 

411 raise ValueError("Parameter 'document' is None.") 

412 

413 return self._Request(HTTPMethod.PATCH, resourcePath, document, headers, idempotent=False)[0] 

414 

415 def DeleteResource(self, resourcePath: str, headers: Nullable[dict[str, str]] = None) -> Nullable[JSONObject]: 

416 """ 

417 Delete a resource. 

418 

419 :param resourcePath: Path of the resource below :attr:`APIURL`, e.g. ``'repos/owner/name/issues/1'``. 

420 :param headers: Optional, headers for this request, added to and overriding :attr:`Headers`. 

421 :returns: The answer's JSON object, or ``None`` if the API answered with no body, which is what a 

422 deletion usually answers with. 

423 :raises ValueError: If parameter 'resourcePath' is ``None``. 

424 :raises TypeError: If parameter 'resourcePath' is not of type :class:`str`. 

425 :raises TypeError: If parameter 'headers' is not of type :class:`dict`. 

426 :raises RESTError: If the request fails, or the answer is neither empty nor a JSON object. 

427 """ 

428 return self._Request(HTTPMethod.DELETE, resourcePath, headers=headers)[0] 

429 

430 def _Request( 

431 self, 

432 method: HTTPMethod, 

433 resourcePath: str, 

434 document: Nullable[JSONObject] = None, 

435 headers: Nullable[dict[str, str]] = None, 

436 idempotent: bool = True 

437 ) -> tuple[Nullable[JSONObject], Nullable[str]]: 

438 """ 

439 Send one request to the REST API and read its answer. 

440 

441 :param method: The HTTP method. 

442 :param resourcePath: Path of the resource below :attr:`APIURL`. 

443 :param document: Optional, the JSON object to send as the request's body. Default: no body. 

444 :param headers: Optional, headers for this request, added to and overriding :attr:`Headers`. 

445 :param idempotent: Optional, ``True``, if repeating the request has the same effect as sending it once, which 

446 is what makes trying it again safe. Default: ``True``. 

447 :returns: The answer's JSON object or ``None`` if it had no body, and the path of the next page or 

448 ``None`` if the answer names none. 

449 :raises ValueError: If parameter 'resourcePath' is ``None``. 

450 :raises TypeError: If parameter 'resourcePath' is not of type :class:`str`. 

451 :raises TypeError: If parameter 'document' is not of type :class:`dict`. 

452 :raises TypeError: If parameter 'headers' is not of type :class:`dict`. 

453 :raises RESTError: If the request fails with an HTTP error, or the API can't be reached. |br| 

454 The notes report what the API said and how often the request was tried. 

455 :raises RESTError: If the answer isn't JSON, isn't valid JSON, or isn't a JSON object. 

456 :raises RESTError: If the next page's URL doesn't belong to this API. |br| 

457 The note says that the token is only sent to the API itself. 

458 """ 

459 if resourcePath is None: 

460 raise ValueError("Parameter 'resourcePath' is None.") 

461 elif not isinstance(resourcePath, str): 

462 ex = TypeError("Parameter 'resourcePath' is not of type 'str'.") 

463 ex.add_note(f"Got type '{getFullyQualifiedName(resourcePath)}'.") 

464 raise ex 

465 

466 if document is not None and not isinstance(document, dict): 

467 ex = TypeError("Parameter 'document' is not of type 'dict'.") 

468 ex.add_note(f"Got type '{getFullyQualifiedName(document)}'.") 

469 raise ex 

470 

471 if headers is not None and not isinstance(headers, dict): 

472 ex = TypeError("Parameter 'headers' is not of type 'dict'.") 

473 ex.add_note(f"Got type '{getFullyQualifiedName(headers)}'.") 

474 raise ex 

475 

476 if document is None: 

477 data = None 

478 mediaType = None 

479 else: 

480 data = json_dumps(document).encode("utf-8") 

481 mediaType = MediaType.JSON 

482 

483 # A resource path is always **below** the API, so a leading delimiter is removed - :meth:`URL.__truediv__ 

484 # <pyTooling.GenericPath.URL.URL.__truediv__>` would otherwise read it as naming its own root. 

485 url = self._apiURL / resourcePath.lstrip("/") 

486 request = Request(str(url), data=data, method=method, headers=self._RequestHeaders(mediaType, headers)) 

487 retries = self._retries if idempotent else 0 

488 

489 for attempt in range(1, retries + 2): 489 ↛ 535line 489 didn't jump to line 535 because the loop on line 489 didn't complete

490 try: 

491 with urlopen(request, timeout=self._timeout) as response: 

492 body = response.read() 

493 contentType = response.headers.get("Content-Type", None) 

494 link = response.headers.get("Link", None) 

495 break 

496 except HTTPError as ex: 

497 if ex.code in TRANSIENT_HTTP_STATUS and attempt <= retries: 

498 delay = self._RetryDelay(attempt) 

499 # :rfc:`9110` writes 'Retry-After' as a non-negative number of seconds or as an HTTP-date. Only the 

500 # first is honored, and only while it asks for longer than the backoff already waits. 

501 if ex.headers is not None and (pause := ex.headers.get("Retry-After", "")).strip().isdigit(): 

502 delay = max(delay, min(float(pause), MAXIMUM_RETRY_AFTER)) 

503 

504 sleep(delay) 

505 continue 

506 

507 error = RESTError(f"Request failed with HTTP {ex.code}: {url}") 

508 try: 

509 answer = json_loads(ex.read()) 

510 except (OSError, ValueError): 

511 answer = None 

512 

513 # A REST API reports the reason in a 'message' field. An answer that has none, or isn't JSON at all, 

514 # is not a second failure - it just says nothing. 

515 if isinstance(answer, dict) and isinstance(message := answer.get("message", None), str): 515 ↛ 518line 515 didn't jump to line 518 because the condition on line 515 was always true

516 error.add_note(f"Answer: {message}") 

517 

518 self._AddErrorNotes(error, ex.code) 

519 if attempt > 1: 

520 error.add_note(f"Tried {attempt} times.") 

521 

522 raise error from ex 

523 except OSError as ex: 

524 if attempt <= retries: 

525 sleep(self._RetryDelay(attempt)) 

526 continue 

527 

528 error = RESTError(f"API couldn't be reached: {url}") 

529 error.add_note(f"Reason: {ex.reason if isinstance(ex, URLError) else ex}") 

530 if attempt > 1: 530 ↛ 533line 530 didn't jump to line 533 because the condition on line 530 was always true

531 error.add_note(f"Tried {attempt} times.") 

532 

533 raise error from ex 

534 

535 return self._ProcessAnswer(body, contentType, url), self._NextResourcePath(link) 

536 

537 def _RequestHeaders(self, mediaType: Nullable[MediaType], headers: Nullable[dict[str, str]]) -> dict[str, str]: 

538 """ 

539 Return the headers one request is sent with. 

540 

541 The client's headers are the base, the authorization is added, and this request's own headers win, so a caller 

542 can state an ``Accept`` or a conditional header for a single request. 

543 

544 :param mediaType: The media type of the request's body, or ``None`` for a request without one. 

545 :param headers: This request's headers, or ``None``. 

546 :returns: The headers of this request. 

547 """ 

548 requestHeaders = dict(self._headers) 

549 # The bearer scheme of :rfc:`6750` is what a token-based REST API expects, and what an OAuth 2.0 flow's access 

550 # token is used with once the flow handed one out. An API authorizing differently overrides this method. 

551 if self._token is not None: 

552 requestHeaders["Authorization"] = f"Bearer {self._token}" 

553 

554 if mediaType is not None: 

555 requestHeaders["Content-Type"] = mediaType 

556 

557 if headers is not None: 

558 requestHeaders.update(headers) 

559 

560 return requestHeaders 

561 

562 def _AddErrorNotes(self, error: RESTError, status: int) -> None: 

563 """ 

564 Add notes explaining what an HTTP status means for this API. 

565 

566 A derived class overrides this to say what a caller can do about a status its API answers with. The default 

567 adds nothing, because a status alone says the same for every API. 

568 

569 :param error: The error the notes are added to. 

570 :param status: The HTTP status the request failed with. 

571 """ 

572 

573 def _RetryDelay(self, attempt: int) -> float: 

574 """ 

575 Return the pause before a request is tried again. 

576 

577 The pause grows exponentially: :attr:`RetryDelay` doubled for every earlier attempt. 

578 

579 :param attempt: The attempt that failed, starting at 1. 

580 :returns: The pause in seconds. 

581 """ 

582 return self._retryDelay * 2 ** (attempt - 1) 

583 

584 def _ProcessAnswer(self, body: bytes, contentType: Nullable[str], url: URL) -> Nullable[JSONObject]: 

585 """ 

586 Read an answer's body as a JSON object. 

587 

588 :param body: The answer's body. 

589 :param contentType: The answer's ``Content-Type`` header, or ``None``. 

590 :param url: The URL that was requested. 

591 :returns: The JSON object, or ``None`` if the answer had no body. 

592 :raises RESTError: If the answer's media type isn't JSON. |br| 

593 The note reports the media type that was announced. 

594 :raises RESTError: If the answer isn't valid JSON. 

595 :raises RESTError: If the answer is JSON, but not a JSON object. 

596 """ 

597 if len(body) == 0: 

598 return None 

599 

600 if contentType is not None and not MediaType.JSON.Matches(contentType): 

601 error = RESTError(f"API didn't answer with JSON: {url}") 

602 error.add_note(f"Got 'Content-Type: {contentType}'.") 

603 raise error 

604 

605 try: 

606 document = json_loads(body) 

607 except ValueError as ex: 

608 raise RESTError(f"API answered with invalid JSON: {url}") from ex 

609 

610 if not isinstance(document, dict): 

611 raise RESTError(f"API didn't answer with a JSON object: {url}") 

612 

613 return document 

614 

615 def _NextResourcePath(self, link: Nullable[str]) -> Nullable[str]: 

616 """ 

617 Return where a paginated collection continues. 

618 

619 :param link: The answer's :rfc:`8288` ``Link`` header, or ``None``. 

620 :returns: Path of the next page below :attr:`APIURL`, or ``None`` if the answer names none. 

621 :raises RESTError: If the next page's URL doesn't belong to this API. |br| 

622 The note says that the token is only sent to the API itself. 

623 """ 

624 if link is None: 

625 return None 

626 elif (match := _NEXT_LINK.search(link)) is None: 

627 return None 

628 

629 nextURL = match.group(1) 

630 prefix = f"{self._apiURL}/" 

631 if not nextURL.startswith(prefix): 

632 error = RESTError(f"The next page is outside the API: {nextURL}") 

633 error.add_note("The request's token is only sent to the API itself.") 

634 raise error 

635 

636 return nextURL[len(prefix):]