Repository: bokeh/bokeh

Issue:
Use a different error for "can't open socket" vs "404"
The errors in session push and pull, e.g. 

https://github.com/bokeh/bokeh/blob/4f519bf4cac9b696550aae8ca1653e489f57f76b/bokeh/client/session.py#L270 

suggest starting "bokeh serve"

But this error will also happen if your app_path is wrong which means the server is started already.
It would be better if the connection recorded whether the failure was a network failure or an http error code, and the specific errno or http error. Then if we fail on 404 we can say "check your app path" rather than "start bokeh serve"

Remember to change both push and pull... for bonus points remove the cut and paste error handling between them so a single place has to be fixed. 

Use a different error for "can't open socket" vs "404"
The errors in session push and pull, e.g. 

https://github.com/bokeh/bokeh/blob/4f519bf4cac9b696550aae8ca1653e489f57f76b/bokeh/client/session.py#L270 

suggest starting "bokeh serve"

But this error will also happen if your app_path is wrong which means the server is started already.
It would be better if the connection recorded whether the failure was a network failure or an http error code, and the specific errno or http error. Then if we fail on 404 we can say "check your app path" rather than "start bokeh serve"

Remember to change both push and pull... for bonus points remove the cut and paste error handling between them so a single place has to be fixed. 



Discussion:
Hi,

do I understand it correctly, that the goal is to create an easy way to access the reason for the connection error from the ClientConnection class  (https://github.com/bokeh/bokeh/blob/57da24a50e53c8b97e6efa155272e1a9379a9689/bokeh/client/connection.py) and raise an error matching this reason?

If so, I would suggest adding specialised DISCONNECTED classes in the states.py file (https://github.com/bokeh/bokeh/blob/57da24a50e53c8b97e6efa155272e1a9379a9689/bokeh/client/states.py) for the given disconnect causes and create a way to access those through the ClientConnection class. 

If this sounds like the correct way to solve this issue to you, I will try my luck implementing it.

Best Regards,
Sönke
Hi,

do I understand it correctly, that the goal is to create an easy way to access the reason for the connection error from the ClientConnection class  (https://github.com/bokeh/bokeh/blob/57da24a50e53c8b97e6efa155272e1a9379a9689/bokeh/client/connection.py) and raise an error matching this reason?

If so, I would suggest adding specialised DISCONNECTED classes in the states.py file (https://github.com/bokeh/bokeh/blob/57da24a50e53c8b97e6efa155272e1a9379a9689/bokeh/client/states.py) for the given disconnect causes and create a way to access those through the ClientConnection class. 

If this sounds like the correct way to solve this issue to you, I will try my luck implementing it.

Best Regards,
Sönke

Resolving patch:
diff --git a/bokeh/client/connection.py b/bokeh/client/connection.py
--- a/bokeh/client/connection.py
+++ b/bokeh/client/connection.py
@@ -23,7 +23,7 @@
 #-----------------------------------------------------------------------------
 
 # External imports
-from tornado.httpclient import HTTPRequest
+from tornado.httpclient import HTTPClientError, HTTPRequest
 from tornado.ioloop import IOLoop
 from tornado.websocket import WebSocketError, websocket_connect
 
@@ -38,6 +38,7 @@
     DISCONNECTED,
     NOT_YET_CONNECTED,
     WAITING_FOR_REPLY,
+    ErrorReason,
 )
 from .websocket import WebSocketClientConnectionWrapper
 
@@ -102,6 +103,33 @@ def url(self):
         ''' The URL of the websocket this Connection is to. '''
         return self._url
 
+    @property
+    def error_reason(self):
+        ''' The reason of the connection loss encoded as a ``DISCONNECTED.ErrorReason`` enum value '''
+        if not isinstance(self._state, DISCONNECTED):
+            return None
+        return self._state.error_reason
+
+    @property
+    def error_code(self):
+        ''' If there was an error that caused a disconnect, this property holds
+        the error code. None otherwise.
+
+        '''
+        if not isinstance(self._state, DISCONNECTED):
+            return 0
+        return self._state.error_code
+
+    @property
+    def error_detail(self):
+        ''' If there was an error that caused a disconnect, this property holds
+        the error detail. Empty string otherwise.
+
+        '''
+        if not isinstance(self._state, DISCONNECTED):
+            return ""
+        return self._state.error_detail
+
     # Internal methods --------------------------------------------------------
 
     def connect(self):
@@ -240,18 +268,21 @@ async def _connect_async(self):
         try:
             socket = await websocket_connect(request, subprotocols=["bokeh", self._session.token])
             self._socket = WebSocketClientConnectionWrapper(socket)
+        except HTTPClientError as e:
+            await self._transition_to_disconnected(DISCONNECTED(ErrorReason.HTTP_ERROR, e.code, e.message))
+            return
         except Exception as e:
             log.info("Failed to connect to server: %r", e)
 
         if self._socket is None:
-            await self._transition_to_disconnected()
+            await self._transition_to_disconnected(DISCONNECTED(ErrorReason.NETWORK_ERROR, None, "Socket invalid."))
         else:
             await self._transition(CONNECTED_BEFORE_ACK())
 
     async def _handle_messages(self):
         message = await self._pop_message()
         if message is None:
-            await self._transition_to_disconnected()
+            await self._transition_to_disconnected(DISCONNECTED(ErrorReason.HTTP_ERROR, 500, "Internal server error."))
         else:
             if message.msgtype == 'PATCH-DOC':
                 log.debug("Got PATCH-DOC, applying to session")
@@ -357,9 +388,9 @@ async def _transition(self, new_state):
         self._state = new_state
         await self._next()
 
-    async def _transition_to_disconnected(self):
+    async def _transition_to_disconnected(self, dis_state):
         self._tell_session_about_disconnect()
-        await self._transition(DISCONNECTED())
+        await self._transition(dis_state)
 
     async def _wait_for_ack(self):
         message = await self._pop_message()
@@ -367,7 +398,7 @@ async def _wait_for_ack(self):
             log.debug("Received %r", message)
             await self._transition(CONNECTED_AFTER_ACK())
         elif message is None:
-            await self._transition_to_disconnected()
+            await self._transition_to_disconnected(DISCONNECTED(ErrorReason.HTTP_ERROR, 500, "Internal server error."))
         else:
             raise ProtocolError("Received %r instead of ACK" % message)
 
diff --git a/bokeh/client/session.py b/bokeh/client/session.py
--- a/bokeh/client/session.py
+++ b/bokeh/client/session.py
@@ -39,6 +39,7 @@
 from ..resources import DEFAULT_SERVER_HTTP_URL, _SessionCoordinates
 from ..util.browser import NEW_PARAM
 from ..util.token import generate_jwt_token, generate_session_id
+from .states import ErrorReason
 from .util import server_url_for_websocket_url, websocket_url_for_server_url
 
 #-----------------------------------------------------------------------------
@@ -290,6 +291,22 @@ def connected(self):
         ''' Whether this session is currently connected. '''
         return self._connection.connected
 
+    @property
+    def error_reason(self):
+        return self._connection.error_reason
+
+    @property
+    def error_code(self):
+        return self._connection.error_code
+
+    @property
+    def error_detail(self):
+        return self._connection.error_detail
+
+    @property
+    def url(self):
+        return self._connection.url
+
     @property
     def document(self):
         ''' A :class:`~bokeh.document.Document` that will be kept in sync with
@@ -334,6 +351,24 @@ def force_roundtrip(self):
         '''
         self._connection.force_roundtrip()
 
+    def check_connection_errors(self):
+        ''' Raises an error, when the connection could not have been
+        established.
+
+        Should be used, after a call to connect.
+
+        Returns:
+            None
+
+        '''
+        if not self.connected:
+            if self.error_reason is ErrorReason.HTTP_ERROR:
+                if self.error_code == 404:
+                    raise IOError("Check your application path! The given Path is not valid: {}".format(self.url))
+                raise IOError("We received an HTTP-Error. Disconnected with error code: {}, given message: {}".format(self.error_id, self.error_message))
+            raise IOError("We failed to connect to the server (to start the server, try the 'bokeh serve' command)")
+
+
     def pull(self):
         ''' Pull the server's state and set it as session.document.
 
@@ -344,8 +379,7 @@ def pull(self):
 
         '''
         self.connect()
-        if not self.connected:
-            raise IOError("Cannot pull session document because we failed to connect to the server (to start the server, try the 'bokeh serve' command)")
+        self.check_connection_errors()
 
         if self.document is None:
             doc = Document()
@@ -382,8 +416,7 @@ def push(self, document=None):
                 raise ValueError("Cannot push() a different document from existing session.document")
 
         self.connect()
-        if not self.connected:
-            raise IOError("Cannot push session document because we failed to connect to the server (to start the server, try the 'bokeh serve' command)")
+        self.check_connection_errors()
         self._connection.push_doc(doc)
         if self._document is None:
             self._attach_document(doc)
diff --git a/bokeh/client/states.py b/bokeh/client/states.py
--- a/bokeh/client/states.py
+++ b/bokeh/client/states.py
@@ -19,6 +19,9 @@
 # Imports
 #-----------------------------------------------------------------------------
 
+# Standard library imports
+from enum import Enum, auto
+
 #-----------------------------------------------------------------------------
 # Globals and constants
 #-----------------------------------------------------------------------------
@@ -27,6 +30,7 @@
     'CONNECTED_BEFORE_ACK',
     'CONNECTED_AFTER_ACK',
     'DISCONNECTED',
+    'ErrorReason',
     'NOT_YET_CONNECTED',
     'WAITING_FOR_REPLY',
 )
@@ -39,6 +43,11 @@
 # Dev API
 #-----------------------------------------------------------------------------
 
+class ErrorReason(Enum):
+    NO_ERROR        = auto()
+    HTTP_ERROR      = auto()
+    NETWORK_ERROR   = auto()
+
 class NOT_YET_CONNECTED(object):
     ''' The ``ClientConnection`` is not yet connected.
 
@@ -71,6 +80,37 @@ class DISCONNECTED(object):
 
     '''
 
+    def __init__(self, reason=ErrorReason.NO_ERROR, error_code=None, error_detail=""):
+        ''' Constructs a DISCONNECT-State with given reason (``ErrorReason``
+        enum), error id and additional information provided as string.
+
+        '''
+        self._error_code = error_code
+        self._error_detail = error_detail
+        self._error_reason = reason
+
+
+    @property
+    def error_reason(self):
+        ''' The reason for the error encoded as an enumeration value.
+
+        '''
+        return self._error_reason
+
+    @property
+    def error_code(self):
+        ''' Holds the error code, if any. None otherwise.
+
+        '''
+        return self._error_code
+
+    @property
+    def error_detail(self):
+        ''' Holds the error message, if any. Empty string otherwise.
+
+        '''
+        return self._error_detail
+
     async def run(self, connection):
         return None
 


Test patch:
diff --git a/tests/unit/bokeh/test_client_server.py b/tests/unit/bokeh/test_client_server.py
--- a/tests/unit/bokeh/test_client_server.py
+++ b/tests/unit/bokeh/test_client_server.py
@@ -288,6 +288,14 @@ def add_roots(doc):
             client_session._loop_until_closed()
             assert not client_session.connected
 
+    def test__check_error_404(self, ManagedServerLoop) -> None:
+        application = Application()
+        with ManagedServerLoop(application) as server:
+            with pytest.raises(IOError):
+                pull_session(session_id='test__check_error_404',
+                                              url=url(server) + 'file_not_found',
+                                              io_loop=server.io_loop)
+
     def test_request_server_info(self, ManagedServerLoop) -> None:
         application = Application()
         with ManagedServerLoop(application) as server:
@@ -989,7 +997,6 @@ def server_change_made():
         assert not client_session.connected
         server.unlisten() # clean up so next test can run
 
-
 @patch('bokeh.client.session.show_session')
 def test_session_show_adds_obj_to_curdoc_if_necessary(m) -> None:
     session = ClientSession()