|
40 | 40 | from typing import Any |
41 | 41 |
|
42 | 42 | from openarmature.observability.correlation import ( |
| 43 | + _reset_terminal_attempt_index, |
| 44 | + _set_terminal_attempt_index, |
43 | 45 | current_attempt_index, |
44 | 46 | current_branch_name, |
45 | 47 | current_dispatch, |
46 | 48 | current_fan_out_index, |
47 | 49 | current_namespace_prefix, |
| 50 | + current_terminal_attempt_index, |
48 | 51 | ) |
49 | 52 |
|
50 | 53 | from ._core import NextCall |
@@ -131,38 +134,48 @@ def __init__( |
131 | 134 | self.on_caught = on_caught |
132 | 135 |
|
133 | 136 | async def __call__(self, state: Any, next_: NextCall) -> Mapping[str, Any]: |
| 137 | + # Establish a clean terminal-attempt scope: an inner |
| 138 | + # RetryMiddleware records its final / exhausting attempt here on |
| 139 | + # give-up, and _emit_event reports it (proposal 0050 §6.3). The |
| 140 | + # ``None`` on entry shadows any stale value from a prior node; the |
| 141 | + # finally cleans up so it never leaks to a sibling / next node. |
| 142 | + terminal_token = _set_terminal_attempt_index(None) |
134 | 143 | try: |
135 | | - return await next_(state) |
136 | | - except Exception as exc: |
137 | | - # BaseException (cancellation) never enters here — it |
138 | | - # extends BaseException, not Exception. Same rule as |
139 | | - # RetryMiddleware: cancellation MUST propagate. |
140 | | - if self.predicate is not None and not self.predicate(exc): |
141 | | - raise |
142 | | - # Resolve the degraded update once, at catch time, and reuse |
143 | | - # it for the event's post_state and the node return so a |
144 | | - # callable degraded_update is invoked exactly once. The |
145 | | - # observable order the spec prescribes — emit the event, then |
146 | | - # on_caught, then return the update — is preserved below; |
147 | | - # resolving here first only populates post_state. |
148 | | - degraded = self._resolve_degraded(state) |
149 | | - self._emit_event(state, exc, degraded) |
150 | | - if self.on_caught is not None: |
151 | | - try: |
152 | | - await self.on_caught(exc) |
153 | | - except Exception as hook_error: # noqa: BLE001 |
154 | | - # on_caught is caller telemetry; a bug in it MUST NOT |
155 | | - # turn a recovered node back into a crash. Isolate it |
156 | | - # the way the observer-delivery contract isolates |
157 | | - # observer exceptions (warn, don't propagate). |
158 | | - # BaseException (cancellation) still propagates by not |
159 | | - # being caught here. |
160 | | - warnings.warn( |
161 | | - f"FailureIsolationMiddleware on_caught raised " |
162 | | - f"{type(hook_error).__name__}: {hook_error}", |
163 | | - stacklevel=2, |
164 | | - ) |
165 | | - return degraded |
| 144 | + try: |
| 145 | + return await next_(state) |
| 146 | + except Exception as exc: |
| 147 | + # BaseException (cancellation) never enters here — it |
| 148 | + # extends BaseException, not Exception. Same rule as |
| 149 | + # RetryMiddleware: cancellation MUST propagate. |
| 150 | + if self.predicate is not None and not self.predicate(exc): |
| 151 | + raise |
| 152 | + # Resolve the degraded update once, at catch time, and |
| 153 | + # reuse it for the event's post_state and the node return |
| 154 | + # so a callable degraded_update is invoked exactly once. |
| 155 | + # The observable order the spec prescribes — emit the |
| 156 | + # event, then on_caught, then return the update — is |
| 157 | + # preserved below; resolving here first only populates |
| 158 | + # post_state. |
| 159 | + degraded = self._resolve_degraded(state) |
| 160 | + self._emit_event(state, exc, degraded) |
| 161 | + if self.on_caught is not None: |
| 162 | + try: |
| 163 | + await self.on_caught(exc) |
| 164 | + except Exception as hook_error: # noqa: BLE001 |
| 165 | + # on_caught is caller telemetry; a bug in it MUST |
| 166 | + # NOT turn a recovered node back into a crash. |
| 167 | + # Isolate it the way the observer-delivery contract |
| 168 | + # isolates observer exceptions (warn, don't |
| 169 | + # propagate). BaseException (cancellation) still |
| 170 | + # propagates by not being caught here. |
| 171 | + warnings.warn( |
| 172 | + f"FailureIsolationMiddleware on_caught raised " |
| 173 | + f"{type(hook_error).__name__}: {hook_error}", |
| 174 | + stacklevel=2, |
| 175 | + ) |
| 176 | + return degraded |
| 177 | + finally: |
| 178 | + _reset_terminal_attempt_index(terminal_token) |
166 | 179 |
|
167 | 180 | def _resolve_degraded(self, state: Any) -> Mapping[str, Any]: |
168 | 181 | if callable(self.degraded_update): |
@@ -192,22 +205,26 @@ def _emit_event(self, state: Any, exc: Exception, degraded: Mapping[str, Any]) - |
192 | 205 | cause = _resolve_cause(exc) |
193 | 206 | cause_category = getattr(cause, "category", None) |
194 | 207 | category = cause_category if isinstance(cause_category, str) else None |
195 | | - # ``attempt_index`` here is deliberately the NODE-level baseline, |
196 | | - # not a per-attempt wire index: failure isolation is a node-level |
197 | | - # concern ("the node, across its retries, was isolated"). When |
198 | | - # this middleware is OUTER of RetryMiddleware, retry has already |
199 | | - # reset the attempt ContextVar to that baseline (0) in its |
200 | | - # ``finally`` by the time the terminal exception reaches this |
201 | | - # catch, which is the frame we want (spec-confirmed). Parenting is |
202 | | - # unaffected: the node's attempt spans are already closed by |
203 | | - # delivery time (their completed event precedes this one on the |
204 | | - # serial queue), so observers parent the marker under the |
205 | | - # invocation span and correlate by ``namespace`` + node name. |
| 208 | + # ``attempt_index`` is the wrapped node's final / exhausting |
| 209 | + # attempt (proposal 0050 §6.3: "the same lineage tuple NodeEvent |
| 210 | + # carries, for correlation with the wrapped node's other events"). |
| 211 | + # When this middleware is OUTER of RetryMiddleware, retry records |
| 212 | + # that index in the terminal-attempt scope on give-up — its own |
| 213 | + # ``finally`` has reset the live attempt-index var to the baseline |
| 214 | + # by the time the exception reaches this catch, so we read the |
| 215 | + # recorded terminal index instead. With no retry, nothing is |
| 216 | + # recorded and we fall back to the live attempt index (0 at a node |
| 217 | + # body). Parenting is unaffected: the node's attempt spans are |
| 218 | + # already closed by delivery time (their completed event precedes |
| 219 | + # this one on the serial queue), so observers parent the marker |
| 220 | + # under the invocation span and correlate by ``namespace`` + name. |
| 221 | + terminal_attempt = current_terminal_attempt_index() |
| 222 | + attempt_index = terminal_attempt if terminal_attempt is not None else current_attempt_index() |
206 | 223 | dispatch( |
207 | 224 | FailureIsolatedEvent( |
208 | 225 | event_name=self.event_name, |
209 | 226 | namespace=current_namespace_prefix(), |
210 | | - attempt_index=current_attempt_index(), |
| 227 | + attempt_index=attempt_index, |
211 | 228 | fan_out_index=current_fan_out_index(), |
212 | 229 | branch_name=current_branch_name(), |
213 | 230 | pre_state=state, |
|
0 commit comments