Skip to content

Commit c14bb80

Browse files
fix(parameters): recover AppConfig sessions after rejected poll tokens (#8615)
* fix(parameters): recover AppConfig sessions after rejected tokens * fix(parameters): preserve valid AppConfig poll tokens * test(parameters): read the cached value with the same sdk_options Since #8619 sdk_options are part of the cache key, so the cached read must pass the same RequiredMinimumPollIntervalInSeconds option as the call that populated it. --------- Co-authored-by: Leandro Damascena <lcdama@amazon.pt>
1 parent 050aba6 commit c14bb80

2 files changed

Lines changed: 337 additions & 8 deletions

File tree

‎aws_lambda_powertools/utilities/parameters/appconfig.py‎

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
if TYPE_CHECKING:
2323
from botocore.config import Config
24+
from botocore.exceptions import ClientError
2425
from mypy_boto3_appconfigdata.client import AppConfigDataClient
2526

2627
from aws_lambda_powertools.utilities.parameters.types import TransformOptions
@@ -30,6 +31,14 @@ class AppConfigProvider(BaseProvider):
3031
"""
3132
AWS App Config Provider
3233
34+
An expired or corrupted poll token starts a new configuration session and retries retrieval once.
35+
If the replacement token is expired or corrupted, retrieval raises GetParameterError and the next
36+
call can start a fresh session. Other service errors, including polling too early, retain
37+
the token and do not trigger session recovery.
38+
If reading the response body fails, retrieval raises GetParameterError and discards
39+
that configuration's token so the next retrieval can recover. Caching and transforms
40+
still apply, and recovery does not reset other configuration profiles.
41+
3342
Parameters
3443
----------
3544
environment: str
@@ -125,17 +134,43 @@ def _get(self, name: str, **sdk_options) -> bytes:
125134
SDK options to propagate to `start_configuration_session` API call
126135
"""
127136
if name not in self._next_token:
128-
sdk_options["ConfigurationProfileIdentifier"] = name
129-
sdk_options["ApplicationIdentifier"] = self.application
130-
sdk_options["EnvironmentIdentifier"] = self.environment
131-
response_configuration = self.client.start_configuration_session(**sdk_options)
132-
self._next_token[name] = response_configuration["InitialConfigurationToken"]
133-
137+
self._start_configuration_session(name, **sdk_options)
138+
139+
try:
140+
return self._get_configuration(name)
141+
except self.client.exceptions.BadRequestException as exc:
142+
if not self._is_token_expired_or_corrupted(exc):
143+
raise
144+
# Expired or corrupted tokens cannot be reused. Restart once, without recursively
145+
# retrying a rejected replacement or bypassing the configured polling interval.
146+
self._start_configuration_session(name, **sdk_options)
147+
return self._get_configuration(name)
148+
149+
def _start_configuration_session(self, name: str, **sdk_options) -> None:
150+
sdk_options["ConfigurationProfileIdentifier"] = name
151+
sdk_options["ApplicationIdentifier"] = self.application
152+
sdk_options["EnvironmentIdentifier"] = self.environment
153+
response = self.client.start_configuration_session(**sdk_options)
154+
self._next_token[name] = response["InitialConfigurationToken"]
155+
156+
def _get_configuration(self, name: str) -> bytes:
134157
# The new AppConfig APIs require two API calls to return the configuration
135158
# First we start the session and after that we retrieve the configuration
136159
# We need to store the token to use in the next execution
137-
response = self.client.get_latest_configuration(ConfigurationToken=self._next_token[name])
138-
return_value = response["Configuration"].read()
160+
try:
161+
response = self.client.get_latest_configuration(ConfigurationToken=self._next_token[name])
162+
except self.client.exceptions.BadRequestException as exc:
163+
if self._is_token_expired_or_corrupted(exc):
164+
self._next_token.pop(name, None)
165+
raise
166+
167+
try:
168+
return_value = response["Configuration"].read()
169+
except Exception:
170+
# The request consumed the token, but we did not receive a complete value.
171+
# A new session on the next call retrieves the configuration again.
172+
self._next_token.pop(name, None)
173+
raise
139174
self._next_token[name] = response["NextPollConfigurationToken"]
140175

141176
# The return of get_latest_configuration can be null because this value is supposed to be cached
@@ -147,6 +182,15 @@ def _get(self, name: str, **sdk_options) -> bytes:
147182

148183
return self.last_returned_value[name]
149184

185+
@staticmethod
186+
def _is_token_expired_or_corrupted(exc: ClientError) -> bool:
187+
problem: object = exc.response
188+
for key in ("Details", "InvalidParameters", "ConfigurationToken", "Problem"):
189+
if not isinstance(problem, dict):
190+
return False
191+
problem = problem.get(key)
192+
return problem in ("Expired", "Corrupted")
193+
150194
def _get_multiple(self, path: str, **sdk_options) -> dict[str, str]:
151195
"""
152196
Retrieving multiple parameter values is not supported with AWS App Config Provider
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
from __future__ import annotations
2+
3+
from io import BytesIO
4+
5+
import boto3
6+
import pytest
7+
from botocore.config import Config
8+
from botocore.response import StreamingBody
9+
from botocore.stub import Stubber
10+
11+
from aws_lambda_powertools.utilities.parameters import AppConfigProvider
12+
from aws_lambda_powertools.utilities.parameters.exceptions import GetParameterError
13+
14+
15+
@pytest.fixture
16+
def provider_and_stubber():
17+
client = boto3.client(
18+
"appconfigdata",
19+
config=Config(region_name="us-east-1", proxies={}),
20+
aws_access_key_id="testing",
21+
aws_secret_access_key="testing",
22+
aws_session_token="testing",
23+
)
24+
provider = AppConfigProvider(environment="dev", application="app", boto3_client=client)
25+
with Stubber(client) as stubber:
26+
yield provider, stubber
27+
stubber.assert_no_pending_responses()
28+
29+
30+
def add_session(stubber, token, name="cfg", **sdk_options):
31+
stubber.add_response(
32+
"start_configuration_session",
33+
{"InitialConfigurationToken": token},
34+
{
35+
"ConfigurationProfileIdentifier": name,
36+
"ApplicationIdentifier": "app",
37+
"EnvironmentIdentifier": "dev",
38+
**sdk_options,
39+
},
40+
)
41+
42+
43+
def add_poll(stubber, token, next_token, value, declared_length=None):
44+
stubber.add_response(
45+
"get_latest_configuration",
46+
{
47+
"NextPollConfigurationToken": next_token,
48+
"Configuration": StreamingBody(BytesIO(value), len(value) if declared_length is None else declared_length),
49+
},
50+
{"ConfigurationToken": token},
51+
)
52+
53+
54+
def add_rejected_token(stubber, token, problem="Expired"):
55+
stubber.add_client_error(
56+
"get_latest_configuration",
57+
"BadRequestException",
58+
"Token expired",
59+
expected_params={"ConfigurationToken": token},
60+
modeled_fields={
61+
"Reason": "InvalidParameters",
62+
"Details": {"InvalidParameters": {"ConfigurationToken": {"Problem": problem}}},
63+
},
64+
)
65+
66+
67+
@pytest.mark.parametrize("problem", ["Expired", "Corrupted"])
68+
def test_appconfig_recovers_rejected_poll_token_and_caches_new_value(provider_and_stubber, problem):
69+
provider, stubber = provider_and_stubber
70+
add_session(stubber, "initial")
71+
add_poll(stubber, "initial", "expired", b"old")
72+
add_rejected_token(stubber, "expired", problem)
73+
add_session(stubber, "fresh")
74+
add_poll(stubber, "fresh", "next", b"new")
75+
76+
assert provider.get("cfg", max_age=300) == b"old"
77+
assert provider.get("cfg", force_fetch=True, max_age=300) == b"new"
78+
assert provider.get("cfg", max_age=300) == b"new"
79+
assert provider._next_token["cfg"] == "next"
80+
81+
82+
@pytest.mark.parametrize("problem", ["Expired", "Corrupted"])
83+
@pytest.mark.parametrize("replacement_problem", ["Expired", "Corrupted"])
84+
def test_appconfig_retries_once_then_allows_recovery_on_a_later_call(
85+
provider_and_stubber,
86+
problem,
87+
replacement_problem,
88+
):
89+
provider, stubber = provider_and_stubber
90+
add_session(stubber, "initial")
91+
add_rejected_token(stubber, "initial", problem)
92+
add_session(stubber, "retry")
93+
add_rejected_token(stubber, "retry", replacement_problem)
94+
95+
with pytest.raises(GetParameterError, match="Token expired"):
96+
provider.get("cfg")
97+
stubber.assert_no_pending_responses()
98+
assert "cfg" not in provider._next_token
99+
100+
add_session(stubber, "fresh")
101+
add_poll(stubber, "fresh", "next", b"recovered")
102+
assert provider.get("cfg") == b"recovered"
103+
104+
105+
@pytest.mark.parametrize("recovery_problem", [None, "Expired", "Corrupted"])
106+
def test_appconfig_preserves_token_and_last_value_for_poll_interval_errors(provider_and_stubber, recovery_problem):
107+
provider, stubber = provider_and_stubber
108+
options = {"RequiredMinimumPollIntervalInSeconds": 30}
109+
add_session(stubber, "initial", **options)
110+
add_poll(stubber, "initial", "next", b"old")
111+
token = "next"
112+
if recovery_problem:
113+
add_rejected_token(stubber, token, recovery_problem)
114+
add_session(stubber, "replacement", **options)
115+
token = "replacement"
116+
add_rejected_token(stubber, token, "PollIntervalNotSatisfied")
117+
add_rejected_token(stubber, token, "PollIntervalNotSatisfied")
118+
add_poll(stubber, token, "last", b"")
119+
120+
assert provider.get("cfg", max_age=300, **options) == b"old"
121+
for _ in range(2):
122+
with pytest.raises(GetParameterError, match="BadRequestException"):
123+
provider.get("cfg", force_fetch=True, **options)
124+
assert provider._next_token["cfg"] == token
125+
assert provider.last_returned_value["cfg"] == b"old"
126+
assert provider.get("cfg", max_age=300, **options) == b"old"
127+
128+
assert provider.get("cfg", force_fetch=True) == b"old"
129+
assert provider._next_token["cfg"] == "last"
130+
131+
132+
@pytest.mark.parametrize("recovery_problem", [None, "Expired", "Corrupted"])
133+
def test_appconfig_preserves_token_for_poll_interval_errors_before_first_value(provider_and_stubber, recovery_problem):
134+
provider, stubber = provider_and_stubber
135+
add_session(stubber, "initial")
136+
token = "initial"
137+
if recovery_problem:
138+
add_rejected_token(stubber, token, recovery_problem)
139+
add_session(stubber, "replacement")
140+
token = "replacement"
141+
add_rejected_token(stubber, token, "PollIntervalNotSatisfied")
142+
add_poll(stubber, token, "next", b"new")
143+
144+
with pytest.raises(GetParameterError, match="BadRequestException"):
145+
provider.get("cfg")
146+
assert provider._next_token["cfg"] == token
147+
assert "cfg" not in provider.last_returned_value
148+
assert provider.get("cfg") == b"new"
149+
150+
151+
@pytest.mark.parametrize(
152+
"modeled_fields",
153+
[
154+
None,
155+
{"Reason": "InvalidParameters"},
156+
{"Details": {"InvalidParameters": {}}},
157+
{"Details": {"InvalidParameters": {"ConfigurationToken": {}}}},
158+
{"Details": {"InvalidParameters": {"ConfigurationToken": {"Problem": "Unknown"}}}},
159+
{"Details": {"InvalidParameters": {"OtherParameter": {"Problem": "Expired"}}}},
160+
],
161+
)
162+
def test_appconfig_preserves_token_for_unrecognized_bad_requests(provider_and_stubber, modeled_fields):
163+
provider, stubber = provider_and_stubber
164+
add_session(stubber, "initial")
165+
add_poll(stubber, "initial", "next", b"old")
166+
stubber.add_client_error(
167+
"get_latest_configuration",
168+
"BadRequestException",
169+
"unrecognized bad request",
170+
expected_params={"ConfigurationToken": "next"},
171+
modeled_fields=modeled_fields,
172+
)
173+
add_poll(stubber, "next", "last", b"")
174+
175+
assert provider.get("cfg", max_age=300) == b"old"
176+
with pytest.raises(GetParameterError, match="unrecognized bad request"):
177+
provider.get("cfg", force_fetch=True)
178+
assert provider._next_token["cfg"] == "next"
179+
assert provider.last_returned_value["cfg"] == b"old"
180+
assert provider.get("cfg", force_fetch=True) == b"old"
181+
182+
183+
def test_appconfig_starts_new_session_after_response_body_read_fails(provider_and_stubber):
184+
provider, stubber = provider_and_stubber
185+
add_session(stubber, "initial")
186+
add_poll(stubber, "initial", "consumed", b"old")
187+
# A truncated response consumed the token but did not deliver usable configuration.
188+
add_poll(stubber, "consumed", "unread", b"partial", declared_length=100)
189+
190+
assert provider.get("cfg", max_age=300) == b"old"
191+
with pytest.raises(GetParameterError, match="read, but total bytes expected"):
192+
provider.get("cfg", force_fetch=True)
193+
assert "cfg" not in provider._next_token
194+
assert provider.last_returned_value["cfg"] == b"old"
195+
196+
add_session(stubber, "fresh")
197+
add_poll(stubber, "fresh", "next", b"complete")
198+
assert provider.get("cfg", force_fetch=True) == b"complete"
199+
200+
201+
@pytest.mark.parametrize("error_code", ["ThrottlingException", "InternalServerException", "ResourceNotFoundException"])
202+
def test_appconfig_does_not_restart_for_unrelated_service_errors(provider_and_stubber, error_code):
203+
provider, stubber = provider_and_stubber
204+
add_session(stubber, "initial")
205+
stubber.add_client_error(
206+
"get_latest_configuration",
207+
error_code,
208+
"service failure",
209+
expected_params={"ConfigurationToken": "initial"},
210+
)
211+
with pytest.raises(GetParameterError, match="service failure"):
212+
provider.get("cfg")
213+
assert provider._next_token["cfg"] == "initial"
214+
stubber.assert_no_pending_responses()
215+
216+
217+
def test_appconfig_preserves_session_options_and_configuration_isolation(provider_and_stubber):
218+
provider, stubber = provider_and_stubber
219+
options = {"RequiredMinimumPollIntervalInSeconds": 30}
220+
add_session(stubber, "other-initial", name="other")
221+
add_poll(stubber, "other-initial", "other-next", b"other-value")
222+
add_session(stubber, "initial", **options)
223+
add_rejected_token(stubber, "initial")
224+
add_session(stubber, "fresh", **options)
225+
add_poll(stubber, "fresh", "next", b'{"enabled":true}')
226+
add_poll(stubber, "next", "next-empty", b"")
227+
add_poll(stubber, "other-next", "other-last", b"")
228+
229+
assert provider.get("other", max_age=0) == b"other-value"
230+
assert provider.get("cfg", transform="json", max_age=0, **options) == {"enabled": True}
231+
assert provider.get("cfg", transform="json", max_age=0) == {"enabled": True}
232+
assert provider.get("other", max_age=0) == b"other-value"
233+
assert provider._next_token == {"other": "other-last", "cfg": "next-empty"}
234+
assert options == {"RequiredMinimumPollIntervalInSeconds": 30}
235+
236+
237+
def test_appconfig_can_recover_after_restart_session_fails(provider_and_stubber):
238+
provider, stubber = provider_and_stubber
239+
add_session(stubber, "initial")
240+
add_rejected_token(stubber, "initial")
241+
stubber.add_client_error("start_configuration_session", "ResourceNotFoundException", "session unavailable")
242+
243+
with pytest.raises(GetParameterError, match="session unavailable"):
244+
provider.get("cfg")
245+
assert "cfg" not in provider._next_token
246+
247+
add_session(stubber, "fresh")
248+
add_poll(stubber, "fresh", "next", b"recovered")
249+
assert provider.get("cfg") == b"recovered"
250+
251+
252+
def test_appconfig_does_not_retry_initial_session_errors(provider_and_stubber):
253+
provider, stubber = provider_and_stubber
254+
stubber.add_client_error("start_configuration_session", "BadRequestException", "invalid session options")
255+
with pytest.raises(GetParameterError, match="invalid session options"):
256+
provider.get("cfg")
257+
assert "cfg" not in provider._next_token
258+
259+
260+
def test_appconfig_does_not_retry_replacement_token_throttling(provider_and_stubber):
261+
provider, stubber = provider_and_stubber
262+
add_session(stubber, "initial")
263+
add_rejected_token(stubber, "initial")
264+
add_session(stubber, "fresh")
265+
stubber.add_client_error(
266+
"get_latest_configuration",
267+
"ThrottlingException",
268+
"slow down",
269+
expected_params={"ConfigurationToken": "fresh"},
270+
)
271+
with pytest.raises(GetParameterError, match="slow down"):
272+
provider.get("cfg")
273+
assert provider._next_token["cfg"] == "fresh"
274+
275+
276+
def test_appconfig_discards_replacement_token_if_its_response_is_truncated(provider_and_stubber):
277+
provider, stubber = provider_and_stubber
278+
add_session(stubber, "initial")
279+
add_rejected_token(stubber, "initial")
280+
add_session(stubber, "fresh")
281+
add_poll(stubber, "fresh", "unread", b"partial", declared_length=100)
282+
with pytest.raises(GetParameterError, match="read, but total bytes expected"):
283+
provider.get("cfg")
284+
assert "cfg" not in provider._next_token
285+
assert "cfg" not in provider.last_returned_value

0 commit comments

Comments
 (0)