posthog 7.3.1__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.
posthog/request.py CHANGED
@@ -8,7 +8,6 @@ from gzip import GzipFile
8
8
  from io import BytesIO
9
9
  from typing import Any, List, Optional, Tuple, Union
10
10
 
11
-
12
11
  import requests
13
12
  from dateutil.tz import tzutc
14
13
  from requests.adapters import HTTPAdapter # type: ignore[import-untyped]
@@ -42,6 +41,9 @@ for attr, value in [
42
41
  if hasattr(socket, attr):
43
42
  KEEP_ALIVE_SOCKET_OPTIONS.append((socket.SOL_TCP, getattr(socket, attr), value))
44
43
 
44
+ # Status codes that indicate transient server errors worth retrying
45
+ RETRY_STATUS_FORCELIST = [408, 500, 502, 503, 504]
46
+
45
47
 
46
48
  def _mask_tokens_in_url(url: str) -> str:
47
49
  """Mask token values in URLs for safe logging, keeping first 10 chars visible."""
@@ -71,20 +73,49 @@ class HTTPAdapterWithSocketOptions(HTTPAdapter):
71
73
 
72
74
 
73
75
  def _build_session(socket_options: Optional[SocketOptions] = None) -> requests.Session:
76
+ """Build a session for general requests (batch, decide, etc.)."""
77
+ adapter = HTTPAdapterWithSocketOptions(
78
+ max_retries=Retry(
79
+ total=2,
80
+ connect=2,
81
+ read=2,
82
+ ),
83
+ socket_options=socket_options,
84
+ )
85
+ session = requests.Session()
86
+ session.mount("https://", adapter)
87
+ return session
88
+
89
+
90
+ def _build_flags_session(
91
+ socket_options: Optional[SocketOptions] = None,
92
+ ) -> requests.Session:
93
+ """
94
+ Build a session for feature flag requests with POST retries.
95
+
96
+ Feature flag requests are idempotent (read-only), so retrying POST
97
+ requests is safe. This session retries on transient server errors
98
+ (408, 5xx) and network failures with exponential backoff
99
+ (0.5s, 1s delays between retries).
100
+ """
74
101
  adapter = HTTPAdapterWithSocketOptions(
75
102
  max_retries=Retry(
76
103
  total=2,
77
104
  connect=2,
78
105
  read=2,
106
+ backoff_factor=0.5,
107
+ status_forcelist=RETRY_STATUS_FORCELIST,
108
+ allowed_methods=["POST"],
79
109
  ),
80
110
  socket_options=socket_options,
81
111
  )
82
- session = requests.sessions.Session()
112
+ session = requests.Session()
83
113
  session.mount("https://", adapter)
84
114
  return session
85
115
 
86
116
 
87
117
  _session = _build_session()
118
+ _flags_session = _build_flags_session()
88
119
  _socket_options: Optional[SocketOptions] = None
89
120
  _pooling_enabled = True
90
121
 
@@ -95,6 +126,12 @@ def _get_session() -> requests.Session:
95
126
  return _build_session(_socket_options)
96
127
 
97
128
 
129
+ def _get_flags_session() -> requests.Session:
130
+ if _pooling_enabled:
131
+ return _flags_session
132
+ return _build_flags_session(_socket_options)
133
+
134
+
98
135
  def set_socket_options(socket_options: Optional[SocketOptions]) -> None:
99
136
  """
100
137
  Configure socket options for all HTTP connections.
@@ -103,11 +140,12 @@ def set_socket_options(socket_options: Optional[SocketOptions]) -> None:
103
140
  from posthog import set_socket_options
104
141
  set_socket_options([(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)])
105
142
  """
106
- global _session, _socket_options
143
+ global _session, _flags_session, _socket_options
107
144
  if socket_options == _socket_options:
108
145
  return
109
146
  _socket_options = socket_options
110
147
  _session = _build_session(socket_options)
148
+ _flags_session = _build_flags_session(socket_options)
111
149
 
112
150
 
113
151
  def enable_keep_alive() -> None:
@@ -145,6 +183,7 @@ def post(
145
183
  path=None,
146
184
  gzip: bool = False,
147
185
  timeout: int = 15,
186
+ session: Optional[requests.Session] = None,
148
187
  **kwargs,
149
188
  ) -> requests.Response:
150
189
  """Post the `kwargs` to the API"""
@@ -165,7 +204,9 @@ def post(
165
204
  gz.write(data.encode("utf-8"))
166
205
  data = buf.getvalue()
167
206
 
168
- res = _get_session().post(url, data=data, headers=headers, timeout=timeout)
207
+ res = (session or _get_session()).post(
208
+ url, data=data, headers=headers, timeout=timeout
209
+ )
169
210
 
170
211
  if res.status_code == 200:
171
212
  log.debug("data uploaded successfully")
@@ -221,8 +262,16 @@ def flags(
221
262
  timeout: int = 15,
222
263
  **kwargs,
223
264
  ) -> Any:
224
- """Post the `kwargs to the flags API endpoint"""
225
- res = post(api_key, host, "/flags/?v=2", gzip, timeout, **kwargs)
265
+ """Post the kwargs to the flags API endpoint with automatic retries."""
266
+ res = post(
267
+ api_key,
268
+ host,
269
+ "/flags/?v=2",
270
+ gzip,
271
+ timeout,
272
+ session=_get_flags_session(),
273
+ **kwargs,
274
+ )
226
275
  return _process_response(
227
276
  res, success_message="Feature flags evaluated successfully"
228
277
  )
@@ -19,6 +19,7 @@ from posthog.request import (
19
19
  determine_server_host,
20
20
  disable_connection_reuse,
21
21
  enable_keep_alive,
22
+ flags,
22
23
  get,
23
24
  set_socket_options,
24
25
  )
@@ -393,3 +394,273 @@ def test_set_socket_options_is_idempotent():
393
394
  assert session1 is session2
394
395
  finally:
395
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 posthog.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 posthog.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 posthog.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 posthog.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 posthog.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 posthog.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 posthog.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")
posthog/version.py CHANGED
@@ -1,4 +1,4 @@
1
- VERSION = "7.3.1"
1
+ VERSION = "7.4.0"
2
2
 
3
3
  if __name__ == "__main__":
4
4
  print(VERSION, end="") # noqa: T201
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: posthog
3
- Version: 7.3.1
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
@@ -9,10 +9,10 @@ posthog/feature_flags.py,sha256=4xAcYEpa97b5Lv9bIo5JHbCO6lhYBnH5EmJ2MrjbU3k,2251
9
9
  posthog/flag_definition_cache.py,sha256=3LnB76BAU29FmypxagkqTpEW6MfjQQTw3E_xWJ679XQ,4697
10
10
  posthog/poller.py,sha256=jBz5rfH_kn_bBz7wCB46Fpvso4ttx4uzqIZWvXBCFmQ,595
11
11
  posthog/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- posthog/request.py,sha256=CfYneafeZH8bfQuAPHHjYIaWTLPl63YsZ66uBmn58vM,10349
12
+ posthog/request.py,sha256=_WdffuI4BgRL9UvbEEi-4uxpFW3P4h7PRDpYM0lawVU,11817
13
13
  posthog/types.py,sha256=OxGHSmmhVYwA7ecmJXUznDCZ1c4gAGtERzSLSYlyQFM,11540
14
14
  posthog/utils.py,sha256=-0w-OLcCaoldkbBebPzQyBzLJSo9G9yBOg8NDVz7La8,16088
15
- posthog/version.py,sha256=ZP1gzAMj9_8ZAgoHQCpxcYzy3w0bR8IQuMtjzF3nWyg,87
15
+ posthog/version.py,sha256=eltPOeTfTkhaA4B9Epak_jIZQQYePn2QTw1EpKwy99E,87
16
16
  posthog/ai/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
17
  posthog/ai/sanitization.py,sha256=Dpx_5gKZfDS38KjmK1C0lvvjm9N8Pp_oIxusac888-g,6057
18
18
  posthog/ai/types.py,sha256=arX98hR1PIPeJ3vFikxTlACIh1xPp6aEUw1gBLcKoB0,3273
@@ -46,12 +46,12 @@ posthog/test/test_feature_flag_result.py,sha256=KCQHismwddFWl-PHtRpZzcL5M45q_oUe
46
46
  posthog/test/test_feature_flags.py,sha256=5tfAYuzNjxgnxsQxlO7sDjmivUccTWf_26l_FEAJ3E8,222554
47
47
  posthog/test/test_flag_definition_cache.py,sha256=_ssIKtrgNw3WsHv-GQNd_Nk-luDxAWgOVnAOMb8gWP8,21883
48
48
  posthog/test/test_module.py,sha256=CERR0dTPGsAmd7YBxK0yKeB2Zr2b_Lv7hNQoeJauc9I,813
49
- posthog/test/test_request.py,sha256=PGJjA8tZ-9PrBZJI1EQGX03TocRFyCbjcRQatwoDvH4,14607
49
+ posthog/test/test_request.py,sha256=vrZSayStAAcTr05h2ci3Y8qmKDIV2umBdiJqhr3NPp0,25176
50
50
  posthog/test/test_size_limited_dict.py,sha256=Wom7BkzpHmusHilZy0SV3PNzhw7ucuQgqrx86jf8euo,765
51
51
  posthog/test/test_types.py,sha256=csLuBiz6RMV36cpg9LVIor4Khq6MfjjGxYXodx5VttY,7586
52
52
  posthog/test/test_utils.py,sha256=YqAnXaMHxzEV_D3AHhs-RXnZYzdEN7kdIlpOT6Ti6t0,9714
53
- posthog-7.3.1.dist-info/licenses/LICENSE,sha256=wGf9JBotDkSygFj43m49oiKlFnpMnn97keiZKF-40vE,2450
54
- posthog-7.3.1.dist-info/METADATA,sha256=9bg4WHQ_dz-3wd0JanCkXIrJ2NzH96YvCbKkmVY0_64,6010
55
- posthog-7.3.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
56
- posthog-7.3.1.dist-info/top_level.txt,sha256=7FBLsRjIUHVKQsXIhozuI3k-mun1tapp8iZO9EmUPEw,8
57
- posthog-7.3.1.dist-info/RECORD,,
53
+ posthog-7.4.0.dist-info/licenses/LICENSE,sha256=wGf9JBotDkSygFj43m49oiKlFnpMnn97keiZKF-40vE,2450
54
+ posthog-7.4.0.dist-info/METADATA,sha256=5LIZKQuKMWBWIgQ6jumdjTA3c-fHc1Si0JNCxTpZh0k,6010
55
+ posthog-7.4.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
56
+ posthog-7.4.0.dist-info/top_level.txt,sha256=7FBLsRjIUHVKQsXIhozuI3k-mun1tapp8iZO9EmUPEw,8
57
+ posthog-7.4.0.dist-info/RECORD,,