databricks-ai-search 0.73__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- databricks/__init__.py +7 -0
- databricks/ai_search/__init__.py +3 -0
- databricks/ai_search/async_index.py +348 -0
- databricks/ai_search/async_utils.py +204 -0
- databricks/ai_search/client.py +1090 -0
- databricks/ai_search/exceptions.py +47 -0
- databricks/ai_search/index.py +682 -0
- databricks/ai_search/reranker.py +84 -0
- databricks/ai_search/utils.py +299 -0
- databricks/ai_search/version.py +2 -0
- databricks_ai_search-0.73.dist-info/LICENSE.md +31 -0
- databricks_ai_search-0.73.dist-info/METADATA +92 -0
- databricks_ai_search-0.73.dist-info/RECORD +28 -0
- databricks_ai_search-0.73.dist-info/WHEEL +5 -0
- databricks_ai_search-0.73.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/conftest.py +110 -0
- tests/test_ai_search_aliases.py +87 -0
- tests/test_async_index.py +412 -0
- tests/test_async_request_utils.py +215 -0
- tests/test_client.py +387 -0
- tests/test_exceptions.py +81 -0
- tests/test_index.py +374 -0
- tests/test_model_serving_environment.py +286 -0
- tests/test_reranker.py +207 -0
- tests/test_reranker_auth.py +179 -0
- tests/test_response_normalization.py +135 -0
- tests/test_utils.py +340 -0
databricks/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# See: https://packaging.python.org/guides/packaging-namespace-packages/#pkgutil-style-namespace-packages
|
|
2
|
+
#
|
|
3
|
+
# This file must only contain the following line, or other packages in the databricks.* namespace
|
|
4
|
+
# may not be importable. The contents of this file must be byte-for-byte equivalent across all packages.
|
|
5
|
+
# If they are not, parallel package installation may lead to clobbered and invalid files.
|
|
6
|
+
# Also see https://github.com/databricks/databricks-sdk-py/issues/343.
|
|
7
|
+
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Any, Dict, List, Optional, Union
|
|
3
|
+
|
|
4
|
+
from databricks.ai_search.async_utils import AsyncRequestUtils
|
|
5
|
+
from databricks.ai_search.index import AISearchIndex
|
|
6
|
+
from databricks.ai_search.reranker import Reranker
|
|
7
|
+
from databricks.ai_search.utils import (
|
|
8
|
+
CredentialStrategy,
|
|
9
|
+
normalize_response_keys_to_snake_case,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AsyncAISearchIndex:
|
|
14
|
+
"""
|
|
15
|
+
AsyncAISearchIndex is a helper class that represents a AI Search Index for ``async/await`` code.
|
|
16
|
+
|
|
17
|
+
Those who wish to use this class should not instantiate it directly, but rather use
|
|
18
|
+
:meth:`AISearchClient.get_async_index` to obtain an instance.
|
|
19
|
+
|
|
20
|
+
Index lifecycle operations (create, delete, ``wait_until_ready``) are available
|
|
21
|
+
only on :class:`AISearchClient`.
|
|
22
|
+
|
|
23
|
+
Example::
|
|
24
|
+
|
|
25
|
+
async with client.get_async_index(
|
|
26
|
+
endpoint_name="my-endpoint", index_name="my-index"
|
|
27
|
+
) as idx:
|
|
28
|
+
results = await idx.similarity_search(
|
|
29
|
+
query_vector=[0.1, 0.2, ...], columns=["id"], num_results=10
|
|
30
|
+
)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
workspace_url: str,
|
|
36
|
+
index_url: str,
|
|
37
|
+
name: str,
|
|
38
|
+
endpoint_name: str,
|
|
39
|
+
mlserving_endpoint_name: Optional[str] = None,
|
|
40
|
+
personal_access_token: Optional[str] = None,
|
|
41
|
+
service_principal_client_id: Optional[str] = None,
|
|
42
|
+
service_principal_client_secret: Optional[str] = None,
|
|
43
|
+
azure_tenant_id: Optional[str] = None,
|
|
44
|
+
azure_login_id: Optional[str] = None,
|
|
45
|
+
use_user_passed_credentials: bool = False,
|
|
46
|
+
credential_strategy: Optional[CredentialStrategy] = None,
|
|
47
|
+
get_reranker_url_callable: Optional[callable] = None,
|
|
48
|
+
mlserving_endpoint_name_for_query: Optional[str] = None,
|
|
49
|
+
total_retries: int = 3,
|
|
50
|
+
backoff_factor: float = 1,
|
|
51
|
+
backoff_jitter: float = 0.2,
|
|
52
|
+
):
|
|
53
|
+
"""
|
|
54
|
+
Initialize an AsyncAISearchIndex instance.
|
|
55
|
+
|
|
56
|
+
:param str workspace_url: The URL of the Databricks workspace.
|
|
57
|
+
:param str index_url: The direct URL to the vector search index endpoint.
|
|
58
|
+
:param str name: The name of the vector search index.
|
|
59
|
+
:param str endpoint_name: The name of the vector search endpoint.
|
|
60
|
+
:param str mlserving_endpoint_name: The name of the model serving endpoint used for embedding generation during ingestion.
|
|
61
|
+
:param str personal_access_token: Personal access token for authentication.
|
|
62
|
+
:param str service_principal_client_id: Service principal client ID for authentication.
|
|
63
|
+
:param str service_principal_client_secret: Service principal client secret for authentication.
|
|
64
|
+
:param str azure_tenant_id: Azure tenant ID for Azure-based authentication.
|
|
65
|
+
:param str azure_login_id: Azure login ID (Databricks Azure Application ID) for authentication.
|
|
66
|
+
:param bool use_user_passed_credentials: Whether credentials were explicitly provided by the user (True) or inferred automatically (False).
|
|
67
|
+
:param CredentialStrategy credential_strategy: The credential strategy to use for authentication.
|
|
68
|
+
:param callable get_reranker_url_callable: A callable function to retrieve the reranker-compatible index URL when needed.
|
|
69
|
+
:param str mlserving_endpoint_name_for_query: The name of the model serving endpoint to use for queries (if different from ingestion endpoint).
|
|
70
|
+
:param int total_retries: Total number of retries for requests. Defaults to 3.
|
|
71
|
+
:param float backoff_factor: Backoff factor for retry delays. Defaults to 1.
|
|
72
|
+
:param float backoff_jitter: Random jitter proportion (0-1) to add to backoff delays. Defaults to 0.2.
|
|
73
|
+
"""
|
|
74
|
+
self._sync = AISearchIndex(
|
|
75
|
+
workspace_url=workspace_url,
|
|
76
|
+
index_url=index_url,
|
|
77
|
+
name=name,
|
|
78
|
+
endpoint_name=endpoint_name,
|
|
79
|
+
mlserving_endpoint_name=mlserving_endpoint_name,
|
|
80
|
+
personal_access_token=personal_access_token,
|
|
81
|
+
service_principal_client_id=service_principal_client_id,
|
|
82
|
+
service_principal_client_secret=service_principal_client_secret,
|
|
83
|
+
azure_tenant_id=azure_tenant_id,
|
|
84
|
+
azure_login_id=azure_login_id,
|
|
85
|
+
use_user_passed_credentials=use_user_passed_credentials,
|
|
86
|
+
credential_strategy=credential_strategy,
|
|
87
|
+
get_reranker_url_callable=get_reranker_url_callable,
|
|
88
|
+
mlserving_endpoint_name_for_query=mlserving_endpoint_name_for_query,
|
|
89
|
+
total_retries=total_retries,
|
|
90
|
+
backoff_factor=backoff_factor,
|
|
91
|
+
backoff_jitter=backoff_jitter,
|
|
92
|
+
)
|
|
93
|
+
self.workspace_url = workspace_url
|
|
94
|
+
self.name = name
|
|
95
|
+
self.endpoint_name = endpoint_name
|
|
96
|
+
self.index_url = self._sync.index_url
|
|
97
|
+
self.total_retries = total_retries
|
|
98
|
+
self.backoff_factor = backoff_factor
|
|
99
|
+
self.backoff_jitter = backoff_jitter
|
|
100
|
+
self._closed = False
|
|
101
|
+
self._token_lock = asyncio.Lock()
|
|
102
|
+
|
|
103
|
+
async def upsert(self, inputs):
|
|
104
|
+
"""
|
|
105
|
+
Upsert data into the index.
|
|
106
|
+
|
|
107
|
+
:param inputs: List of dictionaries to upsert into the index.
|
|
108
|
+
"""
|
|
109
|
+
payload = self._sync._build_upsert_payload(inputs)
|
|
110
|
+
token = await self._aget_token(write=True)
|
|
111
|
+
return await AsyncRequestUtils.issue_request(
|
|
112
|
+
url=f"{self.index_url}/upsert-data",
|
|
113
|
+
token=token,
|
|
114
|
+
method="POST",
|
|
115
|
+
json=payload,
|
|
116
|
+
total_retries=self.total_retries,
|
|
117
|
+
backoff_factor=self.backoff_factor,
|
|
118
|
+
backoff_jitter=self.backoff_jitter,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
async def delete(self, primary_keys):
|
|
122
|
+
"""
|
|
123
|
+
Delete data from the index.
|
|
124
|
+
|
|
125
|
+
:param primary_keys: List of primary keys to delete from the index.
|
|
126
|
+
"""
|
|
127
|
+
payload = self._sync._build_delete_payload(primary_keys)
|
|
128
|
+
token = await self._aget_token(write=True)
|
|
129
|
+
return await AsyncRequestUtils.issue_request(
|
|
130
|
+
url=f"{self.index_url}/delete-data",
|
|
131
|
+
token=token,
|
|
132
|
+
method="DELETE",
|
|
133
|
+
json=payload,
|
|
134
|
+
total_retries=self.total_retries,
|
|
135
|
+
backoff_factor=self.backoff_factor,
|
|
136
|
+
backoff_jitter=self.backoff_jitter,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
async def describe(self):
|
|
140
|
+
"""
|
|
141
|
+
Describe the index. This returns metadata about the index.
|
|
142
|
+
"""
|
|
143
|
+
token = await self._aget_token(control_plane=True)
|
|
144
|
+
return await AsyncRequestUtils.issue_request(
|
|
145
|
+
url=f"{self.workspace_url}/api/2.0/vector-search/indexes/{self.name}",
|
|
146
|
+
token=token,
|
|
147
|
+
method="GET",
|
|
148
|
+
total_retries=self.total_retries,
|
|
149
|
+
backoff_factor=self.backoff_factor,
|
|
150
|
+
backoff_jitter=self.backoff_jitter,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
async def sync(self):
|
|
154
|
+
"""
|
|
155
|
+
Sync the index. This is used to sync the index with the source delta table.
|
|
156
|
+
This only works with managed delta sync index with pipeline type="TRIGGERED".
|
|
157
|
+
"""
|
|
158
|
+
token = await self._aget_token(control_plane=True)
|
|
159
|
+
return await AsyncRequestUtils.issue_request(
|
|
160
|
+
url=f"{self.workspace_url}/api/2.0/vector-search/indexes/{self.name}/sync",
|
|
161
|
+
token=token,
|
|
162
|
+
method="POST",
|
|
163
|
+
total_retries=self.total_retries,
|
|
164
|
+
backoff_factor=self.backoff_factor,
|
|
165
|
+
backoff_jitter=self.backoff_jitter,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
async def scan(self, num_results=10, last_primary_key=None):
|
|
169
|
+
"""
|
|
170
|
+
Given all the data in the index sorted by primary key, this returns the next
|
|
171
|
+
``num_results`` data after the primary key specified by ``last_primary_key``.
|
|
172
|
+
If ``last_primary_key`` is None, it returns the first ``num_results``.
|
|
173
|
+
|
|
174
|
+
Please note if there's ongoing updates to the index, the scan results may not be consistent.
|
|
175
|
+
|
|
176
|
+
:param num_results: Number of results to return.
|
|
177
|
+
:param last_primary_key: Last primary key from previous pagination, used as the exclusive starting primary key.
|
|
178
|
+
"""
|
|
179
|
+
payload = self._sync._build_scan_payload(num_results, last_primary_key)
|
|
180
|
+
token = await self._aget_token()
|
|
181
|
+
response = await AsyncRequestUtils.issue_request(
|
|
182
|
+
url=f"{self.index_url}/scan",
|
|
183
|
+
token=token,
|
|
184
|
+
method="GET",
|
|
185
|
+
json=payload,
|
|
186
|
+
total_retries=self.total_retries,
|
|
187
|
+
backoff_factor=self.backoff_factor,
|
|
188
|
+
backoff_jitter=self.backoff_jitter,
|
|
189
|
+
)
|
|
190
|
+
return normalize_response_keys_to_snake_case(response)
|
|
191
|
+
|
|
192
|
+
async def similarity_search(
|
|
193
|
+
self,
|
|
194
|
+
columns: List[str],
|
|
195
|
+
query_text: Optional[str] = None,
|
|
196
|
+
query_vector: Optional[List[float]] = None,
|
|
197
|
+
filters: Optional[Union[str, Dict[str, Any]]] = None,
|
|
198
|
+
num_results: int = 5,
|
|
199
|
+
debug_level: int = 0,
|
|
200
|
+
score_threshold: Optional[float] = None,
|
|
201
|
+
query_type: Optional[str] = None,
|
|
202
|
+
columns_to_rerank: Optional[List[str]] = None,
|
|
203
|
+
disable_notice: bool = False,
|
|
204
|
+
reranker: Optional[Reranker] = None,
|
|
205
|
+
total_retries: int = 3,
|
|
206
|
+
backoff_factor: float = 1,
|
|
207
|
+
backoff_jitter: float = 0.2,
|
|
208
|
+
*,
|
|
209
|
+
query_columns: Optional[List[str]] = None,
|
|
210
|
+
sort_columns: Optional[List[str]] = None,
|
|
211
|
+
facets: Optional[List[str]] = None,
|
|
212
|
+
_query_experiment_config: Optional[Dict[str, Any]] = None,
|
|
213
|
+
):
|
|
214
|
+
"""similarity_search(columns: List[str], query_text: Optional[str] = None, query_vector: Optional[List[float]] = None, filters: Optional[Union[str, Dict[str, Any]]] = None, num_results: int = 5, debug_level: int = 0, score_threshold: Optional[float] = None, query_type: Optional[str] = None, columns_to_rerank: Optional[List[str]] = None, disable_notice: bool = False, reranker: Optional[Reranker] = None, total_retries: int = 3, backoff_factor: float = 1, backoff_jitter: float = 0.2, query_columns: Optional[List[str]] = None, sort_columns: Optional[List[str]] = None, facets: Optional[List[str]] = None)
|
|
215
|
+
|
|
216
|
+
Perform a similarity search on the index. This returns the top K results that are most similar to the query.
|
|
217
|
+
|
|
218
|
+
:param columns: List of column names to return in the results.
|
|
219
|
+
:param query_text: Query text to search for.
|
|
220
|
+
:param query_vector: Query vector to search for.
|
|
221
|
+
:param filters: Filters to apply to the query.
|
|
222
|
+
:param num_results: Number of results to return.
|
|
223
|
+
:param debug_level: Debug level to use for the query.
|
|
224
|
+
:param score_threshold: Score threshold to use for the query. If reranker is used, the score threshold is applied before reranking.
|
|
225
|
+
:param query_type: Query type of this query. Choices are "ANN", "HYBRID", and "FULL_TEXT".
|
|
226
|
+
:param query_columns: Text columns to search for query_text. When empty, all text columns are searched.
|
|
227
|
+
:param sort_columns: Sort results by column values instead of the default relevance ordering. Each clause has the form "<column> ASC" or "<column> DESC", for example ["rating DESC", "price ASC"].
|
|
228
|
+
:param facets: Facets to compute over the matched results. Each entry is one of: "<column>" (top 10 distinct values by count), "<column> TOP <n>" (top n distinct values, n > 0), or "<column> BUCKETS [[from,to],...]" (inclusive numeric ranges). TOP and BUCKETS are case-insensitive; a column may appear at most once.
|
|
229
|
+
:param columns_to_rerank: (Deprecated) List of column names to use for reranking the results.
|
|
230
|
+
Use the ``reranker`` parameter instead.
|
|
231
|
+
:param disable_notice: Whether to disable the notice message.
|
|
232
|
+
:param reranker: Optional reranker to apply on the top results. Pass an instance of
|
|
233
|
+
:class:`databricks.ai_search.reranker.DatabricksReranker` with
|
|
234
|
+
``columns_to_rerank=[...]``. The reranker reorders the initial results using
|
|
235
|
+
the specified text columns.
|
|
236
|
+
:type reranker: Optional[:class:`databricks.ai_search.reranker.Reranker`]
|
|
237
|
+
:param total_retries: Total number of retries for the request. Set to 0 to disable retries.
|
|
238
|
+
:param backoff_factor: Backoff factor to apply between retry attempts. The delay between retries
|
|
239
|
+
is calculated as {backoff_factor} * (2 ** (retry_count - 1)) seconds. For example, with
|
|
240
|
+
backoff_factor=1, delays are 0.5s, 1s, 2s, 4s, etc.
|
|
241
|
+
:param backoff_jitter: Random jitter to add to backoff delays to avoid thundering herd problem.
|
|
242
|
+
Value between 0 and 1 representing the proportion of jitter to apply.
|
|
243
|
+
|
|
244
|
+
Example:
|
|
245
|
+
Use the Databricks reranker to improve the ordering of hybrid search results:
|
|
246
|
+
|
|
247
|
+
.. code-block:: python
|
|
248
|
+
|
|
249
|
+
from databricks.ai_search.reranker import DatabricksReranker
|
|
250
|
+
|
|
251
|
+
results = index.similarity_search(
|
|
252
|
+
query_text=\"How to create a AI Search index\",
|
|
253
|
+
columns=[\"id\", \"text\", \"parent_doc_summary\", \"date\"],
|
|
254
|
+
# The final number of results to return. The reranker will automatically overfetch 50 documents and rerank them.
|
|
255
|
+
num_results=10,
|
|
256
|
+
query_type=\"hybrid\",
|
|
257
|
+
# Needed for debug info to get any warnings and time to rerank the results.
|
|
258
|
+
debug_level=1,
|
|
259
|
+
# The text reranked will be concatenated and if it is longer than 2000 characters, it will be truncated.
|
|
260
|
+
# Include shorter, important columns first.
|
|
261
|
+
reranker=DatabricksReranker(columns_to_rerank=[\"parent_doc_summary\", \"text\", \"other_column\"]),
|
|
262
|
+
)
|
|
263
|
+
# Check if reranking was successful and how much additional time it took to rerank the results.
|
|
264
|
+
if "warnings" in results['debug_info']:
|
|
265
|
+
print(results['debug_info']['warnings'])
|
|
266
|
+
else:
|
|
267
|
+
print(f"Reranking was successful and took {results['debug_info']['reranker_time']}ms")
|
|
268
|
+
"""
|
|
269
|
+
json_data, is_reranker = self._sync._build_query_payload(
|
|
270
|
+
columns=columns,
|
|
271
|
+
query_text=query_text,
|
|
272
|
+
query_vector=query_vector,
|
|
273
|
+
filters=filters,
|
|
274
|
+
num_results=num_results,
|
|
275
|
+
debug_level=debug_level,
|
|
276
|
+
score_threshold=score_threshold,
|
|
277
|
+
query_type=query_type,
|
|
278
|
+
columns_to_rerank=columns_to_rerank,
|
|
279
|
+
disable_notice=disable_notice,
|
|
280
|
+
reranker=reranker,
|
|
281
|
+
query_columns=query_columns,
|
|
282
|
+
sort_columns=sort_columns,
|
|
283
|
+
facets=facets,
|
|
284
|
+
_query_experiment_config=_query_experiment_config,
|
|
285
|
+
)
|
|
286
|
+
query_url = await asyncio.to_thread(self._sync._resolve_query_url, is_reranker)
|
|
287
|
+
|
|
288
|
+
token = await self._aget_token(index_url=query_url, control_plane=is_reranker)
|
|
289
|
+
response = await AsyncRequestUtils.issue_request(
|
|
290
|
+
url=f"{query_url}/query",
|
|
291
|
+
token=token,
|
|
292
|
+
method="GET",
|
|
293
|
+
json=json_data,
|
|
294
|
+
total_retries=total_retries,
|
|
295
|
+
backoff_factor=backoff_factor,
|
|
296
|
+
backoff_jitter=backoff_jitter,
|
|
297
|
+
)
|
|
298
|
+
response = normalize_response_keys_to_snake_case(response)
|
|
299
|
+
|
|
300
|
+
accumulated = response
|
|
301
|
+
while response.get("next_page_token"):
|
|
302
|
+
response = await self._aget_next_page(query_url, response["next_page_token"], control_plane=is_reranker)
|
|
303
|
+
accumulated["result"]["row_count"] += response["result"]["row_count"]
|
|
304
|
+
accumulated["result"]["data_array"] += response["result"]["data_array"]
|
|
305
|
+
|
|
306
|
+
accumulated.pop("next_page_token", None)
|
|
307
|
+
return accumulated
|
|
308
|
+
|
|
309
|
+
async def aclose(self):
|
|
310
|
+
"""
|
|
311
|
+
Close this index instance.
|
|
312
|
+
"""
|
|
313
|
+
self._closed = True
|
|
314
|
+
|
|
315
|
+
async def __aenter__(self):
|
|
316
|
+
return self
|
|
317
|
+
|
|
318
|
+
async def __aexit__(self, exc_type, exc, tb):
|
|
319
|
+
await self.aclose()
|
|
320
|
+
|
|
321
|
+
async def _aget_token(self, write=False, control_plane=False, index_url=None):
|
|
322
|
+
async with self._token_lock:
|
|
323
|
+
return await asyncio.to_thread(
|
|
324
|
+
self._sync._get_token_for_request,
|
|
325
|
+
write=write,
|
|
326
|
+
control_plane=control_plane,
|
|
327
|
+
index_url=index_url,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
async def _aget_next_page(self, index_url, page_token, control_plane=False):
|
|
331
|
+
payload = self._sync._build_next_page_payload(page_token)
|
|
332
|
+
token = await self._aget_token(control_plane=control_plane)
|
|
333
|
+
response = await AsyncRequestUtils.issue_request(
|
|
334
|
+
url=f"{index_url}/query-next-page",
|
|
335
|
+
token=token,
|
|
336
|
+
method="GET",
|
|
337
|
+
json=payload,
|
|
338
|
+
total_retries=self.total_retries,
|
|
339
|
+
backoff_factor=self.backoff_factor,
|
|
340
|
+
backoff_jitter=self.backoff_jitter,
|
|
341
|
+
)
|
|
342
|
+
return normalize_response_keys_to_snake_case(response)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# Backward-compat alias for the AI Search rebrand. The canonical class is
|
|
346
|
+
# AsyncAISearchIndex; AsyncVectorSearchIndex is the same class accessible under
|
|
347
|
+
# the legacy name.
|
|
348
|
+
AsyncVectorSearchIndex = AsyncAISearchIndex
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Async HTTP transport for the Databricks AI Search SDK.
|
|
2
|
+
|
|
3
|
+
Mirrors :class:`databricks.ai_search.utils.RequestUtils` over
|
|
4
|
+
``httpx.AsyncClient``. Status codes map to the same exception hierarchy as the
|
|
5
|
+
sync path so callers can use a single ``except`` block regardless of which
|
|
6
|
+
client they use.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import datetime
|
|
11
|
+
import random
|
|
12
|
+
from email.utils import parsedate_to_datetime
|
|
13
|
+
from typing import Dict, Optional
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from databricks.ai_search.exceptions import (
|
|
18
|
+
BadRequest,
|
|
19
|
+
NotFound,
|
|
20
|
+
PermissionDenied,
|
|
21
|
+
ResourceConflict,
|
|
22
|
+
TooManyRequests,
|
|
23
|
+
AISearchException,
|
|
24
|
+
)
|
|
25
|
+
from databricks.ai_search.version import VERSION
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_RETRYABLE_STATUS_CODES = {429, 503, 504}
|
|
29
|
+
# Match the sync path's urllib3.Retry network-error coverage: TimeoutException
|
|
30
|
+
# spans the connect/read/write/pool timeouts, NetworkError spans connect/read/
|
|
31
|
+
# write/close failures, and RemoteProtocolError catches mid-stream resets.
|
|
32
|
+
_RETRYABLE_EXCEPTIONS = (
|
|
33
|
+
httpx.TimeoutException,
|
|
34
|
+
httpx.NetworkError,
|
|
35
|
+
httpx.RemoteProtocolError,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Per-event-loop client cache so one httpx.AsyncClient (and its connection
|
|
40
|
+
# pool) is reused across requests on the same loop. Sync mirrors this with
|
|
41
|
+
# the functools.cache-decorated _cached_get_request_session in utils.py.
|
|
42
|
+
_clients: Dict[int, httpx.AsyncClient] = {}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parse_retry_after(value):
|
|
46
|
+
"""Parse a Retry-After header value as either seconds or an HTTP-date.
|
|
47
|
+
|
|
48
|
+
Returns the delay in seconds, or None if the header cannot be parsed.
|
|
49
|
+
"""
|
|
50
|
+
if value is None:
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
return float(value)
|
|
54
|
+
except (TypeError, ValueError):
|
|
55
|
+
pass
|
|
56
|
+
try:
|
|
57
|
+
retry_date = parsedate_to_datetime(value)
|
|
58
|
+
except (TypeError, ValueError):
|
|
59
|
+
return None
|
|
60
|
+
if retry_date is None:
|
|
61
|
+
return None
|
|
62
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
63
|
+
if retry_date.tzinfo is None:
|
|
64
|
+
retry_date = retry_date.replace(tzinfo=datetime.timezone.utc)
|
|
65
|
+
delta = (retry_date - now).total_seconds()
|
|
66
|
+
return max(delta, 0.0)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def _get_client_for_loop():
|
|
70
|
+
"""Return a cached ``httpx.AsyncClient`` bound to the running loop.
|
|
71
|
+
|
|
72
|
+
``httpx.AsyncClient`` holds connection pools that are tied to the event
|
|
73
|
+
loop that created them; sharing across loops produces obscure errors.
|
|
74
|
+
Keying the cache by ``id(loop)`` keeps construction lazy and per-loop.
|
|
75
|
+
The body has no ``await`` between the cache check and insert, so within
|
|
76
|
+
a single loop the check-and-set is atomic.
|
|
77
|
+
"""
|
|
78
|
+
loop = asyncio.get_running_loop()
|
|
79
|
+
key = id(loop)
|
|
80
|
+
client = _clients.get(key)
|
|
81
|
+
if client is None or client.is_closed:
|
|
82
|
+
client = httpx.AsyncClient(timeout=None)
|
|
83
|
+
_clients[key] = client
|
|
84
|
+
return client
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def aclose_all_clients():
|
|
88
|
+
"""Close every cached ``httpx.AsyncClient``. Intended for tests and orderly shutdown.
|
|
89
|
+
|
|
90
|
+
Do not call while requests are in flight — this closes the underlying
|
|
91
|
+
connection pools, which will cause any awaiting coroutines to raise
|
|
92
|
+
``httpx.ClientError``.
|
|
93
|
+
"""
|
|
94
|
+
clients = list(_clients.values())
|
|
95
|
+
_clients.clear()
|
|
96
|
+
for client in clients:
|
|
97
|
+
try:
|
|
98
|
+
await client.aclose()
|
|
99
|
+
except (httpx.HTTPError, OSError):
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class AsyncRequestUtils:
|
|
104
|
+
"""Async equivalent of :class:`RequestUtils`.
|
|
105
|
+
|
|
106
|
+
Issues a single HTTP request with retry/backoff and translates non-2xx
|
|
107
|
+
responses into the SDK's typed exceptions. ``httpx`` has no built-in
|
|
108
|
+
retry support, so the loop is implemented here.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
async def issue_request(
|
|
113
|
+
url: str,
|
|
114
|
+
method: str,
|
|
115
|
+
token: Optional[str] = None,
|
|
116
|
+
params: Optional[dict] = None,
|
|
117
|
+
json: Optional[dict] = None,
|
|
118
|
+
auth: Optional[tuple] = None,
|
|
119
|
+
data: Optional[dict] = None,
|
|
120
|
+
headers: Optional[dict] = None,
|
|
121
|
+
total_retries: int = 3,
|
|
122
|
+
backoff_factor: float = 1.0,
|
|
123
|
+
backoff_jitter: float = 0.2,
|
|
124
|
+
client: Optional[httpx.AsyncClient] = None,
|
|
125
|
+
):
|
|
126
|
+
"""Issue an async HTTP request and return its JSON body.
|
|
127
|
+
|
|
128
|
+
Retries up to ``total_retries`` times on transient failures (HTTP
|
|
129
|
+
429/503/504 plus httpx ``TimeoutException`` / ``NetworkError`` /
|
|
130
|
+
``RemoteProtocolError``). Honors ``Retry-After`` when present;
|
|
131
|
+
otherwise sleeps for
|
|
132
|
+
``backoff_factor * (2 ** (attempt - 1)) * (1 + backoff_jitter * random())``
|
|
133
|
+
seconds between attempts. Non-retryable 4xx responses raise immediately.
|
|
134
|
+
"""
|
|
135
|
+
headers = dict(headers) if headers else {}
|
|
136
|
+
if token:
|
|
137
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
138
|
+
headers["X-Databricks-Python-SDK-Version"] = VERSION
|
|
139
|
+
|
|
140
|
+
owns_client = client is None
|
|
141
|
+
if owns_client:
|
|
142
|
+
client = await _get_client_for_loop()
|
|
143
|
+
|
|
144
|
+
last_exc: Optional[BaseException] = None
|
|
145
|
+
response: Optional[httpx.Response] = None
|
|
146
|
+
for attempt in range(total_retries + 1):
|
|
147
|
+
try:
|
|
148
|
+
response = await client.request(
|
|
149
|
+
method=method,
|
|
150
|
+
url=url,
|
|
151
|
+
params=params,
|
|
152
|
+
json=json,
|
|
153
|
+
data=data,
|
|
154
|
+
headers=headers,
|
|
155
|
+
auth=auth,
|
|
156
|
+
)
|
|
157
|
+
if response.status_code in _RETRYABLE_STATUS_CODES and attempt < total_retries:
|
|
158
|
+
delay = _parse_retry_after(response.headers.get("Retry-After"))
|
|
159
|
+
if delay is None:
|
|
160
|
+
delay = backoff_factor * (2**attempt) * (1 + backoff_jitter * random.random())
|
|
161
|
+
await asyncio.sleep(delay)
|
|
162
|
+
continue
|
|
163
|
+
break
|
|
164
|
+
except _RETRYABLE_EXCEPTIONS as exc:
|
|
165
|
+
last_exc = exc
|
|
166
|
+
if attempt >= total_retries:
|
|
167
|
+
raise
|
|
168
|
+
delay = backoff_factor * (2**attempt) * (1 + backoff_jitter * random.random())
|
|
169
|
+
await asyncio.sleep(delay)
|
|
170
|
+
|
|
171
|
+
if response is None:
|
|
172
|
+
raise AISearchException(
|
|
173
|
+
f"Request to {url} failed: {last_exc!r}",
|
|
174
|
+
status_code=None,
|
|
175
|
+
response_content=None,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
if response.is_success:
|
|
179
|
+
return response.json()
|
|
180
|
+
|
|
181
|
+
status_code = response.status_code
|
|
182
|
+
error_message = f"HTTP {status_code}"
|
|
183
|
+
try:
|
|
184
|
+
error_data = response.json()
|
|
185
|
+
if isinstance(error_data, dict):
|
|
186
|
+
error_message = error_data.get("message") or error_data.get("error") or str(error_data)
|
|
187
|
+
except ValueError:
|
|
188
|
+
# json.JSONDecodeError is a ValueError subclass; we catch the parent
|
|
189
|
+
# because the `json` parameter on this method shadows the stdlib name
|
|
190
|
+
# inside this scope.
|
|
191
|
+
error_message = response.text or f"HTTP {status_code} error"
|
|
192
|
+
|
|
193
|
+
content = response.content
|
|
194
|
+
if status_code == 400:
|
|
195
|
+
raise BadRequest(error_message, status_code=status_code, response_content=content)
|
|
196
|
+
if status_code == 403:
|
|
197
|
+
raise PermissionDenied(error_message, status_code=status_code, response_content=content)
|
|
198
|
+
if status_code == 404:
|
|
199
|
+
raise NotFound(error_message, status_code=status_code, response_content=content)
|
|
200
|
+
if status_code == 409:
|
|
201
|
+
raise ResourceConflict(error_message, status_code=status_code, response_content=content)
|
|
202
|
+
if status_code == 429:
|
|
203
|
+
raise TooManyRequests(error_message, status_code=status_code, response_content=content)
|
|
204
|
+
raise AISearchException(error_message, status_code=status_code, response_content=content)
|