posthoganalytics 6.7.5__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.
Files changed (37) hide show
  1. posthoganalytics/__init__.py +84 -7
  2. posthoganalytics/ai/anthropic/anthropic_async.py +30 -67
  3. posthoganalytics/ai/anthropic/anthropic_converter.py +40 -0
  4. posthoganalytics/ai/gemini/__init__.py +3 -0
  5. posthoganalytics/ai/gemini/gemini.py +1 -1
  6. posthoganalytics/ai/gemini/gemini_async.py +423 -0
  7. posthoganalytics/ai/gemini/gemini_converter.py +160 -24
  8. posthoganalytics/ai/langchain/callbacks.py +55 -11
  9. posthoganalytics/ai/openai/openai.py +27 -2
  10. posthoganalytics/ai/openai/openai_async.py +49 -5
  11. posthoganalytics/ai/openai/openai_converter.py +130 -0
  12. posthoganalytics/ai/sanitization.py +27 -5
  13. posthoganalytics/ai/types.py +1 -0
  14. posthoganalytics/ai/utils.py +32 -2
  15. posthoganalytics/client.py +338 -90
  16. posthoganalytics/contexts.py +81 -0
  17. posthoganalytics/exception_utils.py +250 -2
  18. posthoganalytics/feature_flags.py +26 -10
  19. posthoganalytics/flag_definition_cache.py +127 -0
  20. posthoganalytics/integrations/django.py +149 -50
  21. posthoganalytics/request.py +203 -23
  22. posthoganalytics/test/test_client.py +250 -22
  23. posthoganalytics/test/test_exception_capture.py +418 -0
  24. posthoganalytics/test/test_feature_flag_result.py +441 -2
  25. posthoganalytics/test/test_feature_flags.py +306 -102
  26. posthoganalytics/test/test_flag_definition_cache.py +612 -0
  27. posthoganalytics/test/test_module.py +0 -8
  28. posthoganalytics/test/test_request.py +536 -0
  29. posthoganalytics/test/test_utils.py +4 -1
  30. posthoganalytics/types.py +40 -0
  31. posthoganalytics/version.py +1 -1
  32. {posthoganalytics-6.7.5.dist-info → posthoganalytics-7.4.3.dist-info}/METADATA +12 -12
  33. posthoganalytics-7.4.3.dist-info/RECORD +57 -0
  34. posthoganalytics-6.7.5.dist-info/RECORD +0 -54
  35. {posthoganalytics-6.7.5.dist-info → posthoganalytics-7.4.3.dist-info}/WHEEL +0 -0
  36. {posthoganalytics-6.7.5.dist-info → posthoganalytics-7.4.3.dist-info}/licenses/LICENSE +0 -0
  37. {posthoganalytics-6.7.5.dist-info → posthoganalytics-7.4.3.dist-info}/top_level.txt +0 -0
@@ -3,13 +3,19 @@ from posthoganalytics import contexts
3
3
  from posthoganalytics.client import Client
4
4
 
5
5
  try:
6
- from asgiref.sync import iscoroutinefunction
6
+ from asgiref.sync import iscoroutinefunction, markcoroutinefunction
7
7
  except ImportError:
8
- # Fallback for older Django versions
8
+ # Fallback for older Django versions without asgiref
9
9
  import asyncio
10
10
 
11
11
  iscoroutinefunction = asyncio.iscoroutinefunction
12
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
+
13
19
  if TYPE_CHECKING:
14
20
  from django.http import HttpRequest, HttpResponse # noqa: F401
15
21
  from typing import Callable, Dict, Any, Optional, Union, Awaitable # noqa: F401
@@ -39,26 +45,24 @@ class PosthogContextMiddleware:
39
45
  See the context documentation for more information. The extracted distinct ID and session ID, if found, are used to
40
46
  associate all events captured in the middleware context with the same distinct ID and session as currently active on the
41
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.
42
52
  """
43
53
 
44
- # Django middleware capability flags
45
54
  sync_capable = True
46
55
  async_capable = True
47
56
 
48
57
  def __init__(self, get_response):
49
58
  # type: (Union[Callable[[HttpRequest], HttpResponse], Callable[[HttpRequest], Awaitable[HttpResponse]]]) -> None
59
+ self.get_response = get_response
50
60
  self._is_coroutine = iscoroutinefunction(get_response)
51
- self._async_get_response = None # type: Optional[Callable[[HttpRequest], Awaitable[HttpResponse]]]
52
- self._sync_get_response = None # type: Optional[Callable[[HttpRequest], HttpResponse]]
53
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
54
64
  if self._is_coroutine:
55
- self._async_get_response = cast(
56
- "Callable[[HttpRequest], Awaitable[HttpResponse]]", get_response
57
- )
58
- else:
59
- self._sync_get_response = cast(
60
- "Callable[[HttpRequest], HttpResponse]", get_response
61
- )
65
+ markcoroutinefunction(self)
62
66
 
63
67
  from django.conf import settings
64
68
 
@@ -108,9 +112,18 @@ class PosthogContextMiddleware:
108
112
 
109
113
  def extract_tags(self, request):
110
114
  # type: (HttpRequest) -> Dict[str, Any]
111
- 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)
118
+
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.
112
123
 
113
- (user_id, user_email) = self.extract_request_user(request)
124
+ Centralized tag extraction logic used by both sync and async paths.
125
+ """
126
+ tags = {}
114
127
 
115
128
  # Extract session ID from X-POSTHOG-SESSION-ID header
116
129
  session_id = request.headers.get("X-POSTHOG-SESSION-ID")
@@ -162,59 +175,145 @@ class PosthogContextMiddleware:
162
175
  return tags
163
176
 
164
177
  def extract_request_user(self, request):
165
- user_id = None
166
- email = None
167
-
178
+ # type: (HttpRequest) -> tuple[Optional[str], Optional[str]]
179
+ """Extract user ID and email from request in sync context."""
168
180
  user = getattr(request, "user", None)
181
+ return self._resolve_user_details(user)
169
182
 
170
- if user and getattr(user, "is_authenticated", False):
171
- try:
172
- user_id = str(user.pk)
173
- except Exception:
174
- 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.
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.
175
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):
176
208
  try:
177
- email = str(user.email)
209
+ user = await auser()
210
+ return self._resolve_user_details(user)
178
211
  except Exception:
179
- pass
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)
180
250
 
181
251
  return user_id, email
182
252
 
183
253
  def __call__(self, request):
184
- # type: (HttpRequest) -> HttpResponse
185
- # Purely defensive around django's internal sync/async handling - this should be unreachable, but if it's reached, we may
186
- # as well return something semi-meaningful
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
+ """
187
261
  if self._is_coroutine:
188
- raise RuntimeError(
189
- "PosthogContextMiddleware received sync call but get_response is async"
190
- )
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)
191
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
+ """
192
283
  if self.request_filter and not self.request_filter(request):
193
- assert self._sync_get_response is not None
194
- return self._sync_get_response(request)
284
+ return await self.get_response(request)
195
285
 
196
286
  with contexts.new_context(self.capture_exceptions, client=self.client):
197
- for k, v in self.extract_tags(request).items():
287
+ for k, v in (await self.aextract_tags(request)).items():
198
288
  contexts.tag(k, v)
199
289
 
200
- assert self._sync_get_response is not None
201
- return self._sync_get_response(request)
290
+ return await self.get_response(request)
202
291
 
203
- async def __acall__(self, request):
204
- # type: (HttpRequest) -> HttpResponse
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
+ """
205
306
  if self.request_filter and not self.request_filter(request):
206
- if self._async_get_response is not None:
207
- return await self._async_get_response(request)
208
- else:
209
- assert self._sync_get_response is not None
210
- return self._sync_get_response(request)
307
+ return
211
308
 
212
- with contexts.new_context(self.capture_exceptions, client=self.client):
213
- for k, v in self.extract_tags(request).items():
214
- contexts.tag(k, v)
309
+ if not self.capture_exceptions:
310
+ return
311
+
312
+ # Context and tags already set by __call__ or __acall__
313
+ # Just capture the exception
314
+ if self.client:
315
+ self.client.capture_exception(exception)
316
+ else:
317
+ from posthoganalytics import capture_exception
215
318
 
216
- if self._async_get_response is not None:
217
- return await self._async_get_response(request)
218
- else:
219
- assert self._sync_get_response is not None
220
- return self._sync_get_response(request)
319
+ capture_exception(exception)
@@ -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
- # Retry on both connect and read errors
16
- # by default read errors will only retry idempotent HTTP methods (so not POST)
17
- adapter = requests.adapters.HTTPAdapter(
18
- max_retries=Retry(
19
- total=2,
20
- connect=2,
21
- read=2,
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
- _session = requests.sessions.Session()
25
- _session.mount("https://", adapter)
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 = _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
+ )
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 `kwargs to the flags API endpoint"""
128
- 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
+ )
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
- return get(
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, url: str, host: Optional[str] = None, timeout: Optional[int] = None
166
- ) -> requests.Response:
167
- url = remove_trailing_slash(host or DEFAULT_HOST) + url
168
- res = requests.get(
169
- url,
170
- headers={"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT},
171
- timeout=timeout,
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
- return _process_response(res, success_message=f"GET {url} completed successfully")
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)):