Skip to content

Commit 605fb92

Browse files
authored
[low-code]: Evaluate backoff strategies at runtime (#18053)
* pass options to wait time from header * fix constant backoff * parameterize test * fix tests * missing unit tests * eval header at runtime * eval regex at runtime * evaluate min_wait at runtime * eval factor at runtime * missing unit tests * remove debug print * rename * Add tests * Add tests * Update docs
1 parent 838aebe commit 605fb92

File tree

13 files changed

+211
-106
lines changed

13 files changed

+211
-106
lines changed

airbyte-cdk/python/airbyte_cdk/sources/declarative/parsers/factory.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,14 +229,17 @@ def _create_subcomponent(self, key, definition, kwargs, config, parent_class, in
229229
options = kwargs.get(OPTIONS_STR, {})
230230
try:
231231
# enums can't accept options
232-
if issubclass(expected_type, enum.Enum):
232+
if issubclass(expected_type, enum.Enum) or self.is_primitive(definition):
233233
return expected_type(definition)
234234
else:
235235
return expected_type(definition, options=options)
236236
except Exception as e:
237237
raise Exception(f"failed to instantiate type {expected_type}. {e}")
238238
return definition
239239

240+
def is_primitive(self, obj):
241+
return isinstance(obj, (int, float, bool))
242+
240243
@staticmethod
241244
def is_object_definition_with_class_name(definition):
242245
return isinstance(definition, dict) and "class_name" in definition

airbyte-cdk/python/airbyte_cdk/sources/declarative/requesters/error_handlers/backoff_strategies/constant_backoff_strategy.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
# Copyright (c) 2022 Airbyte, Inc., all rights reserved.
33
#
44

5-
from dataclasses import dataclass
6-
from typing import Optional
5+
from dataclasses import InitVar, dataclass
6+
from typing import Any, Mapping, Optional, Union
77

88
import requests
9+
from airbyte_cdk.sources.declarative.interpolation.interpolated_string import InterpolatedString
910
from airbyte_cdk.sources.declarative.requesters.error_handlers.backoff_strategy import BackoffStrategy
11+
from airbyte_cdk.sources.declarative.types import Config
1012
from dataclasses_jsonschema import JsonSchemaMixin
1113

1214

@@ -19,7 +21,14 @@ class ConstantBackoffStrategy(BackoffStrategy, JsonSchemaMixin):
1921
backoff_time_in_seconds (float): time to backoff before retrying a retryable request.
2022
"""
2123

22-
backoff_time_in_seconds: float
24+
backoff_time_in_seconds: Union[float, InterpolatedString, str]
25+
options: InitVar[Mapping[str, Any]]
26+
config: Config
27+
28+
def __post_init__(self, options: Mapping[str, Any]):
29+
if not isinstance(self.backoff_time_in_seconds, InterpolatedString):
30+
self.backoff_time_in_seconds = str(self.backoff_time_in_seconds)
31+
self.backoff_time_in_seconds = InterpolatedString.create(self.backoff_time_in_seconds, options=options)
2332

2433
def backoff(self, response: requests.Response, attempt_count: int) -> Optional[float]:
25-
return self.backoff_time_in_seconds
34+
return self.backoff_time_in_seconds.eval(self.config)

airbyte-cdk/python/airbyte_cdk/sources/declarative/requesters/error_handlers/backoff_strategies/exponential_backoff_strategy.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
# Copyright (c) 2022 Airbyte, Inc., all rights reserved.
33
#
44

5-
from dataclasses import dataclass
6-
from typing import Optional
5+
from dataclasses import InitVar, dataclass
6+
from typing import Any, Mapping, Optional, Union
77

88
import requests
9+
from airbyte_cdk.sources.declarative.interpolation.interpolated_string import InterpolatedString
910
from airbyte_cdk.sources.declarative.requesters.error_handlers.backoff_strategy import BackoffStrategy
11+
from airbyte_cdk.sources.declarative.types import Config
1012
from dataclasses_jsonschema import JsonSchemaMixin
1113

1214

@@ -19,7 +21,14 @@ class ExponentialBackoffStrategy(BackoffStrategy, JsonSchemaMixin):
1921
factor (float): multiplicative factor
2022
"""
2123

22-
factor: float = 5
24+
options: InitVar[Mapping[str, Any]]
25+
config: Config
26+
factor: Union[float, InterpolatedString, str] = 5
27+
28+
def __post_init__(self, options: Mapping[str, Any]):
29+
if not isinstance(self.factor, InterpolatedString):
30+
self.factor = str(self.factor)
31+
self.factor = InterpolatedString.create(self.factor, options=options)
2332

2433
def backoff(self, response: requests.Response, attempt_count: int) -> Optional[float]:
25-
return self.factor * 2**attempt_count
34+
return self.factor.eval(self.config) * 2**attempt_count

airbyte-cdk/python/airbyte_cdk/sources/declarative/requesters/error_handlers/backoff_strategies/wait_time_from_header_backoff_strategy.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
#
44

55
import re
6-
from dataclasses import dataclass
7-
from typing import Optional
6+
from dataclasses import InitVar, dataclass
7+
from typing import Any, Mapping, Optional, Union
88

99
import requests
10+
from airbyte_cdk.sources.declarative.interpolation.interpolated_string import InterpolatedString
1011
from airbyte_cdk.sources.declarative.requesters.error_handlers.backoff_strategies.header_helper import get_numeric_value_from_header
1112
from airbyte_cdk.sources.declarative.requesters.error_handlers.backoff_strategy import BackoffStrategy
13+
from airbyte_cdk.sources.declarative.types import Config
1214
from dataclasses_jsonschema import JsonSchemaMixin
1315

1416

@@ -22,12 +24,16 @@ class WaitTimeFromHeaderBackoffStrategy(BackoffStrategy, JsonSchemaMixin):
2224
regex (Optional[str]): optional regex to apply on the header to extract its value
2325
"""
2426

25-
header: str
27+
header: Union[InterpolatedString, str]
28+
options: InitVar[Mapping[str, Any]]
29+
config: Config
2630
regex: Optional[str] = None
2731

28-
def __post_init__(self):
32+
def __post_init__(self, options: Mapping[str, Any]):
2933
self.regex = re.compile(self.regex) if self.regex else None
34+
self.header = InterpolatedString.create(self.header, options=options)
3035

3136
def backoff(self, response: requests.Response, attempt_count: int) -> Optional[float]:
32-
header_value = get_numeric_value_from_header(response, self.header, self.regex)
37+
header = self.header.eval(config=self.config)
38+
header_value = get_numeric_value_from_header(response, header, self.regex)
3339
return header_value

airbyte-cdk/python/airbyte_cdk/sources/declarative/requesters/error_handlers/backoff_strategies/wait_until_time_from_header_backoff_strategy.py

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
import numbers
66
import re
77
import time
8-
from dataclasses import dataclass
9-
from typing import Optional
8+
from dataclasses import InitVar, dataclass
9+
from typing import Any, Mapping, Optional, Union
1010

1111
import requests
12+
from airbyte_cdk.sources.declarative.interpolation.interpolated_string import InterpolatedString
1213
from airbyte_cdk.sources.declarative.requesters.error_handlers.backoff_strategies.header_helper import get_numeric_value_from_header
1314
from airbyte_cdk.sources.declarative.requesters.error_handlers.backoff_strategy import BackoffStrategy
15+
from airbyte_cdk.sources.declarative.types import Config
1416
from dataclasses_jsonschema import JsonSchemaMixin
1517

1618

@@ -26,24 +28,36 @@ class WaitUntilTimeFromHeaderBackoffStrategy(BackoffStrategy, JsonSchemaMixin):
2628
regex (Optional[str]): optional regex to apply on the header to extract its value
2729
"""
2830

29-
header: str
30-
min_wait: Optional[float] = None
31-
regex: Optional[str] = None
31+
header: Union[InterpolatedString, str]
32+
options: InitVar[Mapping[str, Any]]
33+
config: Config
34+
min_wait: Optional[Union[float, InterpolatedString, str]] = None
35+
regex: Optional[Union[InterpolatedString, str]] = None
3236

33-
def __post_init__(self):
34-
self.regex = re.compile(self.regex) if self.regex else None
37+
def __post_init__(self, options: Mapping[str, Any]):
38+
self.header = InterpolatedString.create(self.header, options=options)
39+
self.regex = InterpolatedString.create(self.regex, options=options) if self.regex else None
40+
if not isinstance(self.min_wait, InterpolatedString):
41+
self.min_wait = InterpolatedString.create(str(self.min_wait), options=options)
3542

3643
def backoff(self, response: requests.Response, attempt_count: int) -> Optional[float]:
3744
now = time.time()
38-
wait_until = get_numeric_value_from_header(response, self.header, self.regex)
45+
header = self.header.eval(self.config)
46+
if self.regex:
47+
evaled_regex = self.regex.eval(self.config)
48+
regex = re.compile(evaled_regex)
49+
else:
50+
regex = None
51+
wait_until = get_numeric_value_from_header(response, header, regex)
52+
min_wait = self.min_wait.eval(self.config)
3953
if wait_until is None or not wait_until:
40-
return self.min_wait
54+
return min_wait
4155
if (isinstance(wait_until, str) and wait_until.isnumeric()) or isinstance(wait_until, numbers.Number):
4256
wait_time = float(wait_until) - now
4357
else:
4458
return self.min_wait
45-
if self.min_wait:
46-
return max(wait_time, self.min_wait)
59+
if min_wait:
60+
return max(wait_time, min_wait)
4761
elif wait_time < 0:
4862
return None
4963
return wait_time

airbyte-cdk/python/airbyte_cdk/sources/declarative/requesters/error_handlers/default_error_handler.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ class DefaultErrorHandler(ErrorHandler, JsonSchemaMixin):
9494

9595
config: Config
9696
options: InitVar[Mapping[str, Any]]
97+
config: Config
9798
response_filters: Optional[List[HttpResponseFilter]] = None
9899
max_retries: Optional[int] = 5
99100
_max_retries: int = field(init=False, repr=False, default=5)
@@ -111,7 +112,7 @@ def __post_init__(self, options: Mapping[str, Any]):
111112
self.response_filters.append(HttpResponseFilter(ResponseAction.IGNORE, config={}, options={}))
112113

113114
if not self.backoff_strategies:
114-
self.backoff_strategies = [DefaultErrorHandler.DEFAULT_BACKOFF_STRATEGY()]
115+
self.backoff_strategies = [DefaultErrorHandler.DEFAULT_BACKOFF_STRATEGY(options=options, config=self.config)]
115116

116117
self._last_request_to_attempt_count: MutableMapping[requests.PreparedRequest, int] = {}
117118

airbyte-cdk/python/unit_tests/sources/declarative/requesters/error_handlers/backoff_strategies/test_constant_backoff.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,27 @@
88
from airbyte_cdk.sources.declarative.requesters.error_handlers.backoff_strategies.constant_backoff_strategy import ConstantBackoffStrategy
99

1010
BACKOFF_TIME = 10
11+
OPTIONS_BACKOFF_TIME = 20
12+
CONFIG_BACKOFF_TIME = 30
1113

1214

1315
@pytest.mark.parametrize(
14-
"test_name, attempt_count, expected_backoff_time",
16+
"test_name, attempt_count, backofftime, expected_backoff_time",
1517
[
16-
("test_exponential_backoff", 1, BACKOFF_TIME),
17-
("test_exponential_backoff", 2, BACKOFF_TIME),
18+
("test_constant_backoff_first_attempt", 1, BACKOFF_TIME, BACKOFF_TIME),
19+
("test_constant_backoff_first_attempt_float", 1, 6.7, 6.7),
20+
("test_constant_backoff_attempt_round_float", 1.0, 6.7, 6.7),
21+
("test_constant_backoff_attempt_round_float", 1.5, 6.7, 6.7),
22+
("test_constant_backoff_first_attempt_round_float", 1, 10.0, BACKOFF_TIME),
23+
("test_constant_backoff_second_attempt_round_float", 2, 10.0, BACKOFF_TIME),
24+
("test_constant_backoff_from_options", 1, "{{ options['backoff'] }}", OPTIONS_BACKOFF_TIME),
25+
("test_constant_backoff_from_config", 1, "{{ config['backoff'] }}", CONFIG_BACKOFF_TIME),
1826
],
1927
)
20-
def test_exponential_backoff(test_name, attempt_count, expected_backoff_time):
28+
def test_constant_backoff(test_name, attempt_count, backofftime, expected_backoff_time):
2129
response_mock = MagicMock()
22-
backoff_strategy = ConstantBackoffStrategy(backoff_time_in_seconds=BACKOFF_TIME)
30+
backoff_strategy = ConstantBackoffStrategy(
31+
options={"backoff": OPTIONS_BACKOFF_TIME}, backoff_time_in_seconds=backofftime, config={"backoff": CONFIG_BACKOFF_TIME}
32+
)
2333
backoff = backoff_strategy.backoff(response_mock, attempt_count)
2434
assert backoff == expected_backoff_time

airbyte-cdk/python/unit_tests/sources/declarative/requesters/error_handlers/backoff_strategies/test_exponential_backoff.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,28 @@
99
ExponentialBackoffStrategy,
1010
)
1111

12+
options = {"backoff": 5}
13+
config = {"backoff": 5}
14+
1215

1316
@pytest.mark.parametrize(
14-
"test_name, attempt_count, expected_backoff_time",
17+
"test_name, attempt_count, factor, expected_backoff_time",
1518
[
16-
("test_exponential_backoff", 1, 10),
17-
("test_exponential_backoff", 2, 20),
19+
("test_exponential_backoff_first_attempt", 1, 5, 10),
20+
("test_exponential_backoff_second_attempt", 2, 5, 20),
21+
("test_exponential_backoff_from_options", 2, "{{options['backoff']}}", 20),
22+
("test_exponential_backoff_from_config", 2, "{{config['backoff']}}", 20),
1823
],
1924
)
20-
def test_exponential_backoff(test_name, attempt_count, expected_backoff_time):
25+
def test_exponential_backoff(test_name, attempt_count, factor, expected_backoff_time):
2126
response_mock = MagicMock()
22-
backoff_strategy = ExponentialBackoffStrategy(factor=5)
27+
backoff_strategy = ExponentialBackoffStrategy(factor=factor, options=options, config=config)
2328
backoff = backoff_strategy.backoff(response_mock, attempt_count)
2429
assert backoff == expected_backoff_time
2530

2631

2732
def test_exponential_backoff_default():
2833
response_mock = MagicMock()
29-
backoff_strategy = ExponentialBackoffStrategy()
34+
backoff_strategy = ExponentialBackoffStrategy(options=options, config=config)
3035
backoff = backoff_strategy.backoff(response_mock, 3)
3136
assert backoff == 40

airbyte-cdk/python/unit_tests/sources/declarative/requesters/error_handlers/backoff_strategies/test_wait_time_from_header.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
[
1818
("test_wait_time_from_header", "wait_time", SOME_BACKOFF_TIME, None, SOME_BACKOFF_TIME),
1919
("test_wait_time_from_header_string", "wait_time", "60", None, SOME_BACKOFF_TIME),
20+
("test_wait_time_from_header_options", "{{ options['wait_time'] }}", "60", None, SOME_BACKOFF_TIME),
21+
("test_wait_time_from_header_config", "{{ config['wait_time'] }}", "60", None, SOME_BACKOFF_TIME),
2022
("test_wait_time_from_header_not_a_number", "wait_time", "61,60", None, None),
2123
("test_wait_time_from_header_with_regex", "wait_time", "61,60", "([-+]?\d+)", 61), # noqa
2224
("test_wait_time_fœrom_header_with_regex_no_match", "wait_time", "...", "[-+]?\d+", None), # noqa
@@ -26,6 +28,8 @@
2628
def test_wait_time_from_header(test_name, header, header_value, regex, expected_backoff_time):
2729
response_mock = MagicMock()
2830
response_mock.headers = {"wait_time": header_value}
29-
backoff_stratery = WaitTimeFromHeaderBackoffStrategy(header, regex)
31+
backoff_stratery = WaitTimeFromHeaderBackoffStrategy(
32+
header=header, regex=regex, options={"wait_time": "wait_time"}, config={"wait_time": "wait_time"}
33+
)
3034
backoff = backoff_stratery.backoff(response_mock, 1)
3135
assert backoff == expected_backoff_time

airbyte-cdk/python/unit_tests/sources/declarative/requesters/error_handlers/backoff_strategies/test_wait_until_time_from_header.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,26 +11,54 @@
1111
)
1212

1313
SOME_BACKOFF_TIME = 60
14+
REGEX = "[-+]?\\d+"
1415

1516

1617
@pytest.mark.parametrize(
1718
"test_name, header, wait_until, min_wait, regex, expected_backoff_time",
1819
[
1920
("test_wait_until_time_from_header", "wait_until", 1600000060.0, None, None, 60),
21+
("test_wait_until_time_from_header_options", "{{options['wait_until']}}", 1600000060.0, None, None, 60),
22+
("test_wait_until_time_from_header_config", "{{config['wait_until']}}", 1600000060.0, None, None, 60),
2023
("test_wait_until_negative_time", "wait_until", 1500000000.0, None, None, None),
2124
("test_wait_until_time_less_than_min", "wait_until", 1600000060.0, 120, None, 120),
2225
("test_wait_until_no_header", "absent_header", 1600000000.0, None, None, None),
2326
("test_wait_until_time_from_header_not_numeric", "wait_until", "1600000000,1600000000", None, None, None),
2427
("test_wait_until_time_from_header_is_numeric", "wait_until", "1600000060", None, None, 60),
2528
("test_wait_until_time_from_header_with_regex", "wait_until", "1600000060,60", None, "[-+]?\d+", 60), # noqa
29+
("test_wait_until_time_from_header_with_regex_from_options", "wait_until", "1600000060,60", None, "{{options['regex']}}", 60),
30+
# noqa
31+
("test_wait_until_time_from_header_with_regex_from_config", "wait_until", "1600000060,60", None, "{{config['regex']}}", 60), # noqa
2632
("test_wait_until_time_from_header_with_regex_no_match", "wait_time", "...", None, "[-+]?\d+", None), # noqa
2733
("test_wait_until_no_header_with_min", "absent_header", "1600000000.0", SOME_BACKOFF_TIME, None, SOME_BACKOFF_TIME),
34+
(
35+
"test_wait_until_no_header_with_min_from_options",
36+
"absent_header",
37+
"1600000000.0",
38+
"{{options['min_wait']}}",
39+
None,
40+
SOME_BACKOFF_TIME,
41+
),
42+
(
43+
"test_wait_until_no_header_with_min_from_config",
44+
"absent_header",
45+
"1600000000.0",
46+
"{{config['min_wait']}}",
47+
None,
48+
SOME_BACKOFF_TIME,
49+
),
2850
],
2951
)
3052
@patch("time.time", return_value=1600000000.0)
3153
def test_wait_untiltime_from_header(time_mock, test_name, header, wait_until, min_wait, regex, expected_backoff_time):
3254
response_mock = MagicMock()
3355
response_mock.headers = {"wait_until": wait_until}
34-
backoff_stratery = WaitUntilTimeFromHeaderBackoffStrategy(header, min_wait, regex)
56+
backoff_stratery = WaitUntilTimeFromHeaderBackoffStrategy(
57+
header=header,
58+
min_wait=min_wait,
59+
regex=regex,
60+
options={"wait_until": "wait_until", "regex": REGEX, "min_wait": SOME_BACKOFF_TIME},
61+
config={"wait_until": "wait_until", "regex": REGEX, "min_wait": SOME_BACKOFF_TIME},
62+
)
3563
backoff = backoff_stratery.backoff(response_mock, 1)
3664
assert backoff == expected_backoff_time

airbyte-cdk/python/unit_tests/sources/declarative/requesters/error_handlers/test_default_error_handler.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
None,
3131
{},
3232
ResponseStatus.retry(SOME_BACKOFF_TIME),
33-
[ConstantBackoffStrategy(SOME_BACKOFF_TIME)],
33+
[ConstantBackoffStrategy(options={}, backoff_time_in_seconds=SOME_BACKOFF_TIME, config={})],
3434
),
3535
("test_exponential_backoff", HTTPStatus.BAD_GATEWAY, None, None, {}, ResponseStatus.retry(10), None),
3636
(
@@ -40,7 +40,7 @@
4040
None,
4141
{},
4242
ResponseStatus.retry(10),
43-
[DefaultErrorHandler.DEFAULT_BACKOFF_STRATEGY()],
43+
[DefaultErrorHandler.DEFAULT_BACKOFF_STRATEGY(options={}, config={})],
4444
),
4545
("test_chain_backoff_strategy", HTTPStatus.BAD_GATEWAY, None, None, {}, ResponseStatus.retry(10), None),
4646
(
@@ -50,7 +50,10 @@
5050
None,
5151
{},
5252
ResponseStatus.retry(10),
53-
[DefaultErrorHandler.DEFAULT_BACKOFF_STRATEGY(), ConstantBackoffStrategy(SOME_BACKOFF_TIME)],
53+
[
54+
DefaultErrorHandler.DEFAULT_BACKOFF_STRATEGY(options={}, config={}),
55+
ConstantBackoffStrategy(options={}, backoff_time_in_seconds=SOME_BACKOFF_TIME, config={}),
56+
],
5457
),
5558
("test_200", HTTPStatus.OK, None, None, {}, response_status.SUCCESS, None),
5659
("test_3XX", HTTPStatus.PERMANENT_REDIRECT, None, None, {}, response_status.SUCCESS, None),

0 commit comments

Comments
 (0)