809 lines
31 KiB
Python
809 lines
31 KiB
Python
import base64
|
|
import contextlib
|
|
import json
|
|
import logging
|
|
import random
|
|
import time
|
|
import warnings
|
|
from contextvars import ContextVar
|
|
from functools import lru_cache
|
|
from typing import Any, Callable
|
|
|
|
import requests
|
|
|
|
from mlflow.environment_variables import (
|
|
_MLFLOW_DATABRICKS_TRAFFIC_ID,
|
|
_MLFLOW_HTTP_REQUEST_MAX_BACKOFF_FACTOR_LIMIT,
|
|
_MLFLOW_HTTP_REQUEST_MAX_RETRIES_LIMIT,
|
|
MLFLOW_DATABRICKS_ENDPOINT_HTTP_RETRY_TIMEOUT,
|
|
MLFLOW_ENABLE_DB_SDK,
|
|
MLFLOW_HTTP_REQUEST_BACKOFF_FACTOR,
|
|
MLFLOW_HTTP_REQUEST_BACKOFF_JITTER,
|
|
MLFLOW_HTTP_REQUEST_MAX_RETRIES,
|
|
MLFLOW_HTTP_REQUEST_TIMEOUT,
|
|
MLFLOW_HTTP_RESPECT_RETRY_AFTER_HEADER,
|
|
)
|
|
from mlflow.error_classification import ErrorClass, SqlState
|
|
from mlflow.exceptions import (
|
|
CUSTOMER_UNAUTHORIZED,
|
|
ERROR_CODE_TO_HTTP_STATUS,
|
|
INVALID_PARAMETER_VALUE,
|
|
InvalidUrlException,
|
|
MlflowException,
|
|
RestException,
|
|
get_error_code,
|
|
)
|
|
from mlflow.protos import databricks_pb2
|
|
from mlflow.protos.databricks_pb2 import ENDPOINT_NOT_FOUND, ErrorCode
|
|
from mlflow.utils.proto_json_utils import parse_dict
|
|
from mlflow.utils.request_utils import (
|
|
_TRANSIENT_FAILURE_RESPONSE_CODES,
|
|
_get_http_response_with_retries,
|
|
augmented_raise_for_status, # noqa: F401
|
|
cloud_storage_http_request, # noqa: F401
|
|
)
|
|
from mlflow.utils.string_utils import strip_suffix
|
|
from mlflow.utils.workspace_context import get_request_workspace
|
|
from mlflow.utils.workspace_utils import WORKSPACE_HEADER_NAME
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
# Generic ContextVar to disable HTTP-layer 429 retries. When True,
|
|
# _retry_databricks_sdk_call_with_exponential_backoff skips retrying on 429 so
|
|
# that rate-limit errors propagate immediately to the caller's own retry logic.
|
|
_DISABLE_429_RETRY = ContextVar("_DISABLE_429_RETRY", default=False)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def disable_429_retry():
|
|
token = _DISABLE_429_RETRY.set(True)
|
|
try:
|
|
yield
|
|
finally:
|
|
_DISABLE_429_RETRY.reset(token)
|
|
|
|
|
|
def is_429_retry_disabled() -> bool:
|
|
return _DISABLE_429_RETRY.get()
|
|
|
|
|
|
RESOURCE_NON_EXISTENT = "RESOURCE_DOES_NOT_EXIST"
|
|
_REST_API_PATH_PREFIX = "/api/2.0"
|
|
_UC_OSS_REST_API_PATH_PREFIX = "/api/2.1"
|
|
_TRACE_REST_API_PATH_PREFIX = f"{_REST_API_PATH_PREFIX}/mlflow/traces"
|
|
_V3_REST_API_PATH_PREFIX = "/api/3.0"
|
|
_V3_TRACE_REST_API_PATH_PREFIX = f"{_V3_REST_API_PATH_PREFIX}/mlflow/traces"
|
|
_V3_ISSUES_REST_API_PATH_PREFIX = f"{_V3_REST_API_PATH_PREFIX}/mlflow/issues"
|
|
_V3_LABEL_SCHEMAS_REST_API_PATH_PREFIX = f"{_V3_REST_API_PATH_PREFIX}/mlflow/label-schemas"
|
|
_V3_REVIEW_QUEUES_REST_API_PATH_PREFIX = f"{_V3_REST_API_PATH_PREFIX}/mlflow/review-queues"
|
|
_V4_REST_API_PATH_PREFIX = "/api/4.0"
|
|
_V4_TRACE_REST_API_PATH_PREFIX = f"{_V4_REST_API_PATH_PREFIX}/mlflow/traces"
|
|
_ARMERIA_OK = "200 OK"
|
|
_DATABRICKS_SDK_RETRY_AFTER_SECS_DEPRECATION_WARNING = (
|
|
"The 'retry_after_secs' parameter of DatabricksError is deprecated"
|
|
)
|
|
|
|
|
|
def _should_include_workspace_header(endpoint: str) -> bool:
|
|
"""
|
|
Determine whether to attach the workspace header for a given endpoint.
|
|
|
|
Workspace administration endpoints encode the workspace in the path, so the header is redundant
|
|
(and ignored) for those calls. Other endpoints derive isolation from this header when
|
|
workspaces are enabled.
|
|
"""
|
|
|
|
if not endpoint:
|
|
return True
|
|
|
|
normalized = endpoint if endpoint.startswith("/") else f"/{endpoint}"
|
|
return "/mlflow/workspaces" not in normalized
|
|
|
|
|
|
def http_request(
|
|
host_creds,
|
|
endpoint,
|
|
method,
|
|
max_retries=None,
|
|
backoff_factor=None,
|
|
backoff_jitter=None,
|
|
extra_headers=None,
|
|
retry_codes=_TRANSIENT_FAILURE_RESPONSE_CODES,
|
|
timeout=None,
|
|
raise_on_status=True,
|
|
respect_retry_after_header=None,
|
|
retry_timeout_seconds=None,
|
|
**kwargs,
|
|
):
|
|
"""Makes an HTTP request with the specified method to the specified hostname/endpoint. Transient
|
|
errors such as Rate-limited (429), service unavailable (503) and internal error (500) are
|
|
retried with an exponential back off with backoff_factor * (1, 2, 4, ... seconds).
|
|
The function parses the API response (assumed to be JSON) into a Python object and returns it.
|
|
|
|
Args:
|
|
host_creds: A :py:class:`mlflow.rest_utils.MlflowHostCreds` object containing
|
|
hostname and optional authentication.
|
|
endpoint: A string for service endpoint, e.g. "/path/to/object".
|
|
method: A string indicating the method to use, e.g. "GET", "POST", "PUT".
|
|
max_retries: Maximum number of retries before throwing an exception.
|
|
backoff_factor: A time factor for exponential backoff. e.g. value 5 means the HTTP
|
|
request will be retried with interval 5, 10, 20... seconds. A value of 0 turns off the
|
|
exponential backoff.
|
|
backoff_jitter: A random jitter to add to the backoff interval.
|
|
extra_headers: A dict of HTTP header name-value pairs to be included in the request.
|
|
retry_codes: A list of HTTP response error codes that qualifies for retry.
|
|
timeout: Wait for timeout seconds for response from remote server for connect and
|
|
read request.
|
|
raise_on_status: Whether to raise an exception, or return a response, if status falls
|
|
in retry_codes range and retries have been exhausted.
|
|
respect_retry_after_header: Whether to respect Retry-After header on status codes defined
|
|
as Retry.RETRY_AFTER_STATUS_CODES or not.
|
|
retry_timeout_seconds: Timeout for retries. Only effective when using Databricks SDK.
|
|
kwargs: Additional keyword arguments to pass to `requests.Session.request()`
|
|
|
|
Returns:
|
|
requests.Response object.
|
|
"""
|
|
cleaned_hostname = strip_suffix(host_creds.host, "/")
|
|
url = f"{cleaned_hostname}{endpoint}"
|
|
|
|
# Set defaults for retry parameters from environment variables if not specified
|
|
max_retries = MLFLOW_HTTP_REQUEST_MAX_RETRIES.get() if max_retries is None else max_retries
|
|
backoff_factor = (
|
|
MLFLOW_HTTP_REQUEST_BACKOFF_FACTOR.get() if backoff_factor is None else backoff_factor
|
|
)
|
|
backoff_jitter = (
|
|
MLFLOW_HTTP_REQUEST_BACKOFF_JITTER.get() if backoff_jitter is None else backoff_jitter
|
|
)
|
|
|
|
from mlflow.tracking.request_header.registry import resolve_request_headers
|
|
|
|
headers = dict(**resolve_request_headers())
|
|
if extra_headers:
|
|
headers = dict(**headers, **extra_headers)
|
|
|
|
workspace = get_request_workspace()
|
|
if workspace and _should_include_workspace_header(endpoint):
|
|
headers.setdefault(WORKSPACE_HEADER_NAME, workspace)
|
|
|
|
if traffic_id := _MLFLOW_DATABRICKS_TRAFFIC_ID.get():
|
|
headers["x-databricks-traffic-id"] = traffic_id
|
|
|
|
if host_creds.workspace_id:
|
|
headers["x-databricks-org-id"] = host_creds.workspace_id
|
|
|
|
if host_creds.use_databricks_sdk:
|
|
from databricks.sdk.errors import DatabricksError
|
|
|
|
ws_client = get_workspace_client(
|
|
host_creds.use_secret_scope_token,
|
|
host_creds.host,
|
|
host_creds.token,
|
|
host_creds.databricks_auth_profile,
|
|
retry_timeout_seconds=retry_timeout_seconds,
|
|
timeout=timeout,
|
|
)
|
|
|
|
def make_sdk_call():
|
|
# Databricks SDK `APIClient.do` API is for making request using
|
|
# HTTP
|
|
# https://github.com/databricks/databricks-sdk-py/blob/a714146d9c155dd1e3567475be78623f72028ee0/databricks/sdk/core.py#L134
|
|
# suppress the warning due to https://github.com/databricks/databricks-sdk-py/issues/963
|
|
with warnings.catch_warnings():
|
|
warnings.filterwarnings(
|
|
"ignore", message=f".*{_DATABRICKS_SDK_RETRY_AFTER_SECS_DEPRECATION_WARNING}.*"
|
|
)
|
|
raw_response = ws_client.api_client.do(
|
|
method=method,
|
|
path=endpoint,
|
|
headers=headers,
|
|
raw=True,
|
|
query=kwargs.get("params"),
|
|
body=kwargs.get("json"),
|
|
files=kwargs.get("files"),
|
|
data=kwargs.get("data"),
|
|
)
|
|
return raw_response["contents"]._response
|
|
|
|
try:
|
|
# We retry the SDK call with exponential backoff because the Databricks SDK default
|
|
# retry behavior does not handle all transient errors that we want to retry, and it
|
|
# does not support a customizable retry policy based on HTTP response status codes.
|
|
# Note that, in uncommon cases (due to the limited set if HTTP status codes and
|
|
# response strings that Databricks SDK retries on), the SDK may retry internally,
|
|
# and MLflow may retry on top of that, leading to more retries than specified by
|
|
# `max_retries`. This is acceptable, given the enforcement of an overall request
|
|
# timeout via `retry_timeout_seconds`.
|
|
#
|
|
# TODO: Update transient error handling defaults in Databricks SDK to match standard
|
|
# practices (e.g. retrying on 429, 500, 503, etc.), support custom retries in Databricks
|
|
# SDK, and remove this custom retry wrapper from MLflow
|
|
return _retry_databricks_sdk_call_with_exponential_backoff(
|
|
call_func=make_sdk_call,
|
|
retry_codes=retry_codes,
|
|
retry_timeout_seconds=(
|
|
retry_timeout_seconds
|
|
if retry_timeout_seconds is not None
|
|
else MLFLOW_DATABRICKS_ENDPOINT_HTTP_RETRY_TIMEOUT.get()
|
|
),
|
|
backoff_factor=backoff_factor,
|
|
backoff_jitter=backoff_jitter,
|
|
max_retries=max_retries,
|
|
)
|
|
except DatabricksError as e:
|
|
response = requests.Response()
|
|
response.url = url
|
|
response.status_code = ERROR_CODE_TO_HTTP_STATUS.get(e.error_code, 500)
|
|
response.reason = str(e)
|
|
response.encoding = "UTF-8"
|
|
response._content = json.dumps({
|
|
"error_code": e.error_code,
|
|
"message": str(e),
|
|
}).encode("UTF-8")
|
|
return response
|
|
|
|
_validate_max_retries(max_retries)
|
|
_validate_backoff_factor(backoff_factor)
|
|
respect_retry_after_header = (
|
|
MLFLOW_HTTP_RESPECT_RETRY_AFTER_HEADER.get()
|
|
if respect_retry_after_header is None
|
|
else respect_retry_after_header
|
|
)
|
|
|
|
timeout = MLFLOW_HTTP_REQUEST_TIMEOUT.get() if timeout is None else timeout
|
|
auth_str = None
|
|
if host_creds.username and host_creds.password:
|
|
basic_auth_str = f"{host_creds.username}:{host_creds.password}".encode()
|
|
auth_str = "Basic " + base64.standard_b64encode(basic_auth_str).decode("utf-8")
|
|
elif host_creds.token:
|
|
auth_str = f"Bearer {host_creds.token}"
|
|
elif host_creds.client_secret:
|
|
message = (
|
|
"OAuth authentication using DATABRICKS_CLIENT_ID and DATABRICKS_CLIENT_SECRET "
|
|
"requires the Databricks SDK to be enabled and successfully initialized. "
|
|
f"{MLFLOW_ENABLE_DB_SDK.name} is currently set to "
|
|
f"'{MLFLOW_ENABLE_DB_SDK.get()}'."
|
|
)
|
|
if MLFLOW_ENABLE_DB_SDK.get():
|
|
message += (
|
|
" The SDK is enabled but failed to initialize. See the preceding "
|
|
"'Failed to create databricks SDK workspace client' warning for the "
|
|
"underlying error."
|
|
)
|
|
else:
|
|
message += f" Set '{MLFLOW_ENABLE_DB_SDK.name}' to true."
|
|
raise MlflowException(message, error_code=CUSTOMER_UNAUTHORIZED)
|
|
|
|
if auth_str:
|
|
headers["Authorization"] = auth_str
|
|
|
|
if host_creds.client_cert_path is not None:
|
|
kwargs["cert"] = host_creds.client_cert_path
|
|
|
|
if host_creds.aws_sigv4:
|
|
# will overwrite the Authorization header
|
|
from requests_auth_aws_sigv4 import AWSSigV4
|
|
|
|
kwargs["auth"] = AWSSigV4("execute-api")
|
|
elif host_creds.auth:
|
|
from mlflow.tracking.request_auth.registry import fetch_auth
|
|
|
|
kwargs["auth"] = fetch_auth(host_creds.auth)
|
|
|
|
try:
|
|
return _get_http_response_with_retries(
|
|
method,
|
|
url,
|
|
max_retries,
|
|
backoff_factor,
|
|
backoff_jitter,
|
|
retry_codes,
|
|
raise_on_status,
|
|
headers=headers,
|
|
verify=host_creds.verify,
|
|
timeout=timeout,
|
|
respect_retry_after_header=respect_retry_after_header,
|
|
**kwargs,
|
|
)
|
|
except requests.exceptions.Timeout as to:
|
|
raise MlflowException(
|
|
f"API request to {url} failed with timeout exception {to}."
|
|
" To increase the timeout, set the environment variable "
|
|
f"{MLFLOW_HTTP_REQUEST_TIMEOUT!s} to a larger value."
|
|
) from to
|
|
except requests.exceptions.InvalidURL as iu:
|
|
raise InvalidUrlException(f"Invalid url: {url}") from iu
|
|
except Exception as e:
|
|
raise MlflowException(f"API request to {url} failed with exception {e}")
|
|
|
|
|
|
@lru_cache(maxsize=5)
|
|
def get_workspace_client(
|
|
use_secret_scope_token,
|
|
host,
|
|
token,
|
|
databricks_auth_profile,
|
|
retry_timeout_seconds=None,
|
|
timeout=None,
|
|
):
|
|
from databricks.sdk import WorkspaceClient
|
|
from databricks.sdk.config import Config
|
|
|
|
if use_secret_scope_token:
|
|
kwargs = {"host": host, "token": token}
|
|
elif databricks_auth_profile:
|
|
kwargs = {"profile": databricks_auth_profile}
|
|
else:
|
|
kwargs = {}
|
|
if timeout is not None:
|
|
kwargs["http_timeout_seconds"] = timeout
|
|
config = Config(
|
|
**kwargs,
|
|
retry_timeout_seconds=retry_timeout_seconds
|
|
or MLFLOW_DATABRICKS_ENDPOINT_HTTP_RETRY_TIMEOUT.get(),
|
|
)
|
|
# Note: If we use `config` param, all SDK configurations must be set in `config` object.
|
|
return WorkspaceClient(config=config)
|
|
|
|
|
|
def _can_parse_as_json_object(string):
|
|
try:
|
|
return isinstance(json.loads(string), dict)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def http_request_safe(host_creds, endpoint, method, **kwargs):
|
|
"""
|
|
Wrapper around ``http_request`` that also verifies that the request succeeds with code 200.
|
|
"""
|
|
response = http_request(host_creds=host_creds, endpoint=endpoint, method=method, **kwargs)
|
|
return verify_rest_response(response, endpoint)
|
|
|
|
|
|
def verify_rest_response(
|
|
response,
|
|
endpoint,
|
|
expected_status: int = 200,
|
|
):
|
|
"""Verify the return code and format, raise exception if the request was not successful."""
|
|
# Handle Armeria-specific response case where response text is "200 OK"
|
|
# v1/traces endpoint might return empty response
|
|
if response.status_code == 200 and response.text.strip() in (_ARMERIA_OK, ""):
|
|
response._content = b"{}" # Update response content to be an empty JSON dictionary
|
|
return response
|
|
|
|
# Handle non-expected status codes
|
|
if response.status_code != expected_status:
|
|
if _can_parse_as_json_object(response.text):
|
|
raise RestException(json.loads(response.text))
|
|
else:
|
|
base_msg = (
|
|
f"API request to endpoint {endpoint} "
|
|
f"failed with error code {response.status_code} "
|
|
f"!= {expected_status}"
|
|
)
|
|
error_code = get_error_code(response.status_code)
|
|
error_code_name = ErrorCode.Name(error_code)
|
|
raise MlflowException(
|
|
f"{base_msg}. Response body: '{response.text}'",
|
|
error_code=error_code,
|
|
sqlstate=SqlState.from_cp_error_code(error_code_name),
|
|
error_class=ErrorClass.from_cp_error_code(error_code_name),
|
|
)
|
|
|
|
if response.status_code == 204:
|
|
return response
|
|
|
|
# Skip validation for endpoints (e.g. DBFS file-download API) which may return a non-JSON
|
|
# response
|
|
if endpoint.startswith(_REST_API_PATH_PREFIX) and not _can_parse_as_json_object(response.text):
|
|
base_msg = (
|
|
"API request to endpoint was successful but the response body was not "
|
|
"in a valid JSON format"
|
|
)
|
|
raise MlflowException(f"{base_msg}. Response body: '{response.text}'")
|
|
|
|
return response
|
|
|
|
|
|
def _validate_max_retries(max_retries):
|
|
max_retry_limit = _MLFLOW_HTTP_REQUEST_MAX_RETRIES_LIMIT.get()
|
|
|
|
if max_retry_limit < 0:
|
|
raise MlflowException(
|
|
message=f"The current maximum retry limit is invalid ({max_retry_limit}). "
|
|
"Cannot be negative.",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
if max_retries > max_retry_limit:
|
|
raise MlflowException(
|
|
message=f"The configured max_retries value ({max_retries}) is "
|
|
f"in excess of the maximum allowable retries ({max_retry_limit})",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
if max_retries < 0:
|
|
raise MlflowException(
|
|
message=f"The max_retries value must be either 0 a positive integer. Got {max_retries}",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
|
|
def _validate_backoff_factor(backoff_factor):
|
|
max_backoff_factor_limit = _MLFLOW_HTTP_REQUEST_MAX_BACKOFF_FACTOR_LIMIT.get()
|
|
|
|
if max_backoff_factor_limit < 0:
|
|
raise MlflowException(
|
|
message="The current maximum backoff factor limit is invalid "
|
|
f"({max_backoff_factor_limit}). Cannot be negative.",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
if backoff_factor > max_backoff_factor_limit:
|
|
raise MlflowException(
|
|
message=f"The configured backoff_factor value ({backoff_factor}) is in excess "
|
|
"of the maximum allowable backoff_factor limit "
|
|
f"({max_backoff_factor_limit})",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
if backoff_factor < 0:
|
|
raise MlflowException(
|
|
message="The backoff_factor value must be either 0 a positive integer. "
|
|
f"Got {backoff_factor}",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
|
|
def validate_deployment_timeout_config(timeout: int | None, retry_timeout_seconds: int | None):
|
|
"""
|
|
Validate that total retry timeout is not less than single request timeout.
|
|
|
|
Args:
|
|
timeout: Maximum time for a single HTTP request (in seconds)
|
|
retry_timeout_seconds: Maximum time for all retry attempts combined (in seconds)
|
|
"""
|
|
if timeout is not None and retry_timeout_seconds is not None:
|
|
if retry_timeout_seconds < timeout:
|
|
warnings.warn(
|
|
f"MLFLOW_DEPLOYMENT_PREDICT_TOTAL_TIMEOUT ({retry_timeout_seconds}s) is set "
|
|
f"lower than MLFLOW_DEPLOYMENT_PREDICT_TIMEOUT ({timeout}s). This means the "
|
|
"total retry timeout could expire before a single request completes, causing "
|
|
"premature failures. For long-running predictions, ensure "
|
|
"MLFLOW_DEPLOYMENT_PREDICT_TOTAL_TIMEOUT >= MLFLOW_DEPLOYMENT_PREDICT_TIMEOUT. "
|
|
f"Recommended: Set MLFLOW_DEPLOYMENT_PREDICT_TOTAL_TIMEOUT to at least {timeout}s.",
|
|
stacklevel=2,
|
|
)
|
|
|
|
|
|
def _time_sleep(seconds: float) -> None:
|
|
"""
|
|
This function is specifically mocked in `test_rest_utils.py` to test the backoff logic in
|
|
isolation. We avoid wrapping `time.sleep` globally to prevent interfering with unrelated sleep
|
|
calls elsewhere in the codebase or in third-party libraries.
|
|
"""
|
|
time.sleep(seconds)
|
|
|
|
|
|
def _retry_databricks_sdk_call_with_exponential_backoff(
|
|
*,
|
|
call_func: Callable[..., Any],
|
|
retry_codes: list[int],
|
|
retry_timeout_seconds: int,
|
|
backoff_factor: int,
|
|
backoff_jitter: float,
|
|
max_retries: int,
|
|
):
|
|
"""
|
|
Retry a Databricks SDK call with exponential backoff until timeout or max retries reached.
|
|
|
|
Args:
|
|
call_func: Function to call that may raise DatabricksError
|
|
retry_codes: Set of HTTP status codes that should trigger retries
|
|
retry_timeout_seconds: Maximum time to spend retrying in seconds
|
|
backoff_factor: Factor for exponential backoff
|
|
backoff_jitter: Random jitter to add to backoff
|
|
max_retries: Maximum number of retry attempts
|
|
|
|
Returns:
|
|
The result of call_func() on success
|
|
|
|
Raises:
|
|
DatabricksError: If all retries are exhausted or non-retryable error occurs
|
|
"""
|
|
from databricks.sdk.errors import STATUS_CODE_MAPPING, DatabricksError
|
|
|
|
if is_429_retry_disabled():
|
|
retry_codes = frozenset(c for c in retry_codes if c != 429)
|
|
|
|
start_time = time.time()
|
|
attempt = 0
|
|
|
|
while attempt <= max_retries:
|
|
try:
|
|
return call_func()
|
|
except DatabricksError as e:
|
|
# Get HTTP status code from the error
|
|
status_code = next(
|
|
(code for code, cls in STATUS_CODE_MAPPING.items() if isinstance(e, cls)), 500
|
|
)
|
|
# Check if this is a retryable error
|
|
if status_code not in retry_codes:
|
|
raise
|
|
|
|
# Check if we've exceeded max retries
|
|
if attempt >= max_retries:
|
|
_logger.warning(f"Max retries ({max_retries}) exceeded: {e}")
|
|
raise
|
|
|
|
# Calculate backoff time with exponential backoff and jitter
|
|
# NB: Ideally, we'd use urllib3.Retry to compute the jitter, check whether we've
|
|
# exceed max retries, etc. However, urllib3.Retry in urllib3 version 1.x, which MLflow
|
|
# maintains compatibility with, doesn't support retries with jitter
|
|
if attempt <= 0:
|
|
backoff_time = 0 # No backoff on first retry attempt
|
|
else:
|
|
backoff_time = backoff_factor * (2**attempt)
|
|
if backoff_jitter > 0:
|
|
backoff_time += random.random() * backoff_jitter
|
|
|
|
# Check if we've exceeded or would exceed timeout
|
|
elapsed_time = time.time() - start_time
|
|
if elapsed_time + backoff_time >= retry_timeout_seconds:
|
|
_logger.warning(f"Retry timeout ({retry_timeout_seconds}s) exceeded: {e}")
|
|
raise
|
|
|
|
_logger.debug(
|
|
f"Databricks SDK call failed with retryable error (status {status_code}): {e}. "
|
|
f"Retrying in {backoff_time:.2f} seconds (attempt {attempt + 1})"
|
|
)
|
|
|
|
_time_sleep(backoff_time)
|
|
attempt += 1
|
|
|
|
|
|
def _get_path(path_prefix, endpoint_path):
|
|
return f"{path_prefix}{endpoint_path}"
|
|
|
|
|
|
def extract_api_info_for_service(service, path_prefix):
|
|
"""Return a dictionary mapping each API method to a tuple (path, HTTP method)"""
|
|
service_methods = service.DESCRIPTOR.methods
|
|
res = {}
|
|
for service_method in service_methods:
|
|
endpoints = service_method.GetOptions().Extensions[databricks_pb2.rpc].endpoints
|
|
endpoint = endpoints[0]
|
|
endpoint_path = _get_path(path_prefix, endpoint.path)
|
|
res[service().GetRequestClass(service_method)] = (endpoint_path, endpoint.method)
|
|
return res
|
|
|
|
|
|
def extract_all_api_info_for_service(service, path_prefix):
|
|
"""Return a dictionary mapping each API method to a list of tuples [(path, HTTP method)]"""
|
|
service_methods = service.DESCRIPTOR.methods
|
|
res = {}
|
|
for service_method in service_methods:
|
|
endpoints = service_method.GetOptions().Extensions[databricks_pb2.rpc].endpoints
|
|
res[service().GetRequestClass(service_method)] = [
|
|
(_get_path(path_prefix, endpoint.path), endpoint.method) for endpoint in endpoints
|
|
]
|
|
return res
|
|
|
|
|
|
def get_single_trace_endpoint(request_id, use_v3=True):
|
|
"""
|
|
Get the endpoint for a single trace.
|
|
For Databricks tracking URIs, use the V3 API.
|
|
For all other tracking URIs, use the V2 API.
|
|
|
|
Args:
|
|
request_id: The trace ID.
|
|
use_v3: Whether to use the V3 API. If True, use the V3 API. If False, use the V2 API.
|
|
"""
|
|
if use_v3:
|
|
return f"{_V3_TRACE_REST_API_PATH_PREFIX}/{request_id}"
|
|
return f"{_TRACE_REST_API_PATH_PREFIX}/{request_id}"
|
|
|
|
|
|
def get_single_trace_endpoint_v4(location: str, trace_id: str) -> str:
|
|
"""
|
|
Get the endpoint for a single trace using the V4 API.
|
|
"""
|
|
return f"{_V4_TRACE_REST_API_PATH_PREFIX}/{location}/{trace_id}"
|
|
|
|
|
|
def get_single_assessment_endpoint_v4(location: str, trace_id: str, assessment_id: str) -> str:
|
|
"""
|
|
Get the endpoint for a single assessment using the V4 API.
|
|
"""
|
|
return f"{_V4_TRACE_REST_API_PATH_PREFIX}/{location}/{trace_id}/assessments/{assessment_id}"
|
|
|
|
|
|
def get_logged_model_endpoint(model_id: str) -> str:
|
|
return f"{_REST_API_PATH_PREFIX}/mlflow/logged-models/{model_id}"
|
|
|
|
|
|
def get_single_assessment_endpoint(trace_id: str, assessment_id: str) -> str:
|
|
"""
|
|
Get the endpoint for a single assessment.
|
|
|
|
Args:
|
|
trace_id: The trace ID.
|
|
assessment_id: The assessment ID.
|
|
"""
|
|
return f"{_V3_TRACE_REST_API_PATH_PREFIX}/{trace_id}/assessments/{assessment_id}"
|
|
|
|
|
|
def get_trace_tag_endpoint(trace_id):
|
|
"""Get the endpoint for trace tags. Always use v2 endpoint."""
|
|
return f"{_REST_API_PATH_PREFIX}/mlflow/traces/{trace_id}/tags"
|
|
|
|
|
|
def call_endpoint(
|
|
host_creds,
|
|
endpoint,
|
|
method,
|
|
json_body,
|
|
response_proto,
|
|
extra_headers=None,
|
|
retry_timeout_seconds=None,
|
|
expected_status: int = 200,
|
|
):
|
|
# Convert json string to json dictionary, to pass to requests
|
|
if json_body is not None:
|
|
json_body = json.loads(json_body)
|
|
call_kwargs = {
|
|
"host_creds": host_creds,
|
|
"endpoint": endpoint,
|
|
"method": method,
|
|
}
|
|
if extra_headers is not None:
|
|
call_kwargs["extra_headers"] = extra_headers
|
|
if retry_timeout_seconds is not None:
|
|
call_kwargs["retry_timeout_seconds"] = retry_timeout_seconds
|
|
if method == "GET":
|
|
call_kwargs["params"] = json_body
|
|
response = http_request(**call_kwargs)
|
|
else:
|
|
call_kwargs["json"] = json_body
|
|
response = http_request(**call_kwargs)
|
|
|
|
response = verify_rest_response(
|
|
response,
|
|
endpoint,
|
|
expected_status=expected_status,
|
|
)
|
|
if response.status_code == 204:
|
|
return response_proto
|
|
|
|
response_to_parse = response.text
|
|
try:
|
|
js_dict = json.loads(response_to_parse)
|
|
except json.JSONDecodeError:
|
|
if len(response_to_parse) > 50:
|
|
response_to_parse = response_to_parse[:50] + "..."
|
|
_logger.warning(f"Response is not a valid JSON object: {response_to_parse}")
|
|
raise
|
|
|
|
parse_dict(js_dict=js_dict, message=response_proto)
|
|
return response_proto
|
|
|
|
|
|
def call_endpoints(host_creds, endpoints, json_body, response_proto, extra_headers=None):
|
|
# The order that the endpoints are called in is defined by the order
|
|
# specified in ModelRegistryService in model_registry.proto
|
|
for i, (endpoint, method) in enumerate(endpoints):
|
|
try:
|
|
return call_endpoint(
|
|
host_creds, endpoint, method, json_body, response_proto, extra_headers
|
|
)
|
|
except RestException as e:
|
|
if e.error_code != ErrorCode.Name(ENDPOINT_NOT_FOUND) or i == len(endpoints) - 1:
|
|
raise e
|
|
|
|
|
|
class MlflowHostCreds:
|
|
"""
|
|
Provides a hostname and optional authentication for talking to an MLflow tracking server.
|
|
|
|
Args:
|
|
host: Hostname (e.g., http://localhost:5000) to MLflow server. Required.
|
|
username: Username to use with Basic authentication when talking to server.
|
|
If this is specified, password must also be specified.
|
|
password: Password to use with Basic authentication when talking to server.
|
|
If this is specified, username must also be specified.
|
|
token: Token to use with Bearer authentication when talking to server.
|
|
If provided, user/password authentication will be ignored.
|
|
aws_sigv4: If true, we will create a signature V4 to be added for any outgoing request.
|
|
Keys for signing the request can be passed via ENV variables,
|
|
or will be fetched via boto3 session.
|
|
auth: If set, the auth will be added for any outgoing request.
|
|
Keys for signing the request can be passed via ENV variables,
|
|
ignore_tls_verification: If true, we will not verify the server's hostname or TLS
|
|
certificate. This is useful for certain testing situations, but should never be
|
|
true in production.
|
|
If this is set to true ``server_cert_path`` must not be set.
|
|
client_cert_path: Path to ssl client cert file (.pem).
|
|
Sets the cert param of the ``requests.request``
|
|
function (see https://requests.readthedocs.io/en/master/api/).
|
|
server_cert_path: Path to a CA bundle to use.
|
|
Sets the verify param of the ``requests.request``
|
|
function (see https://requests.readthedocs.io/en/master/api/).
|
|
If this is set ``ignore_tls_verification`` must be false.
|
|
use_databricks_sdk: A boolean value represent whether using Databricks SDK for
|
|
authentication.
|
|
databricks_auth_profile: The name of the profile used by Databricks SDK for
|
|
authentication.
|
|
client_id: The client ID used by Databricks OAuth
|
|
client_secret: The client secret used by Databricks OAuth
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
host,
|
|
username=None,
|
|
password=None,
|
|
token=None,
|
|
aws_sigv4=False,
|
|
auth=None,
|
|
ignore_tls_verification=False,
|
|
client_cert_path=None,
|
|
server_cert_path=None,
|
|
use_databricks_sdk=False,
|
|
databricks_auth_profile=None,
|
|
client_id=None,
|
|
client_secret=None,
|
|
use_secret_scope_token=False,
|
|
workspace_id=None,
|
|
):
|
|
if not host:
|
|
raise MlflowException(
|
|
message="host is a required parameter for MlflowHostCreds",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
if ignore_tls_verification and (server_cert_path is not None):
|
|
raise MlflowException(
|
|
message=(
|
|
"When 'ignore_tls_verification' is true then 'server_cert_path' "
|
|
"must not be set! This error may have occurred because the "
|
|
"'MLFLOW_TRACKING_INSECURE_TLS' and 'MLFLOW_TRACKING_SERVER_CERT_PATH' "
|
|
"environment variables are both set - only one of these environment "
|
|
"variables may be set."
|
|
),
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
self.host = host
|
|
self.username = username
|
|
self.password = password
|
|
self.token = token
|
|
self.aws_sigv4 = aws_sigv4
|
|
self.auth = auth
|
|
self.ignore_tls_verification = ignore_tls_verification
|
|
self.client_cert_path = client_cert_path
|
|
self.server_cert_path = server_cert_path
|
|
self.use_databricks_sdk = use_databricks_sdk
|
|
self.databricks_auth_profile = databricks_auth_profile
|
|
self.client_id = client_id
|
|
self.client_secret = client_secret
|
|
self.use_secret_scope_token = use_secret_scope_token
|
|
self.workspace_id = workspace_id
|
|
|
|
def __eq__(self, other):
|
|
if isinstance(other, self.__class__):
|
|
return self.__dict__ == other.__dict__
|
|
return NotImplemented
|
|
|
|
def __hash__(self):
|
|
return hash(frozenset(self.__dict__.items()))
|
|
|
|
@property
|
|
def verify(self):
|
|
if self.use_databricks_sdk:
|
|
# Let databricks-sdk set HTTP request `verify` param.
|
|
return None
|
|
if self.server_cert_path is None:
|
|
return not self.ignore_tls_verification
|
|
else:
|
|
return self.server_cert_path
|