This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/dev/pr-12629-0b9e75a6ceb846cd08b06184127560cbd96c01b1 in repository https://gitbox.apache.org/repos/asf/seatunnel.git
commit 6071eabae585977efbb7f15e066d0787f9c0bf2c Author: SEZ <[email protected]> AuthorDate: Fri Oct 9 08:08:08 2026 +0000 [Improve][seatunnel-cli] Route engine REST calls through one module (#12629) --- seatunnel-cli/seatunnel_cli/agents.py | 27 ++---- seatunnel-cli/seatunnel_cli/cli.py | 32 ++----- seatunnel-cli/seatunnel_cli/connectors.py | 22 ++--- seatunnel-cli/seatunnel_cli/rest.py | 106 ++++++++++++++++++++++ seatunnel-cli/tests/test_rest.py | 146 ++++++++++++++++++++++++++++++ 5 files changed, 274 insertions(+), 59 deletions(-) diff --git a/seatunnel-cli/seatunnel_cli/agents.py b/seatunnel-cli/seatunnel_cli/agents.py index 77f0b707e8..81c42ed7a4 100644 --- a/seatunnel-cli/seatunnel_cli/agents.py +++ b/seatunnel-cli/seatunnel_cli/agents.py @@ -664,28 +664,13 @@ def dry_run_config(config_str: str) -> dict: pass # ── Phase 3: REST API validation ── - from .connectors import _check_engine, _ENGINE_API_BASE + from .connectors import _check_engine if _check_engine(): - try: - import urllib.request - # Use submit-job endpoint with a dry-run approach: - # We validate by checking if config parses on server side - # without actually starting the job - url = f"{_ENGINE_API_BASE}/submit-job" - headers = {"Content-Type": "application/json"} - # Submit with an invalid job name pattern to trigger validation - # without actual execution — this is a best-effort approach - # since SeaTunnel doesn't have a dedicated validate endpoint - data = json.dumps({ - "env": {"job.mode": "BATCH"}, - "params": {"config": config_str, "format": "hocon"}, - }).encode("utf-8") - - # For now, just verify the config format is accepted by the API - # A full submit-and-cancel approach would be too risky - result["phase3_api"] = "SKIPPED (no dedicated validate endpoint)" - except Exception as e: - result["phase3_api"] = f"ERROR: {e}" + # The engine has no validate-only endpoint, and submitting a real job to + # validate it is not an option, so there is nothing to call here. This + # used to build a request URL, headers and body and then discard them + # without ever sending anything; only the status below had any effect. + result["phase3_api"] = "SKIPPED (no dedicated validate endpoint)" # ── Summary ── phases_passed = ["Local: " + local_result] diff --git a/seatunnel-cli/seatunnel_cli/cli.py b/seatunnel-cli/seatunnel_cli/cli.py index 83db23a9e8..b7e4512b0e 100644 --- a/seatunnel-cli/seatunnel_cli/cli.py +++ b/seatunnel-cli/seatunnel_cli/cli.py @@ -38,7 +38,7 @@ from prompt_toolkit import prompt as pt_prompt from prompt_toolkit.completion import WordCompleter from prompt_toolkit.history import FileHistory -from . import __version__, get_data_dir +from . import __version__, get_data_dir, rest from .diagnostics import ParsedError, parse_error from .llm_provider import create_provider, format_llm_error from .agents import Orchestrator @@ -1331,29 +1331,21 @@ class SeaTunnelCLI: def _run_via_rest_api(self, api_base: str): """Submit job via REST API and poll status.""" - import json as _json - import urllib.request - import urllib.error - self.console.print(" Submitting job via REST API...", style="info") try: - url = f"{api_base}/submit-job?format=hocon" - req = urllib.request.Request( - url, + body = rest.request_json( + f"{api_base}/submit-job?format=hocon", + method="POST", data=self.last_config.encode("utf-8"), headers={"Content-Type": "text/plain"}, - method="POST", + timeout=30, ) - with urllib.request.urlopen(req, timeout=30) as resp: - body = _json.loads(resp.read().decode("utf-8")) - job_id = body.get("jobId") job_name = body.get("jobName", "") self.console.print(f" Job submitted: [bold]{job_id}[/bold] ({job_name})", style="success") - except urllib.error.HTTPError as e: - error_body = e.read().decode("utf-8", errors="replace") - self.console.print(f" [error]Submit failed ({e.code}):[/error]") - self._show_error_and_diagnose(error_body) + except rest.RestError as e: + self.console.print(f" [error]Submit failed ({e.status}):[/error]") + self._show_error_and_diagnose(e.body) return except Exception as e: self.console.print(f" [error]Submit failed: {e}[/error]") @@ -1364,9 +1356,6 @@ class SeaTunnelCLI: def _poll_job_status(self, api_base: str, job_id: str): """Poll job status until terminal state, then show results.""" - import json as _json - import urllib.request - terminal_states = {"FINISHED", "CANCELED", "FAILED"} self.console.print(" Waiting for job to complete...", style="info") @@ -1377,10 +1366,7 @@ class SeaTunnelCLI: for _ in range(max_polls): time.sleep(poll_interval) try: - url = f"{api_base}/job-info/{job_id}" - req = urllib.request.Request(url, method="GET") - with urllib.request.urlopen(req, timeout=10) as resp: - info = _json.loads(resp.read().decode("utf-8")) + info = rest.request_json(f"{api_base}/job-info/{job_id}", timeout=10) status = info.get("jobStatus", "UNKNOWN") if status != last_status: diff --git a/seatunnel-cli/seatunnel_cli/connectors.py b/seatunnel-cli/seatunnel_cli/connectors.py index 1fa2e73769..ecce50c197 100644 --- a/seatunnel-cli/seatunnel_cli/connectors.py +++ b/seatunnel-cli/seatunnel_cli/connectors.py @@ -40,10 +40,10 @@ import os import re import subprocess import time -import urllib.request -import urllib.error from pathlib import Path +from . import rest + logger = logging.getLogger(__name__) # ─── Runtime API client ─── @@ -72,12 +72,7 @@ def _check_engine() -> bool: now = time.monotonic() if _ENGINE_AVAILABLE is not None and (now - _ENGINE_CHECK_TS) < 60: return _ENGINE_AVAILABLE - try: - req = urllib.request.Request(f"{_ENGINE_API_BASE}/running-jobs", method="GET") - with urllib.request.urlopen(req, timeout=2) as resp: - _ENGINE_AVAILABLE = resp.status == 200 - except Exception: - _ENGINE_AVAILABLE = False + _ENGINE_AVAILABLE = rest.is_reachable(f"{_ENGINE_API_BASE}/running-jobs", timeout=2) _ENGINE_CHECK_TS = now return _ENGINE_AVAILABLE @@ -127,13 +122,10 @@ def _fetch_option_rules(plugin_type: str, plugin_name: str) -> dict | None: if _check_engine(): try: url = f"{_ENGINE_API_BASE}/option-rules?type={plugin_type}&plugin={plugin_name}" - req = urllib.request.Request(url, method="GET") - with urllib.request.urlopen(req, timeout=5) as resp: - if resp.status == 200: - data = json.loads(resp.read().decode("utf-8")) - _API_CACHE[cache_key] = data - _write_disk_cache(cache_key, data) - return data + data = rest.request_json(url, timeout=5) + _API_CACHE[cache_key] = data + _write_disk_cache(cache_key, data) + return data except Exception as e: logger.debug(f"option-rules API call failed for {cache_key}: {e}") diff --git a/seatunnel-cli/seatunnel_cli/rest.py b/seatunnel-cli/seatunnel_cli/rest.py new file mode 100644 index 0000000000..de844889c0 --- /dev/null +++ b/seatunnel-cli/seatunnel_cli/rest.py @@ -0,0 +1,106 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +"""One place where the CLI talks to the SeaTunnel engine REST API. + +The same six lines of `urllib.request.Request` / `urlopen` / `json.loads` were +repeated at four call sites across three modules, each with its own timeout and +its own idea of what to do with a non-200. That made two things awkward: there +was nowhere to put behaviour that every engine call needs (the response size +bound below), and a caller that wanted an HTTP error *body* had to reach into +`urllib.error.HTTPError` itself. + +Nothing here adds a dependency; it is the same `urllib` calls behind one door. +""" + +import json +import logging +import urllib.error +import urllib.request + +logger = logging.getLogger(__name__) + +# Engine responses are JSON status documents, not payloads. A job list on a busy +# cluster or an errorMsg carrying a full stack trace is the realistic upper end, +# so this is generous while still refusing to buffer an unbounded body into +# memory from a host the CLI does not control. +MAX_RESPONSE_BYTES = 8 * 1024 * 1024 + +DEFAULT_TIMEOUT = 10 + + +class RestError(Exception): + """An HTTP-level failure, carrying the body so callers can diagnose it. + + `_run_via_rest_api` needs the response body of a failed submit -- that is + where the engine explains why a config was rejected -- so the body is read + here rather than leaving callers to remember that `HTTPError` is also a + readable file object. + """ + + def __init__(self, status: int, body: str, url: str): + super().__init__(f"HTTP {status} from {url}") + self.status = status + self.body = body + self.url = url + + +def request_json( + url: str, + *, + method: str = "GET", + data: bytes | None = None, + headers: dict[str, str] | None = None, + timeout: float = DEFAULT_TIMEOUT, +) -> dict: + """Call the engine and return the decoded JSON body. + + Raises `RestError` for an HTTP error status; transport failures and + undecodable bodies propagate as-is, because the callers here already treat + "could not reach the engine" and "engine said no" differently. + """ + req = urllib.request.Request( + url, data=data, headers=headers or {}, method=method + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw = resp.read(MAX_RESPONSE_BYTES + 1) + except urllib.error.HTTPError as e: + body = e.read(MAX_RESPONSE_BYTES).decode("utf-8", errors="replace") + raise RestError(e.code, body, url) from e + + if len(raw) > MAX_RESPONSE_BYTES: + raise RestError( + 0, + f"response from {url} exceeded {MAX_RESPONSE_BYTES} bytes", + url, + ) + return json.loads(raw.decode("utf-8")) + + +def is_reachable(url: str, *, timeout: float = 2) -> bool: + """Whether the engine answered 200 at `url`. Never raises. + + Used for the liveness probe, where any failure -- refused, timed out, 500 -- + means the same thing to the caller: fall back to offline metadata. + """ + req = urllib.request.Request(url, method="GET") + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.status == 200 + except Exception: + return False diff --git a/seatunnel-cli/tests/test_rest.py b/seatunnel-cli/tests/test_rest.py new file mode 100644 index 0000000000..5562723a49 --- /dev/null +++ b/seatunnel-cli/tests/test_rest.py @@ -0,0 +1,146 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +"""Tests for the shared engine REST helper. + +These run against a real loopback HTTP server rather than a patched `urlopen`, +so they exercise the actual `urllib` behaviour the call sites depend on -- +in particular that an error *body* survives, which is what the submit path +feeds into failure diagnosis. +""" + +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest + +from seatunnel_cli import rest + + +class _Handler(BaseHTTPRequestHandler): + """Serves whatever the active test put in `self.server.script`.""" + + def log_message(self, *args): # keep pytest output clean + pass + + def _respond(self): + status, body = self.server.script(self) + payload = body.encode("utf-8") if isinstance(body, str) else body + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + do_GET = _respond + do_POST = _respond + + [email protected] +def server(): + httpd = HTTPServer(("127.0.0.1", 0), _Handler) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + httpd.base = f"http://127.0.0.1:{httpd.server_port}" + try: + yield httpd + finally: + httpd.shutdown() + httpd.server_close() + + +def test_get_returns_decoded_json(server): + server.script = lambda h: (200, json.dumps({"jobStatus": "RUNNING"})) + + assert rest.request_json(f"{server.base}/job-info/7") == {"jobStatus": "RUNNING"} + + +def test_post_sends_body_and_headers(server): + seen = {} + + def script(handler): + seen["method"] = handler.command + seen["type"] = handler.headers.get("Content-Type") + length = int(handler.headers.get("Content-Length", 0)) + seen["body"] = handler.rfile.read(length).decode("utf-8") + return 200, json.dumps({"jobId": "852"}) + + server.script = script + + body = rest.request_json( + f"{server.base}/submit-job?format=hocon", + method="POST", + data=b"env {}\n", + headers={"Content-Type": "text/plain"}, + timeout=5, + ) + + assert body == {"jobId": "852"} + assert seen["method"] == "POST" + assert seen["type"] == "text/plain" + assert seen["body"] == "env {}\n" + + +def test_http_error_keeps_status_and_body(server): + # The submit path shows this body to the user and feeds it to diagnosis, + # so losing it would turn a specific rejection into "Submit failed (400)". + server.script = lambda h: (400, "ErrorCode:[API-02], ErrorDescription:[bad config]") + + with pytest.raises(rest.RestError) as excinfo: + rest.request_json(f"{server.base}/submit-job") + + assert excinfo.value.status == 400 + assert "ErrorCode:[API-02]" in excinfo.value.body + assert excinfo.value.url.endswith("/submit-job") + + +def test_oversized_response_is_refused_rather_than_buffered(server): + big = json.dumps({"errorMsg": "x" * (rest.MAX_RESPONSE_BYTES + 64)}) + server.script = lambda h: (200, big) + + with pytest.raises(rest.RestError) as excinfo: + rest.request_json(f"{server.base}/job-info/7") + + assert "exceeded" in excinfo.value.body + + +def test_a_response_at_the_limit_is_still_accepted(server): + filler = "y" * (rest.MAX_RESPONSE_BYTES - len('{"errorMsg": ""}')) + payload = json.dumps({"errorMsg": filler}) + assert len(payload) == rest.MAX_RESPONSE_BYTES + server.script = lambda h: (200, payload) + + assert rest.request_json(f"{server.base}/job-info/7")["errorMsg"] == filler + + +def test_is_reachable_true_on_200(server): + server.script = lambda h: (200, "[]") + + assert rest.is_reachable(f"{server.base}/running-jobs") is True + + +def test_is_reachable_false_on_error_status(server): + server.script = lambda h: (500, "boom") + + assert rest.is_reachable(f"{server.base}/running-jobs") is False + + +def test_is_reachable_false_when_nothing_is_listening(): + # The liveness probe must not raise: every failure means "engine offline" + # and the caller falls back to offline metadata. + assert rest.is_reachable("http://127.0.0.1:1/running-jobs", timeout=1) is False
