Spaces:
Running
Running
File size: 10,241 Bytes
30d7d85 3c65377 30d7d85 3c65377 c3449ec 827eb22 c3449ec 3c65377 c3449ec 3c65377 2f5718d 3c65377 30d7d85 c6a9e6a 30d7d85 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 | import json
from typing import Tuple
from urllib.parse import urlsplit
def friendly_mcp_liveboard_error(raw_error: str) -> Tuple[str, str]:
"""Return a user-facing MCP liveboard error and a coarse failure category."""
raw = (raw_error or "Unknown MCP error").strip()
normalized = raw.lower()
service_terms = [
"timed out",
"timeout",
"unresponsive",
"gateway",
"504",
"503",
"502",
"connection refused",
"connection reset",
"connection aborted",
"connectionerror",
"server disconnected",
"service unavailable",
"bad gateway",
"network",
"agent.thoughtspot.app",
]
auth_terms = [
"401",
"403",
"unauthorized",
"forbidden",
"trusted auth",
"auth token",
"authentication",
]
answer_token_terms = [
"failed to get answers for any questions",
"no valid answers with thoughtspot session/tokens",
"no liveboard-compatible answers",
"without session/tokens",
"empty-token",
"empty token",
"visualization_type': 'undefined",
'"visualization_type": "undefined',
]
if any(term in normalized for term in auth_terms):
return (
"Liveboard creation failed because ThoughtSpot MCP could not authenticate. "
"Check the ThoughtSpot trusted auth key, username, and environment settings. "
f"Raw MCP error: {raw}",
"mcp_auth",
)
if any(term in normalized for term in answer_token_terms):
return (
"Liveboard creation could not retrieve MCP-compatible answer session/tokens. "
"The model and data may still have deployed; creating a clearly marked backup liveboard is safe for this failure mode. "
f"Raw MCP error: {raw}",
"mcp_answer_tokens",
)
if any(term in normalized for term in service_terms):
return (
"Liveboard creation failed because the ThoughtSpot MCP service appears unavailable or timed out. "
"The model and data may still have deployed; create the liveboard from the ThoughtSpot Spotter/Liveboard workflow once MCP is back. "
f"Raw MCP error: {raw}",
"mcp_service",
)
return (
"Liveboard creation failed during the MCP liveboard generation step. "
"The model and data are deployed, so the run finishes without a liveboard rather than "
"falling back to another liveboard path β build a board from the model in ThoughtSpot, "
"and fix the generation error (Prompt Log) before re-running. "
f"Raw MCP error: {raw}",
"mcp_generation",
)
# --- Trusted-auth login responses (ISS-22) -----------------------------------
#
# What ThoughtSpot's answer to a trusted-auth token request
# (POST /api/rest/2.0/auth/token/full) says about a login, so a failure can name
# its real cause instead of "ThoughtSpot authentication failed". Shapes seen
# for real:
# no account HTTP 400, application/json, code 10002, debug
# 'Invalid parameter values: {"username":"Specified user does not exist."}'
# (runs 2a1c7306af2a, b58980440961, 1a69f7ab4c7f; fake-user probes
# of secloud and sebe 2026-10-14)
# standby HTTP 200, text/html "ThoughtSpot - Start Instance ... Your instance
# entered standby mode ... Complete the verification below to wake
# up your instance" (sebe 2026-10-06, runs b22397c2cfb3, c34abed7dddb)
# Outcomes that mean the build's own login WILL fail, so refusing up front loses
# nothing. Every other outcome lets the build go ahead.
LOGIN_BLOCKING = frozenset({"no_account", "rejected", "standby"})
_STANDBY_MARKERS = ("start instance", "standby mode")
def _json_or_none(text):
try:
return json.loads(text)
except (TypeError, ValueError):
return None
def _host_of_url(url: str) -> str:
return urlsplit(url if "://" in url else f"https://{url}").hostname or url
def is_standby_page(text: str) -> bool:
"""True when a ThoughtSpot response is the cluster's standby / wake-up page."""
low = (text or "").lower()
return any(marker in low for marker in _STANDBY_MARKERS)
def ts_error_reason(text: str) -> str:
"""The reason inside a ThoughtSpot REST v2 error body; "" if `text` is not one.
v2 nests it as {"error": {"message": {"debug": {"code": ..., "debug":
"[\\"<reason>\\"]"}}}} β the innermost debug is a JSON-encoded list of
strings. A body that is an error but not in that shape comes back whole.
"""
body = _json_or_none(text)
if not isinstance(body, dict) or not isinstance(body.get("error"), dict):
return ""
node = body["error"].get("message")
while isinstance(node, dict):
node = node.get("debug", node.get("message"))
if isinstance(node, str):
parsed = _json_or_none(node)
if isinstance(parsed, list):
return "; ".join(str(item) for item in parsed)
return node
return json.dumps(body["error"])[:300]
def classify_trusted_login(status_code: int, content_type: str, text: str) -> Tuple[str, str]:
"""(outcome, reason) for a trusted-auth token response.
outcome:
ok 200 with a token, or 204 (session cookie) β the login works
no_account ThoughtSpot says the user does not exist
rejected 400/401/403 with a ThoughtSpot error body, any other reason
standby the cluster served its standby / wake-up page
transient 429 or 5xx β says nothing about the user; worth retrying
inconclusive anything else (a 4xx that is not ThoughtSpot's, a 200 with
no token). Not proof the login fails, so not a refusal.
"""
body = _json_or_none(text)
shown_type = content_type or "none"
if body is None and is_standby_page(text):
return "standby", "the cluster served its 'Start Instance' standby page"
if status_code == 429 or status_code >= 500:
return "transient", f"HTTP {status_code}"
if status_code == 204:
return "ok", ""
if status_code == 200:
if isinstance(body, dict) and "token" in body:
return "ok", ""
return "inconclusive", f"HTTP 200 without a token (content-type {shown_type})"
if status_code in (400, 401, 403) and isinstance(body, dict) and "error" in body:
reason = ts_error_reason(text)
if "user does not exist" in (text or "").lower():
return "no_account", reason
return "rejected", reason
return "inconclusive", f"HTTP {status_code} (content-type {shown_type})"
def no_account_message(owner: str, ts_url: str) -> str:
return (
f"{owner} has no ThoughtSpot account on {_host_of_url(ts_url)}. Ask a ThoughtSpot "
f"admin to add them, or pass ts_url for a cluster where they have one."
)
def standby_message(ts_url: str) -> str:
return (
f"{_host_of_url(ts_url)} is in standby. Open {ts_url} in a browser and complete "
f"the wake-up check, then retry."
)
def rejected_login_message(owner: str, ts_url: str, status_code: int, reason: str) -> str:
return (
f"ThoughtSpot on {_host_of_url(ts_url)} refused a trusted-auth login for {owner} "
f"(HTTP {status_code}): {reason}"
)
def trusted_auth_failure_message(username: str, ts_url: str, status_code, body: str) -> str:
"""One sentence for a failed trusted-auth login that a person can act on.
Names the user, the cluster and the cause β a missing account as the plain
instruction (no_account_message), a standby cluster as its wake-up
instruction, anything else as ThoughtSpot's own reason with the HTTP
status. deploy_all raises this, so the run's errors, the MCP result and
the Slack "Failed" post all say the same thing instead of the bare
"ThoughtSpot authentication failed" four SEs retried against for two days
(findings triage 2026-10-15, E2).
"""
body = body or ""
status = int(status_code or 0)
outcome, reason = classify_trusted_login(status, "", body)
# The body alone settles the two cases that have a plain instruction, so a
# caller that lost the HTTP status (or recorded the deployer's own standby
# sentence instead of the page) still gets the right answer.
shown = f" (HTTP {status})" if status else ""
if outcome == "no_account" or "user does not exist" in body.lower():
# The instruction first β who, which cluster, what to do β then
# ThoughtSpot's own words, so the run's errors still grep as an auth
# failure and still quote the response.
said = ts_error_reason(body) or " ".join(body.split())[:300]
http = f", HTTP {status}" if status else ""
return (
f"{no_account_message(username, ts_url)} "
f"(ThoughtSpot authentication failed{http}: {said})"
)
if outcome == "standby" or is_standby_page(body) or body == standby_message(ts_url):
return f"ThoughtSpot authentication failed for {username} on {ts_url}: {standby_message(ts_url)}"
if outcome in ("rejected", "transient"):
cause = reason or ts_error_reason(body) or " ".join(body.split())[:300]
else:
cause = ts_error_reason(body) or " ".join(body.split())[:300]
return (
f"ThoughtSpot authentication failed for {username} on {ts_url}{shown}: "
f"{cause or 'no detail recorded'}"
)
def non_json_auth_error(ts_url: str, status_code: int, content_type: str, text: str) -> str:
"""The real cause behind a trusted-auth response that should have been JSON.
Replaces the bare parse error ("Expecting value: line 1 column 1 (char 0)"),
which is all the 2026-10-06 sebe standby runs recorded.
"""
if is_standby_page(text):
return standby_message(ts_url)
snippet = " ".join((text or "").split())[:200]
return (
f"ThoughtSpot returned a non-JSON response to the trusted-auth token request "
f"(HTTP {status_code}, content-type {content_type or 'none'}): {snippet!r}"
)
|