posthoganalytics 7.0.0__py3-none-any.whl → 7.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- posthoganalytics/__init__.py +10 -0
- posthoganalytics/ai/gemini/__init__.py +3 -0
- posthoganalytics/ai/gemini/gemini.py +1 -1
- posthoganalytics/ai/gemini/gemini_async.py +423 -0
- posthoganalytics/ai/gemini/gemini_converter.py +87 -21
- posthoganalytics/ai/openai/openai_converter.py +6 -0
- posthoganalytics/ai/sanitization.py +27 -5
- posthoganalytics/client.py +216 -49
- posthoganalytics/exception_utils.py +71 -15
- posthoganalytics/flag_definition_cache.py +127 -0
- posthoganalytics/request.py +203 -23
- posthoganalytics/test/test_client.py +121 -21
- posthoganalytics/test/test_exception_capture.py +120 -2
- posthoganalytics/test/test_feature_flag_result.py +441 -2
- posthoganalytics/test/test_feature_flags.py +157 -18
- posthoganalytics/test/test_flag_definition_cache.py +612 -0
- posthoganalytics/test/test_request.py +536 -0
- posthoganalytics/test/test_utils.py +4 -1
- posthoganalytics/types.py +40 -0
- posthoganalytics/version.py +1 -1
- {posthoganalytics-7.0.0.dist-info → posthoganalytics-7.4.0.dist-info}/METADATA +2 -1
- {posthoganalytics-7.0.0.dist-info → posthoganalytics-7.4.0.dist-info}/RECORD +25 -22
- {posthoganalytics-7.0.0.dist-info → posthoganalytics-7.4.0.dist-info}/WHEEL +0 -0
- {posthoganalytics-7.0.0.dist-info → posthoganalytics-7.4.0.dist-info}/licenses/LICENSE +0 -0
- {posthoganalytics-7.0.0.dist-info → posthoganalytics-7.4.0.dist-info}/top_level.txt +0 -0
|
@@ -6,16 +6,60 @@ import mock
|
|
|
6
6
|
import pytest
|
|
7
7
|
import requests
|
|
8
8
|
|
|
9
|
+
import posthog.request as request_module
|
|
9
10
|
from posthoganalytics.request import (
|
|
11
|
+
APIError,
|
|
10
12
|
DatetimeSerializer,
|
|
13
|
+
GetResponse,
|
|
14
|
+
KEEP_ALIVE_SOCKET_OPTIONS,
|
|
11
15
|
QuotaLimitError,
|
|
16
|
+
_mask_tokens_in_url,
|
|
12
17
|
batch_post,
|
|
13
18
|
decide,
|
|
14
19
|
determine_server_host,
|
|
20
|
+
disable_connection_reuse,
|
|
21
|
+
enable_keep_alive,
|
|
22
|
+
flags,
|
|
23
|
+
get,
|
|
24
|
+
set_socket_options,
|
|
15
25
|
)
|
|
16
26
|
from posthoganalytics.test.test_utils import TEST_API_KEY
|
|
17
27
|
|
|
18
28
|
|
|
29
|
+
@pytest.mark.parametrize(
|
|
30
|
+
"url, expected",
|
|
31
|
+
[
|
|
32
|
+
# Token with params after - masks keeping first 10 chars
|
|
33
|
+
(
|
|
34
|
+
"https://example.com/api/flags?token=phc_abc123xyz789&send_cohorts",
|
|
35
|
+
"https://example.com/api/flags?token=phc_abc123...&send_cohorts",
|
|
36
|
+
),
|
|
37
|
+
# Token at end of URL
|
|
38
|
+
(
|
|
39
|
+
"https://example.com/api/flags?token=phc_abc123xyz789",
|
|
40
|
+
"https://example.com/api/flags?token=phc_abc123...",
|
|
41
|
+
),
|
|
42
|
+
# No token - unchanged
|
|
43
|
+
(
|
|
44
|
+
"https://example.com/api/flags?other=value",
|
|
45
|
+
"https://example.com/api/flags?other=value",
|
|
46
|
+
),
|
|
47
|
+
# Short token (<10 chars) - unchanged
|
|
48
|
+
(
|
|
49
|
+
"https://example.com/api/flags?token=short",
|
|
50
|
+
"https://example.com/api/flags?token=short",
|
|
51
|
+
),
|
|
52
|
+
# Exactly 10 char token - gets ellipsis
|
|
53
|
+
(
|
|
54
|
+
"https://example.com/api/flags?token=1234567890",
|
|
55
|
+
"https://example.com/api/flags?token=1234567890...",
|
|
56
|
+
),
|
|
57
|
+
],
|
|
58
|
+
)
|
|
59
|
+
def test_mask_tokens_in_url(url, expected):
|
|
60
|
+
assert _mask_tokens_in_url(url) == expected
|
|
61
|
+
|
|
62
|
+
|
|
19
63
|
class TestRequests(unittest.TestCase):
|
|
20
64
|
def test_valid_request(self):
|
|
21
65
|
res = batch_post(
|
|
@@ -107,6 +151,184 @@ class TestRequests(unittest.TestCase):
|
|
|
107
151
|
self.assertEqual(response["featureFlags"], {"flag1": True})
|
|
108
152
|
|
|
109
153
|
|
|
154
|
+
class TestGet(unittest.TestCase):
|
|
155
|
+
"""Unit tests for the get() function HTTP-level behavior."""
|
|
156
|
+
|
|
157
|
+
@mock.patch("posthog.request._session.get")
|
|
158
|
+
def test_get_returns_data_and_etag(self, mock_get):
|
|
159
|
+
"""Test that get() returns GetResponse with data and etag from headers."""
|
|
160
|
+
mock_response = requests.Response()
|
|
161
|
+
mock_response.status_code = 200
|
|
162
|
+
mock_response.headers["ETag"] = '"abc123"'
|
|
163
|
+
mock_response._content = json.dumps({"flags": [{"key": "test-flag"}]}).encode(
|
|
164
|
+
"utf-8"
|
|
165
|
+
)
|
|
166
|
+
mock_get.return_value = mock_response
|
|
167
|
+
|
|
168
|
+
response = get("api_key", "/test-url", host="https://example.com")
|
|
169
|
+
|
|
170
|
+
self.assertIsInstance(response, GetResponse)
|
|
171
|
+
self.assertEqual(response.data, {"flags": [{"key": "test-flag"}]})
|
|
172
|
+
self.assertEqual(response.etag, '"abc123"')
|
|
173
|
+
self.assertFalse(response.not_modified)
|
|
174
|
+
|
|
175
|
+
@mock.patch("posthog.request._session.get")
|
|
176
|
+
def test_get_sends_if_none_match_header_when_etag_provided(self, mock_get):
|
|
177
|
+
"""Test that If-None-Match header is sent when etag parameter is provided."""
|
|
178
|
+
mock_response = requests.Response()
|
|
179
|
+
mock_response.status_code = 200
|
|
180
|
+
mock_response.headers["ETag"] = '"new-etag"'
|
|
181
|
+
mock_response._content = json.dumps({"flags": []}).encode("utf-8")
|
|
182
|
+
mock_get.return_value = mock_response
|
|
183
|
+
|
|
184
|
+
get("api_key", "/test-url", host="https://example.com", etag='"previous-etag"')
|
|
185
|
+
|
|
186
|
+
call_kwargs = mock_get.call_args[1]
|
|
187
|
+
self.assertEqual(call_kwargs["headers"]["If-None-Match"], '"previous-etag"')
|
|
188
|
+
|
|
189
|
+
@mock.patch("posthog.request._session.get")
|
|
190
|
+
def test_get_does_not_send_if_none_match_when_no_etag(self, mock_get):
|
|
191
|
+
"""Test that If-None-Match header is not sent when no etag provided."""
|
|
192
|
+
mock_response = requests.Response()
|
|
193
|
+
mock_response.status_code = 200
|
|
194
|
+
mock_response._content = json.dumps({"flags": []}).encode("utf-8")
|
|
195
|
+
mock_get.return_value = mock_response
|
|
196
|
+
|
|
197
|
+
get("api_key", "/test-url", host="https://example.com")
|
|
198
|
+
|
|
199
|
+
call_kwargs = mock_get.call_args[1]
|
|
200
|
+
self.assertNotIn("If-None-Match", call_kwargs["headers"])
|
|
201
|
+
|
|
202
|
+
@mock.patch("posthog.request._session.get")
|
|
203
|
+
def test_get_handles_304_not_modified(self, mock_get):
|
|
204
|
+
"""Test that 304 Not Modified response returns not_modified=True with no data."""
|
|
205
|
+
mock_response = requests.Response()
|
|
206
|
+
mock_response.status_code = 304
|
|
207
|
+
mock_response.headers["ETag"] = '"unchanged-etag"'
|
|
208
|
+
mock_get.return_value = mock_response
|
|
209
|
+
|
|
210
|
+
response = get(
|
|
211
|
+
"api_key", "/test-url", host="https://example.com", etag='"unchanged-etag"'
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
self.assertIsInstance(response, GetResponse)
|
|
215
|
+
self.assertIsNone(response.data)
|
|
216
|
+
self.assertEqual(response.etag, '"unchanged-etag"')
|
|
217
|
+
self.assertTrue(response.not_modified)
|
|
218
|
+
|
|
219
|
+
@mock.patch("posthog.request._session.get")
|
|
220
|
+
def test_get_304_without_etag_header_uses_request_etag(self, mock_get):
|
|
221
|
+
"""Test that 304 response without ETag header falls back to request etag."""
|
|
222
|
+
mock_response = requests.Response()
|
|
223
|
+
mock_response.status_code = 304
|
|
224
|
+
# Server doesn't return ETag header on 304
|
|
225
|
+
mock_get.return_value = mock_response
|
|
226
|
+
|
|
227
|
+
response = get(
|
|
228
|
+
"api_key", "/test-url", host="https://example.com", etag='"original-etag"'
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
self.assertTrue(response.not_modified)
|
|
232
|
+
self.assertEqual(response.etag, '"original-etag"')
|
|
233
|
+
|
|
234
|
+
@mock.patch("posthog.request._session.get")
|
|
235
|
+
def test_get_200_without_etag_header(self, mock_get):
|
|
236
|
+
"""Test that 200 response without ETag header returns None for etag."""
|
|
237
|
+
mock_response = requests.Response()
|
|
238
|
+
mock_response.status_code = 200
|
|
239
|
+
mock_response._content = json.dumps({"flags": []}).encode("utf-8")
|
|
240
|
+
# No ETag header
|
|
241
|
+
mock_get.return_value = mock_response
|
|
242
|
+
|
|
243
|
+
response = get("api_key", "/test-url", host="https://example.com")
|
|
244
|
+
|
|
245
|
+
self.assertFalse(response.not_modified)
|
|
246
|
+
self.assertIsNone(response.etag)
|
|
247
|
+
self.assertEqual(response.data, {"flags": []})
|
|
248
|
+
|
|
249
|
+
@mock.patch("posthog.request._session.get")
|
|
250
|
+
def test_get_error_response_raises_api_error(self, mock_get):
|
|
251
|
+
"""Test that error responses raise APIError."""
|
|
252
|
+
mock_response = requests.Response()
|
|
253
|
+
mock_response.status_code = 401
|
|
254
|
+
mock_response._content = json.dumps({"detail": "Unauthorized"}).encode("utf-8")
|
|
255
|
+
mock_get.return_value = mock_response
|
|
256
|
+
|
|
257
|
+
with self.assertRaises(APIError) as ctx:
|
|
258
|
+
get("bad_key", "/test-url", host="https://example.com")
|
|
259
|
+
|
|
260
|
+
self.assertEqual(ctx.exception.status, 401)
|
|
261
|
+
self.assertEqual(ctx.exception.message, "Unauthorized")
|
|
262
|
+
|
|
263
|
+
@mock.patch("posthog.request._session.get")
|
|
264
|
+
def test_get_sends_authorization_header(self, mock_get):
|
|
265
|
+
"""Test that Authorization header is sent with Bearer token."""
|
|
266
|
+
mock_response = requests.Response()
|
|
267
|
+
mock_response.status_code = 200
|
|
268
|
+
mock_response._content = json.dumps({}).encode("utf-8")
|
|
269
|
+
mock_get.return_value = mock_response
|
|
270
|
+
|
|
271
|
+
get("my-api-key", "/test-url", host="https://example.com")
|
|
272
|
+
|
|
273
|
+
call_kwargs = mock_get.call_args[1]
|
|
274
|
+
self.assertEqual(call_kwargs["headers"]["Authorization"], "Bearer my-api-key")
|
|
275
|
+
|
|
276
|
+
@mock.patch("posthog.request._session.get")
|
|
277
|
+
def test_get_sends_user_agent_header(self, mock_get):
|
|
278
|
+
"""Test that User-Agent header is sent."""
|
|
279
|
+
mock_response = requests.Response()
|
|
280
|
+
mock_response.status_code = 200
|
|
281
|
+
mock_response._content = json.dumps({}).encode("utf-8")
|
|
282
|
+
mock_get.return_value = mock_response
|
|
283
|
+
|
|
284
|
+
get("api_key", "/test-url", host="https://example.com")
|
|
285
|
+
|
|
286
|
+
call_kwargs = mock_get.call_args[1]
|
|
287
|
+
self.assertIn("User-Agent", call_kwargs["headers"])
|
|
288
|
+
self.assertTrue(
|
|
289
|
+
call_kwargs["headers"]["User-Agent"].startswith("posthog-python/")
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
@mock.patch("posthog.request._session.get")
|
|
293
|
+
def test_get_passes_timeout(self, mock_get):
|
|
294
|
+
"""Test that timeout parameter is passed to the request."""
|
|
295
|
+
mock_response = requests.Response()
|
|
296
|
+
mock_response.status_code = 200
|
|
297
|
+
mock_response._content = json.dumps({}).encode("utf-8")
|
|
298
|
+
mock_get.return_value = mock_response
|
|
299
|
+
|
|
300
|
+
get("api_key", "/test-url", host="https://example.com", timeout=30)
|
|
301
|
+
|
|
302
|
+
call_kwargs = mock_get.call_args[1]
|
|
303
|
+
self.assertEqual(call_kwargs["timeout"], 30)
|
|
304
|
+
|
|
305
|
+
@mock.patch("posthog.request._session.get")
|
|
306
|
+
def test_get_constructs_full_url(self, mock_get):
|
|
307
|
+
"""Test that host and url are combined correctly."""
|
|
308
|
+
mock_response = requests.Response()
|
|
309
|
+
mock_response.status_code = 200
|
|
310
|
+
mock_response._content = json.dumps({}).encode("utf-8")
|
|
311
|
+
mock_get.return_value = mock_response
|
|
312
|
+
|
|
313
|
+
get("api_key", "/api/flags", host="https://example.com")
|
|
314
|
+
|
|
315
|
+
call_args = mock_get.call_args[0]
|
|
316
|
+
self.assertEqual(call_args[0], "https://example.com/api/flags")
|
|
317
|
+
|
|
318
|
+
@mock.patch("posthog.request._session.get")
|
|
319
|
+
def test_get_removes_trailing_slash_from_host(self, mock_get):
|
|
320
|
+
"""Test that trailing slash is removed from host."""
|
|
321
|
+
mock_response = requests.Response()
|
|
322
|
+
mock_response.status_code = 200
|
|
323
|
+
mock_response._content = json.dumps({}).encode("utf-8")
|
|
324
|
+
mock_get.return_value = mock_response
|
|
325
|
+
|
|
326
|
+
get("api_key", "/api/flags", host="https://example.com/")
|
|
327
|
+
|
|
328
|
+
call_args = mock_get.call_args[0]
|
|
329
|
+
self.assertEqual(call_args[0], "https://example.com/api/flags")
|
|
330
|
+
|
|
331
|
+
|
|
110
332
|
@pytest.mark.parametrize(
|
|
111
333
|
"host, expected",
|
|
112
334
|
[
|
|
@@ -128,3 +350,317 @@ class TestRequests(unittest.TestCase):
|
|
|
128
350
|
)
|
|
129
351
|
def test_routing_to_custom_host(host, expected):
|
|
130
352
|
assert determine_server_host(host) == expected
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def test_enable_keep_alive_sets_socket_options():
|
|
356
|
+
try:
|
|
357
|
+
enable_keep_alive()
|
|
358
|
+
from posthoganalytics.request import _session
|
|
359
|
+
|
|
360
|
+
adapter = _session.get_adapter("https://example.com")
|
|
361
|
+
assert adapter.socket_options == KEEP_ALIVE_SOCKET_OPTIONS
|
|
362
|
+
finally:
|
|
363
|
+
set_socket_options(None)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def test_set_socket_options_clears_with_none():
|
|
367
|
+
try:
|
|
368
|
+
enable_keep_alive()
|
|
369
|
+
set_socket_options(None)
|
|
370
|
+
from posthoganalytics.request import _session
|
|
371
|
+
|
|
372
|
+
adapter = _session.get_adapter("https://example.com")
|
|
373
|
+
assert adapter.socket_options is None
|
|
374
|
+
finally:
|
|
375
|
+
set_socket_options(None)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def test_disable_connection_reuse_creates_fresh_sessions():
|
|
379
|
+
try:
|
|
380
|
+
disable_connection_reuse()
|
|
381
|
+
session1 = request_module._get_session()
|
|
382
|
+
session2 = request_module._get_session()
|
|
383
|
+
assert session1 is not session2
|
|
384
|
+
finally:
|
|
385
|
+
request_module._pooling_enabled = True
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def test_set_socket_options_is_idempotent():
|
|
389
|
+
try:
|
|
390
|
+
enable_keep_alive()
|
|
391
|
+
session1 = request_module._session
|
|
392
|
+
enable_keep_alive()
|
|
393
|
+
session2 = request_module._session
|
|
394
|
+
assert session1 is session2
|
|
395
|
+
finally:
|
|
396
|
+
set_socket_options(None)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class TestFlagsSession(unittest.TestCase):
|
|
400
|
+
"""Tests for flags session configuration."""
|
|
401
|
+
|
|
402
|
+
def test_retry_status_forcelist_excludes_rate_limits(self):
|
|
403
|
+
"""Verify 429 (rate limit) is NOT retried - need to wait, not hammer."""
|
|
404
|
+
from posthoganalytics.request import RETRY_STATUS_FORCELIST
|
|
405
|
+
|
|
406
|
+
self.assertNotIn(429, RETRY_STATUS_FORCELIST)
|
|
407
|
+
|
|
408
|
+
def test_retry_status_forcelist_excludes_quota_errors(self):
|
|
409
|
+
"""Verify 402 (payment required/quota) is NOT retried - won't resolve."""
|
|
410
|
+
from posthoganalytics.request import RETRY_STATUS_FORCELIST
|
|
411
|
+
|
|
412
|
+
self.assertNotIn(402, RETRY_STATUS_FORCELIST)
|
|
413
|
+
|
|
414
|
+
@mock.patch("posthog.request._get_flags_session")
|
|
415
|
+
def test_flags_uses_flags_session(self, mock_get_flags_session):
|
|
416
|
+
"""flags() uses the dedicated flags session, not the general session."""
|
|
417
|
+
mock_response = requests.Response()
|
|
418
|
+
mock_response.status_code = 200
|
|
419
|
+
mock_response._content = json.dumps(
|
|
420
|
+
{
|
|
421
|
+
"featureFlags": {"test-flag": True},
|
|
422
|
+
"featureFlagPayloads": {},
|
|
423
|
+
"errorsWhileComputingFlags": False,
|
|
424
|
+
}
|
|
425
|
+
).encode("utf-8")
|
|
426
|
+
|
|
427
|
+
mock_session = mock.MagicMock()
|
|
428
|
+
mock_session.post.return_value = mock_response
|
|
429
|
+
mock_get_flags_session.return_value = mock_session
|
|
430
|
+
|
|
431
|
+
result = flags("test-key", "https://test.posthog.com", distinct_id="user123")
|
|
432
|
+
|
|
433
|
+
self.assertEqual(result["featureFlags"]["test-flag"], True)
|
|
434
|
+
mock_get_flags_session.assert_called_once()
|
|
435
|
+
mock_session.post.assert_called_once()
|
|
436
|
+
|
|
437
|
+
@mock.patch("posthog.request._get_flags_session")
|
|
438
|
+
def test_flags_no_retry_on_quota_limit(self, mock_get_flags_session):
|
|
439
|
+
"""flags() raises QuotaLimitError without retrying (at application level)."""
|
|
440
|
+
mock_response = requests.Response()
|
|
441
|
+
mock_response.status_code = 200
|
|
442
|
+
mock_response._content = json.dumps(
|
|
443
|
+
{
|
|
444
|
+
"quotaLimited": ["feature_flags"],
|
|
445
|
+
"featureFlags": {},
|
|
446
|
+
"featureFlagPayloads": {},
|
|
447
|
+
"errorsWhileComputingFlags": False,
|
|
448
|
+
}
|
|
449
|
+
).encode("utf-8")
|
|
450
|
+
|
|
451
|
+
mock_session = mock.MagicMock()
|
|
452
|
+
mock_session.post.return_value = mock_response
|
|
453
|
+
mock_get_flags_session.return_value = mock_session
|
|
454
|
+
|
|
455
|
+
with self.assertRaises(QuotaLimitError):
|
|
456
|
+
flags("test-key", "https://test.posthog.com", distinct_id="user123")
|
|
457
|
+
|
|
458
|
+
# QuotaLimitError is raised after response is received, not retried
|
|
459
|
+
self.assertEqual(mock_session.post.call_count, 1)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class TestFlagsSessionNetworkRetries(unittest.TestCase):
|
|
463
|
+
"""Tests for network failure retries in the flags session."""
|
|
464
|
+
|
|
465
|
+
def test_flags_session_retry_config_includes_connection_errors(self):
|
|
466
|
+
"""
|
|
467
|
+
Verify that the flags session is configured to retry on connection errors.
|
|
468
|
+
|
|
469
|
+
The urllib3 Retry adapter with connect=2 and read=2 automatically
|
|
470
|
+
retries on network-level failures (DNS failures, connection refused,
|
|
471
|
+
connection reset, etc.) up to 2 times each.
|
|
472
|
+
"""
|
|
473
|
+
from posthoganalytics.request import _build_flags_session
|
|
474
|
+
|
|
475
|
+
session = _build_flags_session()
|
|
476
|
+
|
|
477
|
+
# Get the adapter for https://
|
|
478
|
+
adapter = session.get_adapter("https://test.posthog.com")
|
|
479
|
+
|
|
480
|
+
# Verify retry configuration
|
|
481
|
+
retry = adapter.max_retries
|
|
482
|
+
self.assertEqual(retry.total, 2, "Should have 2 total retries")
|
|
483
|
+
self.assertEqual(retry.connect, 2, "Should retry connection errors twice")
|
|
484
|
+
self.assertEqual(retry.read, 2, "Should retry read errors twice")
|
|
485
|
+
self.assertIn("POST", retry.allowed_methods, "Should allow POST retries")
|
|
486
|
+
|
|
487
|
+
def test_flags_session_retries_on_server_errors(self):
|
|
488
|
+
"""
|
|
489
|
+
Verify that transient server errors (5xx) trigger retries.
|
|
490
|
+
|
|
491
|
+
This tests the status_forcelist configuration which specifies
|
|
492
|
+
which HTTP status codes should trigger a retry.
|
|
493
|
+
"""
|
|
494
|
+
from posthoganalytics.request import _build_flags_session, RETRY_STATUS_FORCELIST
|
|
495
|
+
|
|
496
|
+
session = _build_flags_session()
|
|
497
|
+
adapter = session.get_adapter("https://test.posthog.com")
|
|
498
|
+
retry = adapter.max_retries
|
|
499
|
+
|
|
500
|
+
# Verify the status codes that trigger retries
|
|
501
|
+
self.assertEqual(
|
|
502
|
+
set(retry.status_forcelist),
|
|
503
|
+
set(RETRY_STATUS_FORCELIST),
|
|
504
|
+
"Should retry on transient server errors",
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
# Verify specific codes are included
|
|
508
|
+
self.assertIn(500, retry.status_forcelist)
|
|
509
|
+
self.assertIn(502, retry.status_forcelist)
|
|
510
|
+
self.assertIn(503, retry.status_forcelist)
|
|
511
|
+
self.assertIn(504, retry.status_forcelist)
|
|
512
|
+
|
|
513
|
+
# Verify rate limits and quota errors are NOT retried
|
|
514
|
+
self.assertNotIn(429, retry.status_forcelist)
|
|
515
|
+
self.assertNotIn(402, retry.status_forcelist)
|
|
516
|
+
|
|
517
|
+
def test_flags_session_has_backoff(self):
|
|
518
|
+
"""
|
|
519
|
+
Verify that retries use exponential backoff to avoid thundering herd.
|
|
520
|
+
"""
|
|
521
|
+
from posthoganalytics.request import _build_flags_session
|
|
522
|
+
|
|
523
|
+
session = _build_flags_session()
|
|
524
|
+
adapter = session.get_adapter("https://test.posthog.com")
|
|
525
|
+
retry = adapter.max_retries
|
|
526
|
+
|
|
527
|
+
self.assertEqual(
|
|
528
|
+
retry.backoff_factor,
|
|
529
|
+
0.5,
|
|
530
|
+
"Should use 0.5s backoff factor (0.5s, 1s delays)",
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
class TestFlagsSessionRetryIntegration(unittest.TestCase):
|
|
535
|
+
"""Integration tests that verify actual retry behavior with a local server."""
|
|
536
|
+
|
|
537
|
+
def test_retries_on_503_then_succeeds(self):
|
|
538
|
+
"""
|
|
539
|
+
Verify that 503 errors trigger retries and eventually succeed.
|
|
540
|
+
|
|
541
|
+
Uses a local HTTP server that fails twice with 503, then succeeds.
|
|
542
|
+
This tests the full retry flow including backoff timing.
|
|
543
|
+
"""
|
|
544
|
+
import threading
|
|
545
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
546
|
+
from socketserver import ThreadingMixIn
|
|
547
|
+
from urllib3.util.retry import Retry
|
|
548
|
+
from posthoganalytics.request import HTTPAdapterWithSocketOptions, RETRY_STATUS_FORCELIST
|
|
549
|
+
|
|
550
|
+
request_count = 0
|
|
551
|
+
|
|
552
|
+
class RetryTestHandler(BaseHTTPRequestHandler):
|
|
553
|
+
protocol_version = "HTTP/1.1"
|
|
554
|
+
|
|
555
|
+
def do_POST(self):
|
|
556
|
+
nonlocal request_count
|
|
557
|
+
request_count += 1
|
|
558
|
+
|
|
559
|
+
# Read and discard request body to prevent connection issues
|
|
560
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
561
|
+
if content_length > 0:
|
|
562
|
+
self.rfile.read(content_length)
|
|
563
|
+
|
|
564
|
+
if request_count <= 2:
|
|
565
|
+
self.send_response(503)
|
|
566
|
+
self.send_header("Content-Type", "application/json")
|
|
567
|
+
body = b'{"error": "Service unavailable"}'
|
|
568
|
+
self.send_header("Content-Length", str(len(body)))
|
|
569
|
+
self.end_headers()
|
|
570
|
+
self.wfile.write(body)
|
|
571
|
+
else:
|
|
572
|
+
self.send_response(200)
|
|
573
|
+
self.send_header("Content-Type", "application/json")
|
|
574
|
+
body = (
|
|
575
|
+
b'{"featureFlags": {"test": true}, "featureFlagPayloads": {}}'
|
|
576
|
+
)
|
|
577
|
+
self.send_header("Content-Length", str(len(body)))
|
|
578
|
+
self.end_headers()
|
|
579
|
+
self.wfile.write(body)
|
|
580
|
+
|
|
581
|
+
def log_message(self, format, *args):
|
|
582
|
+
pass # Suppress logging
|
|
583
|
+
|
|
584
|
+
# Use ThreadingMixIn for cleaner shutdown
|
|
585
|
+
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
|
|
586
|
+
daemon_threads = True
|
|
587
|
+
|
|
588
|
+
# Start server on a random available port
|
|
589
|
+
server = ThreadedHTTPServer(("127.0.0.1", 0), RetryTestHandler)
|
|
590
|
+
port = server.server_address[1]
|
|
591
|
+
server_thread = threading.Thread(target=server.serve_forever)
|
|
592
|
+
server_thread.daemon = True
|
|
593
|
+
server_thread.start()
|
|
594
|
+
|
|
595
|
+
try:
|
|
596
|
+
# Build session with same retry config as _build_flags_session
|
|
597
|
+
# but mounted on http:// for local testing
|
|
598
|
+
adapter = HTTPAdapterWithSocketOptions(
|
|
599
|
+
max_retries=Retry(
|
|
600
|
+
total=2,
|
|
601
|
+
connect=2,
|
|
602
|
+
read=2,
|
|
603
|
+
backoff_factor=0.01, # Fast backoff for testing
|
|
604
|
+
status_forcelist=RETRY_STATUS_FORCELIST,
|
|
605
|
+
allowed_methods=["POST"],
|
|
606
|
+
),
|
|
607
|
+
)
|
|
608
|
+
session = requests.Session()
|
|
609
|
+
session.mount("http://", adapter)
|
|
610
|
+
|
|
611
|
+
response = session.post(
|
|
612
|
+
f"http://127.0.0.1:{port}/flags/?v=2",
|
|
613
|
+
json={"distinct_id": "user123"},
|
|
614
|
+
timeout=5,
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
# Should succeed on 3rd attempt
|
|
618
|
+
self.assertEqual(response.status_code, 200)
|
|
619
|
+
self.assertEqual(request_count, 3) # 1 initial + 2 retries
|
|
620
|
+
finally:
|
|
621
|
+
server.shutdown()
|
|
622
|
+
server.server_close()
|
|
623
|
+
|
|
624
|
+
def test_connection_errors_are_retried(self):
|
|
625
|
+
"""
|
|
626
|
+
Verify that connection errors (no server) trigger retries.
|
|
627
|
+
|
|
628
|
+
Binds a socket to get a guaranteed available port, then closes it
|
|
629
|
+
so connection attempts fail with ConnectionError.
|
|
630
|
+
"""
|
|
631
|
+
import socket
|
|
632
|
+
import time
|
|
633
|
+
from urllib3.util.retry import Retry
|
|
634
|
+
from posthoganalytics.request import HTTPAdapterWithSocketOptions, RETRY_STATUS_FORCELIST
|
|
635
|
+
|
|
636
|
+
# Get an available port by binding then closing a socket
|
|
637
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
638
|
+
sock.bind(("127.0.0.1", 0))
|
|
639
|
+
port = sock.getsockname()[1]
|
|
640
|
+
sock.close() # Port is now available but nothing is listening
|
|
641
|
+
|
|
642
|
+
adapter = HTTPAdapterWithSocketOptions(
|
|
643
|
+
max_retries=Retry(
|
|
644
|
+
total=2,
|
|
645
|
+
connect=2,
|
|
646
|
+
read=2,
|
|
647
|
+
backoff_factor=0.05, # Very fast for testing
|
|
648
|
+
status_forcelist=RETRY_STATUS_FORCELIST,
|
|
649
|
+
allowed_methods=["POST"],
|
|
650
|
+
),
|
|
651
|
+
)
|
|
652
|
+
session = requests.Session()
|
|
653
|
+
session.mount("http://", adapter)
|
|
654
|
+
|
|
655
|
+
start = time.time()
|
|
656
|
+
with self.assertRaises(requests.exceptions.ConnectionError):
|
|
657
|
+
session.post(
|
|
658
|
+
f"http://127.0.0.1:{port}/flags/?v=2",
|
|
659
|
+
json={"distinct_id": "user123"},
|
|
660
|
+
timeout=1,
|
|
661
|
+
)
|
|
662
|
+
elapsed = time.time() - start
|
|
663
|
+
|
|
664
|
+
# With 3 attempts and backoff, should take more than instant
|
|
665
|
+
# but less than timeout (confirms retries happened)
|
|
666
|
+
self.assertGreater(elapsed, 0.05, "Should have some delay from retries")
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import sys
|
|
1
2
|
import time
|
|
2
3
|
import unittest
|
|
3
4
|
from dataclasses import dataclass
|
|
@@ -122,7 +123,9 @@ class TestUtils(unittest.TestCase):
|
|
|
122
123
|
"bar": 2,
|
|
123
124
|
"baz": None,
|
|
124
125
|
}
|
|
125
|
-
|
|
126
|
+
# Pydantic V1 is not compatible with Python 3.14+
|
|
127
|
+
if sys.version_info < (3, 14):
|
|
128
|
+
assert utils.clean(ModelV1(foo=1, bar="2")) == {"foo": 1, "bar": "2"}
|
|
126
129
|
assert utils.clean(NestedModel(foo=ModelV2(foo="1", bar=2, baz="3"))) == {
|
|
127
130
|
"foo": {"foo": "1", "bar": 2, "baz": "3"}
|
|
128
131
|
}
|
posthoganalytics/types.py
CHANGED
|
@@ -123,6 +123,7 @@ class FlagsResponse(TypedDict, total=False):
|
|
|
123
123
|
errorsWhileComputingFlags: bool
|
|
124
124
|
requestId: str
|
|
125
125
|
quotaLimit: Optional[List[str]]
|
|
126
|
+
evaluatedAt: Optional[int]
|
|
126
127
|
|
|
127
128
|
|
|
128
129
|
class FlagsAndPayloads(TypedDict, total=True):
|
|
@@ -306,3 +307,42 @@ def to_payloads(response: FlagsResponse) -> Optional[dict[str, str]]:
|
|
|
306
307
|
and value.enabled
|
|
307
308
|
and value.metadata.payload is not None
|
|
308
309
|
}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class FeatureFlagError:
|
|
313
|
+
"""Error type constants for the $feature_flag_error property.
|
|
314
|
+
|
|
315
|
+
These values are sent in analytics events to track flag evaluation failures.
|
|
316
|
+
They should not be changed without considering impact on existing dashboards
|
|
317
|
+
and queries that filter on these values.
|
|
318
|
+
|
|
319
|
+
Error values:
|
|
320
|
+
ERRORS_WHILE_COMPUTING: Server returned errorsWhileComputingFlags=true
|
|
321
|
+
FLAG_MISSING: Requested flag not in API response
|
|
322
|
+
QUOTA_LIMITED: Rate/quota limit exceeded
|
|
323
|
+
TIMEOUT: Request timed out
|
|
324
|
+
CONNECTION_ERROR: Network connectivity issue
|
|
325
|
+
UNKNOWN_ERROR: Unexpected exceptions
|
|
326
|
+
|
|
327
|
+
For API errors with status codes, use the api_error() method which returns
|
|
328
|
+
a string like "api_error_500".
|
|
329
|
+
"""
|
|
330
|
+
|
|
331
|
+
ERRORS_WHILE_COMPUTING = "errors_while_computing_flags"
|
|
332
|
+
FLAG_MISSING = "flag_missing"
|
|
333
|
+
QUOTA_LIMITED = "quota_limited"
|
|
334
|
+
TIMEOUT = "timeout"
|
|
335
|
+
CONNECTION_ERROR = "connection_error"
|
|
336
|
+
UNKNOWN_ERROR = "unknown_error"
|
|
337
|
+
|
|
338
|
+
@staticmethod
|
|
339
|
+
def api_error(status: Union[int, str]) -> str:
|
|
340
|
+
"""Generate API error string with status code.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
status: HTTP status code from the API error
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
Error string like "api_error_500"
|
|
347
|
+
"""
|
|
348
|
+
return f"api_error_{status}"
|
posthoganalytics/version.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: posthoganalytics
|
|
3
|
-
Version: 7.
|
|
3
|
+
Version: 7.4.0
|
|
4
4
|
Summary: Integrate PostHog into any python application.
|
|
5
5
|
Home-page: https://github.com/posthog/posthog-python
|
|
6
6
|
Author: Posthog
|
|
@@ -19,6 +19,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.11
|
|
20
20
|
Classifier: Programming Language :: Python :: 3.12
|
|
21
21
|
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
23
|
Requires-Python: >=3.10
|
|
23
24
|
Description-Content-Type: text/markdown
|
|
24
25
|
License-File: LICENSE
|