diff --git a/.github/workflows/compat-weekly.yml b/.github/workflows/compat-weekly.yml
index 2735505..261462a 100644
--- a/.github/workflows/compat-weekly.yml
+++ b/.github/workflows/compat-weekly.yml
@@ -4,6 +4,7 @@ on:
schedule:
- cron: '0 6 * * 1' # Mondays at 06:00 UTC
workflow_dispatch: {} # manual trigger button
+ workflow_call: {} # callable from prepare-release.yml
jobs:
fetch-latest:
diff --git a/.github/workflows/deploy-snapshot.yml b/.github/workflows/deploy-snapshot.yml
index d9233c9..f6d0ba9 100644
--- a/.github/workflows/deploy-snapshot.yml
+++ b/.github/workflows/deploy-snapshot.yml
@@ -12,13 +12,40 @@ concurrency:
cancel-in-progress: false
jobs:
+ preflight:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ outputs:
+ is-snapshot: ${{ steps.check.outputs.is-snapshot }}
+ version: ${{ steps.check.outputs.version }}
+ steps:
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+
+ - id: check
+ name: Check pom version is -SNAPSHOT
+ run: |
+ set -euo pipefail
+ VERSION=$(grep -m1 -oE '[^<]+' pom.xml | sed -E 's|?version>||g')
+ echo "Detected project version: $VERSION"
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+ if [[ "$VERSION" == *-SNAPSHOT ]]; then
+ echo "is-snapshot=true" >> "$GITHUB_OUTPUT"
+ echo "::notice::Will deploy SNAPSHOT $VERSION to Nexus"
+ else
+ echo "is-snapshot=false" >> "$GITHUB_OUTPUT"
+ echo "::notice::Skipping snapshot deploy — version $VERSION is not a SNAPSHOT (release-prep commit)"
+ fi
+
test:
- if: "!contains(github.event.head_commit.message, 'Release v')"
+ needs: preflight
+ if: needs.preflight.outputs.is-snapshot == 'true'
uses: ./.github/workflows/test.yml
secrets: inherit
deploy:
- needs: test
+ needs: [preflight, test]
+ if: needs.preflight.outputs.is-snapshot == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
@@ -37,4 +64,4 @@ jobs:
env:
NEXUS_SNAPSHOTS_URL: ${{ secrets.NEXUS_SNAPSHOTS_URL }}
NEXUS_USERNAME: ${{ secrets.NEXUS_USERNAME }}
- NEXUS_PASSWORD: ${{ secrets.NEXUS_PASSWORD }}
\ No newline at end of file
+ NEXUS_PASSWORD: ${{ secrets.NEXUS_PASSWORD }}
diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml
index 11657ca..d706284 100644
--- a/.github/workflows/prepare-release.yml
+++ b/.github/workflows/prepare-release.yml
@@ -56,8 +56,14 @@ jobs:
uses: ./.github/workflows/test.yml
secrets: inherit
+ compat:
+ needs: preflight
+ if: needs.preflight.outputs.skip != 'true'
+ uses: ./.github/workflows/compat-weekly.yml
+ secrets: inherit
+
create-release-pr:
- needs: [preflight, test]
+ needs: [preflight, test, compat]
if: needs.preflight.outputs.skip != 'true'
runs-on: ubuntu-latest
permissions:
diff --git a/.gitignore b/.gitignore
index c20e796..84a991e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,3 +39,5 @@ build/
CLAUDE.md
.flattened-pom.xml
+
+plans
diff --git a/README.md b/README.md
index b2e306d..08dcf1e 100644
--- a/README.md
+++ b/README.md
@@ -194,6 +194,13 @@ The chain composes **outside-in** — the last `.with(...)` is the outermost wra
- Trust-all SSL for development (with runtime warning)
- Request/response logging with sensitive-header and credential-body redaction (`NONE`, `BASIC`, `HEADERS`, `BODY`)
- Typed exception hierarchy (400-504 mapped to specific exceptions)
+- **Permissive error handling** — opt out of throw-on-4xx/5xx per request
+ (`.noThrow()`) or at the client level (`throwOnError(false)`). Useful
+ when 4xx is business semantics (e.g. 404 = not found, not an error).
+- **Raw response access** — `.raw()` on every `*ClientResponse`, or declare
+ `RawResponse` as a proxy method return type. Bypasses deserialization
+ and auto-disables throw-on-error — useful for inspecting error bodies
+ or non-JSON responses.
- Per-request timeout support
- HTTP/2 by default
- Spring Boot (sync + async + WebFlux) and Quarkus integration
diff --git a/core/ark-core/src/main/java/xyz/juandiii/ark/core/AbstractArkBuilder.java b/core/ark-core/src/main/java/xyz/juandiii/ark/core/AbstractArkBuilder.java
index 2723b45..e9429ee 100644
--- a/core/ark-core/src/main/java/xyz/juandiii/ark/core/AbstractArkBuilder.java
+++ b/core/ark-core/src/main/java/xyz/juandiii/ark/core/AbstractArkBuilder.java
@@ -30,6 +30,7 @@ public abstract class AbstractArkBuilder> {
protected HttpVersion httpVersion;
protected int connectTimeoutSecs = -1;
protected int readTimeoutSecs = -1;
+ protected boolean throwOnErrorDefault = true;
protected final List requestInterceptors = new ArrayList<>();
protected final List responseInterceptors = new ArrayList<>();
@@ -126,6 +127,20 @@ public B responseInterceptor(ResponseInterceptor interceptor) {
return self();
}
+ /**
+ * Set the client-level default for HTTP error behavior. When {@code true}
+ * (the default), HTTP 4xx/5xx responses raise {@code ApiException}. When
+ * {@code false}, the response is returned unchanged regardless of status.
+ * Individual requests may still opt out via {@code request.noThrow()}.
+ *
+ * @param throwOnError {@code true} (default) to throw on HTTP error status, {@code false} to return the response
+ * @return this builder for chaining
+ */
+ public B throwOnError(boolean throwOnError) {
+ this.throwOnErrorDefault = throwOnError;
+ return self();
+ }
+
protected B self() {
return (B) this;
}
diff --git a/core/ark-core/src/main/java/xyz/juandiii/ark/core/AbstractArkClient.java b/core/ark-core/src/main/java/xyz/juandiii/ark/core/AbstractArkClient.java
index 5dcbef2..3931826 100644
--- a/core/ark-core/src/main/java/xyz/juandiii/ark/core/AbstractArkClient.java
+++ b/core/ark-core/src/main/java/xyz/juandiii/ark/core/AbstractArkClient.java
@@ -18,15 +18,18 @@ public abstract class AbstractArkClient> {
protected final String baseUrl;
protected final List requestInterceptors;
protected final List responseInterceptors;
+ protected final boolean throwOnErrorDefault;
protected AbstractArkClient(JsonSerializer serializer, String userAgent, String baseUrl,
List requestInterceptors,
- List responseInterceptors) {
+ List responseInterceptors,
+ boolean throwOnErrorDefault) {
this.serializer = serializer;
this.userAgent = userAgent;
this.baseUrl = baseUrl;
this.requestInterceptors = List.copyOf(requestInterceptors);
this.responseInterceptors = List.copyOf(responseInterceptors);
+ this.throwOnErrorDefault = throwOnErrorDefault;
}
protected abstract R createRequest(String method, String path);
diff --git a/core/ark-core/src/main/java/xyz/juandiii/ark/core/ArkClient.java b/core/ark-core/src/main/java/xyz/juandiii/ark/core/ArkClient.java
index b078963..08d9406 100644
--- a/core/ark-core/src/main/java/xyz/juandiii/ark/core/ArkClient.java
+++ b/core/ark-core/src/main/java/xyz/juandiii/ark/core/ArkClient.java
@@ -28,15 +28,18 @@ public class ArkClient extends AbstractArkClient implement
private ArkClient(Transport transport, JsonSerializer serializer, String userAgent,
String baseUrl,
List requestInterceptors,
- List responseInterceptors) {
- super(serializer, userAgent, baseUrl, requestInterceptors, responseInterceptors);
+ List responseInterceptors,
+ boolean throwOnErrorDefault) {
+ super(serializer, userAgent, baseUrl, requestInterceptors, responseInterceptors,
+ throwOnErrorDefault);
this.transport = transport;
}
@Override
protected DefaultClientRequest createRequest(String method, String path) {
- return new DefaultClientRequest(method, baseUrl, path, transport, serializer,
- requestInterceptors, responseInterceptors)
+ DefaultClientRequest req = new DefaultClientRequest(method, baseUrl, path, transport, serializer,
+ requestInterceptors, responseInterceptors);
+ return req.throwOnError(throwOnErrorDefault)
.header("User-Agent", userAgent);
}
@@ -85,7 +88,8 @@ public Ark build() {
Objects.requireNonNull(transport, "transport must not be null");
logConfiguration("ArkClient (sync)", transport.getClass().getSimpleName());
return new ArkClient(transport, serializer, buildUserAgent(),
- baseUrl, requestInterceptors, responseInterceptors);
+ baseUrl, requestInterceptors, responseInterceptors,
+ throwOnErrorDefault);
}
}
}
diff --git a/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/AbstractClientRequest.java b/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/AbstractClientRequest.java
index babdc88..1d47219 100644
--- a/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/AbstractClientRequest.java
+++ b/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/AbstractClientRequest.java
@@ -40,6 +40,7 @@ public abstract class AbstractClientRequest>
protected final JsonSerializer serializer;
protected final List requestInterceptors;
protected final List responseInterceptors;
+ private boolean throwOnError = true;
protected AbstractClientRequest(String method, String baseUrl, String path,
JsonSerializer serializer,
@@ -126,6 +127,35 @@ public T timeout(Duration timeout) {
return self();
}
+ /**
+ * Opt out of throwing {@link xyz.juandiii.ark.core.exceptions.ApiException}
+ * on HTTP error status codes (4xx/5xx). When called, the response is
+ * returned to the caller unchanged regardless of status. Use
+ * {@link RawResponse#isError()} or
+ * {@code clientResponse.toEntity(...).isSuccessful()} to branch on outcome.
+ *
+ * @return this request for chaining
+ */
+ @SuppressWarnings("unchecked")
+ public T noThrow() {
+ this.throwOnError = false;
+ return (T) this;
+ }
+
+ /**
+ * Programmatic setter used by {@code AbstractArkClient} to apply the
+ * client-level {@code throwOnError} default to a freshly created request.
+ * Prefer the fluent {@link #noThrow()} on the request itself.
+ *
+ * @param throwOnError {@code true} to throw on HTTP error status (default), {@code false} to return the response
+ * @return this request for chaining
+ */
+ @SuppressWarnings("unchecked")
+ public T throwOnError(boolean throwOnError) {
+ this.throwOnError = throwOnError;
+ return (T) this;
+ }
+
protected void applyInterceptors() {
requestInterceptors.forEach(interceptor -> interceptor.intercept(this));
}
@@ -148,6 +178,7 @@ protected SerializedBody prepareBody() {
}
protected void validateResponse(RawResponse raw) {
+ if (!throwOnError) return;
if (raw.isError()) {
throw ApiException.of(raw.statusCode(), raw.body());
}
diff --git a/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/ClientRequest.java b/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/ClientRequest.java
index 7feb0b6..cc7841d 100644
--- a/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/ClientRequest.java
+++ b/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/ClientRequest.java
@@ -69,6 +69,15 @@ public interface ClientRequest extends RequestContext {
*/
ClientRequest timeout(Duration timeout);
+ /**
+ * Opt out of throwing {@link xyz.juandiii.ark.core.exceptions.ApiException}
+ * on HTTP error status codes (4xx/5xx). When called, the response is
+ * returned to the caller unchanged regardless of status.
+ *
+ * @return this request for chaining
+ */
+ ClientRequest noThrow();
+
/**
* Execute the HTTP request.
*
diff --git a/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/ClientResponse.java b/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/ClientResponse.java
index cc4e373..49bf583 100644
--- a/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/ClientResponse.java
+++ b/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/ClientResponse.java
@@ -55,4 +55,14 @@ public interface ClientResponse {
* @return response wrapper with a {@code Void} body
*/
ArkResponse toBodilessEntity();
+
+ /**
+ * Returns the raw HTTP response — status code, headers, and body as a String —
+ * without deserialization. Useful with {@link ClientRequest#noThrow()} (or
+ * client-level {@code throwOnError(false)}) to inspect error bodies that
+ * don't match a typed schema.
+ *
+ * @return the raw response wrapper produced by the transport
+ */
+ RawResponse raw();
}
diff --git a/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/DefaultClientResponse.java b/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/DefaultClientResponse.java
index d35169d..ede4f0d 100644
--- a/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/DefaultClientResponse.java
+++ b/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/DefaultClientResponse.java
@@ -43,4 +43,9 @@ public ArkResponse toEntity(Class type) {
public ArkResponse toBodilessEntity() {
return new ArkResponse<>(raw.statusCode(), raw.headers(), null);
}
+
+ @Override
+ public RawResponse raw() {
+ return raw;
+ }
}
diff --git a/core/ark-core/src/main/java/xyz/juandiii/ark/core/proxy/SyncReturnTypeHandler.java b/core/ark-core/src/main/java/xyz/juandiii/ark/core/proxy/SyncReturnTypeHandler.java
index 3442759..c7d2d6a 100644
--- a/core/ark-core/src/main/java/xyz/juandiii/ark/core/proxy/SyncReturnTypeHandler.java
+++ b/core/ark-core/src/main/java/xyz/juandiii/ark/core/proxy/SyncReturnTypeHandler.java
@@ -3,6 +3,7 @@
import xyz.juandiii.ark.core.TypeRef;
import xyz.juandiii.ark.core.http.ArkResponse;
import xyz.juandiii.ark.core.http.ClientRequest;
+import xyz.juandiii.ark.core.http.RawResponse;
import xyz.juandiii.ark.core.interceptor.RequestContext;
import java.lang.reflect.ParameterizedType;
@@ -24,6 +25,10 @@ public Object handle(RequestContext request, Type returnType) {
return null;
}
+ if (returnType == RawResponse.class) {
+ return syncRequest.noThrow().retrieve().raw();
+ }
+
if (returnType instanceof ParameterizedType pt
&& pt.getRawType() == ArkResponse.class) {
Type bodyType = pt.getActualTypeArguments()[0];
diff --git a/core/ark-core/src/test/java/xyz/juandiii/ark/core/AbstractArkClientTest.java b/core/ark-core/src/test/java/xyz/juandiii/ark/core/AbstractArkClientTest.java
index d0e240a..4622d93 100644
--- a/core/ark-core/src/test/java/xyz/juandiii/ark/core/AbstractArkClientTest.java
+++ b/core/ark-core/src/test/java/xyz/juandiii/ark/core/AbstractArkClientTest.java
@@ -25,7 +25,7 @@ class AbstractArkClientTest {
private TestArkClient client() {
return new TestArkClient(transport, serializer, "TestAgent/1.0",
- "https://api.example.com", Collections.emptyList(), Collections.emptyList());
+ "https://api.example.com", Collections.emptyList(), Collections.emptyList(), true);
}
@Test
@@ -64,8 +64,10 @@ static class TestArkClient extends AbstractArkClient {
TestArkClient(HttpTransport transport, JsonSerializer serializer, String userAgent,
String baseUrl, List requestInterceptors,
- List responseInterceptors) {
- super(serializer, userAgent, baseUrl, requestInterceptors, responseInterceptors);
+ List responseInterceptors,
+ boolean throwOnErrorDefault) {
+ super(serializer, userAgent, baseUrl, requestInterceptors, responseInterceptors,
+ throwOnErrorDefault);
this.transport = transport;
}
diff --git a/core/ark-core/src/test/java/xyz/juandiii/ark/core/http/NoThrowTest.java b/core/ark-core/src/test/java/xyz/juandiii/ark/core/http/NoThrowTest.java
new file mode 100644
index 0000000..8c837d3
--- /dev/null
+++ b/core/ark-core/src/test/java/xyz/juandiii/ark/core/http/NoThrowTest.java
@@ -0,0 +1,94 @@
+package xyz.juandiii.ark.core.http;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import xyz.juandiii.ark.core.Ark;
+import xyz.juandiii.ark.core.ArkClient;
+import xyz.juandiii.ark.core.JsonSerializer;
+import xyz.juandiii.ark.core.exceptions.NotFoundException;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+
+/**
+ * Verifies the {@code noThrow()} per-request opt-out and the
+ * client-level {@code throwOnError(false)} default for the sync execution model.
+ */
+@ExtendWith(MockitoExtension.class)
+class NoThrowTest {
+
+ @Mock
+ JsonSerializer serializer;
+
+ @Mock
+ HttpTransport transport;
+
+ private Ark client(boolean throwOnError) {
+ return ArkClient.builder()
+ .serializer(serializer)
+ .transport(transport)
+ .baseUrl("https://api.example.com")
+ .throwOnError(throwOnError)
+ .build();
+ }
+
+ private Ark defaultClient() {
+ return ArkClient.builder()
+ .serializer(serializer)
+ .transport(transport)
+ .baseUrl("https://api.example.com")
+ .build();
+ }
+
+ @Test
+ void defaultBehavior_404_throwsNotFoundException() {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(new RawResponse(404, Map.of(), "{\"error\":\"not found\"}"));
+
+ Ark ark = defaultClient();
+ assertThrows(NotFoundException.class, () -> ark.get("/users/1").retrieve());
+ }
+
+ @Test
+ void perRequestNoThrow_404_returnsResponseWithStatus404() {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(new RawResponse(404, Map.of(), "{\"error\":\"not found\"}"));
+
+ Ark ark = defaultClient();
+ ArkResponse response = ark.get("/users/1").noThrow().retrieve().toEntity(String.class);
+
+ assertEquals(404, response.statusCode());
+ assertFalse(response.isSuccessful());
+ }
+
+ @Test
+ void clientLevelThrowOnErrorFalse_404_isPermissive() {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(new RawResponse(404, Map.of(), "{\"error\":\"not found\"}"));
+
+ Ark ark = client(false);
+ ArkResponse response = ark.get("/users/1").retrieve().toEntity(String.class);
+
+ assertEquals(404, response.statusCode());
+ assertFalse(response.isSuccessful());
+ }
+
+ @Test
+ void clientLevelThrowOnErrorFalse_withoutNoThrow_stillPermissive() {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(new RawResponse(500, Map.of(), "boom"));
+
+ Ark ark = client(false);
+ ArkResponse response = ark.get("/users/1").retrieve().toEntity(String.class);
+
+ assertEquals(500, response.statusCode());
+ assertFalse(response.isSuccessful());
+ }
+}
diff --git a/core/ark-core/src/test/java/xyz/juandiii/ark/core/http/RawResponseAccessTest.java b/core/ark-core/src/test/java/xyz/juandiii/ark/core/http/RawResponseAccessTest.java
new file mode 100644
index 0000000..984c825
--- /dev/null
+++ b/core/ark-core/src/test/java/xyz/juandiii/ark/core/http/RawResponseAccessTest.java
@@ -0,0 +1,77 @@
+package xyz.juandiii.ark.core.http;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import xyz.juandiii.ark.core.Ark;
+import xyz.juandiii.ark.core.ArkClient;
+import xyz.juandiii.ark.core.JsonSerializer;
+import xyz.juandiii.ark.core.proxy.SyncReturnTypeHandler;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+
+/**
+ * Verifies fluent {@code .raw()} access and proxy {@code RawResponse} return type
+ * auto-toggling {@code noThrow} for the sync execution model.
+ */
+@ExtendWith(MockitoExtension.class)
+class RawResponseAccessTest {
+
+ @Mock JsonSerializer serializer;
+ @Mock HttpTransport transport;
+
+ private Ark defaultClient() {
+ return ArkClient.builder()
+ .serializer(serializer)
+ .transport(transport)
+ .baseUrl("https://api.example.com")
+ .build();
+ }
+
+ @Test
+ void fluentRaw_200_returnsTransportRawResponse() {
+ RawResponse expected = new RawResponse(200, Map.of("X-Trace", java.util.List.of("abc")), "{\"x\":1}");
+ when(transport.send(anyString(), any(), anyMap(), any(), any())).thenReturn(expected);
+
+ RawResponse raw = defaultClient().get("/foo").retrieve().raw();
+
+ assertSame(expected, raw);
+ assertEquals(200, raw.statusCode());
+ assertEquals("{\"x\":1}", raw.body());
+ }
+
+ @Test
+ void fluentRawWithNoThrow_404_doesNotThrowAndExposesStatusAndBody() {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(new RawResponse(404, Map.of(), "{\"error\":\"missing\"}"));
+
+ RawResponse raw = defaultClient().get("/foo").noThrow().retrieve().raw();
+
+ assertEquals(404, raw.statusCode());
+ assertEquals("{\"error\":\"missing\"}", raw.body());
+ assertTrue(raw.isError());
+ }
+
+ @Test
+ void proxyRawResponseReturnType_404_autoNoThrowAndReturnsRaw() throws Exception {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(new RawResponse(404, Map.of(), "{\"err\":\"x\"}"));
+
+ Ark ark = defaultClient();
+ SyncReturnTypeHandler handler = new SyncReturnTypeHandler();
+ java.lang.reflect.Type returnType = RawResponse.class;
+ Object result = handler.handle(ark.get("/foo"), returnType);
+
+ assertInstanceOf(RawResponse.class, result);
+ RawResponse raw = (RawResponse) result;
+ assertEquals(404, raw.statusCode());
+ assertEquals("{\"err\":\"x\"}", raw.body());
+ }
+}
diff --git a/docs/async.md b/docs/async.md
index cbc3489..253cfdb 100644
--- a/docs/async.md
+++ b/docs/async.md
@@ -109,6 +109,69 @@ client.get("/users/1")
---
+## Permissive error handling
+
+By default, Ark fails the `CompletableFuture` with an `ApiException`
+subtype for any HTTP 4xx/5xx status. When 4xx is a meaningful business
+outcome, opt out and inspect the response.
+
+Per-request opt-out via `.noThrow()`:
+
+```java
+CompletableFuture> response = client.get("/users/1")
+ .noThrow()
+ .retrieve()
+ .toEntity(User.class);
+
+response.thenAccept(r -> {
+ if (r.statusCode() == 404) {
+ // not found, treat as business outcome
+ } else if (r.isSuccessful()) {
+ User body = r.body();
+ }
+});
+```
+
+Client-level default via `throwOnError(false)`:
+
+```java
+AsyncArk permissive = AsyncArkClient.builder()
+ .serializer(serializer)
+ .transport(transport)
+ .baseUrl("https://api.example.com")
+ .throwOnError(false)
+ .build();
+```
+
+---
+
+## Capturing the raw response
+
+When you need the raw response — status, headers, and body as a String —
+without going through deserialization (e.g. to inspect an error body that
+doesn't match your typed schema), use `.raw()`:
+
+```java
+CompletableFuture futureRaw = client.get("/users/1")
+ .noThrow()
+ .retrieve()
+ .raw();
+
+futureRaw.thenAccept(raw -> {
+ if (raw.isError()) {
+ log.warn("Error {}: {}", raw.statusCode(), raw.body());
+ } else {
+ User user = serializer.deserialize(raw.body(), User.class);
+ }
+});
+```
+
+`.raw()` returns a `CompletableFuture` (no deserialization).
+Use it together with `.noThrow()` (or client-level `throwOnError(false)`)
+to inspect bodies on 4xx/5xx without the future failing.
+
+---
+
## Related
- [Error Handling](error-handling.md)
diff --git a/docs/declarative-spring.md b/docs/declarative-spring.md
index 68e114d..d5499be 100644
--- a/docs/declarative-spring.md
+++ b/docs/declarative-spring.md
@@ -132,13 +132,39 @@ Both `value` and `url` work: `@GetExchange("/users")` and `@GetExchange(url = "/
| `T` | Deserializes response body |
| `void` | Calls `toBodilessEntity()` |
| `ArkResponse` | Full response (status + headers + body) |
+| `RawResponse` | Raw status + headers + body String, auto-disables throw-on-error |
| `String` | Raw response body |
| `Mono` | Reactor reactive (requires `ark-spring-boot-starter-webflux`) |
| `Mono>` | Reactor full response |
+| `Mono` | Reactor raw response, auto-disables throw-on-error |
| `Flux` | Reactor stream from JSON array |
---
+## RawResponse as a return type
+
+Proxy methods can declare `RawResponse` (or `CompletableFuture` /
+`Mono`) as the return type. This bypasses deserialization and
+auto-disables throw-on-error for that method — useful for full access to
+the response regardless of status.
+
+```java
+@RegisterArkClient(configKey = "users-api")
+@HttpExchange("/users")
+public interface UserApi {
+ @GetExchange("/{id}")
+ User getUser(@PathVariable String id); // type-safe, throws on 4xx/5xx
+
+ @GetExchange("/{id}")
+ RawResponse getUserRaw(@PathVariable String id); // raw, never throws
+}
+```
+
+The raw method auto-disables throw-on-error for its requests — no need to
+set `throw-on-error=false` at the client level just for this method.
+
+---
+
## Reactive Client (Reactor)
Pass a `ReactorArk` client to `ArkProxy.create()` for reactive Spring WebFlux clients:
diff --git a/docs/mutiny.md b/docs/mutiny.md
index e6c3076..29679ef 100644
--- a/docs/mutiny.md
+++ b/docs/mutiny.md
@@ -169,6 +169,67 @@ client.get("/users/1")
---
+## Permissive error handling
+
+By default, Ark signals an `ApiException` subtype on the `Uni` for any
+HTTP 4xx/5xx status. When 4xx is a meaningful business outcome, opt out
+and inspect the response.
+
+Per-request opt-out via `.noThrow()`:
+
+```java
+Uni> response = client.get("/users/1")
+ .noThrow()
+ .retrieve()
+ .toEntity(User.class);
+
+response.onItem().transformToUni(r -> {
+ if (r.statusCode() == 404) return Uni.createFrom().nullItem();
+ if (r.isSuccessful()) return Uni.createFrom().item(r.body());
+ return Uni.createFrom().failure(new IllegalStateException("status " + r.statusCode()));
+});
+```
+
+Client-level default via `throwOnError(false)`:
+
+```java
+MutinyArk permissive = MutinyArkClient.builder()
+ .serializer(serializer)
+ .transport(transport)
+ .baseUrl("https://api.example.com")
+ .throwOnError(false)
+ .build();
+```
+
+---
+
+## Capturing the raw response
+
+When you need the raw response — status, headers, and body as a String —
+without going through deserialization (e.g. to inspect an error body that
+doesn't match your typed schema), use `.raw()`:
+
+```java
+Uni raw = client.get("/users/1")
+ .noThrow()
+ .retrieve()
+ .raw();
+
+raw.onItem().transformToUni(r -> {
+ if (r.isError()) {
+ log.warn("Error {}: {}", r.statusCode(), r.body());
+ return Uni.createFrom().nullItem();
+ }
+ return Uni.createFrom().item(serializer.deserialize(r.body(), User.class));
+});
+```
+
+`.raw()` returns a `Uni` (no deserialization). Use it
+together with `.noThrow()` (or client-level `throwOnError(false)`) to
+inspect bodies on 4xx/5xx without the Uni failing.
+
+---
+
## Related
- [Quarkus Jackson Extension](quarkus-jackson.md)
diff --git a/docs/quarkus-jackson.md b/docs/quarkus-jackson.md
index ebc49c5..8163302 100644
--- a/docs/quarkus-jackson.md
+++ b/docs/quarkus-jackson.md
@@ -171,6 +171,7 @@ ark.client."user-api".connect-timeout=5
ark.client."user-api".read-timeout=15
ark.client."user-api".tls-configuration-name=my-cert
ark.client."user-api".trust-all=false
+ark.client."user-api".throw-on-error=true
ark.client."user-api".headers.X-Api-Key=${API_KEY}
ark.client."user-api".retry.max-attempts=3
ark.client."user-api".retry.delay=500
@@ -178,6 +179,8 @@ ark.client."user-api".retry.delay=500
> ⚠️ `trust-all=true` disables certificate validation. Use only in local development. See [Security & TLS in README](../README.md#tls).
+> Set `throw-on-error=false` to return HTTP 4xx/5xx responses instead of raising `ApiException`. See [Permissive error handling](sync.md#permissive-error-handling).
+
See [Retry & Backoff](retry.md) for full retry configuration.
```java
@@ -232,6 +235,30 @@ See [Declarative JAX-RS Clients](declarative-jaxrs.md) for full details.
---
+## RawResponse as a return type
+
+Proxy methods can declare `RawResponse` (or `Uni`) as the
+return type. This bypasses deserialization and auto-disables
+throw-on-error for that method — useful for inspecting error bodies that
+don't match a typed schema or for non-JSON responses.
+
+```java
+@RegisterArkClient(configKey = "users-api")
+@Path("/users")
+public interface UserApi {
+ @GET @Path("/{id}")
+ Uni getUser(@PathParam("id") String id); // type-safe, fails Uni on 4xx/5xx
+
+ @GET @Path("/{id}")
+ Uni getUserRaw(@PathParam("id") String id); // raw, never fails the Uni
+}
+```
+
+The raw method auto-disables throw-on-error for its requests — no need to
+set `throw-on-error=false` at the client level just for this method.
+
+---
+
## Native Image
Supports GraalVM native image out of the box. The extension auto-discovers `@RegisterArkClient` interfaces at build time and registers JDK proxy definitions.
diff --git a/docs/reactor.md b/docs/reactor.md
index cd0a3f9..72bb41b 100644
--- a/docs/reactor.md
+++ b/docs/reactor.md
@@ -195,6 +195,67 @@ client.get("/users/1")
---
+## Permissive error handling
+
+By default, Ark signals an `ApiException` subtype on the `Mono` for any
+HTTP 4xx/5xx status. When 4xx is a meaningful business outcome, opt out
+and inspect the response.
+
+Per-request opt-out via `.noThrow()`:
+
+```java
+Mono> response = client.get("/users/1")
+ .noThrow()
+ .retrieve()
+ .toEntity(User.class);
+
+response.flatMap(r -> {
+ if (r.statusCode() == 404) return Mono.empty();
+ if (r.isSuccessful()) return Mono.just(r.body());
+ return Mono.error(new IllegalStateException("status " + r.statusCode()));
+});
+```
+
+Client-level default via `throwOnError(false)`:
+
+```java
+ReactorArk permissive = ReactorArkClient.builder()
+ .serializer(serializer)
+ .transport(transport)
+ .baseUrl("https://api.example.com")
+ .throwOnError(false)
+ .build();
+```
+
+---
+
+## Capturing the raw response
+
+When you need the raw response — status, headers, and body as a String —
+without going through deserialization (e.g. to inspect an error body that
+doesn't match your typed schema), use `.raw()`:
+
+```java
+Mono raw = client.get("/users/1")
+ .noThrow()
+ .retrieve()
+ .raw();
+
+raw.flatMap(r -> {
+ if (r.isError()) {
+ log.warn("Error {}: {}", r.statusCode(), r.body());
+ return Mono.empty();
+ }
+ return Mono.just(serializer.deserialize(r.body(), User.class));
+});
+```
+
+`.raw()` returns a `Mono` (no deserialization). Use it
+together with `.noThrow()` (or client-level `throwOnError(false)`) to
+inspect bodies on 4xx/5xx without the Mono failing.
+
+---
+
## Related
- [Spring Boot Integration](spring-boot.md)
diff --git a/docs/spring-boot.md b/docs/spring-boot.md
index 90bbe48..188c17e 100644
--- a/docs/spring-boot.md
+++ b/docs/spring-boot.md
@@ -186,6 +186,7 @@ ark.client.user-api.http-version=HTTP_2
ark.client.user-api.connect-timeout=5
ark.client.user-api.read-timeout=15
ark.client.user-api.trust-all=false
+ark.client.user-api.throw-on-error=true
ark.client.user-api.headers.Authorization=Bearer ${TOKEN}
ark.client.user-api.tls-configuration-name=my-cert
@@ -195,6 +196,8 @@ spring.ssl.bundle.pem.my-cert.truststore.certificate=classpath:certs/ca.crt
> ⚠️ `trust-all: true` disables certificate validation. Use only in local development. See [Security & TLS in README](../README.md#tls).
+> Set `throw-on-error=false` to return HTTP 4xx/5xx responses instead of raising `ApiException`. See [Permissive error handling](sync.md#permissive-error-handling).
+
Same configuration structure as the sync starter. See [Declarative Spring Clients](declarative-spring.md) for full annotation details.
> **Note:** Retry is not configured via properties for reactive clients - use Reactor's built-in `.retryWhen()` instead. See [Retry & Backoff](retry.md#reactive-reactor--mutiny).
@@ -228,6 +231,7 @@ ark.client.user-api.connect-timeout=5
ark.client.user-api.read-timeout=15
ark.client.user-api.tls-configuration-name=my-cert
ark.client.user-api.trust-all=false
+ark.client.user-api.throw-on-error=true
ark.client.user-api.headers.X-Api-Key=${API_KEY}
ark.client.user-api.retry.max-attempts=3
ark.client.user-api.retry.delay=500
@@ -235,6 +239,8 @@ ark.client.user-api.retry.delay=500
> ⚠️ `trust-all: true` disables certificate validation. Use only in local development. See [Security & TLS in README](../README.md#tls).
+> Set `throw-on-error=false` to return HTTP 4xx/5xx responses instead of raising `ApiException`. See [Permissive error handling](sync.md#permissive-error-handling).
+
See [Retry & Backoff](retry.md) for full retry configuration.
```java
diff --git a/docs/sync.md b/docs/sync.md
index 23d59d7..2f760f0 100644
--- a/docs/sync.md
+++ b/docs/sync.md
@@ -176,6 +176,65 @@ try {
---
+## Permissive error handling
+
+By default, Ark throws an `ApiException` subtype for any HTTP 4xx/5xx
+status. When 4xx is a meaningful business outcome (e.g. 404 = "not
+found", not an error), opt out and inspect the response yourself.
+
+Per-request opt-out via `.noThrow()`:
+
+```java
+ArkResponse response = client.get("/users/1")
+ .noThrow()
+ .retrieve()
+ .toEntity(User.class);
+
+if (response.statusCode() == 404) return Optional.empty();
+if (response.isSuccessful()) return Optional.of(response.body());
+```
+
+Client-level default via `throwOnError(false)`:
+
+```java
+Ark permissive = ArkClient.builder()
+ .serializer(serializer)
+ .transport(transport)
+ .baseUrl("https://api.example.com")
+ .throwOnError(false)
+ .build();
+
+// All requests on this client return responses regardless of status
+ArkResponse response = permissive.get("/users/1").retrieve().toEntity(User.class);
+```
+
+---
+
+## Capturing the raw response
+
+When you need the raw response — status, headers, and body as a String —
+without going through deserialization (e.g. to inspect an error body that
+doesn't match your typed schema), use `.raw()`:
+
+```java
+RawResponse raw = client.get("/users/1")
+ .noThrow()
+ .retrieve()
+ .raw();
+
+if (raw.isError()) {
+ log.warn("Error {}: {}", raw.statusCode(), raw.body());
+} else {
+ User user = serializer.deserialize(raw.body(), User.class);
+}
+```
+
+`.raw()` returns a `RawResponse` directly (no deserialization). Use it
+together with `.noThrow()` (or client-level `throwOnError(false)`) to
+inspect bodies on 4xx/5xx without exceptions.
+
+---
+
## Related
- [Error Handling](error-handling.md) - full exception hierarchy
diff --git a/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/AsyncArkClient.java b/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/AsyncArkClient.java
index bcfbda8..c1690b2 100644
--- a/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/AsyncArkClient.java
+++ b/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/AsyncArkClient.java
@@ -25,15 +25,18 @@ public class AsyncArkClient extends AbstractArkClient
private AsyncArkClient(Transport> transport, JsonSerializer serializer,
String userAgent, String baseUrl,
List requestInterceptors,
- List responseInterceptors) {
- super(serializer, userAgent, baseUrl, requestInterceptors, responseInterceptors);
+ List responseInterceptors,
+ boolean throwOnErrorDefault) {
+ super(serializer, userAgent, baseUrl, requestInterceptors, responseInterceptors,
+ throwOnErrorDefault);
this.transport = transport;
}
@Override
protected DefaultAsyncClientRequest createRequest(String method, String path) {
- return new DefaultAsyncClientRequest(method, baseUrl, path, transport, serializer,
- requestInterceptors, responseInterceptors)
+ DefaultAsyncClientRequest req = new DefaultAsyncClientRequest(method, baseUrl, path, transport, serializer,
+ requestInterceptors, responseInterceptors);
+ return req.throwOnError(throwOnErrorDefault)
.header("User-Agent", userAgent);
}
@@ -68,7 +71,8 @@ public AsyncArk build() {
Objects.requireNonNull(transport, "transport must not be null");
logConfiguration("AsyncArkClient (CompletableFuture)", transport.getClass().getSimpleName());
return new AsyncArkClient(transport, serializer, buildUserAgent(),
- baseUrl, requestInterceptors, responseInterceptors);
+ baseUrl, requestInterceptors, responseInterceptors,
+ throwOnErrorDefault);
}
}
}
diff --git a/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/http/AsyncClientRequest.java b/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/http/AsyncClientRequest.java
index 747b8a7..77ca370 100644
--- a/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/http/AsyncClientRequest.java
+++ b/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/http/AsyncClientRequest.java
@@ -23,5 +23,14 @@ public interface AsyncClientRequest extends RequestContext {
AsyncClientRequest timeout(Duration timeout);
+ /**
+ * Opt out of throwing {@link xyz.juandiii.ark.core.exceptions.ApiException}
+ * on HTTP error status codes (4xx/5xx). When called, the response is
+ * returned to the caller unchanged regardless of status.
+ *
+ * @return this request for chaining
+ */
+ AsyncClientRequest noThrow();
+
AsyncClientResponse retrieve();
}
diff --git a/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/http/AsyncClientResponse.java b/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/http/AsyncClientResponse.java
index a498059..f8d6544 100644
--- a/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/http/AsyncClientResponse.java
+++ b/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/http/AsyncClientResponse.java
@@ -2,6 +2,7 @@
import xyz.juandiii.ark.core.TypeRef;
import xyz.juandiii.ark.core.http.ArkResponse;
+import xyz.juandiii.ark.core.http.RawResponse;
import java.util.concurrent.CompletableFuture;
@@ -21,4 +22,14 @@ public interface AsyncClientResponse {
CompletableFuture> toEntity(Class type);
CompletableFuture> toBodilessEntity();
+
+ /**
+ * Returns the raw HTTP response — status code, headers, and body as a String —
+ * without deserialization. Useful with {@link AsyncClientRequest#noThrow()} (or
+ * client-level {@code throwOnError(false)}) to inspect error bodies that
+ * don't match a typed schema.
+ *
+ * @return future completed with the raw response wrapper produced by the transport
+ */
+ CompletableFuture raw();
}
diff --git a/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/http/DefaultAsyncClientResponse.java b/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/http/DefaultAsyncClientResponse.java
index 0da6912..a60a711 100644
--- a/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/http/DefaultAsyncClientResponse.java
+++ b/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/http/DefaultAsyncClientResponse.java
@@ -50,4 +50,9 @@ public CompletableFuture> toBodilessEntity() {
return future.thenApply(raw ->
new ArkResponse<>(raw.statusCode(), raw.headers(), null));
}
+
+ @Override
+ public CompletableFuture raw() {
+ return future;
+ }
}
diff --git a/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/proxy/AsyncReturnTypeHandler.java b/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/proxy/AsyncReturnTypeHandler.java
index e0bae56..3664a62 100644
--- a/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/proxy/AsyncReturnTypeHandler.java
+++ b/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/proxy/AsyncReturnTypeHandler.java
@@ -4,6 +4,7 @@
import xyz.juandiii.ark.async.http.AsyncClientRequest;
import xyz.juandiii.ark.async.http.AsyncClientResponse;
import xyz.juandiii.ark.core.http.ArkResponse;
+import xyz.juandiii.ark.core.http.RawResponse;
import xyz.juandiii.ark.core.interceptor.RequestContext;
import xyz.juandiii.ark.core.proxy.ReturnTypeHandler;
@@ -13,7 +14,8 @@
/**
* Dispatches async request execution based on method return type.
- * Supports CompletableFuture<T>, CompletableFuture<ArkResponse<T>>, and void.
+ * Supports CompletableFuture<T>, CompletableFuture<ArkResponse<T>>,
+ * CompletableFuture<RawResponse>, and void.
*
* @author Juan Diego Lopez V.
*/
@@ -22,19 +24,23 @@ public final class AsyncReturnTypeHandler implements ReturnTypeHandler {
@Override
public Object handle(RequestContext request, Type returnType) {
AsyncClientRequest asyncRequest = (AsyncClientRequest) request;
- AsyncClientResponse response = asyncRequest.retrieve();
if (returnType == void.class || returnType == Void.class) {
- return response.toBodilessEntity();
+ return asyncRequest.retrieve().toBodilessEntity();
}
if (returnType instanceof ParameterizedType pt
&& pt.getRawType() == CompletableFuture.class) {
Type innerType = pt.getActualTypeArguments()[0];
- return handleFutureType(response, innerType);
+
+ if (innerType == RawResponse.class) {
+ return asyncRequest.noThrow().retrieve().raw();
+ }
+
+ return handleFutureType(asyncRequest.retrieve(), innerType);
}
- return response.body(TypeRef.of(returnType));
+ return asyncRequest.retrieve().body(TypeRef.of(returnType));
}
private Object handleFutureType(AsyncClientResponse response, Type innerType) {
diff --git a/execution-models/ark-async/src/test/java/xyz/juandiii/ark/async/http/AsyncNoThrowTest.java b/execution-models/ark-async/src/test/java/xyz/juandiii/ark/async/http/AsyncNoThrowTest.java
new file mode 100644
index 0000000..535e6dd
--- /dev/null
+++ b/execution-models/ark-async/src/test/java/xyz/juandiii/ark/async/http/AsyncNoThrowTest.java
@@ -0,0 +1,111 @@
+package xyz.juandiii.ark.async.http;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import xyz.juandiii.ark.async.AsyncArk;
+import xyz.juandiii.ark.async.AsyncArkClient;
+import xyz.juandiii.ark.core.JsonSerializer;
+import xyz.juandiii.ark.core.exceptions.NotFoundException;
+import xyz.juandiii.ark.core.http.ArkResponse;
+import xyz.juandiii.ark.core.http.RawResponse;
+import xyz.juandiii.ark.core.http.Transport;
+
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+
+/**
+ * Verifies the {@code noThrow()} per-request opt-out and the
+ * client-level {@code throwOnError(false)} default for the async execution model.
+ */
+@ExtendWith(MockitoExtension.class)
+class AsyncNoThrowTest {
+
+ @Mock
+ JsonSerializer serializer;
+
+ @Mock
+ @SuppressWarnings("unchecked")
+ Transport> transport;
+
+ private AsyncArk client(boolean throwOnError) {
+ return AsyncArkClient.builder()
+ .serializer(serializer)
+ .transport(transport)
+ .baseUrl("https://api.example.com")
+ .throwOnError(throwOnError)
+ .build();
+ }
+
+ private AsyncArk defaultClient() {
+ return AsyncArkClient.builder()
+ .serializer(serializer)
+ .transport(transport)
+ .baseUrl("https://api.example.com")
+ .build();
+ }
+
+ @Test
+ void defaultBehavior_404_completesExceptionallyWithNotFoundException() {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ new RawResponse(404, Map.of(), "{\"error\":\"not found\"}")));
+
+ AsyncArk ark = defaultClient();
+ CompletableFuture> future =
+ ark.get("/users/1").retrieve().toEntity(String.class);
+
+ ExecutionException ex = assertThrows(ExecutionException.class, future::get);
+ assertInstanceOf(NotFoundException.class, ex.getCause());
+ }
+
+ @Test
+ void perRequestNoThrow_404_returnsResponseWithStatus404() throws Exception {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ new RawResponse(404, Map.of(), "{\"error\":\"not found\"}")));
+
+ AsyncArk ark = defaultClient();
+ ArkResponse response =
+ ark.get("/users/1").noThrow().retrieve().toEntity(String.class).get();
+
+ assertEquals(404, response.statusCode());
+ assertFalse(response.isSuccessful());
+ }
+
+ @Test
+ void clientLevelThrowOnErrorFalse_404_isPermissive() throws Exception {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ new RawResponse(404, Map.of(), "{\"error\":\"not found\"}")));
+
+ AsyncArk ark = client(false);
+ ArkResponse response =
+ ark.get("/users/1").retrieve().toEntity(String.class).get();
+
+ assertEquals(404, response.statusCode());
+ assertFalse(response.isSuccessful());
+ }
+
+ @Test
+ void clientLevelThrowOnErrorFalse_withoutNoThrow_stillPermissive() throws Exception {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ new RawResponse(500, Map.of(), "boom")));
+
+ AsyncArk ark = client(false);
+ ArkResponse response =
+ ark.get("/users/1").retrieve().toEntity(String.class).get();
+
+ assertEquals(500, response.statusCode());
+ assertFalse(response.isSuccessful());
+ }
+}
diff --git a/execution-models/ark-async/src/test/java/xyz/juandiii/ark/async/http/AsyncRawResponseAccessTest.java b/execution-models/ark-async/src/test/java/xyz/juandiii/ark/async/http/AsyncRawResponseAccessTest.java
new file mode 100644
index 0000000..4f73da8
--- /dev/null
+++ b/execution-models/ark-async/src/test/java/xyz/juandiii/ark/async/http/AsyncRawResponseAccessTest.java
@@ -0,0 +1,90 @@
+package xyz.juandiii.ark.async.http;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import xyz.juandiii.ark.async.AsyncArk;
+import xyz.juandiii.ark.async.AsyncArkClient;
+import xyz.juandiii.ark.async.proxy.AsyncReturnTypeHandler;
+import xyz.juandiii.ark.core.JsonSerializer;
+import xyz.juandiii.ark.core.http.RawResponse;
+import xyz.juandiii.ark.core.http.Transport;
+
+import java.lang.reflect.Type;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+
+/**
+ * Verifies fluent {@code .raw()} access and proxy {@code RawResponse} return type
+ * auto-toggling {@code noThrow} for the async execution model.
+ */
+@ExtendWith(MockitoExtension.class)
+class AsyncRawResponseAccessTest {
+
+ @Mock JsonSerializer serializer;
+ @Mock @SuppressWarnings("unchecked")
+ Transport> transport;
+
+ interface TypeHelper {
+ CompletableFuture futureRaw();
+ }
+
+ private AsyncArk defaultClient() {
+ return AsyncArkClient.builder()
+ .serializer(serializer)
+ .transport(transport)
+ .baseUrl("https://api.example.com")
+ .build();
+ }
+
+ @Test
+ void fluentRaw_200_returnsTransportRawResponse() throws Exception {
+ RawResponse expected = new RawResponse(200, Map.of(), "ok");
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(expected));
+
+ RawResponse raw = defaultClient().get("/foo").retrieve().raw().get();
+
+ assertSame(expected, raw);
+ assertEquals(200, raw.statusCode());
+ }
+
+ @Test
+ void fluentRawWithNoThrow_404_doesNotThrowAndExposesStatusAndBody() throws Exception {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ new RawResponse(404, Map.of(), "{\"error\":\"missing\"}")));
+
+ RawResponse raw = defaultClient().get("/foo").noThrow().retrieve().raw().get();
+
+ assertEquals(404, raw.statusCode());
+ assertEquals("{\"error\":\"missing\"}", raw.body());
+ assertTrue(raw.isError());
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void proxyFutureRawResponseReturnType_404_autoNoThrowAndReturnsRaw() throws Exception {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ new RawResponse(404, Map.of(), "{\"err\":\"x\"}")));
+
+ AsyncArk ark = defaultClient();
+ AsyncReturnTypeHandler handler = new AsyncReturnTypeHandler();
+ Type returnType = TypeHelper.class.getMethod("futureRaw").getGenericReturnType();
+
+ Object result = handler.handle(ark.get("/foo"), returnType);
+
+ assertInstanceOf(CompletableFuture.class, result);
+ RawResponse raw = ((CompletableFuture) result).get();
+ assertEquals(404, raw.statusCode());
+ assertEquals("{\"err\":\"x\"}", raw.body());
+ }
+}
diff --git a/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/MutinyArkClient.java b/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/MutinyArkClient.java
index c79a1ad..8311a23 100644
--- a/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/MutinyArkClient.java
+++ b/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/MutinyArkClient.java
@@ -23,15 +23,18 @@ public class MutinyArkClient extends AbstractArkClient requestInterceptors,
- List responseInterceptors) {
- super(serializer, userAgent, baseUrl, requestInterceptors, responseInterceptors);
+ List responseInterceptors,
+ boolean throwOnErrorDefault) {
+ super(serializer, userAgent, baseUrl, requestInterceptors, responseInterceptors,
+ throwOnErrorDefault);
this.transport = transport;
}
@Override
protected DefaultMutinyClientRequest createRequest(String method, String path) {
- return new DefaultMutinyClientRequest(method, baseUrl, path, transport, serializer,
- requestInterceptors, responseInterceptors)
+ DefaultMutinyClientRequest req = new DefaultMutinyClientRequest(method, baseUrl, path, transport, serializer,
+ requestInterceptors, responseInterceptors);
+ return req.throwOnError(throwOnErrorDefault)
.header("User-Agent", userAgent);
}
@@ -57,7 +60,8 @@ public MutinyArk build() {
Objects.requireNonNull(transport, "transport must not be null");
logConfiguration("MutinyArkClient (Uni/Multi)", transport.getClass().getSimpleName());
return new MutinyArkClient(transport, serializer, buildUserAgent(),
- baseUrl, requestInterceptors, responseInterceptors);
+ baseUrl, requestInterceptors, responseInterceptors,
+ throwOnErrorDefault);
}
}
}
diff --git a/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/http/DefaultMutinyClientResponse.java b/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/http/DefaultMutinyClientResponse.java
index 122cc6f..d7d1f3a 100644
--- a/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/http/DefaultMutinyClientResponse.java
+++ b/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/http/DefaultMutinyClientResponse.java
@@ -49,4 +49,9 @@ public Uni> toBodilessEntity() {
return uni.onItem().transform(raw ->
new ArkResponse<>(raw.statusCode(), raw.headers(), null));
}
+
+ @Override
+ public Uni raw() {
+ return uni;
+ }
}
diff --git a/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/http/MutinyClientRequest.java b/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/http/MutinyClientRequest.java
index 5e63465..b029692 100644
--- a/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/http/MutinyClientRequest.java
+++ b/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/http/MutinyClientRequest.java
@@ -23,5 +23,14 @@ public interface MutinyClientRequest extends RequestContext {
MutinyClientRequest timeout(Duration timeout);
+ /**
+ * Opt out of throwing {@link xyz.juandiii.ark.core.exceptions.ApiException}
+ * on HTTP error status codes (4xx/5xx). When called, the response is
+ * returned to the caller unchanged regardless of status.
+ *
+ * @return this request for chaining
+ */
+ MutinyClientRequest noThrow();
+
MutinyClientResponse retrieve();
}
diff --git a/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/http/MutinyClientResponse.java b/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/http/MutinyClientResponse.java
index a2e22ab..1286e6b 100644
--- a/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/http/MutinyClientResponse.java
+++ b/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/http/MutinyClientResponse.java
@@ -4,6 +4,7 @@
import io.smallrye.mutiny.Uni;
import xyz.juandiii.ark.core.TypeRef;
import xyz.juandiii.ark.core.http.ArkResponse;
+import xyz.juandiii.ark.core.http.RawResponse;
import java.util.List;
@@ -35,4 +36,14 @@ default Multi bodyAsMulti(Class type) {
Uni> toEntity(Class type);
Uni> toBodilessEntity();
+
+ /**
+ * Returns the raw HTTP response — status code, headers, and body as a String —
+ * without deserialization. Useful with {@link MutinyClientRequest#noThrow()} (or
+ * client-level {@code throwOnError(false)}) to inspect error bodies that
+ * don't match a typed schema.
+ *
+ * @return Uni emitting the raw response wrapper produced by the transport
+ */
+ Uni raw();
}
diff --git a/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/proxy/MutinyReturnTypeHandler.java b/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/proxy/MutinyReturnTypeHandler.java
index 0b904eb..cea4113 100644
--- a/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/proxy/MutinyReturnTypeHandler.java
+++ b/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/proxy/MutinyReturnTypeHandler.java
@@ -4,6 +4,7 @@
import io.smallrye.mutiny.Uni;
import xyz.juandiii.ark.core.TypeRef;
import xyz.juandiii.ark.core.http.ArkResponse;
+import xyz.juandiii.ark.core.http.RawResponse;
import xyz.juandiii.ark.core.interceptor.RequestContext;
import xyz.juandiii.ark.mutiny.http.MutinyClientRequest;
import xyz.juandiii.ark.mutiny.http.MutinyClientResponse;
@@ -14,7 +15,8 @@
/**
* Dispatches Mutiny request execution based on method return type.
- * Supports Uni<T>, Uni<ArkResponse<T>>, Multi<T>, and void.
+ * Supports Uni<T>, Uni<ArkResponse<T>>, Uni<RawResponse>,
+ * Multi<T>, and void.
*
* @author Juan Diego Lopez V.
*/
@@ -23,22 +25,25 @@ public final class MutinyReturnTypeHandler implements ReturnTypeHandler {
@Override
public Object handle(RequestContext request, Type returnType) {
MutinyClientRequest mutinyRequest = (MutinyClientRequest) request;
- MutinyClientResponse response = mutinyRequest.retrieve();
if (returnType == void.class || returnType == Void.class) {
- return response.toBodilessEntity();
+ return mutinyRequest.retrieve().toBodilessEntity();
}
if (returnType instanceof ParameterizedType pt) {
if (pt.getRawType() == Uni.class) {
- return handleUniType(response, pt.getActualTypeArguments()[0]);
+ Type innerType = pt.getActualTypeArguments()[0];
+ if (innerType == RawResponse.class) {
+ return mutinyRequest.noThrow().retrieve().raw();
+ }
+ return handleUniType(mutinyRequest.retrieve(), innerType);
}
if (pt.getRawType() == Multi.class) {
- return handleMultiType(response, pt.getActualTypeArguments()[0]);
+ return handleMultiType(mutinyRequest.retrieve(), pt.getActualTypeArguments()[0]);
}
}
- return response.body(TypeRef.of(returnType));
+ return mutinyRequest.retrieve().body(TypeRef.of(returnType));
}
private Object handleUniType(MutinyClientResponse response, Type innerType) {
diff --git a/execution-models/ark-mutiny/src/test/java/xyz/juandiii/ark/mutiny/http/MutinyNoThrowTest.java b/execution-models/ark-mutiny/src/test/java/xyz/juandiii/ark/mutiny/http/MutinyNoThrowTest.java
new file mode 100644
index 0000000..3812090
--- /dev/null
+++ b/execution-models/ark-mutiny/src/test/java/xyz/juandiii/ark/mutiny/http/MutinyNoThrowTest.java
@@ -0,0 +1,108 @@
+package xyz.juandiii.ark.mutiny.http;
+
+import io.smallrye.mutiny.Uni;
+import io.smallrye.mutiny.helpers.test.UniAssertSubscriber;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import xyz.juandiii.ark.core.JsonSerializer;
+import xyz.juandiii.ark.core.exceptions.NotFoundException;
+import xyz.juandiii.ark.core.http.ArkResponse;
+import xyz.juandiii.ark.core.http.RawResponse;
+import xyz.juandiii.ark.mutiny.MutinyArk;
+import xyz.juandiii.ark.mutiny.MutinyArkClient;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+
+/**
+ * Verifies the {@code noThrow()} per-request opt-out and the
+ * client-level {@code throwOnError(false)} default for the Mutiny execution model.
+ */
+@ExtendWith(MockitoExtension.class)
+class MutinyNoThrowTest {
+
+ @Mock
+ JsonSerializer serializer;
+
+ @Mock
+ MutinyHttpTransport transport;
+
+ private MutinyArk client(boolean throwOnError) {
+ return MutinyArkClient.builder()
+ .serializer(serializer)
+ .transport(transport)
+ .baseUrl("https://api.example.com")
+ .throwOnError(throwOnError)
+ .build();
+ }
+
+ private MutinyArk defaultClient() {
+ return MutinyArkClient.builder()
+ .serializer(serializer)
+ .transport(transport)
+ .baseUrl("https://api.example.com")
+ .build();
+ }
+
+ @Test
+ void defaultBehavior_404_uniFailsWithNotFoundException() {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(Uni.createFrom().item(new RawResponse(404, Map.of(), "{\"error\":\"not found\"}")));
+
+ MutinyArk ark = defaultClient();
+ UniAssertSubscriber> subscriber =
+ ark.get("/users/1").retrieve().toEntity(String.class)
+ .subscribe().withSubscriber(UniAssertSubscriber.create());
+
+ subscriber.assertFailedWith(NotFoundException.class);
+ }
+
+ @Test
+ void perRequestNoThrow_404_returnsResponseWithStatus404() {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(Uni.createFrom().item(new RawResponse(404, Map.of(), "{\"error\":\"not found\"}")));
+
+ MutinyArk ark = defaultClient();
+ ArkResponse response = ark.get("/users/1").noThrow().retrieve()
+ .toEntity(String.class)
+ .await().indefinitely();
+
+ assertEquals(404, response.statusCode());
+ assertFalse(response.isSuccessful());
+ }
+
+ @Test
+ void clientLevelThrowOnErrorFalse_404_isPermissive() {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(Uni.createFrom().item(new RawResponse(404, Map.of(), "{\"error\":\"not found\"}")));
+
+ MutinyArk ark = client(false);
+ ArkResponse response = ark.get("/users/1").retrieve()
+ .toEntity(String.class)
+ .await().indefinitely();
+
+ assertEquals(404, response.statusCode());
+ assertFalse(response.isSuccessful());
+ }
+
+ @Test
+ void clientLevelThrowOnErrorFalse_withoutNoThrow_stillPermissive() {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(Uni.createFrom().item(new RawResponse(500, Map.of(), "boom")));
+
+ MutinyArk ark = client(false);
+ ArkResponse response = ark.get("/users/1").retrieve()
+ .toEntity(String.class)
+ .await().indefinitely();
+
+ assertEquals(500, response.statusCode());
+ assertFalse(response.isSuccessful());
+ }
+}
diff --git a/execution-models/ark-mutiny/src/test/java/xyz/juandiii/ark/mutiny/http/MutinyRawResponseAccessTest.java b/execution-models/ark-mutiny/src/test/java/xyz/juandiii/ark/mutiny/http/MutinyRawResponseAccessTest.java
new file mode 100644
index 0000000..dc7e994
--- /dev/null
+++ b/execution-models/ark-mutiny/src/test/java/xyz/juandiii/ark/mutiny/http/MutinyRawResponseAccessTest.java
@@ -0,0 +1,86 @@
+package xyz.juandiii.ark.mutiny.http;
+
+import io.smallrye.mutiny.Uni;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import xyz.juandiii.ark.core.JsonSerializer;
+import xyz.juandiii.ark.core.http.RawResponse;
+import xyz.juandiii.ark.mutiny.MutinyArk;
+import xyz.juandiii.ark.mutiny.MutinyArkClient;
+import xyz.juandiii.ark.mutiny.proxy.MutinyReturnTypeHandler;
+
+import java.lang.reflect.Type;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+
+/**
+ * Verifies fluent {@code .raw()} access and proxy {@code RawResponse} return type
+ * auto-toggling {@code noThrow} for the Mutiny execution model.
+ */
+@ExtendWith(MockitoExtension.class)
+class MutinyRawResponseAccessTest {
+
+ @Mock JsonSerializer serializer;
+ @Mock MutinyHttpTransport transport;
+
+ interface TypeHelper {
+ Uni uniRaw();
+ }
+
+ private MutinyArk defaultClient() {
+ return MutinyArkClient.builder()
+ .serializer(serializer)
+ .transport(transport)
+ .baseUrl("https://api.example.com")
+ .build();
+ }
+
+ @Test
+ void fluentRaw_200_returnsTransportRawResponse() {
+ RawResponse expected = new RawResponse(200, Map.of(), "ok");
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(Uni.createFrom().item(expected));
+
+ RawResponse raw = defaultClient().get("/foo").retrieve().raw().await().indefinitely();
+
+ assertSame(expected, raw);
+ assertEquals(200, raw.statusCode());
+ }
+
+ @Test
+ void fluentRawWithNoThrow_404_doesNotThrowAndExposesStatusAndBody() {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(Uni.createFrom().item(new RawResponse(404, Map.of(), "{\"error\":\"missing\"}")));
+
+ RawResponse raw = defaultClient().get("/foo").noThrow().retrieve().raw().await().indefinitely();
+
+ assertEquals(404, raw.statusCode());
+ assertEquals("{\"error\":\"missing\"}", raw.body());
+ assertTrue(raw.isError());
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void proxyUniRawResponseReturnType_404_autoNoThrowAndReturnsRaw() throws Exception {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(Uni.createFrom().item(new RawResponse(404, Map.of(), "{\"err\":\"x\"}")));
+
+ MutinyArk ark = defaultClient();
+ MutinyReturnTypeHandler handler = new MutinyReturnTypeHandler();
+ Type returnType = TypeHelper.class.getMethod("uniRaw").getGenericReturnType();
+
+ Object result = handler.handle(ark.get("/foo"), returnType);
+
+ assertInstanceOf(Uni.class, result);
+ RawResponse raw = ((Uni) result).await().indefinitely();
+ assertEquals(404, raw.statusCode());
+ assertEquals("{\"err\":\"x\"}", raw.body());
+ }
+}
diff --git a/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/ReactorArkClient.java b/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/ReactorArkClient.java
index 24b9c91..c88fdce 100644
--- a/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/ReactorArkClient.java
+++ b/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/ReactorArkClient.java
@@ -23,15 +23,18 @@ public class ReactorArkClient extends AbstractArkClient requestInterceptors,
- List responseInterceptors) {
- super(serializer, userAgent, baseUrl, requestInterceptors, responseInterceptors);
+ List responseInterceptors,
+ boolean throwOnErrorDefault) {
+ super(serializer, userAgent, baseUrl, requestInterceptors, responseInterceptors,
+ throwOnErrorDefault);
this.transport = transport;
}
@Override
protected DefaultReactorClientRequest createRequest(String method, String path) {
- return new DefaultReactorClientRequest(method, baseUrl, path, transport, serializer,
- requestInterceptors, responseInterceptors)
+ DefaultReactorClientRequest req = new DefaultReactorClientRequest(method, baseUrl, path, transport, serializer,
+ requestInterceptors, responseInterceptors);
+ return req.throwOnError(throwOnErrorDefault)
.header("User-Agent", userAgent);
}
@@ -57,7 +60,8 @@ public ReactorArk build() {
Objects.requireNonNull(transport, "transport must not be null");
logConfiguration("ReactorArkClient (Mono/Flux)", transport.getClass().getSimpleName());
return new ReactorArkClient(transport, serializer, buildUserAgent(),
- baseUrl, requestInterceptors, responseInterceptors);
+ baseUrl, requestInterceptors, responseInterceptors,
+ throwOnErrorDefault);
}
}
}
diff --git a/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/http/DefaultReactorClientResponse.java b/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/http/DefaultReactorClientResponse.java
index 78259e0..6ee7ae8 100644
--- a/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/http/DefaultReactorClientResponse.java
+++ b/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/http/DefaultReactorClientResponse.java
@@ -49,4 +49,9 @@ public Mono> toBodilessEntity() {
return mono.map(raw ->
new ArkResponse<>(raw.statusCode(), raw.headers(), null));
}
+
+ @Override
+ public Mono raw() {
+ return mono;
+ }
}
diff --git a/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/http/ReactorClientRequest.java b/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/http/ReactorClientRequest.java
index 87c91b8..7465c46 100644
--- a/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/http/ReactorClientRequest.java
+++ b/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/http/ReactorClientRequest.java
@@ -23,5 +23,14 @@ public interface ReactorClientRequest extends RequestContext {
ReactorClientRequest timeout(Duration timeout);
+ /**
+ * Opt out of throwing {@link xyz.juandiii.ark.core.exceptions.ApiException}
+ * on HTTP error status codes (4xx/5xx). When called, the response is
+ * returned to the caller unchanged regardless of status.
+ *
+ * @return this request for chaining
+ */
+ ReactorClientRequest noThrow();
+
ReactorClientResponse retrieve();
}
diff --git a/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/http/ReactorClientResponse.java b/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/http/ReactorClientResponse.java
index 3401242..38e454a 100644
--- a/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/http/ReactorClientResponse.java
+++ b/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/http/ReactorClientResponse.java
@@ -4,6 +4,7 @@
import reactor.core.publisher.Mono;
import xyz.juandiii.ark.core.TypeRef;
import xyz.juandiii.ark.core.http.ArkResponse;
+import xyz.juandiii.ark.core.http.RawResponse;
import java.util.List;
@@ -35,4 +36,14 @@ default Flux bodyAsFlux(Class type) {
Mono> toEntity(Class type);
Mono> toBodilessEntity();
+
+ /**
+ * Returns the raw HTTP response — status code, headers, and body as a String —
+ * without deserialization. Useful with {@link ReactorClientRequest#noThrow()} (or
+ * client-level {@code throwOnError(false)}) to inspect error bodies that
+ * don't match a typed schema.
+ *
+ * @return Mono emitting the raw response wrapper produced by the transport
+ */
+ Mono raw();
}
diff --git a/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/proxy/ReactorReturnTypeHandler.java b/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/proxy/ReactorReturnTypeHandler.java
index 7a1cbb3..af13cdd 100644
--- a/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/proxy/ReactorReturnTypeHandler.java
+++ b/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/proxy/ReactorReturnTypeHandler.java
@@ -4,6 +4,7 @@
import reactor.core.publisher.Mono;
import xyz.juandiii.ark.core.TypeRef;
import xyz.juandiii.ark.core.http.ArkResponse;
+import xyz.juandiii.ark.core.http.RawResponse;
import xyz.juandiii.ark.core.interceptor.RequestContext;
import xyz.juandiii.ark.core.proxy.ReturnTypeHandler;
import xyz.juandiii.ark.reactor.http.ReactorClientRequest;
@@ -14,7 +15,8 @@
/**
* Dispatches Reactor request execution based on method return type.
- * Supports Mono<T>, Mono<ArkResponse<T>>, Flux<T>, and void.
+ * Supports Mono<T>, Mono<ArkResponse<T>>, Mono<RawResponse>,
+ * Flux<T>, and void.
*
* @author Juan Diego Lopez V.
*/
@@ -23,22 +25,25 @@ public final class ReactorReturnTypeHandler implements ReturnTypeHandler {
@Override
public Object handle(RequestContext request, Type returnType) {
ReactorClientRequest reactorRequest = (ReactorClientRequest) request;
- ReactorClientResponse response = reactorRequest.retrieve();
if (returnType == void.class || returnType == Void.class) {
- return response.toBodilessEntity();
+ return reactorRequest.retrieve().toBodilessEntity();
}
if (returnType instanceof ParameterizedType pt) {
if (pt.getRawType() == Mono.class) {
- return handleMonoType(response, pt.getActualTypeArguments()[0]);
+ Type innerType = pt.getActualTypeArguments()[0];
+ if (innerType == RawResponse.class) {
+ return reactorRequest.noThrow().retrieve().raw();
+ }
+ return handleMonoType(reactorRequest.retrieve(), innerType);
}
if (pt.getRawType() == Flux.class) {
- return handleFluxType(response, pt.getActualTypeArguments()[0]);
+ return handleFluxType(reactorRequest.retrieve(), pt.getActualTypeArguments()[0]);
}
}
- return response.body(TypeRef.of(returnType));
+ return reactorRequest.retrieve().body(TypeRef.of(returnType));
}
private Object handleMonoType(ReactorClientResponse response, Type innerType) {
diff --git a/execution-models/ark-reactor/src/test/java/xyz/juandiii/ark/reactor/http/ReactorNoThrowTest.java b/execution-models/ark-reactor/src/test/java/xyz/juandiii/ark/reactor/http/ReactorNoThrowTest.java
new file mode 100644
index 0000000..c30a508
--- /dev/null
+++ b/execution-models/ark-reactor/src/test/java/xyz/juandiii/ark/reactor/http/ReactorNoThrowTest.java
@@ -0,0 +1,105 @@
+package xyz.juandiii.ark.reactor.http;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+import xyz.juandiii.ark.core.JsonSerializer;
+import xyz.juandiii.ark.core.exceptions.NotFoundException;
+import xyz.juandiii.ark.core.http.RawResponse;
+import xyz.juandiii.ark.reactor.ReactorArk;
+import xyz.juandiii.ark.reactor.ReactorArkClient;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+
+/**
+ * Verifies the {@code noThrow()} per-request opt-out and the
+ * client-level {@code throwOnError(false)} default for the reactor execution model.
+ */
+@ExtendWith(MockitoExtension.class)
+class ReactorNoThrowTest {
+
+ @Mock
+ JsonSerializer serializer;
+
+ @Mock
+ ReactorHttpTransport transport;
+
+ private ReactorArk client(boolean throwOnError) {
+ return ReactorArkClient.builder()
+ .serializer(serializer)
+ .transport(transport)
+ .baseUrl("https://api.example.com")
+ .throwOnError(throwOnError)
+ .build();
+ }
+
+ private ReactorArk defaultClient() {
+ return ReactorArkClient.builder()
+ .serializer(serializer)
+ .transport(transport)
+ .baseUrl("https://api.example.com")
+ .build();
+ }
+
+ @Test
+ void defaultBehavior_404_monoEmitsNotFoundException() {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(Mono.just(new RawResponse(404, Map.of(), "{\"error\":\"not found\"}")));
+
+ ReactorArk ark = defaultClient();
+ StepVerifier.create(ark.get("/users/1").retrieve().toEntity(String.class))
+ .expectError(NotFoundException.class)
+ .verify();
+ }
+
+ @Test
+ void perRequestNoThrow_404_returnsResponseWithStatus404() {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(Mono.just(new RawResponse(404, Map.of(), "{\"error\":\"not found\"}")));
+
+ ReactorArk ark = defaultClient();
+ StepVerifier.create(ark.get("/users/1").noThrow().retrieve().toEntity(String.class))
+ .assertNext(response -> {
+ assertEquals(404, response.statusCode());
+ assertFalse(response.isSuccessful());
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void clientLevelThrowOnErrorFalse_404_isPermissive() {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(Mono.just(new RawResponse(404, Map.of(), "{\"error\":\"not found\"}")));
+
+ ReactorArk ark = client(false);
+ StepVerifier.create(ark.get("/users/1").retrieve().toEntity(String.class))
+ .assertNext(response -> {
+ assertEquals(404, response.statusCode());
+ assertFalse(response.isSuccessful());
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void clientLevelThrowOnErrorFalse_withoutNoThrow_stillPermissive() {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(Mono.just(new RawResponse(500, Map.of(), "boom")));
+
+ ReactorArk ark = client(false);
+ StepVerifier.create(ark.get("/users/1").retrieve().toEntity(String.class))
+ .assertNext(response -> {
+ assertEquals(500, response.statusCode());
+ assertFalse(response.isSuccessful());
+ })
+ .verifyComplete();
+ }
+}
diff --git a/execution-models/ark-reactor/src/test/java/xyz/juandiii/ark/reactor/http/ReactorRawResponseAccessTest.java b/execution-models/ark-reactor/src/test/java/xyz/juandiii/ark/reactor/http/ReactorRawResponseAccessTest.java
new file mode 100644
index 0000000..897f98e
--- /dev/null
+++ b/execution-models/ark-reactor/src/test/java/xyz/juandiii/ark/reactor/http/ReactorRawResponseAccessTest.java
@@ -0,0 +1,94 @@
+package xyz.juandiii.ark.reactor.http;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+import xyz.juandiii.ark.core.JsonSerializer;
+import xyz.juandiii.ark.core.http.RawResponse;
+import xyz.juandiii.ark.reactor.ReactorArk;
+import xyz.juandiii.ark.reactor.ReactorArkClient;
+import xyz.juandiii.ark.reactor.proxy.ReactorReturnTypeHandler;
+
+import java.lang.reflect.Type;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+
+/**
+ * Verifies fluent {@code .raw()} access and proxy {@code RawResponse} return type
+ * auto-toggling {@code noThrow} for the Reactor execution model.
+ */
+@ExtendWith(MockitoExtension.class)
+class ReactorRawResponseAccessTest {
+
+ @Mock JsonSerializer serializer;
+ @Mock ReactorHttpTransport transport;
+
+ interface TypeHelper {
+ Mono monoRaw();
+ }
+
+ private ReactorArk defaultClient() {
+ return ReactorArkClient.builder()
+ .serializer(serializer)
+ .transport(transport)
+ .baseUrl("https://api.example.com")
+ .build();
+ }
+
+ @Test
+ void fluentRaw_200_returnsTransportRawResponse() {
+ RawResponse expected = new RawResponse(200, Map.of(), "ok");
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(Mono.just(expected));
+
+ StepVerifier.create(defaultClient().get("/foo").retrieve().raw())
+ .assertNext(raw -> {
+ assertSame(expected, raw);
+ assertEquals(200, raw.statusCode());
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void fluentRawWithNoThrow_404_doesNotThrowAndExposesStatusAndBody() {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(Mono.just(new RawResponse(404, Map.of(), "{\"error\":\"missing\"}")));
+
+ StepVerifier.create(defaultClient().get("/foo").noThrow().retrieve().raw())
+ .assertNext(raw -> {
+ assertEquals(404, raw.statusCode());
+ assertEquals("{\"error\":\"missing\"}", raw.body());
+ assertTrue(raw.isError());
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void proxyMonoRawResponseReturnType_404_autoNoThrowAndReturnsRaw() throws Exception {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(Mono.just(new RawResponse(404, Map.of(), "{\"err\":\"x\"}")));
+
+ ReactorArk ark = defaultClient();
+ ReactorReturnTypeHandler handler = new ReactorReturnTypeHandler();
+ Type returnType = TypeHelper.class.getMethod("monoRaw").getGenericReturnType();
+
+ Object result = handler.handle(ark.get("/foo"), returnType);
+
+ assertInstanceOf(Mono.class, result);
+ StepVerifier.create((Mono) result)
+ .assertNext(raw -> {
+ assertEquals(404, raw.statusCode());
+ assertEquals("{\"err\":\"x\"}", raw.body());
+ })
+ .verifyComplete();
+ }
+}
diff --git a/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/VertxArkClient.java b/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/VertxArkClient.java
index cda680c..e13b18a 100644
--- a/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/VertxArkClient.java
+++ b/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/VertxArkClient.java
@@ -23,15 +23,18 @@ public class VertxArkClient extends AbstractArkClient
private VertxArkClient(VertxHttpTransport transport, JsonSerializer serializer,
String userAgent, String baseUrl,
List requestInterceptors,
- List responseInterceptors) {
- super(serializer, userAgent, baseUrl, requestInterceptors, responseInterceptors);
+ List responseInterceptors,
+ boolean throwOnErrorDefault) {
+ super(serializer, userAgent, baseUrl, requestInterceptors, responseInterceptors,
+ throwOnErrorDefault);
this.transport = transport;
}
@Override
protected DefaultVertxClientRequest createRequest(String method, String path) {
- return new DefaultVertxClientRequest(method, baseUrl, path, transport, serializer,
- requestInterceptors, responseInterceptors)
+ DefaultVertxClientRequest req = new DefaultVertxClientRequest(method, baseUrl, path, transport, serializer,
+ requestInterceptors, responseInterceptors);
+ return req.throwOnError(throwOnErrorDefault)
.header("User-Agent", userAgent);
}
@@ -59,7 +62,8 @@ public VertxArk build() {
Objects.requireNonNull(transport, "transport must not be null");
logConfiguration("VertxArkClient (Future)", transport.getClass().getSimpleName());
return new VertxArkClient(transport, serializer, buildUserAgent(),
- baseUrl, requestInterceptors, responseInterceptors);
+ baseUrl, requestInterceptors, responseInterceptors,
+ throwOnErrorDefault);
}
}
}
diff --git a/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/http/DefaultVertxClientResponse.java b/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/http/DefaultVertxClientResponse.java
index 9c877c2..607cc75 100644
--- a/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/http/DefaultVertxClientResponse.java
+++ b/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/http/DefaultVertxClientResponse.java
@@ -49,4 +49,9 @@ public Future> toBodilessEntity() {
return future.map(raw ->
new ArkResponse<>(raw.statusCode(), raw.headers(), null));
}
+
+ @Override
+ public Future raw() {
+ return future;
+ }
}
diff --git a/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/http/VertxClientRequest.java b/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/http/VertxClientRequest.java
index 54f68cf..4db0ae9 100644
--- a/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/http/VertxClientRequest.java
+++ b/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/http/VertxClientRequest.java
@@ -23,5 +23,14 @@ public interface VertxClientRequest extends RequestContext {
VertxClientRequest timeout(Duration timeout);
+ /**
+ * Opt out of throwing {@link xyz.juandiii.ark.core.exceptions.ApiException}
+ * on HTTP error status codes (4xx/5xx). When called, the response is
+ * returned to the caller unchanged regardless of status.
+ *
+ * @return this request for chaining
+ */
+ VertxClientRequest noThrow();
+
VertxClientResponse retrieve();
}
diff --git a/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/http/VertxClientResponse.java b/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/http/VertxClientResponse.java
index 20dcb5b..6b95b3b 100644
--- a/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/http/VertxClientResponse.java
+++ b/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/http/VertxClientResponse.java
@@ -3,6 +3,7 @@
import io.vertx.core.Future;
import xyz.juandiii.ark.core.TypeRef;
import xyz.juandiii.ark.core.http.ArkResponse;
+import xyz.juandiii.ark.core.http.RawResponse;
/**
* Interface for Vert.x response extraction.
@@ -20,4 +21,14 @@ public interface VertxClientResponse {
Future> toEntity(Class type);
Future> toBodilessEntity();
+
+ /**
+ * Returns the raw HTTP response — status code, headers, and body as a String —
+ * without deserialization. Useful with {@link VertxClientRequest#noThrow()} (or
+ * client-level {@code throwOnError(false)}) to inspect error bodies that
+ * don't match a typed schema.
+ *
+ * @return Future completed with the raw response wrapper produced by the transport
+ */
+ Future raw();
}
diff --git a/execution-models/ark-vertx/src/test/java/xyz/juandiii/ark/vertx/http/VertxNoThrowTest.java b/execution-models/ark-vertx/src/test/java/xyz/juandiii/ark/vertx/http/VertxNoThrowTest.java
new file mode 100644
index 0000000..efa3b6d
--- /dev/null
+++ b/execution-models/ark-vertx/src/test/java/xyz/juandiii/ark/vertx/http/VertxNoThrowTest.java
@@ -0,0 +1,124 @@
+package xyz.juandiii.ark.vertx.http;
+
+import io.vertx.core.Future;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import xyz.juandiii.ark.core.JsonSerializer;
+import xyz.juandiii.ark.core.exceptions.NotFoundException;
+import xyz.juandiii.ark.core.http.ArkResponse;
+import xyz.juandiii.ark.core.http.RawResponse;
+import xyz.juandiii.ark.vertx.VertxArk;
+import xyz.juandiii.ark.vertx.VertxArkClient;
+
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+
+/**
+ * Verifies the {@code noThrow()} per-request opt-out and the
+ * client-level {@code throwOnError(false)} default for the Vert.x execution model.
+ */
+@ExtendWith(MockitoExtension.class)
+class VertxNoThrowTest {
+
+ @Mock
+ JsonSerializer serializer;
+
+ @Mock
+ VertxHttpTransport transport;
+
+ private VertxArk client(boolean throwOnError) {
+ return VertxArkClient.builder()
+ .serializer(serializer)
+ .transport(transport)
+ .baseUrl("https://api.example.com")
+ .throwOnError(throwOnError)
+ .build();
+ }
+
+ private VertxArk defaultClient() {
+ return VertxArkClient.builder()
+ .serializer(serializer)
+ .transport(transport)
+ .baseUrl("https://api.example.com")
+ .build();
+ }
+
+ @Test
+ void defaultBehavior_404_futureFailsWithNotFoundException() throws Exception {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(Future.succeededFuture(new RawResponse(404, Map.of(), "{\"error\":\"not found\"}")));
+
+ VertxArk ark = defaultClient();
+ Future> future = ark.get("/users/1").retrieve().toEntity(String.class);
+
+ CountDownLatch latch = new CountDownLatch(1);
+ AtomicReference error = new AtomicReference<>();
+ future.onComplete(ar -> {
+ if (ar.failed()) error.set(ar.cause());
+ latch.countDown();
+ });
+
+ assertTrue(latch.await(5, TimeUnit.SECONDS));
+ assertInstanceOf(NotFoundException.class, error.get());
+ }
+
+ @Test
+ void perRequestNoThrow_404_returnsResponseWithStatus404() throws Exception {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(Future.succeededFuture(new RawResponse(404, Map.of(), "{\"error\":\"not found\"}")));
+
+ VertxArk ark = defaultClient();
+ ArkResponse response = await(ark.get("/users/1").noThrow().retrieve().toEntity(String.class));
+
+ assertEquals(404, response.statusCode());
+ assertFalse(response.isSuccessful());
+ }
+
+ @Test
+ void clientLevelThrowOnErrorFalse_404_isPermissive() throws Exception {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(Future.succeededFuture(new RawResponse(404, Map.of(), "{\"error\":\"not found\"}")));
+
+ VertxArk ark = client(false);
+ ArkResponse response = await(ark.get("/users/1").retrieve().toEntity(String.class));
+
+ assertEquals(404, response.statusCode());
+ assertFalse(response.isSuccessful());
+ }
+
+ @Test
+ void clientLevelThrowOnErrorFalse_withoutNoThrow_stillPermissive() throws Exception {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(Future.succeededFuture(new RawResponse(500, Map.of(), "boom")));
+
+ VertxArk ark = client(false);
+ ArkResponse response = await(ark.get("/users/1").retrieve().toEntity(String.class));
+
+ assertEquals(500, response.statusCode());
+ assertFalse(response.isSuccessful());
+ }
+
+ private T await(Future future) throws Exception {
+ CountDownLatch latch = new CountDownLatch(1);
+ AtomicReference value = new AtomicReference<>();
+ AtomicReference error = new AtomicReference<>();
+ future.onComplete(ar -> {
+ if (ar.succeeded()) value.set(ar.result());
+ else error.set(ar.cause());
+ latch.countDown();
+ });
+ assertTrue(latch.await(5, TimeUnit.SECONDS));
+ if (error.get() != null) throw new RuntimeException(error.get());
+ return value.get();
+ }
+}
diff --git a/execution-models/ark-vertx/src/test/java/xyz/juandiii/ark/vertx/http/VertxRawResponseAccessTest.java b/execution-models/ark-vertx/src/test/java/xyz/juandiii/ark/vertx/http/VertxRawResponseAccessTest.java
new file mode 100644
index 0000000..2245934
--- /dev/null
+++ b/execution-models/ark-vertx/src/test/java/xyz/juandiii/ark/vertx/http/VertxRawResponseAccessTest.java
@@ -0,0 +1,98 @@
+package xyz.juandiii.ark.vertx.http;
+
+import io.vertx.core.Future;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import xyz.juandiii.ark.core.JsonSerializer;
+import xyz.juandiii.ark.core.http.RawResponse;
+import xyz.juandiii.ark.vertx.VertxArk;
+import xyz.juandiii.ark.vertx.VertxArkClient;
+
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+
+/**
+ * Verifies fluent {@code .raw()} access for the Vert.x execution model.
+ * Vert.x has no proxy return-type handler yet, so only fluent paths are covered.
+ */
+@ExtendWith(MockitoExtension.class)
+class VertxRawResponseAccessTest {
+
+ @Mock JsonSerializer serializer;
+ @Mock VertxHttpTransport transport;
+
+ private VertxArk defaultClient() {
+ return VertxArkClient.builder()
+ .serializer(serializer)
+ .transport(transport)
+ .baseUrl("https://api.example.com")
+ .build();
+ }
+
+ @Test
+ void fluentRaw_200_returnsTransportRawResponse() throws Exception {
+ RawResponse expected = new RawResponse(200, Map.of(), "ok");
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(Future.succeededFuture(expected));
+
+ RawResponse raw = await(defaultClient().get("/foo").retrieve().raw());
+
+ assertSame(expected, raw);
+ assertEquals(200, raw.statusCode());
+ }
+
+ @Test
+ void fluentRawWithNoThrow_404_doesNotThrowAndExposesStatusAndBody() throws Exception {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(Future.succeededFuture(new RawResponse(404, Map.of(), "{\"error\":\"missing\"}")));
+
+ RawResponse raw = await(defaultClient().get("/foo").noThrow().retrieve().raw());
+
+ assertEquals(404, raw.statusCode());
+ assertEquals("{\"error\":\"missing\"}", raw.body());
+ assertTrue(raw.isError());
+ }
+
+ @Test
+ void fluentRawWithClientThrowOnErrorFalse_500_exposesRawBody() throws Exception {
+ when(transport.send(anyString(), any(), anyMap(), any(), any()))
+ .thenReturn(Future.succeededFuture(new RawResponse(500, Map.of(), "boom")));
+
+ VertxArk ark = VertxArkClient.builder()
+ .serializer(serializer)
+ .transport(transport)
+ .baseUrl("https://api.example.com")
+ .throwOnError(false)
+ .build();
+
+ RawResponse raw = await(ark.get("/foo").retrieve().raw());
+
+ assertEquals(500, raw.statusCode());
+ assertEquals("boom", raw.body());
+ assertTrue(raw.isError());
+ }
+
+ private T await(Future future) throws Exception {
+ CountDownLatch latch = new CountDownLatch(1);
+ AtomicReference value = new AtomicReference<>();
+ AtomicReference error = new AtomicReference<>();
+ future.onComplete(ar -> {
+ if (ar.succeeded()) value.set(ar.result());
+ else error.set(ar.cause());
+ latch.countDown();
+ });
+ assertTrue(latch.await(5, TimeUnit.SECONDS));
+ if (error.get() != null) throw new RuntimeException(error.get());
+ return value.get();
+ }
+}
diff --git a/extensions/ark-quarkus-jackson/runtime/src/main/java/xyz/juandiii/ark/quarkus/ArkRecorder.java b/extensions/ark-quarkus-jackson/runtime/src/main/java/xyz/juandiii/ark/quarkus/ArkRecorder.java
index 26a1879..812ecf2 100644
--- a/extensions/ark-quarkus-jackson/runtime/src/main/java/xyz/juandiii/ark/quarkus/ArkRecorder.java
+++ b/extensions/ark-quarkus-jackson/runtime/src/main/java/xyz/juandiii/ark/quarkus/ArkRecorder.java
@@ -75,7 +75,8 @@ public Supplier> createArkClient(String interfaceName, String configKey) {
private record ResolvedConfig(String clientName, String baseUrl, HttpVersion httpVersion,
int connectTimeout, int readTimeout, String tlsConfigName,
- boolean trustAll, Map headers,
+ boolean trustAll, boolean throwOnError,
+ Map headers,
Class>[] interceptorClasses,
RetryPolicy retryPolicy,
LoggingInterceptor.Level loggingLevel) {}
@@ -91,6 +92,7 @@ private static ResolvedConfig resolveConfig(String clientName, ArkClientNamedCon
config != null ? config.readTimeout() : annotation.readTimeout(),
config != null ? config.tlsConfigurationName().orElse(null) : null,
config != null && config.trustAll(),
+ config == null || config.throwOnError(),
config != null ? config.headers() : Map.of(),
annotation != null ? annotation.interceptors() : new Class>[0],
resolveRetryPolicy(config),
@@ -147,6 +149,7 @@ private static > void applyInterceptors(
InterceptorResolver.applyInterceptors(builder, rc.interceptorClasses(),
clazz -> Arc.container().instance(clazz).get());
LoggingInterceptor.apply(builder, rc.loggingLevel());
+ builder.throwOnError(rc.throwOnError());
}
private static String resolveBaseUrl(ArkClientNamedConfig config, RegisterArkClient annotation) {
diff --git a/extensions/ark-quarkus-jackson/runtime/src/main/java/xyz/juandiii/ark/quarkus/config/ArkClientNamedConfig.java b/extensions/ark-quarkus-jackson/runtime/src/main/java/xyz/juandiii/ark/quarkus/config/ArkClientNamedConfig.java
index 2e21ea3..748199d 100644
--- a/extensions/ark-quarkus-jackson/runtime/src/main/java/xyz/juandiii/ark/quarkus/config/ArkClientNamedConfig.java
+++ b/extensions/ark-quarkus-jackson/runtime/src/main/java/xyz/juandiii/ark/quarkus/config/ArkClientNamedConfig.java
@@ -57,6 +57,17 @@ public interface ArkClientNamedConfig {
@WithDefault("false")
boolean trustAll();
+ /**
+ * If true (default), HTTP 4xx/5xx responses raise ApiException. If false,
+ * the response is returned to the caller regardless of status — useful
+ * when 4xx is expected business semantics (e.g. 404 = not found, not an
+ * error). Per-request .noThrow() can still opt out on a client where
+ * this is true.
+ */
+ @WithName("throw-on-error")
+ @WithDefault("true")
+ boolean throwOnError();
+
/**
* Default headers to add to every request.
*/
diff --git a/starters/ark-spring-boot-starter-webflux/src/main/java/xyz/juandiii/ark/spring/webflux/ArkWebFluxClientFactoryBean.java b/starters/ark-spring-boot-starter-webflux/src/main/java/xyz/juandiii/ark/spring/webflux/ArkWebFluxClientFactoryBean.java
index bfbe4c2..1b46c3e 100644
--- a/starters/ark-spring-boot-starter-webflux/src/main/java/xyz/juandiii/ark/spring/webflux/ArkWebFluxClientFactoryBean.java
+++ b/starters/ark-spring-boot-starter-webflux/src/main/java/xyz/juandiii/ark/spring/webflux/ArkWebFluxClientFactoryBean.java
@@ -92,6 +92,9 @@ public T getObject() {
InterceptorResolver.applyHeaders(builder, config != null ? config.headers() : null);
InterceptorResolver.applyInterceptors(builder, annotation.interceptors(), beanFactory::getBean);
LoggingInterceptor.apply(builder, arkProperties.logging().level());
+ if (config != null) {
+ builder.throwOnError(config.throwOnError());
+ }
return (T) ArkProxy.create(clientInterface, builder.build());
}
diff --git a/starters/ark-spring-boot-starter-webflux/src/main/java/xyz/juandiii/ark/spring/webflux/ArkWebFluxProperties.java b/starters/ark-spring-boot-starter-webflux/src/main/java/xyz/juandiii/ark/spring/webflux/ArkWebFluxProperties.java
index ddecc70..e58c311 100644
--- a/starters/ark-spring-boot-starter-webflux/src/main/java/xyz/juandiii/ark/spring/webflux/ArkWebFluxProperties.java
+++ b/starters/ark-spring-boot-starter-webflux/src/main/java/xyz/juandiii/ark/spring/webflux/ArkWebFluxProperties.java
@@ -38,6 +38,7 @@ public record ClientProperties(
@DefaultValue("30") int readTimeout,
String tlsConfigurationName,
@DefaultValue("false") boolean trustAll,
+ @DefaultValue("true") boolean throwOnError,
@DefaultValue Map headers
) {
public ClientProperties {
diff --git a/starters/ark-spring-boot-starter/src/main/java/xyz/juandiii/ark/spring/ArkClientFactoryBean.java b/starters/ark-spring-boot-starter/src/main/java/xyz/juandiii/ark/spring/ArkClientFactoryBean.java
index d0fc02c..141bfc3 100644
--- a/starters/ark-spring-boot-starter/src/main/java/xyz/juandiii/ark/spring/ArkClientFactoryBean.java
+++ b/starters/ark-spring-boot-starter/src/main/java/xyz/juandiii/ark/spring/ArkClientFactoryBean.java
@@ -160,6 +160,9 @@ private > void applyCommon
InterceptorResolver.applyHeaders(builder, config != null ? config.headers() : null);
InterceptorResolver.applyInterceptors(builder, annotation.interceptors(), beanFactory::getBean);
LoggingInterceptor.apply(builder, arkProperties.logging().level());
+ if (config != null) {
+ builder.throwOnError(config.throwOnError());
+ }
}
@Override
diff --git a/starters/ark-spring-boot-starter/src/main/java/xyz/juandiii/ark/spring/ArkProperties.java b/starters/ark-spring-boot-starter/src/main/java/xyz/juandiii/ark/spring/ArkProperties.java
index db3ac12..525de90 100644
--- a/starters/ark-spring-boot-starter/src/main/java/xyz/juandiii/ark/spring/ArkProperties.java
+++ b/starters/ark-spring-boot-starter/src/main/java/xyz/juandiii/ark/spring/ArkProperties.java
@@ -53,6 +53,7 @@ public record ClientProperties(
@DefaultValue("30") int readTimeout,
String tlsConfigurationName,
@DefaultValue("false") boolean trustAll,
+ @DefaultValue("true") boolean throwOnError,
@DefaultValue Map headers,
RetryProperties retry
) {