Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -52,6 +49,11 @@
*
* <p>Internal (ADR-018). May be replaced, renamed, or deleted in any release.</p>
*
* <p>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.</p>
*
* @author Oussama Mahjoub
*/
public final class AudioClientImpl implements AudioClient {
Expand All @@ -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<Interceptor> interceptors;
private final HttpTransport transport;
private final Dispatcher dispatcher;
private final ObservabilityPlugin observability;
private final Map<String, String> defaultHeaders;
private final String userAgent;
Expand All @@ -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<Interceptor> 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;
Expand All @@ -111,8 +105,7 @@ public AudioClientImpl(
public VoiceResponse listVoices() {
try (ObservationHandle obs = observability.start(OP_LIST)) {
try {
HttpResponse<InputStream> response = dispatch(
buildGet(voicesEndpoint, obs), voicesEndpoint, "GET", obs);
HttpResponse<InputStream> response = dispatcher.dispatch(buildGet(voicesEndpoint, obs), obs, null);
return decodeJson(response, VoiceResponse.class, "VoiceResponse");
} catch (RuntimeException e) {
obs.error(e);
Expand All @@ -139,9 +132,8 @@ public void createVoice(CreateVoiceRequest request) {
mb.addField("transcript", request.transcript());
byte[] body = mb.build();

HttpResponse<InputStream> response = dispatch(
buildPost(voicesEndpoint, obs, mb.contentType(), body),
voicesEndpoint, "POST", obs);
HttpResponse<InputStream> response = dispatcher.dispatch(
buildPost(voicesEndpoint, obs, mb.contentType(), body), obs, null);
drain(response);
} catch (RuntimeException e) {
obs.error(e);
Expand All @@ -165,8 +157,7 @@ public void deleteVoice(String name) {
try {
URI endpoint = baseUrl.resolve(
VOICES_PATH + "/" + URLEncoder.encode(name, StandardCharsets.UTF_8));
HttpResponse<InputStream> response = dispatch(
buildDelete(endpoint, obs), endpoint, "DELETE", obs);
HttpResponse<InputStream> response = dispatcher.dispatch(buildDelete(endpoint, obs), obs, null);
drain(response);
} catch (RuntimeException e) {
obs.error(e);
Expand Down Expand Up @@ -196,7 +187,7 @@ public byte[] speech(TextToSpeechRequest request) {
.header("Accept", "audio/*")
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
.build();
HttpResponse<InputStream> response = dispatch(httpReq, speechEndpoint, "POST", obs);
HttpResponse<InputStream> response = dispatcher.dispatch(httpReq, obs, null);
return readBinary(response);
} catch (RuntimeException e) {
obs.error(e);
Expand Down Expand Up @@ -224,7 +215,7 @@ public Flow.Publisher<byte[]> speechStream(TextToSpeechRequest request) {
.header("Accept", "audio/*")
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
.build();
HttpResponse<InputStream> response = dispatch(httpReq, speechEndpoint, "POST", obs);
HttpResponse<InputStream> response = dispatcher.dispatch(httpReq, obs, null);
return new AudioStreamPublisher(response.body());
} catch (RuntimeException e) {
obs.error(e);
Expand Down Expand Up @@ -256,8 +247,7 @@ public SpeechToTextResponse transcribe(TranscriptionRequest request) {
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
.build();
HttpResponse<InputStream> response = dispatch(
httpReq, transcriptionsEndpoint, "POST", obs);
HttpResponse<InputStream> response = dispatcher.dispatch(httpReq, obs, null);
return decodeJson(response, SpeechToTextResponse.class, "SpeechToTextResponse");
} catch (RuntimeException e) {
obs.error(e);
Expand All @@ -274,15 +264,6 @@ public CompletableFuture<SpeechToTextResponse> transcribeAsync(TranscriptionRequ

// --- shared dispatch -------------------------------------------------------------------

private HttpResponse<InputStream> 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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -61,6 +60,11 @@
*
* <p>Internal (ADR-018). May be replaced, renamed, or deleted in any release.</p>
*
* <p>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.</p>
*
* @author Oussama Mahjoub
*/
public final class ChatClientImpl implements ChatClient {
Expand All @@ -70,8 +74,7 @@ public final class ChatClientImpl implements ChatClient {

private final URI endpoint;
private final FanarJsonCodec jsonCodec;
private final List<Interceptor> interceptors;
private final HttpTransport transport;
private final Dispatcher dispatcher;
private final ObservabilityPlugin observability;
private final Map<String, String> defaultHeaders;
private final String userAgent;
Expand All @@ -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
// <user interceptors> — in registration order
// transport — terminal
List<Interceptor> 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;
Expand Down Expand Up @@ -151,13 +142,7 @@ public Flow.Publisher<StreamEvent> stream(ChatRequest request) {

private HttpResponse<InputStream> 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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*
* <p>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).</p>
*
* <p>Internal (ADR-018). Thread-safe: no per-call state outside the chain instance.</p>
*
* @author Oussama Mahjoub
*/
public final class Dispatcher {

private final List<Interceptor> 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<String> apiKeySupplier,
List<Interceptor> userInterceptors) {
Objects.requireNonNull(apiKeySupplier, "apiKeySupplier");
Objects.requireNonNull(userInterceptors, "userInterceptors");
Objects.requireNonNull(retryPolicy, "retryPolicy");
List<Interceptor> 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<InputStream> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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}.
*
* <p>Internal per ADR-018 — nothing in this package is exported.</p>
*
* @author Oussama Mahjoub
*/
package qa.fanar.core.internal.dispatch;
Loading
Loading