Skip to content

Commit 099f461

Browse files
authored
Improve HTTP response handling and retry logic (#520)
Unified HTTP response handling and retry behavior Brings analytics-python in line with the TAPI HTTP agreements and with analytics-java 3.5.5. Upgrade note: proxies and header allowlists This release sends two request headers that earlier versions did not: Authorization (HTTP Basic, carrying the write key) and X-Retry-Count (on retries only). If traffic to Segment passes through a proxy, gateway or WAF that allowlists request headers, add both before upgrading or uploads will be rejected. The equivalent change in the already-released analytics-next had exactly this effect on customers. Headers - Send the write key as Authorization: Basic base64("<writeKey>:"). It remains in the request body, so no server-side change is required. OAuth deployments continue to send Bearer and are unaffected. - Send X-Retry-Count on retries, omitted on the first attempt, so the server can distinguish a retry from a first try. Retry behavior - Retryable statuses: 408, 410, 429, 460 and 5xx except 501 and 505. 511 asks the client to re-authenticate, so it is retried only when an OauthManager is configured, clearing the stale token first. - Retry-After is honored on every retryable status rather than 429 alone, which brings 529 in through the generic 5xx rule. Numeric seconds and the RFC 7231 HTTP-date formats are both accepted, capped at 300s. Naive datetimes are read as UTC; servers emit the RFC 5322 "-0000" offset, and reading it as local time made a future date parse
1 parent 9955ae7 commit 099f461

15 files changed

Lines changed: 1564 additions & 76 deletions

‎HISTORY.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,22 @@
1+
# Unreleased
2+
### Upgrade note: new request headers and proxy allowlists
3+
This release sends two request headers that earlier versions did not:
4+
`Authorization` (HTTP Basic, carrying your write key) and `X-Retry-Count`
5+
(on retries only). If your traffic to Segment goes through a proxy, gateway
6+
or WAF that allowlists request headers, add both before upgrading or uploads
7+
will be rejected.
8+
9+
- Send the write key as an `Authorization: Basic` header. It is still included in the request body, so no server-side change is required. OAuth deployments continue to send `Authorization: Bearer` and are unaffected.
10+
- Send `X-Retry-Count` on retries, so the server can distinguish a retry from a first attempt. Omitted on the first attempt.
11+
- Unified retry handling: 429, 408, 410, 460 and 5xx (except 501 and 505) are retried. `Retry-After` is honoured on all of them, not just 429, which brings 529 in through the generic 5xx rule. 511 asks the client to re-authenticate, so it is retried only when an `oauth_manager` is configured and is dropped otherwise.
12+
- `Retry-After` accepts numeric seconds and the RFC 7231 HTTP-date formats, capped at 300s.
13+
- Rate-limited retries are bounded by elapsed time rather than counted against the retry limit, so a long `Retry-After` no longer exhausts the budget.
14+
- New client options `max_total_backoff_duration` and `max_rate_limit_duration` (default 12 hours each) bound the two waits.
15+
- Only 2xx responses count as a successful upload. A 3xx is now reported as a failed upload rather than silently treated as delivered. It is not retried: a redirect the HTTP client already declined to follow will not succeed on a retry. The Segment endpoint does not redirect, so this only affects custom `host` values.
16+
- Backoff waits are interruptible, so `shutdown()` no longer blocks for the full delay.
17+
- Retry timing uses a monotonic clock, so a system clock change cannot stretch or collapse a backoff.
18+
- Fix a `queue.task_done()` leak that could leave `flush()` waiting forever when a batch was re-queued during shutdown.
19+
120
# 2.3.6 / 2026-4-7
221
- Update and widen PyJWT version to address security issue
322

‎e2e-cli/README.md‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,35 @@
22

33
E2E test CLI for the [analytics-python](https://github.com/segmentio/analytics-python) SDK. Accepts a JSON input describing events and SDK configuration, sends them through the real SDK, and outputs results as JSON.
44

5-
## Setup
5+
## Running E2E tests
6+
7+
### With devbox (recommended)
8+
9+
```bash
10+
# From repo root — activates Python 3.12 and installs deps automatically
11+
devbox shell
12+
13+
# Then from e2e-cli dir:
14+
./run-e2e.sh
15+
```
16+
17+
### Without devbox
18+
19+
Requires Python 3.9+ and Node.js 18+. Using a virtualenv is strongly recommended since macOS system Python is externally managed.
620

721
```bash
8-
cd e2e-cli
922
python3 -m venv .venv
1023
source .venv/bin/activate
11-
pip install -r requirements.txt
12-
pip install -e .
24+
./run-e2e.sh
25+
```
26+
27+
### Override sdk-e2e-tests location
28+
29+
```bash
30+
E2E_TESTS_DIR=../my-e2e-tests ./run-e2e.sh
1331
```
1432

15-
## Usage
33+
## Manual CLI usage
1634

1735
```bash
1836
e2e-cli --input '{"writeKey":"...", ...}'
@@ -21,7 +39,7 @@ e2e-cli --input '{"writeKey":"...", ...}'
2139
Or without installing:
2240

2341
```bash
24-
python3 -m src.cli --input '{"writeKey":"...", ...}'
42+
python3 src/cli.py --input '{"writeKey":"...", ...}'
2543
```
2644

2745
## Input Format

‎e2e-cli/e2e-config.json‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
{
22
"sdk": "python",
3-
"test_suites": "basic",
3+
"test_suites": "basic,retry",
44
"auto_settings": false,
55
"patch": null,
6-
"env": {}
6+
"env": {
7+
"AUTH_HEADER": "true"
8+
}
79
}

‎e2e-cli/run-e2e.sh‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
#
33
# Run E2E tests for analytics-python
44
#
5-
# Prerequisites: Python 3, pip, Node.js 18+
5+
# Prerequisites: Node.js 18+ and one of:
6+
# - devbox (recommended): run `devbox shell` first, then ./run-e2e.sh
7+
# - Python 3.9+ with a virtualenv already activated
68
#
79
# Usage:
810
# ./run-e2e.sh [extra args passed to run-tests.sh]
@@ -17,15 +19,25 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
1719
SDK_ROOT="$SCRIPT_DIR/.."
1820
E2E_DIR="${E2E_TESTS_DIR:-$SDK_ROOT/../sdk-e2e-tests}"
1921

22+
# Resolve python and pip — prefer activated venv/devbox python, fall back to python3
23+
PYTHON="${PYTHON:-$(command -v python || command -v python3)}"
24+
PIP="$PYTHON -m pip"
25+
26+
if [[ -z "$PYTHON" ]]; then
27+
echo "Error: Python not found. Run 'devbox shell' first or activate a virtualenv."
28+
exit 1
29+
fi
30+
2031
echo "=== Building analytics-python e2e-cli ==="
32+
echo "Using Python: $PYTHON"
2133

2234
# Install SDK
2335
cd "$SDK_ROOT"
24-
pip install -e .
36+
$PIP install -e . -q
2537

2638
# Install e2e-cli
2739
cd "$SCRIPT_DIR"
28-
pip install -e .
40+
$PIP install -e . -q
2941

3042
echo ""
3143

‎e2e-cli/src/cli.py‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ def run(input_json: str, debug: bool):
7171
"""Run the E2E CLI with the given input configuration."""
7272
logger = setup_logging(debug)
7373
output = {"success": False, "sentBatches": 0, "error": None}
74+
delivery_errors = []
75+
76+
def on_error(error, batch):
77+
delivery_errors.append(str(error))
7478

7579
try:
7680
data = json.loads(input_json)
@@ -97,6 +101,7 @@ def run(input_json: str, debug: bool):
97101
write_key=write_key,
98102
host=api_host,
99103
debug=debug,
104+
on_error=on_error,
100105
upload_size=flush_at,
101106
upload_interval=flush_interval,
102107
max_retries=max_retries,
@@ -121,10 +126,12 @@ def run(input_json: str, debug: bool):
121126
client.flush()
122127
client.join()
123128

124-
output["success"] = True
125-
# Note: We don't have easy access to batch count from the SDK internals
126-
# This could be enhanced if needed
127-
output["sentBatches"] = 1 # Placeholder
129+
if delivery_errors:
130+
output["success"] = False
131+
output["error"] = delivery_errors[0]
132+
else:
133+
output["success"] = True
134+
output["sentBatches"] = 1
128135

129136
except json.JSONDecodeError as e:
130137
output["error"] = f"Invalid JSON input: {e}"

‎pyproject.toml‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ classifiers = [
2626
]
2727
dependencies = [
2828
"requests~=2.7",
29-
"backoff~=2.1",
3029
"python-dateutil~=2.2",
3130
"PyJWT[crypto]~=2.12",
3231
]

‎requirements.txt‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
backoff==2.2.1
21
cryptography==44.0.0
32
flake8==7.1.1
43
mock==2.0.0

‎segment/analytics/client.py‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ class DefaultConfig(object):
3030
gzip = False
3131
timeout = 15
3232
max_retries = 10
33+
max_total_backoff_duration = 43200
34+
max_rate_limit_duration = 43200
3335
proxies = None
3436
thread = 1
3537
upload_interval = 0.5
@@ -65,9 +67,16 @@ def __init__(
6567
oauth_key_id=DefaultConfig.oauth_key_id,
6668
oauth_auth_server=DefaultConfig.oauth_auth_server,
6769
oauth_scope=DefaultConfig.oauth_scope,
70+
max_total_backoff_duration=DefaultConfig.max_total_backoff_duration,
71+
max_rate_limit_duration=DefaultConfig.max_rate_limit_duration,
6872
):
6973
require("write_key", write_key, str)
7074

75+
if max_total_backoff_duration is None or max_total_backoff_duration < 0:
76+
raise ValueError("max_total_backoff_duration must be a non-negative number")
77+
if max_rate_limit_duration is None or max_rate_limit_duration < 0:
78+
raise ValueError("max_rate_limit_duration must be a non-negative number")
79+
7180
self.queue = queue.Queue(max_queue_size)
7281
self.write_key = write_key
7382
self.on_error = on_error
@@ -78,6 +87,8 @@ def __init__(
7887
self.gzip = gzip
7988
self.timeout = timeout
8089
self.proxies = proxies
90+
self.max_total_backoff_duration = max_total_backoff_duration
91+
self.max_rate_limit_duration = max_rate_limit_duration
8192
self.oauth_manager = None
8293
if oauth_client_id and oauth_client_key and oauth_key_id:
8394
self.oauth_manager = OauthManager(
@@ -118,6 +129,8 @@ def __init__(
118129
timeout=timeout,
119130
proxies=proxies,
120131
oauth_manager=self.oauth_manager,
132+
max_total_backoff_duration=max_total_backoff_duration,
133+
max_rate_limit_duration=max_rate_limit_duration,
121134
)
122135
self.consumers.append(consumer)
123136

@@ -345,7 +358,11 @@ def _enqueue(self, msg):
345358
return False, msg
346359

347360
def flush(self):
348-
"""Forces a flush from the internal queue to the server"""
361+
"""Forces a flush from the internal queue to the server.
362+
363+
Warning: if the consumer is currently rate-limited, this call will
364+
block until the rate limit clears or max_rate_limit_duration elapses.
365+
"""
349366
queue = self.queue
350367
size = queue.qsize()
351368
queue.join()

0 commit comments

Comments
 (0)