microburst 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.
- microburst/__init__.py +3 -0
- microburst/__main__.py +5 -0
- microburst/cli.py +87 -0
- microburst/detect.py +208 -0
- microburst/errors.py +102 -0
- microburst/models.py +174 -0
- microburst/proxy.py +381 -0
- microburst/rules.py +270 -0
- microburst-0.1.0.dist-info/METADATA +165 -0
- microburst-0.1.0.dist-info/RECORD +13 -0
- microburst-0.1.0.dist-info/WHEEL +4 -0
- microburst-0.1.0.dist-info/entry_points.txt +2 -0
- microburst-0.1.0.dist-info/licenses/LICENSE +21 -0
microburst/__init__.py
ADDED
microburst/__main__.py
ADDED
microburst/cli.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""microburst CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from aiohttp import web
|
|
10
|
+
|
|
11
|
+
from microburst.proxy import Microburst, load_config, make_app
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
prog="microburst",
|
|
17
|
+
description="AWS failure injection proxy — inject realistic AWS "
|
|
18
|
+
"errors, latency, and connection faults between your app and any "
|
|
19
|
+
"AWS endpoint (MiniStack, moto, LocalStack, or real AWS).",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"--upstream", "-u", default=None,
|
|
23
|
+
help="upstream AWS endpoint (default: config file or http://localhost:4566)",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--port", "-p", type=int, default=None,
|
|
27
|
+
help="listen port (default: 9999)",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--host", default=None, help="listen host (default: 127.0.0.1)",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--config", "-c", default=None,
|
|
34
|
+
help="YAML config file (upstream, port, rules)",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--resign", action="store_true", default=None,
|
|
38
|
+
help="re-sign requests with AWS_* env credentials (auto for "
|
|
39
|
+
"amazonaws.com upstreams)",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--no-resign", action="store_true",
|
|
43
|
+
help="never re-sign, even for amazonaws.com upstreams",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--verbose", "-v", action="store_true", help="debug logging",
|
|
47
|
+
)
|
|
48
|
+
return parser
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main(argv: list[str] | None = None) -> int:
|
|
52
|
+
args = build_parser().parse_args(argv)
|
|
53
|
+
|
|
54
|
+
logging.basicConfig(
|
|
55
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
56
|
+
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
config = {}
|
|
60
|
+
if args.config:
|
|
61
|
+
config = load_config(args.config)
|
|
62
|
+
|
|
63
|
+
upstream = args.upstream or config.get("upstream") or "http://localhost:4566"
|
|
64
|
+
port = args.port or config.get("port") or 9999
|
|
65
|
+
host = args.host or config.get("host") or "127.0.0.1"
|
|
66
|
+
rules = config.get("rules") or []
|
|
67
|
+
|
|
68
|
+
if args.no_resign:
|
|
69
|
+
resign = False
|
|
70
|
+
elif args.resign:
|
|
71
|
+
resign = True
|
|
72
|
+
else:
|
|
73
|
+
resign = None # auto
|
|
74
|
+
|
|
75
|
+
microburst = Microburst(upstream, rules=rules, resign=resign)
|
|
76
|
+
app = make_app(microburst)
|
|
77
|
+
|
|
78
|
+
print(f"microburst listening on http://{host}:{port} → {upstream}", flush=True)
|
|
79
|
+
print(f"control API: http://{host}:{port}/_microburst/health", flush=True)
|
|
80
|
+
print(f"point your app: AWS_ENDPOINT_URL=http://{host}:{port}", flush=True)
|
|
81
|
+
|
|
82
|
+
web.run_app(app, host=host, port=port, print=None)
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
sys.exit(main())
|
microburst/detect.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Request detection: resolve (service, operation, region, resource) from an
|
|
2
|
+
incoming AWS API request.
|
|
3
|
+
|
|
4
|
+
Mirrors how AWS itself — and MiniStack's router — identify a request:
|
|
5
|
+
SigV4 credential scope names the service and region; ``X-Amz-Target`` or the
|
|
6
|
+
query-protocol ``Action`` parameter names the operation; REST services are
|
|
7
|
+
matched on method + requestUri patterns from the botocore service model.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from urllib.parse import parse_qsl
|
|
16
|
+
|
|
17
|
+
from microburst.models import get_protocol, get_service_model, service_for_scope
|
|
18
|
+
|
|
19
|
+
_CRED_RE = re.compile(
|
|
20
|
+
r"Credential=(?P<key>[^/,]+)/(?P<date>\d{8})/(?P<region>[^/]+)/"
|
|
21
|
+
r"(?P<service>[^/]+)/aws4_request"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Operations that carry large payloads in either direction. For these the body
|
|
25
|
+
# is streamed through untouched — detection never needs them.
|
|
26
|
+
_STREAMING_OPS = {
|
|
27
|
+
("s3", "PutObject"),
|
|
28
|
+
("s3", "GetObject"),
|
|
29
|
+
("s3", "UploadPart"),
|
|
30
|
+
("lambda", "Invoke"), # response payloads can be large; body still small
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class RequestInfo:
|
|
36
|
+
service: str | None
|
|
37
|
+
operation: str | None
|
|
38
|
+
region: str | None
|
|
39
|
+
resource: str | None
|
|
40
|
+
access_key: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse_credential_scope(headers) -> tuple[str | None, str | None, str | None]:
|
|
44
|
+
auth = headers.get("Authorization", "")
|
|
45
|
+
match = _CRED_RE.search(auth)
|
|
46
|
+
if not match:
|
|
47
|
+
return None, None, None
|
|
48
|
+
return (
|
|
49
|
+
service_for_scope(match.group("service")),
|
|
50
|
+
match.group("region"),
|
|
51
|
+
match.group("key"),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_REST_OP_CACHE: dict[str, list[dict]] = {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _uri_to_regex(uri: str) -> re.Pattern:
|
|
59
|
+
"""Convert a Smithy requestUri (``/x/{Label}/{Greedy+}``) to a regex."""
|
|
60
|
+
out = ""
|
|
61
|
+
i = 0
|
|
62
|
+
for match in re.finditer(r"\{[A-Za-z0-9_]+\+?\}", uri):
|
|
63
|
+
out += re.escape(uri[i:match.start()])
|
|
64
|
+
out += ".*" if match.group().endswith("+}") else "[^/]+"
|
|
65
|
+
i = match.end()
|
|
66
|
+
out += re.escape(uri[i:])
|
|
67
|
+
return re.compile(f"^{out}$")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _rest_operations(service: str) -> list[dict]:
|
|
71
|
+
"""Compile candidate operations for a REST-protocol service.
|
|
72
|
+
|
|
73
|
+
Several operations share the same method+path (S3 ``PutObject`` vs
|
|
74
|
+
``CopyObject``); disambiguation happens at match time via query-string
|
|
75
|
+
markers and headers declared in the service model.
|
|
76
|
+
"""
|
|
77
|
+
if service in _REST_OP_CACHE:
|
|
78
|
+
return _REST_OP_CACHE[service]
|
|
79
|
+
entries: list[dict] = []
|
|
80
|
+
model = get_service_model(service)
|
|
81
|
+
if model is not None:
|
|
82
|
+
for op_name in model.operation_names:
|
|
83
|
+
op = model.operation_model(op_name)
|
|
84
|
+
http = op.http
|
|
85
|
+
uri = http.get("requestUri", "/")
|
|
86
|
+
path_uri, _, literal_query = uri.partition("?")
|
|
87
|
+
query_markers = {
|
|
88
|
+
part.split("=", 1)[0] for part in literal_query.split("&") if part
|
|
89
|
+
}
|
|
90
|
+
required_headers = set()
|
|
91
|
+
input_shape = getattr(op, "input_shape", None)
|
|
92
|
+
if input_shape is not None:
|
|
93
|
+
for name, member in input_shape.members.items():
|
|
94
|
+
if (
|
|
95
|
+
member.serialization.get("location") == "header"
|
|
96
|
+
and name in input_shape.required_members
|
|
97
|
+
):
|
|
98
|
+
required_headers.add(
|
|
99
|
+
member.serialization.get("locationName", name).lower()
|
|
100
|
+
)
|
|
101
|
+
entries.append(
|
|
102
|
+
{
|
|
103
|
+
"method": http.get("method", "GET"),
|
|
104
|
+
"regex": _uri_to_regex(path_uri),
|
|
105
|
+
"op": op_name,
|
|
106
|
+
"query_markers": query_markers,
|
|
107
|
+
"required_headers": required_headers,
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
_REST_OP_CACHE[service] = entries
|
|
111
|
+
return entries
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _match_rest_operation(service: str, method: str, path: str, query,
|
|
115
|
+
headers) -> str | None:
|
|
116
|
+
candidates = []
|
|
117
|
+
for entry in _rest_operations(service):
|
|
118
|
+
if entry["method"] != method or not entry["regex"].match(path):
|
|
119
|
+
continue
|
|
120
|
+
score = 0
|
|
121
|
+
if entry["query_markers"]:
|
|
122
|
+
if entry["query_markers"] <= set(query):
|
|
123
|
+
score += 2 * len(entry["query_markers"])
|
|
124
|
+
else:
|
|
125
|
+
score -= 10
|
|
126
|
+
lower_headers = {k.lower() for k in headers}
|
|
127
|
+
for h in entry["required_headers"]:
|
|
128
|
+
score += 2 if h in lower_headers else -10
|
|
129
|
+
candidates.append((score, entry["op"]))
|
|
130
|
+
if not candidates:
|
|
131
|
+
return None
|
|
132
|
+
candidates.sort(key=lambda item: item[0], reverse=True)
|
|
133
|
+
return candidates[0][1]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _operation_for(service: str | None, headers, method: str, path: str,
|
|
137
|
+
query: dict, body: bytes | None) -> str | None:
|
|
138
|
+
if service is None:
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
target = headers.get("X-Amz-Target", "")
|
|
142
|
+
if "." in target:
|
|
143
|
+
return target.rsplit(".", 1)[-1]
|
|
144
|
+
|
|
145
|
+
# Query-protocol style `Action` — some services migrated models to `json`
|
|
146
|
+
# but clients may still speak query; check both query string and body.
|
|
147
|
+
action = query.get("Action")
|
|
148
|
+
if action:
|
|
149
|
+
return action
|
|
150
|
+
if body and b"Action=" in body:
|
|
151
|
+
params = dict(parse_qsl(body.decode("utf-8", "replace")))
|
|
152
|
+
action = params.get("Action")
|
|
153
|
+
if action:
|
|
154
|
+
return action
|
|
155
|
+
|
|
156
|
+
protocol = get_protocol(service)
|
|
157
|
+
if protocol in ("rest-xml", "rest-json"):
|
|
158
|
+
return _match_rest_operation(service, method, path, query, headers)
|
|
159
|
+
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _resource_hint(service: str | None, operation: str | None, path: str,
|
|
164
|
+
body: bytes | None) -> str | None:
|
|
165
|
+
if service is None:
|
|
166
|
+
return None
|
|
167
|
+
if service == "s3":
|
|
168
|
+
segments = [s for s in path.split("/") if s]
|
|
169
|
+
return segments[0] if segments else None
|
|
170
|
+
if body:
|
|
171
|
+
if service == "sqs":
|
|
172
|
+
segments = [s for s in path.split("/") if s]
|
|
173
|
+
return segments[-1] if segments else None
|
|
174
|
+
try:
|
|
175
|
+
payload = json.loads(body)
|
|
176
|
+
except (ValueError, UnicodeDecodeError):
|
|
177
|
+
return None
|
|
178
|
+
if service == "dynamodb":
|
|
179
|
+
return payload.get("TableName")
|
|
180
|
+
for key in ("TopicArn", "TargetArn", "QueueUrl", "FunctionName",
|
|
181
|
+
"Name", "StackName", "Bucket"):
|
|
182
|
+
if isinstance(payload.get(key), str):
|
|
183
|
+
return payload[key].rsplit(":", 1)[-1].rsplit("/", 1)[-1]
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def should_buffer(service: str | None, operation: str | None,
|
|
188
|
+
content_length: int | None) -> bool:
|
|
189
|
+
"""Whether the body is needed for detection/rule matching."""
|
|
190
|
+
if (service, operation) in _STREAMING_OPS:
|
|
191
|
+
return False
|
|
192
|
+
if content_length is None:
|
|
193
|
+
return True
|
|
194
|
+
return content_length <= 4 * 1024 * 1024
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def detect(headers, method: str, path: str, query: dict,
|
|
198
|
+
body: bytes | None) -> RequestInfo:
|
|
199
|
+
service, region, access_key = _parse_credential_scope(headers)
|
|
200
|
+
operation = _operation_for(service, headers, method, path, query, body)
|
|
201
|
+
resource = _resource_hint(service, operation, path, body)
|
|
202
|
+
return RequestInfo(
|
|
203
|
+
service=service,
|
|
204
|
+
operation=operation,
|
|
205
|
+
region=region,
|
|
206
|
+
resource=resource,
|
|
207
|
+
access_key=access_key,
|
|
208
|
+
)
|
microburst/errors.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Error serialization per AWS wire protocol.
|
|
2
|
+
|
|
3
|
+
The SDKs classify failures by the *error code parsed from the body* (plus
|
|
4
|
+
status code), not the status alone. To make injected faults exercise the real
|
|
5
|
+
retry machinery — throttling backoff, adaptive rate limiting, modeled
|
|
6
|
+
retryable exceptions — the body must be shaped the way the service actually
|
|
7
|
+
shapes it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import uuid
|
|
14
|
+
from xml.sax.saxutils import escape
|
|
15
|
+
|
|
16
|
+
from microburst.models import error_http_status, get_protocol
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _request_id() -> str:
|
|
20
|
+
return str(uuid.uuid4())
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _json_body(code: str, message: str) -> bytes:
|
|
24
|
+
return json.dumps({"__type": code, "message": message}).encode()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def render_error(
|
|
28
|
+
service: str | None,
|
|
29
|
+
code: str,
|
|
30
|
+
message: str = "",
|
|
31
|
+
status: int | None = None,
|
|
32
|
+
) -> tuple[int, dict[str, str], bytes]:
|
|
33
|
+
"""Serialize an AWS-looking error. Returns (status, headers, body)."""
|
|
34
|
+
protocol = get_protocol(service) if service else None
|
|
35
|
+
if status is None:
|
|
36
|
+
if service is None:
|
|
37
|
+
status = 503
|
|
38
|
+
else:
|
|
39
|
+
# json/query services conventionally serve client faults at 400;
|
|
40
|
+
# rest protocols lean on 5xx for transient errors.
|
|
41
|
+
default = 400 if protocol in ("json", "query", "ec2") else 503
|
|
42
|
+
status = error_http_status(service, code, default=default)
|
|
43
|
+
if not message:
|
|
44
|
+
message = code
|
|
45
|
+
|
|
46
|
+
rid = _request_id()
|
|
47
|
+
|
|
48
|
+
if protocol == "json":
|
|
49
|
+
headers = {
|
|
50
|
+
"Content-Type": "application/x-amz-json-1.0",
|
|
51
|
+
"x-amzn-RequestId": rid,
|
|
52
|
+
"x-amzn-ErrorType": code,
|
|
53
|
+
}
|
|
54
|
+
return status, headers, _json_body(code, message)
|
|
55
|
+
|
|
56
|
+
if protocol in ("query", "ec2"):
|
|
57
|
+
headers = {
|
|
58
|
+
"Content-Type": "text/xml",
|
|
59
|
+
"x-amzn-RequestId": rid,
|
|
60
|
+
}
|
|
61
|
+
body = (
|
|
62
|
+
"<ErrorResponse>"
|
|
63
|
+
"<Error>"
|
|
64
|
+
f"<Code>{escape(code)}</Code>"
|
|
65
|
+
f"<Message>{escape(message)}</Message>"
|
|
66
|
+
"<Type>Sender</Type>"
|
|
67
|
+
"</Error>"
|
|
68
|
+
f"<RequestId>{rid}</RequestId>"
|
|
69
|
+
"</ErrorResponse>"
|
|
70
|
+
)
|
|
71
|
+
return status, headers, body.encode()
|
|
72
|
+
|
|
73
|
+
if protocol == "rest-xml":
|
|
74
|
+
headers = {
|
|
75
|
+
"Content-Type": "application/xml",
|
|
76
|
+
"x-amz-request-id": rid,
|
|
77
|
+
"x-amz-id-2": uuid.uuid4().hex * 2,
|
|
78
|
+
}
|
|
79
|
+
body = (
|
|
80
|
+
"<Error>"
|
|
81
|
+
f"<Code>{escape(code)}</Code>"
|
|
82
|
+
f"<Message>{escape(message)}</Message>"
|
|
83
|
+
f"<RequestId>{rid}</RequestId>"
|
|
84
|
+
"</Error>"
|
|
85
|
+
)
|
|
86
|
+
return status, headers, body.encode()
|
|
87
|
+
|
|
88
|
+
if protocol == "rest-json":
|
|
89
|
+
headers = {
|
|
90
|
+
"Content-Type": "application/json",
|
|
91
|
+
"x-amzn-RequestId": rid,
|
|
92
|
+
"x-amzn-errortype": code,
|
|
93
|
+
}
|
|
94
|
+
return status, headers, json.dumps({"message": message}).encode()
|
|
95
|
+
|
|
96
|
+
# Unknown service/protocol: closest AWS-looking generic envelope.
|
|
97
|
+
headers = {
|
|
98
|
+
"Content-Type": "application/x-amz-json-1.0",
|
|
99
|
+
"x-amzn-RequestId": rid,
|
|
100
|
+
"x-amzn-ErrorType": code,
|
|
101
|
+
}
|
|
102
|
+
return status, headers, _json_body(code, message)
|
microburst/models.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Service model access via botocore.
|
|
2
|
+
|
|
3
|
+
Wraps botocore's loader to resolve AWS service models (protocol, operations,
|
|
4
|
+
error shapes) by the names requests actually carry: SigV4 credential scope and
|
|
5
|
+
endpoint prefixes. Everything is lazy and cached.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import botocore.session
|
|
11
|
+
|
|
12
|
+
# SigV4 credential scopes that differ from the botocore service name.
|
|
13
|
+
_SCOPE_ALIASES = {
|
|
14
|
+
"monitoring": "cloudwatch",
|
|
15
|
+
"email": "ses",
|
|
16
|
+
"iotdata": "iot-data",
|
|
17
|
+
"iot-jobs-data": "iot-jobs-data",
|
|
18
|
+
"iotwireless": "iotwireless",
|
|
19
|
+
"elasticfilesystem": "elasticfilesystem",
|
|
20
|
+
"api.ecr": "ecr",
|
|
21
|
+
"aoss": "opensearchserverless",
|
|
22
|
+
"cloudfront-keyvaluestore": "cloudfront-keyvaluestore",
|
|
23
|
+
"cognito-idp": "cognito-idp",
|
|
24
|
+
"cognito-identity": "cognito-identity",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
_session = botocore.session.get_session()
|
|
28
|
+
_model_cache: dict[str, object] = {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def service_for_scope(scope: str) -> str:
|
|
32
|
+
"""Map a SigV4 credential scope to a botocore service name."""
|
|
33
|
+
return _SCOPE_ALIASES.get(scope, scope)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_service_model(service: str):
|
|
37
|
+
"""Return the botocore ServiceModel for a service name, or None."""
|
|
38
|
+
if service in _model_cache:
|
|
39
|
+
return _model_cache[service]
|
|
40
|
+
try:
|
|
41
|
+
model = _session.get_service_model(service)
|
|
42
|
+
except Exception: # noqa: BLE001 — unknown service names / broken data loaders
|
|
43
|
+
model = None
|
|
44
|
+
_model_cache[service] = model
|
|
45
|
+
return model
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_protocol(service: str) -> str | None:
|
|
49
|
+
model = get_service_model(service)
|
|
50
|
+
if model is None:
|
|
51
|
+
return None
|
|
52
|
+
return model.protocol
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def error_shape(service: str, code: str):
|
|
56
|
+
"""Find the shape for an error code/name in a service model, or None.
|
|
57
|
+
|
|
58
|
+
Accepts the bare shape name or a Smithy-style ``prefix#Name``.
|
|
59
|
+
"""
|
|
60
|
+
model = get_service_model(service)
|
|
61
|
+
if model is None:
|
|
62
|
+
return None
|
|
63
|
+
name = code.rsplit("#", 1)[-1]
|
|
64
|
+
shape = _shape_or_none(model, name)
|
|
65
|
+
if shape is not None:
|
|
66
|
+
return shape
|
|
67
|
+
for candidate in model.shape_names:
|
|
68
|
+
if candidate == name or candidate.rsplit("#", 1)[-1] == name:
|
|
69
|
+
return _shape_or_none(model, candidate)
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _shape_or_none(model, name: str):
|
|
74
|
+
try:
|
|
75
|
+
return model.shape_for(name)
|
|
76
|
+
except Exception: # noqa: BLE001 — unmodeled codes raise assorted errors
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# AWS's real status for common codes when the service model doesn't carry an
|
|
81
|
+
# ``httpStatusCode`` trait (most don't). Verified behavior that matters: the
|
|
82
|
+
# SDK's retry decision depends on the status — S3 SlowDown at 400 is treated
|
|
83
|
+
# as a terminal client error, at 503 it retries.
|
|
84
|
+
_KNOWN_STATUS = {
|
|
85
|
+
# throttling family (botocore's retryable throttled codes)
|
|
86
|
+
"Throttling": 400,
|
|
87
|
+
"ThrottlingException": 400,
|
|
88
|
+
"ThrottledException": 400,
|
|
89
|
+
"RequestThrottledException": 400,
|
|
90
|
+
"RequestThrottled": 400,
|
|
91
|
+
"EC2ThrottledException": 400,
|
|
92
|
+
"TooManyRequestsException": 429,
|
|
93
|
+
"ProvisionedThroughputExceededException": 400,
|
|
94
|
+
"TransactionInProgressException": 400,
|
|
95
|
+
"RequestLimitExceeded": 503,
|
|
96
|
+
"BandwidthLimitExceeded": 400,
|
|
97
|
+
"LimitExceededException": 400,
|
|
98
|
+
"PriorRequestNotComplete": 400,
|
|
99
|
+
"SlowDown": 503,
|
|
100
|
+
# transient server-side
|
|
101
|
+
"InternalError": 500,
|
|
102
|
+
"InternalServerError": 500,
|
|
103
|
+
"InternalFailure": 500,
|
|
104
|
+
"InternalServiceError": 500,
|
|
105
|
+
"ServiceUnavailable": 503,
|
|
106
|
+
"ServiceUnavailableException": 503,
|
|
107
|
+
"ServiceUnavailableError": 503,
|
|
108
|
+
"RequestTimeout": 408,
|
|
109
|
+
"RequestTimeoutException": 408,
|
|
110
|
+
# common terminal errors — keep these non-retryable
|
|
111
|
+
"AccessDeniedException": 403,
|
|
112
|
+
"AccessDenied": 403,
|
|
113
|
+
"InvalidAccessKeyId": 403,
|
|
114
|
+
"SignatureDoesNotMatch": 403,
|
|
115
|
+
"ExpiredTokenException": 400,
|
|
116
|
+
"UnauthorizedException": 401,
|
|
117
|
+
"NotAuthorizedException": 400,
|
|
118
|
+
"ValidationException": 400,
|
|
119
|
+
"ValidationError": 400,
|
|
120
|
+
"InvalidParameterValue": 400,
|
|
121
|
+
"InvalidParameterException": 400,
|
|
122
|
+
"InvalidParameterValueException": 400,
|
|
123
|
+
"MissingParameter": 400,
|
|
124
|
+
"NoSuchBucket": 404,
|
|
125
|
+
"NoSuchKey": 404,
|
|
126
|
+
"NotFound": 404,
|
|
127
|
+
"NotFoundException": 404,
|
|
128
|
+
"ResourceNotFoundException": 404,
|
|
129
|
+
"ResourceNotFound": 404,
|
|
130
|
+
"ResourceInUseException": 400,
|
|
131
|
+
"ResourceAlreadyExistsException": 400,
|
|
132
|
+
"ConditionalCheckFailedException": 400,
|
|
133
|
+
"InvalidClientTokenId": 403,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _modeled_status(service: str, name: str) -> int | None:
|
|
138
|
+
"""httpStatusCode from the raw service model, when modeled."""
|
|
139
|
+
try:
|
|
140
|
+
loader = _session.get_component("data_loader")
|
|
141
|
+
model = loader.load_service_model(service, "service-2")
|
|
142
|
+
shape = model.get("shapes", {}).get(name)
|
|
143
|
+
if shape:
|
|
144
|
+
status = shape.get("error", {}).get("httpStatusCode")
|
|
145
|
+
return status if isinstance(status, int) else None
|
|
146
|
+
except Exception: # noqa: BLE001 — service model may be absent/partial
|
|
147
|
+
return None
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def error_http_status(service: str, code: str, default: int = 503) -> int:
|
|
152
|
+
"""HTTP status for an error code.
|
|
153
|
+
|
|
154
|
+
Order: modeled ``httpStatusCode`` (rare but authoritative when present) →
|
|
155
|
+
curated AWS-observed map → ``default`` (callers pick per protocol; AWS
|
|
156
|
+
json/query services conventionally serve client errors at 400).
|
|
157
|
+
"""
|
|
158
|
+
name = code.rsplit("#", 1)[-1]
|
|
159
|
+
status = _modeled_status(service, name)
|
|
160
|
+
if status is not None:
|
|
161
|
+
return status
|
|
162
|
+
return _KNOWN_STATUS.get(name, default)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def operation_error_names(service: str, operation: str) -> list[str]:
|
|
166
|
+
"""Error shape names modeled for an operation (for plausible sampling)."""
|
|
167
|
+
model = get_service_model(service)
|
|
168
|
+
if model is None:
|
|
169
|
+
return []
|
|
170
|
+
try:
|
|
171
|
+
op = model.operation_model(operation)
|
|
172
|
+
except Exception: # noqa: BLE001 — OperationNotFoundError and friends
|
|
173
|
+
return []
|
|
174
|
+
return [shape.name for shape in op.error_shapes]
|