Skip to content

Commit 3457f73

Browse files
author
Scott Collins
committed
Initial addition of a randomized failure injection capability for use with the requests package
This new feature adds the ability to simulate randomized failures when invoking the requests package to submit data from the DUM client to the service in AWS. Configuration of the new the feature is controlled within the INI config.
1 parent 28c1dd1 commit 3457f73

8 files changed

Lines changed: 355 additions & 12 deletions

File tree

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ install_requires =
3434
more-itertools>=9.0,<10.8
3535
joblib>=1.3.1,<1.6.0
3636
requests~=2.23
37+
requests-mock~=1.12.1
3738
types-requests~=2.23
3839
PyYAML~=6.0
3940
setuptools~=75.8.1

src/pds/ingress/client/pds_ingress_client.py

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
from more_itertools import chunked as batched
2727
from pds.ingress import __version__
2828
from pds.ingress.util.auth_util import AuthUtil
29+
from pds.ingress.util.backoff_util import simulate_batch_request_failure
30+
from pds.ingress.util.backoff_util import simulate_ingress_failure
2931
from pds.ingress.util.config_util import ConfigUtil
3032
from pds.ingress.util.hash_util import md5_for_path
3133
from pds.ingress.util.log_util import get_log_level
@@ -410,9 +412,12 @@ def request_batch_for_ingress(request_batch, batch_index, node_id, force_overwri
410412
"x-amz-docs-region": api_gateway_region,
411413
}
412414

413-
response = requests.post(
414-
api_gateway_url, params=params, data=json.dumps(request_batch), headers=headers, timeout=600
415-
)
415+
# Simulate a random failure for the batch request if configured to do so
416+
with simulate_batch_request_failure(api_gateway_url.split("?")[0]):
417+
response = requests.post(
418+
api_gateway_url, params=params, data=json.dumps(request_batch), headers=headers, timeout=600
419+
)
420+
416421
elapsed_time = time.time() - start_time
417422

418423
# Ingress request successful
@@ -488,13 +493,15 @@ def ingress_file_to_s3(ingress_response, batch_index, batch_pbar):
488493
batch_pbar, total=os.stat(ingress_path).st_size, filename=os.path.basename(ingress_path)
489494
)
490495

491-
with open(ingress_path, "rb") as infile:
492-
# Wrap file I/O with our upload bar to automatically track file upload progress
493-
wrapped_file = CallbackIOWrapper(upload_pbar.update, infile, "read")
496+
# Simulate a random failure for the S3 ingress request if configured to do so
497+
with simulate_ingress_failure(s3_ingress_url.split("?")[0]):
498+
with open(ingress_path, "rb") as infile:
499+
# Wrap file I/O with our upload bar to automatically track file upload progress
500+
wrapped_file = CallbackIOWrapper(upload_pbar.update, infile, "read")
494501

495-
# Only send the file data if the file is non-empty
496-
response = requests.put(s3_ingress_url, data=wrapped_file if file_length > 0 else b"", headers=headers)
497-
response.raise_for_status()
502+
# Only send the file data if the file is non-empty
503+
response = requests.put(s3_ingress_url, data=wrapped_file if file_length > 0 else b"", headers=headers)
504+
response.raise_for_status()
498505

499506
logger.info("Batch %d : %s Ingest complete", batch_index, trimmed_path)
500507
update_summary_table(SUMMARY_TABLE, "uploaded", ingress_path)
@@ -583,9 +590,11 @@ def ingress_multipart_file_to_s3(ingress_response, batch_index, batch_pbar):
583590
upload_pbar, f"{os.path.basename(ingress_path)} (Part {part_number}/{len(s3_ingress_urls)})"
584591
)
585592

586-
# Submit a single chunk to AWS
587-
response = requests.put(s3_ingress_url, data=next(chunk_iterator))
588-
response.raise_for_status()
593+
# Simulate a random failure for the S3 ingress request if configured to do so
594+
with simulate_ingress_failure(s3_ingress_url.split("?")[0]):
595+
# Submit a single chunk to AWS
596+
response = requests.put(s3_ingress_url, data=next(chunk_iterator))
597+
response.raise_for_status()
589598

590599
completed_parts.append({"ETag": response.headers["ETag"], "PartNumber": part_number})
591600
except Exception as err:

src/pds/ingress/conf.default.ini

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,11 @@ console_format = "%(message)s"
1919
log_group_name = "/pds/nucleus/dum/client-log-group"
2020
log_file_path =
2121
batch_size = 250
22+
23+
[DEBUG]
24+
simulate_batch_request_failures = false
25+
batch_request_failure_rate = 0
26+
batch_request_failure_class = requests.exceptions.HTTPError
27+
simulate_ingress_failures = false
28+
ingress_failure_rate = 0
29+
ingress_failure_class = requests.exceptions.HTTPError

src/pds/ingress/util/backoff_util.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,16 @@
77
automatic backoff/retry of HTTP requests.
88
99
"""
10+
import importlib
11+
import multiprocessing
12+
import random
13+
from contextlib import contextmanager
1014
from http import HTTPStatus
1115

16+
import requests_mock
17+
from distutils.util import strtobool
18+
from pds.ingress.util.config_util import ConfigUtil
19+
1220
# When leveraging this module with the Lambda service functions, the requests
1321
# module will not be available within the Python runtime.
1422
# It should not be needed by those Lambda's, so use MagicMock to bypass any
@@ -24,6 +32,9 @@
2432
SSLError = MagicMock()
2533

2634

35+
MOCK_REQUESTS_SEMAPHORE = multiprocessing.Semaphore(1)
36+
37+
2738
def fatal_code(err: requests.exceptions.RequestException) -> bool:
2839
"""
2940
Determines if the HTTP return code associated with a requests exception
@@ -60,3 +71,138 @@ def fatal_code(err: requests.exceptions.RequestException) -> bool:
6071
else:
6172
# No response to interrogate, so default to no retry
6273
return True
74+
75+
76+
def check_failure_chance(percentage: int) -> bool:
77+
"""
78+
Checks if a simulated failure event should occur based on a given percentage
79+
chance.
80+
81+
Parameters
82+
----------
83+
percentage : int
84+
The desired percentage chance (e.g., 70 for 70%).
85+
86+
Returns
87+
-------
88+
bool: True if the failure should occur, False otherwise.
89+
90+
Raises
91+
------
92+
ValueError: If the percentage is not between 0 and 100.
93+
94+
"""
95+
if not (0 <= percentage <= 100):
96+
raise ValueError("Percentage must be between 0 and 100.")
97+
98+
# Generate a random float between 0.0 and 1.0
99+
random_number = random.random()
100+
101+
# Convert the percentage to a decimal for comparison
102+
chance_threshold = percentage / 100.0
103+
104+
return random_number < chance_threshold
105+
106+
107+
def _simulate_requests_failure(mock_requests, url, http_method, enable_key, failure_rate_key, failure_class_key):
108+
"""
109+
Simulates a random failure for S3 ingress by registering the provided
110+
ingress URL with the requests mocker to raise an HTTPError exception.
111+
112+
Whether the failure is simulated is determined by the `simulate_ingress_failures`
113+
configuration option and the `ingress_failure_rate` percentage chance within
114+
the optional DEBUG section of the INI config. If this section is not present,
115+
this function should always default to not simulating a failure.
116+
117+
If the failure is not simulated, the mock_requests instance is reset to
118+
ensure no previous failures are registered.
119+
120+
Parameters
121+
----------
122+
mock_requests : requests_mock.Mocker
123+
The requests mocker instance to register the simulated failure with.
124+
url : str
125+
The URL to which the simulated failure will be applied.
126+
http_method : str
127+
HTTP method to register the failure for (e.g., 'POST', 'PUT').
128+
enable_key : str
129+
Name of the INI key to check if failure simulation is enabled.
130+
failure_rate_key : str
131+
Name of the INI key that specifies the percentage chance of failure.
132+
failure_class_key : str
133+
Name of the INI key that specifies the exception class to raise on failure.
134+
135+
"""
136+
config = ConfigUtil.get_config()
137+
138+
# Check if simulated failures are enabled, and if so, if we should simulate
139+
# a failure via mock_requests based on the configured failure chance
140+
if bool(strtobool(config.get("DEBUG", enable_key, fallback="false"))):
141+
if check_failure_chance(int(config.get("DEBUG", failure_rate_key, fallback="0"))):
142+
# Dynamically import the exception class to raise
143+
failure_class_str = config.get("DEBUG", failure_class_key, fallback="builtins.RuntimeError")
144+
failure_class_module, failure_exception_name = failure_class_str.rsplit(".", 1)
145+
146+
module = importlib.import_module(failure_class_module)
147+
exception_klass = getattr(module, failure_exception_name)
148+
149+
# Register the URL with the mock_requests to raise the specified exception
150+
mock_requests.register_uri(http_method, url, exc=exception_klass)
151+
152+
return mock_requests
153+
154+
155+
@contextmanager
156+
def simulate_batch_request_failure(api_gateway_url):
157+
"""
158+
Simulates a random failure for an ingress batch request by registering the
159+
provided ingress URL with the requests mocker to raise an HTTPError exception.
160+
161+
Parameters
162+
----------
163+
api_gateway_url : str
164+
The API Gateway URL to which the simulated failure will be applied.
165+
166+
"""
167+
with MOCK_REQUESTS_SEMAPHORE:
168+
with requests_mock.Mocker(real_http=True) as mock_requests:
169+
try:
170+
yield _simulate_requests_failure(
171+
mock_requests,
172+
api_gateway_url,
173+
"POST",
174+
"simulate_batch_request_failures",
175+
"batch_request_failure_rate",
176+
"batch_request_failure_class",
177+
)
178+
finally:
179+
# Remove any previously registered URL(s)
180+
mock_requests.reset()
181+
182+
183+
@contextmanager
184+
def simulate_ingress_failure(s3_ingress_url):
185+
"""
186+
Simulates a random failure for S3 ingress by registering the provided
187+
ingress URL with the requests mocker to raise an HTTPError exception.
188+
189+
Parameters
190+
----------
191+
s3_ingress_url : str
192+
The S3 ingress URL to which the simulated failure will be applied.
193+
194+
"""
195+
with MOCK_REQUESTS_SEMAPHORE:
196+
with requests_mock.Mocker(real_http=True) as mock_requests:
197+
try:
198+
yield _simulate_requests_failure(
199+
mock_requests,
200+
s3_ingress_url,
201+
"PUT",
202+
"simulate_ingress_failures",
203+
"ingress_failure_rate",
204+
"ingress_failure_class",
205+
)
206+
finally:
207+
# Remove any previously registered URL(s)
208+
mock_requests.reset()
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Configuration file intended for testing the DEBUG section and associated failure injection feature
2+
[API_GATEWAY]
3+
url_template = https://{id}.execute-api.{region}.amazonaws.com/{stage}/{resource}
4+
id = abcdefghi
5+
region = us-west-2
6+
stage = test
7+
8+
[COGNITO]
9+
client_id = 123456789
10+
username = cognito_user
11+
password = cognito_pass #pragma: allowlist secret
12+
region = us-west-2
13+
14+
[OTHER]
15+
log_level = INFO
16+
file_format = "[%(asctime)s] %(levelname)s %(threadName)s %(funcName)s : %(message)s"
17+
cloudwatch_format = '%(levelname)s %(threadName)s %(funcName)s : %(message)s'
18+
console_format = "%(message)s"
19+
log_group_name = "/pds/nucleus/dum/client-log-group"
20+
log_file_path =
21+
batch_size = 250
22+
23+
[DEBUG]
24+
simulate_batch_request_failures = true
25+
batch_request_failure_rate = 100
26+
batch_request_failure_class = builtins.TypeError
27+
simulate_ingress_failures = true
28+
ingress_failure_rate = 100
29+
ingress_failure_class = requests.exceptions.HTTPError
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Default configuration file with no DEBUG section included
2+
[API_GATEWAY]
3+
url_template = https://{id}.execute-api.{region}.amazonaws.com/{stage}/{resource}
4+
id = abcdefghi
5+
region = us-west-2
6+
stage = test
7+
8+
[COGNITO]
9+
client_id = 123456789
10+
username = cognito_user
11+
password = cognito_pass #pragma: allowlist secret
12+
region = us-west-2
13+
14+
[OTHER]
15+
log_level = INFO
16+
file_format = "[%(asctime)s] %(levelname)s %(threadName)s %(funcName)s : %(message)s"
17+
cloudwatch_format = '%(levelname)s %(threadName)s %(funcName)s : %(message)s'
18+
console_format = "%(message)s"
19+
log_group_name = "/pds/nucleus/dum/client-log-group"
20+
log_file_path =
21+
batch_size = 250
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
#!/usr/bin/env python3
2+
import unittest
3+
from importlib.resources import files
4+
from os.path import join
5+
6+
import pds.ingress.util.backoff_util
7+
import pds.ingress.util.config_util
8+
import requests
9+
import requests_mock
10+
from pds.ingress.util.backoff_util import simulate_batch_request_failure
11+
from pds.ingress.util.backoff_util import simulate_ingress_failure
12+
13+
14+
class BackoffUtilTest(unittest.TestCase):
15+
@classmethod
16+
def setUpClass(cls) -> None:
17+
cls.test_dir = str(files("tests.pds.ingress").joinpath("util"))
18+
19+
def setUp(self) -> None:
20+
config_path = join(self.test_dir, "data", "mock.backoff.config.ini")
21+
pds.ingress.util.config_util.CONFIG = None
22+
pds.ingress.util.config_util.ConfigUtil.get_config(config_path)
23+
24+
def test_simulate_batch_request_failure(self):
25+
# Unit tests for the simulate_batch_request_failure function context manager
26+
api_gateway_url = "https://example.com/api"
27+
28+
with simulate_batch_request_failure(api_gateway_url) as mock_requests:
29+
self.assertTrue(isinstance(mock_requests, requests_mock.Mocker))
30+
self.assertTrue(mock_requests.real_http)
31+
32+
# Ensure an attempt to submit a POST request to the configured URL
33+
# raises the error class specified in the INI config (builtins.TypeError)
34+
with self.assertRaises(TypeError):
35+
requests.post(api_gateway_url, data=b"")
36+
37+
# Check if the URL was registered with the mock_requests
38+
registered_urls = [req.url for req in mock_requests.request_history]
39+
self.assertIn(api_gateway_url, registered_urls)
40+
41+
# Ensure mock_requests was invoked for our request
42+
self.assertTrue(mock_requests.called)
43+
self.assertGreaterEqual(mock_requests.call_count, 1)
44+
self.assertEqual(str(mock_requests.last_request), "POST https://example.com/api")
45+
46+
# Ensure that the mock_requests context manager cleans up after itself
47+
mock_requests = requests_mock.Mocker(real_http=True)
48+
self.assertFalse(mock_requests.called)
49+
self.assertEqual(mock_requests.call_count, 0)
50+
self.assertListEqual(mock_requests.request_history, [])
51+
52+
def test_simulate_ingress_failure(self):
53+
# Unit tests for the simulate_ingress_failure function context manager
54+
s3_ingress_url = "https://example.com/ingress"
55+
56+
with simulate_ingress_failure(s3_ingress_url) as mock_requests:
57+
self.assertTrue(isinstance(mock_requests, requests_mock.Mocker))
58+
self.assertTrue(mock_requests.real_http)
59+
60+
# Ensure an attempt to submit a PUT request to the configured URL
61+
# raises the error class specified in the INI config (HTTPError)
62+
with self.assertRaises(requests.exceptions.HTTPError):
63+
requests.put(s3_ingress_url, data=b"")
64+
65+
# Check if the URL was registered with the mock_requests
66+
registered_urls = [req.url for req in mock_requests.request_history]
67+
self.assertIn(s3_ingress_url, registered_urls)
68+
69+
# Ensure mock_requests was invoked for our request
70+
self.assertTrue(mock_requests.called)
71+
self.assertGreaterEqual(mock_requests.call_count, 1)
72+
self.assertEqual(str(mock_requests.last_request), "PUT https://example.com/ingress")
73+
74+
# Ensure that the mock_requests context manager cleans up after itself
75+
mock_requests = requests_mock.Mocker(real_http=True)
76+
self.assertFalse(mock_requests.called)
77+
self.assertEqual(mock_requests.call_count, 0)
78+
self.assertListEqual(mock_requests.request_history, [])
79+
80+
81+
if __name__ == "__main__":
82+
unittest.main()

0 commit comments

Comments
 (0)