schemawatch-core 0.1.0__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.
Files changed (33) hide show
  1. schemawatch_core-0.1.0/.gitignore +62 -0
  2. schemawatch_core-0.1.0/LICENSE +21 -0
  3. schemawatch_core-0.1.0/PKG-INFO +44 -0
  4. schemawatch_core-0.1.0/README.md +15 -0
  5. schemawatch_core-0.1.0/pyproject.toml +52 -0
  6. schemawatch_core-0.1.0/schemawatch/__init__.py +71 -0
  7. schemawatch_core-0.1.0/schemawatch/alerts/__init__.py +41 -0
  8. schemawatch_core-0.1.0/schemawatch/alerts/retry.py +46 -0
  9. schemawatch_core-0.1.0/schemawatch/alerts/slack.py +202 -0
  10. schemawatch_core-0.1.0/schemawatch/alerts/webhook.py +162 -0
  11. schemawatch_core-0.1.0/schemawatch/diff.py +247 -0
  12. schemawatch_core-0.1.0/schemawatch/infer.py +251 -0
  13. schemawatch_core-0.1.0/schemawatch/integrations/__init__.py +1 -0
  14. schemawatch_core-0.1.0/schemawatch/integrations/httpx_hook.py +235 -0
  15. schemawatch_core-0.1.0/schemawatch/py.typed +1 -0
  16. schemawatch_core-0.1.0/schemawatch/redact.py +243 -0
  17. schemawatch_core-0.1.0/schemawatch/severity.py +64 -0
  18. schemawatch_core-0.1.0/schemawatch/store.py +152 -0
  19. schemawatch_core-0.1.0/schemawatch/watchdog.py +148 -0
  20. schemawatch_core-0.1.0/tests/__init__.py +1 -0
  21. schemawatch_core-0.1.0/tests/test_alert_integration.py +127 -0
  22. schemawatch_core-0.1.0/tests/test_alerts.py +259 -0
  23. schemawatch_core-0.1.0/tests/test_diff.py +292 -0
  24. schemawatch_core-0.1.0/tests/test_infer.py +146 -0
  25. schemawatch_core-0.1.0/tests/test_integrations.py +355 -0
  26. schemawatch_core-0.1.0/tests/test_lazy_import.py +49 -0
  27. schemawatch_core-0.1.0/tests/test_mock_alerts.py +441 -0
  28. schemawatch_core-0.1.0/tests/test_privacy.py +189 -0
  29. schemawatch_core-0.1.0/tests/test_property.py +67 -0
  30. schemawatch_core-0.1.0/tests/test_redact.py +121 -0
  31. schemawatch_core-0.1.0/tests/test_severity.py +51 -0
  32. schemawatch_core-0.1.0/tests/test_store.py +82 -0
  33. schemawatch_core-0.1.0/tests/test_watchdog.py +123 -0
@@ -0,0 +1,62 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ /lib/
14
+ /lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ share/python-wheels/
20
+ *.egg-info/
21
+ .installed.cfg
22
+ *.egg
23
+
24
+ # Virtual environments
25
+ .venv/
26
+ venv/
27
+ ENV/
28
+ env/
29
+
30
+ # Testing & Coverage
31
+ .coverage
32
+ .coverage.*
33
+ coverage/
34
+ htmlcov/
35
+ .pytest_cache/
36
+ .hypothesis/
37
+
38
+ # Type checking & Linters
39
+ .mypy_cache/
40
+ .ruff_cache/
41
+
42
+ # Environment & Secrets
43
+ .env*
44
+ !.env.example
45
+
46
+ # Databases & Runtime state
47
+ *.db
48
+ *.sqlite
49
+ *.sqlite3
50
+
51
+ # Frontend & Dependencies
52
+ node_modules/
53
+ dist/
54
+
55
+ # OS / IDE
56
+ .DS_Store
57
+ Thumbs.db
58
+ *.swp
59
+ *.swo
60
+ .vscode/
61
+ .idea/
62
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 schemawatch contributors
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,44 @@
1
+ Metadata-Version: 2.5
2
+ Name: schemawatch-core
3
+ Version: 0.1.0
4
+ Summary: Pure Python engine for schema inference, diffing, PII redaction, drift severity classification, and sampling watchdog
5
+ Project-URL: Homepage, https://github.com/Mohd-obaidullah/schemawatch
6
+ Project-URL: Source, https://github.com/Mohd-obaidullah/schemawatch
7
+ Project-URL: Issues, https://github.com/Mohd-obaidullah/schemawatch/issues
8
+ Project-URL: Changelog, https://github.com/Mohd-obaidullah/schemawatch/blob/main/CHANGELOG.md
9
+ Author: Mohd Obaidullah
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: api,contract-testing,drift,monitoring,schema
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Testing
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.10
24
+ Provides-Extra: httpx
25
+ Requires-Dist: httpx>=0.27.0; extra == 'httpx'
26
+ Provides-Extra: redis
27
+ Requires-Dist: redis>=5.0.0; extra == 'redis'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # schemawatch core
31
+
32
+ Pure Python engine for schema inference, diffing, PII redaction, drift severity classification, and sampling watchdog.
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install schemawatch
38
+ ```
39
+
40
+ Optional extras:
41
+ ```bash
42
+ pip install "schemawatch[redis]" # Redis-backed baseline and dedupe store
43
+ pip install "schemawatch[httpx]" # httpx integration hook
44
+ ```
@@ -0,0 +1,15 @@
1
+ # schemawatch core
2
+
3
+ Pure Python engine for schema inference, diffing, PII redaction, drift severity classification, and sampling watchdog.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install schemawatch
9
+ ```
10
+
11
+ Optional extras:
12
+ ```bash
13
+ pip install "schemawatch[redis]" # Redis-backed baseline and dedupe store
14
+ pip install "schemawatch[httpx]" # httpx integration hook
15
+ ```
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "schemawatch-core"
7
+ version = "0.1.0"
8
+ description = "Pure Python engine for schema inference, diffing, PII redaction, drift severity classification, and sampling watchdog"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Mohd Obaidullah" }]
14
+ keywords = ["api", "schema", "drift", "contract-testing", "monitoring"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Software Development :: Testing",
25
+ "Typing :: Typed",
26
+ ]
27
+ dependencies = []
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/Mohd-obaidullah/schemawatch"
31
+ Source = "https://github.com/Mohd-obaidullah/schemawatch"
32
+ Issues = "https://github.com/Mohd-obaidullah/schemawatch/issues"
33
+ Changelog = "https://github.com/Mohd-obaidullah/schemawatch/blob/main/CHANGELOG.md"
34
+
35
+ [project.optional-dependencies]
36
+ redis = ["redis>=5.0.0"]
37
+ httpx = ["httpx>=0.27.0"]
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["schemawatch"]
41
+
42
+ [tool.mypy]
43
+ python_version = "3.10"
44
+ strict = true
45
+ warn_return_any = true
46
+ warn_unused_configs = true
47
+ disallow_untyped_defs = true
48
+ disallow_incomplete_defs = true
49
+ check_untyped_defs = true
50
+ no_implicit_optional = true
51
+ warn_redundant_casts = true
52
+ warn_unused_ignores = true
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from schemawatch.diff import Change, DriftEvent, diff
6
+ from schemawatch.infer import SchemaNode, infer_schema, merge
7
+ from schemawatch.redact import DYNAMIC_KEY_WILDCARD, DynamicKeyWildcard, redact
8
+ from schemawatch.severity import (
9
+ KIND_BECAME_NULLABLE,
10
+ KIND_ENUM_CHANGED,
11
+ KIND_FIELD_ADDED,
12
+ KIND_FIELD_MISSING,
13
+ KIND_TYPE_CHANGED,
14
+ SEVERITY_BREAKING,
15
+ SEVERITY_INFO,
16
+ SEVERITY_WARNING,
17
+ calculate_severity,
18
+ )
19
+ from schemawatch.store import MemoryStore, RedisStore, Store
20
+ from schemawatch.watchdog import Watchdog
21
+
22
+ if TYPE_CHECKING:
23
+ from schemawatch.alerts import SlackAlert, WebhookAlert, fan_out
24
+ from schemawatch.integrations.httpx_hook import SchemawatchHTTPXHook, create_httpx_hook
25
+
26
+ __all__ = [
27
+ "Change",
28
+ "DYNAMIC_KEY_WILDCARD",
29
+ "DriftEvent",
30
+ "DynamicKeyWildcard",
31
+ "KIND_BECAME_NULLABLE",
32
+ "KIND_ENUM_CHANGED",
33
+ "KIND_FIELD_ADDED",
34
+ "KIND_FIELD_MISSING",
35
+ "KIND_TYPE_CHANGED",
36
+ "MemoryStore",
37
+ "RedisStore",
38
+ "SEVERITY_BREAKING",
39
+ "SEVERITY_INFO",
40
+ "SEVERITY_WARNING",
41
+ "SchemaNode",
42
+ "SchemawatchHTTPXHook",
43
+ "SlackAlert",
44
+ "Store",
45
+ "Watchdog",
46
+ "WebhookAlert",
47
+ "calculate_severity",
48
+ "create_httpx_hook",
49
+ "diff",
50
+ "fan_out",
51
+ "infer_schema",
52
+ "merge",
53
+ "redact",
54
+ ]
55
+
56
+
57
+ def __getattr__(name: str) -> Any:
58
+ """Lazy module exports so 'import schemawatch' works without httpx installed."""
59
+ if name in ("SlackAlert", "WebhookAlert", "fan_out"):
60
+ import schemawatch.alerts as alerts
61
+
62
+ return getattr(alerts, name)
63
+ if name in ("SchemawatchHTTPXHook", "create_httpx_hook"):
64
+ import schemawatch.integrations.httpx_hook as httpx_hook
65
+
66
+ return getattr(httpx_hook, name)
67
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
68
+
69
+
70
+ def __dir__() -> list[str]:
71
+ return sorted(list(globals().keys()) + __all__)
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import Callable
5
+
6
+ from schemawatch.alerts.slack import SlackAlert
7
+ from schemawatch.alerts.webhook import WebhookAlert
8
+ from schemawatch.diff import Change, DriftEvent
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def fan_out(
14
+ *dispatchers: Callable[[DriftEvent], None],
15
+ ) -> Callable[[DriftEvent | Change], None]:
16
+ """Combine multiple alert dispatchers into a single fail-open callback.
17
+
18
+ Dispatches sequentially to each handler. If one handler raises an exception,
19
+ the exception is logged and swallowed so subsequent handlers still execute.
20
+ """
21
+
22
+ def _composite(event: DriftEvent | Change) -> None:
23
+ drift_event = (
24
+ event
25
+ if isinstance(event, DriftEvent)
26
+ else DriftEvent(vendor="unknown", endpoint="unknown", changes=(event,))
27
+ )
28
+ for dispatcher in dispatchers:
29
+ try:
30
+ dispatcher(drift_event)
31
+ except Exception as exc:
32
+ logger.exception("schemawatch fan_out: dispatcher failed: %s", exc)
33
+
34
+ return _composite
35
+
36
+
37
+ __all__ = [
38
+ "SlackAlert",
39
+ "WebhookAlert",
40
+ "fan_out",
41
+ ]
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import urllib.error
5
+ from typing import Final
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ RETRYABLE_STATUS_CODES: Final[frozenset[int]] = frozenset({408, 425, 429})
10
+ MAX_RETRY_AFTER_SECONDS: Final[float] = 30.0
11
+
12
+
13
+ def should_retry_error(exc: Exception) -> tuple[bool, float | None]:
14
+ """Determine whether an HTTP/network exception should be retried.
15
+
16
+ Rules:
17
+ - Network errors (URLError, OSError, TimeoutError) are retried.
18
+ - HTTP status codes 408, 425, 429, and 5xx are retried.
19
+ - Other 4xx status codes (400, 401, 403, 404, etc.) are NOT retried.
20
+ - If a Retry-After header is present on a retryable HTTP response, its value
21
+ is honoured as seconds and capped at 30 seconds.
22
+
23
+ Returns:
24
+ (should_retry, retry_after_delay_in_seconds)
25
+ """
26
+ if isinstance(exc, urllib.error.HTTPError):
27
+ code = exc.code
28
+ if code in RETRYABLE_STATUS_CODES or code >= 500:
29
+ retry_after: float | None = None
30
+ if exc.headers:
31
+ raw_ra = exc.headers.get("Retry-After")
32
+ if raw_ra:
33
+ try:
34
+ retry_after = min(max(0.0, float(raw_ra)), MAX_RETRY_AFTER_SECONDS)
35
+ except ValueError:
36
+ retry_after = None
37
+ return True, retry_after
38
+ # Other 4xx are client errors and should NOT be retried
39
+ if 400 <= code < 500:
40
+ return False, None
41
+ return False, None
42
+
43
+ if isinstance(exc, (urllib.error.URLError, OSError, TimeoutError)):
44
+ return True, None
45
+
46
+ return False, None
@@ -0,0 +1,202 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import time
6
+ import urllib.error
7
+ import urllib.parse
8
+ import urllib.request
9
+ from collections.abc import Callable
10
+ from typing import Any
11
+
12
+ from schemawatch.alerts.retry import should_retry_error
13
+ from schemawatch.diff import Change, DriftEvent
14
+ from schemawatch.severity import SEVERITY_BREAKING, SEVERITY_INFO, SEVERITY_WARNING
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ SLACK_COLOR_MAP: dict[str, str] = {
19
+ SEVERITY_BREAKING: "#E01E5A", # Red / Danger
20
+ SEVERITY_WARNING: "#ECB22E", # Yellow / Warning
21
+ SEVERITY_INFO: "#2F80ED", # Blue / Info
22
+ }
23
+ SLACK_COLOR_DEFAULT = "#2F80ED" # Blue / Info
24
+
25
+
26
+ class SlackAlert:
27
+ """Fail-open Slack incoming webhook dispatcher with rich formatting.
28
+
29
+ Maps schema drift changes to colored Slack attachments.
30
+ Never raises exceptions into host application and never logs raw webhook URLs.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ webhook_url: str,
36
+ *,
37
+ channel: str | None = None,
38
+ timeout: float = 5.0,
39
+ max_retries: int = 2,
40
+ backoff_seconds: float = 0.5,
41
+ sleep_fn: Callable[[float], None] = time.sleep,
42
+ ) -> None:
43
+ parsed = urllib.parse.urlparse(webhook_url)
44
+ if parsed.scheme not in ("http", "https"):
45
+ raise ValueError(
46
+ f"Invalid alert webhook URL scheme: {parsed.scheme!r}. Must be http or https."
47
+ )
48
+ self._url = webhook_url
49
+ self._channel = channel
50
+ self._timeout = timeout
51
+ self._max_retries = max_retries
52
+ self._backoff = backoff_seconds
53
+ self._sleep_fn = sleep_fn
54
+ self.last_status_code: int | None = None
55
+ self.last_response_body: str | None = None
56
+ self.last_attempts: int = 0
57
+
58
+ def __repr__(self) -> str:
59
+ return f"SlackAlert(webhook_url='***redacted***', channel={self._channel!r})"
60
+
61
+ def __call__(self, event: DriftEvent | Change) -> None:
62
+ """Dispatch drift event alert to Slack."""
63
+ self.send(event)
64
+
65
+ def _build_payload(self, event: DriftEvent) -> dict[str, Any]:
66
+ header_text = f":warning: API Contract Drift Detected: *{event.vendor}* `{event.endpoint}`"
67
+
68
+ # Group changes by severity in priority order
69
+ severity_order = [SEVERITY_BREAKING, SEVERITY_WARNING, SEVERITY_INFO]
70
+ changes_by_sev: dict[str, list[Change]] = {sev: [] for sev in severity_order}
71
+
72
+ for ch in event.changes:
73
+ if ch.severity in changes_by_sev:
74
+ changes_by_sev[ch.severity].append(ch)
75
+ else:
76
+ changes_by_sev[SEVERITY_INFO].append(ch)
77
+
78
+ attachments: list[dict[str, Any]] = []
79
+ for sev in severity_order:
80
+ sev_changes = changes_by_sev[sev]
81
+ if not sev_changes:
82
+ continue
83
+
84
+ color = SLACK_COLOR_MAP.get(sev, SLACK_COLOR_DEFAULT)
85
+ fields: list[dict[str, Any]] = []
86
+ for ch in sev_changes:
87
+ desc = ch.detail or (f"`{ch.was}` -> `{ch.now}`" if ch.was or ch.now else ch.kind)
88
+ fields.append(
89
+ {
90
+ "title": f"`{ch.field}`",
91
+ "value": f"*{ch.kind}*: {desc}",
92
+ "short": len(ch.field) <= 24,
93
+ }
94
+ )
95
+
96
+ count_str = f" ({len(sev_changes)})" if len(sev_changes) > 1 else ""
97
+ attachments.append(
98
+ {
99
+ "color": color,
100
+ "title": f"[{sev.upper()}]{count_str}",
101
+ "fields": fields,
102
+ "ts": int(event.detected_at),
103
+ }
104
+ )
105
+
106
+ payload: dict[str, Any] = {
107
+ "text": header_text,
108
+ "attachments": attachments,
109
+ }
110
+ if self._channel:
111
+ payload["channel"] = self._channel
112
+ return payload
113
+
114
+ def send(self, event: DriftEvent | Change) -> bool:
115
+ """Send formatted Slack message with retries.
116
+
117
+ Returns True if successful, False otherwise. Never raises exceptions.
118
+ """
119
+ # Normalize Change to DriftEvent if passed directly
120
+ if isinstance(event, Change):
121
+ drift_event = DriftEvent(
122
+ vendor="unknown",
123
+ endpoint="unknown",
124
+ changes=(event,),
125
+ )
126
+ else:
127
+ drift_event = event
128
+
129
+ payload = self._build_payload(drift_event)
130
+ data = json.dumps(payload).encode("utf-8")
131
+ headers = {
132
+ "Content-Type": "application/json",
133
+ "User-Agent": "schemawatch-slack-alert/0.1.0",
134
+ }
135
+
136
+ self.last_status_code = None
137
+ self.last_response_body = None
138
+ self.last_attempts = 0
139
+
140
+ for attempt in range(1, self._max_retries + 2):
141
+ self.last_attempts = attempt
142
+ try:
143
+ req = urllib.request.Request(
144
+ self._url,
145
+ data=data,
146
+ headers=headers,
147
+ method="POST",
148
+ )
149
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp: # nosec B310
150
+ self.last_status_code = resp.status
151
+ self.last_response_body = resp.read().decode("utf-8", errors="replace")
152
+ if 200 <= resp.status < 300:
153
+ return True
154
+ except urllib.error.HTTPError as http_exc:
155
+ self.last_status_code = http_exc.code
156
+ try:
157
+ self.last_response_body = http_exc.read().decode("utf-8", errors="replace")
158
+ except Exception:
159
+ self.last_response_body = None
160
+ should_retry, retry_after = should_retry_error(http_exc)
161
+ logger.warning(
162
+ "schemawatch: slack alert delivery failed (attempt %d/%d): HTTP %d",
163
+ attempt,
164
+ self._max_retries + 1,
165
+ http_exc.code,
166
+ )
167
+ if not should_retry:
168
+ return False
169
+
170
+ if attempt <= self._max_retries:
171
+ delay = (
172
+ retry_after
173
+ if retry_after is not None
174
+ else self._backoff * (2 ** (attempt - 1))
175
+ )
176
+ self._sleep_fn(delay)
177
+ except Exception as exc:
178
+ should_retry, retry_after = should_retry_error(exc)
179
+ # Privacy rule: NEVER log the webhook URL
180
+ logger.warning(
181
+ "schemawatch: slack alert delivery failed (attempt %d/%d): %s",
182
+ attempt,
183
+ self._max_retries + 1,
184
+ type(exc).__name__,
185
+ )
186
+ if not should_retry:
187
+ return False
188
+
189
+ if attempt <= self._max_retries:
190
+ delay = (
191
+ retry_after
192
+ if retry_after is not None
193
+ else self._backoff * (2 ** (attempt - 1))
194
+ )
195
+ self._sleep_fn(delay)
196
+
197
+ logger.error(
198
+ "schemawatch: slack alert delivery exhausted all retries for %s %s",
199
+ drift_event.vendor,
200
+ drift_event.endpoint,
201
+ )
202
+ return False