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