diff --git a/CHANGELOG.md b/CHANGELOG.md
index ee991f3..2cd9d1c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,11 @@ may break public API until 1.0.0 ships.
Pre-1.0 per [ADR-019](docs/adr/019-pre-10-stability-policy.md); see
[ADR-027](docs/adr/027-retry-policy-builder-and-budget.md).
+- **`fanar-core`** (internal, no behaviour change) — the eight domain facades no longer each assemble the
+ interceptor chain, record the transport attributes and call the transport: that plumbing lives in one
+ `internal.dispatch.Dispatcher` (ADR-018 — internals are not a contract). `http.url` is now read from the
+ request the facade built rather than the facade's endpoint field; the two were always the same URI.
+
### Added
- **`fanar-core`** — a total sleep budget for retries ([ADR-027](docs/adr/027-retry-policy-builder-and-budget.md)):
diff --git a/core/src/main/java/qa/fanar/core/internal/audio/AudioClientImpl.java b/core/src/main/java/qa/fanar/core/internal/audio/AudioClientImpl.java
index ac53377..18a23b8 100644
--- a/core/src/main/java/qa/fanar/core/internal/audio/AudioClientImpl.java
+++ b/core/src/main/java/qa/fanar/core/internal/audio/AudioClientImpl.java
@@ -9,7 +9,6 @@
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
-import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -25,10 +24,8 @@
import qa.fanar.core.audio.TextToSpeechRequest;
import qa.fanar.core.audio.TranscriptionRequest;
import qa.fanar.core.audio.VoiceResponse;
-import qa.fanar.core.internal.retry.RetryInterceptor;
-import qa.fanar.core.internal.transport.BearerTokenInterceptor;
+import qa.fanar.core.internal.dispatch.Dispatcher;
import qa.fanar.core.internal.transport.HttpTransport;
-import qa.fanar.core.internal.transport.InterceptorChainImpl;
import qa.fanar.core.internal.transport.MultipartBuilder;
import qa.fanar.core.internal.transport.StreamFlag;
import qa.fanar.core.spi.FanarJsonCodec;
@@ -52,6 +49,11 @@
*
*
Internal (ADR-018). May be replaced, renamed, or deleted in any release.
*
+ * Request plumbing — chain assembly (retry → bearer token → user interceptors → transport),
+ * the {@code http.method} / {@code http.url} / {@code fanar.model} attributes and the trip to the
+ * transport — lives in {@link Dispatcher}; this class owns the endpoint, the wire format and the
+ * decoding.
+ *
* @author Oussama Mahjoub
*/
public final class AudioClientImpl implements AudioClient {
@@ -70,8 +72,7 @@ public final class AudioClientImpl implements AudioClient {
private final URI speechEndpoint;
private final URI transcriptionsEndpoint;
private final FanarJsonCodec jsonCodec;
- private final List interceptors;
- private final HttpTransport transport;
+ private final Dispatcher dispatcher;
private final ObservabilityPlugin observability;
private final Map defaultHeaders;
private final String userAgent;
@@ -91,15 +92,8 @@ public AudioClientImpl(
this.speechEndpoint = baseUrl.resolve(SPEECH_PATH);
this.transcriptionsEndpoint = baseUrl.resolve(TRANSCRIPTIONS_PATH);
this.jsonCodec = Objects.requireNonNull(jsonCodec, "jsonCodec");
- Objects.requireNonNull(apiKeySupplier, "apiKeySupplier");
- Objects.requireNonNull(userInterceptors, "userInterceptors");
- Objects.requireNonNull(retryPolicy, "retryPolicy");
- List chain = new ArrayList<>(userInterceptors.size() + 2);
- chain.add(new RetryInterceptor(retryPolicy));
- chain.add(new BearerTokenInterceptor(apiKeySupplier));
- chain.addAll(userInterceptors);
- this.interceptors = List.copyOf(chain);
- this.transport = Objects.requireNonNull(transport, "transport");
+ // Retry → bearer token → user interceptors → transport: assembled by the Dispatcher.
+ this.dispatcher = new Dispatcher(transport, retryPolicy, apiKeySupplier, userInterceptors);
this.observability = Objects.requireNonNull(observability, "observability");
this.defaultHeaders = Map.copyOf(Objects.requireNonNull(defaultHeaders, "defaultHeaders"));
this.userAgent = userAgent;
@@ -111,8 +105,7 @@ public AudioClientImpl(
public VoiceResponse listVoices() {
try (ObservationHandle obs = observability.start(OP_LIST)) {
try {
- HttpResponse response = dispatch(
- buildGet(voicesEndpoint, obs), voicesEndpoint, "GET", obs);
+ HttpResponse response = dispatcher.dispatch(buildGet(voicesEndpoint, obs), obs, null);
return decodeJson(response, VoiceResponse.class, "VoiceResponse");
} catch (RuntimeException e) {
obs.error(e);
@@ -139,9 +132,8 @@ public void createVoice(CreateVoiceRequest request) {
mb.addField("transcript", request.transcript());
byte[] body = mb.build();
- HttpResponse response = dispatch(
- buildPost(voicesEndpoint, obs, mb.contentType(), body),
- voicesEndpoint, "POST", obs);
+ HttpResponse response = dispatcher.dispatch(
+ buildPost(voicesEndpoint, obs, mb.contentType(), body), obs, null);
drain(response);
} catch (RuntimeException e) {
obs.error(e);
@@ -165,8 +157,7 @@ public void deleteVoice(String name) {
try {
URI endpoint = baseUrl.resolve(
VOICES_PATH + "/" + URLEncoder.encode(name, StandardCharsets.UTF_8));
- HttpResponse response = dispatch(
- buildDelete(endpoint, obs), endpoint, "DELETE", obs);
+ HttpResponse response = dispatcher.dispatch(buildDelete(endpoint, obs), obs, null);
drain(response);
} catch (RuntimeException e) {
obs.error(e);
@@ -196,7 +187,7 @@ public byte[] speech(TextToSpeechRequest request) {
.header("Accept", "audio/*")
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
.build();
- HttpResponse response = dispatch(httpReq, speechEndpoint, "POST", obs);
+ HttpResponse response = dispatcher.dispatch(httpReq, obs, null);
return readBinary(response);
} catch (RuntimeException e) {
obs.error(e);
@@ -224,7 +215,7 @@ public Flow.Publisher speechStream(TextToSpeechRequest request) {
.header("Accept", "audio/*")
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
.build();
- HttpResponse response = dispatch(httpReq, speechEndpoint, "POST", obs);
+ HttpResponse response = dispatcher.dispatch(httpReq, obs, null);
return new AudioStreamPublisher(response.body());
} catch (RuntimeException e) {
obs.error(e);
@@ -256,8 +247,7 @@ public SpeechToTextResponse transcribe(TranscriptionRequest request) {
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
.build();
- HttpResponse response = dispatch(
- httpReq, transcriptionsEndpoint, "POST", obs);
+ HttpResponse response = dispatcher.dispatch(httpReq, obs, null);
return decodeJson(response, SpeechToTextResponse.class, "SpeechToTextResponse");
} catch (RuntimeException e) {
obs.error(e);
@@ -274,15 +264,6 @@ public CompletableFuture transcribeAsync(TranscriptionRequ
// --- shared dispatch -------------------------------------------------------------------
- private HttpResponse dispatch(
- HttpRequest httpReq, URI endpoint, String method, ObservationHandle obs) {
- obs.attribute(FanarObservationAttributes.HTTP_METHOD, method);
- obs.attribute(FanarObservationAttributes.HTTP_URL, endpoint.toString());
-
- InterceptorChainImpl chain = new InterceptorChainImpl(interceptors, transport, obs);
- return chain.proceed(httpReq);
- }
-
private HttpRequest buildGet(URI endpoint, ObservationHandle obs) {
return applyCommonHeaders(HttpRequest.newBuilder(endpoint), obs)
.header("Accept", "application/json")
diff --git a/core/src/main/java/qa/fanar/core/internal/chat/ChatClientImpl.java b/core/src/main/java/qa/fanar/core/internal/chat/ChatClientImpl.java
index a7dbd09..ad6d45d 100644
--- a/core/src/main/java/qa/fanar/core/internal/chat/ChatClientImpl.java
+++ b/core/src/main/java/qa/fanar/core/internal/chat/ChatClientImpl.java
@@ -6,7 +6,6 @@
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
-import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -24,8 +23,8 @@
import qa.fanar.core.internal.retry.RetryInterceptor;
import qa.fanar.core.internal.sse.SseStreamPublisher;
import qa.fanar.core.internal.transport.BearerTokenInterceptor;
+import qa.fanar.core.internal.dispatch.Dispatcher;
import qa.fanar.core.internal.transport.HttpTransport;
-import qa.fanar.core.internal.transport.InterceptorChainImpl;
import qa.fanar.core.internal.transport.StreamFlag;
import qa.fanar.core.spi.FanarJsonCodec;
import qa.fanar.core.spi.FanarObservationAttributes;
@@ -61,6 +60,11 @@
*
* Internal (ADR-018). May be replaced, renamed, or deleted in any release.
*
+ * Request plumbing — chain assembly (retry → bearer token → user interceptors → transport),
+ * the {@code http.method} / {@code http.url} / {@code fanar.model} attributes and the trip to the
+ * transport — lives in {@link Dispatcher}; this class owns the endpoint, the wire format and the
+ * decoding.
+ *
* @author Oussama Mahjoub
*/
public final class ChatClientImpl implements ChatClient {
@@ -70,8 +74,7 @@ public final class ChatClientImpl implements ChatClient {
private final URI endpoint;
private final FanarJsonCodec jsonCodec;
- private final List interceptors;
- private final HttpTransport transport;
+ private final Dispatcher dispatcher;
private final ObservabilityPlugin observability;
private final Map defaultHeaders;
private final String userAgent;
@@ -88,20 +91,8 @@ public ChatClientImpl(
String userAgent) {
this.endpoint = Objects.requireNonNull(baseUrl, "baseUrl").resolve(ENDPOINT);
this.jsonCodec = Objects.requireNonNull(jsonCodec, "jsonCodec");
- Objects.requireNonNull(apiKeySupplier, "apiKeySupplier");
- Objects.requireNonNull(userInterceptors, "userInterceptors");
- Objects.requireNonNull(retryPolicy, "retryPolicy");
- // Chain order (outermost to innermost):
- // RetryInterceptor — wraps everything else, re-runs the chain on retryable failure
- // BearerTokenInterceptor — re-signs each retry attempt
- // — in registration order
- // transport — terminal
- List chain = new ArrayList<>(userInterceptors.size() + 2);
- chain.add(new RetryInterceptor(retryPolicy));
- chain.add(new BearerTokenInterceptor(apiKeySupplier));
- chain.addAll(userInterceptors);
- this.interceptors = List.copyOf(chain);
- this.transport = Objects.requireNonNull(transport, "transport");
+ // Retry → bearer token → user interceptors → transport: assembled by the Dispatcher.
+ this.dispatcher = new Dispatcher(transport, retryPolicy, apiKeySupplier, userInterceptors);
this.observability = Objects.requireNonNull(observability, "observability");
this.defaultHeaders = Map.copyOf(Objects.requireNonNull(defaultHeaders, "defaultHeaders"));
this.userAgent = userAgent;
@@ -151,13 +142,7 @@ public Flow.Publisher stream(ChatRequest request) {
private HttpResponse dispatch(
ChatRequest request, ObservationHandle obs, boolean streaming) {
- obs.attribute(FanarObservationAttributes.FANAR_MODEL, request.model().wireValue());
- obs.attribute(FanarObservationAttributes.HTTP_METHOD, "POST");
- obs.attribute(FanarObservationAttributes.HTTP_URL, endpoint.toString());
-
- HttpRequest httpReq = buildHttpRequest(request, obs, streaming);
- InterceptorChainImpl chain = new InterceptorChainImpl(interceptors, transport, obs);
- return chain.proceed(httpReq);
+ return dispatcher.dispatch(buildHttpRequest(request, obs, streaming), obs, request.model().wireValue());
}
private HttpRequest buildHttpRequest(ChatRequest request, ObservationHandle obs, boolean streaming) {
diff --git a/core/src/main/java/qa/fanar/core/internal/dispatch/Dispatcher.java b/core/src/main/java/qa/fanar/core/internal/dispatch/Dispatcher.java
new file mode 100644
index 0000000..37c3fcd
--- /dev/null
+++ b/core/src/main/java/qa/fanar/core/internal/dispatch/Dispatcher.java
@@ -0,0 +1,85 @@
+package qa.fanar.core.internal.dispatch;
+
+import java.io.InputStream;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Supplier;
+
+import qa.fanar.core.RetryPolicy;
+import qa.fanar.core.internal.retry.RetryInterceptor;
+import qa.fanar.core.internal.transport.BearerTokenInterceptor;
+import qa.fanar.core.internal.transport.HttpTransport;
+import qa.fanar.core.internal.transport.InterceptorChainImpl;
+import qa.fanar.core.spi.FanarObservationAttributes;
+import qa.fanar.core.spi.Interceptor;
+import qa.fanar.core.spi.ObservationHandle;
+
+/**
+ * The request plumbing every domain facade shares: chain assembly, the per-call transport
+ * attributes, and the trip through the chain to the transport.
+ *
+ * Chain order, outermost to innermost (ADR-012, ADR-014): {@link RetryInterceptor} — the SDK's
+ * error boundary, re-running everything below it on a retryable failure — then
+ * {@link BearerTokenInterceptor}, re-signing every attempt, then the user's interceptors in
+ * registration order, then the transport. The chain is assembled once per dispatcher; each
+ * {@link #dispatch} runs it through a fresh {@link InterceptorChainImpl} bound to that call's
+ * observation.
+ *
+ * Before the chain runs, the call's {@link FanarObservationAttributes#FANAR_MODEL} (when the
+ * call addresses a model), {@link FanarObservationAttributes#HTTP_METHOD} and
+ * {@link FanarObservationAttributes#HTTP_URL} are recorded on the observation; the retry boundary
+ * adds the per-attempt status, retry count and rate-limit window (ADR-026).
+ *
+ * Internal (ADR-018). Thread-safe: no per-call state outside the chain instance.
+ *
+ * @author Oussama Mahjoub
+ */
+public final class Dispatcher {
+
+ private final List chain;
+ private final HttpTransport transport;
+
+ /**
+ * @param transport the terminal transport
+ * @param retryPolicy the policy behind the built-in retry interceptor
+ * @param apiKeySupplier the bearer token source, consulted on every attempt
+ * @param userInterceptors the caller's interceptors, in registration order
+ */
+ public Dispatcher(
+ HttpTransport transport,
+ RetryPolicy retryPolicy,
+ Supplier apiKeySupplier,
+ List userInterceptors) {
+ Objects.requireNonNull(apiKeySupplier, "apiKeySupplier");
+ Objects.requireNonNull(userInterceptors, "userInterceptors");
+ Objects.requireNonNull(retryPolicy, "retryPolicy");
+ List assembled = new ArrayList<>(userInterceptors.size() + 2);
+ assembled.add(new RetryInterceptor(retryPolicy));
+ assembled.add(new BearerTokenInterceptor(apiKeySupplier));
+ assembled.addAll(userInterceptors);
+ this.chain = List.copyOf(assembled);
+ this.transport = Objects.requireNonNull(transport, "transport");
+ }
+
+ /**
+ * Record the call's transport attributes and run the request through the chain.
+ *
+ * @param request the fully built outbound request
+ * @param obs the call's observation
+ * @param model the model the call addresses, or {@code null} for calls without one
+ * (the model listing, the voice catalogue)
+ * @return the response as the chain returns it — a success; error responses became typed
+ * exceptions at the retry boundary
+ */
+ public HttpResponse dispatch(HttpRequest request, ObservationHandle obs, String model) {
+ if (model != null) {
+ obs.attribute(FanarObservationAttributes.FANAR_MODEL, model);
+ }
+ obs.attribute(FanarObservationAttributes.HTTP_METHOD, request.method());
+ obs.attribute(FanarObservationAttributes.HTTP_URL, request.uri().toString());
+ return new InterceptorChainImpl(chain, transport, obs).proceed(request);
+ }
+}
diff --git a/core/src/main/java/qa/fanar/core/internal/dispatch/package-info.java b/core/src/main/java/qa/fanar/core/internal/dispatch/package-info.java
new file mode 100644
index 0000000..6851e24
--- /dev/null
+++ b/core/src/main/java/qa/fanar/core/internal/dispatch/package-info.java
@@ -0,0 +1,10 @@
+/**
+ * The request plumbing shared by every domain facade: chain assembly (retry → bearer token →
+ * user interceptors → transport), the per-call transport attributes and the trip through the
+ * chain — see {@link qa.fanar.core.internal.dispatch.Dispatcher}.
+ *
+ * Internal per ADR-018 — nothing in this package is exported.
+ *
+ * @author Oussama Mahjoub
+ */
+package qa.fanar.core.internal.dispatch;
diff --git a/core/src/main/java/qa/fanar/core/internal/images/ImagesClientImpl.java b/core/src/main/java/qa/fanar/core/internal/images/ImagesClientImpl.java
index dc566e3..e975389 100644
--- a/core/src/main/java/qa/fanar/core/internal/images/ImagesClientImpl.java
+++ b/core/src/main/java/qa/fanar/core/internal/images/ImagesClientImpl.java
@@ -6,7 +6,6 @@
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
-import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -18,12 +17,9 @@
import qa.fanar.core.images.ImageGenerationRequest;
import qa.fanar.core.images.ImageGenerationResponse;
import qa.fanar.core.images.ImagesClient;
-import qa.fanar.core.internal.retry.RetryInterceptor;
-import qa.fanar.core.internal.transport.BearerTokenInterceptor;
+import qa.fanar.core.internal.dispatch.Dispatcher;
import qa.fanar.core.internal.transport.HttpTransport;
-import qa.fanar.core.internal.transport.InterceptorChainImpl;
import qa.fanar.core.spi.FanarJsonCodec;
-import qa.fanar.core.spi.FanarObservationAttributes;
import qa.fanar.core.spi.Interceptor;
import qa.fanar.core.spi.ObservabilityPlugin;
import qa.fanar.core.spi.ObservationHandle;
@@ -36,6 +32,11 @@
*
* Internal (ADR-018). May be replaced, renamed, or deleted in any release.
*
+ * Request plumbing — chain assembly (retry → bearer token → user interceptors → transport),
+ * the {@code http.method} / {@code http.url} / {@code fanar.model} attributes and the trip to the
+ * transport — lives in {@link Dispatcher}; this class owns the endpoint, the wire format and the
+ * decoding.
+ *
* @author Oussama Mahjoub
*/
public final class ImagesClientImpl implements ImagesClient {
@@ -45,8 +46,7 @@ public final class ImagesClientImpl implements ImagesClient {
private final URI endpoint;
private final FanarJsonCodec jsonCodec;
- private final List interceptors;
- private final HttpTransport transport;
+ private final Dispatcher dispatcher;
private final ObservabilityPlugin observability;
private final Map defaultHeaders;
private final String userAgent;
@@ -63,15 +63,8 @@ public ImagesClientImpl(
String userAgent) {
this.endpoint = Objects.requireNonNull(baseUrl, "baseUrl").resolve(ENDPOINT);
this.jsonCodec = Objects.requireNonNull(jsonCodec, "jsonCodec");
- Objects.requireNonNull(apiKeySupplier, "apiKeySupplier");
- Objects.requireNonNull(userInterceptors, "userInterceptors");
- Objects.requireNonNull(retryPolicy, "retryPolicy");
- List chain = new ArrayList<>(userInterceptors.size() + 2);
- chain.add(new RetryInterceptor(retryPolicy));
- chain.add(new BearerTokenInterceptor(apiKeySupplier));
- chain.addAll(userInterceptors);
- this.interceptors = List.copyOf(chain);
- this.transport = Objects.requireNonNull(transport, "transport");
+ // Retry → bearer token → user interceptors → transport: assembled by the Dispatcher.
+ this.dispatcher = new Dispatcher(transport, retryPolicy, apiKeySupplier, userInterceptors);
this.observability = Objects.requireNonNull(observability, "observability");
this.defaultHeaders = Map.copyOf(Objects.requireNonNull(defaultHeaders, "defaultHeaders"));
this.userAgent = userAgent;
@@ -106,13 +99,7 @@ public CompletableFuture generateAsync(ImageGenerationR
}
private HttpResponse dispatch(ImageGenerationRequest request, ObservationHandle obs) {
- obs.attribute(FanarObservationAttributes.FANAR_MODEL, request.model().wireValue());
- obs.attribute(FanarObservationAttributes.HTTP_METHOD, "POST");
- obs.attribute(FanarObservationAttributes.HTTP_URL, endpoint.toString());
-
- HttpRequest httpReq = buildHttpRequest(request, obs);
- InterceptorChainImpl chain = new InterceptorChainImpl(interceptors, transport, obs);
- return chain.proceed(httpReq);
+ return dispatcher.dispatch(buildHttpRequest(request, obs), obs, request.model().wireValue());
}
private HttpRequest buildHttpRequest(ImageGenerationRequest request, ObservationHandle obs) {
diff --git a/core/src/main/java/qa/fanar/core/internal/models/ModelsClientImpl.java b/core/src/main/java/qa/fanar/core/internal/models/ModelsClientImpl.java
index cafb398..9a6072f 100644
--- a/core/src/main/java/qa/fanar/core/internal/models/ModelsClientImpl.java
+++ b/core/src/main/java/qa/fanar/core/internal/models/ModelsClientImpl.java
@@ -5,7 +5,6 @@
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
-import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -14,14 +13,11 @@
import qa.fanar.core.FanarTransportException;
import qa.fanar.core.RetryPolicy;
-import qa.fanar.core.internal.retry.RetryInterceptor;
-import qa.fanar.core.internal.transport.BearerTokenInterceptor;
+import qa.fanar.core.internal.dispatch.Dispatcher;
import qa.fanar.core.internal.transport.HttpTransport;
-import qa.fanar.core.internal.transport.InterceptorChainImpl;
import qa.fanar.core.models.ModelsClient;
import qa.fanar.core.models.ModelsResponse;
import qa.fanar.core.spi.FanarJsonCodec;
-import qa.fanar.core.spi.FanarObservationAttributes;
import qa.fanar.core.spi.Interceptor;
import qa.fanar.core.spi.ObservabilityPlugin;
import qa.fanar.core.spi.ObservationHandle;
@@ -44,6 +40,11 @@
*
* Internal (ADR-018). May be replaced, renamed, or deleted in any release.
*
+ * Request plumbing — chain assembly (retry → bearer token → user interceptors → transport),
+ * the {@code http.method} / {@code http.url} / {@code fanar.model} attributes and the trip to the
+ * transport — lives in {@link Dispatcher}; this class owns the endpoint, the wire format and the
+ * decoding.
+ *
* @author Oussama Mahjoub
*/
public final class ModelsClientImpl implements ModelsClient {
@@ -53,8 +54,7 @@ public final class ModelsClientImpl implements ModelsClient {
private final URI endpoint;
private final FanarJsonCodec jsonCodec;
- private final List interceptors;
- private final HttpTransport transport;
+ private final Dispatcher dispatcher;
private final ObservabilityPlugin observability;
private final Map defaultHeaders;
private final String userAgent;
@@ -71,16 +71,8 @@ public ModelsClientImpl(
String userAgent) {
this.endpoint = Objects.requireNonNull(baseUrl, "baseUrl").resolve(ENDPOINT);
this.jsonCodec = Objects.requireNonNull(jsonCodec, "jsonCodec");
- Objects.requireNonNull(apiKeySupplier, "apiKeySupplier");
- Objects.requireNonNull(userInterceptors, "userInterceptors");
- Objects.requireNonNull(retryPolicy, "retryPolicy");
- // Same chain order as ChatClientImpl: retry → bearer-token → user → transport.
- List chain = new ArrayList<>(userInterceptors.size() + 2);
- chain.add(new RetryInterceptor(retryPolicy));
- chain.add(new BearerTokenInterceptor(apiKeySupplier));
- chain.addAll(userInterceptors);
- this.interceptors = List.copyOf(chain);
- this.transport = Objects.requireNonNull(transport, "transport");
+ // Retry → bearer token → user interceptors → transport: assembled by the Dispatcher.
+ this.dispatcher = new Dispatcher(transport, retryPolicy, apiKeySupplier, userInterceptors);
this.observability = Objects.requireNonNull(observability, "observability");
this.defaultHeaders = Map.copyOf(Objects.requireNonNull(defaultHeaders, "defaultHeaders"));
this.userAgent = userAgent;
@@ -113,12 +105,7 @@ public CompletableFuture listAsync() {
}
private HttpResponse dispatch(ObservationHandle obs) {
- obs.attribute(FanarObservationAttributes.HTTP_METHOD, "GET");
- obs.attribute(FanarObservationAttributes.HTTP_URL, endpoint.toString());
-
- HttpRequest httpReq = buildHttpRequest(obs);
- InterceptorChainImpl chain = new InterceptorChainImpl(interceptors, transport, obs);
- return chain.proceed(httpReq);
+ return dispatcher.dispatch(buildHttpRequest(obs), obs, null);
}
private HttpRequest buildHttpRequest(ObservationHandle obs) {
diff --git a/core/src/main/java/qa/fanar/core/internal/moderations/ModerationsClientImpl.java b/core/src/main/java/qa/fanar/core/internal/moderations/ModerationsClientImpl.java
index 4abc47d..7a14cc8 100644
--- a/core/src/main/java/qa/fanar/core/internal/moderations/ModerationsClientImpl.java
+++ b/core/src/main/java/qa/fanar/core/internal/moderations/ModerationsClientImpl.java
@@ -6,7 +6,6 @@
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
-import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -15,15 +14,12 @@
import qa.fanar.core.FanarTransportException;
import qa.fanar.core.RetryPolicy;
-import qa.fanar.core.internal.retry.RetryInterceptor;
-import qa.fanar.core.internal.transport.BearerTokenInterceptor;
+import qa.fanar.core.internal.dispatch.Dispatcher;
import qa.fanar.core.internal.transport.HttpTransport;
-import qa.fanar.core.internal.transport.InterceptorChainImpl;
import qa.fanar.core.moderations.ModerationsClient;
import qa.fanar.core.moderations.SafetyFilterRequest;
import qa.fanar.core.moderations.SafetyFilterResponse;
import qa.fanar.core.spi.FanarJsonCodec;
-import qa.fanar.core.spi.FanarObservationAttributes;
import qa.fanar.core.spi.Interceptor;
import qa.fanar.core.spi.ObservabilityPlugin;
import qa.fanar.core.spi.ObservationHandle;
@@ -38,6 +34,11 @@
*
* Internal (ADR-018). May be replaced, renamed, or deleted in any release.
*
+ * Request plumbing — chain assembly (retry → bearer token → user interceptors → transport),
+ * the {@code http.method} / {@code http.url} / {@code fanar.model} attributes and the trip to the
+ * transport — lives in {@link Dispatcher}; this class owns the endpoint, the wire format and the
+ * decoding.
+ *
* @author Oussama Mahjoub
*/
public final class ModerationsClientImpl implements ModerationsClient {
@@ -47,8 +48,7 @@ public final class ModerationsClientImpl implements ModerationsClient {
private final URI endpoint;
private final FanarJsonCodec jsonCodec;
- private final List interceptors;
- private final HttpTransport transport;
+ private final Dispatcher dispatcher;
private final ObservabilityPlugin observability;
private final Map defaultHeaders;
private final String userAgent;
@@ -65,15 +65,8 @@ public ModerationsClientImpl(
String userAgent) {
this.endpoint = Objects.requireNonNull(baseUrl, "baseUrl").resolve(ENDPOINT);
this.jsonCodec = Objects.requireNonNull(jsonCodec, "jsonCodec");
- Objects.requireNonNull(apiKeySupplier, "apiKeySupplier");
- Objects.requireNonNull(userInterceptors, "userInterceptors");
- Objects.requireNonNull(retryPolicy, "retryPolicy");
- List chain = new ArrayList<>(userInterceptors.size() + 2);
- chain.add(new RetryInterceptor(retryPolicy));
- chain.add(new BearerTokenInterceptor(apiKeySupplier));
- chain.addAll(userInterceptors);
- this.interceptors = List.copyOf(chain);
- this.transport = Objects.requireNonNull(transport, "transport");
+ // Retry → bearer token → user interceptors → transport: assembled by the Dispatcher.
+ this.dispatcher = new Dispatcher(transport, retryPolicy, apiKeySupplier, userInterceptors);
this.observability = Objects.requireNonNull(observability, "observability");
this.defaultHeaders = Map.copyOf(Objects.requireNonNull(defaultHeaders, "defaultHeaders"));
this.userAgent = userAgent;
@@ -108,13 +101,7 @@ public CompletableFuture scoreAsync(SafetyFilterRequest re
}
private HttpResponse dispatch(SafetyFilterRequest request, ObservationHandle obs) {
- obs.attribute(FanarObservationAttributes.FANAR_MODEL, request.model().wireValue());
- obs.attribute(FanarObservationAttributes.HTTP_METHOD, "POST");
- obs.attribute(FanarObservationAttributes.HTTP_URL, endpoint.toString());
-
- HttpRequest httpReq = buildHttpRequest(request, obs);
- InterceptorChainImpl chain = new InterceptorChainImpl(interceptors, transport, obs);
- return chain.proceed(httpReq);
+ return dispatcher.dispatch(buildHttpRequest(request, obs), obs, request.model().wireValue());
}
private HttpRequest buildHttpRequest(SafetyFilterRequest request, ObservationHandle obs) {
diff --git a/core/src/main/java/qa/fanar/core/internal/poems/PoemsClientImpl.java b/core/src/main/java/qa/fanar/core/internal/poems/PoemsClientImpl.java
index 2fa174e..de183a5 100644
--- a/core/src/main/java/qa/fanar/core/internal/poems/PoemsClientImpl.java
+++ b/core/src/main/java/qa/fanar/core/internal/poems/PoemsClientImpl.java
@@ -6,7 +6,6 @@
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
-import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -15,15 +14,12 @@
import qa.fanar.core.FanarTransportException;
import qa.fanar.core.RetryPolicy;
-import qa.fanar.core.internal.retry.RetryInterceptor;
-import qa.fanar.core.internal.transport.BearerTokenInterceptor;
+import qa.fanar.core.internal.dispatch.Dispatcher;
import qa.fanar.core.internal.transport.HttpTransport;
-import qa.fanar.core.internal.transport.InterceptorChainImpl;
import qa.fanar.core.poems.PoemGenerationRequest;
import qa.fanar.core.poems.PoemGenerationResponse;
import qa.fanar.core.poems.PoemsClient;
import qa.fanar.core.spi.FanarJsonCodec;
-import qa.fanar.core.spi.FanarObservationAttributes;
import qa.fanar.core.spi.Interceptor;
import qa.fanar.core.spi.ObservabilityPlugin;
import qa.fanar.core.spi.ObservationHandle;
@@ -36,6 +32,11 @@
*
* Internal (ADR-018). May be replaced, renamed, or deleted in any release.
*
+ * Request plumbing — chain assembly (retry → bearer token → user interceptors → transport),
+ * the {@code http.method} / {@code http.url} / {@code fanar.model} attributes and the trip to the
+ * transport — lives in {@link Dispatcher}; this class owns the endpoint, the wire format and the
+ * decoding.
+ *
* @author Oussama Mahjoub
*/
public final class PoemsClientImpl implements PoemsClient {
@@ -45,8 +46,7 @@ public final class PoemsClientImpl implements PoemsClient {
private final URI endpoint;
private final FanarJsonCodec jsonCodec;
- private final List interceptors;
- private final HttpTransport transport;
+ private final Dispatcher dispatcher;
private final ObservabilityPlugin observability;
private final Map defaultHeaders;
private final String userAgent;
@@ -63,15 +63,8 @@ public PoemsClientImpl(
String userAgent) {
this.endpoint = Objects.requireNonNull(baseUrl, "baseUrl").resolve(ENDPOINT);
this.jsonCodec = Objects.requireNonNull(jsonCodec, "jsonCodec");
- Objects.requireNonNull(apiKeySupplier, "apiKeySupplier");
- Objects.requireNonNull(userInterceptors, "userInterceptors");
- Objects.requireNonNull(retryPolicy, "retryPolicy");
- List chain = new ArrayList<>(userInterceptors.size() + 2);
- chain.add(new RetryInterceptor(retryPolicy));
- chain.add(new BearerTokenInterceptor(apiKeySupplier));
- chain.addAll(userInterceptors);
- this.interceptors = List.copyOf(chain);
- this.transport = Objects.requireNonNull(transport, "transport");
+ // Retry → bearer token → user interceptors → transport: assembled by the Dispatcher.
+ this.dispatcher = new Dispatcher(transport, retryPolicy, apiKeySupplier, userInterceptors);
this.observability = Objects.requireNonNull(observability, "observability");
this.defaultHeaders = Map.copyOf(Objects.requireNonNull(defaultHeaders, "defaultHeaders"));
this.userAgent = userAgent;
@@ -106,13 +99,7 @@ public CompletableFuture generateAsync(PoemGenerationReq
}
private HttpResponse dispatch(PoemGenerationRequest request, ObservationHandle obs) {
- obs.attribute(FanarObservationAttributes.FANAR_MODEL, request.model().wireValue());
- obs.attribute(FanarObservationAttributes.HTTP_METHOD, "POST");
- obs.attribute(FanarObservationAttributes.HTTP_URL, endpoint.toString());
-
- HttpRequest httpReq = buildHttpRequest(request, obs);
- InterceptorChainImpl chain = new InterceptorChainImpl(interceptors, transport, obs);
- return chain.proceed(httpReq);
+ return dispatcher.dispatch(buildHttpRequest(request, obs), obs, request.model().wireValue());
}
private HttpRequest buildHttpRequest(PoemGenerationRequest request, ObservationHandle obs) {
diff --git a/core/src/main/java/qa/fanar/core/internal/tokens/TokensClientImpl.java b/core/src/main/java/qa/fanar/core/internal/tokens/TokensClientImpl.java
index 0fd755e..ca636f0 100644
--- a/core/src/main/java/qa/fanar/core/internal/tokens/TokensClientImpl.java
+++ b/core/src/main/java/qa/fanar/core/internal/tokens/TokensClientImpl.java
@@ -6,7 +6,6 @@
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
-import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -15,12 +14,9 @@
import qa.fanar.core.FanarTransportException;
import qa.fanar.core.RetryPolicy;
-import qa.fanar.core.internal.retry.RetryInterceptor;
-import qa.fanar.core.internal.transport.BearerTokenInterceptor;
+import qa.fanar.core.internal.dispatch.Dispatcher;
import qa.fanar.core.internal.transport.HttpTransport;
-import qa.fanar.core.internal.transport.InterceptorChainImpl;
import qa.fanar.core.spi.FanarJsonCodec;
-import qa.fanar.core.spi.FanarObservationAttributes;
import qa.fanar.core.spi.Interceptor;
import qa.fanar.core.spi.ObservabilityPlugin;
import qa.fanar.core.spi.ObservationHandle;
@@ -39,6 +35,11 @@
*
* Internal (ADR-018). May be replaced, renamed, or deleted in any release.
*
+ * Request plumbing — chain assembly (retry → bearer token → user interceptors → transport),
+ * the {@code http.method} / {@code http.url} / {@code fanar.model} attributes and the trip to the
+ * transport — lives in {@link Dispatcher}; this class owns the endpoint, the wire format and the
+ * decoding.
+ *
* @author Oussama Mahjoub
*/
public final class TokensClientImpl implements TokensClient {
@@ -48,8 +49,7 @@ public final class TokensClientImpl implements TokensClient {
private final URI endpoint;
private final FanarJsonCodec jsonCodec;
- private final List interceptors;
- private final HttpTransport transport;
+ private final Dispatcher dispatcher;
private final ObservabilityPlugin observability;
private final Map defaultHeaders;
private final String userAgent;
@@ -66,15 +66,8 @@ public TokensClientImpl(
String userAgent) {
this.endpoint = Objects.requireNonNull(baseUrl, "baseUrl").resolve(ENDPOINT);
this.jsonCodec = Objects.requireNonNull(jsonCodec, "jsonCodec");
- Objects.requireNonNull(apiKeySupplier, "apiKeySupplier");
- Objects.requireNonNull(userInterceptors, "userInterceptors");
- Objects.requireNonNull(retryPolicy, "retryPolicy");
- List chain = new ArrayList<>(userInterceptors.size() + 2);
- chain.add(new RetryInterceptor(retryPolicy));
- chain.add(new BearerTokenInterceptor(apiKeySupplier));
- chain.addAll(userInterceptors);
- this.interceptors = List.copyOf(chain);
- this.transport = Objects.requireNonNull(transport, "transport");
+ // Retry → bearer token → user interceptors → transport: assembled by the Dispatcher.
+ this.dispatcher = new Dispatcher(transport, retryPolicy, apiKeySupplier, userInterceptors);
this.observability = Objects.requireNonNull(observability, "observability");
this.defaultHeaders = Map.copyOf(Objects.requireNonNull(defaultHeaders, "defaultHeaders"));
this.userAgent = userAgent;
@@ -109,13 +102,7 @@ public CompletableFuture countAsync(TokenizationRequest re
}
private HttpResponse dispatch(TokenizationRequest request, ObservationHandle obs) {
- obs.attribute(FanarObservationAttributes.FANAR_MODEL, request.model().wireValue());
- obs.attribute(FanarObservationAttributes.HTTP_METHOD, "POST");
- obs.attribute(FanarObservationAttributes.HTTP_URL, endpoint.toString());
-
- HttpRequest httpReq = buildHttpRequest(request, obs);
- InterceptorChainImpl chain = new InterceptorChainImpl(interceptors, transport, obs);
- return chain.proceed(httpReq);
+ return dispatcher.dispatch(buildHttpRequest(request, obs), obs, request.model().wireValue());
}
private HttpRequest buildHttpRequest(TokenizationRequest request, ObservationHandle obs) {
diff --git a/core/src/main/java/qa/fanar/core/internal/translations/TranslationsClientImpl.java b/core/src/main/java/qa/fanar/core/internal/translations/TranslationsClientImpl.java
index 9f4cf73..2c61d53 100644
--- a/core/src/main/java/qa/fanar/core/internal/translations/TranslationsClientImpl.java
+++ b/core/src/main/java/qa/fanar/core/internal/translations/TranslationsClientImpl.java
@@ -6,7 +6,6 @@
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
-import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -15,12 +14,9 @@
import qa.fanar.core.FanarTransportException;
import qa.fanar.core.RetryPolicy;
-import qa.fanar.core.internal.retry.RetryInterceptor;
-import qa.fanar.core.internal.transport.BearerTokenInterceptor;
+import qa.fanar.core.internal.dispatch.Dispatcher;
import qa.fanar.core.internal.transport.HttpTransport;
-import qa.fanar.core.internal.transport.InterceptorChainImpl;
import qa.fanar.core.spi.FanarJsonCodec;
-import qa.fanar.core.spi.FanarObservationAttributes;
import qa.fanar.core.spi.Interceptor;
import qa.fanar.core.spi.ObservabilityPlugin;
import qa.fanar.core.spi.ObservationHandle;
@@ -37,6 +33,11 @@
*
* Internal (ADR-018). May be replaced, renamed, or deleted in any release.
*
+ * Request plumbing — chain assembly (retry → bearer token → user interceptors → transport),
+ * the {@code http.method} / {@code http.url} / {@code fanar.model} attributes and the trip to the
+ * transport — lives in {@link Dispatcher}; this class owns the endpoint, the wire format and the
+ * decoding.
+ *
* @author Oussama Mahjoub
*/
public final class TranslationsClientImpl implements TranslationsClient {
@@ -46,8 +47,7 @@ public final class TranslationsClientImpl implements TranslationsClient {
private final URI endpoint;
private final FanarJsonCodec jsonCodec;
- private final List interceptors;
- private final HttpTransport transport;
+ private final Dispatcher dispatcher;
private final ObservabilityPlugin observability;
private final Map defaultHeaders;
private final String userAgent;
@@ -64,15 +64,8 @@ public TranslationsClientImpl(
String userAgent) {
this.endpoint = Objects.requireNonNull(baseUrl, "baseUrl").resolve(ENDPOINT);
this.jsonCodec = Objects.requireNonNull(jsonCodec, "jsonCodec");
- Objects.requireNonNull(apiKeySupplier, "apiKeySupplier");
- Objects.requireNonNull(userInterceptors, "userInterceptors");
- Objects.requireNonNull(retryPolicy, "retryPolicy");
- List chain = new ArrayList<>(userInterceptors.size() + 2);
- chain.add(new RetryInterceptor(retryPolicy));
- chain.add(new BearerTokenInterceptor(apiKeySupplier));
- chain.addAll(userInterceptors);
- this.interceptors = List.copyOf(chain);
- this.transport = Objects.requireNonNull(transport, "transport");
+ // Retry → bearer token → user interceptors → transport: assembled by the Dispatcher.
+ this.dispatcher = new Dispatcher(transport, retryPolicy, apiKeySupplier, userInterceptors);
this.observability = Objects.requireNonNull(observability, "observability");
this.defaultHeaders = Map.copyOf(Objects.requireNonNull(defaultHeaders, "defaultHeaders"));
this.userAgent = userAgent;
@@ -107,13 +100,7 @@ public CompletableFuture translateAsync(TranslationRequest
}
private HttpResponse dispatch(TranslationRequest request, ObservationHandle obs) {
- obs.attribute(FanarObservationAttributes.FANAR_MODEL, request.model().wireValue());
- obs.attribute(FanarObservationAttributes.HTTP_METHOD, "POST");
- obs.attribute(FanarObservationAttributes.HTTP_URL, endpoint.toString());
-
- HttpRequest httpReq = buildHttpRequest(request, obs);
- InterceptorChainImpl chain = new InterceptorChainImpl(interceptors, transport, obs);
- return chain.proceed(httpReq);
+ return dispatcher.dispatch(buildHttpRequest(request, obs), obs, request.model().wireValue());
}
private HttpRequest buildHttpRequest(TranslationRequest request, ObservationHandle obs) {
diff --git a/core/src/test/java/qa/fanar/core/internal/dispatch/DispatcherTest.java b/core/src/test/java/qa/fanar/core/internal/dispatch/DispatcherTest.java
new file mode 100644
index 0000000..cf72c30
--- /dev/null
+++ b/core/src/test/java/qa/fanar/core/internal/dispatch/DispatcherTest.java
@@ -0,0 +1,123 @@
+package qa.fanar.core.internal.dispatch;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpHeaders;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicReference;
+
+import javax.net.ssl.SSLSession;
+
+import org.junit.jupiter.api.Test;
+
+import qa.fanar.core.RetryPolicy;
+import qa.fanar.core.internal.transport.HttpTransport;
+import qa.fanar.core.spi.FanarObservationAttributes;
+import qa.fanar.core.spi.Interceptor;
+import qa.fanar.core.spi.ObservationHandle;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class DispatcherTest {
+
+ private static final URI URL = URI.create("https://api.example.com/v1/chat/completions");
+
+ private final RecordingObservation obs = new RecordingObservation();
+ private final AtomicReference sent = new AtomicReference<>();
+ private final HttpResponse canned = response(200);
+ private final HttpTransport transport = request -> { sent.set(request); return canned; };
+
+ @Test
+ void recordsModelMethodAndUrlThenRunsTheChain() {
+ Dispatcher dispatcher = new Dispatcher(transport, RetryPolicy.disabled(), () -> "t", List.of());
+
+ HttpResponse out = dispatcher.dispatch(post(), obs, "Fanar");
+
+ assertSame(canned, out);
+ assertEquals("Fanar", obs.attributes.get(FanarObservationAttributes.FANAR_MODEL));
+ assertEquals("POST", obs.attributes.get(FanarObservationAttributes.HTTP_METHOD));
+ assertEquals(URL.toString(), obs.attributes.get(FanarObservationAttributes.HTTP_URL));
+ assertEquals(List.of(FanarObservationAttributes.FANAR_MODEL, FanarObservationAttributes.HTTP_METHOD,
+ FanarObservationAttributes.HTTP_URL, FanarObservationAttributes.HTTP_STATUS_CODE,
+ FanarObservationAttributes.FANAR_RETRY_COUNT), new ArrayList<>(obs.attributes.keySet()),
+ "transport attributes first, then what the retry boundary records");
+ }
+
+ @Test
+ void omitsTheModelAttributeForCallsWithoutOne() {
+ new Dispatcher(transport, RetryPolicy.disabled(), () -> "t", List.of()).dispatch(post(), obs, null);
+
+ assertFalse(obs.attributes.containsKey(FanarObservationAttributes.FANAR_MODEL));
+ assertEquals("POST", obs.attributes.get(FanarObservationAttributes.HTTP_METHOD));
+ }
+
+ @Test
+ void chainRunsRetryThenBearerTokenThenUserInterceptorsThenTransport() {
+ List order = new ArrayList<>();
+ AtomicReference authSeenByUser = new AtomicReference<>();
+ Interceptor first = (request, chain) -> {
+ order.add("user-1");
+ authSeenByUser.set(request.headers().firstValue("Authorization").orElse(null));
+ return chain.proceed(request);
+ };
+ Interceptor second = (request, chain) -> { order.add("user-2"); return chain.proceed(request); };
+ HttpTransport recording = request -> { order.add("transport"); sent.set(request); return canned; };
+
+ new Dispatcher(recording, RetryPolicy.disabled(), () -> "secret", List.of(first, second))
+ .dispatch(post(), obs, "Fanar");
+
+ assertEquals(List.of("user-1", "user-2", "transport"), order, "registration order, transport last");
+ assertEquals("Bearer secret", authSeenByUser.get(), "the bearer token is applied above the user interceptors");
+ assertEquals("Bearer secret", sent.get().headers().firstValue("Authorization").orElse(null));
+ assertEquals(0, obs.attributes.get(FanarObservationAttributes.FANAR_RETRY_COUNT), "the retry boundary wraps it all");
+ }
+
+ @Test
+ void rejectsNullConstructorArgs() {
+ RetryPolicy rp = RetryPolicy.disabled();
+ assertThrows(NullPointerException.class, () -> new Dispatcher(null, rp, () -> "t", List.of()));
+ assertThrows(NullPointerException.class, () -> new Dispatcher(transport, null, () -> "t", List.of()));
+ assertThrows(NullPointerException.class, () -> new Dispatcher(transport, rp, null, List.of()));
+ assertThrows(NullPointerException.class, () -> new Dispatcher(transport, rp, () -> "t", null));
+ }
+
+ // --- helpers
+
+ private static HttpRequest post() {
+ return HttpRequest.newBuilder(URL).POST(HttpRequest.BodyPublishers.ofString("{}")).build();
+ }
+
+ private static HttpResponse response(int status) {
+ return new HttpResponse<>() {
+ public int statusCode() { return status; }
+ public HttpRequest request() { return null; }
+ public Optional> previousResponse() { return Optional.empty(); }
+ public HttpHeaders headers() { return HttpHeaders.of(Map.of(), (a, b) -> true); }
+ public InputStream body() { return new ByteArrayInputStream(new byte[0]); }
+ public Optional sslSession() { return Optional.empty(); }
+ public URI uri() { return URL; }
+ public HttpClient.Version version() { return HttpClient.Version.HTTP_1_1; }
+ };
+ }
+
+ private static final class RecordingObservation implements ObservationHandle {
+ final Map attributes = new LinkedHashMap<>();
+ @Override public ObservationHandle attribute(String key, Object value) { attributes.put(key, value); return this; }
+ @Override public ObservationHandle event(String name) { return this; }
+ @Override public ObservationHandle error(Throwable throwable) { return this; }
+ @Override public ObservationHandle child(String operationName) { return this; }
+ @Override public Map propagationHeaders() { return Map.of(); }
+ @Override public void close() { }
+ }
+}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index ce58045..0df0ef5 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -275,6 +275,7 @@ zone (ADR-018).
| Retry policy (public) | `qa.fanar.core.RetryPolicy` + `qa.fanar.core.JitterStrategy` | **implemented** — record + enum + `RetryPolicy.Builder`; validated at construction; `maxDelay` doubles as the `Retry-After` ceiling (ADR-025); `maxTotalDelay` budgets the sum of one call's sleeps (ADR-027). The loop is `RetryInterceptor` below |
| HTTP transport | `qa.fanar.core.internal.transport` (`HttpTransport`, `DefaultHttpTransport`, `InterceptorChainImpl`, `ExceptionMapper`, `ErrorEnvelope`, `RateLimitHeaders`) | **implemented** — `RateLimitHeaders` is the one parser behind both `rateLimit()` and the `fanar.ratelimit.*` attributes (ADR-026) |
| Bearer-token interceptor impl | `qa.fanar.core.internal.transport.BearerTokenInterceptor` | **implemented** — per-call `Supplier` for token rotation |
+| Request dispatcher | `qa.fanar.core.internal.dispatch.Dispatcher` | **implemented** — the plumbing the eight facades share: assembles the chain once (retry → bearer token → user interceptors → transport), records `fanar.model` / `http.method` / `http.url` per call and runs `InterceptorChainImpl`; a facade owns only its endpoint, wire format and decoding (0.4.0, internal refactor under ADR-018) |
| SSE parser | `qa.fanar.core.internal.sse` (`SseFrameAssembler`, `StreamEventDecoder`, `SseStreamPublisher`) | **implemented** — line-oriented accumulator, shape-routed decode, single-subscriber `Flow.Publisher` on a virtual thread |
| Audio stream publisher | `qa.fanar.core.internal.audio.AudioStreamPublisher` | **implemented** — `SseStreamPublisher`'s structural twin minus frame assembly; emits opaque `byte[]` chunks for streamed TTS (ADR-023); `stream:true` spliced via the shared `internal.transport.StreamFlag` helper |
| Retry interceptor impl | `qa.fanar.core.internal.retry.RetryInterceptor` | **implemented** — the SDK's error boundary (maps 4xx/5xx to the typed hierarchy inside the chain, ADR-012 amendment) and retry loop: exponential back-off with configurable jitter, `Retry-After` honoured on both 429 subtypes up to `maxDelay` (a longer hint ends retrying and surfaces the exception with the hint preserved, ADR-025), `retry_attempt` events, `http.status_code` per attempt, `fanar.retry_count` on every exit and the `fanar.ratelimit.*` window from any response carrying the headers (last attempt wins, ADR-026), injectable `Sleeper`+`RandomGenerator`. Proved end to end by `FanarClientRetryIntegrationTest` (core), `FanarAutoConfigurationRetryIntegrationTest` (starter), `FanarChatModelRetryIntegrationTest` (Spring AI), the three `*ObservabilityPluginIntegrationTest`s and `WireLoggingInterceptorIntegrationTest` |
diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md
index 92b4d69..b93b91d 100644
--- a/docs/PROJECT_STATE.md
+++ b/docs/PROJECT_STATE.md
@@ -23,8 +23,10 @@ ADR-012 amended), and rate-limit visibility (ADR-026: the `fanar.ratelimit.*` ob
on every response that carries the headers, `RateLimitInfo` via `rateLimit()` on both 429 exceptions,
Micrometer's cardinality rule), and the `RetryPolicy` total sleep budget + builder (ADR-027:
`maxTotalDelay`, `fanar.retry.max-total-delay`; the canonical constructor's arity changed — the
-cycle's one breaking change). Still to come in 0.4.0: facade plumbing consolidation, live-suite
-budget hygiene + nightly run, Maven Central readiness.
+cycle's one breaking change), and the facade plumbing consolidation (the eight domain facades delegate
+chain assembly, transport attributes and the transport call to one internal `Dispatcher` — no behaviour
+change, ADR-018). Still to come in 0.4.0: live-suite budget hygiene + nightly run, Maven Central
+readiness.
## Planned