easyapi-django 0.30__tar.gz → 0.31__tar.gz
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.
- {easyapi_django-0.30/easyapi_django.egg-info → easyapi_django-0.31}/PKG-INFO +1 -1
- easyapi_django-0.31/easyapi/exception.py +24 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/rate_limit.py +25 -6
- {easyapi_django-0.30 → easyapi_django-0.31/easyapi_django.egg-info}/PKG-INFO +1 -1
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi_django.egg-info/SOURCES.txt +1 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/pyproject.toml +1 -1
- easyapi_django-0.31/tests/test_exception_render.py +69 -0
- easyapi_django-0.30/easyapi/exception.py +0 -11
- {easyapi_django-0.30 → easyapi_django-0.31}/LICENSE +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/README.md +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/__init__.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/auth.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/base.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/blocking.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/calc.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/calc_resource.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/client_ip.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/constants.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/dates.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/filters.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/helpers.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/management/__init__.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/management/commands/__init__.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/management/commands/mcp_serve.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/mcp/__init__.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/mcp/bridge.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/mcp/protocol.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/mcp/resource.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/mcp/tools.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/middleware.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/openapi.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/orm/__init__.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/redis_config.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/routes.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/schemas.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/security.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/serializer.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/tenant/__init__.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/tenant/db_router.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/tenant/registry.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/tenant/tenant.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/util.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi/ws.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi_django.egg-info/dependency_links.txt +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi_django.egg-info/requires.txt +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/easyapi_django.egg-info/top_level.txt +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/setup.cfg +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_api_key_resolver.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_auth.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_base_init.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_block.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_cache.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_cache_auto_account_scope.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_cache_scope.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_client_ip.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_dispatch_error_handling.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_exports.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_filter_validation.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_filters.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_get_obj_m2m_only.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_helpers.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_index_route.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_mcp.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_mcp_middleware_chain.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_normalize_field.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_openapi.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_owner_field.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_redis_config.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_return_result_falsy.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_routes_gate.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_schemas.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_security.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_serializer.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_tenant_connection.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_tenant_registry.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_util.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_ws_channels.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.31}/tests/test_ws_optional.py +0 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from django.http import JsonResponse
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HTTPException(Exception):
|
|
9
|
+
|
|
10
|
+
def render(self, exception):
|
|
11
|
+
(status, detail) = exception.args
|
|
12
|
+
# 5xx is a server-side problem — emit a logged stack trace so the
|
|
13
|
+
# response body is not the only signal. Without this, every
|
|
14
|
+
# ``raise HTTPException(500, ...)`` was invisible to logs/Sentry
|
|
15
|
+
# and operators had to guess from black-box symptoms.
|
|
16
|
+
if isinstance(status, int) and status >= 500:
|
|
17
|
+
logger.exception(
|
|
18
|
+
'HTTPException %s: %s', status, detail,
|
|
19
|
+
exc_info=exception,
|
|
20
|
+
)
|
|
21
|
+
return JsonResponse(
|
|
22
|
+
{'success': False, 'status': status, 'detail': detail},
|
|
23
|
+
status=status,
|
|
24
|
+
)
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import logging
|
|
1
2
|
import time
|
|
3
|
+
|
|
2
4
|
from redis import StrictRedis
|
|
3
5
|
|
|
4
6
|
from .redis_config import REDIS_SERVER, REDIS_DB, KEY_PREFIX
|
|
5
7
|
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
6
10
|
|
|
7
11
|
class RateLimit(StrictRedis):
|
|
8
12
|
def __init__(self):
|
|
@@ -30,7 +34,15 @@ class RateLimit(StrictRedis):
|
|
|
30
34
|
return {"blocked": False}
|
|
31
35
|
|
|
32
36
|
def track_pattern(self, identifier, action="abuse"):
|
|
33
|
-
"""Detect regular request patterns indicative of abuse.
|
|
37
|
+
"""Detect regular request patterns indicative of abuse.
|
|
38
|
+
|
|
39
|
+
Reports the signal but does NOT auto-write a block key. Frontends
|
|
40
|
+
retrying after a server-side error look identical to a bot when
|
|
41
|
+
you only consider request timing — auto-blocking that traffic
|
|
42
|
+
locked legitimate users out and was opaque to debug. Callers may
|
|
43
|
+
decide to escalate based on the returned ``suspicious`` flag plus
|
|
44
|
+
other signals (e.g. authenticated identity, response codes).
|
|
45
|
+
"""
|
|
34
46
|
pattern_key = f"{KEY_PREFIX}rate_limit:pattern:{action}:{identifier}"
|
|
35
47
|
now = self.current_milli_time()
|
|
36
48
|
last_req = self.get(f"{pattern_key}:last")
|
|
@@ -50,8 +62,11 @@ class RateLimit(StrictRedis):
|
|
|
50
62
|
mean = sum(intervals) / len(intervals)
|
|
51
63
|
variance = sum((x - mean) ** 2 for x in intervals) / len(intervals)
|
|
52
64
|
if variance < 100:
|
|
53
|
-
|
|
54
|
-
|
|
65
|
+
logger.warning(
|
|
66
|
+
'rate_limit: suspicious request pattern from %s '
|
|
67
|
+
'(action=%s, mean_interval_ms=%.0f, variance=%.2f)',
|
|
68
|
+
identifier, action, mean, variance,
|
|
69
|
+
)
|
|
55
70
|
return {"suspicious": True, "reason": "Regular request intervals"}
|
|
56
71
|
return {"suspicious": False}
|
|
57
72
|
|
|
@@ -69,9 +84,13 @@ class RateLimit(StrictRedis):
|
|
|
69
84
|
return {"rate_limited": True, "abuse": False, "blocked": True}
|
|
70
85
|
|
|
71
86
|
if action in ["api", "login"]:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
87
|
+
# Pattern detection is recorded for observability but no
|
|
88
|
+
# longer short-circuits to ``abuse=True``. Timing-only
|
|
89
|
+
# signals caught legitimate retry traffic (frontends
|
|
90
|
+
# retrying after server-side errors look identical to bots
|
|
91
|
+
# when judged by intervals alone). Real abuse is caught by
|
|
92
|
+
# the count thresholds below; pattern alone goes to logs.
|
|
93
|
+
self.track_pattern(identifier, "abuse")
|
|
75
94
|
|
|
76
95
|
now = self.current_milli_time()
|
|
77
96
|
type_to_check = action
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""HTTPException.render must log 5xx errors so Sentry / log aggregators
|
|
2
|
+
see them. Silent 5xx renders were the root of an opaque-error incident
|
|
3
|
+
in production."""
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from easyapi.exception import HTTPException
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _render(status, detail='boom'):
|
|
13
|
+
exc = HTTPException(status, detail)
|
|
14
|
+
response = exc.render(exc)
|
|
15
|
+
body = json.loads(response.content.decode('utf-8'))
|
|
16
|
+
return response, body
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_500_logs_exception(caplog):
|
|
20
|
+
with caplog.at_level(logging.ERROR, logger='easyapi.exception'):
|
|
21
|
+
response, body = _render(500, 'database unreachable')
|
|
22
|
+
|
|
23
|
+
assert response.status_code == 500
|
|
24
|
+
assert body == {'success': False, 'status': 500, 'detail': 'database unreachable'}
|
|
25
|
+
|
|
26
|
+
records = [r for r in caplog.records if r.name == 'easyapi.exception']
|
|
27
|
+
assert records, 'expected a log record for 500 render'
|
|
28
|
+
record = records[0]
|
|
29
|
+
assert record.levelno == logging.ERROR
|
|
30
|
+
assert '500' in record.getMessage()
|
|
31
|
+
assert 'database unreachable' in record.getMessage()
|
|
32
|
+
# exc_info attached so traceback is preserved for Sentry-style sinks
|
|
33
|
+
assert record.exc_info is not None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_503_also_logs(caplog):
|
|
37
|
+
with caplog.at_level(logging.ERROR, logger='easyapi.exception'):
|
|
38
|
+
_render(503, 'upstream down')
|
|
39
|
+
records = [r for r in caplog.records if r.name == 'easyapi.exception']
|
|
40
|
+
assert records, '5xx other than 500 must also log'
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_400_does_not_log(caplog):
|
|
44
|
+
"""4xx is client-side; no log noise — they're routine."""
|
|
45
|
+
with caplog.at_level(logging.DEBUG, logger='easyapi.exception'):
|
|
46
|
+
response, body = _render(400, 'bad input')
|
|
47
|
+
assert response.status_code == 400
|
|
48
|
+
records = [r for r in caplog.records if r.name == 'easyapi.exception']
|
|
49
|
+
assert not records
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_403_does_not_log(caplog):
|
|
53
|
+
with caplog.at_level(logging.DEBUG, logger='easyapi.exception'):
|
|
54
|
+
_render(403, 'forbidden')
|
|
55
|
+
records = [r for r in caplog.records if r.name == 'easyapi.exception']
|
|
56
|
+
assert not records
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_404_does_not_log(caplog):
|
|
60
|
+
with caplog.at_level(logging.DEBUG, logger='easyapi.exception'):
|
|
61
|
+
_render(404, 'not found')
|
|
62
|
+
records = [r for r in caplog.records if r.name == 'easyapi.exception']
|
|
63
|
+
assert not records
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_response_body_unchanged_for_5xx():
|
|
67
|
+
"""Logging is additive — must not change the wire response."""
|
|
68
|
+
response, body = _render(500, 'something')
|
|
69
|
+
assert body == {'success': False, 'status': 500, 'detail': 'something'}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|