Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions superset/commands/report/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,11 @@ def _get_csv_data(self) -> bytes:
self._update_query_context()

try:
csv_data = get_chart_csv_data(chart_url=url, auth_cookies=auth_cookies)
csv_data = get_chart_csv_data(
chart_url=url,
auth_cookies=auth_cookies,
timeout=app.config["ALERT_REPORTS_CSV_REQUEST_TIMEOUT"],
)
elapsed_seconds = (datetime.utcnow() - start_time).total_seconds()
logger.info(
"CSV data generation from %s as user %s took %.2fs - execution_id: %s",
Expand Down Expand Up @@ -575,7 +579,11 @@ def _get_embedded_data(self) -> pd.DataFrame:
self._update_query_context()

try:
dataframe = get_chart_dataframe(url, auth_cookies)
dataframe = get_chart_dataframe(
url,
auth_cookies,
timeout=app.config["ALERT_REPORTS_CSV_REQUEST_TIMEOUT"],
)
elapsed_seconds = (datetime.utcnow() - start_time).total_seconds()
logger.info(
"DataFrame generation from %s as user %s took %.2fs - execution_id: %s",
Expand Down
23 changes: 23 additions & 0 deletions superset/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1153,6 +1153,12 @@ class D3TimeFormat(TypedDict, total=False):
# Time before selenium times out after waiting for all DOM class elements named
# "loading" are gone.
SCREENSHOT_LOAD_WAIT = int(timedelta(minutes=1).total_seconds())
# Maximum time (in seconds) selenium waits for an initial page navigation
# (driver.get) to complete. Without it the navigation blocks indefinitely when
# the target page never finishes loading (e.g. an unreachable WEBDRIVER_BASEURL),
# which leaves the report schedule stuck in the WORKING state. Set to None to
# disable (not recommended).
SCREENSHOT_PAGE_LOAD_WAIT = int(timedelta(minutes=2).total_seconds())
# Selenium destroy retries
SCREENSHOT_SELENIUM_RETRIES = 5
# Give selenium an headstart, in seconds
Expand Down Expand Up @@ -1736,6 +1742,11 @@ def allowed_schemas_for_csv_upload( # pylint: disable=unused-argument
# If True creates a default SSL context with ssl.Purpose.CLIENT_AUTH using the
# default system root CA certificates.
SMTP_SSL_SERVER_AUTH = False
# Socket timeout (in seconds) for the SMTP connection used when sending
# alert/report emails. Without a timeout the underlying socket blocks
# indefinitely if the SMTP server becomes unreachable, which leaves report
# schedules stuck in the WORKING state. Set to None to disable (not recommended).
SMTP_TIMEOUT = 30
ENABLE_CHUNK_ENCODING = False

# Whether to bump the logging level to ERROR on the flask_appbuilder package
Expand Down Expand Up @@ -2084,6 +2095,12 @@ def EMAIL_HEADER_MUTATOR( # pylint: disable=invalid-name,unused-argument # noq
# Max tries to run queries to prevent false errors caused by transient errors
# being returned to users. Set to a value >1 to enable retries.
ALERT_REPORTS_QUERY_EXECUTION_MAX_TRIES = 1
# Socket timeout (in seconds) for the HTTP request that fetches chart data when
# generating CSV/dataframe report attachments. Without a timeout the request
# blocks indefinitely if the Superset webserver is unreachable from the worker,
# which leaves the report schedule stuck in the WORKING state. Set to None to
# disable (not recommended).
ALERT_REPORTS_CSV_REQUEST_TIMEOUT = 60
# Custom width for screenshots
ALERT_REPORTS_MIN_CUSTOM_SCREENSHOT_WIDTH = 600
ALERT_REPORTS_MAX_CUSTOM_SCREENSHOT_WIDTH = 2400
Expand Down Expand Up @@ -2128,6 +2145,12 @@ def EMAIL_HEADER_MUTATOR( # pylint: disable=invalid-name,unused-argument # noq
# For workspaces with 10k+ channels, consider increasing to 10
SLACK_API_RATE_LIMIT_RETRY_COUNT = 2

# Timeout (in seconds) for outbound Slack API calls. The Slack SDK defaults to 30s;
# exposing it here lets operators grant more time for large file uploads (multi-MB
# CSVs, PDFs, screenshot sets) to congested or rate-limited Slack endpoints without
# patching code, consistent with the SMTP/CSV/screenshot timeouts.
SLACK_API_TIMEOUT = 30

# The webdriver to use for generating reports when using Selenium (not Playwright).
# This setting is ignored when PLAYWRIGHT_REPORTS_AND_THUMBNAILS is enabled, as
# Playwright always uses Chromium regardless of this value.
Expand Down
33 changes: 24 additions & 9 deletions superset/utils/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -938,6 +938,11 @@ def send_mime_email(
smtp_starttls = config["SMTP_STARTTLS"]
smtp_ssl = config["SMTP_SSL"]
smtp_ssl_server_auth = config["SMTP_SSL_SERVER_AUTH"]
# A missing timeout means the socket blocks forever when the SMTP server is
# unreachable, wedging the report schedule in the WORKING state. Fall back to
# the key being absent for backwards compatibility with custom configs.
# Keep this fallback in sync with the SMTP_TIMEOUT default in config.py.
smtp_timeout = config.get("SMTP_TIMEOUT", 30)

if dryrun:
logger.info("Dryrun enabled, email notification content is below:")
Expand All @@ -948,17 +953,27 @@ def send_mime_email(
# root CA certificates
ssl_context = ssl.create_default_context() if smtp_ssl_server_auth else None
smtp = (
smtplib.SMTP_SSL(smtp_host, smtp_port, context=ssl_context)
smtplib.SMTP_SSL(
smtp_host, smtp_port, context=ssl_context, timeout=smtp_timeout
)
if smtp_ssl
else smtplib.SMTP(smtp_host, smtp_port)
else smtplib.SMTP(smtp_host, smtp_port, timeout=smtp_timeout)
)
Comment thread
rusackas marked this conversation as resolved.
if smtp_starttls:
smtp.starttls(context=ssl_context)
if smtp_user and smtp_password:
smtp.login(smtp_user, smtp_password)
logger.debug("Sent an email to %s", str(e_to))
smtp.sendmail(e_from, e_to, mime_msg.as_string())
smtp.quit()
try:
if smtp_starttls:
smtp.starttls(context=ssl_context)
if smtp_user and smtp_password:
smtp.login(smtp_user, smtp_password)
logger.debug("Sent an email to %s", str(e_to))
smtp.sendmail(e_from, e_to, mime_msg.as_string())
finally:
# Always release the socket; the new timeout means starttls/login/
# sendmail can raise, and a skipped quit() would leak connections in
# the long-lived worker process.
try:
smtp.quit()
except smtplib.SMTPException:
pass


def recipients_string_to_list(address_string: str | None) -> list[str]:
Expand Down
14 changes: 10 additions & 4 deletions superset/utils/csv.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,18 @@ def escape_values(v: Any) -> Union[str, Any]:


def get_chart_csv_data(
chart_url: str, auth_cookies: Optional[dict[str, str]] = None
chart_url: str,
auth_cookies: Optional[dict[str, str]] = None,
timeout: Optional[float] = None,
) -> Optional[bytes]:
content = None
if auth_cookies:
opener = urllib.request.build_opener()
cookie_str = ";".join([f"{key}={val}" for key, val in auth_cookies.items()])
opener.addheaders.append(("Cookie", cookie_str))
response = opener.open(chart_url)
# A missing timeout means the socket blocks forever when the Superset
# webserver is unreachable, wedging the report schedule in WORKING.
response = opener.open(chart_url, timeout=timeout)
content = response.read()
if response.getcode() != 200:
raise URLError(response.getcode())
Expand All @@ -107,11 +111,13 @@ def get_chart_csv_data(


def get_chart_dataframe(
chart_url: str, auth_cookies: Optional[dict[str, str]] = None
chart_url: str,
auth_cookies: Optional[dict[str, str]] = None,
timeout: Optional[float] = None,
) -> Optional[pd.DataFrame]:
# Disable all the unnecessary-lambda violations in this function
# pylint: disable=unnecessary-lambda
content = get_chart_csv_data(chart_url, auth_cookies)
content = get_chart_csv_data(chart_url, auth_cookies, timeout)
if content is None:
return None

Expand Down
6 changes: 5 additions & 1 deletion superset/utils/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@ def get_slack_client() -> WebClient:
token: str = app.config["SLACK_API_TOKEN"]
if callable(token):
token = token()
client = WebClient(token=token, proxy=app.config["SLACK_PROXY"])
client = WebClient(
token=token,
proxy=app.config["SLACK_PROXY"],
timeout=app.config["SLACK_API_TIMEOUT"],
)

max_retry_count = app.config.get("SLACK_API_RATE_LIMIT_RETRY_COUNT", 2)
rate_limit_handler = RateLimitErrorRetryHandler(max_retry_count=max_retry_count)
Expand Down
20 changes: 17 additions & 3 deletions superset/utils/webdriver.py
Original file line number Diff line number Diff line change
Expand Up @@ -461,9 +461,23 @@ def driver(self) -> WebDriver:
self._driver = self._create()
if not self._driver:
raise RuntimeError("WebDriver creation failed")
self._driver.set_window_size(*self._window)
if self._user:
self._auth(self._user)
try:
self._driver.set_window_size(*self._window)
# Bound driver.get() so an unreachable page raises a
# TimeoutException instead of blocking the worker (and the
# report schedule) forever.
page_load_wait = app.config["SCREENSHOT_PAGE_LOAD_WAIT"]
if page_load_wait is not None:
self._driver.set_page_load_timeout(page_load_wait)
if self._user:
self._auth(self._user)
except Exception:
# A failure mid-setup (e.g. the new page-load timeout or auth
# raising) would otherwise leave a partially initialized,
# unauthenticated driver cached for reuse. Tear it down so the
# next access recreates it cleanly.
self._destroy()
raise
return self._driver

def _create_firefox_driver(
Expand Down
40 changes: 38 additions & 2 deletions tests/integration_tests/email_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,9 @@ def test_send_mime(self, mock_smtp, mock_smtp_ssl):
msg = MIMEMultipart()
utils.send_mime_email("from", "to", msg, current_app.config, dryrun=False)
mock_smtp.assert_called_with(
current_app.config["SMTP_HOST"], current_app.config["SMTP_PORT"]
current_app.config["SMTP_HOST"],
current_app.config["SMTP_PORT"],
timeout=current_app.config["SMTP_TIMEOUT"],
)
assert mock_smtp.return_value.starttls.called
mock_smtp.return_value.login.assert_called_with(
Expand All @@ -218,6 +220,7 @@ def test_send_mime_ssl(self, mock_smtp, mock_smtp_ssl):
current_app.config["SMTP_HOST"],
current_app.config["SMTP_PORT"],
context=None,
timeout=current_app.config["SMTP_TIMEOUT"],
)

@mock.patch("smtplib.SMTP_SSL")
Expand All @@ -235,6 +238,7 @@ def test_send_mime_ssl_server_auth(self, mock_smtp, mock_smtp_ssl):
current_app.config["SMTP_HOST"],
current_app.config["SMTP_PORT"],
context=mock.ANY,
timeout=current_app.config["SMTP_TIMEOUT"],
)
called_context = mock_smtp_ssl.call_args.kwargs["context"]
assert called_context.verify_mode == ssl.CERT_REQUIRED
Expand Down Expand Up @@ -266,7 +270,9 @@ def test_send_mime_noauth(self, mock_smtp, mock_smtp_ssl):
)
assert not mock_smtp_ssl.called
mock_smtp.assert_called_with(
current_app.config["SMTP_HOST"], current_app.config["SMTP_PORT"]
current_app.config["SMTP_HOST"],
current_app.config["SMTP_PORT"],
timeout=current_app.config["SMTP_TIMEOUT"],
)
assert not mock_smtp.login.called
current_app.config["SMTP_USER"] = smtp_user
Expand All @@ -281,6 +287,36 @@ def test_send_mime_dryrun(self, mock_smtp, mock_smtp_ssl):
assert not mock_smtp.called
assert not mock_smtp_ssl.called

@mock.patch("smtplib.SMTP_SSL")
@mock.patch("smtplib.SMTP")
Comment thread
rusackas marked this conversation as resolved.
def test_send_mime_respects_custom_timeout(
self, mock_smtp: mock.Mock, mock_smtp_ssl: mock.Mock
) -> None:
Comment thread
rusackas marked this conversation as resolved.
"""A configured SMTP_TIMEOUT must reach the smtplib client.

A missing timeout would block the worker forever when the SMTP server
is unreachable, wedging the report schedule in WORKING (issue #40047).
"""
config = {**current_app.config, "SMTP_TIMEOUT": 7, "SMTP_SSL": False}
mock_smtp.return_value = mock.Mock()
utils.send_mime_email("from", ["to"], MIMEMultipart(), config, dryrun=False)
assert mock_smtp.call_args.kwargs["timeout"] == 7

@mock.patch("smtplib.SMTP_SSL")
@mock.patch("smtplib.SMTP")
Comment thread
rusackas marked this conversation as resolved.
def test_send_mime_timeout_defaults_when_unset(
self, mock_smtp: mock.Mock, mock_smtp_ssl: mock.Mock
) -> None:
Comment thread
rusackas marked this conversation as resolved.
"""An absent SMTP_TIMEOUT key falls back to the 30s default.

Custom configs predating SMTP_TIMEOUT must still get a finite timeout.
"""
config = {k: v for k, v in current_app.config.items() if k != "SMTP_TIMEOUT"}
config["SMTP_SSL"] = False
mock_smtp.return_value = mock.Mock()
utils.send_mime_email("from", ["to"], MIMEMultipart(), config, dryrun=False)
assert mock_smtp.call_args.kwargs["timeout"] == 30


if __name__ == "__main__":
unittest.main()
6 changes: 5 additions & 1 deletion tests/integration_tests/reports/commands_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -2004,7 +2004,11 @@ def test_slack_token_callable_chart_report(
TEST_ID, create_report_slack_chart.id, datetime.utcnow()
).run()
slack_token_mock.assert_called()
slack_client_mock_class.assert_called_with(token="cool_code", proxy=None) # noqa: S106
slack_client_mock_class.assert_called_with(
token="cool_code", # noqa: S106
proxy=None,
timeout=30,
)
assert_log(ReportState.SUCCESS)


Expand Down
Loading
Loading