Skip to content

Latest commit

 

History

History
210 lines (173 loc) · 14.6 KB

File metadata and controls

210 lines (173 loc) · 14.6 KB

Explanation

Background reading on the design decisions behind this library. For "what exists," see reference.md; for "how do I," see how-to.md.

Why core doesn't know about Jackson

The DTOs (Answer, Question, EvaluateRequest, EvaluateResponse, Usage, RequestId) started out annotated directly with @JsonTypeInfo/@JsonSubTypes and hard-wired to Jackson 2's ObjectMapper. That meant every consumer of the client was also a forced consumer of Jackson 2, and the DTOs themselves couldn't be reused (e.g. to serialize the same payloads onto a Kafka topic) without dragging in java.net.http-specific code too.

Splitting the DTOs into typesafe-java-core with zero Jackson dependency, and moving the type discriminator logic into private mixins inside jackson2/jackson3 (ObjectMapper.addMixIn / JsonMapper.Builder.addMixIn), means:

  • core alone is a valid dependency for anything that just needs the payload shapes.
  • Supporting a third JSON library later is "add a fourth codec module," not "touch the DTOs."
  • The DTOs stay exactly what they look like: plain records and a sealed interface, with no clue which serialization library (if any) is reading them.

See ADR 0001 for the full decision record.

Why JsonCodec and HttpTransport are resolved via ServiceLoader, not a compile dependency

core cannot declare a compile dependency on jackson2/jackson3 or on client-jdk — any of those choices would undo the whole point of splitting them out. ServiceLoader lets TypeSafeClient stay agnostic to both while still getting real implementations automatically the moment one codec module and one transport module are on the classpath, the same pattern the JDK itself uses for java.sql.Driver or java.nio.file.spi.FileSystemProvider. The tradeoff: a missing codec or transport module fails at TypeSafeClient.Builder.build() time with a runtime IllegalStateException, not at compile time — deliberately, since a compile-time check here would mean picking one codec/transport as "the real dependency," which is exactly what this design avoids.

Why HttpTransport exists (and why client isn't a separate module)

TypeSafeClient originally called java.net.http.HttpClient directly. Abstracting that behind HttpTransport — mirroring JsonCodec — means someone who wants Apache HttpClient, OkHttp, or a mocked transport for tests can implement one interface (post/get, both already CompletableFuture-returning) instead of forking the retry/backoff logic.

Once that abstraction exists, TypeSafeClient itself has no HTTP-library dependency any more — its only import from java.net is URI, which every JDK module already has. That removed the original reason for a separate client module (keeping core free of java.net.http), so TypeSafeClient/ApiKey/TypeSafeException live in core next to the DTOs: one fewer module to version and depend on, with core exactly as dependency-free as before. See ADR 0001 for the full decision record.

Why TypeSafeClient is an interface, not a final class

It started out public final class TypeSafeClient. Mockito 5's default (inline) mock maker already mocks final classes, so finality was never actually blocking a caller from unit-testing code that depends on TypeSafeClient — but it did block a different, legitimate use: a decorator. final means nothing can implements/present itself as a TypeSafeClient, so a caller who wants to wrap one with caching, metrics, a circuit breaker, or anything else in the classic Decorator shape has no supertype to implement — they'd have to invent their own interface with the same three methods and get every call site to depend on that instead of on TypeSafeClient directly.

Making it an interface costs nothing observable at existing call sites: TypeSafeClient.builder(key).build() still type-checks and behaves identically, since Builder.build() always returned the interface type as far as callers could tell. What moved is the implementation — the retry/backoff/header/ decode logic, previously TypeSafeClient's own body, now lives in DefaultTypeSafeClient, the only concrete TypeSafeClient this library produces. A consumer can now write class CachingTypeSafeClient implements TypeSafeClient and hand it anywhere a TypeSafeClient was expected.

Builder moved with it, onto DefaultTypeSafeClient rather than staying on the TypeSafeClient interface: constructing a DefaultTypeSafeClient — picking defaults, discovering a HttpTransport/JsonCodec via ServiceLoader — is that class's own concern, not something a pure contract interface should carry. That required making DefaultTypeSafeClient itself public (a nested class can't be more accessible than its enclosing class), so it's no longer hidden — but TypeSafeClient.builder(apiKey) still exists as a one-line delegating static method on the interface, so nothing at the call site changes; a consumer only sees DefaultTypeSafeClient by name if they explicitly go looking for it.

Why testkit ships a TypeSafeClient fake instead of "just mock it with Mockito"

Once TypeSafeClient became an interface (see above), Mockito.mock(TypeSafeClient.class) was already enough to stub evaluate()/listModels() — so RecordingTypeSafeClient isn't there to make something possible that wasn't. It's there so a consumer's test doesn't need a Mockito dependency at all, and so the two or three lines of "queue a response, assert on what was sent" every such test wants don't get rewritten by hand each time. It's deliberately a plain FIFO (enqueueEvaluate/enqueueModels) rather than a matcher-based expectations DSL: a test already controls call order — it's the one deciding when to call evaluate()/listModels() — so matching by request content would only restate what the test already knows.

Why mapping uses reflection over records, not an annotation processor or a fluent builder

Issue #2 flagged that reading an answer back means Map<String, Answer> plus a manual (Answer.Noul)-style cast, and that a Noul question with no criteria still needed an empty Map.of() at the call site (since fixed — Question.noul now has a criteria-less overload too). MappingTypeSafeClient fixes the cast: a caller's own record carries @Noul/@Choice/@Score on its components, and gets a populated instance of that same record back.

Three ways to build that mapping, in ascending complexity: a fluent builder (no annotations, just explicit .noul("isUrgent", "...") calls mapped to record positions by hand — doesn't remove the cast, only moves it into the builder's own return type); reflection over Class#getRecordComponents() (no new build step, matches how the rest of this project already avoids codegen — jackson2/jackson3's polymorphism is hand-written mixins, not generated); or an annotation processor generating a real mapper class at compile time (fully typed at compile time, zero reflection cost per call, but a new javac-time dependency and generated-sources step nothing else in this repo has). Reflection won: even repeated, its cost is dwarfed by the network round trip each call wraps, and it keeps mapping's dependency footprint identical to every other module here (core only).

That said, MappingTypeSafeClient still caches each record type's reflection metadata — its per-component question/answer mapping and canonical constructor — the first time that type is used, keyed by Class on the instance. A single call's reflection cost being noise doesn't mean redoing it on every call is free; caching it is nearly free to add (one ConcurrentHashMap) and turns "noise per call" into "noise once per record type," which also means a caller only pays the validation cost (see below) once, not on every request.

@Option only exists nested inside @Choice#options() (@Target({}), not usable on its own) — Question.Choice#criteria() is a Map<String, String>, and a Java annotation attribute can't be a Map; an array of a small carrier annotation is the standard workaround. @Noul/@Score map to a double component (the raw Answer.Noul#noul()/Answer.Score#score() value, not a derived boolean/enum) deliberately: a probability-to-boolean threshold is a policy decision this library shouldn't make on a caller's behalf.

The scalar mapping (double/String) drops Answer's other fields — probabilities(), confidence(), and (for Score) legend() — which is fine for the common case but was a real gap for a caller who wants them: the only escape hatch was dropping evaluateTyped for a plain evaluate() call and going back to Map<String, Answer>. Rather than a second family of confidence-carrying annotations/types, each component's type is simply allowed to be the full Answer.Noul/Answer.Choice/Answer.Score as an alternative to the scalar — componentMappingFor picks an identity extraction instead of unwrapping the scalar when it sees the full type. Same annotation, same validation path, no new concepts.

Why MappingTypeSafeClient doesn't have its own Builder

The natural-looking ask — MappingTypeSafeClient.builder(apiKey)...build(), mirroring TypeSafeClient.builder(apiKey) — was rejected. TypeSafeClient.builder works because TypeSafeClient has exactly one production implementation to build. MappingTypeSafeClient is a decorator, meant to wrap any TypeSafeClient (a plain one, one already wrapped in caching, a FailingTypeSafeClient for testing, a test double) — a builder that constructs its own DefaultTypeSafeClient internally would bake in "wrap a fresh default client" as the only path, against the entire reason the decorator shape exists (see "Why TypeSafeClient is an interface, not a final class" above). It would also duplicate DefaultTypeSafeClient.Builder's whole surface (httpTransport, jsonCodec, endpoint, maxRetries, initialBackoff) as forwarding methods that go stale the moment the original gains an option this copy doesn't.

What shipped instead is a single addition to the existing Builder: build(Function<TypeSafeClient, T> decorate), one line (decorate.apply(build())) that applies a decorator to the client it just built and returns T instead of the plain TypeSafeClient — no cast needed to reach evaluateTyped. It's generic, so core never needs to know MappingTypeSafeClient exists, and it's purely additive: the plain MappingTypeSafeClient.decorate(anyDelegate) static factory still works for every case this doesn't cover — there's no public constructor to call instead. Stacking more than one decorator needs no extra API either — Function#andThen (stdlib) composes them, so builder(apiKey).build(caching.andThen(MappingTypeSafeClient::decorate)) already works.

Why State is a sealed interface, not Object

EvaluateRequest.state() used to be a bare Object — "whatever the caller's JsonCodec can serialize." But docs.typesafe.ai/concepts/state documents state as exactly three shapes: a string, a JSON object, or an array of text values — not open-ended JSON. Object was strictly looser than the real API contract: a caller could pass a List<Integer> or a custom record and it would compile, then fail (or silently misbehave) against the actual API. State (Text/Fields/Messages) makes the three valid shapes a compile-time fact, the same way Answer/Question already do for their own domains.

Unlike Answer/Question, state has no type discriminator on the wire — the API tells the three shapes apart by their raw JSON type (string vs. object vs. array), not a tag field. So each codec's State handling is a serializer that writes the variant's raw value directly (gen.writeString(...) / gen.writeObject(...)/writePOJO(...)), not a type-keyed mixin like Answer/Question use. There's also no deserializer: state only ever appears on EvaluateRequest, which this client only ever writes, never reads back.

Why evaluate/listModels declare no checked exception

They used to declare throws IOException, InterruptedException, mirroring java.net.http.HttpClient's own convention. That was an inconsistency with TypeSafeException itself (already unchecked, for non-2xx statuses): the same call had two failure modes treated differently — one a caller could ignore, the other forced onto every call site's signature or a try/catch. A connection failure, a timeout, and the calling thread being interrupted are now TypeSafeException.Connection/.Timeout/.Interrupted — all unchecked, alongside the per-status subclasses. See ADR 0002 for the full decision record.

Why retries are bounded and exponential

evaluate/evaluateAsync retry 408, 429, and any 5xx up to 5 times, doubling the backoff from an initial 500ms each time (500ms, 1s, 2s, 4s, 8s). Any other status — including a retryable one that outlasts the retry budget — surfaces immediately as TypeSafeException rather than being swallowed or retried indefinitely: a caller should always be able to tell "this request permanently failed" from "this request is still in flight," and an unbounded retry loop against a struggling upstream only makes the overload worse.

Why the endpoint, retry count, and backoff are configurable

They started as private static final constants. Making them Builder options costs three setters and three fields, and buys two things: pointing at a staging endpoint without an environment-specific subclass, and fast unit tests — TypeSafeClientTest doesn't need to wait out a real 500ms+ backoff because nothing forces it to use the production default.

Why evaluateAsync isn't just evaluate wrapped in supplyAsync

A naive CompletableFuture.supplyAsync(() -> evaluate(request)) would burn one thread per in-flight request, blocked on Thread.sleep during backoff. evaluateAsync instead chains off HttpTransport.post's returned future and schedules retries via CompletableFuture.delayedExecutor, so a backoff wait never blocks a thread — the same retry policy, without the thread cost, which matters once callers are firing many requests concurrently. This only holds if the HttpTransport implementation's post is itself genuinely non-blocking (JdkHttpTransport is, since it delegates to HttpClient.sendAsync); a transport backed by a blocking-only HTTP library has no non-blocking send to chain off and has to fall back to a thread-per-call post.