Skip to content
Open
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
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,57 @@ all records have been fetched, so e.g.
will iterate through *all* tickets. Most likely you will want to implement your own cut-off process to stop iterating
when you have got enough data.

Idempotency
-----------

The Zendesk API supports [idempotency keys](https://developer.zendesk.com/api-reference/ticketing/introduction/#idempotency)
to safely retry operations without creating duplicate resources. This client supports idempotency
for ticket creation operations.

**Note:** Currently, only `createTicket()` and `createTicketAsync()` fully support idempotency.
Other create operations (comments, users, etc.) do not yet support this feature, and
`queueCreateTicketAsync` might not work as expected when used with idempotency keys.

### Usage Example

```java
class FooIssueService {
private final Zendesk zendesk;
private final IssueRepository issueRepository;
private final Logger logger = LoggerFactory.getLogger(FooIssueService.class);

// ...

public void postIssueUpdate(FooIssue issue, String update) {
// Note: in production code, we should probably also check for an existing ticket before
// trying to create a new one, in addition to the fallback.
Ticket ticket = new Ticket(issue.getRequesterId(), issue.getTitle(), new Comment(update));
ticket.setIdempotencyKey(generateIdempotencyKey(issue));

try {
ticket = zendesk.createTicket(ticket);
if (Boolean.FALSE.equals(ticket.getIsIdempotencyHit())) {
issueRepository.saveTicketId(issue.getId(), ticket.getId());
logger.info("Created new ticket (id = {})", ticket.getId());
}
} catch (ZendeskResponseIdempotencyConflictException e) {
// Already created by a concurrent update, so just add a comment to the existing ticket
long existingTicketId = issueRepository.getTicketId(issue.getId());
Comment comment = zendesk.createComment(existingTicketId, new Comment(update));
logger.info(
"Added comment (id = {}) to existing ticket (id = {})",
comment.getId(),
existingTicketId);
}
}

private String generateIdempotencyKey(FooIssue issue) {
// Must map 1-to-1 with the issue, so that retries for the same issue use the same key
return String.format("issue-%s", issue.getId());
}
}
```

Community
-------------

Expand Down
6 changes: 6 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,12 @@
<version>4.3.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.15.2</version>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand Down
117 changes: 117 additions & 0 deletions src/main/java/org/zendesk/client/v2/IdempotencyUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package org.zendesk.client.v2;

import java.util.Optional;
import org.asynchttpclient.AsyncCompletionHandler;
import org.asynchttpclient.Request;
import org.asynchttpclient.Response;
import org.zendesk.client.v2.model.IdempotencyState;
import org.zendesk.client.v2.model.IdempotencyState.Status;
import org.zendesk.client.v2.model.IdempotentEntity;

/**
* Utility class for handling Zendesk API idempotency keys.
*
* <p>Provides methods to add idempotency headers to requests and process idempotency-related
* response headers. Supports the Zendesk API's idempotency feature which allows safe retries of
* create operations without creating duplicate resources.
*
* @see <a href="https://developer.zendesk.com/api-reference/ticketing/introduction/#idempotency">
* Zendesk API Idempotency</a>
* @since 1.5.0
*/
public class IdempotencyUtil {

static final String IDEMPOTENCY_KEY_HEADER = "Idempotency-Key";
static final String IDEMPOTENCY_LOOKUP_HEADER = "x-idempotency-lookup";
static final String IDEMPOTENCY_LOOKUP_HIT = "hit";
static final String IDEMPOTENCY_LOOKUP_MISS = "miss";
static final String IDEMPOTENCY_ERROR_NAME = "IdempotentRequestError";

/**
* Adds an idempotency key header to the request if the state is present and pending.
*
* @param request the HTTP request to modify
* @param state the idempotency state, or null if idempotency is not being used
* @return a new request with the idempotency key header added, or the original request if state
* is null
* @throws IllegalArgumentException if the state is not in PENDING status
*/
public static Request addIdempotencyState(Request request, IdempotencyState state) {
if (state == null) {
return request;
}

if (state.getStatus() != Status.PENDING) {
throw new IllegalArgumentException("Idempotency state must be PENDING to add to a request");
}

// https://developer.zendesk.com/api-reference/ticketing/introduction/#idempotency
return request.toBuilder()
.setHeader(IDEMPOTENCY_KEY_HEADER, state.getIdempotencyKey())
.build();
}

/**
* Wraps an async completion handler to process idempotency response headers.
*
* <p>The wrapped handler will automatically update the entity's idempotency fields based on the
* response headers returned by the Zendesk API. If the {@code x-idempotency-lookup} header
* indicates a "hit", the entity will be marked as previously created. If "miss", it will be
* marked as newly created.
*
* @param <T> the entity type that implements IdempotentEntity
* @param handler the original async completion handler
* @param idempotencyState the idempotency state, or null if idempotency is not being used
* @return a wrapped handler that processes idempotency headers, or the original handler if state
* is null
*/
public static <T extends IdempotentEntity> AsyncCompletionHandler<T> wrapHandler(
AsyncCompletionHandler<T> handler,
IdempotencyState idempotencyState) {
if (idempotencyState == null) {
return handler;
}

return new AsyncCompletionHandler<>() {
@Override
public T onCompleted(Response response) throws Exception {
T entity = handler.onCompleted(response);
transitionIdempotencyState(idempotencyState, response)
.ifPresent(newState -> newState.apply(entity));
return entity;
}

@Override
public void onThrowable(Throwable t) {
handler.onThrowable(t);
}
};
}

private static Optional<IdempotencyState> transitionIdempotencyState(
IdempotencyState state,
Response response) {
if (state == null) {
return Optional.empty();
}

// https://developer.zendesk.com/api-reference/ticketing/introduction/#idempotency
String idempotencyLookup = response.getHeader(IDEMPOTENCY_LOOKUP_HEADER);
if (idempotencyLookup == null) {
return Optional.empty();
}

switch (idempotencyLookup) {
case IDEMPOTENCY_LOOKUP_HIT:
return Optional.of(state.toPreviouslyCreated());
case IDEMPOTENCY_LOOKUP_MISS:
return Optional.of(state.toCreated());
default:
return Optional.empty();
}
}

private IdempotencyUtil() {
throw new UnsupportedOperationException("Utility class");
}
}
67 changes: 55 additions & 12 deletions src/main/java/org/zendesk/client/v2/Zendesk.java
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
import org.zendesk.client.v2.model.Forum;
import org.zendesk.client.v2.model.Group;
import org.zendesk.client.v2.model.GroupMembership;
import org.zendesk.client.v2.model.IdempotencyState;
import org.zendesk.client.v2.model.IdempotentEntity;
import org.zendesk.client.v2.model.Identity;
import org.zendesk.client.v2.model.JiraLink;
import org.zendesk.client.v2.model.JobStatus;
Expand Down Expand Up @@ -432,9 +434,15 @@ public ListenableFuture<JobStatus> queueCreateTicketAsync(Ticket ticket) {
}

public ListenableFuture<Ticket> createTicketAsync(Ticket ticket) {
IdempotencyState idempotencyState = IdempotencyState.of(ticket).orElse(null);
return submit(
req("POST", cnst("/tickets.json"), JSON, json(Collections.singletonMap("ticket", ticket))),
handle(Ticket.class, "ticket"));
req(
"POST",
cnst("/tickets.json"),
JSON,
json(Collections.singletonMap("ticket", ticket))),
handle(Ticket.class, "ticket"),
idempotencyState);
}

public Ticket createTicket(Ticket ticket) {
Expand Down Expand Up @@ -3474,8 +3482,7 @@ private byte[] json(Object object) {
}
}

private <T> ListenableFuture<T> submit(
Request request, ZendeskAsyncCompletionHandler<T> handler) {
private <T> ListenableFuture<T> submit(Request request, AsyncCompletionHandler<T> handler) {
if (logger.isDebugEnabled()) {
if (request.getStringData() != null) {
logger.debug(
Expand All @@ -3494,6 +3501,15 @@ private <T> ListenableFuture<T> submit(
return client.executeRequest(request, handler);
}

private <T extends IdempotentEntity> ListenableFuture<T> submit(
Request request,
AsyncCompletionHandler<T> handler,
IdempotencyState idempotencyState) {
return submit(
IdempotencyUtil.addIdempotencyState(request, idempotencyState),
IdempotencyUtil.wrapHandler(handler, idempotencyState));
}

private abstract static class ZendeskAsyncCompletionHandler<T> extends AsyncCompletionHandler<T> {
@Override
public void onThrowable(Throwable t) {
Expand Down Expand Up @@ -3592,12 +3608,20 @@ public T onCompleted(Response response) throws Exception {
}
return mapper.convertValue(
mapper.readTree(response.getResponseBodyAsStream()).get(name), clazz);
} else if (isRateLimitResponse(response)) {
}

if (isRateLimitResponse(response)) {
throw new ZendeskResponseRateLimitException(response);
}

if (isIdempotencyConflict(response)) {
throw new ZendeskResponseIdempotencyConflictException(response);
}

if (response.getStatusCode() == 404) {
return null;
}

throw new ZendeskResponseException(response);
}
}
Expand Down Expand Up @@ -3925,6 +3949,21 @@ private boolean isRateLimitResponse(Response response) {
return response.getStatusCode() == 429;
}

private boolean isIdempotencyConflict(Response response) throws IOException {
if (response.getStatusCode() != 400) {
return false;
}

try {
Map<?, ?> body = mapper.readValue(response.getResponseBody(), Map.class);
return IdempotencyUtil.IDEMPOTENCY_ERROR_NAME.equals(body.get("error"));
} catch (JsonProcessingException e) {
ZendeskResponseException exception = new ZendeskResponseException(response);
exception.addSuppressed(e);
throw exception;
}
}

//////////////////////////////////////////////////////////////////////
// Static helper methods
//////////////////////////////////////////////////////////////////////
Expand All @@ -3935,14 +3974,18 @@ private static <T> T complete(ListenableFuture<T> future) {
} catch (InterruptedException e) {
throw new ZendeskException(e.getMessage(), e);
} catch (ExecutionException e) {
if (e.getCause() instanceof ZendeskResponseRateLimitException) {
throw new ZendeskResponseRateLimitException(
(ZendeskResponseRateLimitException) e.getCause());
}
if (e.getCause() instanceof ZendeskResponseIdempotencyConflictException) {
throw new ZendeskResponseIdempotencyConflictException(
(ZendeskResponseIdempotencyConflictException) e.getCause());
}
if (e.getCause() instanceof ZendeskResponseException) {
throw new ZendeskResponseException((ZendeskResponseException) e.getCause());
}
if (e.getCause() instanceof ZendeskException) {
if (e.getCause() instanceof ZendeskResponseRateLimitException) {
throw new ZendeskResponseRateLimitException(
(ZendeskResponseRateLimitException) e.getCause());
}
if (e.getCause() instanceof ZendeskResponseException) {
throw new ZendeskResponseException((ZendeskResponseException) e.getCause());
}
throw new ZendeskException(e.getCause());
}
throw new ZendeskException(e.getMessage(), e);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package org.zendesk.client.v2;

import java.io.IOException;
import org.asynchttpclient.Response;

/**
* Exception thrown when the Zendesk API returns an idempotency conflict error.
*
* <p>This exception is thrown when a request is retried with the same idempotency key but
* different request parameters. The API returns a 400 status code with
* {@code error: "IdempotentRequestError"} to indicate that the request parameters don't match the
* original request associated with the idempotency key.
*
* <p>To resolve this error, either use a new idempotency key or ensure the request parameters
* match the original request.
*
* @see <a href="https://developer.zendesk.com/api-reference/ticketing/introduction/#idempotency">
* Zendesk API Idempotency</a>
* @since 1.5.0
*/
public class ZendeskResponseIdempotencyConflictException extends ZendeskResponseException {

private static final long serialVersionUID = 1L;

public ZendeskResponseIdempotencyConflictException(Response res) throws IOException {
super(res);
}

public ZendeskResponseIdempotencyConflictException(
int statusCode, String statusText, String body) {
super(statusCode, statusText, body);
}

public ZendeskResponseIdempotencyConflictException(
ZendeskResponseIdempotencyConflictException cause) {
super(cause);
}
}
Loading