Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
decb851
refactor(pom): drop redundant spring-framework-bom (use spring-boot's…
juandiii Jun 19, 2026
61ccd57
ci(compat): add weekly cron workflow for Spring Boot + Quarkus matrix
juandiii Jun 19, 2026
d01aad9
docs(compat): add compatibility matrix verified by weekly cron
juandiii Jun 19, 2026
08203bb
ci(release): gate release PR on compat-matrix passing
juandiii Jun 19, 2026
142524c
Merge from main
juandiii Jun 19, 2026
21cf140
Merge branch 'main' into develop
juandiii Jun 19, 2026
cc6bd40
fix(ci): skip snapshot deploy when pom version is not -SNAPSHOT
juandiii Jun 19, 2026
c5e83e4
Merge branch 'worktree-agent-afd90226bac238864' into develop
juandiii Jun 19, 2026
76fd10f
feat(core): add throwOnError flag + noThrow() opt-out in AbstractClie…
juandiii Jun 24, 2026
1a2390e
feat(client): expose noThrow() in all 5 ClientRequest interfaces + th…
juandiii Jun 24, 2026
452092f
test(noThrow): cover sync/async/reactor/mutiny/vertx noThrow behavior…
juandiii Jun 24, 2026
b599fbb
feat(spring): bind throw-on-error in ArkProperties + apply via ArkCli…
juandiii Jun 24, 2026
175ea30
feat(quarkus): bind throw-on-error in ArkClientNamedConfig + apply vi…
juandiii Jun 24, 2026
e9aa1b6
docs(noThrow): document throw-on-error property in Spring and Quarkus…
juandiii Jun 24, 2026
2a27f80
chore: add gitignore
juandiii Jun 24, 2026
83ebf20
feat(core): expose RawResponse via ClientResponse.raw() across all 5 …
juandiii Jun 24, 2026
a791caf
feat(proxy): handle RawResponse return type in *ReturnTypeHandlers (a…
juandiii Jun 24, 2026
34ecedb
test(raw): cover RawResponse access via fluent .raw() and proxy retur…
juandiii Jun 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/compat-weekly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
33 changes: 30 additions & 3 deletions .github/workflows/deploy-snapshot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<version>[^<]+</version>' 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
Expand All @@ -37,4 +64,4 @@ jobs:
env:
NEXUS_SNAPSHOTS_URL: ${{ secrets.NEXUS_SNAPSHOTS_URL }}
NEXUS_USERNAME: ${{ secrets.NEXUS_USERNAME }}
NEXUS_PASSWORD: ${{ secrets.NEXUS_PASSWORD }}
NEXUS_PASSWORD: ${{ secrets.NEXUS_PASSWORD }}
8 changes: 7 additions & 1 deletion .github/workflows/prepare-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,5 @@ build/
CLAUDE.md

.flattened-pom.xml

plans
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public abstract class AbstractArkBuilder<B extends AbstractArkBuilder<B>> {
protected HttpVersion httpVersion;
protected int connectTimeoutSecs = -1;
protected int readTimeoutSecs = -1;
protected boolean throwOnErrorDefault = true;
protected final List<RequestInterceptor> requestInterceptors = new ArrayList<>();
protected final List<ResponseInterceptor> responseInterceptors = new ArrayList<>();

Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,18 @@ public abstract class AbstractArkClient<R extends AbstractClientRequest<R>> {
protected final String baseUrl;
protected final List<RequestInterceptor> requestInterceptors;
protected final List<ResponseInterceptor> responseInterceptors;
protected final boolean throwOnErrorDefault;

protected AbstractArkClient(JsonSerializer serializer, String userAgent, String baseUrl,
List<RequestInterceptor> requestInterceptors,
List<ResponseInterceptor> responseInterceptors) {
List<ResponseInterceptor> 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);
Expand Down
14 changes: 9 additions & 5 deletions core/ark-core/src/main/java/xyz/juandiii/ark/core/ArkClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,18 @@ public class ArkClient extends AbstractArkClient<DefaultClientRequest> implement
private ArkClient(Transport<RawResponse> transport, JsonSerializer serializer, String userAgent,
String baseUrl,
List<RequestInterceptor> requestInterceptors,
List<ResponseInterceptor> responseInterceptors) {
super(serializer, userAgent, baseUrl, requestInterceptors, responseInterceptors);
List<ResponseInterceptor> 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);
}

Expand Down Expand Up @@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ public abstract class AbstractClientRequest<T extends AbstractClientRequest<T>>
protected final JsonSerializer serializer;
protected final List<RequestInterceptor> requestInterceptors;
protected final List<ResponseInterceptor> responseInterceptors;
private boolean throwOnError = true;

protected AbstractClientRequest(String method, String baseUrl, String path,
JsonSerializer serializer,
Expand Down Expand Up @@ -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));
}
Expand All @@ -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());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,14 @@ public interface ClientResponse {
* @return response wrapper with a {@code Void} body
*/
ArkResponse<Void> 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();
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,9 @@ public <T> ArkResponse<T> toEntity(Class<T> type) {
public ArkResponse<Void> toBodilessEntity() {
return new ArkResponse<>(raw.statusCode(), raw.headers(), null);
}

@Override
public RawResponse raw() {
return raw;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -64,8 +64,10 @@ static class TestArkClient extends AbstractArkClient<DefaultClientRequest> {

TestArkClient(HttpTransport transport, JsonSerializer serializer, String userAgent,
String baseUrl, List<RequestInterceptor> requestInterceptors,
List<ResponseInterceptor> responseInterceptors) {
super(serializer, userAgent, baseUrl, requestInterceptors, responseInterceptors);
List<ResponseInterceptor> responseInterceptors,
boolean throwOnErrorDefault) {
super(serializer, userAgent, baseUrl, requestInterceptors, responseInterceptors,
throwOnErrorDefault);
this.transport = transport;
}

Expand Down
Loading