---
title: "ADR-099: Fail-closed HTTP egress for the specialized services"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-099@0.35"
source: "Adr/Adr099SpecializedFailClosedDispatch.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# ADR-099: Fail-closed HTTP egress for the specialized services

-   *Status:* Accepted
-   *Date:* 2026-07-21
-   *Authors:* Netresearch DTT GmbH

## Context

[ADR-097](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-097@0.35) 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.
-   `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.
