Skip to content

Commit a4fd318

Browse files
authored
PY-206 Improve retries UX in neptune-query (#57)
* Make a warning class for the type inference warnings * Add mechanism for throttled warnings and use that for the ExperimentalWarning * Add throttled warnings for retries * Updates to the warning throttling * Remove unused debug-only function * Update comments in `test_warnings.py` * Format code
1 parent 6280d5a commit a4fd318

File tree

6 files changed

+300
-17
lines changed

6 files changed

+300
-17
lines changed

src/neptune_query/internal/composition/type_inference.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515
import copy
16-
import warnings
1716
from collections import defaultdict
1817
from concurrent.futures import Executor
1918
from dataclasses import dataclass
@@ -28,6 +27,7 @@
2827
from neptune_api.client import AuthenticatedClient
2928

3029
from ...exceptions import AttributeTypeInferenceError
30+
from ...warnings import AttributeWarning
3131
from .. import (
3232
filters,
3333
identifiers,
@@ -39,6 +39,7 @@
3939
HISTOGRAM_SERIES_AGGREGATIONS,
4040
STRING_SERIES_AGGREGATIONS,
4141
)
42+
from ..warnings import throttled_warn
4243
from .attributes import fetch_attribute_definitions
4344

4445
T = TypeVar("T", bound=Union[filters._Filter, filters._Attribute, None])
@@ -155,9 +156,10 @@ def get_result_or_raise(self) -> T:
155156
def emit_warnings(self) -> None:
156157
for attr_state in self.attributes:
157158
if attr_state.warning_text:
158-
msg = f"Attribute '{attr_state.original_attribute.name}': {attr_state.warning_text}"
159-
# TODO: Add category to warnings.py
160-
warnings.warn(msg, stacklevel=3)
159+
throttled_warn(
160+
AttributeWarning(f"Attribute '{attr_state.original_attribute.name}': {attr_state.warning_text}"),
161+
stacklevel=3,
162+
)
161163

162164

163165
def infer_attribute_types_in_filter(

src/neptune_query/internal/experimental.py

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,15 @@
1414
# limitations under the License.
1515

1616
import functools
17-
import warnings
1817
from typing import (
1918
Callable,
2019
ParamSpec,
2120
TypeVar,
2221
)
2322

23+
from neptune_query.internal.warnings import throttled_warn
2424
from neptune_query.warnings import ExperimentalWarning
2525

26-
# registry of functions already warned
27-
_warned_experimentals = set()
28-
2926
T = ParamSpec("T")
3027
R = TypeVar("R")
3128

@@ -38,14 +35,13 @@ def experimental(func: Callable[T, R]) -> Callable[T, R]:
3835

3936
@functools.wraps(func)
4037
def wrapper(*args: T.args, **kwargs: T.kwargs) -> R:
41-
if func not in _warned_experimentals:
42-
warnings.warn(
43-
f"{func.__qualname__} is experimental and might change or be removed "
44-
"in a future minor release. Use with caution in production code.",
45-
category=ExperimentalWarning,
46-
stacklevel=2,
47-
)
48-
_warned_experimentals.add(func)
38+
throttled_warn(
39+
ExperimentalWarning(
40+
f"`{func.__module__}.{func.__qualname__}` is experimental and might change or be removed "
41+
"in a future minor release. Use with caution in production code."
42+
),
43+
stacklevel=3,
44+
)
4945
return func(*args, **kwargs)
5046

5147
return wrapper

src/neptune_query/internal/retrieval/retry.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,15 @@
3232
from neptune_api.types import Response
3333

3434
from ... import exceptions
35+
from ...warnings import (
36+
Http5xxWarning,
37+
Http429Warning,
38+
Http503Warning,
39+
HttpOtherWarning,
40+
)
3541
from .. import env
3642
from ..logger import get_logger
43+
from ..warnings import throttled_warn
3744

3845
logger = get_logger()
3946

@@ -87,7 +94,6 @@ def wrapper(*args: T.args, **kwargs: T.kwargs) -> Response[R]:
8794
response = None
8895
try:
8996
response = func(*args, **kwargs)
90-
9197
if 200 <= response.status_code.value < 300:
9298
return response
9399
except exceptions.NeptuneError:
@@ -111,12 +117,44 @@ def wrapper(*args: T.args, **kwargs: T.kwargs) -> Response[R]:
111117
f"Neptune API request was rate limited. Retry-after header value: {sleep_time} seconds. "
112118
f"Total time spent on rate limiting so far: {rate_limit_time_extension} seconds."
113119
)
120+
throttled_warn(
121+
Http429Warning(
122+
"Neptune API request was rate limited. We will slow down and retry automatically."
123+
),
124+
stacklevel=4,
125+
)
126+
elif response is not None and response.status_code.value == 503:
127+
sleep_time = backoff_strategy(backoff_tries)
128+
logger.debug(
129+
f"Neptune API request failed. Backoff strategy recommends backing off for {sleep_time:.2f} "
130+
f"seconds. Response: {response}. Last exception: {last_exc}."
131+
)
132+
throttled_warn(
133+
Http503Warning("Neptune API is temporarily unavailable. We will retry automatically."),
134+
stacklevel=4,
135+
)
136+
elif response is not None and 500 <= response.status_code.value < 600:
137+
sleep_time = backoff_strategy(backoff_tries)
138+
logger.debug(
139+
f"Neptune API request failed. Backoff strategy recommends backing off for {sleep_time:.2f} "
140+
f"seconds. Response: {response}. Last exception: {last_exc}."
141+
)
142+
throttled_warn(
143+
Http5xxWarning(
144+
f"Neptune API request failed with HTTP code {response.status_code.value}. "
145+
"We will retry automatically."
146+
),
147+
stacklevel=4,
148+
)
114149
else:
115150
sleep_time = backoff_strategy(backoff_tries)
116151
logger.debug(
117152
f"Neptune API request failed. Backoff strategy recommends backing off for {sleep_time:.2f} "
118153
f"seconds. Response: {response}. Last exception: {last_exc}."
119154
)
155+
throttled_warn(
156+
HttpOtherWarning("Neptune API request failed. We will retry automatically"), stacklevel=4
157+
)
120158

121159
elapsed_time = time.monotonic() - start_time
122160

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#
2+
# Copyright (c) 2025, Neptune Labs Sp. z o.o.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
import sys
16+
import warnings
17+
from datetime import (
18+
datetime,
19+
timedelta,
20+
)
21+
from typing import Type
22+
23+
from neptune_query.warnings import (
24+
ExperimentalWarning,
25+
Http5xxWarning,
26+
Http429Warning,
27+
HttpOtherWarning,
28+
)
29+
30+
# registry of warnings that were already emitted with the (type, message) tuple
31+
WARNING_TYPES_EMITTED_ONCE_PER_MESSAGE = (ExperimentalWarning,)
32+
33+
# registry of warning types that were already emitted with the time they should be silenced until
34+
WARNING_TYPES_EMITTED_ONCE_PER_WHILE = (Http429Warning, Http5xxWarning, HttpOtherWarning)
35+
WARNING_SUPPRESSION_DURATION = timedelta(seconds=20)
36+
37+
_silence_warnings_msg: set[tuple[Type[Warning], str]] = set()
38+
_silence_warnings_until: dict[Type[Warning], datetime] = {}
39+
40+
41+
def format_warning(warning: Warning) -> Warning:
42+
# check if stderr is a terminal:
43+
if sys.stderr.isatty():
44+
orange_bold = "\033[1;38;2;255;165;0m"
45+
end = "\033[0m"
46+
msg = f"{orange_bold}{str(warning)}{end}"
47+
return type(warning)(msg)
48+
else:
49+
return warning
50+
51+
52+
def throttled_warn(warning: Warning, stacklevel: int = 3) -> None:
53+
if isinstance(warning, WARNING_TYPES_EMITTED_ONCE_PER_MESSAGE):
54+
key = (type(warning), str(warning))
55+
if key in _silence_warnings_msg:
56+
return
57+
_silence_warnings_msg.add(key)
58+
59+
if isinstance(warning, WARNING_TYPES_EMITTED_ONCE_PER_WHILE):
60+
warning_type = type(warning)
61+
now = datetime.now()
62+
if warning_type in _silence_warnings_until and _silence_warnings_until[warning_type] > now:
63+
return
64+
_silence_warnings_until[warning_type] = now + WARNING_SUPPRESSION_DURATION
65+
66+
warnings.warn(format_warning(warning), stacklevel=stacklevel)

src/neptune_query/warnings.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,25 @@
1414
# limitations under the License.
1515

1616

17+
class AttributeWarning(UserWarning):
18+
"""Warning for attribute issues."""
19+
20+
1721
class ExperimentalWarning(UserWarning):
1822
"""Warning for use of experimental API elements."""
23+
24+
25+
class Http429Warning(UserWarning):
26+
"""Warning for retryable HTTP 429 responses (rate limiting)."""
27+
28+
29+
class Http503Warning(UserWarning):
30+
"""Warning for retryable HTTP 503 responses (service unavailable)."""
31+
32+
33+
class Http5xxWarning(UserWarning):
34+
"""Warning for retryable HTTP 5xx responses (server errors)."""
35+
36+
37+
class HttpOtherWarning(UserWarning):
38+
"""Warning for other retryable HTTP issues."""

0 commit comments

Comments
 (0)