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
45 changes: 44 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,47 @@ Resend resend = Resend.builder()

Create one `Resend` instance and reuse it: every service it returns shares the same HTTP client.

### Retries and timeouts

Retries are off by default. Set `maxRetries` on the builder to retry failed requests for every call:

```java
Resend resend = Resend.builder()
.apiKey("re_123")
.maxRetries(3)
.build();
```

A request is retried when the API answers `429` or any `5xx`, or when the connection fails (refused, reset, or closed
mid-response). The SDK waits between attempts with exponential backoff (starting at 500 ms, capped at 5 s, with
jitter), or for as long as the `Retry-After` header asks for, up to 30 s.

When the last attempt still gets an error response from the API, you get the usual `ResendException`. When the last
attempt fails before any response arrives (a connection failure, a timeout, or a failure that retrying can't fix, such
as an unknown host or a TLS error), the SDK throws a `RuntimeException` wrapping the underlying `IOException`.

A `POST` may already have been processed when a `5xx` or a connection failure happens, so it is retried on `429`
always, but on `5xx` or connection failures only when it carries an idempotency key. Timeouts are never retried.

The waits between attempts block the calling thread and are not covered by the timeout, which applies to each
attempt separately. In the worst case a request takes about `(maxRetries + 1)` attempts plus up to 30 s of waiting per
retry, so keep `maxRetries` small for latency-sensitive code. Interrupting the thread ends the wait immediately.

`RequestOptions` can override both settings for a single request. The timeout covers one whole attempt, from
connecting to reading the full response, and replaces the client's `callTimeout` for that request:

```java
RequestOptions options = RequestOptions.builder()
.setIdempotencyKey("order-1234")
.maxRetries(5)
.timeout(Duration.ofSeconds(15))
.build();

CreateEmailResponse data = resend.emails().send(params, options);
```

Per-request options are available on `emails().send(...)`, `batch().send(...)` and `contacts().imports().create(...)`.

### Custom HTTP client

To take full control of the HTTP layer, pass your own `IHttpClient` with `.httpClient(...)`. For example, to use
Expand All @@ -114,6 +155,8 @@ Resend resend = Resend.builder()
.build();
```

A custom `httpClient` can't be combined with `baseUrl`, the timeouts or `proxy`; configure those on your client.
A custom `httpClient` can't be combined with `baseUrl`, the timeouts, `maxRetries` or `proxy`; configure those on your
client. To enable retries on the built-in `HttpClient`, pass the retry count as the third argument:
`new HttpClient(okHttpClient, "https://api.resend.com", 3)`.

You can view all the examples in the [examples folder](https://github.com/resendlabs/resend-java-example)
47 changes: 37 additions & 10 deletions src/main/java/com/resend/Resend.java
Original file line number Diff line number Diff line change
Expand Up @@ -253,11 +253,12 @@ public Usage usage() {
*
* <p>Only the API key is required. The other options fall into two groups, which can't be combined:</p>
* <ul>
* <li>{@link #baseUrl}, the timeouts and {@link #proxy} configure the built-in HTTP client. When none of them
* is set, the instance uses the same shared client as {@link Resend#Resend(String)}; otherwise it gets a
* client derived from the shared one, which still reuses its connection pool.</li>
* <li>{@link #httpClient} replaces the built-in client entirely. Configure the base URL, timeouts and proxy on
* that client instead.</li>
* <li>{@link #baseUrl}, the timeouts, {@link #maxRetries} and {@link #proxy} configure the built-in HTTP
* client. When none of them is set, the instance uses the same shared client as
* {@link Resend#Resend(String)}; otherwise it gets a client derived from the shared one, which still reuses
* its connection pool.</li>
* <li>{@link #httpClient} replaces the built-in client entirely. Configure the base URL, timeouts, retries and
* proxy on that client instead.</li>
* </ul>
*/
public static final class Builder {
Expand All @@ -269,6 +270,7 @@ public static final class Builder {
private Duration writeTimeout;
private Duration callTimeout;
private Proxy proxy;
private Integer maxRetries;
private IHttpClient<String> httpClient;

private Builder() {
Expand Down Expand Up @@ -359,12 +361,36 @@ public Builder proxy(final Proxy proxy) {
return this;
}

/**
* Sets how many times a failed request is retried. Defaults to 0, which disables retries; a single request
* can override it with {@code RequestOptions.builder().maxRetries(...)}.
*
* <p>A request is retried on HTTP 429, on HTTP 5xx and on connection failures (a refused or reset
* connection, or one closed mid-response), waiting between attempts with exponential backoff, or for the
* time the {@code Retry-After} header asks for. Timeouts and failures that a retry can't fix, such as an
* unknown host or a TLS error, are not retried. A {@code POST} is retried on HTTP 429 always, but on HTTP
* 5xx or a connection failure only when it carries an idempotency key, because the request may already have
* been processed.</p>
*
* @param maxRetries The maximum number of retries per request.
* @return This builder.
* @throws IllegalArgumentException If the value is negative.
*/
public Builder maxRetries(final int maxRetries) {
if (maxRetries < 0) {
throw new IllegalArgumentException("maxRetries must not be negative, got: " + maxRetries);
}
this.maxRetries = maxRetries;
return this;
}

/**
* Sets the HTTP client that executes every request, replacing the built-in one. Use it to plug in another
* HTTP library, a test double, or {@code new HttpClient(okHttpClient, baseUrl)} to supply your own
* {@code OkHttpClient} (which requires declaring the {@code com.squareup.okhttp3:okhttp-jvm} dependency).
*
* <p>Can't be combined with {@link #baseUrl}, the timeouts or {@link #proxy}; set those on the client.</p>
* <p>Can't be combined with {@link #baseUrl}, the timeouts, {@link #maxRetries} or {@link #proxy}; set those
* on the client.</p>
*
* @param httpClient The HTTP client.
* @return This builder.
Expand All @@ -388,16 +414,16 @@ public Resend build() {
}
if (httpClient != null) {
if (configuresBuiltInClient()) {
throw new IllegalStateException("baseUrl, timeouts and proxy configure the built-in HTTP client "
+ "and can't be combined with httpClient(...); set them on your client instead");
throw new IllegalStateException("baseUrl, timeouts, maxRetries and proxy configure the built-in "
+ "HTTP client and can't be combined with httpClient(...); set them on your client instead");
}
return new Resend(apiKey, httpClient);
}
return new Resend(apiKey, buildHttpClient());
}

private boolean configuresBuiltInClient() {
return baseUrl != null || hasOkHttpOverrides();
return baseUrl != null || maxRetries != null || hasOkHttpOverrides();
}

private boolean hasOkHttpOverrides() {
Expand Down Expand Up @@ -431,7 +457,8 @@ private HttpClient buildHttpClient() {
}
client = clientBuilder.build();
}
return new HttpClient(client, baseUrl != null ? baseUrl : HttpClient.BASE_API);
return new HttpClient(client, baseUrl != null ? baseUrl : HttpClient.BASE_API,
maxRetries != null ? maxRetries : 0);
}

private static Duration requireNonNegative(final String name, final Duration timeout) {
Expand Down
74 changes: 74 additions & 0 deletions src/main/java/com/resend/core/net/RequestOptions.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.resend.core.net;

import java.time.Duration;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
Expand All @@ -10,6 +11,8 @@
public class RequestOptions {
private final String idempotencyKey;
private final Map<String, String> additionalHeaders;
private final Duration timeout;
private final Integer maxRetries;

/**
* Constructs a RequestOptions object using the provided builder.
Expand All @@ -19,6 +22,26 @@ public class RequestOptions {
public RequestOptions(Builder builder) {
this.idempotencyKey = builder.idempotencyKey;
this.additionalHeaders = Collections.unmodifiableMap(new HashMap<>(builder.additionalHeaders));
this.timeout = builder.timeout;
this.maxRetries = builder.maxRetries;
}

/**
* Get the maximum number of retries for this request.
*
* @return The maximum number of retries, or {@code null} to use the client's configured default.
*/
public Integer getMaxRetries() {
return maxRetries;
}

/**
* Get the timeout applied to this request.
*
* @return The timeout, or {@code null} to use the client's configured timeouts.
*/
public Duration getTimeout() {
return timeout;
}

/**
Expand Down Expand Up @@ -52,8 +75,12 @@ public static Builder builder() {
* Builder class for constructing RequestOptions objects.
*/
public static class Builder {
private static final Duration MAX_TIMEOUT = Duration.ofNanos(Long.MAX_VALUE);

private String idempotencyKey;
private final Map<String, String> additionalHeaders;
private Duration timeout;
private Integer maxRetries;

/**
* Constructs a new Builder with empty additional headers map.
Expand Down Expand Up @@ -96,6 +123,53 @@ public Builder addAll(Map<String, String> headers) {
return this;
}

/**
* Set the timeout for this request, covering the whole call from connecting to reading the full response.
* It overrides the client's {@code callTimeout} for this request only.
*
* <p>Only the built-in {@code HttpClient} honors this option; a custom {@code IHttpClient} may ignore it.</p>
*
* @param timeout The timeout; {@link Duration#ZERO} means no timeout.
* @return The builder instance.
* @throws IllegalArgumentException If the timeout is negative, or too large to be expressed in nanoseconds
* (more than {@code Long.MAX_VALUE} nanoseconds, about 292 years).
*/
public Builder timeout(Duration timeout) {
if (timeout != null && timeout.isNegative()) {
throw new IllegalArgumentException("timeout must not be negative, got: " + timeout);
}
if (timeout != null && timeout.compareTo(MAX_TIMEOUT) > 0) {
throw new IllegalArgumentException("timeout must not exceed " + MAX_TIMEOUT + ", got: " + timeout);
}
this.timeout = timeout;
return this;
}

/**
* Set the maximum number of times this request is retried after a retryable failure, overriding the
* client's default. Zero disables retries for this request.
*
* <p>A request is retried on HTTP 429, on HTTP 5xx and on connection failures (a refused or reset
* connection, or one closed mid-response), waiting between attempts with exponential backoff (or the
* {@code Retry-After} header when the server sends one). Timeouts and failures that a retry can't fix, such
* as an unknown host or a TLS error, are not retried. A {@code POST} is retried on HTTP 429 always, but on
* HTTP 5xx or a connection failure only when it carries an idempotency key, because the request may already
* have been processed.</p>
*
* <p>Only the built-in {@code HttpClient} honors this option; a custom {@code IHttpClient} may ignore it.</p>
*
* @param maxRetries The maximum number of retries.
* @return The builder instance.
* @throws IllegalArgumentException If the value is negative.
*/
public Builder maxRetries(int maxRetries) {
if (maxRetries < 0) {
throw new IllegalArgumentException("maxRetries must not be negative, got: " + maxRetries);
}
this.maxRetries = maxRetries;
return this;
}

/**
* Build a new RequestOptions object.
*
Expand Down
Loading
Loading