Skip to content

Commit 7df8178

Browse files
sanketkediajairad26
authored andcommitted
[ENH]: Client side retries
1 parent 128ad9d commit 7df8178

File tree

8 files changed

+303
-60
lines changed

8 files changed

+303
-60
lines changed

chromadb/api/async_fastapi.py

Lines changed: 67 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@
66
import logging
77
import httpx
88
from overrides import override
9+
from tenacity import (
10+
AsyncRetrying,
11+
RetryError,
12+
before_sleep_log,
13+
retry_if_exception,
14+
stop_after_attempt,
15+
wait_exponential,
16+
wait_random_exponential,
17+
)
918
from chromadb import __version__
1019
from chromadb.auth import UserIdentity
1120
from chromadb.api.async_api import AsyncServerAPI
@@ -16,6 +25,7 @@
1625
create_collection_configuration_to_json,
1726
update_collection_configuration_to_json,
1827
)
28+
from chromadb.api.fastapi import is_retryable_exception
1929
from chromadb.config import DEFAULT_DATABASE, DEFAULT_TENANT, System, Settings
2030
from chromadb.telemetry.opentelemetry import (
2131
OpenTelemetryClient,
@@ -140,20 +150,63 @@ def _get_client(self) -> httpx.AsyncClient:
140150
async def _make_request(
141151
self, method: str, path: str, **kwargs: Dict[str, Any]
142152
) -> Any:
143-
# If the request has json in kwargs, use orjson to serialize it,
144-
# remove it from kwargs, and add it to the content parameter
145-
# This is because httpx uses a slower json serializer
146-
if "json" in kwargs:
147-
data = orjson.dumps(kwargs.pop("json"), option=orjson.OPT_SERIALIZE_NUMPY)
148-
kwargs["content"] = data
149-
150-
# Unlike requests, httpx does not automatically escape the path
151-
escaped_path = urllib.parse.quote(path, safe="/", encoding=None, errors=None)
152-
url = self._api_url + escaped_path
153-
154-
response = await self._get_client().request(method, url, **cast(Any, kwargs))
155-
BaseHTTPClient._raise_chroma_error(response)
156-
return orjson.loads(response.text)
153+
async def _send_request() -> Any:
154+
# If the request has json in kwargs, use orjson to serialize it,
155+
# remove it from kwargs, and add it to the content parameter
156+
# This is because httpx uses a slower json serializer
157+
if "json" in kwargs:
158+
data = orjson.dumps(
159+
kwargs.pop("json"), option=orjson.OPT_SERIALIZE_NUMPY
160+
)
161+
kwargs["content"] = data
162+
163+
# Unlike requests, httpx does not automatically escape the path
164+
escaped_path = urllib.parse.quote(
165+
path, safe="/", encoding=None, errors=None
166+
)
167+
url = self._api_url + escaped_path
168+
169+
response = await self._get_client().request(
170+
method, url, **cast(Any, kwargs)
171+
)
172+
BaseHTTPClient._raise_chroma_error(response)
173+
return orjson.loads(response.text)
174+
175+
retry_config = self._settings.retry_config
176+
177+
if retry_config is None:
178+
return await _send_request()
179+
180+
min_delay = max(float(retry_config.min_delay), 0.0)
181+
max_delay = max(float(retry_config.max_delay), min_delay)
182+
multiplier = max(min_delay, 1e-3)
183+
exp_base = retry_config.factor if retry_config.factor > 0 else 2.0
184+
185+
wait_args = {
186+
"multiplier": multiplier,
187+
"min": min_delay,
188+
"max": max_delay,
189+
"exp_base": exp_base,
190+
}
191+
192+
wait_strategy = (
193+
wait_random_exponential(**wait_args)
194+
if retry_config.jitter
195+
else wait_exponential(**wait_args)
196+
)
197+
198+
retrying = AsyncRetrying(
199+
stop=stop_after_attempt(retry_config.max_attempts),
200+
wait=wait_strategy,
201+
retry=retry_if_exception(is_retryable_exception),
202+
before_sleep=before_sleep_log(logger, logging.INFO),
203+
reraise=True,
204+
)
205+
206+
try:
207+
return await retrying(_send_request)
208+
except RetryError as e:
209+
raise e.last_attempt.exception() from None
157210

158211
@trace_method("AsyncFastAPI.heartbeat", OpenTelemetryGranularity.OPERATION)
159212
@override

chromadb/api/fastapi.py

Lines changed: 87 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@
66
import httpx
77
import urllib.parse
88
from overrides import override
9+
from tenacity import (
10+
RetryError,
11+
Retrying,
12+
before_sleep_log,
13+
retry_if_exception,
14+
stop_after_attempt,
15+
wait_exponential,
16+
wait_random_exponential,
17+
)
918

1019
from chromadb.api.collection_configuration import (
1120
CreateCollectionConfiguration,
@@ -57,6 +66,28 @@
5766
logger = logging.getLogger(__name__)
5867

5968

69+
def is_retryable_exception(exception: BaseException) -> bool:
70+
if isinstance(
71+
exception,
72+
(
73+
httpx.ConnectError,
74+
httpx.ConnectTimeout,
75+
httpx.ReadTimeout,
76+
httpx.WriteTimeout,
77+
httpx.PoolTimeout,
78+
httpx.NetworkError,
79+
httpx.RemoteProtocolError,
80+
),
81+
):
82+
return True
83+
84+
if isinstance(exception, httpx.HTTPStatusError):
85+
# Retry on server errors that might be temporary
86+
return exception.response.status_code in [502, 503, 504]
87+
88+
return False
89+
90+
6091
class FastAPI(BaseHTTPClient, ServerAPI):
6192
def __init__(self, system: System):
6293
super().__init__(system)
@@ -97,20 +128,62 @@ def __init__(self, system: System):
97128
self._session.headers[header] = value.get_secret_value()
98129

99130
def _make_request(self, method: str, path: str, **kwargs: Dict[str, Any]) -> Any:
100-
# If the request has json in kwargs, use orjson to serialize it,
101-
# remove it from kwargs, and add it to the content parameter
102-
# This is because httpx uses a slower json serializer
103-
if "json" in kwargs:
104-
data = orjson.dumps(kwargs.pop("json"), option=orjson.OPT_SERIALIZE_NUMPY)
105-
kwargs["content"] = data
106-
107-
# Unlike requests, httpx does not automatically escape the path
108-
escaped_path = urllib.parse.quote(path, safe="/", encoding=None, errors=None)
109-
url = self._api_url + escaped_path
110-
111-
response = self._session.request(method, url, **cast(Any, kwargs))
112-
BaseHTTPClient._raise_chroma_error(response)
113-
return orjson.loads(response.text)
131+
def _send_request() -> Any:
132+
# If the request has json in kwargs, use orjson to serialize it,
133+
# remove it from kwargs, and add it to the content parameter
134+
# This is because httpx uses a slower json serializer
135+
if "json" in kwargs:
136+
data = orjson.dumps(
137+
kwargs.pop("json"), option=orjson.OPT_SERIALIZE_NUMPY
138+
)
139+
kwargs["content"] = data
140+
141+
# Unlike requests, httpx does not automatically escape the path
142+
escaped_path = urllib.parse.quote(
143+
path, safe="/", encoding=None, errors=None
144+
)
145+
url = self._api_url + escaped_path
146+
147+
response = self._session.request(method, url, **cast(Any, kwargs))
148+
BaseHTTPClient._raise_chroma_error(response)
149+
return orjson.loads(response.text)
150+
151+
retry_config = self._settings.retry_config
152+
153+
if retry_config is None:
154+
return _send_request()
155+
156+
min_delay = max(float(retry_config.min_delay), 0.0)
157+
max_delay = max(float(retry_config.max_delay), min_delay)
158+
multiplier = max(min_delay, 1e-3)
159+
exp_base = retry_config.factor if retry_config.factor > 0 else 2.0
160+
161+
wait_args = {
162+
"multiplier": multiplier,
163+
"min": min_delay,
164+
"max": max_delay,
165+
"exp_base": exp_base,
166+
}
167+
168+
wait_strategy = (
169+
wait_random_exponential(**wait_args)
170+
if retry_config.jitter
171+
else wait_exponential(**wait_args)
172+
)
173+
174+
retrying = Retrying(
175+
stop=stop_after_attempt(retry_config.max_attempts),
176+
wait=wait_strategy,
177+
retry=retry_if_exception(is_retryable_exception),
178+
before_sleep=before_sleep_log(logger, logging.INFO),
179+
reraise=True,
180+
)
181+
182+
try:
183+
return retrying(_send_request)
184+
except RetryError as e:
185+
# Re-raise the last exception that caused the retry to fail
186+
raise e.last_attempt.exception() from None
114187

115188
@trace_method("FastAPI.heartbeat", OpenTelemetryGranularity.OPERATION)
116189
@override

chromadb/config.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from overrides import override
1212
from typing_extensions import Literal
1313
import platform
14+
from pydantic import BaseModel
1415

1516
in_pydantic_v2 = False
1617
try:
@@ -97,6 +98,14 @@ class APIVersion(str, Enum):
9798
V2 = "/api/v2"
9899

99100

101+
class RetryConfig(BaseModel):
102+
factor: float = 2.0
103+
min_delay: int = 1
104+
max_delay: int = 5
105+
max_attempts: int = 5
106+
jitter: bool = True
107+
108+
100109
# NOTE(hammadb) 1/13/2024 - This has to be in config.py instead of being localized to the module
101110
# that uses it because of a circular import issue. This is a temporary solution until we can
102111
# refactor the code to remove the circular import.
@@ -133,6 +142,8 @@ def empty_str_to_none(cls, v: str) -> Optional[str]:
133142
return None
134143
return v
135144

145+
retry_config: Optional[RetryConfig] = RetryConfig()
146+
136147
chroma_server_nofile: Optional[int] = None
137148
# the number of maximum threads to handle synchronous tasks in the FastAPI server
138149
chroma_server_thread_pool_size: int = 40

clients/new-js/packages/chromadb/src/admin-client.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { defaultAdminClientArgs, HttpMethod, normalizeMethod } from "./utils";
22
import { createClient, createConfig } from "@hey-api/client-fetch";
33
import { Database, DefaultService as Api } from "./api";
4-
import { chromaFetch } from "./chroma-fetch";
4+
import { createChromaFetch } from "./chroma-fetch";
5+
import type { RetryConfig } from "./retry";
56

67
/**
78
* Configuration options for the AdminClient.
@@ -17,6 +18,8 @@ export interface AdminClientArgs {
1718
headers?: Record<string, string>;
1819
/** Additional fetch options for HTTP requests */
1920
fetchOptions?: RequestInit;
21+
/** Retry configuration for HTTP requests. Set to null to disable retries */
22+
retryConfig?: RetryConfig | null;
2023
}
2124

2225
/**
@@ -43,8 +46,17 @@ export class AdminClient {
4346
* @param args - Optional configuration for the admin client
4447
*/
4548
constructor(args?: AdminClientArgs) {
46-
const { host, port, ssl, headers, fetchOptions } =
47-
args || defaultAdminClientArgs;
49+
const {
50+
host,
51+
port,
52+
ssl,
53+
headers,
54+
fetchOptions,
55+
retryConfig,
56+
} = {
57+
...defaultAdminClientArgs,
58+
...(args ?? {}),
59+
};
4860

4961
const baseUrl = `${ssl ? "https" : "http"}://${host}:${port}`;
5062

@@ -56,7 +68,7 @@ export class AdminClient {
5668
};
5769

5870
this.apiClient = createClient(createConfig(configOptions));
59-
this.apiClient.setConfig({ fetch: chromaFetch });
71+
this.apiClient.setConfig({ fetch: createChromaFetch({ retryConfig }) });
6072
}
6173

6274
/**

clients/new-js/packages/chromadb/src/chroma-client.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import { DefaultService as Api, ChecklistResponse } from "./api";
99
import { CollectionMetadata, UserIdentity } from "./types";
1010
import { Collection, CollectionImpl } from "./collection";
1111
import { EmbeddingFunction, getEmbeddingFunction } from "./embedding-function";
12-
import { chromaFetch } from "./chroma-fetch";
12+
import { createChromaFetch } from "./chroma-fetch";
13+
import type { RetryConfig } from "./retry";
1314
import * as process from "node:process";
1415
import {
1516
ChromaConnectionError,
@@ -39,6 +40,8 @@ export interface ChromaClientArgs {
3940
headers?: Record<string, string>;
4041
/** Additional fetch options for HTTP requests */
4142
fetchOptions?: RequestInit;
43+
/** Retry configuration for HTTP requests. Set to null to disable retries */
44+
retryConfig?: RetryConfig | null;
4245
/** @deprecated Use host, port, and ssl instead */
4346
path?: string;
4447
/** @deprecated */
@@ -68,6 +71,7 @@ export class ChromaClient {
6871
database = defaultArgs.database,
6972
headers = defaultArgs.headers,
7073
fetchOptions = defaultArgs.fetchOptions,
74+
retryConfig = defaultArgs.retryConfig,
7175
} = args;
7276

7377
if (args.path) {
@@ -109,7 +113,7 @@ export class ChromaClient {
109113
};
110114

111115
this.apiClient = createClient(createConfig(configOptions));
112-
this.apiClient.setConfig({ fetch: chromaFetch });
116+
this.apiClient.setConfig({ fetch: createChromaFetch({ retryConfig }) });
113117
}
114118

115119
/**

0 commit comments

Comments
 (0)