easyapi-django 0.30__tar.gz → 0.32__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.32}/PKG-INFO +10 -7
- {easyapi_django-0.30 → easyapi_django-0.32}/README.md +9 -6
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/base.py +39 -9
- easyapi_django-0.32/easyapi/exception.py +24 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/rate_limit.py +25 -6
- {easyapi_django-0.30 → easyapi_django-0.32/easyapi_django.egg-info}/PKG-INFO +10 -7
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi_django.egg-info/SOURCES.txt +1 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/pyproject.toml +1 -1
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_cache_scope.py +28 -12
- easyapi_django-0.32/tests/test_exception_render.py +69 -0
- easyapi_django-0.30/easyapi/exception.py +0 -11
- {easyapi_django-0.30 → easyapi_django-0.32}/LICENSE +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/__init__.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/auth.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/blocking.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/calc.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/calc_resource.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/client_ip.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/constants.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/dates.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/filters.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/helpers.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/management/__init__.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/management/commands/__init__.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/management/commands/mcp_serve.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/mcp/__init__.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/mcp/bridge.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/mcp/protocol.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/mcp/resource.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/mcp/tools.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/middleware.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/openapi.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/orm/__init__.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/redis_config.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/routes.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/schemas.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/security.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/serializer.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/tenant/__init__.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/tenant/db_router.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/tenant/registry.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/tenant/tenant.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/util.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi/ws.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi_django.egg-info/dependency_links.txt +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi_django.egg-info/requires.txt +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/easyapi_django.egg-info/top_level.txt +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/setup.cfg +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_api_key_resolver.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_auth.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_base_init.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_block.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_cache.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_cache_auto_account_scope.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_client_ip.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_dispatch_error_handling.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_exports.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_filter_validation.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_filters.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_get_obj_m2m_only.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_helpers.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_index_route.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_mcp.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_mcp_middleware_chain.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_normalize_field.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_openapi.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_owner_field.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_redis_config.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_return_result_falsy.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_routes_gate.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_schemas.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_security.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_serializer.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_tenant_connection.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_tenant_registry.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_util.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_ws_channels.py +0 -0
- {easyapi_django-0.30 → easyapi_django-0.32}/tests/test_ws_optional.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: easyapi_django
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.32
|
|
4
4
|
Summary: A simple rest api generator for django based on models
|
|
5
5
|
Author-email: Stamatios Stamou Jr <bushier.outsets.0c@icloud.com>
|
|
6
6
|
License: MIT
|
|
@@ -278,12 +278,15 @@ class TaskResource(BaseResource):
|
|
|
278
278
|
cache_scope_fields = ['space_id', ('account', 'plan_id')]
|
|
279
279
|
```
|
|
280
280
|
|
|
281
|
-
|
|
282
|
-
configured field is **missing** from the session payload, the
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
281
|
+
Lenient by default: if the request has authenticated context but a
|
|
282
|
+
configured field is **missing** from the session payload, the framework
|
|
283
|
+
logs a `WARNING` (logger `easyapi.base`) and **skips the scope fold** —
|
|
284
|
+
the request still serves, the cache may be shared across scopes until
|
|
285
|
+
the operator updates the session payload. Anonymous requests also skip
|
|
286
|
+
the fold. `None`, `0` and `''` count as present (a real value).
|
|
287
|
+
|
|
288
|
+
Use `before_cache` for the rare case that needs context outside
|
|
289
|
+
`self.user` / `self.account`:
|
|
287
290
|
|
|
288
291
|
```python
|
|
289
292
|
async def before_cache(self, request):
|
|
@@ -251,12 +251,15 @@ class TaskResource(BaseResource):
|
|
|
251
251
|
cache_scope_fields = ['space_id', ('account', 'plan_id')]
|
|
252
252
|
```
|
|
253
253
|
|
|
254
|
-
|
|
255
|
-
configured field is **missing** from the session payload, the
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
254
|
+
Lenient by default: if the request has authenticated context but a
|
|
255
|
+
configured field is **missing** from the session payload, the framework
|
|
256
|
+
logs a `WARNING` (logger `easyapi.base`) and **skips the scope fold** —
|
|
257
|
+
the request still serves, the cache may be shared across scopes until
|
|
258
|
+
the operator updates the session payload. Anonymous requests also skip
|
|
259
|
+
the fold. `None`, `0` and `''` count as present (a real value).
|
|
260
|
+
|
|
261
|
+
Use `before_cache` for the rare case that needs context outside
|
|
262
|
+
`self.user` / `self.account`:
|
|
260
263
|
|
|
261
264
|
```python
|
|
262
265
|
async def before_cache(self, request):
|
|
@@ -393,18 +393,21 @@ class BaseResource(View):
|
|
|
393
393
|
for source, field in self.cache_scope_fields:
|
|
394
394
|
container = sources.get(source)
|
|
395
395
|
if container is None:
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
396
|
+
logger.warning(
|
|
397
|
+
"cache scope source %r missing from session for %s; "
|
|
398
|
+
"skipping scope fold (cache may be shared across "
|
|
399
|
+
"scopes — populate the session payload to fix)",
|
|
400
|
+
source, self.__class__.__name__,
|
|
400
401
|
)
|
|
402
|
+
return None
|
|
401
403
|
if field not in container:
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
404
|
+
logger.warning(
|
|
405
|
+
"cache scope field %r missing from %s session payload "
|
|
406
|
+
"for %s; skipping scope fold (cache may be shared "
|
|
407
|
+
"across scopes — populate the session payload to fix)",
|
|
408
|
+
field, source, self.__class__.__name__,
|
|
407
409
|
)
|
|
410
|
+
return None
|
|
408
411
|
parts.append(f'{source}.{field}={container[field]!r}')
|
|
409
412
|
|
|
410
413
|
digest = hashlib.md5(':'.join(parts).encode('utf-8')).hexdigest()[:16]
|
|
@@ -514,6 +517,33 @@ class BaseResource(View):
|
|
|
514
517
|
)
|
|
515
518
|
|
|
516
519
|
async def dispatch(self, request, *args, **kwargs) -> None:
|
|
520
|
+
"""Outer wrapper — guarantees unhandled exceptions never reach
|
|
521
|
+
the wire silently. ``HTTPException`` re-raises (rendered by
|
|
522
|
+
:class:`ExceptionMiddleware`); anything else gets logged with
|
|
523
|
+
full traceback and returns a sanitized JSON 500.
|
|
524
|
+
|
|
525
|
+
Without this net, exceptions from ``_authenticate``, ``aset_tenant``,
|
|
526
|
+
``_serve_cache``, ``pre_process``, ``_parse_body``, ``build_filters``
|
|
527
|
+
or ``serialize`` bubbled to Django's default async handler with no
|
|
528
|
+
easyapi-side log — Sentry stayed empty and operators were blind.
|
|
529
|
+
"""
|
|
530
|
+
try:
|
|
531
|
+
return await self._dispatch(request, *args, **kwargs)
|
|
532
|
+
except HTTPException:
|
|
533
|
+
raise
|
|
534
|
+
except Exception:
|
|
535
|
+
logger.exception(
|
|
536
|
+
'Unhandled error in dispatch %s %s',
|
|
537
|
+
request.method, request.path,
|
|
538
|
+
)
|
|
539
|
+
if getattr(django_settings, 'DEBUG', False):
|
|
540
|
+
raise
|
|
541
|
+
return JsonResponse(
|
|
542
|
+
{'success': False, 'status': 500, 'detail': 'Internal error'},
|
|
543
|
+
status=500,
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
async def _dispatch(self, request, *args, **kwargs) -> None:
|
|
517
547
|
self.identifier = get_client_ip(request)
|
|
518
548
|
|
|
519
549
|
await self._enforce_rate_limit(request)
|
|
@@ -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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: easyapi_django
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.32
|
|
4
4
|
Summary: A simple rest api generator for django based on models
|
|
5
5
|
Author-email: Stamatios Stamou Jr <bushier.outsets.0c@icloud.com>
|
|
6
6
|
License: MIT
|
|
@@ -278,12 +278,15 @@ class TaskResource(BaseResource):
|
|
|
278
278
|
cache_scope_fields = ['space_id', ('account', 'plan_id')]
|
|
279
279
|
```
|
|
280
280
|
|
|
281
|
-
|
|
282
|
-
configured field is **missing** from the session payload, the
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
281
|
+
Lenient by default: if the request has authenticated context but a
|
|
282
|
+
configured field is **missing** from the session payload, the framework
|
|
283
|
+
logs a `WARNING` (logger `easyapi.base`) and **skips the scope fold** —
|
|
284
|
+
the request still serves, the cache may be shared across scopes until
|
|
285
|
+
the operator updates the session payload. Anonymous requests also skip
|
|
286
|
+
the fold. `None`, `0` and `''` count as present (a real value).
|
|
287
|
+
|
|
288
|
+
Use `before_cache` for the rare case that needs context outside
|
|
289
|
+
`self.user` / `self.account`:
|
|
287
290
|
|
|
288
291
|
```python
|
|
289
292
|
async def before_cache(self, request):
|
|
@@ -154,28 +154,44 @@ def test_multiple_dimensions_combine():
|
|
|
154
154
|
assert a._build_cache_key(_request(), None) != b._build_cache_key(_request(), None)
|
|
155
155
|
|
|
156
156
|
|
|
157
|
-
# ──
|
|
157
|
+
# ── lenient semantics (warn + skip fold, never 500) ────────────────
|
|
158
158
|
|
|
159
159
|
|
|
160
|
-
def
|
|
160
|
+
def test_missing_field_logs_warning_and_skips_fold(caplog):
|
|
161
|
+
"""Missing scope field on authenticated request must not block the
|
|
162
|
+
request — silent leak between scopes is preferable to taking the
|
|
163
|
+
site down. Operators see a warning to fix the session payload."""
|
|
164
|
+
import logging as _logging
|
|
165
|
+
|
|
161
166
|
class _R(_Resource):
|
|
162
167
|
cache_scope_fields = ['space_id']
|
|
163
168
|
res = _make(_R, user={'id': 1}) # space_id NOT in user
|
|
164
|
-
with pytest.raises(HTTPException) as exc:
|
|
165
|
-
res._build_cache_key(_request(), None)
|
|
166
|
-
assert exc.value.args[0] == 500
|
|
167
|
-
assert 'space_id' in exc.value.args[1]
|
|
168
169
|
|
|
170
|
+
with caplog.at_level(_logging.WARNING, logger='easyapi.base'):
|
|
171
|
+
key = res._build_cache_key(_request(), None)
|
|
172
|
+
|
|
173
|
+
assert 'scope=' not in key # fold skipped
|
|
174
|
+
records = [r for r in caplog.records if r.name == 'easyapi.base']
|
|
175
|
+
assert records, 'expected a warning when scope field is missing'
|
|
176
|
+
msg = records[0].getMessage()
|
|
177
|
+
assert 'space_id' in msg
|
|
178
|
+
assert '_R' in msg or 'class' in msg.lower()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_missing_account_source_logs_warning_and_skips_fold(caplog):
|
|
182
|
+
import logging as _logging
|
|
169
183
|
|
|
170
|
-
def test_authenticated_user_missing_account_source_raises_500():
|
|
171
|
-
"""User present, account expected but absent — refuses to cache."""
|
|
172
184
|
class _R(_Resource):
|
|
173
185
|
cache_scope_fields = [('account', 'plan_id')]
|
|
174
186
|
res = _make(_R, user={'id': 1}, account=None)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
187
|
+
|
|
188
|
+
with caplog.at_level(_logging.WARNING, logger='easyapi.base'):
|
|
189
|
+
key = res._build_cache_key(_request(), None)
|
|
190
|
+
|
|
191
|
+
assert 'scope=' not in key
|
|
192
|
+
records = [r for r in caplog.records if r.name == 'easyapi.base']
|
|
193
|
+
assert records
|
|
194
|
+
assert 'account' in records[0].getMessage()
|
|
179
195
|
|
|
180
196
|
|
|
181
197
|
# ── coexistence with session_cache ─────────────────────────────────
|
|
@@ -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
|