Skip to content

Commit e8dfbae

Browse files
committed
fix(prompts): address review on cooldown edge cases
1 parent 57f18f6 commit e8dfbae

2 files changed

Lines changed: 47 additions & 47 deletions

File tree

‎posthog/ai/prompts.py‎

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from dataclasses import dataclass
1515
from typing import Any, Dict, List, Literal, Optional, Union, overload
1616

17+
from posthog.capture_v1 import _parse_retry_after
1718
from posthog.request import USER_AGENT, _get_session
1819
from posthog.utils import remove_trailing_slash
1920

@@ -160,14 +161,9 @@ def _is_same_origin(url: str, host: str) -> bool:
160161

161162

162163
def _parse_retry_after_seconds(value: Optional[str]) -> Optional[float]:
163-
"""Read a Retry-After header of the delta-seconds form the API sends."""
164-
if not value:
165-
return None
166-
try:
167-
seconds = float(value.strip())
168-
except ValueError:
169-
return None
170-
if not math.isfinite(seconds) or seconds <= 0:
164+
"""Read a Retry-After header (delta-seconds or HTTP-date) as a bounded cooldown."""
165+
seconds = _parse_retry_after(value)
166+
if seconds is None or not math.isfinite(seconds) or seconds <= 0:
171167
return None
172168
return min(seconds, MAX_REFETCH_COOLDOWN_SECONDS)
173169

@@ -554,8 +550,13 @@ def _get_internal(
554550

555551
# A failed refetch left this entry in cooldown. Serving it keeps one
556552
# throttled client from turning every later get() into another
557-
# request, which is what holds it against the limit.
558-
if cached.retry_not_before is not None and now < cached.retry_not_before:
553+
# request, which is what holds it against the limit. A zero TTL is
554+
# an explicit request to refetch on every read, so it opts out.
555+
if (
556+
ttl > 0
557+
and cached.retry_not_before is not None
558+
and now < cached.retry_not_before
559+
):
559560
return PromptResult(
560561
source="stale_cache",
561562
prompt=cached.prompt,

‎posthog/test/ai/test_prompts.py‎

Lines changed: 36 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -325,75 +325,62 @@ def test_use_stale_cache_on_fetch_failure_with_warning(
325325
warning_call = mock_log.warning.call_args
326326
self.assertIn("using stale cache", warning_call[0][0])
327327

328+
@parameterized.expand(
329+
[
330+
# The Retry-After cooldown (300s) outlives the 60s default, so the
331+
# holds after second 1460 prove the server's value governs.
332+
("server_error", 500, None, 60.0),
333+
("rate_limited_retry_after", 429, {"Retry-After": "300"}, 300.0),
334+
]
335+
)
328336
@patch("posthog.ai.prompts._get_session")
329337
@patch("posthog.ai.prompts.time.time")
330-
def test_hold_a_cooldown_after_a_failed_refetch_then_retry(
331-
self, mock_time, mock_get_session
338+
def test_hold_a_cooldown_after_each_failed_refetch(
339+
self, _scenario, status, headers, cooldown, mock_time, mock_get_session
332340
):
333341
# Without the cooldown, one throttled client turns every later get()
334342
# into another network request until one succeeds.
335343
mock_get = mock_get_session.return_value.get
336344
mock_get.side_effect = [
337345
MockResponse(json_data=self.mock_prompt_response),
338-
MockResponse(status_code=500, ok=False),
346+
MockResponse(status_code=status, ok=False, headers=headers),
347+
MockResponse(status_code=status, ok=False, headers=headers),
339348
MockResponse(json_data=self.mock_prompt_response),
340349
]
341350
mock_time.return_value = 1000.0
342351

343352
prompts = Prompts(self.create_mock_posthog())
344353
prompts.get("test-prompt", cache_ttl_seconds=300, with_metadata=False)
345354

346-
# Past TTL: the refetch fails, stale cache is served, cooldown starts.
355+
# Past TTL: the refetch fails, stale cache is served, a cooldown starts.
347356
mock_time.return_value = 1400.0
348357
result = prompts.get("test-prompt", cache_ttl_seconds=300, with_metadata=True)
349358
self.assertEqual(result.source, "stale_cache")
350359
self.assertEqual(mock_get.call_count, 2)
351360

352-
# Within the 60s cooldown: stale cache again, no network attempt.
353-
mock_time.return_value = 1430.0
361+
# Within the cooldown: stale cache again, no network attempt.
362+
mock_time.return_value = 1400.0 + cooldown - 1
354363
result = prompts.get("test-prompt", cache_ttl_seconds=300, with_metadata=True)
355364
self.assertEqual(result.source, "stale_cache")
356365
self.assertEqual(mock_get.call_count, 2)
357366

358-
# Past the cooldown: the network is retried and the cache refreshed.
359-
mock_time.return_value = 1470.0
360-
result = prompts.get("test-prompt", cache_ttl_seconds=300, with_metadata=True)
361-
self.assertEqual(result.source, "api")
362-
self.assertEqual(mock_get.call_count, 3)
363-
364-
@patch("posthog.ai.prompts._get_session")
365-
@patch("posthog.ai.prompts.time.time")
366-
def test_hold_the_cooldown_for_the_retry_after_a_429_sends(
367-
self, mock_time, mock_get_session
368-
):
369-
# A rate-limited client must wait as long as the server told it to,
370-
# not the 60s default.
371-
mock_get = mock_get_session.return_value.get
372-
mock_get.side_effect = [
373-
MockResponse(json_data=self.mock_prompt_response),
374-
MockResponse(status_code=429, ok=False, headers={"Retry-After": "300"}),
375-
MockResponse(json_data=self.mock_prompt_response),
376-
]
377-
mock_time.return_value = 1000.0
378-
379-
prompts = Prompts(self.create_mock_posthog())
380-
prompts.get("test-prompt", cache_ttl_seconds=300, with_metadata=False)
381-
382-
mock_time.return_value = 1400.0
367+
# Past the cooldown: the retry fails too and a new cooldown starts.
368+
mock_time.return_value = 1400.0 + cooldown + 1
383369
result = prompts.get("test-prompt", cache_ttl_seconds=300, with_metadata=True)
384370
self.assertEqual(result.source, "stale_cache")
371+
self.assertEqual(mock_get.call_count, 3)
385372

386-
# Past the 60s default but within Retry-After: no network attempt.
387-
mock_time.return_value = 1600.0
373+
# Within the second cooldown: no network attempt.
374+
mock_time.return_value = 1400.0 + 2 * cooldown
388375
result = prompts.get("test-prompt", cache_ttl_seconds=300, with_metadata=True)
389376
self.assertEqual(result.source, "stale_cache")
390-
self.assertEqual(mock_get.call_count, 2)
377+
self.assertEqual(mock_get.call_count, 3)
391378

392-
# Past Retry-After: the network is retried.
393-
mock_time.return_value = 1701.0
379+
# Past the second cooldown: the network is retried and the cache refreshed.
380+
mock_time.return_value = 1400.0 + 2 * cooldown + 2
394381
result = prompts.get("test-prompt", cache_ttl_seconds=300, with_metadata=True)
395382
self.assertEqual(result.source, "api")
396-
self.assertEqual(mock_get.call_count, 3)
383+
self.assertEqual(mock_get.call_count, 4)
397384

398385
@patch("posthog.ai.prompts._get_session")
399386
@patch("posthog.ai.prompts.log")
@@ -619,6 +606,18 @@ def test_default_cache_ttl_seconds_zero_disables_caching(
619606
prompts.get("test-prompt", with_metadata=False)
620607
self.assertEqual(mock_get.call_count, 2)
621608

609+
# A failed refetch must not start a cooldown here: a zero TTL is an
610+
# explicit request to refetch on every read.
611+
mock_get.side_effect = [
612+
MockResponse(status_code=500, ok=False),
613+
MockResponse(json_data=self.mock_prompt_response),
614+
]
615+
result = prompts.get("test-prompt", with_metadata=True)
616+
self.assertEqual(result.source, "stale_cache")
617+
result = prompts.get("test-prompt", with_metadata=True)
618+
self.assertEqual(result.source, "api")
619+
self.assertEqual(mock_get.call_count, 4)
620+
622621
@patch("posthog.ai.prompts._get_session")
623622
def test_url_encode_prompt_names_with_special_characters(self, mock_get_session):
624623
"""Should URL-encode prompt names with special characters."""

0 commit comments

Comments
 (0)