apitally 0.11.2__py3-none-any.whl → 0.12.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.
apitally/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.11.2"
1
+ __version__ = "0.12.0"
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import logging
5
+ import random
5
6
  import time
6
7
  from functools import partial
7
8
  from typing import Any, Dict, Optional, Tuple
@@ -82,18 +83,21 @@ class ApitallyClient(ApitallyClientBase):
82
83
  data = self.get_sync_data()
83
84
  self._sync_data_queue.put_nowait((time.time(), data))
84
85
 
85
- failed_items = []
86
+ i = 0
86
87
  while not self._sync_data_queue.empty():
87
88
  timestamp, data = self._sync_data_queue.get_nowait()
88
89
  try:
89
90
  if (time_offset := time.time() - timestamp) <= MAX_QUEUE_TIME:
91
+ if i > 0:
92
+ await asyncio.sleep(random.uniform(0.1, 0.3))
90
93
  data["time_offset"] = time_offset
91
94
  await self._send_sync_data(client, data)
92
- self._sync_data_queue.task_done()
95
+ i += 1
93
96
  except httpx.HTTPError:
94
- failed_items.append((timestamp, data))
95
- for item in failed_items:
96
- self._sync_data_queue.put_nowait(item)
97
+ self._sync_data_queue.put_nowait((timestamp, data))
98
+ break
99
+ finally:
100
+ self._sync_data_queue.task_done()
97
101
 
98
102
  @retry(raise_on_giveup=False)
99
103
  async def _send_startup_data(self, client: httpx.AsyncClient, data: Dict[str, Any]) -> None:
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import logging
4
4
  import queue
5
+ import random
5
6
  import time
6
7
  from functools import partial
7
8
  from threading import Event, Thread
@@ -95,18 +96,21 @@ class ApitallyClient(ApitallyClientBase):
95
96
  data = self.get_sync_data()
96
97
  self._sync_data_queue.put_nowait((time.time(), data))
97
98
 
98
- failed_items = []
99
+ i = 0
99
100
  while not self._sync_data_queue.empty():
100
101
  timestamp, data = self._sync_data_queue.get_nowait()
101
102
  try:
102
103
  if (time_offset := time.time() - timestamp) <= MAX_QUEUE_TIME:
104
+ if i > 0:
105
+ time.sleep(random.uniform(0.1, 0.3))
103
106
  data["time_offset"] = time_offset
104
107
  self._send_sync_data(session, data)
105
- self._sync_data_queue.task_done()
108
+ i += 1
106
109
  except requests.RequestException:
107
- failed_items.append((timestamp, data))
108
- for item in failed_items:
109
- self._sync_data_queue.put_nowait(item)
110
+ self._sync_data_queue.put_nowait((timestamp, data))
111
+ break
112
+ finally:
113
+ self._sync_data_queue.task_done()
110
114
 
111
115
  @retry(raise_on_giveup=False)
112
116
  def _send_startup_data(self, session: requests.Session, data: Dict[str, Any]) -> None:
apitally/django.py CHANGED
@@ -94,7 +94,7 @@ class ApitallyMiddleware:
94
94
  response = self.get_response(request)
95
95
  response_time = time.perf_counter() - start_time
96
96
  path = self.get_path(request)
97
- if request.method is not None and path is not None:
97
+ if request.method is not None and request.method != "OPTIONS" and path is not None:
98
98
  try:
99
99
  consumer = self.get_consumer(request)
100
100
  consumer_identifier = consumer.identifier if consumer else None
apitally/flask.py CHANGED
@@ -93,7 +93,7 @@ class ApitallyMiddleware:
93
93
  response_headers: Headers,
94
94
  ) -> None:
95
95
  rule, is_handled_path = self.get_rule(environ)
96
- if is_handled_path or not self.filter_unhandled_paths:
96
+ if (is_handled_path or not self.filter_unhandled_paths) and environ["REQUEST_METHOD"] != "OPTIONS":
97
97
  consumer = self.get_consumer()
98
98
  consumer_identifier = consumer.identifier if consumer else None
99
99
  self.client.consumer_registry.add_or_update_consumer(consumer)
apitally/starlette.py CHANGED
@@ -1,35 +1,29 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import contextlib
4
5
  import json
5
6
  import time
6
- from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Union
7
+ from typing import Any, Callable, Dict, List, Optional, Tuple, Union
7
8
  from warnings import warn
8
9
 
9
10
  from httpx import HTTPStatusError
10
- from starlette.concurrency import iterate_in_threadpool
11
- from starlette.middleware.base import BaseHTTPMiddleware
11
+ from starlette.datastructures import Headers
12
+ from starlette.requests import Request
12
13
  from starlette.routing import BaseRoute, Match, Router
13
14
  from starlette.schemas import EndpointInfo, SchemaGenerator
14
- from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR
15
15
  from starlette.testclient import TestClient
16
- from starlette.types import ASGIApp
16
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
17
17
 
18
18
  from apitally.client.asyncio import ApitallyClient
19
19
  from apitally.client.base import Consumer as ApitallyConsumer
20
20
  from apitally.common import get_versions
21
21
 
22
22
 
23
- if TYPE_CHECKING:
24
- from starlette.middleware.base import RequestResponseEndpoint
25
- from starlette.requests import Request
26
- from starlette.responses import Response
27
-
28
-
29
23
  __all__ = ["ApitallyMiddleware", "ApitallyConsumer"]
30
24
 
31
25
 
32
- class ApitallyMiddleware(BaseHTTPMiddleware):
26
+ class ApitallyMiddleware:
33
27
  def __init__(
34
28
  self,
35
29
  app: ASGIApp,
@@ -40,6 +34,7 @@ class ApitallyMiddleware(BaseHTTPMiddleware):
40
34
  filter_unhandled_paths: bool = True,
41
35
  identify_consumer_callback: Optional[Callable[[Request], Union[str, ApitallyConsumer, None]]] = None,
42
36
  ) -> None:
37
+ self.app = app
43
38
  self.filter_unhandled_paths = filter_unhandled_paths
44
39
  self.identify_consumer_callback = identify_consumer_callback
45
40
  self.client = ApitallyClient(client_id=client_id, env=env)
@@ -47,7 +42,6 @@ class ApitallyMiddleware(BaseHTTPMiddleware):
47
42
  self._delayed_set_startup_data_task: Optional[asyncio.Task] = None
48
43
  self.delayed_set_startup_data(app_version, openapi_url)
49
44
  _register_shutdown_handler(app, self.client.handle_shutdown)
50
- super().__init__(app)
51
45
 
52
46
  def delayed_set_startup_data(self, app_version: Optional[str] = None, openapi_url: Optional[str] = None) -> None:
53
47
  self._delayed_set_startup_data_task = asyncio.create_task(
@@ -61,34 +55,55 @@ class ApitallyMiddleware(BaseHTTPMiddleware):
61
55
  data = _get_startup_data(self.app, app_version, openapi_url)
62
56
  self.client.set_startup_data(data)
63
57
 
64
- async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
65
- try:
58
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
59
+ if scope["type"] == "http" and scope["method"] != "OPTIONS":
60
+ request = Request(scope)
61
+ response_status = 0
62
+ response_time = 0.0
63
+ response_headers = Headers()
64
+ response_body = b""
66
65
  start_time = time.perf_counter()
67
- response = await call_next(request)
68
- except BaseException as e:
69
- await self.add_request(
70
- request=request,
71
- response=None,
72
- status_code=HTTP_500_INTERNAL_SERVER_ERROR,
73
- response_time=time.perf_counter() - start_time,
74
- exception=e,
75
- )
76
- raise e from None
66
+
67
+ async def send_wrapper(message: Message) -> None:
68
+ nonlocal response_time, response_status, response_headers, response_body
69
+ if message["type"] == "http.response.start":
70
+ response_time = time.perf_counter() - start_time
71
+ response_status = message["status"]
72
+ response_headers = Headers(scope=message)
73
+ elif message["type"] == "http.response.body" and response_status == 422:
74
+ response_body += message["body"]
75
+ await send(message)
76
+
77
+ try:
78
+ await self.app(scope, receive, send_wrapper)
79
+ except BaseException as e:
80
+ self.add_request(
81
+ request=request,
82
+ response_status=500,
83
+ response_time=time.perf_counter() - start_time,
84
+ response_headers=response_headers,
85
+ response_body=response_body,
86
+ exception=e,
87
+ )
88
+ raise e from None
89
+ else:
90
+ self.add_request(
91
+ request=request,
92
+ response_status=response_status,
93
+ response_time=response_time,
94
+ response_headers=response_headers,
95
+ response_body=response_body,
96
+ )
77
97
  else:
78
- await self.add_request(
79
- request=request,
80
- response=response,
81
- status_code=response.status_code,
82
- response_time=time.perf_counter() - start_time,
83
- )
84
- return response
98
+ await self.app(scope, receive, send) # pragma: no cover
85
99
 
86
- async def add_request(
100
+ def add_request(
87
101
  self,
88
102
  request: Request,
89
- response: Optional[Response],
90
- status_code: int,
103
+ response_status: int,
91
104
  response_time: float,
105
+ response_headers: Headers,
106
+ response_body: bytes,
92
107
  exception: Optional[BaseException] = None,
93
108
  ) -> None:
94
109
  path_template, is_handled_path = self.get_path_template(request)
@@ -100,26 +115,23 @@ class ApitallyMiddleware(BaseHTTPMiddleware):
100
115
  consumer=consumer_identifier,
101
116
  method=request.method,
102
117
  path=path_template,
103
- status_code=status_code,
118
+ status_code=response_status,
104
119
  response_time=response_time,
105
120
  request_size=request.headers.get("Content-Length"),
106
- response_size=response.headers.get("Content-Length") if response is not None else None,
121
+ response_size=response_headers.get("Content-Length"),
107
122
  )
108
- if (
109
- status_code == 422
110
- and response is not None
111
- and response.headers.get("Content-Type") == "application/json"
112
- ):
113
- body = await self.get_response_json(response)
114
- if isinstance(body, dict) and "detail" in body and isinstance(body["detail"], list):
115
- # Log FastAPI / Pydantic validation errors
116
- self.client.validation_error_counter.add_validation_errors(
117
- consumer=consumer_identifier,
118
- method=request.method,
119
- path=path_template,
120
- detail=body["detail"],
121
- )
122
- if status_code == 500 and exception is not None:
123
+ if response_status == 422 and response_body and response_headers.get("Content-Type") == "application/json":
124
+ with contextlib.suppress(json.JSONDecodeError):
125
+ body = json.loads(response_body)
126
+ if isinstance(body, dict) and "detail" in body and isinstance(body["detail"], list):
127
+ # Log FastAPI / Pydantic validation errors
128
+ self.client.validation_error_counter.add_validation_errors(
129
+ consumer=consumer_identifier,
130
+ method=request.method,
131
+ path=path_template,
132
+ detail=body["detail"],
133
+ )
134
+ if response_status == 500 and exception is not None:
123
135
  self.client.server_error_counter.add_server_error(
124
136
  consumer=consumer_identifier,
125
137
  method=request.method,
@@ -127,21 +139,6 @@ class ApitallyMiddleware(BaseHTTPMiddleware):
127
139
  exception=exception,
128
140
  )
129
141
 
130
- @staticmethod
131
- async def get_response_json(response: Response) -> Any:
132
- if hasattr(response, "body"):
133
- try:
134
- return json.loads(response.body)
135
- except json.JSONDecodeError: # pragma: no cover
136
- return None
137
- elif hasattr(response, "body_iterator"):
138
- try:
139
- response_body = [section async for section in response.body_iterator]
140
- response.body_iterator = iterate_in_threadpool(iter(response_body))
141
- return json.loads(b"".join(response_body))
142
- except json.JSONDecodeError: # pragma: no cover
143
- return None
144
-
145
142
  @staticmethod
146
143
  def get_path_template(request: Request) -> Tuple[str, bool]:
147
144
  for route in request.app.routes:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: apitally
3
- Version: 0.11.2
3
+ Version: 0.12.0
4
4
  Summary: Simple API monitoring & analytics for REST APIs built with FastAPI, Flask, Django, Starlette and Litestar.
5
5
  Home-page: https://apitally.io
6
6
  License: MIT
@@ -1,19 +1,19 @@
1
- apitally/__init__.py,sha256=bmMNWd6X6fx5JJd3CqzukH9Aez4xKCZuJNcmMU1pbEc,23
1
+ apitally/__init__.py,sha256=eHjt9DPsMbptabS2yGx9Yhbyzq5hFSUHXb7zc8Q_8-o,23
2
2
  apitally/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
- apitally/client/asyncio.py,sha256=L8cnh2Pd_abC9Y5dnoH18kPHjcQ1n_ngmJJu7yU1G2Q,4774
3
+ apitally/client/asyncio.py,sha256=Y5sbRLRnJCIJx9VQ2DGgQsYNKGvURV2U1y3VxHuPhgQ,4874
4
4
  apitally/client/base.py,sha256=v4LSOYNIOoeL3KTVyBlXBY5LCXc79Lvu9yK5Y_KILSQ,15442
5
5
  apitally/client/logging.py,sha256=QMsKIIAFo92PNBUleeTgsrsQa7SEal-oJa1oOHUr1wI,507
6
- apitally/client/threading.py,sha256=NhKgA8b9F1Sa0BdnTCcyVyWKI6vy9shGF9umG70kpfc,5113
6
+ apitally/client/threading.py,sha256=cASa0C9nyRp5gf5IzCDj6TE-v8t8SW4zJ38W6NdJ3Q8,5204
7
7
  apitally/common.py,sha256=GbVmnXxhRvV30d7CfCQ9r0AeXj14Mv9Jm_Yd1bRWP28,1088
8
- apitally/django.py,sha256=vqBloQH4WaxvIlVpDZoazPcj5ljFapi1kvUHRgkd0O4,13763
8
+ apitally/django.py,sha256=Zw8a971UwGKaEMPUtmlBbjufAYwMkSjRSQlss8FDY-E,13795
9
9
  apitally/django_ninja.py,sha256=dqQtnz2s8YWYHCwvkK5BjokjvpZJpPNhP0vng4kFtrQ,120
10
10
  apitally/django_rest_framework.py,sha256=dqQtnz2s8YWYHCwvkK5BjokjvpZJpPNhP0vng4kFtrQ,120
11
11
  apitally/fastapi.py,sha256=hEyYZsvIaA3OXZSSFdey5iqeEjfBPHgfNbyX8pLm7GI,123
12
- apitally/flask.py,sha256=KZxWN1xeXUazYYluu3aoKkZQ_aRljHmtjZi1AxvzpGw,6402
12
+ apitally/flask.py,sha256=7TJIoAT91-bR_7gZkL0clDk-Whl-V21hbo4nASaDmB4,6447
13
13
  apitally/litestar.py,sha256=sQcrHw-JV9AlpnXlrczmaDe0k6tD9PYQsc8nyQul8Ko,8802
14
14
  apitally/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- apitally/starlette.py,sha256=tizWLlKMltKU1w3pUqa4EHfY3wjneUVZyvndmgl82c4,8747
16
- apitally-0.11.2.dist-info/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
17
- apitally-0.11.2.dist-info/METADATA,sha256=enN-rzxB2Ooc8jFKhJrksz4cxrRH39f_43TkpW3ksrQ,6994
18
- apitally-0.11.2.dist-info/WHEEL,sha256=d2fvjOD7sXsVzChCqf0Ty0JbHKBaLYwDbGQDwQTnJ50,88
19
- apitally-0.11.2.dist-info/RECORD,,
15
+ apitally/starlette.py,sha256=PA_0BTy9aVtrY2_QrWU7Js5vjW1uxZ476rmg95fXq2g,8881
16
+ apitally-0.12.0.dist-info/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
17
+ apitally-0.12.0.dist-info/METADATA,sha256=gGryZ9u9sLV8mYrcoRhvYK4lQ4bFf9vhbvrCENA6T-I,6994
18
+ apitally-0.12.0.dist-info/WHEEL,sha256=d2fvjOD7sXsVzChCqf0Ty0JbHKBaLYwDbGQDwQTnJ50,88
19
+ apitally-0.12.0.dist-info/RECORD,,