Skip to content

Commit ff34c0c

Browse files
authored
Merge pull request #808 from aleksei-averchenko-wise/create-ticket-idempotency-key
Add support for idempotency keys when creating tickets
2 parents b990fba + 2ff6446 commit ff34c0c

11 files changed

Lines changed: 712 additions & 90 deletions

AGENTS.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Agent Instructions for zendesk-java-client
2+
3+
This document provides guidance for AI agents and developers working on this project.
4+
5+
## Java Version Requirements
6+
7+
This project **compiles with Java 11** (as specified in `pom.xml` with `maven.compiler.source` and
8+
`maven.compiler.target` set to 11) but **must maintain Java 8 API compatibility**.
9+
10+
For example, you're allowed to use Java 11 compiler features in the code base (such as type inference
11+
with `var`) but not any new standard library features introduced after Java 8, such as `VarHandle`.
12+
This is enforced at build time using the `animal-sniffer` enforcer plugin.
13+
14+
### Running Maven Commands
15+
16+
**NB:** When running Maven commands, ensure you're using the Java 11 version of the JDK to avoid
17+
any build issues and ensure compatibility. The precise way to do so depends on developer machine
18+
setup.
19+
20+
### Common Commands
21+
22+
**Build the project:**
23+
```bash
24+
mvn verify
25+
```
26+
27+
**Run tests:**
28+
```bash
29+
mvn test
30+
```
31+
32+
**Apply code formatting:**
33+
```bash
34+
mvn spotless:apply
35+
```
36+
37+
**Check code formatting without applying changes:**
38+
```bash
39+
mvn spotless:check
40+
```
41+
42+
## Code Formatting
43+
44+
This project uses [Spotless](https://github.com/diffplug/spotless) with google-java-format for code
45+
formatting.
46+
47+
- All Java code must be formatted before committing
48+
- Run `mvn spotless:apply` to format code automatically
49+
50+
## Project Structure
51+
52+
- **Source code:** `src/main/java/org/zendesk/client/v2/`
53+
- **Tests:** `src/test/java/org/zendesk/client/v2/`
54+
- **Main entry point:** `Zendesk.java` - The primary API client class
55+
56+
## Dependencies
57+
58+
Key dependencies include:
59+
- async-http-client for HTTP operations
60+
- Jackson for JSON serialization/deserialization
61+
- SLF4J for logging
62+
- JUnit 4 for testing
63+
- WireMock for HTTP mocking in tests

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
AGENTS.md

README.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,95 @@ all records have been fetched, so e.g.
3333
will iterate through *all* tickets. Most likely you will want to implement your own cut-off process to stop iterating
3434
when you have got enough data.
3535

36+
Idempotency
37+
-----------
38+
39+
The Zendesk API supports [idempotency keys](https://developer.zendesk.com/api-reference/ticketing/introduction/#idempotency)
40+
to safely retry operations without creating duplicate resources. This client supports idempotent
41+
ticket creation via `createTicketIdempotent` and `createTicketIdempotentAsync`.
42+
Either method may throw a `ZendeskResponseIdempotencyConflictException` if the same idempotency key
43+
is used in two requests with non-identical payloads.
44+
45+
### Usage Example
46+
47+
The following example illustrates a usage pattern for publishing updates to a Zendesk ticket
48+
that tracks some application specific issue. It ensures that only one ticket is created per
49+
issue, even if multiple updates are published concurrently for the same issue, or if the update is
50+
retried due to a transient failure after the ticket has already been created.
51+
52+
```java
53+
class FooIssueService {
54+
55+
private final Zendesk zendesk;
56+
private final Logger logger = LoggerFactory.getLogger(FooIssueService.class);
57+
58+
// Simple use case: the ticket payload depends only on the issue itself
59+
public void postIssueUpdateSimple(FooIssue issue, String update) {
60+
IdempotentResult<Ticket> result = zendesk.createTicketIdempotent(
61+
toTicketSimple(issue),
62+
toIdempotencyKey(issue));
63+
64+
if (!result.isDuplicateRequest()) {
65+
logger.info("Created new ticket (id = {})", result.get().getId());
66+
}
67+
68+
postIssueComment(result.get().getId(), update);
69+
}
70+
71+
// Advanced use case: the ticket payload depends on the update
72+
public void postIssueUpdateAdvanced(FooIssue issue, String update) {
73+
// Fast path pre-check, would be unsafe without idempotency b/c TOCTOU.
74+
Optional<Ticket> optTicket = findTicket(issue);
75+
if (optTicket.isPresent()) {
76+
postIssueComment(optTicket.get().getId(), update);
77+
return;
78+
}
79+
80+
try {
81+
IdempotentResult<Ticket> result = zendesk.createTicketIdempotent(
82+
toTicketAdvanced(issue, update),
83+
toIdempotencyKey(issue));
84+
85+
if (!result.isDuplicateRequest()) {
86+
logger.info("Created new ticket (id = {})", result.get().getId());
87+
}
88+
} catch (ZendeskResponseIdempotencyConflictException e) {
89+
Ticket ticket = findTicket(issue).orElseThrow(
90+
() -> new IllegalStateException(
91+
String.format("Ticket not found for issue %s", issue.getId()), e));
92+
postIssueComment(ticket.getId(), update);
93+
}
94+
}
95+
96+
private static Ticket toTicketSimple(FooIssue issue) {
97+
return toTicketAdvanced(issue, "See comments for details");
98+
}
99+
100+
private static Ticket toTicketAdvanced(FooIssue issue, String update) {
101+
Ticket ticket = new Ticket(issue.getRequesterId(), issue.getTitle(), new Comment(update));
102+
ticket.setExternalId(toIdempotencyKey(issue));
103+
return ticket;
104+
}
105+
106+
private static String toIdempotencyKey(FooIssue issue) {
107+
// Must map the issue 1-to-1, so that retries for the same issue use the same key.
108+
return String.format("foo-issue-%s", issue.getId());
109+
}
110+
111+
private void postIssueComment(long ticketId, String update) {
112+
Comment comment = zendesk.createComment(ticketId, new Comment(update));
113+
logger.info("Added comment (id = {}) to ticket (id = {})", comment.getId(), ticketId);
114+
}
115+
116+
private Optional<Ticket> findTicket(FooIssue issue) {
117+
Iterator<Ticket> ticketsIt = zendesk.getTicketsByExternalId(issue.getId()).iterator();
118+
return ticketsIt.hasNext()
119+
? Optional.of(ticketsIt.next())
120+
: Optional.empty();
121+
}
122+
}
123+
```
124+
36125
Community
37126
-------------
38127

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package org.zendesk.client.v2;
2+
3+
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import com.fasterxml.jackson.databind.JsonNode;
5+
import com.fasterxml.jackson.databind.ObjectMapper;
6+
import org.asynchttpclient.AsyncCompletionHandler;
7+
import org.asynchttpclient.RequestBuilder;
8+
import org.asynchttpclient.Response;
9+
import org.zendesk.client.v2.model.IdempotentResult;
10+
11+
/**
12+
* Utility class for handling Zendesk API idempotency keys.
13+
*
14+
* <p>Provides methods to add idempotency headers to requests and process idempotency-related
15+
* response headers. Supports the Zendesk API's idempotency feature which allows safe retries of
16+
* create operations without creating duplicate resources.
17+
*
18+
* @see <a href="https://developer.zendesk.com/api-reference/ticketing/introduction/#idempotency">
19+
* Zendesk API Idempotency</a>
20+
* @since 1.5.0
21+
*/
22+
public class IdempotencyUtil {
23+
24+
static final String IDEMPOTENCY_KEY_HEADER = "Idempotency-Key";
25+
static final String IDEMPOTENCY_LOOKUP_HEADER = "x-idempotency-lookup";
26+
static final String IDEMPOTENCY_LOOKUP_HIT = "hit";
27+
static final String IDEMPOTENCY_LOOKUP_MISS = "miss";
28+
static final String IDEMPOTENCY_ERROR_NAME = "IdempotentRequestError";
29+
30+
public static RequestBuilder addIdempotencyHeader(RequestBuilder builder, String idempotencyKey) {
31+
// https://developer.zendesk.com/api-reference/ticketing/introduction/#idempotency
32+
return builder.setHeader(IDEMPOTENCY_KEY_HEADER, idempotencyKey);
33+
}
34+
35+
public static <T> AsyncCompletionHandler<IdempotentResult<T>> wrapHandler(
36+
AsyncCompletionHandler<T> handler) {
37+
return new AsyncCompletionHandler<>() {
38+
@Override
39+
public IdempotentResult<T> onCompleted(Response response) throws Exception {
40+
T entity = handler.onCompleted(response);
41+
boolean duplicateRequest = isDuplicateResponse(response);
42+
43+
return new IdempotentResult<>(entity, duplicateRequest);
44+
}
45+
46+
@Override
47+
public void onThrowable(Throwable t) {
48+
handler.onThrowable(t);
49+
}
50+
};
51+
}
52+
53+
public static boolean isIdempotencyConflict(Response response, ObjectMapper mapper)
54+
throws JsonProcessingException {
55+
if (response.getStatusCode() != 400) {
56+
return false;
57+
}
58+
59+
// Note: Jackson's own docs are a bit outdated in that `readTree` returns
60+
// `MissingNode.getInstance()` and not `null` when given an essentially empty string.
61+
JsonNode error = mapper.readTree(response.getResponseBody()).path("error");
62+
return IDEMPOTENCY_ERROR_NAME.equals(error.textValue());
63+
}
64+
65+
private static boolean isDuplicateResponse(Response response) {
66+
// https://developer.zendesk.com/api-reference/ticketing/introduction/#idempotency
67+
String idempotencyLookup = response.getHeader(IDEMPOTENCY_LOOKUP_HEADER);
68+
if (idempotencyLookup == null) {
69+
idempotencyLookup = "<absent>";
70+
}
71+
72+
switch (idempotencyLookup) {
73+
case IDEMPOTENCY_LOOKUP_HIT:
74+
return true;
75+
case IDEMPOTENCY_LOOKUP_MISS:
76+
return false;
77+
default:
78+
throw new IllegalArgumentException(
79+
String.format(
80+
"Unexpected value of the idempotency lookup header: %s", idempotencyLookup));
81+
}
82+
}
83+
84+
private IdempotencyUtil() {
85+
throw new UnsupportedOperationException("Utility class");
86+
}
87+
}

0 commit comments

Comments
 (0)