Skip to content

Commit 69ed67a

Browse files
vjfridgeCodex GPT-6
andcommitted
fix: back off feature flag flushes within invocation cleanup
Skip invocation-end flush attempts for 60 seconds after three full-budget timeouts, keep failures isolated, and restore existing wrapper error handling. Environment: Datadog workspace Co-Authored-By: Codex GPT-6 <noreply@localhost>
1 parent 41477ba commit 69ed67a

4 files changed

Lines changed: 278 additions & 109 deletions

File tree

‎datadog_lambda/feature_flags.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import logging
44
import math
5+
from time import monotonic
56

67
from datadog_lambda.config import config
78

@@ -13,9 +14,15 @@
1314

1415
logger = logging.getLogger(__name__)
1516
SAFETY_MARGIN_MS = 100.0
17+
MAX_CONSECUTIVE_TIMEOUTS = 3
18+
TIMEOUT_BACKOFF_S = 60.0
19+
# Warm invocations share this state; a new process starts with no cooldown.
20+
_consecutive_timeouts = 0
21+
_skip_until = 0.0
1622

1723

1824
def flush_feature_flag_telemetry(context):
25+
global _consecutive_timeouts, _skip_until
1926
try:
2027
if _telemetry is None:
2128
return
@@ -35,7 +42,18 @@ def flush_feature_flag_telemetry(context):
3542
"Skipping feature flag telemetry flush: no invocation time remains"
3643
)
3744
return
45+
if monotonic() < _skip_until:
46+
logger.debug("Skipping feature flag telemetry flush: backing off")
47+
return
3848
outcome = _telemetry.flush(budget_ms / 1000.0)
49+
if outcome != "timed_out":
50+
_consecutive_timeouts = 0
51+
elif budget_ms >= cap_ms:
52+
# A short invocation budget says nothing about intake health.
53+
_consecutive_timeouts += 1
54+
if _consecutive_timeouts >= MAX_CONSECUTIVE_TIMEOUTS:
55+
_consecutive_timeouts = 0
56+
_skip_until = monotonic() + TIMEOUT_BACKOFF_S
3957
if outcome != "completed":
4058
logger.debug("Feature flag telemetry flush incomplete: %s", outcome)
4159
except Exception:

‎datadog_lambda/wrapper.py‎

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -195,31 +195,18 @@ def __call__(self, event, context, **kwargs):
195195
return self.blocking_response
196196
self.response = self.func(event, context, **kwargs)
197197
except Exception:
198-
try:
199-
from datadog_lambda.metric import submit_errors_metric
200-
201-
submit_errors_metric(context)
202-
if self.span:
203-
self.span.set_traceback()
204-
except Exception as e:
205-
logger.error(format_err_with_traceback(e))
198+
from datadog_lambda.metric import submit_errors_metric
199+
200+
submit_errors_metric(context)
201+
202+
if self.span:
203+
self.span.set_traceback()
206204
raise
207205
except BaseException as e:
208206
if "BlockingException" in type(e).__name__:
209207
self.blocking_response = get_asm_blocked_response(self.event_source)
210-
else:
211-
raise
212208
finally:
213-
try:
214-
self._after(event, context)
215-
except Exception as e:
216-
logger.error(format_err_with_traceback(e))
217-
finally:
218-
if (
219-
config.feature_flags_enabled
220-
and flush_feature_flag_telemetry is not None
221-
):
222-
flush_feature_flag_telemetry(context)
209+
self._after(event, context)
223210
if self.blocking_response:
224211
return self.blocking_response
225212
return self.response
@@ -417,6 +404,12 @@ def _after(self, event, context):
417404
except Exception as e:
418405
logger.debug("Failed to create cold start spans. %s", e)
419406

407+
if (
408+
config.feature_flags_enabled
409+
and flush_feature_flag_telemetry is not None
410+
):
411+
flush_feature_flag_telemetry(context)
412+
420413
if not config.flush_to_log or should_use_extension:
421414
from datadog_lambda.metric import flush_stats
422415

‎tests/test_feature_flags.py‎

Lines changed: 78 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import importlib.util
21
import os
32
import subprocess
43
import sys
@@ -7,11 +6,84 @@
76
from unittest.mock import Mock, patch
87

98
from datadog_lambda import feature_flags
10-
from datadog_lambda import wrapper
119
from datadog_lambda.config import Config
1210

1311

1412
class FeatureFlagFlushTest(unittest.TestCase):
13+
def setUp(self):
14+
for name, value in [("_consecutive_timeouts", 0), ("_skip_until", 0.0)]:
15+
patcher = patch.object(feature_flags, name, value)
16+
patcher.start()
17+
self.addCleanup(patcher.stop)
18+
19+
def test_full_budget_timeouts_back_off_and_retry_after_expiry(self):
20+
with patch.object(
21+
feature_flags, "monotonic", return_value=1000.0
22+
) as now, patch.object(feature_flags, "_telemetry") as telemetry, patch.object(
23+
Config, "feature_flag_flush_timeout_ms", 250
24+
):
25+
telemetry.FLUSH_API_VERSION = 1
26+
telemetry.flush.return_value = "timed_out"
27+
context = Mock(get_remaining_time_in_millis=lambda: 30000)
28+
for _ in range(3):
29+
feature_flags.flush_feature_flag_telemetry(context)
30+
self.assertEqual(telemetry.flush.call_count, 3)
31+
telemetry.flush.assert_called_with(0.25)
32+
for timestamp in (1000.0, 1030.0, 1059.999):
33+
now.return_value = timestamp
34+
feature_flags.flush_feature_flag_telemetry(context)
35+
self.assertEqual(telemetry.flush.call_count, 3)
36+
now.return_value = 1060.0
37+
telemetry.flush.return_value = "completed"
38+
feature_flags.flush_feature_flag_telemetry(context)
39+
self.assertEqual(telemetry.flush.call_count, 4)
40+
# Recovery starts a new streak, not an immediate second cooldown.
41+
telemetry.flush.return_value = "timed_out"
42+
for _ in range(2):
43+
feature_flags.flush_feature_flag_telemetry(context)
44+
telemetry.flush.return_value = "completed"
45+
feature_flags.flush_feature_flag_telemetry(context)
46+
self.assertEqual(telemetry.flush.call_count, 7)
47+
48+
def test_completed_and_failed_flushes_reset_timeout_streak(self):
49+
for outcome in ("completed", "failed"):
50+
with self.subTest(outcome=outcome), patch.object(
51+
feature_flags, "_telemetry"
52+
) as telemetry, patch.object(
53+
feature_flags, "monotonic", return_value=1000.0
54+
):
55+
telemetry.FLUSH_API_VERSION = 1
56+
outcomes = [
57+
"timed_out",
58+
"timed_out",
59+
outcome,
60+
"timed_out",
61+
"timed_out",
62+
"completed",
63+
]
64+
telemetry.flush.side_effect = outcomes
65+
for _ in outcomes:
66+
feature_flags.flush_feature_flag_telemetry(
67+
Mock(get_remaining_time_in_millis=lambda: 30000)
68+
)
69+
self.assertEqual(telemetry.flush.call_count, len(outcomes))
70+
71+
def test_short_budget_timeouts_neither_count_nor_reset_streak(self):
72+
with patch.object(feature_flags, "_telemetry") as telemetry, patch.object(
73+
feature_flags, "monotonic", return_value=1000.0
74+
):
75+
telemetry.FLUSH_API_VERSION = 1
76+
telemetry.flush.return_value = "timed_out"
77+
for remaining in (30000, 200, 200, 200, 30000, 30000):
78+
feature_flags.flush_feature_flag_telemetry(
79+
Mock(get_remaining_time_in_millis=lambda: remaining)
80+
)
81+
self.assertEqual(telemetry.flush.call_count, 6)
82+
feature_flags.flush_feature_flag_telemetry(
83+
Mock(get_remaining_time_in_millis=lambda: 30000)
84+
)
85+
self.assertEqual(telemetry.flush.call_count, 6)
86+
1587
def test_budget(self):
1688
for remaining, cap, expected in [
1789
(5000, 500, 0.5),
@@ -118,7 +190,8 @@ def test_unsupported_tracer_skip_is_logged(self):
118190
)
119191

120192
def test_disabled_feature_flags_do_not_import_flush_modules(self):
121-
code = textwrap.dedent("""
193+
code = textwrap.dedent(
194+
"""
122195
import sys
123196
124197
class BlockFlushImports:
@@ -134,7 +207,8 @@ def find_spec(self, fullname, *args):
134207
assert not wrapper.config.feature_flags_enabled
135208
assert "datadog_lambda.feature_flags" not in sys.modules
136209
assert "ddtrace.internal.openfeature._telemetry" not in sys.modules
137-
""")
210+
"""
211+
)
138212
result = subprocess.run(
139213
[sys.executable, "-c", code],
140214
env={
@@ -160,88 +234,3 @@ def test_incomplete_diagnostic(self):
160234
log.debug.assert_called_with(
161235
"Feature flag telemetry flush incomplete: %s", "timed_out"
162236
)
163-
164-
def test_wrapper_preserves_result_and_exception_when_cleanup_fails(self):
165-
for raises in [False, True]:
166-
original = ValueError("handler error")
167-
handler = (
168-
Mock(side_effect=original)
169-
if raises
170-
else Mock(return_value={"ok": True})
171-
)
172-
# Isolate invocation control flow from tracing/metrics initialization.
173-
decorated = object.__new__(wrapper._LambdaDecorator)
174-
decorated.func = handler
175-
decorated.blocking_response = None
176-
decorated.span = None
177-
decorated._before = Mock()
178-
decorated._after = Mock(side_effect=RuntimeError("cleanup error"))
179-
context = Mock(get_remaining_time_in_millis=lambda: 30000)
180-
with patch.object(wrapper, "flush_feature_flag_telemetry") as flush, patch(
181-
"datadog_lambda.metric.submit_errors_metric",
182-
side_effect=RuntimeError("metrics error"),
183-
):
184-
if raises:
185-
with self.assertRaises(ValueError) as caught:
186-
decorated({}, context)
187-
self.assertIs(caught.exception, original)
188-
else:
189-
self.assertEqual(decorated({}, context), {"ok": True})
190-
flush.assert_called_once_with(context)
191-
192-
193-
class FeatureFlagTracerIntegrationTest(unittest.TestCase):
194-
@unittest.skipUnless(
195-
feature_flags._telemetry is not None
196-
and importlib.util.find_spec("openfeature") is not None,
197-
"requires tracer flush contract v1 and the optional OpenFeature SDK",
198-
)
199-
def test_short_warm_invocations_flush_both_writers(self):
200-
from ddtrace.internal.openfeature._flagevaluation_writer import (
201-
FlagEvaluationWriter,
202-
_EvalEvent,
203-
)
204-
from ddtrace.internal.openfeature._evp_transport import EVPRoute
205-
from ddtrace.internal.openfeature.writer import ExposureWriter
206-
207-
route = EVPRoute("http://agent:8126", "/evp_proxy/v2", {})
208-
selector = Mock(configuration_source="agentless")
209-
selector.select.return_value = route
210-
selector.send.side_effect = lambda route, send: send(route)
211-
connection = Mock()
212-
connection.getresponse.return_value.status = 202
213-
evaluations = FlagEvaluationWriter(
214-
route_selector=selector, connection_factory=lambda *a, **k: connection
215-
)
216-
exposures = ExposureWriter(
217-
enabled=True,
218-
route_selector=selector,
219-
connection_factory=lambda *a, **k: connection,
220-
)
221-
original = ValueError("handler failure")
222-
223-
def handler(event, context):
224-
evaluations.enqueue(
225-
_EvalEvent("flag", "on", "allocation", "subject", {}, False, "", 1)
226-
)
227-
exposures.enqueue({"timestamp": 1})
228-
if event.get("raise"):
229-
raise original
230-
return {"ok": True}
231-
232-
decorated = object.__new__(wrapper._LambdaDecorator)
233-
decorated.func = handler
234-
decorated.blocking_response = None
235-
decorated.span = None
236-
decorated._before = Mock()
237-
decorated._after = Mock(side_effect=RuntimeError("unrelated cleanup"))
238-
context = Mock(get_remaining_time_in_millis=lambda: 30000)
239-
with patch("datadog_lambda.metric.submit_errors_metric"):
240-
self.assertEqual(decorated({}, context), {"ok": True})
241-
self.assertEqual(connection.request.call_count, 2)
242-
with self.assertRaises(ValueError) as caught:
243-
decorated({"raise": True}, context)
244-
self.assertIs(caught.exception, original)
245-
self.assertEqual(connection.request.call_count, 4)
246-
self.assertEqual(decorated({}, context), {"ok": True})
247-
self.assertEqual(connection.request.call_count, 6)

0 commit comments

Comments
 (0)