diff --git a/README.md b/README.md index bb41fc9..1044a60 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,14 @@ aimed to provide HTTP api testing functionalities by wrapping the well known [Py It is based on [robotframework-requests Library](https://github.com/MarketSquare/robotframework-requests) but uses *httpx* library with HTTP/2 support instead of *requests* library. +## Key Features + +- **HTTP/2 Support**: Native HTTP/2 support through httpx +- **Enhanced Retry Mechanisms**: Comprehensive retry functionality with exponential backoff +- **Session Management**: Full session support with custom configurations +- **Modern Architecture**: Built on the modern httpx library +- **Backward Compatibility**: Drop-in replacement for robotframework-requests + ## Install stable version ```sh pip install robotframework-httpx @@ -12,7 +20,7 @@ pip install robotframework-httpx ```robotframework *** Settings *** Library Collections -Library RequestsLibrary +Library HttpxLibrary Suite Setup Create Session jsonplaceholder https://jsonplaceholder.typicode.com @@ -33,8 +41,37 @@ Post Request Test Status Should Be 201 ${resp} Dictionary Should Contain Key ${resp.json()} id + +Retry Request Test + # Configure retry behavior + Set Global Retry Configuration max_retries=5 backoff_factor=0.5 + + # Make request with retry on failure + ${resp}= GET On Session With Retry jsonplaceholder /posts/1 max_retries=3 + Status Should Be 200 ${resp} ``` +## 🔄 Enhanced Retry Mechanisms + +HttpxLibrary provides comprehensive retry functionality to handle transient failures: + +```robotframework +# Configure global retry settings +Set Global Retry Configuration max_retries=5 backoff_factor=0.5 retry_on_status=500,502,503 + +# Session-specific retry configuration +Set Session Retry Configuration my_session max_retries=10 backoff_factor=1.0 + +# HTTP methods with retry support +${response}= GET On Session With Retry my_session /api/data max_retries=3 +${response}= POST On Session With Retry my_session /api/submit json=${data} + +# Wait until service is ready +${response}= Wait Until Request Succeeds my_session GET /health timeout=60 +``` + +See [RETRY_FEATURES.md](RETRY_FEATURES.md) for detailed documentation. + ### 📖 Keywords documentation robotframework-httpx offers a wide set of keywords which can be found in the Keywords documentation diff --git a/RETRY_FEATURES.md b/RETRY_FEATURES.md new file mode 100644 index 0000000..3538e31 --- /dev/null +++ b/RETRY_FEATURES.md @@ -0,0 +1,248 @@ +# Enhanced Retry Mechanisms + +HttpxLibrary provides comprehensive retry mechanisms to handle transient failures and improve the reliability of HTTP requests. This feature addresses the lack of built-in retry functionality in the httpx library. + +## Features + +### 1. Global Retry Configuration +Set default retry behavior for all HTTP requests: + +```robotframework +Set Global Retry Configuration max_retries=5 backoff_factor=0.5 retry_on_status=500,502,503,504,429 +``` + +### 2. Session-Specific Retry Configuration +Override global settings for specific sessions: + +```robotframework +Create Session api_session https://api.example.com +Set Session Retry Configuration api_session max_retries=10 backoff_factor=1.0 +``` + +### 3. Request-Level Retry Parameters +Override retry settings for individual requests: + +```robotframework +${response}= GET On Session With Retry api_session /endpoint max_retries=3 retry_on_status=429,503 +``` + +### 4. Flexible Retry Logic +- **Exponential Backoff**: Configurable backoff factor with optional jitter +- **Status Code Based**: Retry on specific HTTP status codes +- **Exception Based**: Retry on connection errors, timeouts, etc. +- **Maximum Backoff**: Configurable maximum wait time between retries + +## Available Keywords + +### Configuration Keywords + +#### `Set Global Retry Configuration` +Sets the default retry configuration for all HTTP requests. + +**Parameters:** +- `max_retries` (int): Maximum number of retry attempts (default: 3) +- `backoff_factor` (float): Backoff factor for exponential backoff (default: 0.3) +- `backoff_max` (float): Maximum backoff time in seconds (default: 120.0) +- `retry_on_status` (str/list): HTTP status codes to retry on (default: "500,502,503,504,429") +- `jitter` (bool): Whether to add random jitter to backoff times (default: True) + +#### `Set Session Retry Configuration` +Sets retry configuration for a specific session. + +**Parameters:** +- `alias` (str): Session alias name +- Same parameters as `Set Global Retry Configuration` + +#### `Get Retry Configuration` +Returns the current retry configuration. + +**Parameters:** +- `alias` (str, optional): Session alias name. If None, returns global configuration. + +#### `Clear Session Retry Configuration` +Clears session-specific retry configuration, falling back to global settings. + +**Parameters:** +- `alias` (str): Session alias name + +### Request Keywords + +#### `Retry Request On Session` +Performs HTTP request with custom retry logic. + +**Parameters:** +- `alias` (str): Session alias name +- `method` (str): HTTP method (GET, POST, PUT, etc.) +- `url` (str): Request URL +- `max_retries` (int, optional): Override max retries for this request +- `backoff_factor` (float, optional): Override backoff factor for this request +- `retry_on_status` (str/list, optional): Override retry status codes for this request +- `expected_status`: Expected HTTP status code +- `msg`: Custom error message +- `**kwargs`: Additional request parameters + +#### HTTP Method Keywords with Retry +All standard HTTP methods have retry-enabled versions: +- `GET On Session With Retry` +- `POST On Session With Retry` +- `PUT On Session With Retry` +- `PATCH On Session With Retry` +- `DELETE On Session With Retry` + +#### `Wait Until Request Succeeds` +Repeatedly makes HTTP requests until success or timeout. + +**Parameters:** +- `alias` (str): Session alias name +- `method` (str): HTTP method +- `url` (str): Request URL +- `timeout` (float): Maximum time to wait in seconds (default: 60.0) +- `interval` (float): Time between attempts in seconds (default: 1.0) +- `expected_status`: Expected HTTP status code (default: 200) +- `**kwargs`: Additional request parameters + +## Usage Examples + +### Basic Usage + +```robotframework +*** Settings *** +Library HttpxLibrary + +*** Test Cases *** +Basic Retry Example + # Create session + Create Session api https://api.example.com + + # Configure global retry settings + Set Global Retry Configuration max_retries=5 backoff_factor=0.5 + + # Make request with retry + ${response}= GET On Session With Retry api /data + Should Be Equal As Integers ${response.status_code} 200 +``` + +### Advanced Configuration + +```robotframework +*** Test Cases *** +Advanced Retry Configuration + Create Session flaky_api https://flaky-service.com + + # Configure session-specific retry behavior + Set Session Retry Configuration flaky_api + ... max_retries=10 + ... backoff_factor=1.0 + ... retry_on_status=429,500,502,503,504 + ... jitter=True + + # Make request with custom retry parameters + ${response}= POST On Session With Retry flaky_api /submit + ... json=${data} + ... max_retries=3 + ... retry_on_status=429,503 + + Should Be Equal As Integers ${response.status_code} 201 +``` + +### Health Check Pattern + +```robotframework +*** Test Cases *** +Wait For Service To Be Ready + Create Session health https://service.example.com + + # Wait until service is healthy + ${response}= Wait Until Request Succeeds + ... health GET /health + ... timeout=120 interval=5 expected_status=200 + + Log Service is ready! +``` + +### Error Handling + +```robotframework +*** Test Cases *** +Handle Transient Failures + Create Session api https://api.example.com + + # Configure to retry only on server errors + Set Session Retry Configuration api + ... max_retries=3 + ... retry_on_status=500,502,503,504 + + # This will retry on 5xx errors but not on 4xx + Run Keyword And Expect Error * + ... GET On Session With Retry api /not-found expected_status=200 +``` + +## Retry Logic Details + +### Backoff Calculation +The backoff time is calculated using exponential backoff with optional jitter: + +``` +backoff_time = backoff_factor * (2 ** attempt_number) +if jitter: + backoff_time *= (0.5 + random() * 0.5) +backoff_time = min(backoff_time, backoff_max) +``` + +### Default Retry Conditions +By default, requests are retried on: +- **HTTP Status Codes**: 500, 502, 503, 504, 429 +- **Exceptions**: ConnectError, TimeoutException, RequestError + +### Configuration Hierarchy +1. Request-level parameters (highest priority) +2. Session-specific configuration +3. Global configuration (lowest priority) + +## Best Practices + +1. **Set Reasonable Limits**: Don't set max_retries too high to avoid long delays +2. **Use Appropriate Status Codes**: Only retry on transient failures (5xx, 429) +3. **Configure Backoff**: Use exponential backoff to avoid overwhelming servers +4. **Enable Jitter**: Helps prevent thundering herd problems +5. **Set Timeouts**: Always set reasonable timeout values +6. **Monitor Retry Behavior**: Log retry attempts for debugging + +## Integration with Existing Code + +The retry functionality is fully backward compatible. Existing code will continue to work unchanged, and you can gradually adopt retry features where needed. + +```robotframework +# Existing code - no changes needed +${response}= GET On Session api /data + +# Enhanced with retry - drop-in replacement +${response}= GET On Session With Retry api /data +``` + +## Performance Considerations + +- Retry mechanisms add latency in failure scenarios +- Configure appropriate timeouts to prevent excessive delays +- Use session-specific configurations for different service reliability levels +- Monitor retry metrics to optimize configuration + +## Troubleshooting + +### Common Issues + +1. **Too Many Retries**: Reduce max_retries or check service health +2. **Long Delays**: Reduce backoff_factor or backoff_max +3. **No Retries**: Check retry_on_status configuration +4. **Unexpected Retries**: Verify status codes and exception types + +### Debugging + +Enable debug logging to see retry behavior: + +```robotframework +Set Log Level DEBUG +${response}= GET On Session With Retry api /endpoint max_retries=3 +``` + +This will log retry attempts, backoff times, and failure reasons. diff --git a/RETRY_IMPLEMENTATION_SUMMARY.md b/RETRY_IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..a3cb35b --- /dev/null +++ b/RETRY_IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,157 @@ +# Enhanced Retry Mechanisms Implementation Summary + +## Overview +This implementation adds comprehensive retry functionality to robotframework-httpx, addressing the lack of built-in retry mechanisms in the httpx library compared to the requests library. + +## Files Created/Modified + +### New Files +1. **`src/HttpxLibrary/RetryKeywords.py`** - Core retry functionality +2. **`atests/test_retry_mechanisms.robot`** - Comprehensive test suite +3. **`examples/retry_example.robot`** - Usage examples +4. **`RETRY_FEATURES.md`** - Detailed documentation +5. **`utests/test_retry_keywords.py`** - Unit tests + +### Modified Files +1. **`src/HttpxLibrary/SessionKeywords.py`** - Added retry integration +2. **`src/HttpxLibrary/HttpxOnSessionKeywords.py`** - Added retry-enabled HTTP methods +3. **`README.md`** - Updated with retry feature information + +## Key Features Implemented + +### 1. Retry Configuration System +- **Global Configuration**: Default retry behavior for all requests +- **Session-Specific Configuration**: Override global settings per session +- **Request-Level Configuration**: Override settings for individual requests +- **Configuration Hierarchy**: Request > Session > Global + +### 2. Flexible Retry Logic +- **Exponential Backoff**: Configurable backoff factor with optional jitter +- **Status Code Based Retries**: Retry on specific HTTP status codes (default: 500, 502, 503, 504, 429) +- **Exception Based Retries**: Retry on connection errors, timeouts, etc. +- **Maximum Backoff Time**: Configurable ceiling for backoff delays +- **Jitter Support**: Random variation to prevent thundering herd + +### 3. New Keywords Added + +#### Configuration Keywords +- `Set Global Retry Configuration` +- `Set Session Retry Configuration` +- `Get Retry Configuration` +- `Clear Session Retry Configuration` + +#### Request Keywords +- `Retry Request On Session` - Generic retry-enabled request +- `GET On Session With Retry` +- `POST On Session With Retry` +- `PUT On Session With Retry` +- `PATCH On Session With Retry` +- `DELETE On Session With Retry` +- `Wait Until Request Succeeds` - Health check pattern + +### 4. Enhanced Error Handling +- Distinguishes between retryable and non-retryable errors +- Comprehensive logging of retry attempts +- Graceful degradation when max retries exceeded + +## Technical Implementation Details + +### RetryConfig Class +```python +class RetryConfig: + def __init__(self, + max_retries: int = 3, + backoff_factor: float = 0.3, + backoff_max: float = 120.0, + retry_on_status: List[int] = None, + retry_on_exceptions: List[Exception] = None, + jitter: bool = True) +``` + +### Backoff Calculation +```python +backoff_time = backoff_factor * (2 ** attempt_number) +if jitter: + backoff_time *= (0.5 + random() * 0.5) +backoff_time = min(backoff_time, backoff_max) +``` + +### Integration Points +- Inherits from existing `HttpxKeywords` and `SessionKeywords` +- Uses existing session management and caching +- Maintains backward compatibility +- Integrates with existing logging and debugging + +## Usage Examples + +### Basic Usage +```robotframework +# Configure global retry settings +Set Global Retry Configuration max_retries=5 backoff_factor=0.5 + +# Make request with retry +${response}= GET On Session With Retry api /data +``` + +### Advanced Configuration +```robotframework +# Session-specific configuration +Set Session Retry Configuration flaky_api +... max_retries=10 +... backoff_factor=1.0 +... retry_on_status=429,500,502,503,504 + +# Request with custom retry parameters +${response}= POST On Session With Retry flaky_api /submit +... json=${data} +... max_retries=3 +... retry_on_status=429,503 +``` + +### Health Check Pattern +```robotframework +# Wait until service is ready +${response}= Wait Until Request Succeeds +... health GET /health +... timeout=120 interval=5 expected_status=200 +``` + +## Backward Compatibility +- All existing keywords continue to work unchanged +- No breaking changes to existing API +- Retry functionality is opt-in +- Default behavior remains the same + +## Testing Strategy +1. **Unit Tests**: Core retry logic and configuration +2. **Integration Tests**: Full Robot Framework test suite +3. **Example Scripts**: Real-world usage patterns +4. **Error Scenarios**: Failure modes and edge cases + +## Performance Considerations +- Minimal overhead when retry is not needed +- Configurable backoff to prevent server overload +- Jitter to prevent synchronized retry storms +- Maximum backoff limits to prevent excessive delays + +## Future Enhancements +1. **Metrics Collection**: Retry attempt statistics +2. **Circuit Breaker Pattern**: Fail fast after repeated failures +3. **Custom Retry Strategies**: User-defined retry logic +4. **Async Support**: Non-blocking retry mechanisms + +## Benefits Over robotframework-requests +1. **HTTP/2 Support**: Native HTTP/2 with retry functionality +2. **Modern Architecture**: Built on httpx's modern foundation +3. **Enhanced Retry Logic**: More sophisticated than requests-based solutions +4. **Better Error Handling**: Comprehensive exception management +5. **Flexible Configuration**: Multiple levels of configuration + +## Migration Path +Users can gradually adopt retry functionality: +1. Start with global configuration for basic retry behavior +2. Add session-specific configuration for different services +3. Use request-level overrides for specific scenarios +4. Implement health check patterns for service dependencies + +This implementation provides a robust, flexible, and user-friendly retry mechanism that addresses the key limitation of httpx while maintaining full backward compatibility with existing code. diff --git a/atests/test_retry_mechanisms.robot b/atests/test_retry_mechanisms.robot new file mode 100644 index 0000000..342c8c7 --- /dev/null +++ b/atests/test_retry_mechanisms.robot @@ -0,0 +1,151 @@ +*** Settings *** +Library Collections +Library HttpxLibrary +Library OperatingSystem + +Suite Setup Setup Test Environment +Suite Teardown Teardown Test Environment + +*** Variables *** +${BASE_URL} http://httpbin.org +${FLAKY_URL} http://httpbin.org/status/500,200 +${TIMEOUT_URL} http://httpbin.org/delay/2 + +*** Test Cases *** + +Test Global Retry Configuration + [Documentation] Test setting and getting global retry configuration + Set Global Retry Configuration max_retries=5 backoff_factor=0.5 retry_on_status=500,502,503 + ${config}= Get Retry Configuration + Should Be Equal As Integers ${config}[max_retries] 5 + Should Be Equal As Numbers ${config}[backoff_factor] 0.5 + Should Contain ${config}[retry_on_status] 500 + Should Contain ${config}[retry_on_status] 502 + Should Contain ${config}[retry_on_status] 503 + +Test Session Retry Configuration + [Documentation] Test setting session-specific retry configuration + Create Session test_session ${BASE_URL} + Set Session Retry Configuration test_session max_retries=3 backoff_factor=1.0 + ${config}= Get Retry Configuration test_session + Should Be Equal As Integers ${config}[max_retries] 3 + Should Be Equal As Numbers ${config}[backoff_factor] 1.0 + +Test Clear Session Retry Configuration + [Documentation] Test clearing session retry configuration + Create Session clear_session ${BASE_URL} + Set Session Retry Configuration clear_session max_retries=10 + ${config_before}= Get Retry Configuration clear_session + Should Be Equal As Integers ${config_before}[max_retries] 10 + + Clear Session Retry Configuration clear_session + ${config_after}= Get Retry Configuration clear_session + # Should fall back to global configuration + Should Be Equal As Integers ${config_after}[max_retries] 5 + +Test Retry Request On Session Success + [Documentation] Test successful request with retry mechanism + Create Session retry_session ${BASE_URL} + ${response}= Retry Request On Session retry_session GET /get + Should Be Equal As Integers ${response.status_code} 200 + Should Contain ${response.text} "url" + +Test Retry Request On Session With Custom Parameters + [Documentation] Test retry request with custom retry parameters + Create Session custom_session ${BASE_URL} + ${response}= Retry Request On Session custom_session GET /get + ... max_retries=2 backoff_factor=0.1 retry_on_status=404,500 + Should Be Equal As Integers ${response.status_code} 200 + +Test GET On Session With Retry + [Documentation] Test GET request with retry functionality + Create Session get_retry_session ${BASE_URL} + ${response}= GET On Session With Retry get_retry_session /get max_retries=3 + Should Be Equal As Integers ${response.status_code} 200 + Should Contain ${response.text} "url" + +Test POST On Session With Retry + [Documentation] Test POST request with retry functionality + Create Session post_retry_session ${BASE_URL} + &{data}= Create Dictionary key=value test=data + ${response}= POST On Session With Retry post_retry_session /post + ... json=${data} max_retries=2 + Should Be Equal As Integers ${response.status_code} 200 + Should Contain ${response.text} "key" + +Test PUT On Session With Retry + [Documentation] Test PUT request with retry functionality + Create Session put_retry_session ${BASE_URL} + &{data}= Create Dictionary update=true + ${response}= PUT On Session With Retry put_retry_session /put + ... json=${data} max_retries=2 + Should Be Equal As Integers ${response.status_code} 200 + +Test PATCH On Session With Retry + [Documentation] Test PATCH request with retry functionality + Create Session patch_retry_session ${BASE_URL} + &{data}= Create Dictionary patch=true + ${response}= PATCH On Session With Retry patch_retry_session /patch + ... json=${data} max_retries=2 + Should Be Equal As Integers ${response.status_code} 200 + +Test DELETE On Session With Retry + [Documentation] Test DELETE request with retry functionality + Create Session delete_retry_session ${BASE_URL} + ${response}= DELETE On Session With Retry delete_retry_session /delete max_retries=2 + Should Be Equal As Integers ${response.status_code} 200 + +Test Wait Until Request Succeeds + [Documentation] Test waiting until request succeeds + Create Session wait_session ${BASE_URL} + ${response}= Wait Until Request Succeeds wait_session GET /get + ... timeout=30 interval=1 expected_status=200 + Should Be Equal As Integers ${response.status_code} 200 + +Test Wait Until Request Succeeds With Any Status + [Documentation] Test waiting until request succeeds with any status + Create Session wait_any_session ${BASE_URL} + ${response}= Wait Until Request Succeeds wait_any_session GET /status/404 + ... timeout=10 interval=1 expected_status=any + Should Be Equal As Integers ${response.status_code} 404 + +Test Retry With Different Status Codes + [Documentation] Test retry behavior with different HTTP status codes + Create Session status_session ${BASE_URL} + Set Session Retry Configuration status_session max_retries=1 retry_on_status=404 + + # This should not retry (200 is not in retry_on_status) + ${response}= Retry Request On Session status_session GET /status/200 + Should Be Equal As Integers ${response.status_code} 200 + + # This should retry once but still fail (404 is in retry_on_status) + Run Keyword And Expect Error * + ... Retry Request On Session status_session GET /status/404 expected_status=200 + +Test Retry Configuration Inheritance + [Documentation] Test that session config overrides global config + # Set global config + Set Global Retry Configuration max_retries=2 backoff_factor=0.2 + + # Create session without specific config (should use global) + Create Session inherit_session ${BASE_URL} + ${global_config}= Get Retry Configuration inherit_session + Should Be Equal As Integers ${global_config}[max_retries] 2 + + # Set session-specific config (should override global) + Set Session Retry Configuration inherit_session max_retries=7 + ${session_config}= Get Retry Configuration inherit_session + Should Be Equal As Integers ${session_config}[max_retries] 7 + +*** Keywords *** + +Setup Test Environment + [Documentation] Setup test environment + Log Setting up test environment for retry mechanisms + # Reset to default global configuration + Set Global Retry Configuration max_retries=3 backoff_factor=0.3 + +Teardown Test Environment + [Documentation] Cleanup test environment + Log Cleaning up test environment + Delete All Sessions diff --git a/examples/retry_example.robot b/examples/retry_example.robot new file mode 100644 index 0000000..cbf1d09 --- /dev/null +++ b/examples/retry_example.robot @@ -0,0 +1,110 @@ +*** Settings *** +Documentation Example demonstrating HttpxLibrary retry mechanisms +Library HttpxLibrary + +*** Variables *** +${BASE_URL} https://httpbin.org + +*** Test Cases *** + +Basic Retry Example + [Documentation] Basic example of using retry functionality + + # Create a session + Create Session api ${BASE_URL} + + # Configure global retry settings + Set Global Retry Configuration max_retries=5 backoff_factor=0.5 + + # Make a request that will succeed immediately + ${response}= GET On Session With Retry api /get + Log Response status: ${response.status_code} + + # Make a request with custom retry parameters + ${response}= GET On Session With Retry api /get + ... max_retries=3 retry_on_status=500,502,503 + Log Response with custom retry: ${response.status_code} + +Advanced Retry Configuration + [Documentation] Advanced retry configuration example + + Create Session advanced_api ${BASE_URL} + + # Set session-specific retry configuration + Set Session Retry Configuration advanced_api + ... max_retries=10 + ... backoff_factor=1.0 + ... retry_on_status=429,500,502,503,504 + + # Check the configuration + ${config}= Get Retry Configuration advanced_api + Log Max retries: ${config}[max_retries] + Log Backoff factor: ${config}[backoff_factor] + Log Retry on status: ${config}[retry_on_status] + + # Use the Retry Request On Session keyword directly + ${response}= Retry Request On Session advanced_api GET /get + Should Be Equal As Integers ${response.status_code} 200 + +Wait Until Service Is Ready + [Documentation] Example of waiting until a service becomes available + + Create Session health_check ${BASE_URL} + + # Wait until the service responds successfully + ${response}= Wait Until Request Succeeds + ... health_check GET /get + ... timeout=60 interval=2 expected_status=200 + + Log Service is ready! Status: ${response.status_code} + +Different HTTP Methods With Retry + [Documentation] Examples of different HTTP methods with retry + + Create Session methods_api ${BASE_URL} + + # GET with retry + ${get_response}= GET On Session With Retry methods_api /get + Log GET Response: ${get_response.status_code} + + # POST with retry + &{post_data}= Create Dictionary message=Hello World + ${post_response}= POST On Session With Retry methods_api /post + ... json=${post_data} max_retries=3 + Log POST Response: ${post_response.status_code} + + # PUT with retry + &{put_data}= Create Dictionary update=true + ${put_response}= PUT On Session With Retry methods_api /put + ... json=${put_data} + Log PUT Response: ${put_response.status_code} + + # DELETE with retry + ${delete_response}= DELETE On Session With Retry methods_api /delete + Log DELETE Response: ${delete_response.status_code} + +Error Handling With Retry + [Documentation] Example of error handling with retry mechanisms + + Create Session error_api ${BASE_URL} + + # Configure to retry on specific status codes + Set Session Retry Configuration error_api + ... max_retries=2 + ... retry_on_status=500,503 + + # This will succeed immediately (200 status) + ${success_response}= GET On Session With Retry error_api /status/200 + Log Success response: ${success_response.status_code} + + # This will fail after retries (404 is not in retry_on_status) + Run Keyword And Expect Error * + ... GET On Session With Retry error_api /status/404 expected_status=200 + + Log Error handling completed + +*** Keywords *** + +Cleanup + [Documentation] Cleanup sessions + Delete All Sessions diff --git a/src/HttpxLibrary/HttpxOnSessionKeywords.py b/src/HttpxLibrary/HttpxOnSessionKeywords.py index 26d97f4..e52cbd0 100644 --- a/src/HttpxLibrary/HttpxOnSessionKeywords.py +++ b/src/HttpxLibrary/HttpxOnSessionKeywords.py @@ -222,3 +222,147 @@ def options_on_session(self, alias, url, response = self._common_request("options", session, url, **kwargs) self._check_status(expected_status, response, msg) return response + + @warn_if_equal_symbol_in_url + @keyword("GET On Session With Retry") + def get_on_session_with_retry(self, alias, url, params=None, + expected_status=None, msg=None, + max_retries=None, backoff_factor=None, + retry_on_status=None, **kwargs): + """ + Sends a GET request on a previously created HTTP Session with enhanced retry logic. + + This keyword extends `GET On Session` with configurable retry parameters. + + Args: + alias: Session alias name + url: Request URL + params: Query parameters + expected_status: Expected HTTP status code + msg: Custom error message + max_retries: Override max retries for this request + backoff_factor: Override backoff factor for this request + retry_on_status: Override retry status codes for this request (comma-separated string) + **kwargs: Additional request parameters + + Examples: + | ${response}= | GET On Session With Retry | my_session | /api/data | + | ${response}= | GET On Session With Retry | my_session | /api/flaky | max_retries=5 | + | ${response}= | GET On Session With Retry | my_session | /api/service | retry_on_status=500,503 | + """ + return self.retry_request_on_session( + alias, 'GET', url, + params=params, + expected_status=expected_status, + msg=msg, + max_retries=max_retries, + backoff_factor=backoff_factor, + retry_on_status=retry_on_status, + **kwargs + ) + + @warn_if_equal_symbol_in_url + @keyword("POST On Session With Retry") + def post_on_session_with_retry(self, alias, url, data=None, json=None, + expected_status=None, msg=None, + max_retries=None, backoff_factor=None, + retry_on_status=None, **kwargs): + """ + Sends a POST request on a previously created HTTP Session with enhanced retry logic. + + This keyword extends `POST On Session` with configurable retry parameters. + + Args: + alias: Session alias name + url: Request URL + data: Request body data + json: JSON request body + expected_status: Expected HTTP status code + msg: Custom error message + max_retries: Override max retries for this request + backoff_factor: Override backoff factor for this request + retry_on_status: Override retry status codes for this request (comma-separated string) + **kwargs: Additional request parameters + + Examples: + | ${response}= | POST On Session With Retry | my_session | /api/submit | json=${data} | + | ${response}= | POST On Session With Retry | my_session | /api/upload | data=${file_data} | max_retries=3 | + """ + return self.retry_request_on_session( + alias, 'POST', url, + data=data, + json=json, + expected_status=expected_status, + msg=msg, + max_retries=max_retries, + backoff_factor=backoff_factor, + retry_on_status=retry_on_status, + **kwargs + ) + + @warn_if_equal_symbol_in_url + @keyword("PUT On Session With Retry") + def put_on_session_with_retry(self, alias, url, data=None, json=None, + expected_status=None, msg=None, + max_retries=None, backoff_factor=None, + retry_on_status=None, **kwargs): + """ + Sends a PUT request on a previously created HTTP Session with enhanced retry logic. + + This keyword extends `PUT On Session` with configurable retry parameters. + """ + return self.retry_request_on_session( + alias, 'PUT', url, + data=data, + json=json, + expected_status=expected_status, + msg=msg, + max_retries=max_retries, + backoff_factor=backoff_factor, + retry_on_status=retry_on_status, + **kwargs + ) + + @warn_if_equal_symbol_in_url + @keyword("PATCH On Session With Retry") + def patch_on_session_with_retry(self, alias, url, data=None, json=None, + expected_status=None, msg=None, + max_retries=None, backoff_factor=None, + retry_on_status=None, **kwargs): + """ + Sends a PATCH request on a previously created HTTP Session with enhanced retry logic. + + This keyword extends `PATCH On Session` with configurable retry parameters. + """ + return self.retry_request_on_session( + alias, 'PATCH', url, + data=data, + json=json, + expected_status=expected_status, + msg=msg, + max_retries=max_retries, + backoff_factor=backoff_factor, + retry_on_status=retry_on_status, + **kwargs + ) + + @warn_if_equal_symbol_in_url + @keyword("DELETE On Session With Retry") + def delete_on_session_with_retry(self, alias, url, + expected_status=None, msg=None, + max_retries=None, backoff_factor=None, + retry_on_status=None, **kwargs): + """ + Sends a DELETE request on a previously created HTTP Session with enhanced retry logic. + + This keyword extends `DELETE On Session` with configurable retry parameters. + """ + return self.retry_request_on_session( + alias, 'DELETE', url, + expected_status=expected_status, + msg=msg, + max_retries=max_retries, + backoff_factor=backoff_factor, + retry_on_status=retry_on_status, + **kwargs + ) diff --git a/src/HttpxLibrary/RetryKeywords.py b/src/HttpxLibrary/RetryKeywords.py new file mode 100644 index 0000000..df8a1da --- /dev/null +++ b/src/HttpxLibrary/RetryKeywords.py @@ -0,0 +1,370 @@ +import time +import random +from typing import List, Union, Callable, Optional +from robot.api import logger +from robot.api.deco import keyword +import httpx +from httpx import Response, HTTPStatusError, ConnectError, TimeoutException, RequestError + + +class RetryConfig: + """Configuration class for retry behavior""" + + def __init__(self, + max_retries: int = 3, + backoff_factor: float = 0.3, + backoff_max: float = 120.0, + retry_on_status: List[int] = None, + retry_on_exceptions: List[Exception] = None, + jitter: bool = True): + self.max_retries = max_retries + self.backoff_factor = backoff_factor + self.backoff_max = backoff_max + self.retry_on_status = retry_on_status or [500, 502, 503, 504, 429] + self.retry_on_exceptions = retry_on_exceptions or [ + ConnectError, TimeoutException, RequestError + ] + self.jitter = jitter + + def should_retry_status(self, status_code: int) -> bool: + """Check if we should retry based on status code""" + return status_code in self.retry_on_status + + def should_retry_exception(self, exception: Exception) -> bool: + """Check if we should retry based on exception type""" + return any(isinstance(exception, exc_type) for exc_type in self.retry_on_exceptions) + + def get_backoff_time(self, attempt: int) -> float: + """Calculate backoff time for given attempt""" + backoff = self.backoff_factor * (2 ** attempt) + if self.jitter: + backoff *= (0.5 + random.random() * 0.5) # Add jitter + return min(backoff, self.backoff_max) + + +class RetryKeywords: + """Keywords for enhanced retry mechanisms""" + + def __init__(self): + self.global_retry_config = RetryConfig() + self.session_retry_configs = {} + + @keyword("Set Global Retry Configuration") + def set_global_retry_configuration(self, + max_retries: int = 3, + backoff_factor: float = 0.3, + backoff_max: float = 120.0, + retry_on_status: Union[str, List[int]] = "500,502,503,504,429", + jitter: bool = True): + """ + Sets global retry configuration for all HTTP requests. + + Args: + max_retries: Maximum number of retry attempts (default: 3) + backoff_factor: Backoff factor for exponential backoff (default: 0.3) + backoff_max: Maximum backoff time in seconds (default: 120.0) + retry_on_status: HTTP status codes to retry on, comma-separated string or list (default: "500,502,503,504,429") + jitter: Whether to add random jitter to backoff times (default: True) + + Examples: + | Set Global Retry Configuration | max_retries=5 | backoff_factor=0.5 | + | Set Global Retry Configuration | retry_on_status=500,502,503 | jitter=False | + """ + if isinstance(retry_on_status, str): + retry_on_status = [int(code.strip()) for code in retry_on_status.split(',')] + + self.global_retry_config = RetryConfig( + max_retries=max_retries, + backoff_factor=backoff_factor, + backoff_max=backoff_max, + retry_on_status=retry_on_status, + jitter=jitter + ) + + logger.info(f"Global retry configuration set: max_retries={max_retries}, " + f"backoff_factor={backoff_factor}, retry_on_status={retry_on_status}") + + @keyword("Set Session Retry Configuration") + def set_session_retry_configuration(self, + alias: str, + max_retries: int = 3, + backoff_factor: float = 0.3, + backoff_max: float = 120.0, + retry_on_status: Union[str, List[int]] = "500,502,503,504,429", + jitter: bool = True): + """ + Sets retry configuration for a specific session. + + Args: + alias: Session alias name + max_retries: Maximum number of retry attempts (default: 3) + backoff_factor: Backoff factor for exponential backoff (default: 0.3) + backoff_max: Maximum backoff time in seconds (default: 120.0) + retry_on_status: HTTP status codes to retry on, comma-separated string or list (default: "500,502,503,504,429") + jitter: Whether to add random jitter to backoff times (default: True) + + Examples: + | Set Session Retry Configuration | my_session | max_retries=5 | + | Set Session Retry Configuration | api_session | retry_on_status=429,503 | backoff_factor=1.0 | + """ + if isinstance(retry_on_status, str): + retry_on_status = [int(code.strip()) for code in retry_on_status.split(',')] + + self.session_retry_configs[alias] = RetryConfig( + max_retries=max_retries, + backoff_factor=backoff_factor, + backoff_max=backoff_max, + retry_on_status=retry_on_status, + jitter=jitter + ) + + logger.info(f"Session '{alias}' retry configuration set: max_retries={max_retries}, " + f"backoff_factor={backoff_factor}, retry_on_status={retry_on_status}") + + @keyword("Get Retry Configuration") + def get_retry_configuration(self, alias: Optional[str] = None) -> dict: + """ + Gets the current retry configuration for a session or global configuration. + + Args: + alias: Session alias name. If None, returns global configuration. + + Returns: + Dictionary containing retry configuration + + Examples: + | ${config}= | Get Retry Configuration | + | ${config}= | Get Retry Configuration | my_session | + """ + if alias and alias in self.session_retry_configs: + config = self.session_retry_configs[alias] + else: + config = self.global_retry_config + + return { + 'max_retries': config.max_retries, + 'backoff_factor': config.backoff_factor, + 'backoff_max': config.backoff_max, + 'retry_on_status': config.retry_on_status, + 'jitter': config.jitter + } + + @keyword("Clear Session Retry Configuration") + def clear_session_retry_configuration(self, alias: str): + """ + Clears retry configuration for a specific session, falling back to global configuration. + + Args: + alias: Session alias name + + Examples: + | Clear Session Retry Configuration | my_session | + """ + if alias in self.session_retry_configs: + del self.session_retry_configs[alias] + logger.info(f"Retry configuration cleared for session '{alias}'") + else: + logger.warn(f"No retry configuration found for session '{alias}'") + + def _get_retry_config(self, alias: Optional[str] = None) -> RetryConfig: + """Get retry configuration for session or global""" + if alias and alias in self.session_retry_configs: + return self.session_retry_configs[alias] + return self.global_retry_config + + def _execute_with_retry(self, + request_func: Callable, + retry_config: RetryConfig, + *args, **kwargs) -> Response: + """ + Execute HTTP request with retry logic + + Args: + request_func: Function to execute (e.g., session.get) + retry_config: Retry configuration to use + *args, **kwargs: Arguments to pass to request_func + + Returns: + HTTP Response object + """ + last_exception = None + last_response = None + + for attempt in range(retry_config.max_retries + 1): + try: + response = request_func(*args, **kwargs) + + # Check if we should retry based on status code + if retry_config.should_retry_status(response.status_code): + last_response = response + if attempt < retry_config.max_retries: + backoff_time = retry_config.get_backoff_time(attempt) + logger.warn(f"Request failed with status {response.status_code}, " + f"retrying in {backoff_time:.2f} seconds (attempt {attempt + 1}/{retry_config.max_retries + 1})") + time.sleep(backoff_time) + continue + else: + logger.warn(f"Request failed with status {response.status_code}, " + f"max retries ({retry_config.max_retries}) exceeded") + return response + + # Success case + if attempt > 0: + logger.info(f"Request succeeded on attempt {attempt + 1}") + return response + + except Exception as e: + last_exception = e + + # Check if we should retry based on exception type + if retry_config.should_retry_exception(e): + if attempt < retry_config.max_retries: + backoff_time = retry_config.get_backoff_time(attempt) + logger.warn(f"Request failed with {type(e).__name__}: {str(e)}, " + f"retrying in {backoff_time:.2f} seconds (attempt {attempt + 1}/{retry_config.max_retries + 1})") + time.sleep(backoff_time) + continue + else: + logger.warn(f"Request failed with {type(e).__name__}: {str(e)}, " + f"max retries ({retry_config.max_retries}) exceeded") + raise e + else: + # Don't retry for this exception type + raise e + + # This should not be reached, but just in case + if last_exception: + raise last_exception + if last_response: + return last_response + + raise RuntimeError("Unexpected error in retry logic") + + @keyword("Retry Request On Session") + def retry_request_on_session(self, + alias: str, + method: str, + url: str, + max_retries: Optional[int] = None, + backoff_factor: Optional[float] = None, + retry_on_status: Optional[Union[str, List[int]]] = None, + expected_status=None, + msg=None, + **kwargs) -> Response: + """ + Performs HTTP request with custom retry logic on a session. + + Args: + alias: Session alias name + method: HTTP method (GET, POST, PUT, etc.) + url: Request URL + max_retries: Override max retries for this request + backoff_factor: Override backoff factor for this request + retry_on_status: Override retry status codes for this request + expected_status: Expected HTTP status code + msg: Custom error message + **kwargs: Additional request parameters + + Returns: + HTTP Response object + + Examples: + | ${response}= | Retry Request On Session | my_session | GET | /api/data | + | ${response}= | Retry Request On Session | my_session | POST | /api/submit | max_retries=5 | + | ${response}= | Retry Request On Session | my_session | GET | /api/flaky | retry_on_status=500,503 | + """ + session = self._cache.switch(alias) + + # Get base retry config and override if specified + retry_config = self._get_retry_config(alias) + + if max_retries is not None: + retry_config.max_retries = max_retries + if backoff_factor is not None: + retry_config.backoff_factor = backoff_factor + if retry_on_status is not None: + if isinstance(retry_on_status, str): + retry_config.retry_on_status = [int(code.strip()) for code in retry_on_status.split(',')] + else: + retry_config.retry_on_status = retry_on_status + + # Get the method function from session + method_func = getattr(session, method.lower()) + + # Execute with retry + response = self._execute_with_retry( + method_func, + retry_config, + self._get_url(session, url), + **kwargs + ) + + # Check expected status if provided + if expected_status is not None: + self._check_status(expected_status, response, msg) + + return response + + @keyword("Wait Until Request Succeeds") + def wait_until_request_succeeds(self, + alias: str, + method: str, + url: str, + timeout: float = 60.0, + interval: float = 1.0, + expected_status: Union[int, str] = 200, + **kwargs) -> Response: + """ + Repeatedly makes HTTP requests until it succeeds or timeout is reached. + + Args: + alias: Session alias name + method: HTTP method (GET, POST, PUT, etc.) + url: Request URL + timeout: Maximum time to wait in seconds (default: 60.0) + interval: Time between attempts in seconds (default: 1.0) + expected_status: Expected HTTP status code (default: 200) + **kwargs: Additional request parameters + + Returns: + HTTP Response object when successful + + Examples: + | ${response}= | Wait Until Request Succeeds | my_session | GET | /health | + | ${response}= | Wait Until Request Succeeds | my_session | GET | /api/ready | timeout=120 | interval=5 | + """ + session = self._cache.switch(alias) + method_func = getattr(session, method.lower()) + + start_time = time.time() + attempt = 0 + + while time.time() - start_time < timeout: + attempt += 1 + try: + response = method_func(self._get_url(session, url), **kwargs) + + # Check if status matches expected + if isinstance(expected_status, int): + if response.status_code == expected_status: + logger.info(f"Request succeeded on attempt {attempt} after {time.time() - start_time:.2f} seconds") + return response + elif isinstance(expected_status, str): + if expected_status.lower() in ['any', 'anything']: + logger.info(f"Request completed on attempt {attempt} after {time.time() - start_time:.2f} seconds") + return response + elif response.status_code == int(expected_status): + logger.info(f"Request succeeded on attempt {attempt} after {time.time() - start_time:.2f} seconds") + return response + + logger.debug(f"Attempt {attempt}: Got status {response.status_code}, expected {expected_status}") + + except Exception as e: + logger.debug(f"Attempt {attempt}: Request failed with {type(e).__name__}: {str(e)}") + + if time.time() - start_time + interval < timeout: + time.sleep(interval) + else: + break + + raise TimeoutError(f"Request did not succeed within {timeout} seconds after {attempt} attempts") diff --git a/src/HttpxLibrary/SessionKeywords.py b/src/HttpxLibrary/SessionKeywords.py index 2c319bc..2ed6bad 100644 --- a/src/HttpxLibrary/SessionKeywords.py +++ b/src/HttpxLibrary/SessionKeywords.py @@ -14,6 +14,7 @@ from HttpxLibrary.exceptions import InvalidResponse, InvalidExpectedStatus from HttpxLibrary.utils import is_file_descriptor, is_string_type from .HttpxKeywords import HttpxKeywords +from .RetryKeywords import RetryKeywords try: # noinspection PyUnresolvedReferences @@ -22,9 +23,13 @@ pass -class SessionKeywords(HttpxKeywords): +class SessionKeywords(HttpxKeywords, RetryKeywords): DEFAULT_RETRIES = 3 + def __init__(self): + HttpxKeywords.__init__(self) + RetryKeywords.__init__(self) + def _create_session( self, alias, @@ -702,6 +707,45 @@ def _common_request( return resp + def _common_request_with_retry( + self, + method, + session, + uri, + use_retry=True, + **kwargs): + """ + Enhanced version of _common_request that supports retry logic + """ + if not use_retry: + return self._common_request(method, session, uri, **kwargs) + + # Get session alias for retry config lookup + session_alias = None + for alias, cached_session in self._cache._connections.items(): + if cached_session is session: + session_alias = alias + break + + retry_config = self._get_retry_config(session_alias) + method_function = getattr(session, method) + + def request_with_logging(): + self._capture_output() + resp = method_function(self._get_url(session, uri), **kwargs) + log.log_request(resp) + self._print_debug() + session.last_resp = resp + log.log_response(resp) + + data = kwargs.get('data', None) + if method == "get" and is_file_descriptor(data): + data.close() + + return resp + + return self._execute_with_retry(request_with_logging, retry_config) + @staticmethod def _check_status(expected_status, resp, msg=None): """ diff --git a/utests/test_retry_keywords.py b/utests/test_retry_keywords.py new file mode 100644 index 0000000..6bda5ca --- /dev/null +++ b/utests/test_retry_keywords.py @@ -0,0 +1,210 @@ +import unittest +from unittest.mock import Mock, patch, MagicMock +import time +import httpx +from HttpxLibrary.RetryKeywords import RetryKeywords, RetryConfig + + +class TestRetryKeywords(unittest.TestCase): + + def setUp(self): + self.retry_keywords = RetryKeywords() + + def test_retry_config_creation(self): + """Test RetryConfig creation with default values""" + config = RetryConfig() + self.assertEqual(config.max_retries, 3) + self.assertEqual(config.backoff_factor, 0.3) + self.assertEqual(config.backoff_max, 120.0) + self.assertEqual(config.retry_on_status, [500, 502, 503, 504, 429]) + self.assertTrue(config.jitter) + + def test_retry_config_custom_values(self): + """Test RetryConfig creation with custom values""" + config = RetryConfig( + max_retries=5, + backoff_factor=0.5, + retry_on_status=[500, 503], + jitter=False + ) + self.assertEqual(config.max_retries, 5) + self.assertEqual(config.backoff_factor, 0.5) + self.assertEqual(config.retry_on_status, [500, 503]) + self.assertFalse(config.jitter) + + def test_should_retry_status(self): + """Test status code retry logic""" + config = RetryConfig(retry_on_status=[500, 502, 503]) + + self.assertTrue(config.should_retry_status(500)) + self.assertTrue(config.should_retry_status(502)) + self.assertTrue(config.should_retry_status(503)) + self.assertFalse(config.should_retry_status(200)) + self.assertFalse(config.should_retry_status(404)) + + def test_should_retry_exception(self): + """Test exception retry logic""" + config = RetryConfig() + + self.assertTrue(config.should_retry_exception(httpx.ConnectError("Connection failed"))) + self.assertTrue(config.should_retry_exception(httpx.TimeoutException("Timeout"))) + self.assertFalse(config.should_retry_exception(ValueError("Invalid value"))) + + def test_backoff_time_calculation(self): + """Test backoff time calculation""" + config = RetryConfig(backoff_factor=1.0, jitter=False) + + # Test exponential backoff without jitter + self.assertEqual(config.get_backoff_time(0), 1.0) # 1.0 * (2^0) = 1.0 + self.assertEqual(config.get_backoff_time(1), 2.0) # 1.0 * (2^1) = 2.0 + self.assertEqual(config.get_backoff_time(2), 4.0) # 1.0 * (2^2) = 4.0 + + def test_backoff_time_with_max(self): + """Test backoff time respects maximum""" + config = RetryConfig(backoff_factor=10.0, backoff_max=5.0, jitter=False) + + # Should be capped at backoff_max + self.assertEqual(config.get_backoff_time(10), 5.0) + + def test_set_global_retry_configuration(self): + """Test setting global retry configuration""" + self.retry_keywords.set_global_retry_configuration( + max_retries=5, + backoff_factor=0.5, + retry_on_status="500,502,503" + ) + + config = self.retry_keywords.global_retry_config + self.assertEqual(config.max_retries, 5) + self.assertEqual(config.backoff_factor, 0.5) + self.assertEqual(config.retry_on_status, [500, 502, 503]) + + def test_set_session_retry_configuration(self): + """Test setting session-specific retry configuration""" + self.retry_keywords.set_session_retry_configuration( + "test_session", + max_retries=10, + backoff_factor=1.0 + ) + + config = self.retry_keywords.session_retry_configs["test_session"] + self.assertEqual(config.max_retries, 10) + self.assertEqual(config.backoff_factor, 1.0) + + def test_get_retry_configuration(self): + """Test getting retry configuration""" + # Test global configuration + global_config = self.retry_keywords.get_retry_configuration() + self.assertEqual(global_config['max_retries'], 3) # default value + + # Test session configuration + self.retry_keywords.set_session_retry_configuration("test_session", max_retries=7) + session_config = self.retry_keywords.get_retry_configuration("test_session") + self.assertEqual(session_config['max_retries'], 7) + + def test_clear_session_retry_configuration(self): + """Test clearing session retry configuration""" + # Set session config + self.retry_keywords.set_session_retry_configuration("test_session", max_retries=5) + self.assertIn("test_session", self.retry_keywords.session_retry_configs) + + # Clear session config + self.retry_keywords.clear_session_retry_configuration("test_session") + self.assertNotIn("test_session", self.retry_keywords.session_retry_configs) + + def test_get_retry_config_hierarchy(self): + """Test retry configuration hierarchy (session > global)""" + # Set global config + self.retry_keywords.set_global_retry_configuration(max_retries=3) + + # Should return global config for non-existent session + config = self.retry_keywords._get_retry_config("non_existent") + self.assertEqual(config.max_retries, 3) + + # Set session config + self.retry_keywords.set_session_retry_configuration("test_session", max_retries=7) + + # Should return session config + config = self.retry_keywords._get_retry_config("test_session") + self.assertEqual(config.max_retries, 7) + + @patch('time.sleep') + def test_execute_with_retry_success_immediately(self, mock_sleep): + """Test successful request on first attempt""" + mock_response = Mock() + mock_response.status_code = 200 + + mock_request_func = Mock(return_value=mock_response) + config = RetryConfig(max_retries=3) + + result = self.retry_keywords._execute_with_retry(mock_request_func, config) + + self.assertEqual(result, mock_response) + mock_request_func.assert_called_once() + mock_sleep.assert_not_called() + + @patch('time.sleep') + def test_execute_with_retry_success_after_retries(self, mock_sleep): + """Test successful request after retries""" + # First call returns 500, second call returns 200 + mock_response_fail = Mock() + mock_response_fail.status_code = 500 + mock_response_success = Mock() + mock_response_success.status_code = 200 + + mock_request_func = Mock(side_effect=[mock_response_fail, mock_response_success]) + config = RetryConfig(max_retries=3, retry_on_status=[500]) + + result = self.retry_keywords._execute_with_retry(mock_request_func, config) + + self.assertEqual(result, mock_response_success) + self.assertEqual(mock_request_func.call_count, 2) + mock_sleep.assert_called_once() + + @patch('time.sleep') + def test_execute_with_retry_max_retries_exceeded(self, mock_sleep): + """Test max retries exceeded""" + mock_response = Mock() + mock_response.status_code = 500 + + mock_request_func = Mock(return_value=mock_response) + config = RetryConfig(max_retries=2, retry_on_status=[500]) + + result = self.retry_keywords._execute_with_retry(mock_request_func, config) + + # Should return the last failed response + self.assertEqual(result, mock_response) + self.assertEqual(mock_request_func.call_count, 3) # initial + 2 retries + self.assertEqual(mock_sleep.call_count, 2) + + @patch('time.sleep') + def test_execute_with_retry_exception_handling(self, mock_sleep): + """Test retry on exceptions""" + # First call raises exception, second call succeeds + mock_response = Mock() + mock_response.status_code = 200 + + mock_request_func = Mock(side_effect=[httpx.ConnectError("Connection failed"), mock_response]) + config = RetryConfig(max_retries=3) + + result = self.retry_keywords._execute_with_retry(mock_request_func, config) + + self.assertEqual(result, mock_response) + self.assertEqual(mock_request_func.call_count, 2) + mock_sleep.assert_called_once() + + @patch('time.sleep') + def test_execute_with_retry_non_retryable_exception(self, mock_sleep): + """Test non-retryable exception is raised immediately""" + mock_request_func = Mock(side_effect=ValueError("Invalid value")) + config = RetryConfig(max_retries=3) + + with self.assertRaises(ValueError): + self.retry_keywords._execute_with_retry(mock_request_func, config) + + mock_request_func.assert_called_once() + mock_sleep.assert_not_called() + + +if __name__ == '__main__': + unittest.main()