Skip to content

Commit db1a07e

Browse files
committed
Catch request exceptions and bundle them into a single BF error type
1 parent 4376dbf commit db1a07e

3 files changed

Lines changed: 113 additions & 61 deletions

File tree

brainframe/api/bf_errors.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import re
22
from typing import Optional
33

4-
54
kind_to_error_type = {}
65
"""Maps error kinds to their corresponding error type."""
76

@@ -14,6 +13,7 @@ def _register_error(kind: Optional[str] = None):
1413
:param kind: If provided, this class will be associated with this kind
1514
instead of using the class's name as the kind
1615
"""
16+
1717
def wrapper(cls):
1818
global kind_to_error_type
1919
nonlocal kind
@@ -290,3 +290,9 @@ class RemoteConnectionError(BaseAPIError):
290290
"""The server encountered an error while connecting to a remote resource
291291
that is required for the requested operation.
292292
"""
293+
294+
295+
@_register_error()
296+
class ServerNotReadyError(BaseAPIError):
297+
"""The client was able to communicate with the server, but the server had
298+
not completed startup or was in an invalid state"""

brainframe/api/status_receiver.py

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,7 @@
33
from time import sleep
44
from typing import Any, Callable, Dict, List, Optional
55

6-
import requests
7-
8-
from .bf_errors import UnknownError
9-
from .bf_codecs import ZoneStatus
6+
from . import bf_codecs, bf_errors
107
from .stubs.zone_statuses import ZONE_STATUS_STREAM_TYPE, ZONE_STATUS_TYPE
118

129

@@ -53,7 +50,8 @@ def add_listener(self, listener: Callable[[ZONE_STATUS_TYPE], Any]):
5350
def is_running(self) -> bool:
5451
return self._running
5552

56-
def latest_statuses(self, stream_id: int) -> Dict[str, ZoneStatus]:
53+
def latest_statuses(self, stream_id: int) \
54+
-> Dict[str, bf_codecs.ZoneStatus]:
5755
"""Returns the latest cached list of ZoneStatuses for that stream_id,
5856
or any empty dict if none are cached"""
5957
return self._latest_statuses.get(stream_id, {})
@@ -83,15 +81,7 @@ def _run(self):
8381
timeout=self._ZONE_STATUS_STREAM_TIMEOUT)
8482
try:
8583
zone_statuses = next(zone_status_stream)
86-
except (StopIteration,
87-
requests.exceptions.RequestException,
88-
UnknownError) as ex:
89-
90-
# Catch any 502 errors that happen during server restart
91-
if isinstance(ex, UnknownError) \
92-
and ex.status_code != 502:
93-
raise
94-
84+
except (StopIteration, bf_errors.ServerNotReadyError) as ex:
9585
if not self._running:
9686
break
9787

brainframe/api/stubs/base_stub.py

Lines changed: 102 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
1+
import json
12
import logging
3+
import typing
24
from typing import Any, BinaryIO, Optional, Tuple, Union
35
from urllib.parse import urlparse
46

57
import requests
6-
import json
78
from requests import Response
89

9-
from brainframe.api.bf_codecs import Codec
10-
from brainframe.api.bf_errors import InvalidSessionError, UnknownError, \
11-
kind_to_error_type
10+
from brainframe.api import bf_codecs, bf_errors
1211

1312
DEFAULT_TIMEOUT = 30
1413
"""The default timeout for most requests."""
@@ -76,7 +75,7 @@ def _put_json(self, api_url, timeout, json_data):
7675
return json.loads(resp.content)
7776
return None
7877

79-
def _post_codec(self, api_url, timeout, codec: Codec):
78+
def _post_codec(self, api_url, timeout, codec: bf_codecs.Codec):
8079
"""Send a POST request to the given URL.
8180
8281
:param api_url: The /api/blah/blah to append to the base_url
@@ -272,33 +271,29 @@ def _send_authorized(self, request: requests.Request, timeout) \
272271
"""
273272
if self._credentials is None:
274273
# No credentials provided, send the request without any auth
275-
resp = self._send_no_auth(request, timeout)
274+
send_func = self._send_no_auth
276275
elif self._session_id is None:
277276
# Authenticate with username and password to get a new session ID
278-
resp = self._send_with_credentials(request, timeout)
277+
send_func = self._send_with_credentials
279278
else:
280279
# Authenticate with the session ID
281-
resp = self._send_with_session_id(request, timeout)
280+
send_func = self._send_with_session_id
282281

282+
resp = send_func(request, timeout)
283283
return resp
284284

285285
def _send_no_auth(self, request: requests.Request, timeout) \
286286
-> requests.Response:
287287
"""Sends the given request with no authorization."""
288288
resp = self._send_request(request, timeout)
289-
if not resp.ok:
290-
raise _make_api_error(resp.content, resp.status_code)
291-
292289
return resp
293290

294291
def _send_with_credentials(self, request: requests.Request, timeout) \
295292
-> requests.Response:
296293
"""Sends the given request with HTTP Basic Authorization."""
297-
request.auth = self._credentials
298294

295+
request.auth = self._credentials
299296
resp = self._send_request(request, timeout)
300-
if not resp.ok:
301-
raise _make_api_error(resp.content, resp.status_code)
302297

303298
if "session_id" in resp.cookies:
304299
# Update the session ID if we don't already have one
@@ -310,12 +305,9 @@ def _send_with_session_id(self, request: requests.Request, timeout) \
310305
-> requests.Response:
311306
"""Sends the given request with the session ID."""
312307
request.cookies = {"session_id": self._session_id}
313-
314308
try:
315309
resp = self._send_request(request, timeout)
316-
if not resp.ok:
317-
raise _make_api_error(resp.content, resp.status_code)
318-
except InvalidSessionError:
310+
except bf_errors.InvalidSessionError:
319311
# The session likely expired. Try again with the username and
320312
# password to fetch a new session
321313
request.cookies = None
@@ -333,38 +325,102 @@ def _send_request(request: requests.Request, timeout: int) \
333325
:return: The response data
334326
"""
335327
prepared = request.prepare()
336-
return requests.Session().send(prepared, stream=True, timeout=timeout)
328+
329+
session = requests.Session()
330+
try:
331+
resp = session.send(prepared, stream=True, timeout=timeout)
332+
except requests.exceptions.RequestException as exc:
333+
raise _make_api_error(exception=exc)
334+
335+
if not resp.ok:
336+
raise _make_api_error(resp=resp)
337+
338+
return resp
337339

338340

339-
def _make_api_error(resp_content, status_code):
341+
@typing.overload
342+
def _make_api_error(exception: Exception) -> bf_errors.BaseAPIError:
343+
...
344+
345+
346+
@typing.overload
347+
def _make_api_error(resp: requests.Response) -> bf_errors.BaseAPIError:
348+
...
349+
350+
351+
def _make_api_error(resp: requests.Response = None,
352+
exception: BaseException = None) \
353+
-> bf_errors.BaseAPIError:
340354
"""Makes the corresponding error for this response.
341355
342-
:param resp_content: The HTTP response to inspect for info
356+
Pass either a requests.Response with a status code, or an exception
357+
358+
:param resp: The HTTP response to inspect for info
359+
:param exception: An exception during the request
343360
:return: An error that can be thrown describing this failure
344361
"""
345-
if len(resp_content) == 0:
346-
kind = UnknownError.kind
347-
description = ("A failure happened but the server did not respond "
348-
"with a proper error")
349-
else:
350-
try:
351-
resp_content = json.loads(resp_content)
352-
kind = resp_content["title"]
353-
description = resp_content["description"]
354-
except ValueError:
355-
# The content of the error was not in the proper format. This might
356-
# happen if some part of our request handling pipeline failed that
357-
# doesn't know about our error handling format. Not ideal.
358-
kind = UnknownError.kind
359-
resp_content = resp_content.decode("utf-8")
360-
description = ("A failure happened, and the response was not in "
361-
"the proper error format: " + resp_content)
362-
363-
if kind not in kind_to_error_type:
364-
info = f"Unknown error kind {kind}: " + description
365-
logging.error(info)
366-
return UnknownError(info, status_code)
362+
363+
server_not_ready_msg = "A network exception occurred while communicating " \
364+
"with the BrainFrame server"
365+
366+
if resp is not None:
367+
if len(resp.content) == 0:
368+
description = ("A failure happened but the server did not respond "
369+
"with a proper error")
370+
return bf_errors.UnknownError(description)
371+
else:
372+
resp_content = resp.content.decode("utf-8")
373+
374+
# This is here to catch the nginx error that can occur as the server
375+
# starts up
376+
if resp.status_code == 502:
377+
description = f"{server_not_ready_msg}: {resp_content}"
378+
return bf_errors.ServerNotReadyError(description)
379+
380+
try:
381+
resp_content = json.loads(resp.content)
382+
kind = resp_content["title"]
383+
description = resp_content["description"]
384+
except ValueError:
385+
# The content of the error was not in the proper format. This
386+
# might happen if some part of our request handling pipeline
387+
# failed that doesn't know about our error handling format. Not
388+
# ideal.
389+
exc_type = bf_errors.UnknownError
390+
description = (
391+
f"A failure happened, and the response was not in "
392+
f"the proper error format: {resp_content}"
393+
)
394+
else:
395+
try:
396+
exc_type = bf_errors.kind_to_error_type[kind]
397+
except KeyError:
398+
description = f"Unknown error kind {kind}: {description}"
399+
logging.error(description)
400+
exc_type = bf_errors.UnknownError
401+
402+
if exc_type is bf_errors.UnknownError:
403+
return exc_type(description, resp.status_code)
404+
else:
405+
return exc_type(description)
406+
407+
elif exception is not None:
408+
if isinstance(exception, requests.exceptions.RequestException):
409+
exc_type = bf_errors.ServerNotReadyError
410+
description = server_not_ready_msg
411+
else:
412+
exc_type = bf_errors.UnknownError
413+
description = "An unknown network exception occurred while " \
414+
"attempting to communicate with the BrainFrame server"
415+
416+
new_exc = exc_type(description)
417+
# This is identical to `raise x from y`, but does not immediately raise
418+
new_exc.__cause__ = exception
419+
return new_exc
420+
367421
else:
368-
if kind == UnknownError.kind:
369-
return kind_to_error_type[kind](description, status_code)
370-
return kind_to_error_type[kind](description)
422+
description = f"{_make_api_error.__name__} called without arguments"
423+
new_exc = ValueError(description)
424+
if exception is not None:
425+
new_exc.__cause__ = exception
426+
raise new_exc

0 commit comments

Comments
 (0)