schemawatch-core 0.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.
- schemawatch/__init__.py +71 -0
- schemawatch/alerts/__init__.py +41 -0
- schemawatch/alerts/retry.py +46 -0
- schemawatch/alerts/slack.py +202 -0
- schemawatch/alerts/webhook.py +162 -0
- schemawatch/diff.py +247 -0
- schemawatch/infer.py +251 -0
- schemawatch/integrations/__init__.py +1 -0
- schemawatch/integrations/httpx_hook.py +235 -0
- schemawatch/py.typed +1 -0
- schemawatch/redact.py +243 -0
- schemawatch/severity.py +64 -0
- schemawatch/store.py +152 -0
- schemawatch/watchdog.py +148 -0
- schemawatch_core-0.1.0.dist-info/METADATA +44 -0
- schemawatch_core-0.1.0.dist-info/RECORD +18 -0
- schemawatch_core-0.1.0.dist-info/WHEEL +4 -0
- schemawatch_core-0.1.0.dist-info/licenses/LICENSE +21 -0
schemawatch/__init__.py
ADDED
|
@@ -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
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from schemawatch.alerts.retry import should_retry_error
|
|
15
|
+
from schemawatch.diff import Change, DriftEvent
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class WebhookAlert:
|
|
21
|
+
"""Fail-open generic webhook alert dispatcher with retries.
|
|
22
|
+
|
|
23
|
+
Dispatches JSON alert payloads to a configured HTTP webhook endpoint.
|
|
24
|
+
Guaranteed never to raise exceptions into the host application and never
|
|
25
|
+
to log raw webhook URLs (preventing token/credential leaks).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
url: str,
|
|
31
|
+
*,
|
|
32
|
+
secret: str | None = None,
|
|
33
|
+
headers: dict[str, str] | None = None,
|
|
34
|
+
timeout: float = 5.0,
|
|
35
|
+
max_retries: int = 2,
|
|
36
|
+
backoff_seconds: float = 0.5,
|
|
37
|
+
sleep_fn: Callable[[float], None] = time.sleep,
|
|
38
|
+
) -> None:
|
|
39
|
+
parsed = urllib.parse.urlparse(url)
|
|
40
|
+
if parsed.scheme not in ("http", "https"):
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"Invalid alert webhook URL scheme: {parsed.scheme!r}. Must be http or https."
|
|
43
|
+
)
|
|
44
|
+
self._url = url
|
|
45
|
+
self._secret = secret
|
|
46
|
+
self._headers = headers or {}
|
|
47
|
+
self._timeout = timeout
|
|
48
|
+
self._max_retries = max_retries
|
|
49
|
+
self._backoff = backoff_seconds
|
|
50
|
+
self._sleep_fn = sleep_fn
|
|
51
|
+
self.last_status_code: int | None = None
|
|
52
|
+
self.last_response_body: str | None = None
|
|
53
|
+
self.last_attempts: int = 0
|
|
54
|
+
|
|
55
|
+
def __repr__(self) -> str:
|
|
56
|
+
return f"WebhookAlert(url='***redacted***', timeout={self._timeout})"
|
|
57
|
+
|
|
58
|
+
def __call__(self, event: DriftEvent | Change) -> None:
|
|
59
|
+
"""Dispatch drift event alert to the webhook."""
|
|
60
|
+
self.send(event)
|
|
61
|
+
|
|
62
|
+
def send(self, event: DriftEvent | Change) -> bool:
|
|
63
|
+
"""Send drift event alert with retries.
|
|
64
|
+
|
|
65
|
+
Returns True if successful, False otherwise. Never raises exceptions.
|
|
66
|
+
"""
|
|
67
|
+
# Normalize single Change to DriftEvent if passed directly
|
|
68
|
+
if isinstance(event, Change):
|
|
69
|
+
drift_event = DriftEvent(
|
|
70
|
+
vendor="unknown",
|
|
71
|
+
endpoint="unknown",
|
|
72
|
+
changes=(event,),
|
|
73
|
+
)
|
|
74
|
+
else:
|
|
75
|
+
drift_event = event
|
|
76
|
+
|
|
77
|
+
payload: dict[str, Any] = {
|
|
78
|
+
"event": "schema_drift",
|
|
79
|
+
"vendor": drift_event.vendor,
|
|
80
|
+
"endpoint": drift_event.endpoint,
|
|
81
|
+
"highest_severity": drift_event.highest_severity,
|
|
82
|
+
"detected_at": drift_event.detected_at,
|
|
83
|
+
"changes": [c.to_dict() for c in drift_event.changes],
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
data = json.dumps(payload).encode("utf-8")
|
|
87
|
+
req_headers = {
|
|
88
|
+
"Content-Type": "application/json",
|
|
89
|
+
"User-Agent": "schemawatch-alert/0.1.0",
|
|
90
|
+
**self._headers,
|
|
91
|
+
}
|
|
92
|
+
if self._secret:
|
|
93
|
+
sig = hmac.new(self._secret.encode("utf-8"), data, hashlib.sha256).hexdigest()
|
|
94
|
+
req_headers["X-Schemawatch-Signature"] = f"sha256={sig}"
|
|
95
|
+
|
|
96
|
+
self.last_status_code = None
|
|
97
|
+
self.last_response_body = None
|
|
98
|
+
self.last_attempts = 0
|
|
99
|
+
|
|
100
|
+
for attempt in range(1, self._max_retries + 2):
|
|
101
|
+
self.last_attempts = attempt
|
|
102
|
+
try:
|
|
103
|
+
req = urllib.request.Request(
|
|
104
|
+
self._url,
|
|
105
|
+
data=data,
|
|
106
|
+
headers=req_headers,
|
|
107
|
+
method="POST",
|
|
108
|
+
)
|
|
109
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp: # nosec B310
|
|
110
|
+
self.last_status_code = resp.status
|
|
111
|
+
self.last_response_body = resp.read().decode("utf-8", errors="replace")
|
|
112
|
+
if 200 <= resp.status < 300:
|
|
113
|
+
return True
|
|
114
|
+
except urllib.error.HTTPError as http_exc:
|
|
115
|
+
self.last_status_code = http_exc.code
|
|
116
|
+
try:
|
|
117
|
+
self.last_response_body = http_exc.read().decode("utf-8", errors="replace")
|
|
118
|
+
except Exception:
|
|
119
|
+
self.last_response_body = None
|
|
120
|
+
should_retry, retry_after = should_retry_error(http_exc)
|
|
121
|
+
logger.warning(
|
|
122
|
+
"schemawatch: webhook delivery failed (attempt %d/%d): HTTP %d",
|
|
123
|
+
attempt,
|
|
124
|
+
self._max_retries + 1,
|
|
125
|
+
http_exc.code,
|
|
126
|
+
)
|
|
127
|
+
if not should_retry:
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
if attempt <= self._max_retries:
|
|
131
|
+
delay = (
|
|
132
|
+
retry_after
|
|
133
|
+
if retry_after is not None
|
|
134
|
+
else self._backoff * (2 ** (attempt - 1))
|
|
135
|
+
)
|
|
136
|
+
self._sleep_fn(delay)
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
should_retry, retry_after = should_retry_error(exc)
|
|
139
|
+
# Privacy rule: NEVER log the webhook URL
|
|
140
|
+
logger.warning(
|
|
141
|
+
"schemawatch: webhook delivery failed (attempt %d/%d): %s",
|
|
142
|
+
attempt,
|
|
143
|
+
self._max_retries + 1,
|
|
144
|
+
type(exc).__name__,
|
|
145
|
+
)
|
|
146
|
+
if not should_retry:
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
if attempt <= self._max_retries:
|
|
150
|
+
delay = (
|
|
151
|
+
retry_after
|
|
152
|
+
if retry_after is not None
|
|
153
|
+
else self._backoff * (2 ** (attempt - 1))
|
|
154
|
+
)
|
|
155
|
+
self._sleep_fn(delay)
|
|
156
|
+
|
|
157
|
+
logger.error(
|
|
158
|
+
"schemawatch: webhook alert delivery exhausted all retries for %s %s",
|
|
159
|
+
drift_event.vendor,
|
|
160
|
+
drift_event.endpoint,
|
|
161
|
+
)
|
|
162
|
+
return False
|