ADR-099: Fail-closed HTTP egress for the specialized services
- Status
-
Accepted
- Date
-
2026-07-21
- Authors
-
Netresearch DTT GmbH
Context
ADR-097 routed every specialized dispatch through the pipeline
via runLifecycle(), so an image / speech / translation call now gets the same
telemetry row, correlation id and circuit breaker as a chat call. But the
wrapping is a convention: a new service method — or a refactor — that builds a
request and sends it without calling runLifecycle() would compile, pass, and
silently spend against the provider with no telemetry, no circuit breaker and no
usage. Nothing enforced the convention.
Decision
The HTTP egress fails closed. AbstractSpecializedService tracks a private
$withinLifecycle flag, set only while a runLifecycle() dispatch is
executing (saved/restored around the terminal, so nesting is safe).
assertWithinLifecycle() throws a \LogicException when the flag is false,
and it is called at every HTTP-egress point — executeRequest() and the two
binary/multipart senders in TextToSpeechService and
WhisperTranscriptionService — before their try block, so the guard is
not swallowed by the Throwable -> ServiceUnavailableException mapping.
A \LogicException (not a service exception) is deliberate: dispatching
outside a lifecycle is a programmer error, not a runtime fault, and must not be
retried or mapped to a transient failure.
Enabling the guard required routing the last unwrapped provider calls through
runLifecycle(). DeepL's detectLanguage() is a billable translate call and
now runs as ProviderOperation::Translation — closing a real telemetry gap.
Its getUsage() and getGlossaries() are free metadata lookups; they run as
the new ProviderOperation::Metadata so they are observable and circuit-breaker
guarded like any provider HTTP call, but labelled honestly rather than as a
translation.
Consequences
- A specialized service method that reaches the provider without
runLifecycle()now throws immediately instead of spending unobserved. The guard is the single invariant behind ADR-097's promise. ProviderOperationgained aMetadatacase for provider status / metadata calls that are not themselves an AI generation.- DeepL
detectLanguage/getUsage/getGlossariesnow emit a telemetry row and are subject to the provider circuit breaker. They record no usage and enforce no budget (they are not billable generations, apart from the translate calldetectLanguagealready made), so cost accounting is unchanged. - Test doubles that exercise the low-level HTTP helpers must dispatch inside a
lifecycle — the
TestableSpecializedServicedelegate wraps its call inrunLifecycle(), mirroring production. A deliberately unwrapped delegate asserts the guard throws. - Still deferred: folding the per-service usage recording into a tagged pipeline extractor read by the usage middleware.