Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@
import com.netflix.conductor.client.metrics.PayloadKind;
import com.netflix.conductor.common.config.ObjectMapperProvider;

import io.orkes.conductor.client.http.ApiException;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JavaType;
Expand Down Expand Up @@ -393,23 +395,30 @@ private RequestBody serialize(String contentType, @NotNull Object body) {
return RequestBody.create(content, MediaType.parse(contentType));
}
// Existing behavior for unsupported non-JSON, non-text types
throw new ConductorClientException("Content type \"" + contentType + "\" is not supported");
ConductorClientException unsupported =
new ConductorClientException("Content type \"" + contentType + "\" is not supported");
unsupported.setDefinite(true);
throw unsupported;
}

protected <T> T handleResponse(Response response, Type returnType) {
if (!response.isSuccessful()) {
String respBody = bodyAsString(response);
boolean definite = ApiException.definiteFor(response.code());
try {
ConductorClientException exception = objectMapper.readValue(respBody, ConductorClientException.class);
exception.setStatus(response.code());
exception.setDefinite(definite);
throw exception;
} catch (JsonProcessingException jpe) {
// Ignore
}
throw new ConductorClientException(response.message(),
ConductorClientException exception = new ConductorClientException(response.message(),
response.code(),
response.headers().toMultimap(),
respBody);
exception.setDefinite(definite);
throw exception;
}

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,9 @@ public void checkAndUploadToExternalStorage(StartWorkflowRequest startWorkflowRe
* 1024L)) {
String errorMsg = String.format("Input payload larger than the allowed threshold of: %d KB",
conductorClientConfiguration.getWorkflowInputPayloadThresholdKB());
throw new ConductorClientException(errorMsg);
ConductorClientException tooLarge = new ConductorClientException(errorMsg);
tooLarge.setDefinite(true);
throw tooLarge;
} else {
eventDispatcher.publish(new WorkflowPayloadUsedEvent(startWorkflowRequest.getName(),
startWorkflowRequest.getVersion(),
Expand All @@ -259,7 +261,9 @@ public void checkAndUploadToExternalStorage(StartWorkflowRequest startWorkflowRe
eventDispatcher.publish(new WorkflowStartedEvent(startWorkflowRequest.getName(),
startWorkflowRequest.getVersion(), false, e));

throw new ConductorClientException(e);
ConductorClientException serializationFailed = new ConductorClientException(e);
serializationFailed.setDefinite(true);
throw serializationFailed;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import com.netflix.conductor.common.validation.ValidationError;

import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import lombok.Setter;

Expand All @@ -35,10 +36,17 @@ static boolean initPreferErrOverResponse() {

private final static boolean PREFER_ERR_OVER_RESPONSE = initPreferErrOverResponse();

private static final int HTTP_PAYMENT_REQUIRED = 402;
private static final int HTTP_REQUEST_TIMEOUT = 408;
private static final int HTTP_CONFLICT = 409;
private static final int HTTP_LOCKED = 423;
private static final int HTTP_TOO_MANY_REQUESTS = 429;

private int status;
private String instance;
private String code;
@Setter private boolean retryable;
@JsonIgnore @Setter private boolean definite;
private List<ValidationError> validationErrors; //List of validation errors. Available when the status code is 400
private Map<String, List<String>> responseHeaders;
private String responseBody;
Expand Down Expand Up @@ -94,6 +102,47 @@ public boolean isClientError() {
return getStatus() > 399 && getStatus() < 499;
}

/**
* Whether this error proves the request had no effect.
*
* <p>{@code true} means the server never applied the request, so retrying it is safe.
*
* <p>{@code false} means the outcome is unknown: the request may or may not have been applied.
* It does not mean the request succeeded, and it does not mean retrying is unsafe — only that
* a retry may duplicate the work. {@code false} is the default, because most transport
* failures prove nothing.
*
* <p>A dropped connection is always indeterminate, including {@code ConnectException}. OkHttp
* may retry a request on a fresh route after a pooled connection fails mid-send, so "failed to
* connect" can follow a request the server already received.
*
* <p>This is not {@code isRetryable()}. That one says whether trying again is worth it; this
* one says whether trying again can duplicate work. They are independent and often opposite: a
* 503 is retryable and indeterminate at the same time.
*/
public boolean isDefinite() {
return definite;
}

/**
* Whether an HTTP status proves the server rejected the request without applying it.
*
* <p>Most 4xx codes qualify. 402, 408, 409, 423 and 429 do not: Conductor can return these
* after it has already written, so a caller that retried them could duplicate the work.
*
* <p>This classification reflects the current server's behaviour and may change as the server
* changes; it is advisory, not a durable guarantee.
*/
public static boolean definiteFor(int status) {
return status >= 400
&& status < 500
&& status != HTTP_PAYMENT_REQUIRED
&& status != HTTP_REQUEST_TIMEOUT
&& status != HTTP_CONFLICT
&& status != HTTP_LOCKED
&& status != HTTP_TOO_MANY_REQUESTS;
}

/**
* @return HTTP status code
*/
Expand Down Expand Up @@ -122,15 +171,18 @@ public String toString() {
builder.append(getMessage());
}

builder.append(" {");

if (status > 0) {
builder.append(" {status=").append(status);
builder.append("status=").append(status);
if (this.code != null) {
builder.append(", code='").append(code).append("'");
}

builder.append(", retryable: ").append(retryable);
builder.append(", retryable: ").append(retryable).append(", ");
}

builder.append("definite: ").append(definite);

if (this.instance != null) {
builder.append(", instance: ").append(instance);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,14 @@
import java.util.List;
import java.util.Map;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import io.orkes.conductor.client.http.ApiException;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;

import static org.junit.jupiter.api.Assertions.*;

class ConductorClientExceptionTest {
Expand Down Expand Up @@ -132,4 +138,88 @@ void testIsClientErrorBoundaries() {
assertFalse(new ConductorClientException(499, "").isClientError());
assertFalse(new ConductorClientException(500, "").isClientError());
}

@Test
@DisplayName("An error is indeterminate unless something proves otherwise")
void isDefinite_byDefault_isFalse() {
var e = new ConductorClientException("boom");
assertFalse(e.isDefinite(), "the safe default is indeterminate");
}

@Test
@DisplayName("The flag round-trips, so the getter cannot quietly become a constant")
void setDefinite_thenIsDefinite_isTrue() {
var e = new ConductorClientException("boom");
e.setDefinite(true);
assertTrue(e.isDefinite());
}

@Test
@DisplayName("A plain 4xx means the server rejected the request without applying it")
void definiteFor_clientErrors_isTrue() {
assertTrue(ApiException.definiteFor(400));
assertTrue(ApiException.definiteFor(401));
assertTrue(ApiException.definiteFor(403));
assertTrue(ApiException.definiteFor(404));
assertTrue(ApiException.definiteFor(405));
assertTrue(ApiException.definiteFor(415));
}

@Test
@DisplayName("A 5xx may have applied the write before failing, so it stays indeterminate")
void definiteFor_serverErrors_isFalse() {
assertFalse(ApiException.definiteFor(500));
assertFalse(ApiException.definiteFor(502));
assertFalse(ApiException.definiteFor(503));
assertFalse(ApiException.definiteFor(504));
}

@Test
@DisplayName("Conductor can return these five 4xx codes after it has already written")
void definiteFor_postWriteClientErrors_isFalse() {
assertFalse(ApiException.definiteFor(402), "a definition can be written before replaceTags throws PAYMENT_REQUIRED");
assertFalse(ApiException.definiteFor(408), "the server may have begun processing a partial request");
assertFalse(ApiException.definiteFor(409), "FAIL_ON_RUNNING throws CONFLICT after createOnly, without removing the row");
assertFalse(ApiException.definiteFor(423), "LOCK is returned on paths that invite a retry");
assertFalse(ApiException.definiteFor(429), "RATE_LIMITED is thrown after createOnly");
}

@Test
@DisplayName("No status means no response, which proves nothing")
void definiteFor_noStatus_isFalse() {
assertFalse(ApiException.definiteFor(0));
assertFalse(ApiException.definiteFor(200));
}

@Test
@DisplayName("toString renders a clean brace with no status, the Jepsen case the status>0 guard would hide definite in")
void toString_withNoStatus_rendersDefiniteWithoutGarbage() {
var e = new ConductorClientException("connection failed");
e.setDefinite(false);

assertEquals(0, e.getStatus(), "this is the no-response case the guard must not hide definite behind");
assertEquals(
"com.netflix.conductor.client.exception.ConductorClientException: connection failed {definite: false}",
e.toString());
}

@Test
@DisplayName("toString with a status keeps the existing status>0 shape, now followed by definite")
void toString_withStatus_rendersStatusThenDefinite() {
var e = new ConductorClientException(400, "bad request");
e.setRetryable(true);
e.setDefinite(true);

assertEquals(
"com.netflix.conductor.client.exception.ConductorClientException: bad request {status=400, retryable: true, definite: true}",
e.toString());
}

@Test
@DisplayName("A server error body cannot talk the client into claiming definiteness")
void definite_isNotDeserializedFromTheResponseBody() throws Exception {
var mapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
var e = mapper.readValue("{\"definite\":true,\"code\":\"X\"}", ConductorClientException.class);
assertFalse(e.isDefinite(), "definiteness is the client's call, not the server's");
}
}
Loading
Loading