posthoganalytics 6.7.0__py3-none-any.whl → 7.4.3__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 +84 -7
- posthoganalytics/ai/anthropic/__init__.py +10 -0
- posthoganalytics/ai/anthropic/anthropic.py +95 -65
- posthoganalytics/ai/anthropic/anthropic_async.py +95 -65
- posthoganalytics/ai/anthropic/anthropic_converter.py +443 -0
- posthoganalytics/ai/gemini/__init__.py +15 -1
- posthoganalytics/ai/gemini/gemini.py +66 -71
- posthoganalytics/ai/gemini/gemini_async.py +423 -0
- posthoganalytics/ai/gemini/gemini_converter.py +652 -0
- posthoganalytics/ai/langchain/callbacks.py +58 -13
- posthoganalytics/ai/openai/__init__.py +16 -1
- posthoganalytics/ai/openai/openai.py +140 -149
- posthoganalytics/ai/openai/openai_async.py +127 -82
- posthoganalytics/ai/openai/openai_converter.py +741 -0
- posthoganalytics/ai/sanitization.py +248 -0
- posthoganalytics/ai/types.py +125 -0
- posthoganalytics/ai/utils.py +339 -356
- posthoganalytics/client.py +345 -97
- posthoganalytics/contexts.py +81 -0
- posthoganalytics/exception_utils.py +250 -2
- posthoganalytics/feature_flags.py +26 -10
- posthoganalytics/flag_definition_cache.py +127 -0
- posthoganalytics/integrations/django.py +157 -19
- posthoganalytics/request.py +203 -23
- posthoganalytics/test/test_client.py +250 -22
- posthoganalytics/test/test_exception_capture.py +418 -0
- posthoganalytics/test/test_feature_flag_result.py +441 -2
- posthoganalytics/test/test_feature_flags.py +308 -104
- posthoganalytics/test/test_flag_definition_cache.py +612 -0
- posthoganalytics/test/test_module.py +0 -8
- 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-6.7.0.dist-info → posthoganalytics-7.4.3.dist-info}/METADATA +12 -12
- posthoganalytics-7.4.3.dist-info/RECORD +57 -0
- posthoganalytics-6.7.0.dist-info/RECORD +0 -49
- {posthoganalytics-6.7.0.dist-info → posthoganalytics-7.4.3.dist-info}/WHEEL +0 -0
- {posthoganalytics-6.7.0.dist-info → posthoganalytics-7.4.3.dist-info}/licenses/LICENSE +0 -0
- {posthoganalytics-6.7.0.dist-info → posthoganalytics-7.4.3.dist-info}/top_level.txt +0 -0
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
from typing import TYPE_CHECKING, cast
|
|
2
|
-
from posthoganalytics import contexts
|
|
2
|
+
from posthoganalytics import contexts
|
|
3
3
|
from posthoganalytics.client import Client
|
|
4
4
|
|
|
5
|
+
try:
|
|
6
|
+
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
|
|
7
|
+
except ImportError:
|
|
8
|
+
# Fallback for older Django versions without asgiref
|
|
9
|
+
import asyncio
|
|
10
|
+
|
|
11
|
+
iscoroutinefunction = asyncio.iscoroutinefunction
|
|
12
|
+
|
|
13
|
+
# No-op fallback for markcoroutinefunction
|
|
14
|
+
# Older Django versions without asgiref typically don't support async middleware anyway
|
|
15
|
+
def markcoroutinefunction(func):
|
|
16
|
+
return func
|
|
17
|
+
|
|
18
|
+
|
|
5
19
|
if TYPE_CHECKING:
|
|
6
20
|
from django.http import HttpRequest, HttpResponse # noqa: F401
|
|
7
|
-
from typing import Callable, Dict, Any, Optional # noqa: F401
|
|
21
|
+
from typing import Callable, Dict, Any, Optional, Union, Awaitable # noqa: F401
|
|
8
22
|
|
|
9
23
|
|
|
10
24
|
class PosthogContextMiddleware:
|
|
@@ -31,11 +45,24 @@ class PosthogContextMiddleware:
|
|
|
31
45
|
See the context documentation for more information. The extracted distinct ID and session ID, if found, are used to
|
|
32
46
|
associate all events captured in the middleware context with the same distinct ID and session as currently active on the
|
|
33
47
|
frontend. See the documentation for `set_context_session` and `identify_context` for more details.
|
|
48
|
+
|
|
49
|
+
This middleware is hybrid-capable: it supports both WSGI (sync) and ASGI (async) Django applications. The middleware
|
|
50
|
+
detects at initialization whether the next middleware in the chain is async or sync, and adapts its behavior accordingly.
|
|
51
|
+
This ensures compatibility with both pure sync and pure async middleware chains, as well as mixed chains in ASGI mode.
|
|
34
52
|
"""
|
|
35
53
|
|
|
54
|
+
sync_capable = True
|
|
55
|
+
async_capable = True
|
|
56
|
+
|
|
36
57
|
def __init__(self, get_response):
|
|
37
|
-
# type: (Callable[[HttpRequest], HttpResponse]) -> None
|
|
58
|
+
# type: (Union[Callable[[HttpRequest], HttpResponse], Callable[[HttpRequest], Awaitable[HttpResponse]]]) -> None
|
|
38
59
|
self.get_response = get_response
|
|
60
|
+
self._is_coroutine = iscoroutinefunction(get_response)
|
|
61
|
+
|
|
62
|
+
# Mark this instance as a coroutine function if get_response is async
|
|
63
|
+
# This is required for Django to correctly detect async middleware
|
|
64
|
+
if self._is_coroutine:
|
|
65
|
+
markcoroutinefunction(self)
|
|
39
66
|
|
|
40
67
|
from django.conf import settings
|
|
41
68
|
|
|
@@ -85,9 +112,18 @@ class PosthogContextMiddleware:
|
|
|
85
112
|
|
|
86
113
|
def extract_tags(self, request):
|
|
87
114
|
# type: (HttpRequest) -> Dict[str, Any]
|
|
88
|
-
tags
|
|
115
|
+
"""Extract tags from request in sync context."""
|
|
116
|
+
user_id, user_email = self.extract_request_user(request)
|
|
117
|
+
return self._build_tags(request, user_id, user_email)
|
|
89
118
|
|
|
90
|
-
|
|
119
|
+
def _build_tags(self, request, user_id, user_email):
|
|
120
|
+
# type: (HttpRequest, Optional[str], Optional[str]) -> Dict[str, Any]
|
|
121
|
+
"""
|
|
122
|
+
Build tags dict from request and user info.
|
|
123
|
+
|
|
124
|
+
Centralized tag extraction logic used by both sync and async paths.
|
|
125
|
+
"""
|
|
126
|
+
tags = {}
|
|
91
127
|
|
|
92
128
|
# Extract session ID from X-POSTHOG-SESSION-ID header
|
|
93
129
|
session_id = request.headers.get("X-POSTHOG-SESSION-ID")
|
|
@@ -139,43 +175,145 @@ class PosthogContextMiddleware:
|
|
|
139
175
|
return tags
|
|
140
176
|
|
|
141
177
|
def extract_request_user(self, request):
|
|
142
|
-
|
|
143
|
-
email
|
|
144
|
-
|
|
178
|
+
# type: (HttpRequest) -> tuple[Optional[str], Optional[str]]
|
|
179
|
+
"""Extract user ID and email from request in sync context."""
|
|
145
180
|
user = getattr(request, "user", None)
|
|
181
|
+
return self._resolve_user_details(user)
|
|
146
182
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
pass
|
|
183
|
+
async def aextract_tags(self, request):
|
|
184
|
+
# type: (HttpRequest) -> Dict[str, Any]
|
|
185
|
+
"""
|
|
186
|
+
Async version of extract_tags for use in async request handling.
|
|
152
187
|
|
|
188
|
+
Uses await request.auser() instead of request.user to avoid
|
|
189
|
+
SynchronousOnlyOperation in async context.
|
|
190
|
+
|
|
191
|
+
Follows Django's naming convention for async methods (auser, asave, etc.).
|
|
192
|
+
"""
|
|
193
|
+
user_id, user_email = await self.aextract_request_user(request)
|
|
194
|
+
return self._build_tags(request, user_id, user_email)
|
|
195
|
+
|
|
196
|
+
async def aextract_request_user(self, request):
|
|
197
|
+
# type: (HttpRequest) -> tuple[Optional[str], Optional[str]]
|
|
198
|
+
"""
|
|
199
|
+
Async version of extract_request_user for use in async request handling.
|
|
200
|
+
|
|
201
|
+
Uses await request.auser() instead of request.user to avoid
|
|
202
|
+
SynchronousOnlyOperation in async context.
|
|
203
|
+
|
|
204
|
+
Follows Django's naming convention for async methods (auser, asave, etc.).
|
|
205
|
+
"""
|
|
206
|
+
auser = getattr(request, "auser", None)
|
|
207
|
+
if callable(auser):
|
|
153
208
|
try:
|
|
154
|
-
|
|
209
|
+
user = await auser()
|
|
210
|
+
return self._resolve_user_details(user)
|
|
155
211
|
except Exception:
|
|
156
|
-
|
|
212
|
+
# If auser() fails, return empty - don't break the request
|
|
213
|
+
# Real errors (permissions, broken auth) will be logged by Django
|
|
214
|
+
return None, None
|
|
215
|
+
|
|
216
|
+
# Fallback for test requests without auser
|
|
217
|
+
return None, None
|
|
218
|
+
|
|
219
|
+
def _resolve_user_details(self, user):
|
|
220
|
+
# type: (Any) -> tuple[Optional[str], Optional[str]]
|
|
221
|
+
"""
|
|
222
|
+
Extract user ID and email from a user object.
|
|
223
|
+
|
|
224
|
+
Handles both authenticated and unauthenticated users, as well as
|
|
225
|
+
legacy Django where is_authenticated was a method.
|
|
226
|
+
"""
|
|
227
|
+
user_id = None
|
|
228
|
+
email = None
|
|
229
|
+
|
|
230
|
+
if user is None:
|
|
231
|
+
return user_id, email
|
|
232
|
+
|
|
233
|
+
# Handle is_authenticated (property in modern Django, method in legacy)
|
|
234
|
+
is_authenticated = getattr(user, "is_authenticated", False)
|
|
235
|
+
if callable(is_authenticated):
|
|
236
|
+
is_authenticated = is_authenticated()
|
|
237
|
+
|
|
238
|
+
if not is_authenticated:
|
|
239
|
+
return user_id, email
|
|
240
|
+
|
|
241
|
+
# Extract user primary key
|
|
242
|
+
user_pk = getattr(user, "pk", None)
|
|
243
|
+
if user_pk is not None:
|
|
244
|
+
user_id = str(user_pk)
|
|
245
|
+
|
|
246
|
+
# Extract user email
|
|
247
|
+
user_email = getattr(user, "email", None)
|
|
248
|
+
if user_email:
|
|
249
|
+
email = str(user_email)
|
|
157
250
|
|
|
158
251
|
return user_id, email
|
|
159
252
|
|
|
160
253
|
def __call__(self, request):
|
|
161
|
-
# type: (HttpRequest) -> HttpResponse
|
|
254
|
+
# type: (HttpRequest) -> Union[HttpResponse, Awaitable[HttpResponse]]
|
|
255
|
+
"""
|
|
256
|
+
Unified entry point for both sync and async request handling.
|
|
257
|
+
|
|
258
|
+
When sync_capable and async_capable are both True, Django passes requests
|
|
259
|
+
without conversion. This method detects the mode and routes accordingly.
|
|
260
|
+
"""
|
|
261
|
+
if self._is_coroutine:
|
|
262
|
+
return self.__acall__(request)
|
|
263
|
+
else:
|
|
264
|
+
# Synchronous path
|
|
265
|
+
if self.request_filter and not self.request_filter(request):
|
|
266
|
+
return self.get_response(request)
|
|
267
|
+
|
|
268
|
+
with contexts.new_context(self.capture_exceptions, client=self.client):
|
|
269
|
+
for k, v in self.extract_tags(request).items():
|
|
270
|
+
contexts.tag(k, v)
|
|
271
|
+
|
|
272
|
+
return self.get_response(request)
|
|
273
|
+
|
|
274
|
+
async def __acall__(self, request):
|
|
275
|
+
# type: (HttpRequest) -> Awaitable[HttpResponse]
|
|
276
|
+
"""
|
|
277
|
+
Asynchronous entry point for async request handling.
|
|
278
|
+
|
|
279
|
+
This method is called when the middleware chain is async.
|
|
280
|
+
Uses aextract_tags() which calls request.auser() to avoid
|
|
281
|
+
SynchronousOnlyOperation when accessing user in async context.
|
|
282
|
+
"""
|
|
162
283
|
if self.request_filter and not self.request_filter(request):
|
|
163
|
-
return self.get_response(request)
|
|
284
|
+
return await self.get_response(request)
|
|
164
285
|
|
|
165
286
|
with contexts.new_context(self.capture_exceptions, client=self.client):
|
|
166
|
-
for k, v in self.
|
|
287
|
+
for k, v in (await self.aextract_tags(request)).items():
|
|
167
288
|
contexts.tag(k, v)
|
|
168
289
|
|
|
169
|
-
return self.get_response(request)
|
|
290
|
+
return await self.get_response(request)
|
|
170
291
|
|
|
171
292
|
def process_exception(self, request, exception):
|
|
293
|
+
# type: (HttpRequest, Exception) -> None
|
|
294
|
+
"""
|
|
295
|
+
Process exceptions from views and downstream middleware.
|
|
296
|
+
|
|
297
|
+
Django calls this WHILE still inside the context created by __call__,
|
|
298
|
+
so request tags have already been extracted and set. This method just
|
|
299
|
+
needs to capture the exception directly.
|
|
300
|
+
|
|
301
|
+
Django converts view exceptions into responses before they propagate through
|
|
302
|
+
the middleware stack, so the context manager in __call__/__acall__ never sees them.
|
|
303
|
+
|
|
304
|
+
Note: Django's process_exception is always synchronous, even for async views.
|
|
305
|
+
"""
|
|
172
306
|
if self.request_filter and not self.request_filter(request):
|
|
173
307
|
return
|
|
174
308
|
|
|
175
309
|
if not self.capture_exceptions:
|
|
176
310
|
return
|
|
177
311
|
|
|
312
|
+
# Context and tags already set by __call__ or __acall__
|
|
313
|
+
# Just capture the exception
|
|
178
314
|
if self.client:
|
|
179
315
|
self.client.capture_exception(exception)
|
|
180
316
|
else:
|
|
317
|
+
from posthoganalytics import capture_exception
|
|
318
|
+
|
|
181
319
|
capture_exception(exception)
|
posthoganalytics/request.py
CHANGED
|
@@ -1,28 +1,163 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import logging
|
|
3
|
+
import re
|
|
4
|
+
import socket
|
|
5
|
+
from dataclasses import dataclass
|
|
3
6
|
from datetime import date, datetime
|
|
4
7
|
from gzip import GzipFile
|
|
5
8
|
from io import BytesIO
|
|
6
|
-
from typing import Any, Optional, Union
|
|
9
|
+
from typing import Any, List, Optional, Tuple, Union
|
|
7
10
|
|
|
8
11
|
import requests
|
|
9
12
|
from dateutil.tz import tzutc
|
|
13
|
+
from requests.adapters import HTTPAdapter # type: ignore[import-untyped]
|
|
14
|
+
from urllib3.connection import HTTPConnection
|
|
10
15
|
from urllib3.util.retry import Retry
|
|
11
16
|
|
|
12
17
|
from posthoganalytics.utils import remove_trailing_slash
|
|
13
18
|
from posthoganalytics.version import VERSION
|
|
14
19
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
SocketOptions = List[Tuple[int, int, Union[int, bytes]]]
|
|
21
|
+
|
|
22
|
+
KEEPALIVE_IDLE_SECONDS = 60
|
|
23
|
+
KEEPALIVE_INTERVAL_SECONDS = 60
|
|
24
|
+
KEEPALIVE_PROBE_COUNT = 3
|
|
25
|
+
|
|
26
|
+
# TCP keepalive probes idle connections to prevent them from being dropped.
|
|
27
|
+
# SO_KEEPALIVE is cross-platform, but timing options vary:
|
|
28
|
+
# - Linux: TCP_KEEPIDLE, TCP_KEEPINTVL, TCP_KEEPCNT
|
|
29
|
+
# - macOS: only SO_KEEPALIVE (uses system defaults)
|
|
30
|
+
# - Windows: TCP_KEEPIDLE, TCP_KEEPINTVL (since Windows 10 1709)
|
|
31
|
+
KEEP_ALIVE_SOCKET_OPTIONS: SocketOptions = list(
|
|
32
|
+
HTTPConnection.default_socket_options
|
|
33
|
+
) + [
|
|
34
|
+
(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1),
|
|
35
|
+
]
|
|
36
|
+
for attr, value in [
|
|
37
|
+
("TCP_KEEPIDLE", KEEPALIVE_IDLE_SECONDS),
|
|
38
|
+
("TCP_KEEPINTVL", KEEPALIVE_INTERVAL_SECONDS),
|
|
39
|
+
("TCP_KEEPCNT", KEEPALIVE_PROBE_COUNT),
|
|
40
|
+
]:
|
|
41
|
+
if hasattr(socket, attr):
|
|
42
|
+
KEEP_ALIVE_SOCKET_OPTIONS.append((socket.SOL_TCP, getattr(socket, attr), value))
|
|
43
|
+
|
|
44
|
+
# Status codes that indicate transient server errors worth retrying
|
|
45
|
+
RETRY_STATUS_FORCELIST = [408, 500, 502, 503, 504]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _mask_tokens_in_url(url: str) -> str:
|
|
49
|
+
"""Mask token values in URLs for safe logging, keeping first 10 chars visible."""
|
|
50
|
+
return re.sub(r"(token=)([^&]{10})[^&]*", r"\1\2...", url)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class GetResponse:
|
|
55
|
+
"""Response from a GET request with ETag support."""
|
|
56
|
+
|
|
57
|
+
data: Any
|
|
58
|
+
etag: Optional[str] = None
|
|
59
|
+
not_modified: bool = False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class HTTPAdapterWithSocketOptions(HTTPAdapter):
|
|
63
|
+
"""HTTPAdapter with configurable socket options."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, *args, socket_options: Optional[SocketOptions] = None, **kwargs):
|
|
66
|
+
self.socket_options = socket_options
|
|
67
|
+
super().__init__(*args, **kwargs)
|
|
68
|
+
|
|
69
|
+
def init_poolmanager(self, *args, **kwargs):
|
|
70
|
+
if self.socket_options is not None:
|
|
71
|
+
kwargs["socket_options"] = self.socket_options
|
|
72
|
+
super().init_poolmanager(*args, **kwargs)
|
|
73
|
+
|
|
74
|
+
|
|
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
|
+
"""
|
|
101
|
+
adapter = HTTPAdapterWithSocketOptions(
|
|
102
|
+
max_retries=Retry(
|
|
103
|
+
total=2,
|
|
104
|
+
connect=2,
|
|
105
|
+
read=2,
|
|
106
|
+
backoff_factor=0.5,
|
|
107
|
+
status_forcelist=RETRY_STATUS_FORCELIST,
|
|
108
|
+
allowed_methods=["POST"],
|
|
109
|
+
),
|
|
110
|
+
socket_options=socket_options,
|
|
22
111
|
)
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
|
|
112
|
+
session = requests.Session()
|
|
113
|
+
session.mount("https://", adapter)
|
|
114
|
+
return session
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_session = _build_session()
|
|
118
|
+
_flags_session = _build_flags_session()
|
|
119
|
+
_socket_options: Optional[SocketOptions] = None
|
|
120
|
+
_pooling_enabled = True
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _get_session() -> requests.Session:
|
|
124
|
+
if _pooling_enabled:
|
|
125
|
+
return _session
|
|
126
|
+
return _build_session(_socket_options)
|
|
127
|
+
|
|
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
|
+
|
|
135
|
+
def set_socket_options(socket_options: Optional[SocketOptions]) -> None:
|
|
136
|
+
"""
|
|
137
|
+
Configure socket options for all HTTP connections.
|
|
138
|
+
|
|
139
|
+
Example:
|
|
140
|
+
from posthoganalytics import set_socket_options
|
|
141
|
+
set_socket_options([(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)])
|
|
142
|
+
"""
|
|
143
|
+
global _session, _flags_session, _socket_options
|
|
144
|
+
if socket_options == _socket_options:
|
|
145
|
+
return
|
|
146
|
+
_socket_options = socket_options
|
|
147
|
+
_session = _build_session(socket_options)
|
|
148
|
+
_flags_session = _build_flags_session(socket_options)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def enable_keep_alive() -> None:
|
|
152
|
+
"""Enable TCP keepalive to prevent idle connections from being dropped."""
|
|
153
|
+
set_socket_options(KEEP_ALIVE_SOCKET_OPTIONS)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def disable_connection_reuse() -> None:
|
|
157
|
+
"""Disable connection reuse, creating a fresh connection for each request."""
|
|
158
|
+
global _pooling_enabled
|
|
159
|
+
_pooling_enabled = False
|
|
160
|
+
|
|
26
161
|
|
|
27
162
|
US_INGESTION_ENDPOINT = "https://us.i.posthog.com"
|
|
28
163
|
EU_INGESTION_ENDPOINT = "https://eu.i.posthog.com"
|
|
@@ -48,6 +183,7 @@ def post(
|
|
|
48
183
|
path=None,
|
|
49
184
|
gzip: bool = False,
|
|
50
185
|
timeout: int = 15,
|
|
186
|
+
session: Optional[requests.Session] = None,
|
|
51
187
|
**kwargs,
|
|
52
188
|
) -> requests.Response:
|
|
53
189
|
"""Post the `kwargs` to the API"""
|
|
@@ -68,7 +204,9 @@ def post(
|
|
|
68
204
|
gz.write(data.encode("utf-8"))
|
|
69
205
|
data = buf.getvalue()
|
|
70
206
|
|
|
71
|
-
res =
|
|
207
|
+
res = (session or _get_session()).post(
|
|
208
|
+
url, data=data, headers=headers, timeout=timeout
|
|
209
|
+
)
|
|
72
210
|
|
|
73
211
|
if res.status_code == 200:
|
|
74
212
|
log.debug("data uploaded successfully")
|
|
@@ -124,8 +262,16 @@ def flags(
|
|
|
124
262
|
timeout: int = 15,
|
|
125
263
|
**kwargs,
|
|
126
264
|
) -> Any:
|
|
127
|
-
"""Post the
|
|
128
|
-
res = post(
|
|
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
|
+
)
|
|
129
275
|
return _process_response(
|
|
130
276
|
res, success_message="Feature flags evaluated successfully"
|
|
131
277
|
)
|
|
@@ -139,12 +285,13 @@ def remote_config(
|
|
|
139
285
|
timeout: int = 15,
|
|
140
286
|
) -> Any:
|
|
141
287
|
"""Get remote config flag value from remote_config API endpoint"""
|
|
142
|
-
|
|
288
|
+
response = get(
|
|
143
289
|
personal_api_key,
|
|
144
290
|
f"/api/projects/@current/feature_flags/{key}/remote_config?token={project_api_key}",
|
|
145
291
|
host,
|
|
146
292
|
timeout,
|
|
147
293
|
)
|
|
294
|
+
return response.data
|
|
148
295
|
|
|
149
296
|
|
|
150
297
|
def batch_post(
|
|
@@ -162,15 +309,42 @@ def batch_post(
|
|
|
162
309
|
|
|
163
310
|
|
|
164
311
|
def get(
|
|
165
|
-
api_key: str,
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
312
|
+
api_key: str,
|
|
313
|
+
url: str,
|
|
314
|
+
host: Optional[str] = None,
|
|
315
|
+
timeout: Optional[int] = None,
|
|
316
|
+
etag: Optional[str] = None,
|
|
317
|
+
) -> GetResponse:
|
|
318
|
+
"""
|
|
319
|
+
Make a GET request with optional ETag support.
|
|
320
|
+
|
|
321
|
+
If an etag is provided, sends If-None-Match header. Returns GetResponse with:
|
|
322
|
+
- not_modified=True and data=None if server returns 304
|
|
323
|
+
- not_modified=False and data=response if server returns 200
|
|
324
|
+
"""
|
|
325
|
+
log = logging.getLogger("posthog")
|
|
326
|
+
full_url = remove_trailing_slash(host or DEFAULT_HOST) + url
|
|
327
|
+
headers = {"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT}
|
|
328
|
+
|
|
329
|
+
if etag:
|
|
330
|
+
headers["If-None-Match"] = etag
|
|
331
|
+
|
|
332
|
+
res = _get_session().get(full_url, headers=headers, timeout=timeout)
|
|
333
|
+
|
|
334
|
+
masked_url = _mask_tokens_in_url(full_url)
|
|
335
|
+
|
|
336
|
+
# Handle 304 Not Modified
|
|
337
|
+
if res.status_code == 304:
|
|
338
|
+
log.debug(f"GET {masked_url} returned 304 Not Modified")
|
|
339
|
+
response_etag = res.headers.get("ETag")
|
|
340
|
+
return GetResponse(data=None, etag=response_etag or etag, not_modified=True)
|
|
341
|
+
|
|
342
|
+
# Handle normal response
|
|
343
|
+
data = _process_response(
|
|
344
|
+
res, success_message=f"GET {masked_url} completed successfully"
|
|
172
345
|
)
|
|
173
|
-
|
|
346
|
+
response_etag = res.headers.get("ETag")
|
|
347
|
+
return GetResponse(data=data, etag=response_etag, not_modified=False)
|
|
174
348
|
|
|
175
349
|
|
|
176
350
|
class APIError(Exception):
|
|
@@ -187,6 +361,12 @@ class QuotaLimitError(APIError):
|
|
|
187
361
|
pass
|
|
188
362
|
|
|
189
363
|
|
|
364
|
+
# Re-export requests exceptions for use in client.py
|
|
365
|
+
# This keeps all requests library imports centralized in this module
|
|
366
|
+
RequestsTimeout = requests.exceptions.Timeout
|
|
367
|
+
RequestsConnectionError = requests.exceptions.ConnectionError
|
|
368
|
+
|
|
369
|
+
|
|
190
370
|
class DatetimeSerializer(json.JSONEncoder):
|
|
191
371
|
def default(self, obj: Any):
|
|
192
372
|
if isinstance(obj, (date, datetime)):
|