Sending web requests
Use the web gateway to call an HTTP API and read its response. Requests share Fluxzero’s tracing, audit and testing facilities. A named command or query keeps the external operation recognizable to the rest of your application.
For the complete application pattern, including tests and work after a Model commit, see Building API integrations.
Give each external interaction a local command or query
Section titled “Give each external interaction a local command or query”Use a named command for an external action and a typed query (Request<T>) for an external read. Put the actual
WebRequestGateway call in the payload’s @HandleCommand or @HandleQuery method. Other handlers invoke the
operation through Fluxzero.sendCommandAndWait(...) or Fluxzero.queryAndWait(...), just like other application
behavior. Choose command versus query by the operation’s meaning, not only its HTTP verb.
The handler below runs locally without registration. The example partner API returns an order as JSON with HTTP 200.
PartnerOrder is the response value; partner.api.token (PARTNER_API_TOKEN) supplies the credential.
public record GetPartnerOrder(@NotNull UUID orderId) implements Request<PartnerOrder> { @HandleQuery PartnerOrder handle() { var request = WebRequest.get("https://partner.example/api/orders/" + orderId) .header("Authorization", "Bearer " + ApplicationProperties.requireProperty("partner.api.token")) .build(); var response = Fluxzero.sendWebRequestAndWait(request); if (response.getStatus() != 200) { throw new IllegalStateException("Order lookup returned HTTP " + response.getStatus()); } return response.getPayloadAs(PartnerOrder.class); }}
public record PartnerOrder(UUID orderId, String status) {}data class GetPartnerOrder(@field:NotNull val orderId: UUID) : Request<PartnerOrder> { @HandleQuery fun handle(): PartnerOrder { val request = WebRequest.get("https://partner.example/api/orders/$orderId") .header("Authorization", "Bearer " + ApplicationProperties.requireProperty("partner.api.token")) .build() val response = Fluxzero.sendWebRequestAndWait(request) check(response.status == 200) { "Order lookup returned HTTP ${response.status}" } return response.getPayloadAs(PartnerOrder::class.java) }}
data class PartnerOrder(val orderId: UUID, val status: String)Call it from application code in an active Fluxzero context:
PartnerOrder existing = Fluxzero.queryAndWait(new GetPartnerOrder(orderId));val existing: PartnerOrder = Fluxzero.queryAndWait(GetPartnerOrder(orderId))Shared URL construction, headers, settings and response mapping may live in a small helper, interface or base class.
Keep each operation recognizable as its own command/query; do not replace them with a generic HTTP-command envelope
or require callers to inject an API service. Resolve configuration through ApplicationProperties at this integration
boundary. If settings need parsing, a typed settings value can be loaded here without making it a bean. The fixed
example URLs stand in for a configured, validated endpoint; validate configured base URLs before appending operation paths.
A local command/query and its outgoing HTTP request have separate delivery rules. Local self-handling does not
select native HTTP: the nested WebRequest still uses the auditable proxy route and configured transport retries.
The local command/query itself is not a persisted job and has no independent tracker retry. Add @TrackSelf only
when that operation itself must arrive through the Runtime, have a durable consumer position, or be replayed/retried
independently. Add @Consumer only for a required tracking configuration. Neither is needed merely to call an API.
For an external write that depends on committed Model state, a registered post-commit event handler can dispatch the
local command and wait for its outcome. Let failures reach that caller’s error/retry policy. Local dispatch does not
make HTTP part of the Model transaction: do not perform external I/O in @Apply or send the write from a mutation
handler while its changes are still pending. Read-only external queries may run from ordinary query/orchestration
handlers; their results are not replayable Model state until explicitly recorded.
Reading response bodies
Section titled “Reading response bodies”Use response.getPayloadAs(MyReply.class) in Java or response.getPayloadAs(MyReply::class.java) in Kotlin to decode
a typed response. The SDK handles gzip decoding on the message-gateway route. If you consume the body as a stream,
read it only once. See WebResponse for body conversion and stream ownership.
Credentials and visible audit logs
Section titled “Credentials and visible audit logs”Fluxzero Auditlog automatically replaces standard credential-header values such as Authorization,
Proxy-Authorization, X-Api-Key, Cookie and Set-Cookie with <value scrambled> in visible web request/response
records. Matching is case-insensitive, and downloads through Auditlog apply the same filtering. Requests remain
auditable without exposing these bearer tokens or API keys in the visible logs. Using such a header is not a reason
to replace the gateway or opt into native execution.
The example resolves the credential from partner.api.token through ApplicationProperties; its conventional
environment variable is PARTNER_API_TOKEN. Keep it in runtime configuration instead of source code.
This is Auditlog projection/response masking: the original credential still reaches the HTTP transport. It does not promise removal of secrets from arbitrary URLs, bodies, custom fields or application-written logs.
Asynchronous and fire-and-forget
Section titled “Asynchronous and fire-and-forget”You can send requests asynchronously:
Fluxzero.get().webRequestGateway() .send(request) .thenAccept(response -> log.info("Received: {}", response.getBodyString()));Fluxzero.get().webRequestGateway() .send(request) .thenAccept { response -> log.info("Received: {}", response.bodyString) }Or send them fire-and-forget:
Fluxzero.get().webRequestGateway() .sendAndForget(Guarantee.STORED, request);Fluxzero.get().webRequestGateway() .sendAndForget(Guarantee.STORED, request)Relative vs absolute URLs
Section titled “Relative vs absolute URLs”Flux distinguishes between relative and absolute URLs:
- Absolute URLs (e.g.,
https://api.example.com/...) → Sent via the Flux Web Proxy by default; explicit native settings execute directly from the application - Relative URLs (e.g.,
/internal/task) → Routed to local handlers in other connected Flux apps
This enables request-response flows across distributed services without coupling.
Timeouts, retries and an explicit native alternative
Section titled “Timeouts, retries and an explicit native alternative”Waiting for the response is independent of transport: sendAndWait blocks the caller, while send returns a future.
In the local command/query handler, pass WebRequestSettings to either method (or to
Fluxzero.sendWebRequestAndWait) to configure the request:
WebRequestSettings settings = WebRequestSettings.builder() .timeout(Duration.ofSeconds(5)) .maxRetries(2) .retryDelay(Duration.ofMillis(250)) .retryableStatusCodes(Set.of(502, 503, 504)) .build();WebResponse response = Fluxzero.sendWebRequestAndWait(request, settings);val settings = WebRequestSettings.builder() .timeout(Duration.ofSeconds(5)) .maxRetries(2) .retryDelay(Duration.ofMillis(250)) .retryableStatusCodes(setOf(502, 503, 504)) .build()val response = Fluxzero.sendWebRequestAndWait(request, settings)maxRetries counts additional attempts and defaults to zero. Transport failures and selected statuses can trigger
retries; all attempts and retryDelay share the overall timeout. Inspect the returned status before decoding the
body. Retry writes only when their semantics or an external idempotency contract make repetition safe. Domain polling
and reconciliation are separate from these transport retries.
Buffered request bodies retain their serialized byte length in both the proxy and native transports. For HTTP/1.1,
the client supplies Content-Length automatically; do not add it manually. An endpoint that requires a known body
length can use the ordinary proxy route, including its WebRequest/WebResponse audit trail.
Use .useNativeHttpClient(true) only for a deliberate requirement to execute an absolute HTTP(S) request directly
from the application. It still uses the SDK gateway and its timeout/retry configuration, but bypasses WebRequest and
WebResponse message logging, local web handlers, dispatch interceptors and consumer isolation. This trades away the
normal message audit route and is not necessary merely because a request contains authentication headers.
TestFixture still handles native-configured requests through registered remote stubs and preserves retry counts and
retryable statuses without real retry delays. This preserves the gateway test boundary; it does not qualify real
network or proxy behavior.
Isolating traffic with consumer
Section titled “Isolating traffic with consumer”The consumer field in WebRequestSettings configures outbound HTTP processing on the proxy route. It is separate
from @Consumer on a command/query handler and does not require making the calling self-handler tracked. Use it when
you need to isolate HTTP traffic:
WebRequestSettings settings = WebRequestSettings.builder() .consumer("external-api-xyz") .timeout(Duration.ofSeconds(5)) .build();val settings = WebRequestSettings.builder() .consumer("external-api-xyz") .timeout(Duration.ofSeconds(5)) .build()On the default proxy route, this allows you to:
- Isolate third-party API traffic (e.g., apply rate limits)
- Use different retry or error-handling strategies
- Segment outbound traffic by destination
Mocking external endpoints in tests
Section titled “Mocking external endpoints in tests”Register a handler for the external URL in the fixture. Then dispatch the actual query: its own handler builds the request and decodes the stubbed response. This checks your integration boundary without a network call.
@Consumer(name = "partner-api-stub")static class PartnerApiStub { @HandleGet("https://partner.example/api/orders/{orderId}") WebResponse getOrder(@PathParam("orderId") UUID orderId) { return WebResponse.builder().status(200).contentType("application/json") .payload(Map.of("orderId", orderId, "status", "accepted")) .build(); }}
@Testvoid readPartnerOrder() { var orderId = UUID.fromString("00000000-0000-0000-0000-000000000001"); TestFixture.create(new PartnerApiStub()) .withProperty("partner.api.token", "test-token") .whenQuery(new GetPartnerOrder(orderId)) .expectResult(new PartnerOrder(orderId, "accepted")) .expectOnlyWebRequests(WebRequest.get("https://partner.example/api/orders/" + orderId) .header("Authorization", "Bearer test-token").build());}@Consumer(name = "partner-api-stub")class PartnerApiStub { @HandleGet("https://partner.example/api/orders/{orderId}") fun getOrder(@PathParam("orderId") orderId: UUID): WebResponse = WebResponse.builder().status(200).contentType("application/json") .payload(mapOf("orderId" to orderId, "status" to "accepted")) .build()}
@Testfun readPartnerOrder() { val orderId = UUID.fromString("00000000-0000-0000-0000-000000000001") TestFixture.create(PartnerApiStub()) .withProperty("partner.api.token", "test-token") .whenQuery(GetPartnerOrder(orderId)) .expectResult(PartnerOrder(orderId, "accepted")) .expectOnlyWebRequests(WebRequest.get("https://partner.example/api/orders/$orderId") .header("Authorization", "Bearer test-token").build())}The stub’s @Consumer gives the simulated remote endpoint independent tracked processing, including in asynchronous
fixtures where the caller waits for its response. It belongs to the test endpoint, not to the production local
command/query. Use TestFixture.createAsync(new PartnerApiStub()) in Java or
TestFixture.createAsync(PartnerApiStub()) in Kotlin when the scenario needs asynchronous tracking. Local
self-handlers still execute locally in that fixture.
This checks the application’s command/query behavior, request construction, URL routing and response mapping without real network access. It does not prove DNS, TLS, proxy behavior or the third party’s deployed contract. Add cases for non-2xx responses, malformed payloads and relevant timeout/retry outcomes. Preserve unexpected technical failures unless the application’s contract explicitly maps them to an expected business outcome.
Summary
Section titled “Summary”- ✅ Give each external interaction a local command/query with its
WebRequestGatewaycall in the handler - ✅ Default routing follows the URL (relative → internal, absolute → proxy); native execution is explicit
- ✅ Default proxy traffic is auditable, with standard credentials masked in visible Auditlog records
- ✅ Supports retries, timeouts, and consumer-based isolation
- ✅ Easily mock remote endpoints in tests
Isolate outbound failures without changing existing configurations
Section titled “Isolate outbound failures without changing existing configurations”The additional resilience controls are opt-in on both supported SDK lines. Existing applications retain
retryPolicy(LEGACY), fixed retry delays and no per-origin concurrency limit; no defaults-version threshold changes
these settings.
Use retryPolicy(RetryPolicy.SAFE) for configured retries of GET, HEAD, PUT, DELETE, OPTIONS and TRACE, or of a
request with exactly one nonblank Idempotency-Key header. For POST/PATCH, use a stable key supported by the upstream’s
deduplication contract. The SDK reuses the same serialized body and headers across attempts; the presence of a key
alone cannot guarantee that an upstream deduplicates it. RetryPolicy.IDEMPOTENT is an explicit application guarantee
for operations with proven idempotent semantics. LEGACY preserves the previous method-independent retry behavior.
None of these choices enables retries unless maxRetries is positive.
WebRequestSettings settings = WebRequestSettings.builder() .timeout(Duration.ofSeconds(5)).maxRetries(2) .retryPolicy(RetryPolicy.SAFE) .retryDelay(Duration.ofMillis(250)).retryJitter(true) .build();val settings = WebRequestSettings.builder() .timeout(Duration.ofSeconds(5)).maxRetries(2) .retryPolicy(RetryPolicy.SAFE) .retryDelay(Duration.ofMillis(250)).retryJitter(true) .build()With jitter enabled, each retry waits a randomized duration between half and all of retryDelay. All attempts,
redirects and retry waits still consume the same timeout budget; retries do not reset it. Fixture retries enforce
the same safety policy without actual retry waits. A new SDK publishes maxRetries=0 for disallowed SAFE retries,
so an older proxy also refrains from retrying them. Jitter requires an updated proxy on the proxy route.
Configure concurrency limits through the normal property infrastructure:
| Transport owner | Property | Environment variable | Default |
|---|---|---|---|
| Proxy process | fluxzero.proxy.forward.maxConcurrentPerOrigin | FLUXZERO_PROXY_FORWARD_MAX_CONCURRENT_PER_ORIGIN | 0 (disabled) |
| Native application client | fluxzero.web.native.maxConcurrentPerOrigin | FLUXZERO_WEB_NATIVE_MAX_CONCURRENT_PER_ORIGIN | 0 (disabled) |
A positive value limits outstanding executions per original HTTP origin: lowercase scheme and hostname plus the effective port, without DNS resolution. Paths, credentials and query strings do not create separate origins. Limits are local to the owning client/process, not cluster-wide. Namespace views of one native client share its limit. Idle origins are removed from bookkeeping; no permanent hostname cache or additional unbounded queue is kept.
The proxy admits requests within its existing bounded queue and selects eligible segment heads before reserving a global execution slot. Set the per-origin limit below the existing global concurrency limit to leave execution capacity for other origins. Requests in one segment remain ordered, including response publication. The global outstanding bound and backpressure remain in force: a completely full queue or a preceding request in the same segment can still delay another origin. The limit is held across retries and response publication and is released on completion or shutdown handoff. Waiting in the proxy queue consumes the existing request deadline.
Native execution has no added waiting queue: exceeding its limit returns HTTP 503 immediately, without an HTTP attempt or automatic transport retry of that local response. The permit is held across retries and released on completion, failure or cancellation; closing the client cancels its limited in-flight requests and retry waits.
Redirects retain the original request’s permit and existing redirect policy. Multiple original origins redirecting
to the same destination are not a shared destination bulkhead. Use SAME_ORIGIN or NEVER when that distinction
matters. These local limits also do not deduplicate replayed requests or replace domain idempotency.
© 2026 Fluxzero