diff --git a/changelog.d/fixed-error-pages-reflect-404.md b/changelog.d/fixed-error-pages-reflect-404.md
new file mode 100644
index 0000000000..cdff183046
--- /dev/null
+++ b/changelog.d/fixed-error-pages-reflect-404.md
@@ -0,0 +1 @@
+- The ingress-nginx error backend (`src/deploy/k8s/error-pages.yaml`) now reflects an intercepted 403 or 404 as that status with a dedicated "Page not found" document (HTML or JSON by caller) instead of falling through to the 503 "Hive is restarting" page with an auto-refresh. With the controller's global `custom-http-errors` list including 404, every missing URL on the hub had been served as a 503, which Google Search Console reported as Soft 404 and which told visitors a healthy hub was down ([#17431](https://github.com/hivecommons/hive/issues/17431)). The global list is documented as `502,503,504`.
diff --git a/src/deploy/k8s/error-pages.yaml b/src/deploy/k8s/error-pages.yaml
index 79e983ac0f..5922b56473 100644
--- a/src/deploy/k8s/error-pages.yaml
+++ b/src/deploy/k8s/error-pages.yaml
@@ -60,6 +60,50 @@ data:
# "ok": false is the field clients key off (issue #4241).
error.json: |
{"ok":false,"error":"hive unavailable","detail":"This hive is restarting or its API is temporarily unreachable. The request was NOT applied - retry shortly."}
+ # Served when the controller intercepts a 403 or 404. These are NOT "retry
+ # shortly" states: the upstream answered, and the resource is missing or
+ # forbidden. Before these documents existed, an intercepted 404 fell through
+ # to the 503 "Hive is restarting" page with an auto-refresh, which Google
+ # Search Console reported as "Soft 404" on hive.hivecommons.dev and which
+ # told a human a healthy hub was down (#17431). No spinner, no refresh.
+ notfound.json: |
+ {"ok":false,"error":"not found","detail":"No such page or API route on this hive. The request was NOT applied."}
+ notfound.html: |
+
+
+
+
+
+
+ Hive — Page Not Found
+
+
+
+
+
🔍
+
Page not found
+
The page you're looking for doesn't exist on this hive or has moved.
+
Back to the front page
+
+
+
# Served for a 401 intercepted on the hive-api-xhr Ingress (the dashboard's
# XHR routes under /api/, see src/pkg/hub/saas_provision.go). That Ingress
# deliberately has no auth-signin: fetch() cannot follow the cross-origin
@@ -118,7 +162,13 @@ data:
# ingress-nginx sends a request here when a hive Ingress intercepts one of
# the upstream statuses named in its custom-http-errors annotation ("502,503"
# on the hive Ingress, "401,502,503" on the hive-api-xhr Ingress -- see
- # src/pkg/hub/saas_provision.go). It rewrites the
+ # src/pkg/hub/saas_provision.go), or in the controller's GLOBAL
+ # `custom-http-errors` ConfigMap key for an Ingress with no annotation of
+ # its own (the hub's). Keep that global list to "502,503,504": with 403 and
+ # 404 in it, every missing page on hive.hivecommons.dev was intercepted and
+ # -- because this backend had no 404 branch -- answered as a 503 "Hive is
+ # restarting" page (#17431). The 403/404 branches below are the safety net
+ # if the global list ever grows again. It rewrites the
# URI to "/" and passes the original request's metadata in headers:
#
# X-Code the intercepted upstream status, e.g. "502"
@@ -165,10 +215,15 @@ data:
# 401 gets its own document: it is intercepted from the hive-api-xhr
# Ingress (issue #7405) and means "sign in again", not "retry shortly".
error_page 401 /unauthorized.$hive_error_ext;
+ # 403 and 404 share the not-found document: the upstream answered and the
+ # resource is missing or off-limits, so no spinner and no auto-refresh.
+ error_page 403 404 /notfound.$hive_error_ext;
location = /error.html { internal; }
location = /error.json { internal; }
location = /unauthorized.html { internal; }
location = /unauthorized.json { internal; }
+ location = /notfound.html { internal; }
+ location = /notfound.json { internal; }
# Probed by the kubelet directly, never through the Ingress, so it is not
# affected by the status mapping below.
@@ -181,6 +236,8 @@ data:
# Reflect the intercepted status back to the caller. `return` answers
# every method, so a POST is no longer served a static file with 200.
if ($http_x_code = "401") { return 401; }
+ if ($http_x_code = "403") { return 403; }
+ if ($http_x_code = "404") { return 404; }
if ($http_x_code = "500") { return 500; }
if ($http_x_code = "502") { return 502; }
if ($http_x_code = "504") { return 504; }