This is an automated email from the ASF dual-hosted git repository. beto pushed a commit to branch cloudflare-error-page in repository https://gitbox.apache.org/repos/asf/superset.git
commit 7edac00cb6794a335f8c5eac32354bdc62fa09a2 Author: Beto Dealmeida <[email protected]> AuthorDate: Tue Dec 16 15:33:14 2025 -0500 fun: CloudFlare error page --- superset/templates/cloudflare_error.html | 184 +++++++++++++++++++++++++++++++ superset/views/error_handling.py | 169 ++++++++++++++++++++++++---- 2 files changed, 329 insertions(+), 24 deletions(-) diff --git a/superset/templates/cloudflare_error.html b/superset/templates/cloudflare_error.html new file mode 100644 index 0000000000..c7b307e0ba --- /dev/null +++ b/superset/templates/cloudflare_error.html @@ -0,0 +1,184 @@ +{# Custom Cloudflare error page template with inlined CSS #} +<!DOCTYPE html> +<!--[if lt IE 7]> <html class="no-js ie6 oldie" lang="en-US"> <![endif]--> +<!--[if IE 7]> <html class="no-js ie7 oldie" lang="en-US"> <![endif]--> +<!--[if IE 8]> <html class="no-js ie8 oldie" lang="en-US"> <![endif]--> +<!--[if gt IE 8]><!--> <html class="no-js" lang="en-US"> <!--<![endif]--> +<head> +{% set error_code = params.error_code or 500 %} +{% set title = params.title or 'Internal server error' %} +{% set html_title = params.html_title or ((error_code | string) + ': ' + title) %} +<title>{{ html_title }}</title> +<meta charset="UTF-8" /> +<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> +<meta http-equiv="X-UA-Compatible" content="IE=Edge" /> +<meta name="robots" content="noindex, nofollow" /> +<meta name="viewport" content="width=device-width,initial-scale=1" /> +<style> +*{box-sizing:border-box} +body{margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;font-size:15px;line-height:1.6;color:#404040;background:#fff} +a{color:#2f7bbf;text-decoration:none} +a:hover{color:#f48120;text-decoration:underline} +h1,h2,h3,h4,h5{margin:0;font-weight:400} +p{margin:0 0 1em} +.cf-wrapper{max-width:960px;margin:0 auto} +.cf-header{padding:40px 15px 25px} +.cf-header h1{font-size:56px;font-weight:200;line-height:1.2;color:#222} +.cf-header h1 span{display:inline-block} +.cf-code-label{font-size:14px;font-weight:600;background:#f0f0f0;color:#666;padding:3px 10px;border-radius:3px;margin-left:15px;vertical-align:middle} +.cf-header-meta{margin-top:12px;font-size:14px;color:#666} +.cf-status-section{background:linear-gradient(90deg,#f7f7f7 0%,#fff 50%,#f7f7f7 100%);padding:30px 0} +.cf-status-row{display:flex;align-items:center;justify-content:center;max-width:720px;margin:0 auto;padding:0 15px} +.cf-status-item{flex:1;text-align:center;padding:20px 10px} +.cf-status-icon{position:relative;width:90px;height:90px;margin:0 auto 12px} +.cf-status-badge{position:absolute;bottom:0;left:50%;transform:translateX(-50%);width:28px;height:28px;border-radius:50%;border:3px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.15)} +.cf-status-badge.ok{background:#9bca3e} +.cf-status-badge.error{background:#cf3a32} +.cf-status-badge svg{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:14px;height:14px} +.cf-status-label{font-size:13px;color:#888;margin-bottom:2px} +.cf-status-name{font-size:22px;color:#666;font-weight:300} +.cf-status-text{font-size:18px;font-weight:500;margin-top:2px} +.cf-status-text.ok{color:#9bca3e} +.cf-status-text.error{color:#cf3a32} +.cf-arrow{flex:0 0 60px;height:2px;background:#ccc;position:relative;margin:0 -5px;margin-top:-30px} +.cf-arrow:after{content:'';position:absolute;right:-1px;top:-5px;width:0;height:0;border:6px solid transparent;border-left-color:#ccc} +.cf-arrow.error{background:#cf3a32} +.cf-arrow.error:after{border-left-color:#cf3a32} +.cf-content{padding:35px 15px;max-width:960px;margin:0 auto} +.cf-content-row{display:flex;flex-wrap:wrap;gap:40px} +.cf-content-col{flex:1;min-width:280px} +.cf-content-col h2{font-size:26px;margin-bottom:12px;color:#333} +.cf-content-col h5{font-size:15px;font-weight:600;margin:16px 0 6px;color:#333} +.cf-content-col p{color:#555;line-height:1.7} +.cf-footer{padding:18px 15px;border-top:1px solid #eee;font-size:13px;color:#777;text-align:center} +.cf-footer-item{margin:0 8px} +.cf-footer button{background:none;border:none;color:#2f7bbf;cursor:pointer;font-size:13px;padding:0} +.cf-footer button:hover{text-decoration:underline} +.cf-icon-browser{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 90 90'%3E%3Crect x='8' y='15' width='74' height='55' rx='4' fill='%23f1f1f1' stroke='%23ddd' stroke-width='2'/%3E%3Crect x='8' y='15' width='74' height='14' rx='4' fill='%23ddd'/%3E%3Ccircle cx='20' cy='22' r='4' fill='%23ff6058'/%3E%3Ccircle cx='32' cy='22' r='4' fill='%23ffc130'/%3E%3Ccircle cx='44' cy='22' r='4' fill='%2327ca40'/%3E%3Crect x='18' y='38' width='54' height='6 [...] +.cf-icon-cloud{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 90 70'%3E%3Cpath d='M72.5 55H20c-8.3 0-15-6.7-15-15 0-7.4 5.4-13.6 12.4-14.8C18.8 15.2 27.6 8 38 8c9.5 0 17.6 5.9 20.9 14.2 1.4-.3 2.9-.5 4.4-.5 9.7 0 17.6 7.6 18.2 17.1C87.2 40.5 92 46.5 92 53.5c0 8.6-6.9 15.5-15.5 15.5h-4z' fill='%23f6821f'/%3E%3Cpath d='M60.8 55H25.5C18.6 55 13 49.4 13 42.5c0-6.2 4.5-11.3 10.4-12.3C24.5 22.4 31.4 17 39.5 17c7.4 0 13.7 4.6 16.3 11.1 1.1-.2 2.2 [...] +.cf-icon-server{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 70 90'%3E%3Crect x='5' y='8' width='60' height='22' rx='3' fill='%23f1f1f1' stroke='%23ddd' stroke-width='2'/%3E%3Crect x='5' y='34' width='60' height='22' rx='3' fill='%23f1f1f1' stroke='%23ddd' stroke-width='2'/%3E%3Crect x='5' y='60' width='60' height='22' rx='3' fill='%23f1f1f1' stroke='%23ddd' stroke-width='2'/%3E%3Ccircle cx='18' cy='19' r='5' fill='%2327ca40'/%3E%3Ccircl [...] +@media (max-width:720px){ +.cf-header h1{font-size:36px} +.cf-code-label{display:block;margin:10px 0 0;width:fit-content} +.cf-status-row{flex-direction:column} +.cf-status-item{padding:15px 10px} +.cf-arrow{display:none} +.cf-content-row{flex-direction:column} +} +</style> +</head> +<body> +<div class="cf-wrapper"> + <div class="cf-header"> + <h1> + <span>{{ title }}</span> + <span class="cf-code-label">Error code {{ error_code }}</span> + </h1> + <div class="cf-header-meta"> + Visit <a href="https://www.cloudflare.com/" target="_blank" rel="noopener noreferrer">cloudflare.com</a> for more information. + </div> + <div class="cf-header-meta">{{ params.time }}</div> + </div> +</div> + +{% set browser_status = params.browser_status.status or 'ok' %} +{% set cloudflare_status = params.cloudflare_status.status or 'ok' %} +{% set host_status = params.host_status.status or 'ok' %} + +<div class="cf-status-section"> + <div class="cf-status-row"> + <div class="cf-status-item"> + <div class="cf-status-icon"> + <div class="cf-icon-browser"></div> + <div class="cf-status-badge {{ browser_status }}"> + {% if browser_status == 'ok' %} + <svg viewBox="0 0 14 14" fill="none"><path d="M3 7l3 3 5-6" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> + {% else %} + <svg viewBox="0 0 14 14" fill="none"><path d="M3 3l8 8M11 3l-8 8" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg> + {% endif %} + </div> + </div> + <div class="cf-status-label">You</div> + <div class="cf-status-name">Browser</div> + <div class="cf-status-text {{ browser_status }}">{{ params.browser_status.status_text or ('Working' if browser_status == 'ok' else 'Error') }}</div> + </div> + + <div class="cf-arrow {{ 'error' if browser_status != 'ok' else '' }}"></div> + + <div class="cf-status-item"> + <div class="cf-status-icon"> + <div class="cf-icon-cloud"></div> + <div class="cf-status-badge {{ cloudflare_status }}"> + {% if cloudflare_status == 'ok' %} + <svg viewBox="0 0 14 14" fill="none"><path d="M3 7l3 3 5-6" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> + {% else %} + <svg viewBox="0 0 14 14" fill="none"><path d="M3 3l8 8M11 3l-8 8" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg> + {% endif %} + </div> + </div> + <div class="cf-status-label">{{ params.cloudflare_status.location or 'San Francisco' }}</div> + <div class="cf-status-name">Cloudflare</div> + <div class="cf-status-text {{ cloudflare_status }}">{{ params.cloudflare_status.status_text or ('Working' if cloudflare_status == 'ok' else 'Error') }}</div> + </div> + + <div class="cf-arrow {{ 'error' if host_status != 'ok' else '' }}"></div> + + <div class="cf-status-item"> + <div class="cf-status-icon"> + <div class="cf-icon-server"></div> + <div class="cf-status-badge {{ host_status }}"> + {% if host_status == 'ok' %} + <svg viewBox="0 0 14 14" fill="none"><path d="M3 7l3 3 5-6" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> + {% else %} + <svg viewBox="0 0 14 14" fill="none"><path d="M3 3l8 8M11 3l-8 8" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg> + {% endif %} + </div> + </div> + <div class="cf-status-label">{{ params.host_status.location or 'superset.io' }}</div> + <div class="cf-status-name">Host</div> + <div class="cf-status-text {{ host_status }}">{{ params.host_status.status_text or ('Working' if host_status == 'ok' else 'Error') }}</div> + </div> + </div> +</div> + +<div class="cf-content"> + <div class="cf-content-row"> + <div class="cf-content-col"> + <h2>What happened?</h2> + {{ params.what_happened | safe }} + </div> + <div class="cf-content-col"> + <h2>What can I do?</h2> + {{ params.what_can_i_do | safe }} + </div> + </div> +</div> + +<div class="cf-wrapper"> + <div class="cf-footer"> + <span class="cf-footer-item">Cloudflare Ray ID: <strong>{{ params.ray_id }}</strong></span> + • + <span class="cf-footer-item" id="cf-footer-item-ip">Your IP: <button type="button" id="cf-footer-ip-reveal">Click to reveal</button><span id="cf-footer-ip" class="hidden">{{ params.client_ip }}</span></span> + • + {% if params.perf_sec_by %} + <span class="cf-footer-item">{{ params.perf_sec_by.text or 'Performance & security by' }} <a href="{{ params.perf_sec_by.link_url or 'https://www.cloudflare.com/' }}" target="_blank" rel="noopener noreferrer">{{ params.perf_sec_by.link_text or 'Cloudflare' }}</a></span> + {% else %} + <span class="cf-footer-item">Performance & security by <a href="https://www.cloudflare.com/5xx-error-landing" target="_blank" rel="noopener noreferrer">Cloudflare</a></span> + {% endif %} + </div> +</div> +<script> +(function(){ + var btn = document.getElementById('cf-footer-ip-reveal'); + var ip = document.getElementById('cf-footer-ip'); + if (btn && ip) { + btn.addEventListener('click', function() { + btn.style.display = 'none'; + ip.style.display = 'inline'; + }); + } +})(); +</script> +<style>.hidden{display:none}</style> +</body> +</html> diff --git a/superset/views/error_handling.py b/superset/views/error_handling.py index 236291d96e..84a7dc0a04 100644 --- a/superset/views/error_handling.py +++ b/superset/views/error_handling.py @@ -19,17 +19,17 @@ from __future__ import annotations import dataclasses import functools import logging +import secrets import typing -from importlib.resources import files from typing import Any, Callable, cast from flask import ( Flask, request, Response, - send_file, ) from flask_wtf.csrf import CSRFError +from jinja2 import Environment, PackageLoader from sqlalchemy import exc from werkzeug.exceptions import HTTPException @@ -54,6 +54,127 @@ logger = logging.getLogger(__name__) JSON_MIMETYPE = "application/json; charset=utf-8" +# Set up Jinja2 environment for Cloudflare error template +_cf_env = Environment( + loader=PackageLoader("superset", "templates"), + autoescape=True, +) +_cf_template = _cf_env.get_template("cloudflare_error.html") + +# Error code to title mapping for Cloudflare-style error pages +CLOUDFLARE_ERROR_TITLES: dict[int, str] = { + 400: "Bad Request", + 401: "Access Denied", + 403: "Access Denied", + 404: "Web page is not found", + 405: "Method Not Allowed", + 408: "Request Timeout", + 429: "Too Many Requests", + 500: "Internal Server Error", + 502: "Bad Gateway", + 503: "Service Temporarily Unavailable", + 504: "Gateway Timeout", +} + +# What happened descriptions for each error code +CLOUDFLARE_WHAT_HAPPENED: dict[int, str] = { + 400: """The server could not understand your request due to invalid syntax. + Please check your request and try again.""", + 401: """This page requires authentication. You need to log in to access + this resource.""", + 403: """You don't have permission to access this resource. The owner of + this website has banned your access based on your credentials.""", + 404: """The page you requested could not be found. It may have been moved, + deleted, or never existed in the first place.""", + 405: """The request method is not supported for the requested resource.""", + 408: """The server timed out waiting for your request. Please try again.""", + 429: """You've made too many requests in a short period of time. + Please slow down and try again later.""", + 500: """There is an unknown connection issue between Superset and the + origin web server. As a result, the web page can not be displayed.""", + 502: """Superset was unable to get a valid response from the upstream server.""", + 503: """The server is temporarily unable to handle your request due to + maintenance or capacity problems. Please try again later.""", + 504: """Superset was unable to get a response from the upstream server + in time.""", +} + + +def render_cloudflare_error_page( + error_code: int, + error_message: str | None = None, +) -> str: + """ + Render a Cloudflare-style error page for the given error code. + + Args: + error_code: HTTP status code + error_message: Optional custom error message to display + + Returns: + Rendered HTML string for the error page + """ + from datetime import datetime, timezone + + # Generate ray ID and get client IP + ray_id = request.headers.get("Cf-Ray", secrets.token_hex(8))[:16] + client_ip = request.headers.get("X-Forwarded-For", request.remote_addr or "Unknown") + utc_now = datetime.now(timezone.utc) + time_str = utc_now.strftime("%Y-%m-%d %H:%M:%S UTC") + + title = CLOUDFLARE_ERROR_TITLES.get(error_code, "Error") + what_happened = error_message or CLOUDFLARE_WHAT_HAPPENED.get( + error_code, + "An unexpected error occurred while processing your request.", + ) + + # Determine status indicators based on error type + host = request.host or "superset.io" + if error_code >= 500: + # Server errors: browser and Cloudflare OK, host has issue + browser_status = {"status": "ok"} + cloudflare_status = {"status": "ok", "location": "San Francisco"} + host_status = {"status": "error", "location": host} + elif error_code in (401, 403): + # Auth errors: browser has issue (needs auth) + browser_status = {"status": "error"} + cloudflare_status = {"status": "ok", "location": "San Francisco"} + host_status = {"status": "ok", "location": host} + else: + # Client errors (404, etc): browser has issue + browser_status = {"status": "error"} + cloudflare_status = {"status": "ok", "location": "San Francisco"} + host_status = {"status": "ok", "location": host} + + params = { + "html_title": f"superset.io | {error_code}: {title}", + "title": title, + "error_code": error_code, + "time": time_str, + "ray_id": ray_id, + "client_ip": client_ip, + "browser_status": browser_status, + "cloudflare_status": cloudflare_status, + "host_status": host_status, + "what_happened": f"<p>{what_happened}</p>", + "what_can_i_do": """ + <h5>If you are a visitor of this website:</h5> + <p>Please try again in a few minutes. If you continue to see this + error, you can contact the site administrator.</p> + <h5>If you are the owner of this website:</h5> + <p>Check your Superset logs for more information about this error. + You may need to restart the service or check your database + connections.</p> + """, + "perf_sec_by": { + "text": "Performance & security by", + "link_text": "Apache Superset", + "link_url": "https://superset.apache.org", + }, + } + + return _cf_template.render(params=params) + def get_error_level_from_status( status_code: int, @@ -157,18 +278,16 @@ def set_app_error_handlers(app: Flask) -> None: # noqa: C901 @app.errorhandler(HTTPException) def show_http_exception(ex: HTTPException) -> FlaskResponse: logger.warning("HTTPException", exc_info=True) + error_code = ex.code or 500 - if ( - "text/html" in request.accept_mimetypes - and not app.config["DEBUG"] - and ex.code in {404, 500} - ): - path = files("superset") / f"static/assets/{ex.code}.html" - # Try to serve HTML file; fall back to JSON if not built - try: - return send_file(path, max_age=0), ex.code - except FileNotFoundError: - pass + if "text/html" in request.accept_mimetypes: + return Response( + render_cloudflare_error_page( + error_code, utils.error_msg_from_exception(ex) + ), + status=error_code, + mimetype="text/html", + ) return json_error_response( [ @@ -178,7 +297,7 @@ def set_app_error_handlers(app: Flask) -> None: # noqa: C901 level=ErrorLevel.ERROR, ), ], - status=ex.code or 500, + status=error_code, ) @app.errorhandler(CommandException) @@ -190,13 +309,12 @@ def set_app_error_handlers(app: Flask) -> None: # noqa: C901 """ logger.warning("CommandException", exc_info=True) - if "text/html" in request.accept_mimetypes and not app.config["DEBUG"]: - path = files("superset") / "static/assets/500.html" - # Try to serve HTML file; fall back to JSON if not built - try: - return send_file(path, max_age=0), 500 - except FileNotFoundError: - pass + if "text/html" in request.accept_mimetypes: + return Response( + render_cloudflare_error_page(ex.status, ex.message), + status=ex.status, + mimetype="text/html", + ) extra = ex.normalized_messages() if isinstance(ex, CommandInvalidError) else {} return json_error_response( @@ -218,9 +336,12 @@ def set_app_error_handlers(app: Flask) -> None: # noqa: C901 logger.warning("Exception", exc_info=True) logger.exception(ex) - if "text/html" in request.accept_mimetypes and not app.config["DEBUG"]: - path = files("superset") / "static/assets/500.html" - return send_file(path, max_age=0), 500 + if "text/html" in request.accept_mimetypes: + return Response( + render_cloudflare_error_page(500, utils.error_msg_from_exception(ex)), + status=500, + mimetype="text/html", + ) return json_error_response( [
