Skip to content

Commit 167ab66

Browse files
authored
[Core] Improved http_client transport layer to allow customisation of retry (#1998)
### **User description** # Description What - Rearchitected the HTTP retry system in port_ocean/helpers/retry.py to make it extensible for custom retry configurations per integration, while maintaining backward compatibility. Why - The existing retry mechanism was hardcoded and not easily customizable for different integrations like GitHub that need specific retry headers (e.g., X-RateLimit-Reset) and additional status codes. How - Added RetryConfig class to encapsulate all retry parameters and implemented register_retry_config_callback() for global configuration override. Updated RetryTransport to use the new configuration system with priority: direct config > callback > legacy params. Added comprehensive unit tests. ## Type of change Please leave one option from the following and delete the rest: - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] New Integration (non-breaking change which adds a new integration) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [x] Non-breaking change (fix of existing functionality that will not change current behavior) - [ ] Documentation (added/updated documentation) <h4> All tests should be run against the port production environment(using a testing org). </h4> ### Core testing checklist - [ ] Integration able to create all default resources from scratch - [ ] Resync finishes successfully - [ ] Resync able to create entities - [ ] Resync able to update entities - [ ] Resync able to detect and delete entities - [ ] Scheduled resync able to abort existing resync and start a new one - [ ] Tested with at least 2 integrations from scratch - [ ] Tested with Kafka and Polling event listeners - [ ] Tested deletion of entities that don't pass the selector ### Integration testing checklist - [ ] Integration able to create all default resources from scratch - [ ] Resync able to create entities - [ ] Resync able to update entities - [ ] Resync able to detect and delete entities - [ ] Resync finishes successfully - [ ] If new resource kind is added or updated in the integration, add example raw data, mapping and expected result to the `examples` folder in the integration directory. - [ ] If resource kind is updated, run the integration with the example data and check if the expected result is achieved - [ ] If new resource kind is added or updated, validate that live-events for that resource are working as expected - [ ] Docs PR link [here](#) ### Preflight checklist - [ ] Handled rate limiting - [ ] Handled pagination - [ ] Implemented the code in async - [ ] Support Multi account ## Screenshots Include screenshots from your environment showing how the resources of the integration will look. ## API Documentation Provide links to the API documentation used for this integration. ___ ### **PR Type** Enhancement ___ ### **Description** - Refactored HTTP retry system to support custom configurations - Added RetryConfig class for extensible retry parameters - Implemented global callback registration for integration-specific configs - Maintained backward compatibility with legacy parameters ___ ### Diagram Walkthrough ```mermaid flowchart LR A["RetryConfig Class"] --> B["RetryTransport"] C["Global Callback"] --> B D["Legacy Parameters"] --> B B --> E["Custom Retry Logic"] E --> F["Integration Support"] ``` <details> <summary><h3> File Walkthrough</h3></summary> <table><thead><tr><th></th><th align="left">Relevant files</th></tr></thead><tbody><tr><td><strong>Enhancement</strong></td><td><table> <tr> <td> <details> <summary><strong>retry.py</strong><dd><code>Core retry system refactoring with extensible configuration</code></dd></summary> <hr> port_ocean/helpers/retry.py <ul><li>Added RetryConfig class with comprehensive retry parameters<br> <li> Implemented register_retry_config_callback() for global configuration<br> <li> Refactored RetryTransport to use new configuration system with <br>priority handling<br> <li> Added support for custom retry headers and additional status codes</ul> </details> </td> <td><a href="https://github.com/port-labs/ocean/pull/1998/files#diff-a02bcfe3ab769cf2d3d6ef3f313947e35886fbe56e5c55f7a9799043014aff1a">+154/-72</a></td> </tr> <tr> <td> <details> <summary><strong>async_client.py</strong><dd><code>Async client integration with new retry config</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary> <hr> port_ocean/helpers/async_client.py <ul><li>Added retry_config parameter to OceanAsyncClient constructor<br> <li> Updated transport initialization to support new RetryConfig<br> <li> Maintained backward compatibility with transport_kwargs</ul> </details> </td> <td><a href="https://github.com/port-labs/ocean/pull/1998/files#diff-3c9a9d831715dc18b79b9a6441706c5b183cd6603a1aae59d93a9d7930068724">+27/-16</a>&nbsp; </td> </tr> </table></td></tr><tr><td><strong>Tests</strong></td><td><table> <tr> <td> <details> <summary><strong>test_retry.py</strong><dd><code>Complete test suite for retry configuration system</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary> <hr> port_ocean/tests/helpers/test_retry.py <ul><li>Added comprehensive unit tests for RetryConfig class<br> <li> Implemented tests for callback registration and priority handling<br> <li> Added integration-style configuration tests<br> <li> Covered edge cases and error scenarios</ul> </details> </td> <td><a href="https://github.com/port-labs/ocean/pull/1998/files#diff-c42b356bbe06340decb55f8cc403664e93588d7f3df5abbec46380c4d2c81703">+308/-0</a>&nbsp; </td> </tr> </table></td></tr><tr><td><strong>Documentation</strong></td><td><table> <tr> <td> <details> <summary><strong>CHANGELOG.md</strong><dd><code>Version 0.27.7 changelog entry</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary> <hr> CHANGELOG.md - Added entry for version 0.27.7 with retry transport improvements </details> </td> <td><a href="https://github.com/port-labs/ocean/pull/1998/files#diff-06572a96a58dc510037d5efa622f9bec8519bc1beab13c9f251e97e657a9d4ed">+6/-0</a>&nbsp; &nbsp; &nbsp; </td> </tr> </table></td></tr><tr><td><strong>Configuration changes</strong></td><td><table> <tr> <td> <details> <summary><strong>pyproject.toml</strong><dd><code>Version bump and dependency cleanup</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary> <hr> pyproject.toml <ul><li>Updated version from 0.27.6 to 0.27.7<br> <li> Removed pre-commit dependency from dev dependencies</ul> </details> </td> <td><a href="https://github.com/port-labs/ocean/pull/1998/files#diff-50c86b7ed8ac2cf95bd48334961bf0530cdc77b5a56f852c5c61b89d735fd711">+1/-2</a>&nbsp; &nbsp; &nbsp; </td> </tr> </table></td></tr></tr></tbody></table> </details> ___
1 parent 976b032 commit 167ab66

File tree

5 files changed

+488
-82
lines changed

5 files changed

+488
-82
lines changed

CHANGELOG.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
<!-- towncrier release notes start -->
9+
10+
## 0.28.0 (2025-08-19)
11+
12+
### Improvements
13+
14+
- Made HTTP retry config extensible with new RetryConfig class and callback to apply per-integration policies without code changes.
15+
- Added rate-limit aware retries through configurable retry-after headers (e.g., X-RateLimit-Reset) and additional retry status codes that extend safe defaults.
16+
- Control max_attempts/base_delay/jitter_ratio/max_backoff_wait and override retryable_methods via callback.
17+
918
## 0.27.10 (2025-08-24)
1019

1120
### Improvements
@@ -20,7 +29,6 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
2029
- Removed premature cleanup of Prometheus metrics after subprocess finish to fix reconciliation stuck on pending
2130
- Enhanced sync state tracking across different phases
2231

23-
2432
## 0.27.8 (2025-08-18)
2533

2634
### Improvements

port_ocean/helpers/async_client.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import httpx
44
from loguru import logger
55

6-
from port_ocean.helpers.retry import RetryTransport
6+
from port_ocean.helpers.retry import RetryTransport, RetryConfig
77
from port_ocean.helpers.stream import Stream
88

99

@@ -18,10 +18,12 @@ def __init__(
1818
self,
1919
transport_class: Type[RetryTransport] = RetryTransport,
2020
transport_kwargs: dict[str, Any] | None = None,
21+
retry_config: RetryConfig | None = None,
2122
**kwargs: Any,
2223
):
2324
self._transport_kwargs = transport_kwargs
2425
self._transport_class = transport_class
26+
self._retry_config = retry_config
2527
super().__init__(**kwargs)
2628

2729
def _init_transport( # type: ignore[override]
@@ -33,9 +35,8 @@ def _init_transport( # type: ignore[override]
3335
return super()._init_transport(transport=transport, **kwargs)
3436

3537
return self._transport_class(
36-
wrapped_transport=httpx.AsyncHTTPTransport(
37-
**kwargs,
38-
),
38+
wrapped_transport=httpx.AsyncHTTPTransport(**kwargs),
39+
retry_config=self._retry_config,
3940
logger=logger,
4041
**(self._transport_kwargs or {}),
4142
)
@@ -44,10 +45,8 @@ def _init_proxy_transport( # type: ignore[override]
4445
self, proxy: httpx.Proxy, **kwargs: Any
4546
) -> httpx.AsyncBaseTransport:
4647
return self._transport_class(
47-
wrapped_transport=httpx.AsyncHTTPTransport(
48-
proxy=proxy,
49-
**kwargs,
50-
),
48+
wrapped_transport=httpx.AsyncHTTPTransport(proxy=proxy, **kwargs),
49+
retry_config=self._retry_config,
5150
logger=logger,
5251
**(self._transport_kwargs or {}),
5352
)

port_ocean/helpers/retry.py

Lines changed: 162 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,24 @@
44
from datetime import datetime
55
from functools import partial
66
from http import HTTPStatus
7-
from typing import Any, Callable, Coroutine, Iterable, Mapping, Union, cast
7+
from typing import (
8+
Any,
9+
Callable,
10+
Coroutine,
11+
Iterable,
12+
Mapping,
13+
Union,
14+
cast,
15+
Optional,
16+
List,
17+
)
818
import httpx
919
from dateutil.parser import isoparse
1020
import logging
1121

22+
MAX_BACKOFF_WAIT_IN_SECONDS = 60
1223
_ON_RETRY_CALLBACK: Callable[[httpx.Request], httpx.Request] | None = None
24+
_RETRY_CONFIG_CALLBACK: Callable[[], "RetryConfig"] | None = None
1325

1426

1527
def register_on_retry_callback(
@@ -19,6 +31,92 @@ def register_on_retry_callback(
1931
_ON_RETRY_CALLBACK = _on_retry_callback
2032

2133

34+
def register_retry_config_callback(
35+
retry_config_callback: Callable[[], "RetryConfig"]
36+
) -> None:
37+
"""Register a callback function that returns a RetryConfig instance.
38+
39+
The callback will be called when a RetryTransport needs to be created.
40+
41+
Args:
42+
retry_config_callback: A function that returns a RetryConfig instance
43+
"""
44+
global _RETRY_CONFIG_CALLBACK
45+
_RETRY_CONFIG_CALLBACK = retry_config_callback
46+
47+
48+
class RetryConfig:
49+
"""Configuration class for retry behavior that can be customized per integration."""
50+
51+
def __init__(
52+
self,
53+
max_attempts: int = 10,
54+
max_backoff_wait: float = MAX_BACKOFF_WAIT_IN_SECONDS,
55+
base_delay: float = 0.1,
56+
jitter_ratio: float = 0.1,
57+
respect_retry_after_header: bool = True,
58+
retryable_methods: Optional[Iterable[str]] = None,
59+
retry_status_codes: Optional[Iterable[int]] = None,
60+
retry_after_headers: Optional[List[str]] = None,
61+
additional_retry_status_codes: Optional[Iterable[int]] = None,
62+
):
63+
"""
64+
Initialize retry configuration.
65+
66+
Args:
67+
max_attempts: Maximum number of retry attempts
68+
max_backoff_wait: Maximum backoff wait time in seconds
69+
base_delay: Base delay for exponential backoff
70+
jitter_ratio: Jitter ratio for backoff (0-0.5)
71+
respect_retry_after_header: Whether to respect Retry-After header
72+
retryable_methods: HTTP methods that can be retried (overrides defaults if provided)
73+
retry_status_codes: DEPRECATED - use additional_retry_status_codes instead
74+
retry_after_headers: Custom headers to check for retry timing (e.g., ['X-RateLimit-Reset', 'Retry-After'])
75+
additional_retry_status_codes: Additional status codes to retry (extends system defaults)
76+
"""
77+
self.max_attempts = max_attempts
78+
self.max_backoff_wait = max_backoff_wait
79+
self.base_delay = base_delay
80+
self.jitter_ratio = jitter_ratio
81+
self.respect_retry_after_header = respect_retry_after_header
82+
83+
# Default retryable methods - always include these unless explicitly overridden
84+
default_methods = frozenset(
85+
["HEAD", "GET", "PUT", "DELETE", "OPTIONS", "TRACE"]
86+
)
87+
self.retryable_methods = (
88+
frozenset(retryable_methods) if retryable_methods else default_methods
89+
)
90+
91+
# Default retry status codes - always include these for system reliability
92+
default_status_codes = frozenset(
93+
[
94+
HTTPStatus.TOO_MANY_REQUESTS,
95+
HTTPStatus.BAD_GATEWAY,
96+
HTTPStatus.SERVICE_UNAVAILABLE,
97+
HTTPStatus.GATEWAY_TIMEOUT,
98+
HTTPStatus.UNAUTHORIZED,
99+
HTTPStatus.BAD_REQUEST,
100+
]
101+
)
102+
103+
# Additional status codes to retry (extends defaults)
104+
additional_codes = (
105+
frozenset(additional_retry_status_codes)
106+
if additional_retry_status_codes
107+
else frozenset()
108+
)
109+
110+
# Combine defaults with additional codes for extensibility
111+
self.retry_status_codes = default_status_codes | additional_codes
112+
self.retry_after_headers = retry_after_headers or ["Retry-After"]
113+
114+
if jitter_ratio < 0 or jitter_ratio > 0.5:
115+
raise ValueError(
116+
f"Jitter ratio should be between 0 and 0.5, actual {jitter_ratio}"
117+
)
118+
119+
22120
# Adapted from https://github.com/encode/httpx/issues/108#issuecomment-1434439481
23121
class RetryTransport(httpx.AsyncBaseTransport, httpx.BaseTransport):
24122
"""
@@ -41,32 +139,16 @@ class RetryTransport(httpx.AsyncBaseTransport, httpx.BaseTransport):
41139
["HEAD", "GET", "PUT", "DELETE", "OPTIONS", "TRACE"].
42140
retry_status_codes (Iterable[int], optional): The HTTP status codes that can be retried. Defaults to
43141
[429, 502, 503, 504].
142+
retry_config (RetryConfig, optional): Configuration for retry behavior. If not provided, uses defaults.
143+
logger (Any, optional): The logger to use for logging retries.
44144
45145
Attributes:
46146
_wrapped_transport (Union[httpx.BaseTransport, httpx.AsyncBaseTransport]): The underlying HTTP transport
47147
being wrapped.
48-
_max_attempts (int): The maximum number of times to retry a request.
49-
_backoff_factor (float): The factor by which the wait time increases with each retry attempt.
50-
_respect_retry_after_header (bool): Whether to respect the Retry-After header in HTTP responses.
51-
_retryable_methods (frozenset): The HTTP methods that can be retried.
52-
_retry_status_codes (frozenset): The HTTP status codes that can be retried.
53-
_jitter_ratio (float): The amount of jitter to add to the backoff time.
54-
_max_backoff_wait (float): The maximum time to wait between retries in seconds.
148+
_retry_config (RetryConfig): The retry configuration object.
149+
_logger (Any): The logger to use for logging retries.
55150
"""
56151

57-
RETRYABLE_METHODS = frozenset(["HEAD", "GET", "PUT", "DELETE", "OPTIONS", "TRACE"])
58-
RETRYABLE_STATUS_CODES = frozenset(
59-
[
60-
HTTPStatus.TOO_MANY_REQUESTS,
61-
HTTPStatus.BAD_GATEWAY,
62-
HTTPStatus.SERVICE_UNAVAILABLE,
63-
HTTPStatus.GATEWAY_TIMEOUT,
64-
HTTPStatus.UNAUTHORIZED,
65-
HTTPStatus.BAD_REQUEST,
66-
]
67-
)
68-
MAX_BACKOFF_WAIT_IN_SECONDS = 60
69-
70152
def __init__(
71153
self,
72154
wrapped_transport: Union[httpx.BaseTransport, httpx.AsyncBaseTransport],
@@ -77,6 +159,7 @@ def __init__(
77159
respect_retry_after_header: bool = True,
78160
retryable_methods: Iterable[str] | None = None,
79161
retry_status_codes: Iterable[int] | None = None,
162+
retry_config: Optional[RetryConfig] = None,
80163
logger: Any | None = None,
81164
) -> None:
82165
"""
@@ -106,29 +189,27 @@ def __init__(
106189
retry_status_codes (Iterable[int], optional):
107190
The HTTP status codes that can be retried.
108191
Defaults to [429, 502, 503, 504].
192+
retry_config (RetryConfig, optional):
193+
Configuration for retry behavior. If not provided, uses default configuration.
109194
logger (Any): The logger to use for logging retries.
110195
"""
111196
self._wrapped_transport = wrapped_transport
112-
if jitter_ratio < 0 or jitter_ratio > 0.5:
113-
raise ValueError(
114-
f"Jitter ratio should be between 0 and 0.5, actual {jitter_ratio}"
197+
198+
if retry_config is not None:
199+
self._retry_config = retry_config
200+
elif _RETRY_CONFIG_CALLBACK is not None:
201+
self._retry_config = _RETRY_CONFIG_CALLBACK()
202+
else:
203+
self._retry_config = RetryConfig(
204+
max_attempts=max_attempts,
205+
max_backoff_wait=max_backoff_wait,
206+
base_delay=base_delay,
207+
jitter_ratio=jitter_ratio,
208+
respect_retry_after_header=respect_retry_after_header,
209+
retryable_methods=retryable_methods,
210+
retry_status_codes=retry_status_codes,
115211
)
116212

117-
self._max_attempts = max_attempts
118-
self._base_delay = base_delay
119-
self._respect_retry_after_header = respect_retry_after_header
120-
self._retryable_methods = (
121-
frozenset(retryable_methods)
122-
if retryable_methods
123-
else self.RETRYABLE_METHODS
124-
)
125-
self._retry_status_codes = (
126-
frozenset(retry_status_codes)
127-
if retry_status_codes
128-
else self.RETRYABLE_STATUS_CODES
129-
)
130-
self._jitter_ratio = jitter_ratio
131-
self._max_backoff_wait = max_backoff_wait
132213
self._logger = logger
133214

134215
def handle_request(self, request: httpx.Request) -> httpx.Response:
@@ -206,12 +287,13 @@ def close(self) -> None:
206287
transport.close()
207288

208289
def _is_retryable_method(self, request: httpx.Request) -> bool:
209-
return request.method in self._retryable_methods or request.extensions.get(
210-
"retryable", False
290+
return (
291+
request.method in self._retry_config.retryable_methods
292+
or request.extensions.get("retryable", False)
211293
)
212294

213295
def _should_retry(self, response: httpx.Response) -> bool:
214-
return response.status_code in self._retry_status_codes
296+
return response.status_code in self._retry_config.retry_status_codes
215297

216298
def _log_error(
217299
self,
@@ -314,46 +396,54 @@ def _log_response_size(
314396
)
315397

316398
async def _should_retry_async(self, response: httpx.Response) -> bool:
317-
return response.status_code in self._retry_status_codes
399+
return response.status_code in self._retry_config.retry_status_codes
318400

319401
def _calculate_sleep(
320402
self, attempts_made: int, headers: Union[httpx.Headers, Mapping[str, str]]
321403
) -> float:
322-
# Retry-After
323-
# The Retry-After response HTTP header indicates how long the user agent should wait before
324-
# making a follow-up request. There are three main cases this header is used:
325-
# - When sent with a 503 (Service Unavailable) response, this indicates how long the service
326-
# is expected to be unavailable.
327-
# - When sent with a 429 (Too Many Requests) response, this indicates how long to wait before
328-
# making a new request.
329-
# - When sent with a redirect response, such as 301 (Moved Permanently), this indicates the
330-
# minimum time that the user agent is asked to wait before issuing the redirected request.
331-
retry_after_header = (headers.get("Retry-After") or "").strip()
332-
if self._respect_retry_after_header and retry_after_header:
333-
if retry_after_header.isdigit():
334-
return float(retry_after_header)
335-
336-
try:
337-
parsed_date = isoparse(
338-
retry_after_header
339-
).astimezone() # converts to local time
340-
diff = (parsed_date - datetime.now().astimezone()).total_seconds()
341-
if diff > 0:
342-
return min(diff, self._max_backoff_wait)
343-
except ValueError:
344-
pass
345-
346-
backoff = self._base_delay * (2 ** (attempts_made - 1))
347-
jitter = (backoff * self._jitter_ratio) * random.choice([1, -1])
404+
# Check custom retry headers first, then fall back to Retry-After
405+
if self._retry_config.respect_retry_after_header:
406+
for header_name in self._retry_config.retry_after_headers:
407+
if header_value := (headers.get(header_name) or "").strip():
408+
sleep_time = self._parse_retry_header(header_value)
409+
if sleep_time is not None:
410+
return min(sleep_time, self._retry_config.max_backoff_wait)
411+
412+
# Fall back to exponential backoff
413+
backoff = self._retry_config.base_delay * (2 ** (attempts_made - 1))
414+
jitter = (backoff * self._retry_config.jitter_ratio) * random.choice([1, -1])
348415
total_backoff = backoff + jitter
349-
return min(total_backoff, self._max_backoff_wait)
416+
return min(total_backoff, self._retry_config.max_backoff_wait)
417+
418+
def _parse_retry_header(self, header_value: str) -> Optional[float]:
419+
"""Parse retry header value and return sleep time in seconds.
420+
421+
Args:
422+
header_value: The header value to parse (e.g., "30", "2023-12-01T12:00:00Z")
423+
424+
Returns:
425+
Sleep time in seconds if parsing succeeds, None if the header value cannot be parsed
426+
"""
427+
if header_value.isdigit():
428+
return float(header_value)
429+
430+
try:
431+
# Try to parse as ISO date (common for rate limit headers like X-RateLimit-Reset)
432+
parsed_date = isoparse(header_value).astimezone()
433+
diff = (parsed_date - datetime.now().astimezone()).total_seconds()
434+
if diff > 0:
435+
return diff
436+
except ValueError:
437+
pass
438+
439+
return None
350440

351441
async def _retry_operation_async(
352442
self,
353443
request: httpx.Request,
354444
send_method: Callable[..., Coroutine[Any, Any, httpx.Response]],
355445
) -> httpx.Response:
356-
remaining_attempts = self._max_attempts
446+
remaining_attempts = self._retry_config.max_attempts
357447
attempts_made = 0
358448
response: httpx.Response | None = None
359449
error: Exception | None = None
@@ -403,7 +493,7 @@ def _retry_operation(
403493
request: httpx.Request,
404494
send_method: Callable[..., httpx.Response],
405495
) -> httpx.Response:
406-
remaining_attempts = self._max_attempts
496+
remaining_attempts = self._retry_config.max_attempts
407497
attempts_made = 0
408498
response: httpx.Response | None = None
409499
error: Exception | None = None

0 commit comments

Comments
 (0)