@@ -1081,27 +1081,54 @@ def _unpack_and_recover_state(
10811081 return state_obj , output_schema , input_schema
10821082
10831083
1084+ # ---------------------------------------------------------------------------
1085+ # Shared HTML styles for error and landing pages
1086+ # ---------------------------------------------------------------------------
1087+
1088+ _FONT_IMPORTS = (
1089+ '<link rel="preconnect" href="https://fonts.googleapis.com">'
1090+ '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>'
1091+ '<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">' # noqa: E501
1092+ )
1093+
10841094# ---------------------------------------------------------------------------
10851095# 404 sink for unmatched routes
10861096# ---------------------------------------------------------------------------
10871097
1088- _NOT_FOUND_HTML_TEMPLATE = """\
1098+ _ERROR_PAGE_STYLE = """\
1099+ <style>
1100+ body {{ font-family: 'Inter', system-ui, -apple-system, sans-serif; max-width: 600px;
1101+ margin: 0 auto; padding: 60px 20px 0; color: #2c2c1e; text-align: center;
1102+ background: #faf8f0; }}
1103+ .logo {{ margin-bottom: 24px; }}
1104+ .logo img {{ width: 120px; height: 120px; border-radius: 50%;
1105+ box-shadow: 0 4px 24px rgba(0,0,0,0.12); }}
1106+ h1 {{ color: #2d5016; margin-bottom: 8px; font-weight: 700; }}
1107+ code {{ font-family: 'JetBrains Mono', monospace; background: #f0ece0;
1108+ padding: 2px 6px; border-radius: 3px; font-size: 0.9em; color: #2c2c1e; }}
1109+ a {{ color: #2d5016; text-decoration: none; }}
1110+ a:hover {{ color: #4a7c23; }}
1111+ p {{ line-height: 1.7; color: #6b6b5a; }}
1112+ .detail {{ margin-top: 12px; padding: 12px 16px; background: #f0ece0;
1113+ border-radius: 6px; font-size: 0.9em; color: #6b6b5a; }}
1114+ footer {{ margin-top: 48px; padding: 20px 0; border-top: 1px solid #f0ece0;
1115+ color: #6b6b5a; font-size: 0.85em; }}
1116+ footer a {{ color: #2d5016; font-weight: 600; }}
1117+ footer a:hover {{ color: #4a7c23; }}
1118+ </style>"""
1119+
1120+ _NOT_FOUND_HTML_TEMPLATE = (
1121+ """\
10891122 <!DOCTYPE html>
10901123<html lang="en">
10911124<head>
10921125<meta charset="utf-8">
10931126<meta name="viewport" content="width=device-width, initial-scale=1">
1094- <title>404 — vgi-rpc endpoint</title>
1095- <style>
1096- body {{ font-family: system-ui, -apple-system, sans-serif; max-width: 600px;
1097- margin: 60px auto; padding: 0 20px; color: #333; text-align: center; }}
1098- .logo {{ margin-bottom: 24px; }}
1099- .logo img {{ width: 120px; height: 120px; }}
1100- h1 {{ color: #555; }}
1101- code {{ background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-size: 0.95em; }}
1102- a {{ color: #0066cc; }}
1103- p {{ line-height: 1.6; }}
1104- </style>
1127+ <title>404 — vgi-rpc</title>
1128+ """
1129+ + _FONT_IMPORTS
1130+ + _ERROR_PAGE_STYLE
1131+ + """
11051132</head>
11061133<body>
11071134<div class="logo">
@@ -1110,9 +1137,82 @@ def _unpack_and_recover_state(
11101137<h1>404 — Not Found</h1>
11111138<p>This is a <code>vgi-rpc</code> service endpoint{protocol_fragment}.</p>
11121139<p>RPC methods are available under <code>{prefix}/<method></code>.</p>
1113- <p>Learn more at <a href="https://vgi-rpc.query.farm">vgi-rpc.query.farm</a>.</p>
1140+ <footer>
1141+ Powered by <a href="https://vgi-rpc.query.farm"><code>vgi-rpc</code></a>
1142+ </footer>
1143+ </body>
1144+ </html>"""
1145+ )
1146+
1147+ _UNAUTHORIZED_HTML_TEMPLATE = (
1148+ """\
1149+ <!DOCTYPE html>
1150+ <html lang="en">
1151+ <head>
1152+ <meta charset="utf-8">
1153+ <meta name="viewport" content="width=device-width, initial-scale=1">
1154+ <title>401 — Unauthorized</title>
1155+ """
1156+ + _FONT_IMPORTS
1157+ + _ERROR_PAGE_STYLE
1158+ + """
1159+ </head>
1160+ <body>
1161+ <div class="logo">
1162+ <img src="https://vgi-rpc-python.query.farm/assets/logo-hero.png" alt="vgi-rpc logo">
1163+ </div>
1164+ <h1>401 — Unauthorized</h1>
1165+ <p>Authentication is required to access this <code>vgi-rpc</code> service.</p>
1166+ {detail}
1167+ <footer>
1168+ Powered by <a href="https://vgi-rpc.query.farm"><code>vgi-rpc</code></a>
1169+ </footer>
11141170</body>
11151171</html>"""
1172+ )
1173+
1174+ _UNAUTHORIZED_BODY_CACHE : dict [str , bytes ] = {}
1175+
1176+
1177+ def _get_unauthorized_body (detail : str ) -> bytes :
1178+ """Return cached UTF-8 bytes for a 401 HTML page with the given detail message.
1179+
1180+ Args:
1181+ detail: The error description to display on the page.
1182+
1183+ Returns:
1184+ UTF-8 encoded HTML bytes.
1185+
1186+ """
1187+ body = _UNAUTHORIZED_BODY_CACHE .get (detail )
1188+ if body is not None :
1189+ return body
1190+ detail_html = f'<div class="detail">{ _html .escape (detail )} </div>' if detail else ""
1191+ body = _UNAUTHORIZED_HTML_TEMPLATE .format (detail = detail_html ).encode ("utf-8" )
1192+ # Bound cache size to prevent memory issues with unique error messages.
1193+ if len (_UNAUTHORIZED_BODY_CACHE ) < 64 :
1194+ _UNAUTHORIZED_BODY_CACHE [detail ] = body
1195+ return body
1196+
1197+
1198+ def _error_serializer (req : falcon .Request , resp : falcon .Response , exc : falcon .HTTPError ) -> None :
1199+ """Serialize Falcon HTTP errors as styled HTML pages.
1200+
1201+ Only ``HTTPUnauthorized`` (401) gets a styled HTML page; all other errors
1202+ fall back to Falcon's default JSON serialization.
1203+
1204+ Args:
1205+ req: The Falcon request.
1206+ resp: The Falcon response.
1207+ exc: The Falcon HTTP error being serialized.
1208+
1209+ """
1210+ if isinstance (exc , falcon .HTTPUnauthorized ):
1211+ resp .content_type = "text/html; charset=utf-8"
1212+ resp .data = _get_unauthorized_body (exc .description or "" )
1213+ else :
1214+ resp .content_type = falcon .MEDIA_JSON
1215+ resp .data = exc .to_json ()
11161216
11171217
11181218def _make_not_found_sink (
@@ -1151,12 +1251,6 @@ def _not_found_sink(req: falcon.Request, resp: falcon.Response, **kwargs: Any) -
11511251# Landing page at GET {prefix}
11521252# ---------------------------------------------------------------------------
11531253
1154- _FONT_IMPORTS = (
1155- '<link rel="preconnect" href="https://fonts.googleapis.com">'
1156- '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>'
1157- '<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">' # noqa: E501
1158- )
1159-
11601254_LANDING_HTML_TEMPLATE = (
11611255 """\
11621256 <!DOCTYPE html>
@@ -2084,6 +2178,7 @@ def make_wsgi_app(
20842178 if capability_headers :
20852179 middleware .append (_CapabilitiesMiddleware (capability_headers ))
20862180 app : falcon .App [falcon .Request , falcon .Response ] = falcon .App (middleware = middleware or None )
2181+ app .set_error_serializer (_error_serializer )
20872182
20882183 # OAuth well-known endpoint (must be before RPC routes)
20892184 if _validated_oauth_metadata is not None :
0 commit comments