posthoganalytics 7.0.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.
@@ -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
- assert utils.clean(ModelV1(foo=1, bar="2")) == {"foo": 1, "bar": "2"}
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}"
@@ -1,4 +1,4 @@
1
- VERSION = "7.0.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: posthoganalytics
3
- Version: 7.0.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
@@ -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