Repository: jupyterhub/jupyterhub

Issue:
Better handling of spawner failures
<!-- Thank you for contributing. These HTML comments will not render in the issue, but you can delete them once you've read them if you prefer! -->

### Proposed change
<!-- Use this section to describe the feature you'd like to be added. -->

Currently, when a Spawner fails to launch the Jupyter server, there is no good way in which it can inform the user of the cause.

I propose we define a "SpawnException" class that the spawner can throw, and which enables the spawner to pass an html_message that is used to display a nicely formatted exception (include some formatting, links to status pages of underlying systems, ...)


### Who would use this feature?
<!-- Describe who would benefit from using this feature. -->

Advanced JupyterHub deployments can provide better error messages to inform the user about why their request failed: have they requested an invalid setup? Have they run out of quota? Is there a maintenance going on in the underlying system?

### Suggest a solution
<!-- Describe what you think needs to be done. Doing that is an excellent first step to get the feature implemented. -->

Spawner class definition:

```
class SpawnException(Exception):
    def __init__(self, *args, html_message=None, **kwargs):
        super(SpawnException, self).__init__(*args, **kwargs)

        self.html_message = html_message
```

Add handling of this exception class in [SpawnProgressApiHandler](https://github.com/jupyterhub/jupyterhub/blob/e540d143bb8d219aca6023f104e3dbe76ca12bb8/jupyterhub/apihandlers/users.py#L716)

```
            if f and f.done() and f.exception():
                if isinstance(f.exception(), SpawnException) and f.exception().html_message is not None:
                   failed_event['html_message'] = f.exception().html_message
                failed_event['message'] = "Spawn failed: %s" % f.exception()
                await self.send_event(failed_event)

```

Log spawn failure as a warning/info instead of as an error in [user.py](https://github.com/jupyterhub/jupyterhub/blob/e540d143bb8d219aca6023f104e3dbe76ca12bb8/jupyterhub/user.py#L748), improve graceful handling of the SpawnException elsewhere.



Discussion:
Thank you for opening your first issue in this project! Engagement like this is essential for open source projects! :hugs:
<br>If you haven't done so already, check out [Jupyter's Code of Conduct](https://github.com/jupyter/governance/blob/master/conduct/code_of_conduct.md). Also, please try to follow the issue template as it helps other other community members to contribute more effectively.
![welcome](https://raw.githubusercontent.com/jupyterhub/.github/master/images/welcome.jpg)
You can meet the other [Jovyans](https://jupyter.readthedocs.io/en/latest/community/content-community.html?highlight=jovyan#what-is-a-jovyan) by joining our [Discourse forum](http://discourse.jupyter.org/). There is also an intro thread there where you can stop by and say Hi! :wave:
<br>Welcome to the Jupyter community! :tada:


Resolving patch:
diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py
--- a/jupyterhub/apihandlers/users.py
+++ b/jupyterhub/apihandlers/users.py
@@ -714,7 +714,12 @@ async def get(self, user_name, server_name=''):
             # check if spawner has just failed
             f = spawn_future
             if f and f.done() and f.exception():
-                failed_event['message'] = "Spawn failed: %s" % f.exception()
+                exc = f.exception()
+                message = getattr(exc, "jupyterhub_message", str(exc))
+                failed_event['message'] = f"Spawn failed: {message}"
+                html_message = getattr(exc, "jupyterhub_html_message", "")
+                if html_message:
+                    failed_event['html_message'] = html_message
                 await self.send_event(failed_event)
                 return
             else:
@@ -747,7 +752,12 @@ async def get(self, user_name, server_name=''):
             # what happened? Maybe spawn failed?
             f = spawn_future
             if f and f.done() and f.exception():
-                failed_event['message'] = "Spawn failed: %s" % f.exception()
+                exc = f.exception()
+                message = getattr(exc, "jupyterhub_message", str(exc))
+                failed_event['message'] = f"Spawn failed: {message}"
+                html_message = getattr(exc, "jupyterhub_html_message", "")
+                if html_message:
+                    failed_event['html_message'] = html_message
             else:
                 self.log.warning(
                     "Server %s didn't start for unknown reason", spawner._log_name
diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py
--- a/jupyterhub/handlers/pages.py
+++ b/jupyterhub/handlers/pages.py
@@ -387,6 +387,7 @@ async def get(self, for_user, server_name=''):
                 server_name=server_name,
                 spawn_url=spawn_url,
                 failed=True,
+                failed_html_message=getattr(exc, 'jupyterhub_html_message', ''),
                 failed_message=getattr(exc, 'jupyterhub_message', ''),
                 exception=exc,
             )


Test patch: