Skip to content

Commit 44d0100

Browse files
pmbrullclaude
andauthored
Fixes open-metadata#31459: actionable errors when the OMeta client cannot start (open-metadata#31461)
* Fixes open-metadata#31459: actionable errors when the OMeta client cannot start Every workflow starts with GET /system/version. When that call did not return the expected JSON, the client returned the raw `Response` as if it were the decoded body, and the user got `TypeError: 'Response' object is not subscriptable` several frames from the real misconfiguration. Name each failure mode instead: - HTML body (hostPort missing `/api`, so the request hit the UI catch-all) now raises `HtmlResponseError` from the client itself, since no endpoint legitimately answers HTML. CSV and ODCS-YAML exports still get their `Response` back. - `get_server_version` reads the raw response and classifies 401/403, 404, other non-2xx, non-JSON bodies, a missing `version` field and an exhausted retry budget, each naming the URL actually called. - The version mismatch message now carries the remedy (which client version to install). - `create_ometa_client` keeps the exception class, the host and the cause instead of flattening everything into `ValueError(str)`. - `health_check` reuses `get_server_version` rather than repeating the same unguarded subscript. `OMetaClientInitError` subclasses `ValueError` so existing callers keep working. Drops the two subscript errors this fixes from the basedpyright baseline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Address review: keep the HTML error provider-neutral in REST Two findings on open-metadata#31461. IceS2: `REST` is not OpenMetadata-specific — around 25 connectors import it to call third-party APIs — so an HTML login page from, say, Superset would have told the user to add `/api` to `hostPort`. The message raised by the client is now provider-neutral, and callers that know which API they were talking to pass a `hint`. `server_mixin`, which does know the target is an OpenMetadata server, supplies the `hostPort`/`/api` advice. gitar-bot: `_read_version` sniffed HTML with a bare `startswith("<")` while the client also required an `<html` tag, so an XML or SVG error page was reported as a UI page with a misleading hint. Both paths now share the helper, promoted to `is_html_body`. Adds tests for the neutral client message and for an XML body reaching the "not JSON" branch rather than the HTML one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Make the HTML-response error opt-in per client CI caught the real cost of raising from the transport layer: test_ingestion_get_version_still_tolerates_a_non_json_reply failed because `AirflowApiClient` shares this `REST` class and deliberately splits its two paths — `get_version` (ingestion) parses a bad body to `{}`, while `test_get_version` (connection gate) is strict via `get_raw`. Raising on HTML broke the lenient one, which is exactly what that test guards. Add `ClientConfig.raise_on_html`, default False, and set it only where the OpenMetadata client is built. The OpenMetadata API never answers HTML, so the guard still covers its endpoints; the ~20 connectors that construct their own `ClientConfig` keep the behaviour they had. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 796378b commit 44d0100

6 files changed

Lines changed: 454 additions & 59 deletions

File tree

ingestion/.basedpyright/baseline.json

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -13686,24 +13686,6 @@
1368613686
}
1368713687
}
1368813688
],
13689-
"./src/metadata/ingestion/ometa/mixins/server_mixin.py": [
13690-
{
13691-
"code": "reportIndexIssue",
13692-
"range": {
13693-
"startColumn": 26,
13694-
"endColumn": 60,
13695-
"lineCount": 1
13696-
}
13697-
},
13698-
{
13699-
"code": "reportOptionalSubscript",
13700-
"range": {
13701-
"startColumn": 26,
13702-
"endColumn": 60,
13703-
"lineCount": 1
13704-
}
13705-
}
13706-
],
1370713689
"./src/metadata/ingestion/ometa/mixins/service_mixin.py": [
1370813690
{
1370913691
"code": "reportInvalidTypeVarUse",
@@ -15208,22 +15190,6 @@
1520815190
"endColumn": 65,
1520915191
"lineCount": 1
1521015192
}
15211-
},
15212-
{
15213-
"code": "reportIndexIssue",
15214-
"range": {
15215-
"startColumn": 22,
15216-
"endColumn": 56,
15217-
"lineCount": 1
15218-
}
15219-
},
15220-
{
15221-
"code": "reportOptionalSubscript",
15222-
"range": {
15223-
"startColumn": 22,
15224-
"endColumn": 56,
15225-
"lineCount": 1
15226-
}
1522715193
}
1522815194
],
1522915195
"./src/metadata/ingestion/ometa/sse_client.py": [

ingestion/src/metadata/ingestion/ometa/client.py

Lines changed: 74 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,71 @@ def __init__(self, method: str, url: object, cause: BaseException) -> None:
5555
self.cause = cause
5656

5757

58+
class HtmlResponseError(Exception):
59+
"""An HTML page came back where the API answers JSON.
60+
61+
The body is a web page, not an API response: the request reached a UI, a login
62+
page or a proxy rather than the endpoint. Raised instead of handing the caller a
63+
``Response`` it would try to subscript.
64+
65+
``REST`` is generic - connectors use it against third-party APIs too - so the
66+
message stays provider-neutral. Callers that know which API they were talking to
67+
pass a ``hint`` with the advice specific to it.
68+
"""
69+
70+
def __init__(self, url: object, status_code: int, hint: Optional[str] = None) -> None: # noqa: UP045
71+
super().__init__(
72+
f"Got an HTML page instead of JSON from [{url}] (HTTP {status_code})."
73+
" The endpoint served a web page, not an API response - check the configured"
74+
" host/URL and that no proxy or login page is intercepting the call." + (f" {hint}" if hint else "")
75+
)
76+
self.url = url
77+
self.status_code = status_code
78+
79+
80+
def is_html_body(resp: requests.Response) -> bool:
81+
"""Whether a non-JSON body is an HTML page.
82+
83+
Content type first; some proxies mislabel index.html as text/plain, so fall back
84+
to sniffing an `<html` tag in the head of the body. CSV and ODCS-YAML exports are
85+
legitimate non-JSON payloads and must not match.
86+
"""
87+
if "html" in resp.headers.get("Content-Type", "").lower():
88+
return True
89+
head = resp.text[:2048].lstrip()
90+
return head.startswith("<") and "<html" in head.lower()
91+
92+
93+
def _decode_body(resp: requests.Response, url: object, raise_on_html: bool = False):
94+
"""Decode a successful response body.
95+
96+
JSON when it parses; otherwise the ``Response`` itself, for the text payloads
97+
some endpoints answer with (CSV and ODCS-YAML exports).
98+
99+
``raise_on_html`` turns an HTML page into an ``HtmlResponseError`` instead. It
100+
is opt-in because callers disagree on what HTML means: the OpenMetadata API
101+
never answers it, but connectors share this client and some deliberately
102+
tolerate a non-JSON reply on their ingestion path.
103+
"""
104+
try:
105+
return resp.json()
106+
except JSONDecodeError as json_decode_error:
107+
if raise_on_html and is_html_body(resp):
108+
raise HtmlResponseError(url, resp.status_code) from json_decode_error
109+
logger.debug(
110+
"Non-JSON response (%s) from [%s] with content type [%s] returned as-is: %s",
111+
resp.status_code,
112+
url,
113+
resp.headers.get("Content-Type", "unknown"),
114+
json_decode_error,
115+
)
116+
return resp
117+
except Exception as exc:
118+
logger.debug(traceback.format_exc())
119+
logger.warning(f"Unexpected error while returning response {resp} in json format - {exc}")
120+
return None
121+
122+
58123
class APIError(Exception):
59124
"""
60125
Represent API related error.
@@ -129,6 +194,10 @@ class ClientConfig(ConfigModel):
129194
user_agent: Optional[str] = None # noqa: UP045
130195
raw_data: Optional[bool] = False # noqa: UP045
131196
allow_redirects: Optional[bool] = False # noqa: UP045
197+
# Treat an HTML body as an error rather than handing the caller the raw
198+
# Response. Off by default: connectors share this client against third-party
199+
# APIs, and some tolerate a non-JSON reply on purpose.
200+
raise_on_html: bool = False
132201
auth_token_mode: Optional[str] = "Bearer" # noqa: UP045
133202
verify: Optional[Union[bool, str]] = None # noqa: UP007, UP045
134203
cookies: Optional[Any] = None # noqa: UP045
@@ -309,18 +378,7 @@ def _one_request(self, method: str, url: URL, opts: dict, retry: int, raw: bool
309378
return resp
310379

311380
if resp.text != "":
312-
try:
313-
return resp.json()
314-
except JSONDecodeError as json_decode_error:
315-
logger.debug(
316-
"Non-JSON response (%s) returned as-is: %s",
317-
resp.status_code,
318-
json_decode_error,
319-
)
320-
return resp
321-
except Exception as exc:
322-
logger.debug(traceback.format_exc())
323-
logger.warning(f"Unexpected error while returning response {resp} in json format - {exc}")
381+
return _decode_body(resp, url, self.config.raise_on_html)
324382

325383
except HTTPError as http_error:
326384
# retry if we hit Rate Limit
@@ -336,6 +394,10 @@ def _one_request(self, method: str, url: URL, opts: dict, retry: int, raw: bool
336394
raise APIError(error, http_error) from http_error
337395
else:
338396
raise
397+
except HtmlResponseError:
398+
# Already carries the actionable message; the catch-all below would
399+
# downgrade it to a warning and hand the caller a None.
400+
raise
339401
except (
340402
requests.exceptions.ConnectionError,
341403
requests.exceptions.Timeout,

ingestion/src/metadata/ingestion/ometa/client_utils.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@
2727
logger = ometa_logger()
2828

2929

30+
class OMetaClientInitError(ValueError):
31+
"""The OpenMetadata client could not be initialized.
32+
33+
Subclasses ValueError for backwards compatibility with callers that already
34+
catch the flattened error this used to raise.
35+
"""
36+
37+
3038
def create_ometa_client(
3139
metadata_config: OpenMetadataConnection,
3240
user_agent: Optional[str] = None, # noqa: UP045
@@ -50,8 +58,12 @@ def create_ometa_client(
5058
return metadata # noqa: TRY300
5159
except Exception as exc:
5260
logger.debug(traceback.format_exc())
53-
logger.warning(f"Wild error initialising the OMeta Client {exc}")
54-
raise ValueError(exc) # noqa: B904
61+
# `raise ValueError(exc)` used to drop the class name, so a TypeError from a
62+
# bad hostPort reached the user as a bare string with no hint of its origin.
63+
raise OMetaClientInitError(
64+
f"Could not initialize the OpenMetadata client against [{metadata_config.hostPort}]:"
65+
f" {type(exc).__name__}: {exc}"
66+
) from exc
5567

5668

5769
def get_chart_entities_from_id(

ingestion/src/metadata/ingestion/ometa/mixins/server_mixin.py

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,31 @@
1616

1717
from typing import Optional
1818

19+
import requests
20+
from requests.exceptions import JSONDecodeError
21+
1922
from metadata.__version__ import (
2023
get_client_version,
2124
get_server_version_from_string,
2225
match_versions,
2326
)
2427
from metadata.generated.schema.settings.settings import Settings, SettingType
25-
from metadata.ingestion.ometa.client import REST
28+
from metadata.ingestion.ometa.client import REST, HtmlResponseError, is_html_body
2629
from metadata.ingestion.ometa.routes import ROUTES
2730
from metadata.utils.logger import ometa_logger
2831

2932
logger = ometa_logger()
3033

34+
VERSION_PATH = "/system/version"
35+
36+
# `REST` cannot say this - connectors use it against third-party APIs - but here we
37+
# know the target is an OpenMetadata server, whose UI answers index.html for unknown
38+
# routes. A `hostPort` missing `/api` therefore lands on the UI with a 200.
39+
HOST_PORT_HINT = (
40+
"The OpenMetadata UI answers index.html for unknown routes, so `hostPort` most likely"
41+
" does not point at the API: check that it ends in `/api` (e.g. https://<host>/api)."
42+
)
43+
3144

3245
class VersionMismatchException(Exception): # noqa: N818
3346
"""
@@ -73,24 +86,72 @@ def get_server_version(self) -> str:
7386
Run endpoint /system/version to check server version
7487
:return: Server version
7588
"""
89+
response = self.client.get_raw(VERSION_PATH)
90+
return get_server_version_from_string(self._read_version(response))
91+
92+
@staticmethod
93+
def _read_version(response: Optional[requests.Response]) -> str: # noqa: UP045
94+
"""Pull `version` out of a /system/version response, or say why we can't.
95+
96+
This is the first call every workflow makes, so it is where a wrong
97+
`hostPort`, a bad token or an unreachable API shows up. Each case gets its
98+
own message - the generic path used to end in a `TypeError` several frames
99+
away from the actual misconfiguration.
100+
"""
101+
if response is None:
102+
# `_request` returns None once the 504/429 retry budget is exhausted.
103+
raise VersionNotFoundException(
104+
f"No response from {VERSION_PATH} after exhausting the retry budget."
105+
" The server is unreachable or persistently returning 429/504."
106+
)
107+
url = response.url
108+
if response.status_code in (401, 403):
109+
raise VersionNotFoundException(
110+
f"Not authorized to read [{url}] (HTTP {response.status_code})."
111+
" Check the JWT token / auth provider set in `workflowConfig.openMetadataServerConfig`"
112+
" and that the bot user is still active."
113+
)
114+
if response.status_code == 404:
115+
raise VersionNotFoundException(
116+
f"No OpenMetadata API found at [{url}] (HTTP 404)."
117+
" Check `hostPort` and `apiVersion` in `workflowConfig.openMetadataServerConfig`."
118+
)
119+
if not response.ok:
120+
raise VersionNotFoundException(
121+
f"Cannot read the server version from [{url}] (HTTP {response.status_code}): {response.text[:500]}"
122+
)
123+
76124
try:
77-
raw_version = self.client.get("/system/version")["version"]
78-
except KeyError:
79-
raise VersionNotFoundException( # noqa: B904
80-
"Cannot Find Version at api/v1/system/version."
81-
+ " If running the server in DEV mode locally, make sure to `mvn clean install`."
125+
payload = response.json()
126+
except JSONDecodeError as exc:
127+
# `get_raw` skips the client's JSON handling, so classify the body here,
128+
# with the same sniffing the client uses.
129+
if is_html_body(response):
130+
raise HtmlResponseError(url, response.status_code, hint=HOST_PORT_HINT) from exc
131+
raise VersionNotFoundException(
132+
f"The response from [{url}] is not JSON"
133+
f" (content type [{response.headers.get('Content-Type', 'unknown')}]): {response.text[:500]}"
134+
) from exc
135+
136+
if not isinstance(payload, dict) or "version" not in payload:
137+
raise VersionNotFoundException(
138+
f"No `version` field in the response from [{url}]: {str(payload)[:500]}."
139+
" If running the server in DEV mode locally, make sure to `mvn clean install`."
82140
)
83-
return get_server_version_from_string(raw_version)
141+
return payload["version"]
84142

85143
def validate_versions(self) -> None:
86144
"""
87145
Validate Server & Client versions. They should match.
88146
Otherwise, raise VersionMismatchException.
89147
"""
90148
if not match_versions(self.server_version, self.client_version):
149+
major_minor = ".".join(self.server_version.split(".")[:2])
91150
raise VersionMismatchException(
92151
f"Server version is {self.server_version} vs. Client version {self.client_version}."
93-
f" Major and minor versions should match."
152+
f" Major and minor versions should match. Either install the matching client with"
153+
f" `pip install 'openmetadata-ingestion~={major_minor}.0'` or point `hostPort` at a"
154+
f" {self.client_version} server."
94155
)
95156

96157
def log_server_version(self) -> None:

ingestion/src/metadata/ingestion/ometa/ometa_api.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,9 @@ def __init__(
339339
extra_headers=extra_headers,
340340
auth_token=self._auth_provider.get_access_token,
341341
verify=get_verify_ssl(self.config.sslConfig),
342+
# The OpenMetadata API never answers HTML, so a page here means the
343+
# request reached the UI or a proxy instead of the API.
344+
raise_on_html=True,
342345
**(additional_client_config_arguments or {}),
343346
)
344347

@@ -1049,10 +1052,9 @@ def bulk_create_or_update(
10491052

10501053
def health_check(self) -> bool:
10511054
"""
1052-
Run version api call. Return `true` if response is not None
1055+
Run version api call. Raises with an actionable message if the API is not reachable.
10531056
"""
1054-
raw_version = self.client.get("/system/version")["version"]
1055-
return raw_version is not None
1057+
return bool(self.get_server_version())
10561058

10571059
def close(self):
10581060
"""

0 commit comments

Comments
 (0)