conseqa 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.
- conseqa/__init__.py +67 -0
- conseqa/_canonical.py +119 -0
- conseqa/_client.py +512 -0
- conseqa/_package.py +211 -0
- conseqa/_runner.py +94 -0
- conseqa/_telemetry.py +124 -0
- conseqa/_version.py +1 -0
- conseqa/connectors.py +522 -0
- conseqa/errors.py +54 -0
- conseqa/py.typed +0 -0
- conseqa-0.1.0.dist-info/METADATA +209 -0
- conseqa-0.1.0.dist-info/RECORD +15 -0
- conseqa-0.1.0.dist-info/WHEEL +5 -0
- conseqa-0.1.0.dist-info/licenses/LICENSE +201 -0
- conseqa-0.1.0.dist-info/top_level.txt +1 -0
conseqa/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Conseqa for Python: verify what your agent actually changed.
|
|
2
|
+
|
|
3
|
+
from conseqa import Conseqa, load_contract_package
|
|
4
|
+
|
|
5
|
+
package = load_contract_package("dist/conseqa")
|
|
6
|
+
refund = package.contract(
|
|
7
|
+
"refund.processed", 1,
|
|
8
|
+
bind_result=lambda reply: {"refundId": reply["id"]},
|
|
9
|
+
)
|
|
10
|
+
conseqa = Conseqa(package, sidecar_token=os.environ["CONSEQA_SIDECAR_TOKEN"])
|
|
11
|
+
reply = conseqa.protect(
|
|
12
|
+
refund,
|
|
13
|
+
action_key="ticket-8042-refund",
|
|
14
|
+
intent={"paymentId": "pay_1001", "amountMinor": 2500, "currency": "USD"},
|
|
15
|
+
execute=lambda call: create_refund(reference=call.provider_correlation_key),
|
|
16
|
+
)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from ._canonical import canonical_json, payload_digest
|
|
20
|
+
from ._client import Conseqa, ExecuteContext
|
|
21
|
+
from ._package import (
|
|
22
|
+
Classification,
|
|
23
|
+
Contract,
|
|
24
|
+
ContractPackage,
|
|
25
|
+
IntentContext,
|
|
26
|
+
ambiguous,
|
|
27
|
+
calculate_build_hash,
|
|
28
|
+
load_contract_package,
|
|
29
|
+
not_executed,
|
|
30
|
+
rejected,
|
|
31
|
+
)
|
|
32
|
+
from ._runner import HttpRunnerTransport
|
|
33
|
+
from ._version import __version__
|
|
34
|
+
from .errors import (
|
|
35
|
+
ConseqaError,
|
|
36
|
+
ContractPackageIntegrityError,
|
|
37
|
+
DuplicateActionError,
|
|
38
|
+
ProtectionUnavailableError,
|
|
39
|
+
RunnerRejectedError,
|
|
40
|
+
RunnerUnavailableError,
|
|
41
|
+
UnregisteredContractError,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"Classification",
|
|
46
|
+
"Conseqa",
|
|
47
|
+
"ConseqaError",
|
|
48
|
+
"Contract",
|
|
49
|
+
"ContractPackage",
|
|
50
|
+
"ContractPackageIntegrityError",
|
|
51
|
+
"DuplicateActionError",
|
|
52
|
+
"ExecuteContext",
|
|
53
|
+
"HttpRunnerTransport",
|
|
54
|
+
"IntentContext",
|
|
55
|
+
"ProtectionUnavailableError",
|
|
56
|
+
"RunnerRejectedError",
|
|
57
|
+
"RunnerUnavailableError",
|
|
58
|
+
"UnregisteredContractError",
|
|
59
|
+
"__version__",
|
|
60
|
+
"ambiguous",
|
|
61
|
+
"calculate_build_hash",
|
|
62
|
+
"canonical_json",
|
|
63
|
+
"load_contract_package",
|
|
64
|
+
"not_executed",
|
|
65
|
+
"payload_digest",
|
|
66
|
+
"rejected",
|
|
67
|
+
]
|
conseqa/_canonical.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Canonical JSON and hashing, byte for byte what the JavaScript packages produce.
|
|
2
|
+
|
|
3
|
+
The runner and the hosted service compare digests made by either SDK, so the same value
|
|
4
|
+
must give the same text here as `canonicalJson` gives in `@conseqa/contracts`: keys sorted
|
|
5
|
+
by UTF-16 code unit, no whitespace, strings escaped as `JSON.stringify` escapes them, and
|
|
6
|
+
numbers written as JavaScript writes them.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
from decimal import Decimal
|
|
15
|
+
from typing import Any, Union
|
|
16
|
+
|
|
17
|
+
#: The largest whole number JavaScript carries exactly. A larger one would be rounded on
|
|
18
|
+
#: its way through the runner, so the digest made here would describe a different value.
|
|
19
|
+
MAX_SAFE_INTEGER = 2**53 - 1
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _utf16_units(key: str) -> bytes:
|
|
23
|
+
return key.encode("utf-16-be", "surrogatepass")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _string(value: str, path: str) -> str:
|
|
27
|
+
for character in value:
|
|
28
|
+
if 0xD800 <= ord(character) <= 0xDFFF:
|
|
29
|
+
raise TypeError(f"Text with an unpaired surrogate at {path}")
|
|
30
|
+
# json.dumps escapes exactly what JSON.stringify escapes: the quote, the backslash and
|
|
31
|
+
# the control characters below U+0020, with the same short forms and lowercase hex.
|
|
32
|
+
return json.dumps(value, ensure_ascii=False)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _number(value: float, path: str) -> str:
|
|
36
|
+
"""A double the way ECMAScript's Number::toString writes it."""
|
|
37
|
+
if math.isnan(value) or math.isinf(value):
|
|
38
|
+
raise TypeError(f"Canonical JSON cannot contain NaN or Infinity (at {path})")
|
|
39
|
+
if value == 0:
|
|
40
|
+
return "0"
|
|
41
|
+
# repr() gives the shortest digits that read back as the same double, as JavaScript does.
|
|
42
|
+
sign, digits, exponent = Decimal(repr(value)).as_tuple()
|
|
43
|
+
text = "".join(str(digit) for digit in digits).lstrip("0") or "0"
|
|
44
|
+
stripped = text.rstrip("0")
|
|
45
|
+
exponent = int(exponent) + (len(text) - len(stripped))
|
|
46
|
+
text = stripped
|
|
47
|
+
count = len(text)
|
|
48
|
+
point = count + exponent # how many digits stand before the decimal point
|
|
49
|
+
if count <= point <= 21:
|
|
50
|
+
body = text + "0" * (point - count)
|
|
51
|
+
elif 0 < point <= 21:
|
|
52
|
+
body = f"{text[:point]}.{text[point:]}"
|
|
53
|
+
elif -6 < point <= 0:
|
|
54
|
+
body = "0." + "0" * (-point) + text
|
|
55
|
+
else:
|
|
56
|
+
power = point - 1
|
|
57
|
+
mantissa = text if count == 1 else f"{text[0]}.{text[1:]}"
|
|
58
|
+
body = f"{mantissa}e{'+' if power >= 0 else '-'}{abs(power)}"
|
|
59
|
+
return f"-{body}" if sign else body
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def canonical_json(value: Any) -> str:
|
|
63
|
+
"""Stable JSON text for a JSON value. Anything that is not plain JSON is refused."""
|
|
64
|
+
seen: set[int] = set()
|
|
65
|
+
|
|
66
|
+
def visit(candidate: Any, path: str) -> str:
|
|
67
|
+
if candidate is None:
|
|
68
|
+
return "null"
|
|
69
|
+
if candidate is True:
|
|
70
|
+
return "true"
|
|
71
|
+
if candidate is False:
|
|
72
|
+
return "false"
|
|
73
|
+
if isinstance(candidate, str):
|
|
74
|
+
return _string(candidate, path)
|
|
75
|
+
if isinstance(candidate, int):
|
|
76
|
+
if abs(candidate) > MAX_SAFE_INTEGER:
|
|
77
|
+
raise TypeError(
|
|
78
|
+
f"Whole number at {path} is too large to carry exactly; send it as text"
|
|
79
|
+
)
|
|
80
|
+
return str(candidate)
|
|
81
|
+
if isinstance(candidate, float):
|
|
82
|
+
return _number(candidate, path)
|
|
83
|
+
if isinstance(candidate, (list, tuple, dict)):
|
|
84
|
+
if id(candidate) in seen:
|
|
85
|
+
raise TypeError(f"Circular value at {path}")
|
|
86
|
+
seen.add(id(candidate))
|
|
87
|
+
try:
|
|
88
|
+
if isinstance(candidate, dict):
|
|
89
|
+
for key in candidate:
|
|
90
|
+
if not isinstance(key, str):
|
|
91
|
+
raise TypeError(f"Key that is not text at {path}")
|
|
92
|
+
return (
|
|
93
|
+
"{"
|
|
94
|
+
+ ",".join(
|
|
95
|
+
f"{_string(key, path)}:{visit(candidate[key], f'{path}.{key}')}"
|
|
96
|
+
for key in sorted(candidate, key=_utf16_units)
|
|
97
|
+
)
|
|
98
|
+
+ "}"
|
|
99
|
+
)
|
|
100
|
+
return (
|
|
101
|
+
"["
|
|
102
|
+
+ ",".join(visit(item, f"{path}[{index}]") for index, item in enumerate(candidate))
|
|
103
|
+
+ "]"
|
|
104
|
+
)
|
|
105
|
+
finally:
|
|
106
|
+
seen.discard(id(candidate))
|
|
107
|
+
raise TypeError(f"Value that is not JSON at {path}: {type(candidate).__name__}")
|
|
108
|
+
|
|
109
|
+
return visit(value, "$")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def sha256_hex(value: Union[str, bytes]) -> str:
|
|
113
|
+
data = value.encode("utf-8") if isinstance(value, str) else value
|
|
114
|
+
return hashlib.sha256(data).hexdigest()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def payload_digest(intent: Any) -> str:
|
|
118
|
+
"""The digest of an intent, as every SDK and the runner compute it."""
|
|
119
|
+
return sha256_hex(canonical_json(intent))
|
conseqa/_client.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"""`Conseqa.protect`: record what a call is meant to do, make the call, report how it ended.
|
|
2
|
+
|
|
3
|
+
This follows the JavaScript SDK step for step, and sends the runner the same requests:
|
|
4
|
+
|
|
5
|
+
1. Register the intent. A required action that cannot be registered is not made.
|
|
6
|
+
2. Record that the call is about to start.
|
|
7
|
+
3. Make the call. Whatever it returns or raises is what `protect` returns or raises.
|
|
8
|
+
4. Report how it ended: what it returned and what identifies the result, or what the
|
|
9
|
+
exception proves.
|
|
10
|
+
|
|
11
|
+
The runner does the rest: it reads the system of record and decides the verdict.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import inspect
|
|
18
|
+
import threading
|
|
19
|
+
import uuid
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from typing import Any, Callable, Dict, Mapping, Optional, Union
|
|
23
|
+
|
|
24
|
+
from ._canonical import canonical_json, payload_digest
|
|
25
|
+
from ._package import Classification, Contract, ContractPackage, IntentContext
|
|
26
|
+
from ._runner import HttpRunnerTransport
|
|
27
|
+
from ._telemetry import OperationTelemetry, SignalHandler
|
|
28
|
+
from ._version import __version__
|
|
29
|
+
from .errors import (
|
|
30
|
+
DuplicateActionError,
|
|
31
|
+
ProtectionUnavailableError,
|
|
32
|
+
RunnerRejectedError,
|
|
33
|
+
UnregisteredContractError,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
JsonObject = Dict[str, Any]
|
|
37
|
+
SDK_NAME = "conseqa-python"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class ExecuteContext:
|
|
42
|
+
"""What the protected call is given."""
|
|
43
|
+
|
|
44
|
+
#: The reference to send with the call, so its record can be found by it afterwards.
|
|
45
|
+
provider_correlation_key: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _iso(moment: datetime) -> str:
|
|
49
|
+
if moment.tzinfo is None:
|
|
50
|
+
raise ValueError("A time without a zone is ambiguous; use a timezone-aware datetime")
|
|
51
|
+
text = moment.astimezone(timezone.utc).isoformat(timespec="milliseconds")
|
|
52
|
+
return text.replace("+00:00", "Z")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _now() -> datetime:
|
|
56
|
+
return datetime.now(timezone.utc)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _new_id() -> str:
|
|
60
|
+
return str(uuid.uuid4())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class _Run:
|
|
65
|
+
contract: Contract
|
|
66
|
+
mode: str
|
|
67
|
+
action_key: str
|
|
68
|
+
intent_id: str
|
|
69
|
+
registration: JsonObject
|
|
70
|
+
context: ExecuteContext
|
|
71
|
+
telemetry: OperationTelemetry
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Conseqa:
|
|
75
|
+
"""The client an application keeps for as long as it runs."""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
package: ContractPackage,
|
|
80
|
+
*,
|
|
81
|
+
runner_url: str = "http://127.0.0.1:4319",
|
|
82
|
+
sidecar_token: Optional[str] = None,
|
|
83
|
+
runner: Any = None,
|
|
84
|
+
runtime_heartbeat_interval: float = 30.0,
|
|
85
|
+
instance_id: Optional[str] = None,
|
|
86
|
+
on_signal: Optional[SignalHandler] = None,
|
|
87
|
+
clock: Optional[Callable[[], datetime]] = None,
|
|
88
|
+
id_factory: Optional[Callable[[], str]] = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""
|
|
91
|
+
`package` comes from `load_contract_package`. `runner_url` and `sidecar_token` say
|
|
92
|
+
where the runner listens and what it expects; `runner` replaces them with a
|
|
93
|
+
transport of your own. `runtime_heartbeat_interval` is how often, in seconds, the
|
|
94
|
+
application tells the runner which contract build it runs; 0 switches that off.
|
|
95
|
+
"""
|
|
96
|
+
if not isinstance(package, ContractPackage):
|
|
97
|
+
raise TypeError("package must come from load_contract_package")
|
|
98
|
+
if runner is not None and sidecar_token is not None:
|
|
99
|
+
raise TypeError("sidecar_token cannot be used with a runner transport of your own")
|
|
100
|
+
if instance_id is not None:
|
|
101
|
+
try:
|
|
102
|
+
instance_id = str(uuid.UUID(instance_id))
|
|
103
|
+
except (ValueError, AttributeError, TypeError) as error:
|
|
104
|
+
raise TypeError(
|
|
105
|
+
"instance_id must be an opaque UUID, not a host name or a person"
|
|
106
|
+
) from error
|
|
107
|
+
if runtime_heartbeat_interval < 0:
|
|
108
|
+
raise TypeError("runtime_heartbeat_interval must be zero or positive")
|
|
109
|
+
self._package = package
|
|
110
|
+
self._runner = runner or HttpRunnerTransport(runner_url, sidecar_token=sidecar_token)
|
|
111
|
+
self._on_signal = on_signal
|
|
112
|
+
self._clock = clock or _now
|
|
113
|
+
self._new_id = id_factory or _new_id
|
|
114
|
+
self.instance_id = instance_id or _new_id()
|
|
115
|
+
self._heartbeat_interval = float(runtime_heartbeat_interval)
|
|
116
|
+
self._closed = threading.Event()
|
|
117
|
+
self._heartbeat_thread: Optional[threading.Thread] = None
|
|
118
|
+
if self._heartbeat_interval > 0 and hasattr(self._runner, "report_runtime_instance"):
|
|
119
|
+
self._heartbeat_thread = threading.Thread(
|
|
120
|
+
target=self._heartbeats, name="conseqa-heartbeat", daemon=True
|
|
121
|
+
)
|
|
122
|
+
self._heartbeat_thread.start()
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------- lifecycle of the client
|
|
125
|
+
|
|
126
|
+
def close(self) -> None:
|
|
127
|
+
"""Stops the background report of which build this application runs."""
|
|
128
|
+
self._closed.set()
|
|
129
|
+
|
|
130
|
+
def __enter__(self) -> "Conseqa":
|
|
131
|
+
return self
|
|
132
|
+
|
|
133
|
+
def __exit__(self, *_exc: Any) -> None:
|
|
134
|
+
self.close()
|
|
135
|
+
|
|
136
|
+
def _heartbeats(self) -> None:
|
|
137
|
+
while not self._closed.is_set():
|
|
138
|
+
try:
|
|
139
|
+
self._runner.report_runtime_instance(
|
|
140
|
+
{
|
|
141
|
+
"instanceId": self.instance_id,
|
|
142
|
+
"buildHash": self._package.build_hash,
|
|
143
|
+
"packageName": self._package.package_name,
|
|
144
|
+
"packageVersion": self._package.package_version,
|
|
145
|
+
"observedAt": _iso(self._clock()),
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
except Exception:
|
|
149
|
+
pass # a diagnostic report must never get in the way of the application
|
|
150
|
+
self._closed.wait(self._heartbeat_interval)
|
|
151
|
+
|
|
152
|
+
# ------------------------------------------------------------------------------- protect
|
|
153
|
+
|
|
154
|
+
def protect(
|
|
155
|
+
self,
|
|
156
|
+
contract: Contract,
|
|
157
|
+
*,
|
|
158
|
+
action_key: str,
|
|
159
|
+
intent: Mapping[str, Any],
|
|
160
|
+
execute: Callable[[ExecuteContext], Any],
|
|
161
|
+
mode: str = "required",
|
|
162
|
+
deadline_at: Union[datetime, str, None] = None,
|
|
163
|
+
) -> Any:
|
|
164
|
+
"""Makes one protected call and returns what it returned.
|
|
165
|
+
|
|
166
|
+
`action_key` is one stable key for the business operation, such as the ticket or
|
|
167
|
+
order it belongs to. `intent` is what the call is meant to do, in the shape of the
|
|
168
|
+
contract's intent schema. `execute` is the call you already make; it is given the
|
|
169
|
+
reference to send with it. With `mode="required"` the call is not made unless the
|
|
170
|
+
intent was recorded first; with `"best_effort"` it goes ahead anyway and shows as
|
|
171
|
+
an unprotected gap.
|
|
172
|
+
"""
|
|
173
|
+
if inspect.iscoroutinefunction(execute):
|
|
174
|
+
raise TypeError("execute is a coroutine function; use protect_async")
|
|
175
|
+
run = self._begin(contract, action_key, intent, mode, deadline_at)
|
|
176
|
+
with run.telemetry.active():
|
|
177
|
+
if not (self._register(run) and self._authorize(run)):
|
|
178
|
+
return self._unprotected(run, lambda: execute(run.context))
|
|
179
|
+
try:
|
|
180
|
+
result = execute(run.context)
|
|
181
|
+
except BaseException as error:
|
|
182
|
+
self._after_exception(run, error)
|
|
183
|
+
raise
|
|
184
|
+
if inspect.isawaitable(result):
|
|
185
|
+
# The call was never made: what came back is a promise to make it.
|
|
186
|
+
closer = getattr(result, "close", None)
|
|
187
|
+
if callable(closer):
|
|
188
|
+
closer()
|
|
189
|
+
self._report(
|
|
190
|
+
run,
|
|
191
|
+
self._event(
|
|
192
|
+
run,
|
|
193
|
+
2,
|
|
194
|
+
"action.exception",
|
|
195
|
+
classification=Classification(
|
|
196
|
+
"not_executed", False, "awaitable_in_protect"
|
|
197
|
+
).as_json(),
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
run.telemetry.fail()
|
|
201
|
+
run.telemetry.end()
|
|
202
|
+
raise TypeError("execute returned an awaitable; use protect_async")
|
|
203
|
+
self._after_return(run, result)
|
|
204
|
+
return result
|
|
205
|
+
|
|
206
|
+
async def protect_async(
|
|
207
|
+
self,
|
|
208
|
+
contract: Contract,
|
|
209
|
+
*,
|
|
210
|
+
action_key: str,
|
|
211
|
+
intent: Mapping[str, Any],
|
|
212
|
+
execute: Callable[[ExecuteContext], Any],
|
|
213
|
+
mode: str = "required",
|
|
214
|
+
deadline_at: Union[datetime, str, None] = None,
|
|
215
|
+
) -> Any:
|
|
216
|
+
"""`protect` for an application that runs on asyncio. `execute` may be a coroutine."""
|
|
217
|
+
run = self._begin(contract, action_key, intent, mode, deadline_at)
|
|
218
|
+
with run.telemetry.active():
|
|
219
|
+
registered = await asyncio.to_thread(self._register, run)
|
|
220
|
+
if not (registered and await asyncio.to_thread(self._authorize, run)):
|
|
221
|
+
|
|
222
|
+
async def unprotected() -> Any:
|
|
223
|
+
outcome = execute(run.context)
|
|
224
|
+
return await outcome if inspect.isawaitable(outcome) else outcome
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
result = await unprotected()
|
|
228
|
+
except BaseException:
|
|
229
|
+
run.telemetry.fail()
|
|
230
|
+
run.telemetry.end()
|
|
231
|
+
raise
|
|
232
|
+
run.telemetry.end()
|
|
233
|
+
return result
|
|
234
|
+
try:
|
|
235
|
+
result = execute(run.context)
|
|
236
|
+
if inspect.isawaitable(result):
|
|
237
|
+
result = await result
|
|
238
|
+
except BaseException as error:
|
|
239
|
+
# Shielded: a task cancelled in the middle of a call must still say so.
|
|
240
|
+
await asyncio.shield(asyncio.to_thread(self._after_exception, run, error))
|
|
241
|
+
raise
|
|
242
|
+
await asyncio.to_thread(self._after_return, run, result)
|
|
243
|
+
return result
|
|
244
|
+
|
|
245
|
+
# ------------------------------------------------------------------------------- observe
|
|
246
|
+
|
|
247
|
+
def observe(
|
|
248
|
+
self, operation: str, execute: Callable[[], Any], *, action_key: Optional[str] = None
|
|
249
|
+
) -> Any:
|
|
250
|
+
"""Runs a call without protecting it, and marks it as one that could have been.
|
|
251
|
+
|
|
252
|
+
Coverage is the share of an operation's runs that were protected. This is how the
|
|
253
|
+
runs that were not get counted. It needs the application to have OpenTelemetry.
|
|
254
|
+
"""
|
|
255
|
+
del action_key # accepted for parity with the JavaScript SDK; never sent anywhere
|
|
256
|
+
telemetry = OperationTelemetry(operation, self._on_signal)
|
|
257
|
+
telemetry.emit(
|
|
258
|
+
"conseqa.unprotected.observed",
|
|
259
|
+
protection_level="NONE",
|
|
260
|
+
attributes={"explicitly_eligible": True},
|
|
261
|
+
)
|
|
262
|
+
with telemetry.active():
|
|
263
|
+
try:
|
|
264
|
+
result = execute()
|
|
265
|
+
except BaseException:
|
|
266
|
+
telemetry.fail()
|
|
267
|
+
telemetry.end()
|
|
268
|
+
raise
|
|
269
|
+
telemetry.end()
|
|
270
|
+
return result
|
|
271
|
+
|
|
272
|
+
# ----------------------------------------------------------------------------- the steps
|
|
273
|
+
|
|
274
|
+
def _begin(
|
|
275
|
+
self,
|
|
276
|
+
contract: Contract,
|
|
277
|
+
action_key: str,
|
|
278
|
+
intent: Mapping[str, Any],
|
|
279
|
+
mode: str,
|
|
280
|
+
deadline_at: Union[datetime, str, None],
|
|
281
|
+
) -> _Run:
|
|
282
|
+
if not isinstance(contract, Contract) or contract._package is not self._package:
|
|
283
|
+
raise UnregisteredContractError(
|
|
284
|
+
"The contract did not come from the package this client was given"
|
|
285
|
+
)
|
|
286
|
+
if mode not in ("required", "best_effort"):
|
|
287
|
+
raise ValueError('mode must be "required" or "best_effort"')
|
|
288
|
+
if not isinstance(action_key, str) or not 1 <= len(action_key) <= 512:
|
|
289
|
+
raise ValueError("action_key must be text of 1 to 512 characters")
|
|
290
|
+
if not isinstance(intent, Mapping):
|
|
291
|
+
raise TypeError("intent must be a JSON object")
|
|
292
|
+
intent_body: JsonObject = dict(intent)
|
|
293
|
+
digest = payload_digest(intent_body) # also refuses anything that is not JSON
|
|
294
|
+
intent_id = self._new_id()
|
|
295
|
+
correlation = contract.correlation_key(
|
|
296
|
+
IntentContext(action_key=action_key, intent_id=intent_id, intent=intent_body)
|
|
297
|
+
)
|
|
298
|
+
if not isinstance(correlation, str) or not 1 <= len(correlation) <= 256:
|
|
299
|
+
raise ValueError("The correlation key must be text of 1 to 256 characters")
|
|
300
|
+
telemetry = OperationTelemetry(contract.operation, self._on_signal)
|
|
301
|
+
registration: JsonObject = {
|
|
302
|
+
"intentId": intent_id,
|
|
303
|
+
"actionKey": action_key,
|
|
304
|
+
"operation": contract.operation,
|
|
305
|
+
"protectionMode": mode,
|
|
306
|
+
"protectionLevel": contract.protection_level,
|
|
307
|
+
"contract": {
|
|
308
|
+
"name": contract.name,
|
|
309
|
+
"version": contract.version,
|
|
310
|
+
"buildHash": contract.build_hash,
|
|
311
|
+
},
|
|
312
|
+
"providerCorrelationKey": correlation,
|
|
313
|
+
"intent": intent_body,
|
|
314
|
+
"payloadDigest": digest,
|
|
315
|
+
"registeredAt": _iso(self._clock()),
|
|
316
|
+
"sdk": {"name": SDK_NAME, "version": __version__, "instanceId": self.instance_id},
|
|
317
|
+
}
|
|
318
|
+
if deadline_at is not None:
|
|
319
|
+
registration["deadlineAt"] = (
|
|
320
|
+
_iso(deadline_at)
|
|
321
|
+
if isinstance(deadline_at, datetime)
|
|
322
|
+
else _iso(datetime.fromisoformat(deadline_at.replace("Z", "+00:00")))
|
|
323
|
+
)
|
|
324
|
+
trace_context = telemetry.trace_context
|
|
325
|
+
if trace_context:
|
|
326
|
+
registration["traceContext"] = trace_context
|
|
327
|
+
return _Run(
|
|
328
|
+
contract=contract,
|
|
329
|
+
mode=mode,
|
|
330
|
+
action_key=action_key,
|
|
331
|
+
intent_id=intent_id,
|
|
332
|
+
registration=registration,
|
|
333
|
+
context=ExecuteContext(provider_correlation_key=correlation),
|
|
334
|
+
telemetry=telemetry,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
def _event(self, run: _Run, sequence: int, kind: str, **fields: Any) -> JsonObject:
|
|
338
|
+
return {
|
|
339
|
+
"eventId": self._new_id(),
|
|
340
|
+
"intentId": run.intent_id,
|
|
341
|
+
"sequence": sequence,
|
|
342
|
+
"occurredAt": _iso(self._clock()),
|
|
343
|
+
"type": kind,
|
|
344
|
+
**fields,
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
def _send(self, run: _Run, event: JsonObject) -> Optional[BaseException]:
|
|
348
|
+
"""Sends one event. Returns what went wrong, or None."""
|
|
349
|
+
try:
|
|
350
|
+
self._runner.append_event(event)
|
|
351
|
+
return None
|
|
352
|
+
except Exception as error:
|
|
353
|
+
return error
|
|
354
|
+
|
|
355
|
+
def _register(self, run: _Run) -> bool:
|
|
356
|
+
"""True when the intent is recorded. False when the call may go ahead unprotected."""
|
|
357
|
+
telemetry = run.telemetry
|
|
358
|
+
level = run.contract.protection_level
|
|
359
|
+
failure: Optional[BaseException] = None
|
|
360
|
+
acknowledged: Optional[JsonObject] = None
|
|
361
|
+
try:
|
|
362
|
+
acknowledged = self._runner.register_intent(run.registration)
|
|
363
|
+
if not acknowledged["contractHashValidated"]:
|
|
364
|
+
failure = RunnerRejectedError(
|
|
365
|
+
"Runner did not validate the exact contract build hash", 409
|
|
366
|
+
)
|
|
367
|
+
except Exception as error:
|
|
368
|
+
failure = error
|
|
369
|
+
|
|
370
|
+
if acknowledged and acknowledged["status"] == "duplicate":
|
|
371
|
+
telemetry.emit(
|
|
372
|
+
"conseqa.intent.duplicate", intent_id=acknowledged["intentId"], protection_level=level
|
|
373
|
+
)
|
|
374
|
+
telemetry.fail()
|
|
375
|
+
telemetry.end()
|
|
376
|
+
raise DuplicateActionError(
|
|
377
|
+
f"Action key {run.action_key} is already registered; "
|
|
378
|
+
"execution stopped before dispatch",
|
|
379
|
+
acknowledged["intentId"],
|
|
380
|
+
acknowledged["providerCorrelationKey"],
|
|
381
|
+
)
|
|
382
|
+
if acknowledged and failure is None and (
|
|
383
|
+
acknowledged["intentId"] != run.intent_id
|
|
384
|
+
or acknowledged["providerCorrelationKey"] != run.context.provider_correlation_key
|
|
385
|
+
):
|
|
386
|
+
telemetry.fail()
|
|
387
|
+
telemetry.end()
|
|
388
|
+
raise ProtectionUnavailableError(
|
|
389
|
+
"Runner acknowledgement did not match the newly registered intent; execution stopped"
|
|
390
|
+
)
|
|
391
|
+
if failure is not None:
|
|
392
|
+
telemetry.emit(
|
|
393
|
+
"conseqa.protection.gap",
|
|
394
|
+
intent_id=run.intent_id,
|
|
395
|
+
protection_level="NONE",
|
|
396
|
+
attributes={"phase": "intent_registration", "mode": run.mode},
|
|
397
|
+
)
|
|
398
|
+
if run.mode == "required":
|
|
399
|
+
telemetry.fail()
|
|
400
|
+
telemetry.end()
|
|
401
|
+
raise ProtectionUnavailableError(
|
|
402
|
+
"Required Conseqa intent could not be durably registered"
|
|
403
|
+
) from failure
|
|
404
|
+
return False
|
|
405
|
+
telemetry.emit(
|
|
406
|
+
"conseqa.intent.registered",
|
|
407
|
+
intent_id=run.intent_id,
|
|
408
|
+
protection_level=level,
|
|
409
|
+
attributes={"durable_via": "runner"},
|
|
410
|
+
)
|
|
411
|
+
return True
|
|
412
|
+
|
|
413
|
+
def _authorize(self, run: _Run) -> bool:
|
|
414
|
+
"""Records that the call is about to start. False: go ahead unprotected."""
|
|
415
|
+
telemetry = run.telemetry
|
|
416
|
+
failure = self._send(run, self._event(run, 1, "dispatch.authorized"))
|
|
417
|
+
if failure is None:
|
|
418
|
+
telemetry.emit(
|
|
419
|
+
"conseqa.dispatch.authorized",
|
|
420
|
+
intent_id=run.intent_id,
|
|
421
|
+
protection_level=run.contract.protection_level,
|
|
422
|
+
attributes={"durable_via": "runner"},
|
|
423
|
+
)
|
|
424
|
+
return True
|
|
425
|
+
telemetry.emit(
|
|
426
|
+
"conseqa.protection.gap",
|
|
427
|
+
intent_id=run.intent_id,
|
|
428
|
+
protection_level="NONE",
|
|
429
|
+
attributes={"phase": "dispatch_authorization", "mode": run.mode},
|
|
430
|
+
)
|
|
431
|
+
if run.mode == "required":
|
|
432
|
+
self._send(
|
|
433
|
+
run,
|
|
434
|
+
self._event(
|
|
435
|
+
run,
|
|
436
|
+
2,
|
|
437
|
+
"action.abandoned",
|
|
438
|
+
reason="Required dispatch authorization could not be durably recorded",
|
|
439
|
+
),
|
|
440
|
+
)
|
|
441
|
+
telemetry.fail()
|
|
442
|
+
telemetry.end()
|
|
443
|
+
raise ProtectionUnavailableError(
|
|
444
|
+
"Required Conseqa dispatch could not be durably authorized"
|
|
445
|
+
) from failure
|
|
446
|
+
return False
|
|
447
|
+
|
|
448
|
+
def _unprotected(self, run: _Run, call: Callable[[], Any]) -> Any:
|
|
449
|
+
try:
|
|
450
|
+
result = call()
|
|
451
|
+
except BaseException:
|
|
452
|
+
run.telemetry.fail()
|
|
453
|
+
run.telemetry.end()
|
|
454
|
+
raise
|
|
455
|
+
run.telemetry.end()
|
|
456
|
+
return result
|
|
457
|
+
|
|
458
|
+
def _report(self, run: _Run, event: JsonObject) -> None:
|
|
459
|
+
if self._send(run, event) is not None:
|
|
460
|
+
run.telemetry.emit(
|
|
461
|
+
"conseqa.lifecycle.persistence_failed",
|
|
462
|
+
protection_level="PARTIAL",
|
|
463
|
+
attributes={"phase": event["type"]},
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
def _after_exception(self, run: _Run, error: BaseException) -> None:
|
|
467
|
+
try:
|
|
468
|
+
classification = run.contract.classify_exception(error)
|
|
469
|
+
if not isinstance(classification, Classification):
|
|
470
|
+
raise TypeError("classify_exception must return a Classification")
|
|
471
|
+
except Exception:
|
|
472
|
+
# A classifier that fails has told us nothing, which is what ambiguous means.
|
|
473
|
+
classification = Classification(
|
|
474
|
+
"ambiguous",
|
|
475
|
+
False,
|
|
476
|
+
"classification_failed",
|
|
477
|
+
"The contract exception classifier failed",
|
|
478
|
+
)
|
|
479
|
+
self._report(
|
|
480
|
+
run, self._event(run, 2, "action.exception", classification=classification.as_json())
|
|
481
|
+
)
|
|
482
|
+
run.telemetry.fail()
|
|
483
|
+
run.telemetry.end()
|
|
484
|
+
|
|
485
|
+
def _after_return(self, run: _Run, result: Any) -> None:
|
|
486
|
+
binding: Optional[JsonObject] = None
|
|
487
|
+
binding_failure: Optional[str] = None
|
|
488
|
+
try:
|
|
489
|
+
candidate = run.contract.bind_result(result)
|
|
490
|
+
if candidate is None:
|
|
491
|
+
binding_failure = "The provider result did not contain a usable binding"
|
|
492
|
+
else:
|
|
493
|
+
binding = dict(candidate)
|
|
494
|
+
canonical_json(binding) # a binding is a JSON object or it is nothing
|
|
495
|
+
except Exception:
|
|
496
|
+
binding = None
|
|
497
|
+
binding_failure = "The contract could not bind the provider result"
|
|
498
|
+
|
|
499
|
+
returned = self._event(run, 2, "action.returned", disposition="success")
|
|
500
|
+
if binding is not None:
|
|
501
|
+
returned["binding"] = binding
|
|
502
|
+
self._report(run, returned)
|
|
503
|
+
if binding_failure:
|
|
504
|
+
self._report(run, self._event(run, 3, "binding.failed", reason=binding_failure))
|
|
505
|
+
elif binding is not None:
|
|
506
|
+
run.telemetry.emit(
|
|
507
|
+
"conseqa.result.bound",
|
|
508
|
+
intent_id=run.intent_id,
|
|
509
|
+
protection_level=run.contract.protection_level,
|
|
510
|
+
)
|
|
511
|
+
run.telemetry.end()
|
|
512
|
+
|