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 WhisperTranscriptionServicebefore 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.
  • ProviderOperation gained a Metadata case for provider status / metadata calls that are not themselves an AI generation.
  • DeepL detectLanguage / getUsage / getGlossaries now 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 call detectLanguage already made), so cost accounting is unchanged.
  • Test doubles that exercise the low-level HTTP helpers must dispatch inside a lifecycle — the TestableSpecializedService delegate wraps its call in runLifecycle(), 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.