Skip to content

Commit b5a4711

Browse files
committed
retry only retryable failures, honour retry-after
1 parent 5c87a12 commit b5a4711

7 files changed

Lines changed: 240 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.9.5] - 2026-06-11
9+
10+
### Changed
11+
12+
- **Permanent client errors (4xx except 408/429) are no longer retried** (sync and async): the batch is dropped after the first attempt instead of burning the full retry budget
13+
- A `Retry-After` header on `429`/`503` responses now overrides the computed backoff delay
14+
815
## [0.9.4] - 2026-06-11
916

1017
### Added

logtide_sdk/_retry.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Failure classification for the transport retry loops (spec 002 §6).
2+
3+
Retryable: network errors, 408, 429, 5xx. Permanent client errors (other
4+
4xx) must be dropped after the first attempt. A Retry-After header
5+
(delta-seconds form) overrides the computed backoff delay.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from typing import Any
11+
12+
__all__ = ["classify_failure"]
13+
14+
15+
def _status_and_headers(exc: Exception) -> tuple[int | None, Any]:
16+
# requests.HTTPError carries .response with .status_code/.headers
17+
response = getattr(exc, "response", None)
18+
if response is not None:
19+
status = getattr(response, "status_code", None)
20+
if status is not None:
21+
return status, getattr(response, "headers", None)
22+
# aiohttp.ClientResponseError carries .status/.headers directly
23+
status = getattr(exc, "status", None)
24+
if isinstance(status, int):
25+
return status, getattr(exc, "headers", None)
26+
return None, None
27+
28+
29+
def classify_failure(exc: Exception) -> tuple[bool, float | None]:
30+
"""Return ``(retryable, retry_after_seconds)`` for a send failure."""
31+
status, headers = _status_and_headers(exc)
32+
33+
if status is None:
34+
return True, None # network/timeout errors: retry with backoff
35+
36+
retryable = status in (408, 429) or status >= 500
37+
38+
retry_after: float | None = None
39+
if headers is not None:
40+
try:
41+
raw = headers.get("Retry-After")
42+
except AttributeError:
43+
raw = None
44+
if raw is not None:
45+
try:
46+
seconds = float(raw)
47+
if seconds >= 0:
48+
retry_after = seconds
49+
except (TypeError, ValueError):
50+
pass
51+
52+
return retryable, retry_after

logtide_sdk/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@
44
import it without circular imports. Bump together with ``pyproject.toml``.
55
"""
66

7-
VERSION = "0.9.4"
7+
VERSION = "0.9.5"
88
SDK_NAME = "logtide-python"

logtide_sdk/async_client.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from logtide_sdk.enums import CircuitState, LogLevel
2222
from logtide_sdk.exceptions import CircuitBreakerOpenError
2323
from logtide_sdk.json_encoder import logtide_json_dumps
24+
from logtide_sdk._retry import classify_failure
2425
from logtide_sdk._version import SDK_NAME, VERSION
2526
from logtide_sdk.scope import get_current_scope
2627
from logtide_sdk.tracecontext import generate_trace_id
@@ -530,11 +531,22 @@ async def _send_logs_with_retry(self, logs: list[LogEntry]) -> None:
530531
attempt += 1
531532
self._circuit_breaker.record_failure()
532533

534+
retryable, retry_after = classify_failure(e)
535+
533536
with self._metrics_lock:
534537
self._metrics.errors += 1
535-
if attempt <= self.options.max_retries:
538+
if retryable and attempt <= self.options.max_retries:
536539
self._metrics.retries += 1
537540

541+
# Permanent client errors (4xx except 408/429) will not become
542+
# valid by retrying: drop the batch after the first attempt.
543+
if not retryable:
544+
if self.options.debug:
545+
print(f"[LogTide] Non-retryable error, dropping batch: {e}")
546+
with self._metrics_lock:
547+
self._metrics.logs_dropped += len(logs)
548+
break
549+
538550
if attempt > self.options.max_retries:
539551
if self.options.debug:
540552
print(f"[LogTide] Failed to send logs after {attempt} attempts: {e}")
@@ -545,7 +557,8 @@ async def _send_logs_with_retry(self, logs: list[LogEntry]) -> None:
545557
if self.options.debug:
546558
print(f"[LogTide] Retry {attempt}/{self.options.max_retries} in {delay}s")
547559

548-
await asyncio.sleep(delay)
560+
# A server-provided Retry-After overrides the computed backoff
561+
await asyncio.sleep(retry_after if retry_after is not None else delay)
549562
delay *= 2
550563

551564
if self._circuit_breaker.state == CircuitState.OPEN and state_before != CircuitState.OPEN:

logtide_sdk/client.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from logtide_sdk.enums import CircuitState, LogLevel
1818
from logtide_sdk.exceptions import CircuitBreakerOpenError
1919
from logtide_sdk.json_encoder import logtide_json_dumps
20+
from logtide_sdk._retry import classify_failure
2021
from logtide_sdk._version import SDK_NAME, VERSION
2122
from logtide_sdk.scope import get_current_scope
2223
from logtide_sdk.tracecontext import generate_trace_id
@@ -712,11 +713,22 @@ def _send_logs_with_retry(self, log_entries: list[LogEntry]) -> None:
712713
attempt += 1
713714
self._circuit_breaker.record_failure()
714715

716+
retryable, retry_after = classify_failure(e)
717+
715718
with self._metrics_lock:
716719
self._metrics.errors += 1
717-
if attempt <= self.options.max_retries:
720+
if retryable and attempt <= self.options.max_retries:
718721
self._metrics.retries += 1
719722

723+
# Permanent client errors (4xx except 408/429) will not become
724+
# valid by retrying: drop the batch after the first attempt.
725+
if not retryable:
726+
if self.options.debug:
727+
print(f"[LogTide] Non-retryable error, dropping batch: {e}")
728+
with self._metrics_lock:
729+
self._metrics.logs_dropped += len(log_entries)
730+
break
731+
720732
if attempt > self.options.max_retries:
721733
if self.options.debug:
722734
print(f"[LogTide] Failed to send logs after {attempt} attempts: {e}")
@@ -734,7 +746,8 @@ def _send_logs_with_retry(self, log_entries: list[LogEntry]) -> None:
734746
self._metrics.logs_dropped += len(log_entries)
735747
break
736748

737-
time.sleep(delay)
749+
# A server-provided Retry-After overrides the computed backoff
750+
time.sleep(retry_after if retry_after is not None else delay)
738751
delay *= 2
739752

740753
# Only count a trip when the circuit *transitions* to OPEN during this call,

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "logtide-sdk"
7-
version = "0.9.4"
7+
version = "0.9.5"
88
description = "Official Python SDK for LogTide - Self-hosted log management with async client, logging integration, batching, retry, circuit breaker, and middleware"
99
readme = "README.md"
1010
license = { text = "MIT" }

tests/test_retry_policy.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""Retry policy per spec 002 §6 (conformance C07/C08/C09).
2+
3+
Retryable: network errors, 408, 429, 5xx. Permanent client errors (other
4+
4xx) are dropped after the first attempt. A Retry-After header overrides the
5+
computed backoff delay.
6+
"""
7+
8+
from unittest.mock import Mock
9+
10+
import pytest
11+
import requests
12+
13+
from logtide_sdk import ClientOptions, LogTideClient
14+
from logtide_sdk.models import LogEntry
15+
from logtide_sdk.enums import LogLevel
16+
17+
18+
def http_error(status: int, headers: dict | None = None) -> requests.HTTPError:
19+
response = Mock(spec=requests.Response)
20+
response.status_code = status
21+
response.headers = headers or {}
22+
return requests.HTTPError(f"HTTP {status}", response=response)
23+
24+
25+
@pytest.fixture
26+
def client():
27+
c = LogTideClient(
28+
ClientOptions(
29+
api_url="http://localhost:8080",
30+
api_key="lp_k",
31+
service="svc",
32+
retry_delay_ms=1,
33+
)
34+
)
35+
yield c
36+
c._closed = True
37+
38+
39+
def entry() -> LogEntry:
40+
return LogEntry(service="svc", level=LogLevel.INFO, message="m")
41+
42+
43+
def test_no_retry_on_permanent_4xx(client, mocker):
44+
send = mocker.patch.object(client, "_send_logs", side_effect=http_error(400))
45+
sleep = mocker.patch("logtide_sdk.client.time.sleep")
46+
47+
client._send_logs_with_retry([entry()])
48+
49+
assert send.call_count == 1, "400 must not be retried"
50+
sleep.assert_not_called()
51+
metrics = client.get_metrics()
52+
assert metrics.logs_dropped == 1
53+
assert metrics.retries == 0
54+
55+
56+
def test_no_retry_on_401(client, mocker):
57+
send = mocker.patch.object(client, "_send_logs", side_effect=http_error(401))
58+
client._send_logs_with_retry([entry()])
59+
assert send.call_count == 1
60+
61+
62+
def test_retries_on_5xx_then_succeeds(client, mocker):
63+
send = mocker.patch.object(
64+
client, "_send_logs", side_effect=[http_error(500), None]
65+
)
66+
mocker.patch("logtide_sdk.client.time.sleep")
67+
68+
client._send_logs_with_retry([entry()])
69+
70+
assert send.call_count == 2
71+
metrics = client.get_metrics()
72+
assert metrics.logs_sent == 1
73+
assert metrics.retries == 1
74+
75+
76+
def test_retries_on_408_and_429(client, mocker):
77+
send = mocker.patch.object(
78+
client, "_send_logs", side_effect=[http_error(408), http_error(429), None]
79+
)
80+
mocker.patch("logtide_sdk.client.time.sleep")
81+
client._send_logs_with_retry([entry()])
82+
assert send.call_count == 3
83+
84+
85+
def test_retries_on_network_error(client, mocker):
86+
send = mocker.patch.object(
87+
client,
88+
"_send_logs",
89+
side_effect=[requests.ConnectionError("refused"), None],
90+
)
91+
mocker.patch("logtide_sdk.client.time.sleep")
92+
client._send_logs_with_retry([entry()])
93+
assert send.call_count == 2
94+
95+
96+
def test_retry_after_overrides_backoff(client, mocker):
97+
mocker.patch.object(
98+
client,
99+
"_send_logs",
100+
side_effect=[http_error(429, {"Retry-After": "7"}), None],
101+
)
102+
sleep = mocker.patch("logtide_sdk.client.time.sleep")
103+
104+
client._send_logs_with_retry([entry()])
105+
106+
sleep.assert_called_once_with(7.0)
107+
108+
109+
@pytest.mark.asyncio
110+
async def test_async_no_retry_on_permanent_4xx(mocker):
111+
import aiohttp
112+
113+
from logtide_sdk.async_client import AsyncLogTideClient
114+
115+
client = AsyncLogTideClient(
116+
ClientOptions(api_url="http://localhost:8080", api_key="lp_k", service="svc")
117+
)
118+
try:
119+
error = aiohttp.ClientResponseError(
120+
request_info=Mock(), history=(), status=403, headers={}
121+
)
122+
send = mocker.patch.object(client, "_send_logs", side_effect=error)
123+
await client._send_logs_with_retry([entry()])
124+
assert send.call_count == 1, "403 must not be retried"
125+
finally:
126+
client._closed = True
127+
128+
129+
@pytest.mark.asyncio
130+
async def test_async_retry_after_overrides_backoff(mocker):
131+
import aiohttp
132+
133+
from logtide_sdk.async_client import AsyncLogTideClient
134+
135+
client = AsyncLogTideClient(
136+
ClientOptions(
137+
api_url="http://localhost:8080", api_key="lp_k", service="svc", retry_delay_ms=1
138+
)
139+
)
140+
try:
141+
error = aiohttp.ClientResponseError(
142+
request_info=Mock(), history=(), status=429, headers={"Retry-After": "5"}
143+
)
144+
mocker.patch.object(client, "_send_logs", side_effect=[error, None])
145+
sleep = mocker.patch("logtide_sdk.async_client.asyncio.sleep")
146+
await client._send_logs_with_retry([entry()])
147+
sleep.assert_called_once_with(5.0)
148+
finally:
149+
client._closed = True

0 commit comments

Comments
 (0)