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": "[\\"\\"]"}}}} — 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}" )