Skip to content

feat!: developer-experience improvements across the SDK surface#208

Open
OmarAlJarrah wants to merge 46 commits into
mainfrom
dx-improvements
Open

feat!: developer-experience improvements across the SDK surface#208
OmarAlJarrah wants to merge 46 commits into
mainfrom
dx-improvements

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Jul 5, 2026

Copy link
Copy Markdown
Member

Summary

The toolkit had strong internals whose ergonomics and documentation lagged behind: making a first request meant a mandatory global bootstrap, building a request meant a verbose builder with no verb shortcuts, reading a response meant null-chasing through body?.source()?.readUtf8(), and the typed error model, streaming decoders, and resilience steps that already existed were undemonstrated or missing their last-mile one-liner. Two subsystems both called themselves "pipeline," and two different classes were both named RetryStep.

This change closes those gaps across the whole surface. It is a broad, mostly-additive pass with a set of deliberate pre-1.0 breaking changes where a name or signature was worth correcting now rather than after 1.0. sdk-core keeps its zero-runtime-dependency invariant; every convenience that needs Okio or Jackson lives in an adapter module.

First-request friction

  • Zero-config I/O. Io.provider now resolves a single IoProvider on the classpath via ServiceLoader, so Io.installProvider(OkioIoProvider) is optional when the Okio adapter is present. This removes the one bootstrap line every caller previously had to run before the first request or hit a runtime failure; explicit install still overrides, and 0-or-many providers still fail loudly with an actionable message.
  • Turnkey pipelines. HttpPipeline.standard(transport) / HttpPipelineBuilder.appendStandardResilience() assemble redirect + retry + instrumentation with sane defaults in one call, so getting resilience no longer requires knowing and ordering the individual Default*Step classes. (The async mirror assembles retry + instrumentation; there is no async redirect step to include.)
  • Installation docs. The README previously had no way to actually depend on the project. It gains an Installation section (Gradle + Maven coordinates), a per-module coordinate column, an ergonomic quick start, a "How do I…" snippet set, and a minimal SimpleGetApp sample alongside the full one — the quick start now teaches the ergonomic path instead of the manual one.

Request & response ergonomics

  • Request building. The builder now defaults to GET (so build() no longer requires an explicit method and no longer throws when one is omitted) and gains verb methods (get/post/put/delete/head/patch, where the body-less verbs also clear any previously-set body); Request.get/post/put/delete/head/patch(...) static factories for the common one-liners; typed addHeader/setHeader(HttpHeaderName, String | List<String> | MediaType) overloads so callers holding a typed header name or media type no longer .toString() both by hand; and a url(URI) overload. url(String) now throws an unchecked IllegalArgumentException instead of a checked MalformedURLException, so a literal URL no longer forces a try/catch.
  • Typed bodies. RequestBody.create(value, serde) serializes a typed value in one call instead of the manual serialize-then-wrap two-step; Serde.contentType() supplies the default media type so it doesn't have to be restated at every call site.
  • Media types. MediaType.APPLICATION_JSON (and the other common constants) are now reachable directly on MediaType; previously the constants lived only on a separate CommonMediaTypes object, so callers reached for the parse-a-string form (a typo risk).
  • Response reading. Response.isSuccessful, ResponseBody.string()/bytes(), and the five Status range predicates (isInformational, isRedirect, isClientError, isServerError, isError) remove the common null-chasing and status arithmetic; HttpExceptionFactory.isErrorStatus now delegates to Status.isError.
  • Typed reads. response.deserialize(serde, Class | TypeRef) and response.parsedWith(jsonHandler(serde, T::class.java)).value() stream and close the body. The new dependency-free TypeRef<T> enables parametric decode (List<User>) without a Jackson import at the call site (Deserializer gains default TypeRef methods; the Jackson adapter resolves the generic type), and a new jsonHandlerOrThrow(...) handler decodes on 2xx and throws the mapped HttpException (with a bounded, buffered error body) on non-2xx.

Error handling

  • Typed errors on demand. HttpPipelineBuilder.throwOnHttpError() (and its async mirror) maps 4xx/5xx to the typed HttpException hierarchy so callers stop hand-writing check(status.isSuccess). It runs at a new outermost Stage.PRE_REDIRECT deliberately: evaluating the response at the outermost stage means it only ever sees the final response, so it never throws on an intermediate 3xx redirect hop and never preempts a retryable 5xx that the inner retry step would have recovered.
  • Call-site mapping. Response.throwOnError() does the same at a call site, buffering the error body (bounded to 1 MiB) so the exception's body survives the response being closed; HttpException.bodyAs(...) decodes a typed error payload and closes the body.
  • Transport failures. Both reference transports now surface transport-level failures as NetworkException (which extends IOException, so existing catch (IOException) still works) so the documented NetworkException contract is real, and both retry stacks now classify the same failure identically.

Configuration & per-request control

  • Per-request overrides. A single slow or non-standard endpoint previously needed a whole second transport + pipeline. The new RequestOptions carrier (send(request, options)) threads a per-call timeout and maxRetries override — plus an opaque tags map for correlation/extension — through the pipeline to the transports and the retry step, with fallback to the configured defaults. The SPI overloads (execute/executeAsync(request, options)) are default methods, so existing third-party transports keep compiling.
  • Config surface. HttpRetryOptions, HttpInstrumentationOptions, and HttpRedirectOptions gain builders (Builder<T> + newBuilder()) so a Java caller can set the seventh field without positionally re-supplying the six defaults before it. HttpRetryOptions.fromConfiguration(...) wires the previously-orphaned max-retry key; the JDK transport now defaults to a 10s connect timeout (it was unbounded, deviating from the documented default); both transports gain opt-in proxyFromEnvironment().

Java interop

  • Value-class demotion. ETag and HttpRange are demoted from @JvmInline value class to plain final classes: their @JvmInline name mangling (parse-1jReFK8, ifMatch-a6HYibk, …) produced identifiers Java can't call, making the whole typed conditional-request / range surface unreachable. They keep value semantics and gain String overloads on RequestConditions.Builder.ifMatch/ifNoneMatch.
  • SAM extractors. Pagination strategies take dedicated functional interfaces (CursorExtractor / LinkExtractor / PageNumberExtractor) instead of a raw kotlin.jvm.functions.Function1, which is opaque and awkward to implement from Java.
  • Fail-fast pillars. Adding a second, distinct step to a pillar stage now throws IllegalStateException (naming the stage and pointing at replace<T>()) instead of silently warning-and-replacing, so a mis-assembled pipeline fails at build time instead of dropping a step.

Async

  • Terminal helper. CompletableFuture<Response>.handleWith(handler) replaces the error-prone whenComplete { r, e -> … r.use{} } pattern every async caller re-wrote: it applies the handler, closes the response on both paths, and unwraps CompletionException so the caller sees the real cause. AsyncHttpPipeline.sendAsync(request, handler) adds the typed overload.
  • Cancellation parity. HttpClient.asAsync(executor) and HttpPipeline.toAsync(executor) now share one interrupting-future path, so cancel(true) interrupts the blocking send on either bridge (asAsync previously leaked the running request).

Naming

  • Pipelines are transports. HttpPipeline / AsyncHttpPipeline now implement the HttpClient / AsyncHttpClient SPIs (an additive public-supertype change), so a fully-configured pipeline can drive pagination or nest as a transport — its steps run instead of the raw transport.
  • Recovery stack renamed. The recovery-layer stack is renamed so it can no longer be confused with the user-facing pipeline: ExecutionPipelineRecoveryChain, RequestPipeline/ResponsePipelineRequestRecoveryChain/ResponseRecoveryChain (and their requestChain/responseChain members), the recovery RetryStep/ThrowOnHttpErrorStepRetryRecovery/ThrowOnHttpErrorRecovery (ending the two duplicate simple-name collisions with the stage steps), and ClientIdentityStep drops its redundant public apply(request) so there is a single transform verb.

Breaking changes

Pre-1.0 alpha; source/binary breaks are expected. The notable ones:

  • url(String) no longer declares MalformedURLException (Java callers that caught it must drop the catch).
  • ETag/HttpRange are no longer @JvmInline value classes (their mangled member names change).
  • Pagination strategy constructors take the new SAM extractor types instead of Function1.
  • Adding a second distinct pillar step throws instead of replacing.
  • The recovery-stack types are renamed (RecoveryChain/RetryRecovery/ThrowOnHttpErrorRecovery, requestChain/responseChain), and ClientIdentityStep.apply(request) is removed.
  • jsonBody(serde, value) is removed in favor of the core RequestBody.create(value, serde) (it was a redundant pass-through with reversed argument order and a misleading name).

API snapshots are regenerated accordingly.

Behavior changes (source/binary compatible)

  • JacksonSerde.from(mapper) now operates on a defensive mapper.copy() and registers TristateModule by default (with a registerTristate opt-out overload): a bare caller-supplied mapper silently corrupted Tristate PATCH payloads (absent vs. explicit-null), and the call no longer mutates the caller's mapper.
  • Per-call RequestOptions.maxRetries with a negative value now falls back to the configured default, matching how a negative configured value is already clamped.
  • The builder delete() clears a previously-set body, matching get()/head().

Testing

Full gated build green: every module's tests, ktlint, detekt, allWarningsAsErrors, binary-compatibility (apiCheck), the aggregate 80% Kover floor, and the R8 shrink-survival guard. New behavior is covered by unit tests per area; the sdk-example sample and its smoke test run against the new APIs.

Correctness and hardening follow-ups

A close read of the DX surface above surfaced a set of correctness, resource-safety, security, and
cross-transport-consistency gaps in the new conveniences (and a few latent ones they newly exposed).
This section folds in the fixes. They are grouped by area; each is stated as the problem and the change.
The full gated build — every module's tests, ktlint, detekt, allWarningsAsErrors, apiCheck, the 80%
Kover floor, and the R8 shrink-survival guard — is green, and API snapshots are regenerated.

Security & logging

  • URL-valued headers were logged verbatim. Location (and Content-Location) sat in the default
    instrumentation allow-list, so their values — full URLs — were written to logs unredacted, bypassing
    the UrlRedactor that redacts url.full. Because logging runs inside the redirect loop, an OAuth
    302 Location: …?code=…, a pre-signed download URL, or an implicit-flow fragment token landed in
    plaintext. These headers are now routed through UrlRedactor (new redactUrlValue, handling absolute
    and relative values) before logging, so the query/fragment credentials are redacted while the host/path
    stays visible for diagnostics.

Retry, recovery & cancellation

  • Read timeouts were misclassified as cancellation. SocketTimeoutException extends
    InterruptedIOException, so both retry steps treated a plain read timeout as a cancellation and never
    retried it (and, on the sync path, set the caller thread's interrupt flag). Socket timeouts are now
    excluded from the interrupt carve-out and left to normal retry classification.
  • Typed exceptions were buried under IOException. DefaultRetryStep wrapped every non-IOException
    terminal failure in IOException("HTTP pipeline failure"), so when a pipeline carrying
    throwOnHttpError() was used as another pipeline's transport, the typed HttpException was destroyed as
    it crossed the outer retry step. Unchecked exceptions now propagate as-is (they do not violate the
    @Throws(IOException) contract), keeping the typed exception catchable.
  • Cancelled async calls kept retrying. DefaultAsyncRetryStep did not check the returned future before
    scheduling the next attempt, so a cancelled call kept issuing network requests. The retry loop and both
    completion handlers now stop and close any in-flight response once the future is done.
  • Status-based retries stopped after one re-send. The recovery-layer RetryRecovery treated any
    response from a re-sent attempt as terminal, so a 503, 503, 200 sequence returned the raw second 503
    instead of retrying to the 200. Re-sent responses are now re-classified: a status in
    retryableStatuses is mapped to the matching HttpException (body buffered into a bounded, replayable
    copy, connection released), so retries continue and the terminal outcome is a typed exception.
  • retryableStatuses could only narrow. RetryRecovery gated HttpException retries on the built-in
    classifier before consulting retryableStatuses, so a configured status the classifier did not already
    deem retryable (e.g. 425) was silently ignored. retryableStatuses is now authoritative for an
    HttpException.
  • Transport-level cancellation could trigger a retry (OkHttp). A call cancelled inside OkHttp
    (dispatcher().cancelAll(), an interceptor's call.cancel()) surfaced as a retryable NetworkException
    and was re-sent. Such cancellations now complete with a non-retryable InterruptedIOException; a
    call-timeout is discriminated by exception type and stays retryable.

Resource safety

  • throwOnError error bodies were single-use. The buffered exception body was consumed by the first
    read, so bodyAs(...) followed by a bodySnapshot() saw an empty body. The error body is now buffered
    into a replayable in-memory copy through a shared helper (also used by the recovery-layer error mapper, so
    the two cannot drift), and the buffer is acquired inside the body's use block so a provider-resolution
    failure still releases the connection.
  • ResponseBody.create could fail a successful call on double-close. Its close() now guards against a
    second close, honouring the documented idempotent-close contract even if the wrapped source throws.
  • Pagination probes leaked connections. CloseablePages buffers the page fetched by a hasNext() probe
    and now closes both the current and any prefetched page on close(), so an emptiness check or a peeking
    wrapper no longer strands a connection.
  • The async→coroutines bridge dropped a response on a cancellation race. asAsyncCoroutines now closes a
    Response computed in the window where the returned future is cancelled.
  • Rejected executor tasks escaped synchronously. interruptibleFuture now surfaces a
    RejectedExecutionException from a shut-down executor through the returned future rather than throwing on
    the caller's thread, and hands off cancel(true)'s interrupt under a short guard so a stale interrupt
    cannot land on a pooled thread after the task finishes.
  • Auth-challenge replay could mask the real 401. AuthStep re-sent the request body on a 401 challenge
    without checking replayability; a single-use body tripped its consume-once guard and surfaced as an
    IOException. The replay is now gated on body replayability, matching the retry and redirect steps.

Type-safe decoding

  • Generic decode targets erased silently. TypeRef now rejects a captured unresolved type variable at
    construction instead of resolving it to Object (which caused heap pollution and a late
    ClassCastException). The reified Deserializer/Response.deserialize extensions capture T as a
    TypeRef, so deserialize<List<User>>() routes through the generic overloads instead of the erased raw
    class. Deserializer gains a ByteArray + TypeRef overload for parity.
  • A literal null JSON body NPE'd later. Jackson decode overloads that promise a non-null value now
    throw DeserializationException when the body is the JSON literal null, at the boundary rather than at
    first use.
  • Response.deserialize threw outside the serde hierarchy. A null body now throws SerdeException
    rather than a raw IllegalStateException.
  • deserializeAs(InputStream, …) closed the caller's stream. It now drives a per-call parser with
    auto-close disabled, matching its contract and the sibling overloads.
  • jsonHandlerOrThrow mislabelled failures. decode() lets a genuine mid-stream IOException
    propagate instead of rebranding it as a codec failure; a non-2xx non-error status (e.g. a 304) is
    reported as a protocol condition with the status and ETag/Location preserved, not as a deserialization
    failure.

Cross-transport consistency

  • Content-Type behaved differently per transport. The JDK transport now emits Content-Type from the
    body's media type when the caller set none (so the RequestBody.create(value, serde) pattern carries a
    Content-Type on both transports), and on OkHttp an explicit caller Content-Type header now wins over
    the body's auto-stamped type (previously BridgeInterceptor overwrote it). The residual bodyless-DELETE
    Content-Length: 0 difference the java.net.http API cannot avoid is documented, as are the differing
    default response-timeout budgets and the JDK's HTTPS-CONNECT Basic-proxy-auth limitation (with its
    workaround).

Zero-config I/O under shrinking

  • R8 removed the auto-discovered provider. The sdk-io-okio3 consumer keep-rules pinned only
    OkioIoProvider, not the ServiceLoader-instantiated shim the zero-config path actually resolves, so a
    shrunk build lost its only provider. The shim and its constructor are now kept. Installing the real
    provider after the shim was auto-resolved also no longer logs a spurious mixed-provider warning.

Provider & builder ergonomics

  • Io.installProvider/Io.provider are @JvmStatic (the documented Java spelling now compiles); the
    hot-path avoids a redundant volatile write; the resolution KDoc is corrected.
  • HttpPipelineBuilder/AsyncHttpPipelineBuilder gain explicit flattening constructors that take a
    pipeline, so Builder(pipeline) no longer silently nests it as a raw transport; StagedSteps.reload is
    all-or-nothing so a pillar collision cannot leave a half-configured builder; the pillar-vacancy check is
    shared; close() documents that it does not close the underlying transport; AsyncHttpPipeline gains a
    sendAsync(request, options, handler) overload.

Java interop & API surface

  • HttpClient.execute declares @Throws(IOException) (the SDK's NetworkException is an IOException), so
    a Java transport can throw it and a Java caller can catch it at the call site; the misplaced @Throws on
    the asBlocking() factory moves onto the wrapper's execute.
  • The coroutines, Reactor, and Netty adapters gain per-call RequestOptions overloads so options are no
    longer dropped; the paginators thread RequestOptions to every page fetch.
  • The two identical pagination extractor SAMs collapse into one ItemsExtractor; ETag and
    RequestConditions reject RFC-illegal values; ResponseBody.string() honours the declared charset;
    Response exposes the full status-range predicate family; the six CommonMediaTypes constants that
    overlap MediaType's delegate to it; ClientIdentityStep preserves multi-valued headers and skips blank
    tokens; a serde-encoded multipart part defaults its Content-Type; HttpRedirectOptions takes Set and
    makes a single defensive copy.

Documentation

  • The README cursor-pagination example is rewritten against the real constructor signatures (the previous
    snippet did not compile); the pipeline-stage diagram includes the outermost stage; the throwOnHttpError
    and per-call-timeout semantics are stated accurately; and docs/ references to the renamed recovery-stack
    types are updated.

…sErrorStatus

Add isInformational (1xx), isRedirect (3xx), isClientError (4xx),
isServerError (5xx), isError (4xx–5xx) to Status alongside the
existing isSuccess. HttpExceptionFactory.isErrorStatus now delegates
to Status.fromCode(code).isError, making the two consistent.
Response.isSuccessful delegates to status.isSuccess, eliminating the
need to reach through the status property. ResponseBody gains
string(charset) and bytes() convenience readers that both close the
body in a finally block, removing the common null-chasing pattern of
body?.source()?.readUtf8().
…se.deserialize()

throwOnError() buffers the error body into an in-memory ResponseBody
before throwing so the exception's body remains readable even after
the transport connection is released. HttpException.bodyAs() decodes
the error body via a Deserializer, returning null on missing body or
decode failure without propagating. Response.deserialize() streams
the body through a Serde and closes the response in a finally block;
a reified overload provides Kotlin-only type-inference sugar.
…factory

T4 — six @JvmField constants on MediaType.Companion (APPLICATION_JSON,
TEXT_PLAIN, APPLICATION_OCTET_STREAM, APPLICATION_FORM_URLENCODED,
TEXT_HTML, APPLICATION_XML) that delegate to CommonMediaTypes, giving
callers a single canonical access point (MediaType.APPLICATION_JSON)
without duplicating the registry.

T8 — default contentType() method on Serde returns
CommonMediaTypes.APPLICATION_JSON so implementations do not need to
declare it unless they deviate from JSON. A matching RequestBody.create
overload accepts (Any, Serde, MediaType = serde.contentType()), serializes
via Serde.serializer.serialize, and delegates to the existing String
create; no Jackson dependency required in sdk-core.
…ult GET, and verb shortcuts

T5 — five typed HttpHeaderName overloads on RequestBuilder mirror what
Headers.Builder already exposes: addHeader/setHeader(name, String),
addHeader/setHeader(name, List<String>), and a convenience
addHeader(name, MediaType) that takes the wire form of the media type.

T6 — url(String) no longer declares @throws(MalformedURLException).
Internally it catches MalformedURLException and re-throws
IllegalArgumentException("Invalid URL: …", cause) so callers do not
have to handle a checked exception on a builder chain. A new url(URI)
overload converts via URI.toURL() with the same wrapping.

T7 — build() defaults the method to Method.GET when none was set, so a
minimal Request.builder().url(url).build() no longer throws. Six verb
shortcuts (get, post, put, delete, head, patch) set the method and
body in one call; get() and head() also clear any previously-set body.
Request.Companion gains static factories get(url) and post(url, body).
from() now operates on a defensive copy of the caller's mapper (never
mutating the original) and registers TristateModule on that copy so that
Tristate PATCH fields — Present/Null/Absent — serialize correctly without
silent data corruption.

Adds a registerTristate: Boolean = true parameter (@jvmoverloads so Java
callers keep the single-arg form) for callers who pre-registered the module
themselves and want to opt out of the automatic registration.

Tests cover all three Tristate states, the no-mutation guarantee, and the
opt-out path.
Adds a thin public jsonBody(serde, value) function to Extensions.kt that
delegates to RequestBody.create(value, serde) — a discoverable entry point
for building a JSON request body without importing sdk-core factory methods
directly from the Jackson adapter module.
…licit install

When no provider is explicitly installed and exactly one IoProvider is
discoverable via ServiceLoader on the classpath, Io.provider resolves it
automatically and caches the result. Explicit installProvider still wins.

- Io: add @volatile resolved field; double-checked lock for ServiceLoader scan
- Io.selectProvider (internal): testable 0/1/many decision; 0 → actionable error, 1 → return it, many → ambiguity error naming all candidates
- Io.installProvider / swapProvider: both clear resolved so explicit installs and test teardowns always take full effect
- OkioIoProviderLoader: internal shim with public no-arg ctor allowing ServiceLoader to instantiate OkioIoProvider (Kotlin object, private ctor)
- META-INF/services: registers OkioIoProviderLoader in sdk-io-okio3
- IoTest: adds selectProvider unit tests (0/1/2 candidates), ServiceLoader discovery assertion, and integration test proving Io.provider resolves without installProvider
Add a ThrowOnHttpErrorStep (and its AsyncHttpStep mirror) that maps a 4xx/5xx
response to the matching typed HttpException, reusing Response.throwOnError() to
buffer a bounded (<=1 MiB) copy of the error body so the exception stays readable
after the connection is released while capping memory against rogue payloads. A
2xx (or any non-error status that reaches the step) is returned untouched — the
success-path body is never read, consumed, or closed.

The step occupies a new outermost non-pillar stage, PRE_REDIRECT, so it only
evaluates the terminal response: it runs outside both the redirect and retry
loops and therefore never throws on an intermediate 3xx hop nor preempts a retry
of a retryable 5xx. Expose throwOnHttpError() on both pipeline builders to append
the step in one call.
… preset

Add a turnkey preset that assembles the standard resilience pillars in one call.
HttpPipelineBuilder.appendStandardResilience() appends DefaultRedirectStep,
DefaultRetryStep, and DefaultInstrumentationStep with their no-arg defaults, each
landing in its own pillar slot so the run order is fixed by Stage. The
HttpPipeline.standard(transport) companion is the shorthand for building that
pipeline over a transport.

The async mirror — AsyncHttpPipelineBuilder.appendStandardResilience(scheduler)
and AsyncHttpPipeline.standard(transport, scheduler) — appends the async retry
and instrumentation defaults. It takes a ScheduledExecutorService because the
async retry step schedules its non-blocking backoff on one, and it omits a
redirect step since the SDK ships no async redirect default.
…-replace

A pillar stage in the staged HTTP pipeline admits exactly one step (redirect,
retry, auth, logging, serde). Previously, appending or prepending a second
step to an already-occupied pillar silently overwrote the first and only
logged an SLF4J warning, so a misconfiguration — e.g. two retry steps — was
easy to miss until runtime.

Installing a distinct second step on an occupied pillar now throws
IllegalStateException naming the stage, the existing step's type, and the
incoming step's type, and points the caller at replace<T>() to swap. Re-adding
the same instance stays idempotent, and the replace<T>() path is unchanged.
The behaviour is symmetric across the sync and async builders; the enforcement
lives in the shared StagedSteps helper, so the per-builder warning callback and
its logger are gone.

BREAKING CHANGE: a second distinct pillar step now throws rather than replacing
the first. Callers that relied on the last-write-wins overwrite must use
replace<T>() explicitly.
HttpPipeline now implements HttpClient (execute delegates to send) and
AsyncHttpPipeline implements AsyncHttpClient (executeAsync delegates to
sendAsync). send/sendAsync remain the primary entry points; the SPI methods are
thin conformance delegates.

This lets a fully configured pipeline stand in anywhere a transport is expected
— most usefully as the client backing a Paginator/AsyncPaginator, so every page
request runs through the pipeline's redirect / retry / auth / logging steps
instead of hitting the raw transport. The inherited close() stays a no-op: a
pipeline wraps a caller-supplied transport it does not own and must not close.

BREAKING CHANGE: HttpPipeline and AsyncHttpPipeline gain a public supertype
(HttpClient / AsyncHttpClient) and the corresponding execute/executeAsync/close
members. The .api snapshot is updated accordingly.
…ures

Both reference transports (OkHttp and java.net.http) now wrap a non-interrupt
transport-level IOException — connection refused, DNS lookup failure, peer reset,
TLS handshake failure, a non-interrupt timeout — in NetworkException before it
leaves execute()/executeAsync(), instead of propagating the raw client exception.

NetworkException is itself an IOException, so existing catch (IOException) sites are
unaffected; callers that catch NetworkException now match the documented contract, and
both retry stacks already classify it as retryable (it is a Retryable and an IOException).

Interrupt semantics are preserved: an InterruptedIOException (including OkHttp's
SocketTimeoutException and a thread interrupted mid-send on the JDK client) passes through
untouched with the interrupt flag re-asserted, never repackaged as a NetworkException. The
wrapping is scoped to the client dispatch call only, so a later response-adaptation failure
keeps its own exception type.

The JDK async path routes the exchange future's failure through a mapper that strips
CompletionException/ExecutionException envelopes before classifying the root cause; the
AsyncResponseBridge gains an opt-in error-mapping hook for that.
Add a proxyFromEnvironment() method to the OkHttpTransport and JdkHttpTransport builders.
It resolves proxy settings from the process-wide global Configuration via
ProxyOptions.fromConfiguration — the standard HTTPS_PROXY / HTTP_PROXY / NO_PROXY environment
variables plus their https.proxy* / http.nonProxyHosts system-property equivalents — and
applies the resolved proxy to the client being built.

The method is strictly opt-in: the environment is never consulted unless it is called, and it
is a no-op when nothing there configures a proxy (or when NO_PROXY=* bypasses everything). It
writes the same slot as proxy(...), so an explicit proxy() and proxyFromEnvironment() follow
last-writer-wins.
Add a terminal operator that maps an async Response future to a typed
result via a ResponseHandler, mirroring how a ResponseHandler is invoked
on a synchronous Response:

- CompletableFuture<Response>.handleWith(handler) applies handler.handle
  on success and guarantees the Response is closed afterward (idempotent
  double close is safe even for handlers that read nothing), and on
  failure fails the returned future with the unwrapped cause so callers
  see the real transport/step failure rather than a Completion/Execution
  wrapper.
- AsyncHttpPipeline.sendAsync(request, handler) as a convenience over
  sendAsync(request).handleWith(handler).
HttpPipeline.toAsync(executor) returned a future whose cancel(true)
interrupts the blocking worker, but HttpClient.asAsync(executor) used a
plain supplyAsync whose cancel(true) did nothing and leaked the running
request. Extract the interrupting-future construction into a single
internal helper (Futures.interruptibleFuture) that captures the worker
thread and interrupts it on cancel(true), and route both bridges through
it so cancellation behaves identically: cancel(true) interrupts the
in-flight blocking send, cancel(false) lets it run to completion, and a
not-yet-started or already-finished task is never interrupted.
The cursor, Link-header, and page-number pagination strategies each took a
raw Kotlin function type (`(Response) -> CursorResult<T>` / `-> List<T>`) as
their extractor constructor parameter. From Java that surfaced as
`kotlin.jvm.functions.Function1` — an awkward, mangled type to implement.

Replace each with a dedicated single-method functional interface —
`CursorExtractor<T>`, `LinkExtractor<T>`, `PageNumberExtractor<T>` — mirroring
the exact signature it replaces. Kotlin call sites pass a lambda unchanged via
SAM conversion; Java callers now implement a clean, named interface (or pass a
lambda) instead of `Function1`.

This is a source- and binary-breaking change to the three strategy
constructors; the `.api` snapshot is updated accordingly.
Decoding a parametric response (`List<User>`, `Map<String, Dto>`) previously
forced the call site to name a Jackson `TypeReference`, coupling application
code to the serde library the SPI exists to hide. A bare `Class<T>` token can't
carry the element type.

Add `TypeRef<T>`, a dependency-free type-capture base in sdk-core built purely
on `java.lang.reflect` (the Gson `TypeToken` pattern): `object : TypeRef<List<User>>() {}`
records the generic superclass and reads the reflective `Type` and erased
`rawClass` back out. `Deserializer` gains `deserialize(input, TypeRef<T>)` for
String and InputStream, with a default that delegates to the existing `Class`
overload when the ref wraps a plain class and otherwise fails clearly — so a
format-agnostic deserializer never silently returns a map. `JacksonSerde`
overrides these to resolve the full type via `ObjectMapper.constructType`, so
`List<User>` decodes with no Jackson type at the call site.

Also add `jsonHandlerOrThrow` (Class and TypeReference overloads): a status-aware
response handler that decodes only on 2xx and, on a non-2xx response, throws the
mapped `HttpException` (carrying the unconsumed error body) instead of trying to
deserialize the error payload as the success type. The plain `jsonHandler` is
unchanged.
…itional-header overloads

ETag and HttpRange were @JvmInline value classes, so Kotlin mangled the JVM
names of their factories and accessors (e.g. parse-1jReFK8, bytes-Oepi37k,
Builder.ifMatch-a6HYibk). Those suffixes are not legal Java identifiers, which
left the entire typed conditional-request / range surface unreachable from Java.

Demote both to plain immutable final classes with hand-written equals/hashCode/
toString over the wrapped header string, preserving every factory, accessor, and
flag. The factories now compile to clean, unmangled names (ETag.parse, ETag.strong,
ETag.weak, HttpRange.bytes/from/suffix/parse) and ETag.ANY becomes a plain static
field. Boxing is irrelevant for these header helpers.

Add String overloads ifMatch(String)/ifNoneMatch(String) on RequestConditions.Builder
that parse a raw RFC 7232 header value, giving Java callers a String escape hatch
alongside the typed ETag overloads (which are also now unmangled). Blank/malformed
values are rejected with IllegalArgumentException.
Introduce RequestOptions, an immutable per-request override carrier holding a
timeout, an opaque tag map, and a maxRetries budget. Every field defaults to
null/empty, meaning use-the-pipeline/transport-default, and a shared EMPTY
instance represents override-nothing. Follows the SDK's private-ctor +
Builder<T> + newBuilder() convention with a @JvmStatic builder() and value
equality.

This is the model half of per-request overrides; later commits thread it
through the transport SPIs, the pipeline, and the retry step.
Add a per-call execute(request, options) / executeAsync(request, options)
overload to the HttpClient and AsyncHttpClient SPIs. The overload is a default
method that ignores options and delegates to the single-argument form, so both
SPIs stay single-abstract-method fun interfaces and every existing implementer
(SAM literals, older transports, test fakes) keeps compiling unchanged.

Both reference transports override the overload to apply RequestOptions.timeout
as the per-call timeout: OkHttp sets it on Call.timeout() (overriding the
client's callTimeout for this call only), and the JDK transport resolves it
against the configured responseTimeout and sets it via HttpRequest.timeout. A
null timeout keeps the configured default. The single-argument execute paths now
delegate to the overload with RequestOptions.EMPTY.

Tests cover a short per-call timeout firing on a slow response, a generous
per-call timeout not prematurely failing, and the EMPTY no-override path — sync
and async, on both transports.
Thread the caller's RequestOptions through the sync and async pipelines.
HttpPipeline.send and AsyncHttpPipeline.sendAsync gain a (request, options)
overload; the no-options forms delegate with RequestOptions.EMPTY, and the
HttpClient / AsyncHttpClient per-call SPI overrides route through them so
options survive when a pipeline is used as a transport. The options ride on the
per-call state, are exposed to steps via PipelineNext.options /
AsyncPipelineNext.options, and are threaded into the terminal transport dispatch
so per-call timeouts take effect.

The stage retry steps (DefaultRetryStep and DefaultAsyncRetryStep) now read
RequestOptions.maxRetries for the in-flight call: a non-null value replaces the
configured HttpRetryOptions.maxRetries budget for that call only, so a caller can
fail fast (maxRetries=0) or widen/narrow the budget without a second pipeline; a
null value keeps the configured default.

Tags are carried on the options and readable by steps; no further wiring — the
recovery-package steps are a later unit's concern.

Tests cover options reaching the transport and steps (sync + async, empty and
stepped pipelines) and per-call maxRetries overriding, capping, and falling back
to the configured budget on both retry stacks.
…ecovery to end the naming collision

The recovery-aware primitives in `org.dexpace.sdk.core.pipeline` shared "Pipeline"
and "RetryStep"/"ThrowOnHttpErrorStep" names with the user-facing stage-based dispatch
pipeline in `org.dexpace.sdk.core.http.pipeline`, so it was impossible to tell which
layer a given type belonged to. Give the recovery layer a distinct "Recovery" vocabulary:

- ExecutionPipeline  -> RecoveryChain          (dispatch method execute -> recover)
- RequestPipeline    -> RequestRecoveryChain   (dispatch method execute -> recover)
- ResponsePipeline   -> ResponseRecoveryChain  (fold entry point stays `apply`)
- pipeline.step.retry.RetryStep         -> RetryRecovery
- pipeline.step.ThrowOnHttpErrorStep    -> ThrowOnHttpErrorRecovery

The colliding stage-layer types (http.pipeline.steps.RetryStep /
ThrowOnHttpErrorStep) are unchanged. Step interfaces and the shared retry
utilities (RequestPipelineStep, ResponsePipelineStep, ResponseRecoveryStep,
RetrySettings, BackoffCalculator, RetryAfterParser) keep their names.

ClientIdentityStep previously exposed both the RequestPipelineStep `execute`
override and a public `apply(request)`; collapse to a single public verb
(`execute`), moving the transform to a private helper.

This is a source- and binary-breaking rename (pre-1.0). Docs (pipelines.md,
architecture.md, http.md) and the package maps in README/CLAUDE are updated to
the new names, an orientation note distinguishes the dispatch pipeline from the
recovery layer, and the stale claim that a duplicate pillar step "emits a warning"
is corrected to reflect that a distinct second pillar step throws
IllegalStateException.
… and a minimal GET sample

Add Installation section with Gradle/Maven coordinates. Rewrite Quick start to lead with
HttpPipeline.of + Request.get and show parsedWith(jsonHandler(...)).value() for typed reads.
Add Coordinate column to Modules table. Add How-do-I section with bearer auth, throwOnHttpError,
per-request RequestOptions, pagination, and jsonBody snippets. Rewrite ExampleApp.createUser to
use the ergonomic jsonBody / parsedWith / throwOnError path. Add SimpleGetApp.kt (minimal
plain-HTTP GET sample, ~30 lines) and SimpleGetAppTest smoke test.
A per-call RequestOptions.maxRetries override was applied verbatim by the sync
and async retry steps. A negative value therefore collapsed the retry budget to
zero attempts, which is both surprising and inconsistent with the configured
retry path — where a negative HttpRetryOptions.maxRetries is already clamped to
the default budget.

Treat a negative per-call override the same way: fall back to the configured
budget rather than disabling retries. A caller that genuinely wants no retries
still passes 0. Covers both DefaultRetryStep and DefaultAsyncRetryStep with a
regression test on each stack.
RecoveryChain and its collaborators were renamed to the RequestRecoveryChain /
ResponseRecoveryChain "chain" vocabulary, but the two constructor properties
were left as requestPipeline / responsePipeline, so the getters and KDoc still
spoke of "pipelines" while every referenced type said "chain".

Rename the properties to requestChain / responseChain to match, and update the
KDoc and references. This renames the public getters (getRequestChain /
getResponseChain), a binary-incompatible change.
jsonBody(serde, value) was a thin pass-through to RequestBody.create(value,
serde) with the arguments reversed and a name implying JSON-specific behavior it
did not have — RequestBody.create already defaults the media type to the serde's
content type. The two entry points did the same thing and invited confusion
about which to reach for.

Remove jsonBody and point callers (README, example app) at
RequestBody.create(value, serde). Drops jsonBody from the public API.
…serialize

The Request companion offered only get(url) and post(url, body) static
factories even though the builder exposes all six verbs, and Response.deserialize
could only decode via a Class token or a reified Kotlin type — neither of which
lets a Java caller decode a parametric target such as List<User>.

Add symmetric put/patch (with body) and delete/head (body-less) static factories
mirroring the builder verbs, and a Response.deserialize(serde, TypeRef) overload
that streams the body through the serde's deserializer for parametric decodes,
closing the response afterward exactly like the Class overload.
…-close

On a non-2xx response, jsonHandlerOrThrow threw an HttpException that carried the
live, still-open response body. Handlers are typically run inside response.use {
... }, so as the exception unwound the block closed the response — and any caller
that later read the exception body hit a use-after-close on a released stream.

Route the error path through Response.throwOnError, which buffers a bounded
(<= 1 MiB) copy of the body into the thrown exception. The body now stays
readable after the response is closed. The 2xx path still streams and decodes as
before; KDoc updated to state the error body is buffered.
Several of the new developer-experience conveniences had edge-case bugs or
incomplete threading. This tightens them:

- MediaType's common constants (APPLICATION_JSON, TEXT_PLAIN, ...) are built
  directly via of() instead of delegating to CommonMediaTypes, breaking a
  class-initialization cycle that could leave the constants null depending on
  which class initialized first.
- Request.build() defaults to GET only when no body is present; a body with no
  method now reports the missing method rather than the misleading "GET must
  not carry a request body".
- Response.throwOnError() returns 1xx/3xx responses unchanged with their body
  intact instead of draining the body and throwing IllegalArgumentException;
  only 4xx/5xx map to an HttpException.
- Per-call RequestOptions now thread through every sync<->async bridge
  (asAsync/asBlocking/toAsync/toBlocking) and through the virtual-thread and
  coroutine adapters, which previously dropped them via the one-arg SPI default.
- OkHttp timeouts surface as a retryable NetworkException instead of a thread
  interrupt on both the sync and async paths, matching the JDK transport, and
  no longer set a spurious interrupt flag; a sub-millisecond per-call timeout is
  clamped so it is not truncated to okio's "no timeout"; RequestOptions rejects
  a non-positive timeout at construction.
- interruptibleFuture closes a Response that loses the completion/cancel race
  instead of leaking its connection.
- Io.provider consults the thread-context classloader before the defining one
  so ServiceLoader discovery works under nested classloaders, and warns when an
  explicit install replaces a provider that was already resolved and handed out.
- appendStandardResilience() validates its target pillars up front, so a call
  that collides with an already-configured pillar leaves the builder unchanged,
  and its documentation states the precondition.
- Serde.contentType() is abstract (JacksonSerde declares application/json) so a
  non-JSON serde cannot silently stamp the wrong Content-Type; JacksonSerde.from
  documents the ObjectMapper.copy() subclass requirement.
- RequestBody.create(value, serde) serializes straight to bytes; the sync and
  async retry steps share one max-retries-override helper; error-status checks
  use Status.isError; the JDK transport reuses Futures.unwrap; both transports
  share ProxyOptions.fromEnvironment().

Public API snapshots are regenerated: Serde.contentType() is now abstract and
ProxyOptions gains fromEnvironment().
- ResponseBody.string()/ResponseHandler.string() decode using the charset
  declared in the body's Content-Type, falling back to UTF-8, instead of
  hard-coding UTF-8 (silent mojibake on non-UTF-8 responses).
- ResponseBody.create() close() is now idempotent so a source that throws on
  a second close cannot turn a successful call into a failure.
- Response gains isInformational/isRedirect/isClientError/isServerError/isError
  alongside isSuccessful so callers need not reach into status.*.
- CommonMediaTypes' six overlapping constants delegate to the MediaType
  companion constants, removing duplicate instances.
- ETag.strong()/weak() reject characters outside the RFC 7232 etagc set.
- RequestConditions.build() rejects mixing '*' with concrete entity-tags and
  collapses repeated '*' (RFC 7232 grammar).
- Request builder verbs share one private helper; url(URI) also maps the
  unwrapped IllegalArgumentException from a non-absolute URI to the contextual
  message.
- HttpRange KDoc corrected to describe the grammar the parser actually accepts.
- Io.installProvider and Io.provider are now @JvmStatic so Java callers use
  the documented Io.installProvider(...) / Io.getProvider() spelling directly.
- The provider fast path no longer issues a volatile store on every read once a
  provider is resolved.
- IoProvider gains an 'underlying' hook; the install-after-resolve WARN compares
  through it, so installing a singleton after its ServiceLoader shim was
  auto-resolved no longer logs a spurious mixed-provider warning.
- Clarified the resolution KDoc: the scan repeats until it succeeds, a resolved
  provider is cached process-wide, and hierarchical-classloader deployments with
  distinct per-loader providers should install explicitly.
- HttpRedirectOptions takes Set<Method> (matching its getter and the sibling
  option builders) and makes a single defensive copy per build.
- RequestOptions.timeout KDoc documents the per-transport scope difference and
  that the timeout is per-attempt, not an overall deadline.
- TypeRef rejects a captured unresolved type variable at construction instead
  of silently resolving it to Object (heap pollution / late ClassCastException).
- The reified Deserializer.deserialize and Response.deserialize extensions
  capture T as a TypeRef so a parametric target (List<User>) routes through the
  generic overloads rather than erasing to the raw class.
- Deserializer gains a ByteArray + TypeRef overload for parity with the String
  and InputStream forms.
- throwOnError buffers the error body into a replayable in-memory copy through a
  shared helper, so bodyAs(), string(), and bodySnapshot() can each read it; the
  buffer is acquired inside the body's use block so a provider-resolution failure
  still closes the connection.
- Response.deserialize throws SerdeException (not a raw IllegalStateException)
  for a null body, staying inside the serde exception hierarchy callers catch.
- ResponseExtensions gets @file:JvmName so Java reaches the helpers on a clean
  facade class.
- DefaultRetryStep / DefaultAsyncRetryStep no longer misclassify a
  SocketTimeoutException (a subclass of InterruptedIOException) as cancellation;
  a read timeout is now left to the normal retry classification and retried.
- DefaultRetryStep lets an unchecked exception (e.g. a typed HttpException thrown
  by an inner throwOnHttpError pipeline used as its transport) propagate as-is
  instead of burying it under IOException("HTTP pipeline failure").
- DefaultAsyncRetryStep stops the retry loop and closes any in-flight response
  once the returned future is cancelled or completed, so a cancelled call
  launches no further network attempts.
- RetryRecovery re-classifies each re-sent response: a status in
  retryableStatuses is mapped to the matching HttpException (body buffered into a
  bounded replayable copy, connection released), so a 503,503,200 sequence keeps
  retrying to the 200 and the terminal outcome is a typed exception, not a raw
  error response.
- RetryRecovery treats retryableStatuses as authoritative for an HttpException,
  so a configured status the built-in classifier does not mark retryable (e.g.
  425) is now retried, and the default set is the effective set.
- StagedSteps.reload is now all-or-nothing: it validates the rebuilt pillar
  state in scratch collections and commits only on success, so a pillar
  collision no longer leaves the builder half-reloaded with steps dropped.
- HttpPipelineBuilder/AsyncHttpPipelineBuilder gain explicit flattening
  constructors that take a pipeline (from() delegates to them); the generic
  transport constructor still nests. KDoc spells out flatten vs nest so the
  common Builder(pipeline) call no longer silently nests.
- The duplicated pillar-vacancy check moves onto the shared StagedSteps.
- HttpPipeline/AsyncHttpPipeline document that close() does NOT close the
  underlying transport, so use{}/try-with-resources over a pipeline no longer
  implies the transport was released.
- AsyncHttpPipeline gains sendAsync(request, options, handler).
- AuthStep gates its 401-challenge replay on body replayability: a
  non-replayable body surfaces the real 401 instead of a masked IOException.
- The async standard preset documents that it follows no redirects.
- ThrowOnHttpErrorRecovery reuses the shared bufferErrorBody helper.
- Stage.PRE_REDIRECT documents its outermost terminal-response role.
- Location / Content-Location header values are redacted through UrlRedactor
  (new redactUrlValue helper handling absolute and relative values) before
  logging, so an OAuth code, pre-signed signature, or implicit-flow token in a
  redirect target is no longer written to logs verbatim — matching url.full.
- interruptibleFuture surfaces a RejectedExecutionException from a shut-down or
  saturated executor through the returned future instead of throwing
  synchronously, honouring the async-only failure-delivery contract.
- InterruptibleFuture.cancel(true) hands off the interrupt under a short guard so
  a stale interrupt cannot land on the worker after it returns to the pool.
- HttpClient.execute overloads declare @throws(IOException) (NetworkException
  extends IOException), so a Java transport can throw it and a Java caller can
  catch it at the call site; the @throws moves off asBlocking()'s pure-factory
  onto the wrapper's execute where the blocking wait happens.
- A shared closeQuietly util replaces the duplicated silent-close copy in
  Futures.
- PageNumberExtractor and LinkExtractor (byte-identical SAMs) collapse into one
  ItemsExtractor<T>; CursorExtractor stays distinct.
- Paginator/AsyncPaginator take an optional RequestOptions and pass it to every
  page fetch, so per-call overrides reach pagination when a pipeline drives it.
- CloseablePages owns any page prefetched by a hasNext() probe and closes both
  the current and the prefetched page on close(), so an emptiness probe or a
  peeking wrapper no longer leaks a connection.
- ClientIdentityStep append mode preserves all values of a multi-valued header
  and treats an all-blank token list as a no-op instead of emitting a blank one.
- A serde-encoded multipart part defaults its Content-Type to the serde's media
  type when the caller gives none.
…oding

- sdk-io-okio3 ships a consumer keep rule for OkioIoProviderLoader and its no-arg
  constructor, so the ServiceLoader-based zero-config provider survives R8/ProGuard
  shrinking instead of being tree-shaken away.
- JacksonSerde.deserializeAs(InputStream, TypeReference) drives a per-call parser
  with AUTO_CLOSE_SOURCE disabled, so it no longer closes the caller's stream.
- Every non-null Jackson decode overload throws DeserializationException when the
  body is the JSON literal null instead of returning null through a non-null type.
- JacksonDeserializer overrides deserialize(ByteArray, TypeRef) for parametric parity.
- jsonHandlerOrThrow lands on a clean JsonResponseHandlers Java facade.
- decode() lets a genuine mid-stream IOException propagate unwrapped instead of
  rebranding it as a SerdeException.
- A non-2xx non-error status is reported as a protocol condition (with the status
  code and ETag/Location captured) rather than a deserialization failure.
…ferences

- Rewrite the README cursor-pagination example against the real Paginator and
  CursorPaginationStrategy/CursorExtractor signatures (the previous snippet used
  parameters that do not exist and would not compile).
- Add PRE_REDIRECT to the pipeline-stages diagram and note its outermost,
  final-response-only role.
- Clarify that throwOnHttpError maps 4xx/5xx and passes 1xx/3xx/2xx through.
- Update docs/refs-comparison.md and docs/implementation-plan.md to the current
  recovery-stack type names (RequestRecoveryChain / ResponseRecoveryChain /
  RecoveryChain / RetryRecovery), leaving the *Step interfaces unchanged.
- OkHttp: a call cancelled inside OkHttp (dispatcher/interceptor) completes with a
  non-retryable InterruptedIOException instead of a retryable NetworkException, so
  a deliberately cancelled request is not re-sent; a call-timeout is discriminated
  by exception type and stays retryable.
- OkHttp: an explicit caller Content-Type header now wins over the body's
  auto-stamped media type (previously BridgeInterceptor overwrote it).
- JDK transport: emits Content-Type from the body's media type when the caller
  set none, so the RequestBody.create(value, serde) pattern carries a Content-Type
  on both transports; documents the residual bodyless-DELETE Content-Length: 0
  difference the java.net.http API cannot avoid.
- Documented the OkHttp vs JDK default response-timeout divergence and the JDK
  HTTPS-CONNECT Basic-proxy-auth limitation (with the workaround).
- Coroutines/reactor/netty adapters gain per-call RequestOptions overloads so
  options are no longer dropped; asAsyncCoroutines closes a Response discarded in
  the cancellation race; corrected the coroutines and virtual-threads KDocs.
- apiDump for the accumulated sdk-core / sdk-io-okio3 public-API changes.
- Update two RetryStepTest cases that asserted the old IOException-wrapping to
  expect the unchecked exception propagating as-is.
- ktlint formatting and detekt suppressions (ReturnCount on the auth/async-retry
  early-exit paths; IteratorHasNextCallsNextMethod on the deliberate page
  prefetch) for the new code.
Cover the trickiest corrected behaviors so a future change cannot silently
reintroduce them: RetryRecovery re-classifying re-sent responses (503,503,200
reaches 200; exhaustion yields the mapped exception; a configured retryable
status widens the set); TypeRef rejecting a captured type variable; the Response
status-range predicates; charset-aware ResponseBody.string(); the replayable
throwOnError error body; SocketTimeoutException being retried rather than
treated as cancellation; no spurious mixed-provider warning; atomic
StagedSteps.reload and the flattening HttpPipelineBuilder(pipeline) constructor;
the auth-challenge non-replayable-body path; Jackson rejecting a literal-null
body and not closing a caller stream; and UrlRedactor.redactUrlValue.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant