pdmv-http-client 2.1.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.
@@ -0,0 +1,277 @@
1
+ """
2
+ REST client for the McM application.
3
+ """
4
+
5
+ import warnings
6
+ from typing import Union
7
+
8
+ from rest.applications.base import BaseClient
9
+
10
+
11
+ class McM(BaseClient):
12
+ """
13
+ Initializes an HTTP client for querying McM.
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ id: str = BaseClient.SSO,
19
+ debug: bool = False,
20
+ cookie: Union[str, None] = None,
21
+ dev: bool = True,
22
+ client_id: str = "",
23
+ client_secret: str = "",
24
+ ):
25
+ # Set the HTTP session
26
+ super().__init__(
27
+ app="mcm",
28
+ id=id,
29
+ debug=debug,
30
+ cookie=cookie,
31
+ dev=dev,
32
+ client_id=client_id,
33
+ client_secret=client_secret,
34
+ )
35
+
36
+ def __get(self, url):
37
+ warnings.warn(
38
+ "This name mangled method will be removed in the future, use self._get(...) instead",
39
+ DeprecationWarning,
40
+ stacklevel=2,
41
+ )
42
+ return self._get(url=url)
43
+
44
+ def __put(self, url, data):
45
+ warnings.warn(
46
+ "This name mangled method will be removed in the future, use self._put(...) instead",
47
+ DeprecationWarning,
48
+ stacklevel=2,
49
+ )
50
+ return self._put(url=url, data=data)
51
+
52
+ def __post(self, url, data):
53
+ warnings.warn(
54
+ "This name mangled method will be removed in the future, use self._post(...) instead",
55
+ DeprecationWarning,
56
+ stacklevel=2,
57
+ )
58
+ return self._post(url=url, data=data)
59
+
60
+ def __delete(self, url):
61
+ warnings.warn(
62
+ "This name mangled method will be removed in the future, use self._delete(...) instead",
63
+ DeprecationWarning,
64
+ stacklevel=2,
65
+ )
66
+ return self._delete(url=url)
67
+
68
+ # McM methods
69
+ def get(self, object_type, object_id=None, query="", method="get", page=-1):
70
+ """
71
+ Get data from McM
72
+ object_type - [chained_campaigns, chained_requests, campaigns, requests, flows, etc.]
73
+ object_id - usually prep id of desired object
74
+ query - query to be run in order to receive an object, e.g. tags=M17p1A, multiple parameters can be used with & tags=M17p1A&pwg=HIG
75
+ method - action to be performed, such as get, migrate or inspect
76
+ page - which page to be fetched. -1 means no pagination, return all results
77
+ """
78
+ object_type = object_type.strip()
79
+ if object_id:
80
+ object_id = object_id.strip()
81
+ self.logger.debug(
82
+ "Object ID %s provided, method is %s, database %s",
83
+ object_id,
84
+ method,
85
+ object_type,
86
+ )
87
+ url = "restapi/%s/%s/%s" % (object_type, method, object_id)
88
+ result = self._get(url)
89
+ if type(result) == list:
90
+ return result
91
+ elif type(result) == str:
92
+ return result
93
+ result = result.get("results")
94
+ if not result:
95
+ return None
96
+ return result
97
+ elif query:
98
+ if page != -1:
99
+ self.logger.debug(
100
+ "Fetching page %s of %s for query %s", page, object_type, query
101
+ )
102
+ url = "search/?db_name=%s&limit=50&page=%d&%s" % (
103
+ object_type,
104
+ page,
105
+ query,
106
+ )
107
+ results = self._get(url).get("results", [])
108
+ self.logger.debug(
109
+ "Found %s %s in page %s for query %s",
110
+ len(results),
111
+ object_type,
112
+ page,
113
+ query,
114
+ )
115
+ return results
116
+ else:
117
+ self.logger.debug(
118
+ "Page not given, will use pagination to build response"
119
+ )
120
+ page_results = [{}]
121
+ results = []
122
+ page = 0
123
+ while page_results:
124
+ page_results = self.get(
125
+ object_type=object_type, query=query, method=method, page=page
126
+ )
127
+ results += page_results
128
+ page += 1
129
+
130
+ return results
131
+ else:
132
+ self.logger.error("Neither object ID, nor query is given, doing nothing...")
133
+
134
+ def update(self, object_type, object_data):
135
+ """
136
+ Update data in McM
137
+ object_type - [chained_campaigns, chained_requests, campaigns, requests, flows, etc.]
138
+ object_data - new JSON of an object to be updated
139
+ """
140
+ return self.put(object_type, object_data, method="update")
141
+
142
+ def put(self, object_type, object_data, method="save"):
143
+ """
144
+ Put data into McM
145
+ object_type - [chained_campaigns, chained_requests, campaigns, requests, flows, etc.]
146
+ object_data - new JSON of an object to be updated
147
+ method - action to be performed, default is 'save'
148
+ """
149
+ url = "restapi/%s/%s" % (object_type, method)
150
+ res = self._put(url, object_data)
151
+ return res
152
+
153
+ def post(self, object_type, object_data, method):
154
+ """
155
+ Post data into McM
156
+ object_type - [chained_campaigns, chained_requests, campaigns, requests, flows, etc.]
157
+ object_data - JSON to be posted
158
+ method - restapi to be called
159
+ """
160
+ url = "restapi/%s/%s" % (object_type, method)
161
+ res = self._post(url, object_data)
162
+ return res
163
+
164
+ def approve(self, object_type, object_id, level=None):
165
+ if level is None:
166
+ url = "restapi/%s/approve/%s" % (object_type, object_id)
167
+ else:
168
+ url = "restapi/%s/approve/%s/%d" % (object_type, object_id, level)
169
+
170
+ return self._get(url)
171
+
172
+ def clone_request(self, object_data):
173
+ return self.put("requests", object_data, method="clone")
174
+
175
+ def get_range_of_requests(self, query):
176
+ res = self._put("restapi/requests/listwithfile", data={"contents": query})
177
+ return res.get("results", None)
178
+
179
+ def delete(self, object_type, object_id):
180
+ url = "restapi/%s/delete/%s" % (object_type, object_id)
181
+ self._delete(url)
182
+
183
+ def forceflow(self, prepid):
184
+ """
185
+ Force a flow on a chained request with given `prepid`
186
+ """
187
+ res = self._get("restapi/chained_requests/flow/%s/force" % (prepid))
188
+ return res.get("results", None)
189
+
190
+ def reset(self, prepid):
191
+ """
192
+ Reset a request
193
+ """
194
+ res = self._get("restapi/requests/reset/%s" % (prepid))
195
+ return res.get("results", None)
196
+
197
+ def soft_reset(self, prepid):
198
+ """
199
+ Soft reset a request
200
+ """
201
+ res = self._get("restapi/requests/soft_reset/%s" % (prepid))
202
+ return res.get("results", None)
203
+
204
+ def option_reset(self, prepid):
205
+ """
206
+ Option reset a request
207
+ """
208
+ res = self._get("restapi/requests/option_reset/%s" % (prepid))
209
+ return res.get("results", None)
210
+
211
+ def ticket_generate(self, ticket_prepid):
212
+ """
213
+ Generate chains for a ticket
214
+ """
215
+ res = self._get("restapi/mccms/generate/%s" % (ticket_prepid))
216
+ return res.get("results", None)
217
+
218
+ def ticket_generate_reserve(self, ticket_prepid):
219
+ """
220
+ Generate and reserve chains for a ticket
221
+ """
222
+ res = self._get("restapi/mccms/generate/%s/reserve" % (ticket_prepid))
223
+ return res.get("results", None)
224
+
225
+ def rewind(self, chained_request_prepid):
226
+ """
227
+ Rewind a chained request
228
+ """
229
+ res = self._get("restapi/chained_requests/rewind/%s" % (chained_request_prepid))
230
+ return res.get("results", None)
231
+
232
+ def flow(self, chained_request_prepid):
233
+ """
234
+ Flow a chained request
235
+ """
236
+ res = self._get("restapi/chained_requests/flow/%s" % (chained_request_prepid))
237
+ return res.get("results", None)
238
+
239
+ def root_requests_from_ticket(self, ticket_prepid):
240
+ """
241
+ Return list of all root (first ones in the chain) requests of a ticket
242
+ """
243
+ mccm = self.get("mccms", ticket_prepid)
244
+ query = ""
245
+ for root_request in mccm.get("requests", []):
246
+ if isinstance(root_request, str):
247
+ query += "%s\n" % (root_request)
248
+ elif isinstance(root_request, list):
249
+ # List always contains two elements - start and end of a range
250
+ query += "%s -> %s\n" % (root_request[0], root_request[1])
251
+ else:
252
+ self.logger.error(
253
+ "%s is of unsupported type %s", root_request, type(root_request)
254
+ )
255
+
256
+ requests = self.get_range_of_requests(query)
257
+ return requests
258
+
259
+ def is_root_request(self, request: Union[str, dict]) -> bool:
260
+ """
261
+ Checks if a request is a root request.
262
+
263
+ Args:
264
+ request: Request prepid or request data.
265
+ The request must exists and its data must comply the expected
266
+ schema.
267
+ """
268
+ request_data: dict = {}
269
+ if isinstance(request, str):
270
+ # Received request prepid
271
+ request_data = self.get(object_type="requests", object_id=request) or {}
272
+ elif isinstance(request, dict):
273
+ request_data = request
274
+ else:
275
+ raise ValueError(f"Unexpected value: {request} - {type(request)}")
276
+
277
+ return request_data.get("type", "") in ("LHE", "Prod")
@@ -0,0 +1,399 @@
1
+ """
2
+ This module allows invalidating
3
+ root requests and its generated chains.
4
+ This is intended to be used only for
5
+ `production_manager` users or `administrators`.
6
+ """
7
+
8
+ import datetime
9
+ import logging
10
+ from copy import deepcopy
11
+ from itertools import groupby
12
+ from typing import Union
13
+
14
+ from rest import McM
15
+ from rest.utils.miscellaneous import pformat
16
+
17
+
18
+ class InvalidateDeleteRequests:
19
+ """
20
+ Invalidate and delete all the chain requests in McM
21
+ linked to a root request in McM and delete the
22
+ root requests if required.
23
+ """
24
+
25
+ def __init__(self, mcm: McM) -> None:
26
+ self.mcm = mcm
27
+ self.logger = self._get_logger()
28
+ self._chain_request_type = "chained_requests"
29
+ self._request_type = "requests"
30
+ self.logger.warning(
31
+ "Sending requests to target environment: %s", self.mcm.server
32
+ )
33
+
34
+ def _get_logger(self) -> logging.Logger:
35
+ """
36
+ Creates a logger to record the performed steps.
37
+ """
38
+ logger: logging.Logger = logging.getLogger(__name__)
39
+ logger.handlers.clear() # Avoid to record the same message twice in the log
40
+ formatter = logging.Formatter("[%(asctime)s][%(levelname)s] %(message)s")
41
+ current_date = str(datetime.datetime.now().strftime("%Y_%m_%d_%H_%M_%S"))
42
+ fh: logging.Handler = logging.FileHandler(
43
+ f"mcm_invalidate_requests_{current_date}.log"
44
+ )
45
+ fh.setFormatter(formatter)
46
+ logger.addHandler(fh)
47
+ return logger
48
+
49
+ def _get_invalidations(self, requests: list[str]) -> list[dict]:
50
+ """
51
+ Get all the invalidation records related to the given requests.
52
+
53
+ Args:
54
+ requests: List of `prepids` used to retrieve its invalidations.
55
+
56
+ Returns:
57
+ Invalidations related to the requests.
58
+ """
59
+ results: list[dict] = []
60
+ for req in requests:
61
+ inv_req = self.mcm.get("invalidations", query=f"prepid={req}")
62
+ assert isinstance(inv_req, list)
63
+ results += inv_req
64
+
65
+ return results
66
+
67
+ def _announce_invalidations(self, invalidations: list[dict]) -> dict:
68
+ """
69
+ Announce an invalidation for the given invalidation records.
70
+
71
+ Args:
72
+ invalidations: List of `invalidation` records to announce.
73
+
74
+ Returns:
75
+ Announce result.
76
+ """
77
+ # Flatten and just get the `_id` field
78
+ inv_ids: list[str] = [
79
+ i.get("_id", "") for i in invalidations if i.get("_id", "")
80
+ ]
81
+ announce_result = self.mcm.put(
82
+ object_type="invalidations", object_data=inv_ids, method="announce"
83
+ )
84
+
85
+ return announce_result
86
+
87
+ def _process_invalidation(self, request_prepids: list[str]) -> None:
88
+ """
89
+ Get all the invalidations related to a request and announces them
90
+ in case they exist.
91
+
92
+ Args:
93
+ request_prepids: List of request's prepid to process its
94
+ invalidation.
95
+ """
96
+ invalidations_to_announce = self._get_invalidations(requests=request_prepids)
97
+ if invalidations_to_announce:
98
+ announce_result = self._announce_invalidations(
99
+ invalidations=invalidations_to_announce
100
+ )
101
+ self.logger.info("Invalidation result: %s", announce_result)
102
+ if not announce_result.get("results"):
103
+ msg = f"Unable to invalidate records for all the following requests: {request_prepids}"
104
+ self.logger.error(msg)
105
+ self.logger.error("Invalidation records: %s", invalidations_to_announce)
106
+ raise RuntimeError(msg)
107
+ pass
108
+
109
+ def _invalidate_delete_chain_requests(
110
+ self, chain_req_data: list[dict], remove_chain: bool = False
111
+ ) -> None:
112
+ """
113
+ Rewinds the chain request to the root request
114
+ invalidating and deleting all the intermediate requests.
115
+
116
+ Also, deletes the chain request also if required.
117
+
118
+ Args:
119
+ chain_req_data: Chain requests data objects
120
+ to process.
121
+ remove_chain: Deletes the chain request after
122
+ perform the invalidation. If False, the chain request
123
+ disable `flag` is re-enabled.
124
+ """
125
+ chain_req_operate: list[dict] = []
126
+ chain_request_only_with_root: list[dict] = []
127
+ chain_request_more_than_root: list[dict] = []
128
+
129
+ # Filter the chain requests that only have the root request.
130
+ for chain_request in chain_req_data:
131
+ if len(chain_request.get("chain", [])) == 1:
132
+ chain_request_only_with_root.append(chain_request)
133
+ else:
134
+ chain_request_more_than_root.append(chain_request)
135
+
136
+ # Group the chained request by the second request in the chain
137
+ # (The first that is not the `root` request).
138
+ if chain_request_more_than_root:
139
+ grouped_chain = {
140
+ k: list(g)
141
+ for k, g in groupby(
142
+ chain_request_more_than_root, lambda el: el["chain"][1]
143
+ )
144
+ }
145
+ if len(grouped_chain) != 1:
146
+ error_chained_request_prepids: list[str] = [
147
+ ch["prepid"]
148
+ for ch in chain_request_more_than_root
149
+ if ch.get("prepid")
150
+ ]
151
+ msg = (
152
+ "Unable to process chained requests. After grouping them by the second request, "
153
+ f"more than one group has been found. This expects only one.\n"
154
+ f"Request groups: {grouped_chain.keys()}\n"
155
+ f"Chained requests: {pformat(error_chained_request_prepids)}"
156
+ )
157
+ self.logger.error(msg)
158
+ raise ValueError(msg)
159
+
160
+ # Include in the list to operate.
161
+ chain_req_operate += list(grouped_chain.values())[0]
162
+
163
+ if chain_request_only_with_root:
164
+ only_prepids = [el.get("prepid") for el in chain_request_only_with_root]
165
+ self.logger.warning(
166
+ "The following chain request only contain the root request in its chain: %s",
167
+ pformat(only_prepids),
168
+ )
169
+ chain_req_operate += chain_request_only_with_root
170
+
171
+ # Order the chain requests by `step` descending
172
+ chain_req_operate = sorted(
173
+ chain_req_operate, key=lambda el: el["step"], reverse=True
174
+ )
175
+ self.logger.info(
176
+ "Chain request pre-reset order (after sort): %s",
177
+ [el.get("prepid") for el in chain_req_operate],
178
+ )
179
+
180
+ # Set the flag to `False` and save
181
+ for ch_r in chain_req_operate:
182
+ ch_req_prepid = ch_r.get("prepid")
183
+ updated_ch_r = self.mcm.get(
184
+ object_type=self._chain_request_type, object_id=ch_req_prepid
185
+ )
186
+ updated_ch_r["action_parameters"]["flag"] = False
187
+ updated_ch_r = self.mcm.update(
188
+ object_type=self._chain_request_type, object_data=updated_ch_r
189
+ )
190
+ self.logger.info("Disable 'flag' response: %s", updated_ch_r)
191
+
192
+ if not updated_ch_r or not updated_ch_r.get("results"):
193
+ msg = f"Error updating chain requests: {ch_req_prepid}"
194
+ self.logger.error(msg)
195
+ raise RuntimeError(msg)
196
+
197
+ for ch_r in chain_req_operate:
198
+ ch_req_prepid = ch_r.get("prepid")
199
+
200
+ # Rewind the chain request to `root`.
201
+ rewind_endpoint = f"restapi/chained_requests/rewind_to_root/{ch_req_prepid}"
202
+ rewind_response = self.mcm._get(rewind_endpoint)
203
+ self.logger.info("Rewind chain request response: %s", rewind_response)
204
+ if not rewind_response or not rewind_response.get("results"):
205
+ msg = f"Unable to rewind chain request to root ({ch_r}) - Details: {rewind_response}"
206
+ self.logger.error(msg)
207
+ raise RuntimeError(msg)
208
+
209
+ # Announce the invalidation.
210
+ ch_req_requests: list[str] = ch_r.get("chain")
211
+ self._process_invalidation(request_prepids=ch_r.get("chain"))
212
+
213
+ # Delete the other requests EXCEPT for the `root`
214
+ # The first record in this list is the `root` request
215
+ chain_delete = ch_req_requests[1:]
216
+
217
+ # INFO: They must be deleted in order from the deepest
218
+ # data tier to upwards
219
+ chain_delete = reversed(chain_delete)
220
+
221
+ for rd in chain_delete:
222
+ self.mcm.delete(object_type=self._request_type, object_id=rd)
223
+
224
+ # Process the chain request
225
+ if remove_chain:
226
+ # Precondition: All the chained request share the same
227
+ # root request.
228
+ root_request_prepid: str = chain_req_operate[0].get("chain", [])[0]
229
+ self.logger.warning(
230
+ "Full resetting the root request (%s) as it is required to properly delete the chains",
231
+ root_request_prepid,
232
+ )
233
+ result = self.mcm.reset(prepid=root_request_prepid)
234
+ if not result:
235
+ raise RuntimeError("Unable to reset the root request", result)
236
+
237
+ # Process the root request invalidation.
238
+ self._process_invalidation([root_request_prepid])
239
+
240
+ # Remove all the chain requests
241
+ for ch_r in chain_req_operate:
242
+ ch_req_prepid = ch_r.get("prepid")
243
+ self.mcm.delete(
244
+ object_type=self._chain_request_type, object_id=ch_req_prepid
245
+ )
246
+ else:
247
+ # Re-enable the `flag`
248
+ for ch_r in chain_req_operate:
249
+ ch_req_prepid = ch_r.get("prepid")
250
+ updated_ch_r = self.mcm.get(
251
+ object_type=self._chain_request_type, object_id=ch_req_prepid
252
+ )
253
+ updated_ch_r["action_parameters"]["flag"] = True
254
+ updated_ch_r = self.mcm.update(
255
+ object_type=self._chain_request_type, object_data=updated_ch_r
256
+ )
257
+ self.logger.info("Disable 'flag' response: %s", updated_ch_r)
258
+ if not updated_ch_r or not updated_ch_r.get("results"):
259
+ msg = f"Error updating chain requests: {ch_req_prepid}"
260
+ self.logger.error(msg)
261
+ raise RuntimeError(msg)
262
+
263
+ def _invalidate_delete_root_request(
264
+ self, root_prepid: str, remove_root: bool = False, remove_chain: bool = False
265
+ ) -> None:
266
+ """
267
+ Invalidates and deletes all the chained request
268
+ linked to a root request and remove the root
269
+ request if required.
270
+
271
+ Args:
272
+ root_prepid: Root request prepid
273
+ remove_root: Invalidate and delete the root
274
+ request after processing its chains. If True,
275
+ the parameter `remove_chains` will be forced to
276
+ `True`
277
+ remove_chain: Delete the chain request after
278
+ processing it. If False, the chained requests
279
+ `flag` is re-enabled.
280
+ """
281
+ self.logger.info(
282
+ "Processing root request: %s, remove root: %s, remove chains: %s",
283
+ root_prepid,
284
+ remove_root,
285
+ remove_chain,
286
+ )
287
+
288
+ if remove_root and not remove_chain:
289
+ self.logger.warning(
290
+ "Setting `remove_chain` to True as a cascade delete is requested"
291
+ )
292
+ remove_chain = True
293
+
294
+ if remove_chain:
295
+ self.logger.warning(
296
+ (
297
+ "Root request will be FULL RESET "
298
+ "and its output dataset will be invalidated! "
299
+ "This is required to delete all its related chained requests."
300
+ )
301
+ )
302
+
303
+ # 1. Get all the chained requests and process them.
304
+ chain_req: list[dict] = self.mcm.get(
305
+ object_type=self._chain_request_type, query=f"contains={root_prepid}"
306
+ )
307
+ self.logger.info(
308
+ "Request (%s), operating chain requests: %s",
309
+ root_prepid,
310
+ pformat([ch.get("prepid") for ch in chain_req]),
311
+ )
312
+ self._invalidate_delete_chain_requests(
313
+ chain_req_data=chain_req, remove_chain=remove_chain
314
+ )
315
+
316
+ # 2. Remove the root request if required.
317
+ if remove_root:
318
+ self.mcm.delete(object_type=self._request_type, object_id=root_prepid)
319
+
320
+ def _filter_root_requests(self, requests_prepid: list[str]) -> list[str]:
321
+ """
322
+ Filter the given request's prepids and only pick
323
+ those related only to root requests.
324
+ """
325
+ root_requests: list[str] = []
326
+ for rid in requests_prepid:
327
+ if self.mcm.is_root_request(request=rid):
328
+ root_requests.append(rid)
329
+ return root_requests
330
+
331
+ def invalidate_delete_cascade_requests(
332
+ self,
333
+ requests_prepid: list[str],
334
+ remove_root: bool = False,
335
+ remove_chain: bool = False,
336
+ limit: int = 2**64,
337
+ ) -> dict[str, list[str]]:
338
+ """
339
+ Invalidate and delete all the request including into the
340
+ chain requests related to the provided root request.
341
+ This could be considered as a "delete in cascade" process.
342
+
343
+ Args:
344
+ requests_prepid: List of root requests to process.
345
+ In case a non-root request is provided, it will be
346
+ filtered.
347
+ remove_root: After processing the chained requests,
348
+ invalidate the dataset related to the root request and
349
+ delete it.
350
+ remove_chain: After removing the intermediate requests,
351
+ remove the chain or re-enable it chain again.
352
+ limit: Process root requests until the limit is reached.
353
+
354
+ Returns:
355
+ Details about the requests processed in three categories: Success, Failed, Filtered.
356
+ """
357
+ root_request_prepids: list[str] = self._filter_root_requests(
358
+ requests_prepid=deepcopy(requests_prepid)
359
+ )
360
+ discarded_prepids = list(set(requests_prepid) - set(root_request_prepids))
361
+ if discarded_prepids:
362
+ self.logger.info(
363
+ "Following requests are not valid root requests (%s): %s",
364
+ len(discarded_prepids),
365
+ pformat(discarded_prepids),
366
+ )
367
+
368
+ failed_requests: list[str] = []
369
+ success_requests: list[str] = []
370
+ total_to_process = len(root_request_prepids)
371
+ for idx, root_prepid in enumerate(root_request_prepids, start=1):
372
+ try:
373
+ self.logger.info(
374
+ "(%s/%s) Processing root request", idx, total_to_process
375
+ )
376
+ self._invalidate_delete_root_request(
377
+ root_prepid=root_prepid,
378
+ remove_root=remove_root,
379
+ remove_chain=remove_chain,
380
+ )
381
+ success_requests.append(root_prepid)
382
+ except Exception as e:
383
+ self.logger.error(
384
+ "Unable to process root request (%s): %s",
385
+ root_prepid,
386
+ e,
387
+ exc_info=True,
388
+ )
389
+ failed_requests.append(root_prepid)
390
+ finally:
391
+ if limit <= idx:
392
+ self.logger.info("Early stopping processing root requests...")
393
+ break
394
+
395
+ return {
396
+ "success": success_requests,
397
+ "failed": failed_requests,
398
+ "filtered": discarded_prepids,
399
+ }