django-request-replay 0.1.1__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.
- django_request_replay-0.1.1/LICENSE +21 -0
- django_request_replay-0.1.1/PKG-INFO +203 -0
- django_request_replay-0.1.1/README.md +183 -0
- django_request_replay-0.1.1/django_request_replay/__init__.py +0 -0
- django_request_replay-0.1.1/django_request_replay/admin.py +17 -0
- django_request_replay-0.1.1/django_request_replay/apps.py +9 -0
- django_request_replay-0.1.1/django_request_replay/management/commands/__init__.py +0 -0
- django_request_replay-0.1.1/django_request_replay/management/commands/replay_request.py +53 -0
- django_request_replay-0.1.1/django_request_replay/management/commands/replay_requests.py +33 -0
- django_request_replay-0.1.1/django_request_replay/middleware.py +59 -0
- django_request_replay-0.1.1/django_request_replay/migrations/0001_initial.py +28 -0
- django_request_replay-0.1.1/django_request_replay/migrations/0002_replayrequest_body_truncated.py +18 -0
- django_request_replay-0.1.1/django_request_replay/migrations/__init__.py +0 -0
- django_request_replay-0.1.1/django_request_replay/models.py +17 -0
- django_request_replay-0.1.1/django_request_replay/redaction.py +57 -0
- django_request_replay-0.1.1/django_request_replay/replay.py +60 -0
- django_request_replay-0.1.1/django_request_replay/tests.py +493 -0
- django_request_replay-0.1.1/django_request_replay.egg-info/PKG-INFO +203 -0
- django_request_replay-0.1.1/django_request_replay.egg-info/SOURCES.txt +22 -0
- django_request_replay-0.1.1/django_request_replay.egg-info/dependency_links.txt +1 -0
- django_request_replay-0.1.1/django_request_replay.egg-info/requires.txt +2 -0
- django_request_replay-0.1.1/django_request_replay.egg-info/top_level.txt +1 -0
- django_request_replay-0.1.1/pyproject.toml +33 -0
- django_request_replay-0.1.1/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sedra Zeidan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-request-replay
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Replay failed Django HTTP requests locally
|
|
5
|
+
Author: Sedra Zeidan
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Framework :: Django
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: Django<7.0,>=6.1
|
|
18
|
+
Requires-Dist: requests<3.0,>=2.32
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# django-request-replay
|
|
22
|
+
|
|
23
|
+
Replay failed Django HTTP requests locally.
|
|
24
|
+
|
|
25
|
+
## The Problem
|
|
26
|
+
|
|
27
|
+
Some Django errors are difficult to reproduce.
|
|
28
|
+
|
|
29
|
+
A request may fail in production or staging, but reproducing the exact request locally can be difficult.
|
|
30
|
+
|
|
31
|
+
`django-request-replay` captures failed requests and allows developers to replay them later.
|
|
32
|
+
|
|
33
|
+
## How It Works
|
|
34
|
+
|
|
35
|
+
```text
|
|
36
|
+
HTTP Request
|
|
37
|
+
↓
|
|
38
|
+
Django Middleware
|
|
39
|
+
↓
|
|
40
|
+
Request fails
|
|
41
|
+
↓
|
|
42
|
+
Capture safely
|
|
43
|
+
↓
|
|
44
|
+
ReplayRequest
|
|
45
|
+
↓
|
|
46
|
+
Developer finds the request ID
|
|
47
|
+
↓
|
|
48
|
+
python manage.py replay_request <id>
|
|
49
|
+
↓
|
|
50
|
+
Request is replayed locally
|
|
51
|
+
Installation
|
|
52
|
+
Install the package with pip:
|
|
53
|
+
pip install django-request-replay
|
|
54
|
+
Add the app to INSTALLED_APPS:
|
|
55
|
+
INSTALLED_APPS = [
|
|
56
|
+
...
|
|
57
|
+
"django_request_replay",
|
|
58
|
+
]
|
|
59
|
+
Add the middleware:
|
|
60
|
+
MIDDLEWARE = [
|
|
61
|
+
"django_request_replay.middleware.RequestReplayMiddleware",
|
|
62
|
+
...
|
|
63
|
+
]
|
|
64
|
+
Run migrations:
|
|
65
|
+
python manage.py migrate
|
|
66
|
+
Configuration
|
|
67
|
+
Add the following settings to your Django project:
|
|
68
|
+
REPLAY_BASE_URL = "http://127.0.0.1:8000"
|
|
69
|
+
REPLAY_ENABLED = True
|
|
70
|
+
By default, the middleware captures:
|
|
71
|
+
500
|
|
72
|
+
502
|
|
73
|
+
503
|
|
74
|
+
504
|
|
75
|
+
You can customize the captured status codes:
|
|
76
|
+
REPLAY_STATUS_CODES = [500, 502, 503, 504]
|
|
77
|
+
The request body is limited to 10 MB by default:
|
|
78
|
+
MAX_BODY_SIZE = 10 * 1024 * 1024
|
|
79
|
+
Usage
|
|
80
|
+
List Captured Requests
|
|
81
|
+
To see captured failed requests:
|
|
82
|
+
python manage.py replay_requests
|
|
83
|
+
Example:
|
|
84
|
+
ID METHOD PATH STATUS
|
|
85
|
+
------------------------------------------------
|
|
86
|
+
12 POST /api/payment/ 500
|
|
87
|
+
11 GET /api/profile/ 503
|
|
88
|
+
10 POST /api/orders/ 500
|
|
89
|
+
You can filter by status code:
|
|
90
|
+
python manage.py replay_requests --status 500
|
|
91
|
+
Limit the number of results:
|
|
92
|
+
python manage.py replay_requests --limit 5
|
|
93
|
+
Combine filters:
|
|
94
|
+
python manage.py replay_requests --status 500 --limit 5
|
|
95
|
+
Replay a Request
|
|
96
|
+
Once you know the request ID:
|
|
97
|
+
python manage.py replay_request 12
|
|
98
|
+
Example:
|
|
99
|
+
Replay completed: POST /api/payment/ -> 500 (0.145s)
|
|
100
|
+
The request is sent again to the configured REPLAY_BASE_URL.
|
|
101
|
+
Verbose Mode
|
|
102
|
+
Use --verbose to see additional response information:
|
|
103
|
+
python manage.py replay_request 12 --verbose
|
|
104
|
+
Example:
|
|
105
|
+
Replay completed: POST /api/payment/ -> 500 (0.145s)
|
|
106
|
+
|
|
107
|
+
Response status: 500
|
|
108
|
+
Content-Type: text/html
|
|
109
|
+
Response size: 4218 bytes
|
|
110
|
+
Security
|
|
111
|
+
Sensitive request data is redacted before being stored.
|
|
112
|
+
Sensitive Headers
|
|
113
|
+
The following headers are redacted by default:
|
|
114
|
+
Authorization
|
|
115
|
+
Cookie
|
|
116
|
+
Set-Cookie
|
|
117
|
+
X-API-Key
|
|
118
|
+
Sensitive Body Fields
|
|
119
|
+
The following JSON fields are redacted:
|
|
120
|
+
password
|
|
121
|
+
token
|
|
122
|
+
secret
|
|
123
|
+
api_key
|
|
124
|
+
Example:
|
|
125
|
+
{
|
|
126
|
+
"username": "sedra",
|
|
127
|
+
"password": "[REDACTED]",
|
|
128
|
+
"token": "[REDACTED]"
|
|
129
|
+
}
|
|
130
|
+
Nested JSON objects and lists are also handled recursively.
|
|
131
|
+
Sensitive authentication headers are not sent during replay.
|
|
132
|
+
Large Request Bodies
|
|
133
|
+
Request bodies larger than the configured limit are not stored.
|
|
134
|
+
Instead, the request is marked as truncated:
|
|
135
|
+
body_truncated = True
|
|
136
|
+
This helps prevent the replay database from growing unexpectedly because of very large request bodies.
|
|
137
|
+
Replay Protection
|
|
138
|
+
Replayed requests include a special header:
|
|
139
|
+
X-Django-Replay: 1
|
|
140
|
+
The middleware detects this header and does not capture the replayed request again.
|
|
141
|
+
This prevents an infinite capture loop:
|
|
142
|
+
Replay request
|
|
143
|
+
↓
|
|
144
|
+
Fails again
|
|
145
|
+
↓
|
|
146
|
+
Captured again
|
|
147
|
+
↓
|
|
148
|
+
Replayed again
|
|
149
|
+
↓
|
|
150
|
+
...
|
|
151
|
+
Testing
|
|
152
|
+
Run the test suite with:
|
|
153
|
+
python example/manage.py test django_request_replay
|
|
154
|
+
The project includes tests for:
|
|
155
|
+
Request redaction
|
|
156
|
+
Header redaction
|
|
157
|
+
Nested JSON redaction
|
|
158
|
+
Failed request capture
|
|
159
|
+
Exception capture
|
|
160
|
+
5xx responses
|
|
161
|
+
Body truncation
|
|
162
|
+
Query parameters
|
|
163
|
+
Replay behavior
|
|
164
|
+
Sensitive headers
|
|
165
|
+
Replay loop prevention
|
|
166
|
+
Management commands
|
|
167
|
+
Disabled replay
|
|
168
|
+
Connection errors
|
|
169
|
+
Project Structure
|
|
170
|
+
|
|
171
|
+
django-request-replay/
|
|
172
|
+
│
|
|
173
|
+
├── django_request_replay/
|
|
174
|
+
│ ├── middleware.py
|
|
175
|
+
│ ├── models.py
|
|
176
|
+
│ ├── redaction.py
|
|
177
|
+
│ ├── replay.py
|
|
178
|
+
│ ├── admin.py
|
|
179
|
+
│ ├── tests.py
|
|
180
|
+
│ └── management/
|
|
181
|
+
│ └── commands/
|
|
182
|
+
│ ├── replay_request.py
|
|
183
|
+
│ └── replay_requests.py
|
|
184
|
+
│
|
|
185
|
+
├── example/
|
|
186
|
+
│ ├── manage.py
|
|
187
|
+
│ └── config/
|
|
188
|
+
│
|
|
189
|
+
├── pyproject.toml
|
|
190
|
+
├── README.md
|
|
191
|
+
└── .gitignore
|
|
192
|
+
Roadmap
|
|
193
|
+
[ ] Configurable redaction rules
|
|
194
|
+
[ ] Request retention and cleanup command
|
|
195
|
+
[ ] Better query parameter preservation
|
|
196
|
+
[ ] Structured logging
|
|
197
|
+
[ ] GitHub Actions CI
|
|
198
|
+
[ ] PyPI release
|
|
199
|
+
[ ] More Django version compatibility
|
|
200
|
+
[ ] Response comparison
|
|
201
|
+
[ ] Advanced replay options
|
|
202
|
+
License
|
|
203
|
+
MIT
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# django-request-replay
|
|
2
|
+
|
|
3
|
+
Replay failed Django HTTP requests locally.
|
|
4
|
+
|
|
5
|
+
## The Problem
|
|
6
|
+
|
|
7
|
+
Some Django errors are difficult to reproduce.
|
|
8
|
+
|
|
9
|
+
A request may fail in production or staging, but reproducing the exact request locally can be difficult.
|
|
10
|
+
|
|
11
|
+
`django-request-replay` captures failed requests and allows developers to replay them later.
|
|
12
|
+
|
|
13
|
+
## How It Works
|
|
14
|
+
|
|
15
|
+
```text
|
|
16
|
+
HTTP Request
|
|
17
|
+
↓
|
|
18
|
+
Django Middleware
|
|
19
|
+
↓
|
|
20
|
+
Request fails
|
|
21
|
+
↓
|
|
22
|
+
Capture safely
|
|
23
|
+
↓
|
|
24
|
+
ReplayRequest
|
|
25
|
+
↓
|
|
26
|
+
Developer finds the request ID
|
|
27
|
+
↓
|
|
28
|
+
python manage.py replay_request <id>
|
|
29
|
+
↓
|
|
30
|
+
Request is replayed locally
|
|
31
|
+
Installation
|
|
32
|
+
Install the package with pip:
|
|
33
|
+
pip install django-request-replay
|
|
34
|
+
Add the app to INSTALLED_APPS:
|
|
35
|
+
INSTALLED_APPS = [
|
|
36
|
+
...
|
|
37
|
+
"django_request_replay",
|
|
38
|
+
]
|
|
39
|
+
Add the middleware:
|
|
40
|
+
MIDDLEWARE = [
|
|
41
|
+
"django_request_replay.middleware.RequestReplayMiddleware",
|
|
42
|
+
...
|
|
43
|
+
]
|
|
44
|
+
Run migrations:
|
|
45
|
+
python manage.py migrate
|
|
46
|
+
Configuration
|
|
47
|
+
Add the following settings to your Django project:
|
|
48
|
+
REPLAY_BASE_URL = "http://127.0.0.1:8000"
|
|
49
|
+
REPLAY_ENABLED = True
|
|
50
|
+
By default, the middleware captures:
|
|
51
|
+
500
|
|
52
|
+
502
|
|
53
|
+
503
|
|
54
|
+
504
|
|
55
|
+
You can customize the captured status codes:
|
|
56
|
+
REPLAY_STATUS_CODES = [500, 502, 503, 504]
|
|
57
|
+
The request body is limited to 10 MB by default:
|
|
58
|
+
MAX_BODY_SIZE = 10 * 1024 * 1024
|
|
59
|
+
Usage
|
|
60
|
+
List Captured Requests
|
|
61
|
+
To see captured failed requests:
|
|
62
|
+
python manage.py replay_requests
|
|
63
|
+
Example:
|
|
64
|
+
ID METHOD PATH STATUS
|
|
65
|
+
------------------------------------------------
|
|
66
|
+
12 POST /api/payment/ 500
|
|
67
|
+
11 GET /api/profile/ 503
|
|
68
|
+
10 POST /api/orders/ 500
|
|
69
|
+
You can filter by status code:
|
|
70
|
+
python manage.py replay_requests --status 500
|
|
71
|
+
Limit the number of results:
|
|
72
|
+
python manage.py replay_requests --limit 5
|
|
73
|
+
Combine filters:
|
|
74
|
+
python manage.py replay_requests --status 500 --limit 5
|
|
75
|
+
Replay a Request
|
|
76
|
+
Once you know the request ID:
|
|
77
|
+
python manage.py replay_request 12
|
|
78
|
+
Example:
|
|
79
|
+
Replay completed: POST /api/payment/ -> 500 (0.145s)
|
|
80
|
+
The request is sent again to the configured REPLAY_BASE_URL.
|
|
81
|
+
Verbose Mode
|
|
82
|
+
Use --verbose to see additional response information:
|
|
83
|
+
python manage.py replay_request 12 --verbose
|
|
84
|
+
Example:
|
|
85
|
+
Replay completed: POST /api/payment/ -> 500 (0.145s)
|
|
86
|
+
|
|
87
|
+
Response status: 500
|
|
88
|
+
Content-Type: text/html
|
|
89
|
+
Response size: 4218 bytes
|
|
90
|
+
Security
|
|
91
|
+
Sensitive request data is redacted before being stored.
|
|
92
|
+
Sensitive Headers
|
|
93
|
+
The following headers are redacted by default:
|
|
94
|
+
Authorization
|
|
95
|
+
Cookie
|
|
96
|
+
Set-Cookie
|
|
97
|
+
X-API-Key
|
|
98
|
+
Sensitive Body Fields
|
|
99
|
+
The following JSON fields are redacted:
|
|
100
|
+
password
|
|
101
|
+
token
|
|
102
|
+
secret
|
|
103
|
+
api_key
|
|
104
|
+
Example:
|
|
105
|
+
{
|
|
106
|
+
"username": "sedra",
|
|
107
|
+
"password": "[REDACTED]",
|
|
108
|
+
"token": "[REDACTED]"
|
|
109
|
+
}
|
|
110
|
+
Nested JSON objects and lists are also handled recursively.
|
|
111
|
+
Sensitive authentication headers are not sent during replay.
|
|
112
|
+
Large Request Bodies
|
|
113
|
+
Request bodies larger than the configured limit are not stored.
|
|
114
|
+
Instead, the request is marked as truncated:
|
|
115
|
+
body_truncated = True
|
|
116
|
+
This helps prevent the replay database from growing unexpectedly because of very large request bodies.
|
|
117
|
+
Replay Protection
|
|
118
|
+
Replayed requests include a special header:
|
|
119
|
+
X-Django-Replay: 1
|
|
120
|
+
The middleware detects this header and does not capture the replayed request again.
|
|
121
|
+
This prevents an infinite capture loop:
|
|
122
|
+
Replay request
|
|
123
|
+
↓
|
|
124
|
+
Fails again
|
|
125
|
+
↓
|
|
126
|
+
Captured again
|
|
127
|
+
↓
|
|
128
|
+
Replayed again
|
|
129
|
+
↓
|
|
130
|
+
...
|
|
131
|
+
Testing
|
|
132
|
+
Run the test suite with:
|
|
133
|
+
python example/manage.py test django_request_replay
|
|
134
|
+
The project includes tests for:
|
|
135
|
+
Request redaction
|
|
136
|
+
Header redaction
|
|
137
|
+
Nested JSON redaction
|
|
138
|
+
Failed request capture
|
|
139
|
+
Exception capture
|
|
140
|
+
5xx responses
|
|
141
|
+
Body truncation
|
|
142
|
+
Query parameters
|
|
143
|
+
Replay behavior
|
|
144
|
+
Sensitive headers
|
|
145
|
+
Replay loop prevention
|
|
146
|
+
Management commands
|
|
147
|
+
Disabled replay
|
|
148
|
+
Connection errors
|
|
149
|
+
Project Structure
|
|
150
|
+
|
|
151
|
+
django-request-replay/
|
|
152
|
+
│
|
|
153
|
+
├── django_request_replay/
|
|
154
|
+
│ ├── middleware.py
|
|
155
|
+
│ ├── models.py
|
|
156
|
+
│ ├── redaction.py
|
|
157
|
+
│ ├── replay.py
|
|
158
|
+
│ ├── admin.py
|
|
159
|
+
│ ├── tests.py
|
|
160
|
+
│ └── management/
|
|
161
|
+
│ └── commands/
|
|
162
|
+
│ ├── replay_request.py
|
|
163
|
+
│ └── replay_requests.py
|
|
164
|
+
│
|
|
165
|
+
├── example/
|
|
166
|
+
│ ├── manage.py
|
|
167
|
+
│ └── config/
|
|
168
|
+
│
|
|
169
|
+
├── pyproject.toml
|
|
170
|
+
├── README.md
|
|
171
|
+
└── .gitignore
|
|
172
|
+
Roadmap
|
|
173
|
+
[ ] Configurable redaction rules
|
|
174
|
+
[ ] Request retention and cleanup command
|
|
175
|
+
[ ] Better query parameter preservation
|
|
176
|
+
[ ] Structured logging
|
|
177
|
+
[ ] GitHub Actions CI
|
|
178
|
+
[ ] PyPI release
|
|
179
|
+
[ ] More Django version compatibility
|
|
180
|
+
[ ] Response comparison
|
|
181
|
+
[ ] Advanced replay options
|
|
182
|
+
License
|
|
183
|
+
MIT
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from django.contrib import admin
|
|
2
|
+
|
|
3
|
+
from .models import ReplayRequest
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@admin.register(ReplayRequest)
|
|
7
|
+
class ReplayRequestAdmin(admin.ModelAdmin):
|
|
8
|
+
list_display = (
|
|
9
|
+
"id",
|
|
10
|
+
"method",
|
|
11
|
+
"path",
|
|
12
|
+
"status_code",
|
|
13
|
+
"body_truncated",
|
|
14
|
+
"created_at",
|
|
15
|
+
)
|
|
16
|
+
list_filter = ("status_code", "body_truncated", "method")
|
|
17
|
+
search_fields = ("path", "method")
|
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
2
|
+
import requests
|
|
3
|
+
from django_request_replay.replay import replay_request
|
|
4
|
+
from django_request_replay.models import ReplayRequest
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Command(BaseCommand):
|
|
8
|
+
help = "Replay a captured Django request"
|
|
9
|
+
|
|
10
|
+
def add_arguments(self, parser):
|
|
11
|
+
parser.add_argument(
|
|
12
|
+
"replay_request_id",
|
|
13
|
+
type=int,
|
|
14
|
+
)
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"--verbose",
|
|
17
|
+
action="store_true",
|
|
18
|
+
help="Print detailed information about the replayed request and response",)
|
|
19
|
+
|
|
20
|
+
def handle(self, *args, **options):
|
|
21
|
+
replay_request_id = options["replay_request_id"]
|
|
22
|
+
|
|
23
|
+
if not ReplayRequest.objects.filter(
|
|
24
|
+
id=replay_request_id
|
|
25
|
+
).exists():
|
|
26
|
+
raise CommandError(
|
|
27
|
+
f"ReplayRequest with id {replay_request_id} does not exist."
|
|
28
|
+
)
|
|
29
|
+
captured_request = ReplayRequest.objects.get(
|
|
30
|
+
id=replay_request_id
|
|
31
|
+
)
|
|
32
|
+
try:
|
|
33
|
+
response = replay_request(replay_request_id)
|
|
34
|
+
except requests.exceptions.ConnectionError:
|
|
35
|
+
raise CommandError(
|
|
36
|
+
"Could not connect to the replay server. "
|
|
37
|
+
"Make sure Django is running and REPLAY_BASE_URL is correct."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
self.stdout.write(
|
|
41
|
+
self.style.SUCCESS(
|
|
42
|
+
f"Replay completed: "
|
|
43
|
+
f"{captured_request.method} "
|
|
44
|
+
f"{captured_request.path} "
|
|
45
|
+
f"-> {response.status_code} "
|
|
46
|
+
f"({response.replay_elapsed_time:.3f}s)"
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
if options["verbose"]:
|
|
50
|
+
self.stdout.write("")
|
|
51
|
+
self.stdout.write(f"Response status: {response.status_code}")
|
|
52
|
+
self.stdout.write(f"Content-Type: {response.headers.get('Content-Type')}")
|
|
53
|
+
self.stdout.write(f"Response size: {len(response.content)} bytes")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from django.core.management.base import BaseCommand
|
|
2
|
+
|
|
3
|
+
from django_request_replay.models import ReplayRequest
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Command(BaseCommand):
|
|
7
|
+
help = "List captured failed requests"
|
|
8
|
+
|
|
9
|
+
def handle(self, *args, **options):
|
|
10
|
+
requests = ReplayRequest.objects.order_by("-created_at")
|
|
11
|
+
|
|
12
|
+
if not requests.exists():
|
|
13
|
+
self.stdout.write("No captured requests found.")
|
|
14
|
+
return
|
|
15
|
+
|
|
16
|
+
self.stdout.write(
|
|
17
|
+
f"{'ID':<6}"
|
|
18
|
+
f"{'METHOD':<10}"
|
|
19
|
+
f"{'PATH':<40}"
|
|
20
|
+
f"{'STATUS':<10}"
|
|
21
|
+
f"CREATED"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
self.stdout.write("-" * 90)
|
|
25
|
+
|
|
26
|
+
for request in requests:
|
|
27
|
+
self.stdout.write(
|
|
28
|
+
f"{request.id:<6}"
|
|
29
|
+
f"{request.method:<10}"
|
|
30
|
+
f"{request.path:<40}"
|
|
31
|
+
f"{request.status_code:<10}"
|
|
32
|
+
f"{request.created_at}"
|
|
33
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from .models import ReplayRequest
|
|
2
|
+
from .redaction import redact_headers , redact_body
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
|
|
5
|
+
class RequestReplayMiddleware:
|
|
6
|
+
def __init__(self, get_response):
|
|
7
|
+
self.get_response = get_response
|
|
8
|
+
|
|
9
|
+
self.status_codes = getattr(
|
|
10
|
+
settings,
|
|
11
|
+
"REPLAY_STATUS_CODES",
|
|
12
|
+
[500, 502, 503, 504],
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
self.max_body_size = getattr(
|
|
16
|
+
settings,
|
|
17
|
+
"MAX_BODY_SIZE",
|
|
18
|
+
10 * 1024 * 1024,
|
|
19
|
+
)
|
|
20
|
+
def _get_body(self, request):
|
|
21
|
+
body = request.body
|
|
22
|
+
if len(body) > self.max_body_size:
|
|
23
|
+
return "", True
|
|
24
|
+
return redact_body(
|
|
25
|
+
body.decode("utf-8", errors="replace")
|
|
26
|
+
), False
|
|
27
|
+
def _save_request(self, request, status_code):
|
|
28
|
+
body, body_truncated = self._get_body(request)
|
|
29
|
+
|
|
30
|
+
user = getattr(request, "user", None)
|
|
31
|
+
|
|
32
|
+
ReplayRequest.objects.create(
|
|
33
|
+
method=request.method,
|
|
34
|
+
path=request.path,
|
|
35
|
+
query_params=request.GET.dict(),
|
|
36
|
+
headers=redact_headers(request.headers),
|
|
37
|
+
body=body,
|
|
38
|
+
body_truncated=body_truncated,
|
|
39
|
+
status_code=status_code,
|
|
40
|
+
user_id=(
|
|
41
|
+
str(user.pk)
|
|
42
|
+
if user is not None and user.is_authenticated
|
|
43
|
+
else None
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
def __call__(self, request):
|
|
47
|
+
if request.headers.get("X-Django-Replay") == "1":
|
|
48
|
+
return self.get_response(request)
|
|
49
|
+
try:
|
|
50
|
+
response = self.get_response(request)
|
|
51
|
+
|
|
52
|
+
except Exception:
|
|
53
|
+
self._save_request(request, 500)
|
|
54
|
+
raise
|
|
55
|
+
|
|
56
|
+
if response.status_code in self.status_codes:
|
|
57
|
+
self._save_request(request, response.status_code)
|
|
58
|
+
return response
|
|
59
|
+
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Generated by Django 6.1.1 on 2026-09-25 22:51
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
initial = True
|
|
9
|
+
|
|
10
|
+
dependencies = [
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.CreateModel(
|
|
15
|
+
name='ReplayRequest',
|
|
16
|
+
fields=[
|
|
17
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
18
|
+
('method', models.CharField(max_length=10)),
|
|
19
|
+
('path', models.CharField(max_length=500)),
|
|
20
|
+
('query_params', models.JSONField(default=dict)),
|
|
21
|
+
('headers', models.JSONField(default=dict)),
|
|
22
|
+
('body', models.TextField(blank=True)),
|
|
23
|
+
('status_code', models.PositiveIntegerField()),
|
|
24
|
+
('user_id', models.CharField(blank=True, max_length=255, null=True)),
|
|
25
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
26
|
+
],
|
|
27
|
+
),
|
|
28
|
+
]
|
django_request_replay-0.1.1/django_request_replay/migrations/0002_replayrequest_body_truncated.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Generated by Django 6.1.1 on 2026-09-27 23:15
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('django_request_replay', '0001_initial'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AddField(
|
|
14
|
+
model_name='replayrequest',
|
|
15
|
+
name='body_truncated',
|
|
16
|
+
field=models.BooleanField(default=False),
|
|
17
|
+
),
|
|
18
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ReplayRequest(models.Model):
|
|
5
|
+
method = models.CharField(max_length=10)
|
|
6
|
+
path = models.CharField(max_length=500)
|
|
7
|
+
query_params = models.JSONField(default=dict)
|
|
8
|
+
headers = models.JSONField(default=dict)
|
|
9
|
+
body = models.TextField(blank=True)
|
|
10
|
+
body_truncated = models.BooleanField(default=False)
|
|
11
|
+
status_code = models.PositiveIntegerField()
|
|
12
|
+
user_id = models.CharField(
|
|
13
|
+
max_length=255,
|
|
14
|
+
null=True,
|
|
15
|
+
blank=True,
|
|
16
|
+
)
|
|
17
|
+
created_at = models.DateTimeField(auto_now_add=True)
|