Skip to content

[http-client] Add client filter scopes#1662

Open
DamianReeves wants to merge 4 commits into
getkyo:mainfrom
DamianReeves:codex/http-client-filters
Open

[http-client] Add client filter scopes#1662
DamianReeves wants to merge 4 commits into
getkyo:mainfrom
DamianReeves:codex/http-client-filters

Conversation

@DamianReeves

@DamianReeves DamianReeves commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR adds programmatic and scoped client filter configuration for kyo-http, building on the existing HttpFilter model and the existing HttpFilter.Factory SPI.

The goal is to make cross-cutting client concerns first-class without requiring every typed route or convenience call to repeat the same filter setup. Supported scenarios include auth headers, request IDs, logging, tracing, metrics, and other request/response middleware that should apply consistently across outgoing HTTP requests.

Closes #1660

What changed

  • Adds clientFilter to HttpClientConfig, defaulting to HttpFilter.noop.
  • Adds HttpClientConfig.filter(...) and HttpClientConfig.filters(...) builder methods that append filters in order.
  • Adds HttpClient.withFilter(...) and HttpClient.withFilters(...) for dynamic, scoped filter configuration.
  • Composes client filters in this order:
    1. HttpFilter.Factory ServiceLoader filters
    2. HttpClientConfig filters
    3. scoped HttpClient.withFilter filters
    4. typed route filters
  • Applies the same config and scoped client filters to WebSocket HTTP upgrade handshakes.
  • Preserves WebSocket query strings in the client upgrade path and server dispatch path.
  • Prevents user/filter-provided headers from duplicating required WebSocket upgrade headers such as Host, Upgrade, Connection, Sec-WebSocket-Version, and Sec-WebSocket-Key.
  • Updates the kyo-http README with the new client filter configuration options and composition order.

Why

kyo-http already has a solid filter abstraction and an SPI path through HttpFilter.Factory, but the client side had two important gaps:

  • Programmatic factory-level configuration was awkward for application code that wants to construct one client config and pass it through a scope.
  • Reusable client policy could be attached to typed routes, but not consistently applied to all convenience calls and WebSocket handshakes from a configured scope.

This PR keeps the existing route-level filter support, keeps SPI support for library-provided filters such as tracing, and adds config plus scoped layers for application-controlled middleware.

Behavior notes

  • Filters configured in HttpClientConfig apply to outgoing HTTP requests and WebSocket upgrade handshakes.
  • Scoped filters apply only inside the HttpClient.withFilter or HttpClient.withFilters block.
  • Route filters remain the most local layer and run after SPI, config, and scoped filters.
  • WebSocket filters apply to the HTTP upgrade request only. They do not intercept WebSocket messages after the protocol upgrade.
  • HttpClientConfig remains a case class. Its equality now includes the clientFilter field by reference, similar to the existing function-valued retryOn field.

Pros

  • Centralizes client auth, logging, tracing, and metrics setup.
  • Lets applications choose SPI, config, scoped, or route-level filter installation based on ownership and lifetime.
  • Reuses the existing HttpFilter.Passthrough abstraction instead of introducing a separate middleware type.
  • Keeps the no-filter fast path in the backend.
  • Makes HTTP and WebSocket handshake configuration consistent.

Tradeoffs

  • HttpClientConfig equality now includes the filter reference, so two configs with behaviorally equivalent but separately allocated filters will not compare equal.
  • The WebSocket path has to bridge the HTTP filter abstraction around the upgrade handshake, which is narrower than full message-level WebSocket middleware.
  • Filters that try to override required WebSocket upgrade headers are ignored for those specific headers to keep the handshake valid.

Validation

  • sbt 'kyo-http/doctest': passed, 64 doctest blocks, 0 failures
  • sbt 'kyo-http/test': passed, 2,184 tests, 0 failed, 30 pending
  • git diff --check: passed

Related issue

@DamianReeves DamianReeves marked this pull request as ready for review June 5, 2026 15:48
@DamianReeves DamianReeves changed the title [codex] Add client filter scopes [http-client] Add client filter scopes Jun 5, 2026
Comment thread kyo-http/shared/src/main/scala/kyo/internal/client/HttpClientBackend.scala Outdated
Comment thread kyo-http/shared/src/main/scala/kyo/internal/client/HttpClientBackend.scala Outdated
HttpResponse(HttpStatus.SwitchingProtocols).addField("body", result)
}
}.asInstanceOf[HttpResponse["body" ~ A] < (Async & Abort[HttpException | HttpResponse.Halt])]
).map(_.fields.body).asInstanceOf[A < (S & Async & Abort[HttpException])]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are these casts necessary? are they safe?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Necessary given the current HttpFilter.Passthrough shape, and safe. The continuation type is fixed: next: HttpRequest[In] => HttpResponse[Out] < (Async & Abort[E2 | Halt]). It has no slot for the WebSocket user-handler effect row S (the WS callback is f: HttpWebSocket => A < S with arbitrary S), so to thread the session through a filter we erase S to match the continuation type and restore it after. I pulled that into two named helpers (eraseWebSocketUserEffects / handleWebSocketFilterResult) with comments so the intent is explicit.

Why it is safe: filters only forward the continuation result; they never inspect or handle S, and the suspended S effects are preserved unchanged at the runtime level across the erase/restore. This mirrors the existing non-WS pattern in poolWith (from #1518), which does the same erase-into-continuation + restore-result cast. The WS path is actually stricter on Halt: instead of casting Halt away like the non-WS path, it runs Abort.run[Halt] and converts a filter Halt into an HttpStatusException.

One residual assumption worth naming: this is sound as long as S is disjoint from what filters handle (Async, Abort[Halt]). The only realistic collision would be a user S containing Abort[HttpResponse.Halt], which is an http-internal type and not something a WS handler row would carry.

If you would prefer the casts gone entirely, the clean route is to scope the filter to the upgrade handshake only and serve the session outside the filter (where S flows naturally). That needs splitting WebSocketCodec.requestUpgradeWith so the handshake and the f(wsStream) session are no longer in one resource bracket. Happy to do that as a follow-up if you want it.

@fwbrasil fwbrasil Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's safe. Can you introduce tests that use effects like Var in the provided function? It seems we need an S type param in HttpFilter.apply or restrict the provided function to not have free S effects

Comment thread kyo-http/shared/src/main/scala/kyo/internal/server/UnsafeServerDispatch.scala Outdated
Comment thread kyo-http/shared/src/main/scala/kyo/HttpClient.scala Outdated

/** Applies multiple client filters for all `HttpClient` calls within the given computation. */
def withFilters[A, S](filters: Seq[HttpFilter.Passthrough[Nothing]])(v: A < S)(using Frame): A < S =
withFilter(filters.foldLeft(HttpFilter.noop: HttpFilter.Passthrough[Nothing])(_.andThen(_)))(v)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reasoning about the filters running in a computation becomes more difficult with the new features. Can we have methods to inspect the current enabled filters + to disable them in nested scopes?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added both. Inspect: HttpClient.useConfig(config => ...) and HttpClient.useFilter(filter => ...) expose the active config and composed client filter. Disable in nested scope: HttpClient.withoutFilters { ... } (backed by HttpClientConfig.clearFilters) clears the config/scoped client filters for that computation while leaving ServiceLoader and route filters in place. Tests cover the nested-disable + outer-scope-restore case.

One limitation to flag: useFilter/withoutFilters only see and clear the programmatic config.clientFilter; the ServiceLoader-discovered filters (Factory.composedClient) are not included, so a caller cannot inspect or fully disable those. Happy to widen the scope if you want those covered too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Followed up on the ServiceLoader-filter limitation I flagged above with a read-only blast-radius exploration. Sketch of how we could cover the auto-discovered filters too, if you want it:

Proposal (additive, non-breaking): add disableAutoFilters: Boolean = false to HttpClientConfig, plus a dedicated HttpClient.useAutoFilter accessor (exposes the composed Factory.composedClient) and a HttpClient.withoutAutoFilters { ... } scope. Backend composition becomes (if config.disableAutoFilters then noop else Factory.composedClient).andThen(config.clientFilter) at the two client sites (poolWith, runWsSessionWith). This keeps Factory.composedClient as a memoized lazy val and preserves the eq noop fast path exactly.

Why not just fold the auto filters into config.clientFilter: it would force ServiceLoader discovery at every HttpClientConfig() construction (including the Local.init default and every doctest), defeat the lazy-val memoization, and break the no-filter hot path.

Footgun to avoid: I would NOT make the existing withoutFilters clear auto filters. Today the only auto client filter is OTLP W3C trace-context propagation; having withoutFilters silently drop it would break distributed traces with no compile error. Hence a separate, explicit withoutAutoFilters rather than overloading withoutFilters.

Open question for you: scope. Do you want server-side parity in the same change? The server path composes composedServer.andThen(route.filter) in HttpHandler, which would have the same inspect/disable gap. Happy to do client-only now and server as a follow-up, or both together. Size is roughly M client-only (field + conditional + accessor + scope + tests + README/scaladoc), M/L if server parity is included.

Can leave the current limitation as-is for this PR and land the auto-filter support separately, or fold it in here. Your call.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the proposal to handle auto filters separately. We should also document the terminology and behavior. About parity, it's always ideal.

@DamianReeves DamianReeves force-pushed the codex/http-client-filters branch from 8fd0e67 to e8194f6 Compare June 7, 2026 09:07
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.

Add scoped and config-driven HttpClient middleware

2 participants