From ae06035711fa3f363cc6ef9865ac20c616641eaf Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Mon, 29 Sep 2025 15:06:07 +0100 Subject: [PATCH 1/6] fix: set HTTP 429 on failed spawns --- jupyterhub/handlers/pages.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py index 31182dbc..14eda89e 100644 --- a/jupyterhub/handlers/pages.py +++ b/jupyterhub/handlers/pages.py @@ -177,7 +177,6 @@ class SpawnHandler(BaseHandler): await spawner.run_auth_state_hook(auth_state) # Try to start server directly when query arguments are passed. - error_message = '' query_options = {} for key, byte_list in self.request.query_arguments.items(): query_options[key] = [bs.decode('utf8') for bs in byte_list] @@ -185,6 +184,8 @@ class SpawnHandler(BaseHandler): # 'next' is reserved argument for redirect after spawn query_options.pop('next', None) + spawn_exc = None + if len(query_options) > 0: try: self.log.debug( @@ -200,12 +201,21 @@ class SpawnHandler(BaseHandler): "Failed to spawn single-user server with query arguments", exc_info=True, ) - error_message = str(e) + spawn_exc = e # fallback to behavior without failing query arguments spawner_options_form = await spawner.get_options_form() if spawner_options_form: self.log.debug("Serving options form for %s", spawner._log_name) + + # Explicitly catch 429 errors and report them to the client + if isinstance(spawn_exc, web.HTTPError) and spawn_exc.status_code == 429: + self.set_status(spawn_exc.status_code) + + for name, value in spawn_exc.headers.items(): + self.set_header(name, value) + error_message = '' if spawn_exc is None else str(spawn_exc) + form = await self._render_form( for_user=user, spawner_options_form=spawner_options_form, @@ -265,6 +275,14 @@ class SpawnHandler(BaseHandler): self.log.error( "Failed to spawn single-user server with form", exc_info=True ) + + # Explicitly catch 429 errors and report them to the client + if isinstance(e, web.HTTPError) and e.status_code == 429: + self.set_status(e.status_code) + + for name, value in e.headers.items(): + self.set_header(name, value) + spawner_options_form = await user.spawner.get_options_form() form = await self._render_form( for_user=user, spawner_options_form=spawner_options_form, message=str(e) From 26d5ee3ebaa1955f27ff92090d4afbc6c487db03 Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Fri, 3 Oct 2025 09:52:15 +0100 Subject: [PATCH 2/6] feat: catch all HTTPError, format appropriately --- jupyterhub/apihandlers/users.py | 7 +++-- jupyterhub/handlers/pages.py | 38 ++++++++++++++++++++------- jupyterhub/utils.py | 10 +++++++ share/jupyterhub/templates/spawn.html | 3 ++- 4 files changed, 44 insertions(+), 14 deletions(-) diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py index a08ae08f..d8fbcfb2 100644 --- a/jupyterhub/apihandlers/users.py +++ b/jupyterhub/apihandlers/users.py @@ -24,6 +24,7 @@ from ..roles import assign_default_roles from ..scopes import needs_scope from ..user import User from ..utils import ( + format_exception, isoformat, iterate_until, maybe_future, @@ -865,9 +866,8 @@ class SpawnProgressAPIHandler(APIHandler): failed_event['message'] = "Spawn cancelled" elif f and f.done() and f.exception(): exc = f.exception() - message = getattr(exc, "jupyterhub_message", str(exc)) + message, html_message = format_exception(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: @@ -906,9 +906,8 @@ class SpawnProgressAPIHandler(APIHandler): failed_event['message'] = "Spawn cancelled" elif f and f.done() and f.exception(): exc = f.exception() - message = getattr(exc, "jupyterhub_message", str(exc)) + message, html_message = format_exception(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: diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py index 14eda89e..9d5131c0 100644 --- a/jupyterhub/handlers/pages.py +++ b/jupyterhub/handlers/pages.py @@ -15,7 +15,13 @@ from tornado.httputil import url_concat from .. import __version__, orm from ..metrics import SERVER_POLL_DURATION_SECONDS, ServerPollStatus from ..scopes import describe_raw_scopes, needs_scope -from ..utils import maybe_future, url_escape_path, url_path_join, utcnow +from ..utils import ( + maybe_future, + url_escape_path, + url_path_join, + utcnow, + format_exception, +) from .base import BaseHandler @@ -208,18 +214,24 @@ class SpawnHandler(BaseHandler): if spawner_options_form: self.log.debug("Serving options form for %s", spawner._log_name) - # Explicitly catch 429 errors and report them to the client - if isinstance(spawn_exc, web.HTTPError) and spawn_exc.status_code == 429: + # Explicitly catch HTTPError and report them to the client + # This may need scoping to particular error codes. + if isinstance(spawn_exc, web.HTTPError): self.set_status(spawn_exc.status_code) for name, value in spawn_exc.headers.items(): self.set_header(name, value) - error_message = '' if spawn_exc is None else str(spawn_exc) + + if spawn_exc: + error_message, error_html_message = format_exception(spawn_exc) + else: + error_message = error_html_message = None form = await self._render_form( for_user=user, spawner_options_form=spawner_options_form, message=error_message, + html_message=error_html_message, ) self.finish(form) else: @@ -276,16 +288,22 @@ class SpawnHandler(BaseHandler): "Failed to spawn single-user server with form", exc_info=True ) - # Explicitly catch 429 errors and report them to the client - if isinstance(e, web.HTTPError) and e.status_code == 429: + # Explicitly catch HTTPError and report them to the client + # This may need scoping to particular error codes. + if isinstance(e, web.HTTPError): self.set_status(e.status_code) for name, value in e.headers.items(): self.set_header(name, value) + error_message, error_html_message = format_exception(e) + spawner_options_form = await user.spawner.get_options_form() form = await self._render_form( - for_user=user, spawner_options_form=spawner_options_form, message=str(e) + for_user=user, + spawner_options_form=spawner_options_form, + message=error_message, + html_message=error_html_message, ) self.finish(form) return @@ -397,6 +415,8 @@ class SpawnPendingHandler(BaseHandler): if isinstance(exc, web.HTTPError): status_code = exc.status_code self.set_status(status_code) + + message, html_message = format_exception(exc, only_jupyterhub=True) html = await self.render_template( "not_running.html", user=user, @@ -404,8 +424,8 @@ class SpawnPendingHandler(BaseHandler): server_name=server_name, spawn_url=spawn_url, failed=True, - failed_html_message=getattr(exc, 'jupyterhub_html_message', ''), - failed_message=getattr(exc, 'jupyterhub_message', ''), + failed_html_message=html_message, + failed_message=message, exception=exc, ) self.finish(html) diff --git a/jupyterhub/utils.py b/jupyterhub/utils.py index aaefe7df..255d6b1b 100644 --- a/jupyterhub/utils.py +++ b/jupyterhub/utils.py @@ -984,3 +984,13 @@ def fmt_ip_url(ip): if ":" in ip: return f"[{ip}]" return ip + + +def format_exception(exc, *, only_jupyterhub): + """ + Format an exception into a text string and HTML pair. + """ + default_message = None if only_jupyterhub else str(exc) + return getattr(exc, "jupyterhub_message", default_message), getattr( + exc, "jupyterhub_html_message" + ) diff --git a/share/jupyterhub/templates/spawn.html b/share/jupyterhub/templates/spawn.html index b55018c9..86f8ec15 100644 --- a/share/jupyterhub/templates/spawn.html +++ b/share/jupyterhub/templates/spawn.html @@ -14,7 +14,8 @@ {% if for_user and user.name != for_user.name -%}

Spawning server for {{ for_user.name }}

{% endif -%} - {% if error_message -%}

Error: {{ error_message }}

{% endif %} + {% if error_message %}

Error: {{ error_message }}

+ {% elif error_html_message %}

{{ error_html_message | safe}}

{% endif %}
Date: Fri, 3 Oct 2025 08:56:06 +0000 Subject: [PATCH 3/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- jupyterhub/handlers/pages.py | 2 +- share/jupyterhub/templates/spawn.html | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py index 9d5131c0..eff4a458 100644 --- a/jupyterhub/handlers/pages.py +++ b/jupyterhub/handlers/pages.py @@ -16,11 +16,11 @@ from .. import __version__, orm from ..metrics import SERVER_POLL_DURATION_SECONDS, ServerPollStatus from ..scopes import describe_raw_scopes, needs_scope from ..utils import ( + format_exception, maybe_future, url_escape_path, url_path_join, utcnow, - format_exception, ) from .base import BaseHandler diff --git a/share/jupyterhub/templates/spawn.html b/share/jupyterhub/templates/spawn.html index 86f8ec15..f8d4b4ad 100644 --- a/share/jupyterhub/templates/spawn.html +++ b/share/jupyterhub/templates/spawn.html @@ -14,8 +14,11 @@ {% if for_user and user.name != for_user.name -%}

Spawning server for {{ for_user.name }}

{% endif -%} - {% if error_message %}

Error: {{ error_message }}

- {% elif error_html_message %}

{{ error_html_message | safe}}

{% endif %} + {% if error_message %} +

Error: {{ error_message }}

+ {% elif error_html_message %} +

{{ error_html_message | safe }}

+ {% endif %} Date: Fri, 3 Oct 2025 10:01:54 +0100 Subject: [PATCH 4/6] fix: add default --- jupyterhub/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jupyterhub/utils.py b/jupyterhub/utils.py index 255d6b1b..e8cee2e2 100644 --- a/jupyterhub/utils.py +++ b/jupyterhub/utils.py @@ -986,7 +986,7 @@ def fmt_ip_url(ip): return ip -def format_exception(exc, *, only_jupyterhub): +def format_exception(exc, *, only_jupyterhub=False): """ Format an exception into a text string and HTML pair. """ From da835fbe8682d9bc18e1ba6ad421d13352898bac Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Fri, 3 Oct 2025 11:57:10 +0100 Subject: [PATCH 5/6] fix: fallback on to None --- jupyterhub/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jupyterhub/utils.py b/jupyterhub/utils.py index e8cee2e2..d2b888fc 100644 --- a/jupyterhub/utils.py +++ b/jupyterhub/utils.py @@ -992,5 +992,5 @@ def format_exception(exc, *, only_jupyterhub=False): """ default_message = None if only_jupyterhub else str(exc) return getattr(exc, "jupyterhub_message", default_message), getattr( - exc, "jupyterhub_html_message" + exc, "jupyterhub_html_message", None ) From 1978c369857570400950e18c2769b097564782a4 Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Fri, 3 Oct 2025 12:13:47 +0100 Subject: [PATCH 6/6] fix: missing arg --- jupyterhub/handlers/pages.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py index eff4a458..198185f7 100644 --- a/jupyterhub/handlers/pages.py +++ b/jupyterhub/handlers/pages.py @@ -98,7 +98,9 @@ class SpawnHandler(BaseHandler): default_url = None - async def _render_form(self, for_user, spawner_options_form, message=''): + async def _render_form( + self, for_user, spawner_options_form, message='', html_message='' + ): auth_state = await for_user.get_auth_state() return await self.render_template( 'spawn.html', @@ -106,6 +108,7 @@ class SpawnHandler(BaseHandler): auth_state=auth_state, spawner_options_form=spawner_options_form, error_message=message, + html_error_message=html_message, url=url_concat( self.request.uri, {"_xsrf": self.xsrf_token.decode('ascii')} ),