`). |
| `getStatistics()` | Dataset statistics. Completes with `JsonNode`; throws `NotFoundError` if the dataset is gone (no separate "statistics absent" state). |
-| `createItemsPublicUrl(DatasetListItemsOptions, Long expiresInSecs)` | A public (optionally signed) items URL, served as `json`. Completes with `String`. |
+| `createItemsPublicUrl(DatasetListItemsOptions, Long expiresInSecs)` | A public (optionally signed) items URL, served as `json`. `expiresInSecs` must be positive and at most `4611686018427387` when set. Completes with `String`. |
| `createItemsPublicUrl(DatasetListItemsOptions, Long expiresInSecs, DownloadItemsFormat format)` | As above, with the URL's serialization `format` set explicitly. |
> **Server-side item filters, `unwind`, and iteration.** The dataset-items endpoint applies
> `offset`/`limit` to the raw rows and then transforms them: a filter (`skipEmpty`, `skipHidden`,
> `clean`, `simplified`) can drop rows (fewer items returned than scanned), and `unwind` can split a
-> row's array field into several items (more items returned than scanned). Where the API reports the
-> number of rows it scanned (`X-Apify-Pagination-Count`), `iterateItems` advances by that number
-> rather than by the number of items returned, so neither case repeats already-seen items, skips
-> rows, nor ends iteration early. (A reported `0` is trusted only when the page also returned
-> nothing, since scanning zero rows can never produce items; any other combination falls back to the
-> returned count, as if the header were absent.)
+> row's array field into several items (more items returned than scanned). On a page from
+> `listItems`, `PaginationList.getCount()` is the number of rows the API scanned for it
+> (`X-Apify-Pagination-Count`), or the number of items returned when that is larger, since the header
+> is derived from the dataset's item count, which lags a fresh push. `iterateItems` advances by that
+> count, capped at the rows the request asked for, rather than by the number of items returned, so
+> neither case repeats already-seen items, skips rows, nor ends iteration early. The `limit` counts
+> rows too: a filter can leave fewer items than `limit`, and `unwind` more. To page through a
+> filtered dataset by hand, advance `offset` by `min(count, limit)`; the cap matters only with
+> `unwind`. `PaginationList.getScannedCount()` still returns the raw header value, and is
+> deprecated in favor of `getCount()`.
```java
Dataset ds = client.datasets().getOrCreate("my-dataset").join();
@@ -106,8 +110,11 @@ adds `attachment` (`Boolean`), `bom` (`Boolean`), `delimiter` (`String`), `skipH
`createItemsPublicUrl(DatasetListItemsOptions, Long expiresInSecs[, DownloadItemsFormat format])`
completes with a `String` URL. If the dataset is private, the client fetches it, reads its
URL-signing secret, and appends an HMAC-SHA256 signature (bounded by `expiresInSecs`, or
-non-expiring when `null`); for public datasets the URL is unsigned. The optional `format` sets the
-serialization the URL serves items in (default `json` when omitted/`null`).
+non-expiring when `null`); for public datasets the URL is unsigned. `expiresInSecs` must be positive
+and at most `4611686018427387`, otherwise the method throws `IllegalArgumentException` before any
+request is sent. The expiry counts from the moment the URL is signed, after the dataset lookup. The
+optional `format` sets the serialization the URL serves items in (default `json` when
+omitted/`null`).
> **Public-URL limitation (matches the JavaScript reference client).** The public-URL builders
> (`createItemsPublicUrl`, and the key-value-store `getRecordPublicUrl` / `createKeysPublicUrl`)
@@ -139,7 +146,7 @@ datasets.
| `setRecordJson(String key, Object value)` | Store JSON. Completes with no value. |
| `deleteRecord(String key)` | Delete a record. Completes with no value. |
| `getRecordPublicUrl(String key)` | A public (optionally signed) record URL. Completes with `String`. |
-| `createKeysPublicUrl(Long expiresInSecs)` | A public (optionally signed) key-list URL. Completes with `String`. |
+| `createKeysPublicUrl(Long expiresInSecs)` | A public (optionally signed) key-list URL. `expiresInSecs` must be positive and at most `4611686018427387` when set. Completes with `String`. |
| `createKeysPublicUrl(ListKeysOptions, Long expiresInSecs)` | As above, forwarding key-listing filters (`limit`, `prefix`, `collection`, `exclusiveStartKey`). |
`ListKeysOptions` fields (all optional): `limit(Long)`, `exclusiveStartKey(String)`,
@@ -186,7 +193,8 @@ Both `getRecordPublicUrl` and `createKeysPublicUrl` complete with a `String` URL
and unsigned for public ones. They differ because they sign different things: a single record URL
signs the record key directly (there is no expiry to bound), while a key-list URL uses an
expiry-aware storage-content signature — hence only `createKeysPublicUrl` takes an `expiresInSecs`
-(`null` = non-expiring).
+(`null` = non-expiring, otherwise positive and at most `4611686018427387`; anything else throws
+`IllegalArgumentException` before any request is sent).
## Request queues
diff --git a/pom.xml b/pom.xml
index bbb34ab..b8e90ab 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
com.apify
apify-client
- 0.12.0
+ 0.13.0
jar
Apify Java Client
diff --git a/src/main/java/com/apify/client/PaginationList.java b/src/main/java/com/apify/client/PaginationList.java
index 6caf693..77b7a21 100644
--- a/src/main/java/com/apify/client/PaginationList.java
+++ b/src/main/java/com/apify/client/PaginationList.java
@@ -39,7 +39,17 @@ public long getLimit() {
return limit;
}
- /** Number of items actually returned in this page. */
+ /**
+ * Number of entries on this page. On a page from {@link
+ * com.apify.client.dataset.DatasetClient#listItems}, the number of dataset rows the API scanned
+ * for it ({@code X-Apify-Pagination-Count}), or the number of items returned when that is larger,
+ * since the header is derived from the dataset's item count, which lags a fresh push. Filters
+ * ({@code clean}, {@code skipEmpty}) can leave it above the number of items returned. The next
+ * page starts at {@code offset + Math.min(count, limit)}; the cap matters only with {@code
+ * unwind}, which can return more items than the rows scanned. On any other list it is usually the
+ * number of items returned; when the API drops entries after counting them, as the Store list
+ * can, it is the number counted.
+ */
public long getCount() {
return count;
}
@@ -50,24 +60,17 @@ public boolean isDesc() {
}
/**
- * The number of rows the API scanned to produce this page, before any item-level transform (e.g.
- * dataset items' {@code clean}/{@code skipEmpty}/{@code skipHidden} filters, which can make this
- * larger than {@link #getCount()}, or {@code unwind}, which can make it smaller), as reported by
- * the {@code X-Apify-Pagination-Count} response header. {@code null} when the endpoint does not
- * send that header, in which case {@link #getCount()} is the right value to advance pagination by
- * - the two always agree outside the dataset items endpoint.
- *
- * A reported {@code 0} together with a nonzero {@link #getCount()} cannot be a genuine answer
- * (scanning zero rows can never produce items), so that specific combination is treated as the
- * header not actually being populated for the request rather than as real data. See {@code
- * AsyncPaginatedPublisher.applyPage} for the exact fallback callers reading this field directly
- * should mirror.
+ * The raw value of the {@code X-Apify-Pagination-Count} response header of a dataset items page:
+ * the number of rows the API scanned to produce it. {@code null} when the header is absent, which
+ * is always the case outside the dataset items endpoint.
*
- *
Internal pagination-engine detail, exposed here (rather than hidden) only because {@link
- * #getItems()} and this metadata necessarily travel together on the same page object; most
- * callers never need it directly - {@link com.apify.client.dataset.DatasetClient#iterateItems}
- * already accounts for it.
+ * @deprecated {@link #getCount()} reports the scanned rows, or the items returned when that is
+ * larger, which is what pagination has to advance by: the header is derived from the
+ * dataset's item count, which lags a fresh push, so it can fall below the items returned.
+ * Page by {@code getCount()}, as {@link com.apify.client.dataset.DatasetClient#iterateItems}
+ * does.
*/
+ @Deprecated
public Long getScannedCount() {
return scannedCount;
}
@@ -104,6 +107,12 @@ public void setDesc(boolean desc) {
this.desc = desc;
}
+ /**
+ * Sets the raw value of the scanned-rows header.
+ *
+ * @deprecated see {@link #getScannedCount()}
+ */
+ @Deprecated
public void setScannedCount(Long scannedCount) {
this.scannedCount = scannedCount;
}
diff --git a/src/main/java/com/apify/client/Version.java b/src/main/java/com/apify/client/Version.java
index 2e3d187..8c1d9fe 100644
--- a/src/main/java/com/apify/client/Version.java
+++ b/src/main/java/com/apify/client/Version.java
@@ -13,13 +13,13 @@ public final class Version {
* The semantic version of this client library (see SemVer).
* Changes to the public interface other than additive ones are considered breaking changes.
*/
- public static final String CLIENT_VERSION = "0.12.0";
+ public static final String CLIENT_VERSION = "0.13.0";
/**
* The version of the Apify OpenAPI specification this client was generated and verified against.
* Corresponds to the {@code info.version} field of the Apify OpenAPI document.
*/
- public static final String API_SPEC_VERSION = "v2-2026-10-29T100357Z";
+ public static final String API_SPEC_VERSION = "v2-2026-11-04T151257Z";
private Version() {}
}
diff --git a/src/main/java/com/apify/client/actor/Actor.java b/src/main/java/com/apify/client/actor/Actor.java
index 52bc6be..a5778ee 100644
--- a/src/main/java/com/apify/client/actor/Actor.java
+++ b/src/main/java/com/apify/client/actor/Actor.java
@@ -28,6 +28,7 @@ public final class Actor extends ApifyResource {
private String seoDescription;
private List categories = List.of();
private ActorStandby actorStandby;
+ private String standbyUrl;
private String actorPermissionLevel;
/** The unique Actor ID. */
@@ -142,6 +143,14 @@ public ActorStandby getActorStandby() {
return actorStandby;
}
+ /**
+ * The URL for sending requests to the Actor in Standby mode; {@code null} if Standby mode is not
+ * enabled. See {@link ActorClient#server()} for sending them.
+ */
+ public String getStandbyUrl() {
+ return standbyUrl;
+ }
+
/** The permission level of the Actor on the Apify platform (e.g. {@code "OWNER"}). */
public String getActorPermissionLevel() {
return actorPermissionLevel;
diff --git a/src/main/java/com/apify/client/actor/ActorClient.java b/src/main/java/com/apify/client/actor/ActorClient.java
index 36666dc..8228ae3 100644
--- a/src/main/java/com/apify/client/actor/ActorClient.java
+++ b/src/main/java/com/apify/client/actor/ActorClient.java
@@ -18,6 +18,9 @@
import com.apify.client.webhook.NestedWebhookCollectionClient;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import tools.jackson.databind.JsonNode;
/**
@@ -27,11 +30,17 @@
* access its runs, builds, versions and webhooks.
*/
public final class ActorClient {
+ private static final Logger LOG = LoggerFactory.getLogger(ActorClient.class);
+
+ /** Set once the deprecation of {@link #standby()} was logged, which is done once per process. */
+ private static final AtomicBoolean STANDBY_DEPRECATION_LOGGED = new AtomicBoolean();
+
private final ApifyClient root;
private final HttpClientCore http;
private final ResourceContext ctx;
private final String baseUrl;
private final String id;
+ private final ActorServerClient server;
public ActorClient(ApifyClient root, HttpClientCore http, String baseUrl, String id) {
this.root = root;
@@ -39,6 +48,7 @@ public ActorClient(ApifyClient root, HttpClientCore http, String baseUrl, String
this.ctx = ResourceContext.single(http, baseUrl, ApiPaths.ACTORS, id);
this.baseUrl = baseUrl;
this.id = id;
+ this.server = new ActorServerClient(http, baseUrl, id);
}
/** The Actor's ID (or {@code username~name}) as provided. */
@@ -196,6 +206,36 @@ public RunClient lastRun(LastRunOptions options) {
return RunClient.lastRun(root, http, ctx.subUrl(""), options);
}
+ /**
+ * Returns a client for the server interface of this Actor.
+ *
+ * A Server Actor runs a web server, its server interface, that is called over HTTP without
+ * starting a run for each call. Server Actors were formerly called Actor Standby, and {@link
+ * #standby()} is a deprecated alias of this method. Every call returns the same client, so the
+ * server URL it fetches is reused.
+ *
+ * @see Standby mode
+ */
+ public ActorServerClient server() {
+ return server;
+ }
+
+ /**
+ * Returns a client for the server interface of this Actor in Standby mode, named after Actor
+ * Standby, the former name of Server Actors.
+ *
+ * @deprecated Use {@link #server()} instead. Will be removed in a future major version.
+ */
+ @Deprecated
+ public ActorServerClient standby() {
+ if (STANDBY_DEPRECATION_LOGGED.compareAndSet(false, true)) {
+ LOG.warn(
+ "`standby()` is deprecated and will be removed in a future major version. Use"
+ + " `server()` instead.");
+ }
+ return server();
+ }
+
/** A client for this Actor's build collection. */
public BuildCollectionClient builds() {
return new BuildCollectionClient(http, ctx.subUrl(""), "builds");
diff --git a/src/main/java/com/apify/client/actor/ActorServerClient.java b/src/main/java/com/apify/client/actor/ActorServerClient.java
new file mode 100644
index 0000000..b16c200
--- /dev/null
+++ b/src/main/java/com/apify/client/actor/ActorServerClient.java
@@ -0,0 +1,400 @@
+package com.apify.client.actor;
+
+import com.apify.client.build.Build;
+import com.apify.client.http.ApiResponse;
+import com.apify.client.http.ServerActorException;
+import com.apify.client.http.Timeout;
+import com.apify.client.internal.ApiPaths;
+import com.apify.client.internal.CancellableFuture;
+import com.apify.client.internal.HttpClientCore;
+import com.apify.client.internal.Json;
+import com.apify.client.internal.QueryParams;
+import com.apify.client.internal.RequestPolicy;
+import com.apify.client.internal.ResourceContext;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import tools.jackson.databind.JsonNode;
+
+/**
+ * A client for the server interface of a specific Actor, from {@link ActorClient#server()}.
+ *
+ *
A Server Actor (formerly Actor Standby) runs a web server. You call it over HTTP, and the
+ * platform keeps a run ready to answer, so each call skips the run startup. This client sends
+ * requests to the Actor's web server and gets its URL and OpenAPI specification.
+ *
+ *
{@code
+ * ActorServerClient server = client.actor("username/actor-name").server();
+ * ApiResponse response =
+ * server.request("/search", new ActorServerRequestOptions().param("q", "apify")).join();
+ * System.out.println(response.bodyAsString());
+ * }
+ *
+ * @see Standby mode
+ */
+public final class ActorServerClient {
+
+ /** The tag whose build serves the server interface when the Actor sets no build. */
+ private static final String DEFAULT_BUILD_TAG = "latest";
+
+ /** A build number like {@code 0.1.23}: the version {@code 0.1} and the sequence number 23. */
+ private static final Pattern BUILD_NUMBER_PATTERN = Pattern.compile("^(\\d+)\\.(\\d+)\\.(\\d+)$");
+
+ /** The path of an Actor's builds, relative to the Actor. */
+ private static final String BUILDS_PATH = "builds";
+
+ /** The path of a request that names none: the root of the server. */
+ private static final String ROOT_PATH = "/";
+
+ private static final String FRAGMENT_START = "#";
+
+ private final HttpClientCore http;
+ private final ResourceContext ctx;
+ private final String baseUrl;
+ private final String id;
+
+ /**
+ * The server URL. Getting it takes an API call for the Actor, so it is cached for {@link #getUrl}
+ * and the requests. It is pending while the first lookup runs, so concurrent first calls share
+ * one API request, and it is cleared when the lookup fails, so the next call tries again.
+ */
+ private final AtomicReference> url = new AtomicReference<>();
+
+ ActorServerClient(HttpClientCore http, String baseUrl, String id) {
+ this.http = http;
+ this.ctx = ResourceContext.single(http, baseUrl, ApiPaths.ACTORS, id);
+ this.baseUrl = baseUrl;
+ this.id = id;
+ }
+
+ /**
+ * Returns the URL of the Actor's server.
+ *
+ * The URL is the Actor's {@code standbyUrl}, fetched from the API by the first call of this
+ * method or {@link #getOpenApiSpec()} and reused by later calls on this client. Use it to call
+ * the server with another HTTP client, for example one generated from the server's OpenAPI
+ * specification. The API request takes the {@link Timeout#SHORT} timeout.
+ *
+ * @return the URL of the Actor's server; fails with a {@link ServerActorException} when the Actor
+ * is not a Server Actor, and with a {@link com.apify.client.http.NotFoundError} when the
+ * Actor does not exist
+ */
+ public CompletableFuture getUrl() {
+ return getUrl(Timeout.SHORT);
+ }
+
+ /**
+ * As {@link #getUrl()}, with {@code timeout} for the API request that fetches the URL. It is not
+ * used when the URL is already cached, nor while another caller's lookup is running, since the
+ * timeout of the caller that started it applies.
+ *
+ * @throws IllegalArgumentException if {@code timeout} is {@code null}
+ */
+ public CompletableFuture getUrl(Timeout timeout) {
+ requireTimeout(timeout);
+ // A copy, so that cancelling the returned future leaves the shared lookup alone.
+ return sharedUrl(timeout).copy();
+ }
+
+ private static void requireTimeout(Timeout timeout) {
+ if (timeout == null) {
+ throw new IllegalArgumentException("timeout must not be null");
+ }
+ }
+
+ /** The cached lookup of the server URL, started if there is none. */
+ private CompletableFuture sharedUrl(Timeout timeout) {
+ while (true) {
+ CompletableFuture cached = url.get();
+ if (cached != null) {
+ return cached;
+ }
+ CancellableFuture lookup = new CancellableFuture<>();
+ if (url.compareAndSet(null, lookup)) {
+ CompletableFuture fetch;
+ try {
+ fetch = fetchServerActor(timeout);
+ } catch (RuntimeException e) {
+ // A lookup that cannot even start must not stay cached, or every later call would wait on
+ // it.
+ fetch = CompletableFuture.failedFuture(e);
+ }
+ fetch.whenComplete(
+ (actor, error) -> {
+ if (error == null) {
+ lookup.complete(actor.getStandbyUrl());
+ } else {
+ url.compareAndSet(lookup, null);
+ lookup.completeExceptionally(HttpClientCore.unwrapCompletion(error));
+ }
+ });
+ return lookup;
+ }
+ }
+ }
+
+ /**
+ * Returns the OpenAPI specification of the Actor's server.
+ *
+ * The specification is the web server schema from the Actor's definition, taken from the build
+ * that serves the server interface: the build set in {@code actorStandby.build} (a build tag or a
+ * build number), or {@code latest} if none is set. Use it to generate an HTTP client for the
+ * server, and point that client at the URL returned by {@link #getUrl()}.
+ *
+ * @return the OpenAPI specification, or empty if the Actor's definition does not include one;
+ * fails with a {@link ServerActorException} when the Actor is not a Server Actor or the build
+ * that serves its server interface does not exist, and with a {@link
+ * com.apify.client.http.NotFoundError} when the Actor does not exist
+ * @see Web
+ * server schema
+ */
+ public CompletableFuture> getOpenApiSpec() {
+ return getOpenApiSpec(Timeout.SHORT);
+ }
+
+ /** As {@link #getOpenApiSpec()}, with {@code timeout} for each API request it makes. */
+ public CompletableFuture> getOpenApiSpec(Timeout timeout) {
+ requireTimeout(timeout);
+ return fetchServerActor(timeout)
+ .thenCompose(
+ actor -> {
+ url.set(CancellableFuture.completed(actor.getStandbyUrl()));
+ String buildRef = buildRefOf(actor);
+ Optional taggedBuildId = taggedBuildId(actor, buildRef);
+ CompletableFuture> buildId =
+ taggedBuildId.isPresent()
+ ? CompletableFuture.completedFuture(taggedBuildId)
+ // Not a tag, so look `buildRef` up as a build number, which the API cannot
+ // resolve directly.
+ : findBuildIdByNumber(buildRef, timeout);
+ return buildId.thenCompose(found -> fetchBuild(found, buildRef, timeout));
+ })
+ .thenApply(
+ build ->
+ Optional.ofNullable(build.getActorDefinition())
+ .map(definition -> definition.getWebServerSchema())
+ .filter(schema -> !schema.isNull()));
+ }
+
+ /**
+ * Sends an HTTP request to the Actor's server and completes with its response, the body buffered.
+ *
+ * The request goes to the URL returned by {@link #getUrl()} and carries the client's token in
+ * the {@code X-Apify-Authorization} header, which the platform consumes, so it never reaches the
+ * Actor's code. Headers you set, {@code Authorization} included, reach the Actor's server as they
+ * are. The first request to an idle Actor waits for the platform to start a server run, which is
+ * why the timeout defaults to {@link Timeout#LONG}.
+ *
+ *
The future completes with any response of the server, error statuses included, so check
+ * {@link ApiResponse#statusCode()}. A rate-limit (429) or Service Unavailable (503) response is
+ * retried with the client's retry settings, and the last one is returned once the retries run
+ * out. The Actor's code can return these statuses too, and then it gets the request again. A
+ * redirect is returned as it is, since following it would send the token to a host the Actor's
+ * code picked. A transport error, a timeout included, is not retried, since the Actor may have
+ * handled the request before it failed. Request bodies are sent uncompressed unless {@link
+ * ActorServerRequestOptions#compression} is set, since the Actor's server may not decode a
+ * compressed body.
+ *
+ * @param path the path on the Actor's server, for example {@code /search}. It is always resolved
+ * against the server URL, so the request, and the token with it, cannot be sent to another
+ * host. Pass the query as {@link ActorServerRequestOptions#param params}. A fragment ({@code
+ * #...}) is dropped, since it is never sent to the server.
+ * @param options the method, query, headers, body, compression and timeout; {@code null} sends a
+ * plain {@code GET}
+ * @return the response; fails with a {@link ServerActorException} when the Actor is not a Server
+ * Actor, and with a {@link com.apify.client.http.NotFoundError} when the Actor does not exist
+ */
+ public CompletableFuture request(String path, ActorServerRequestOptions options) {
+ ActorServerRequestOptions opts = options != null ? options : new ActorServerRequestOptions();
+ byte[] body = Json.toRequestBody(opts.bodyValue());
+ return getUrl()
+ .thenCompose(
+ serverUrl ->
+ http.call(
+ opts.methodValue(),
+ requestUrl(serverUrl, path, opts),
+ body,
+ contentTypeOf(opts),
+ opts.headersValue(),
+ opts.timeoutValue(),
+ false,
+ RequestPolicy.server(opts.compressionValue())));
+ }
+
+ /** A {@code GET} request to {@code path} on the Actor's server; see {@link #request}. */
+ public CompletableFuture request(String path) {
+ return request(path, null);
+ }
+
+ /** A {@code GET} request to the root of the Actor's server; see {@link #request}. */
+ public CompletableFuture request() {
+ return request(ROOT_PATH, null);
+ }
+
+ /**
+ * As {@link #request}, but completes with the response as soon as its headers arrive, with the
+ * body unread. A response whose body is large, or never ends, can be processed in chunks this
+ * way. The body holds its connection open until it is read to the end or closed, so close the
+ * returned {@link ActorServerStreamResponse}. A rate-limit (429) or Service Unavailable (503)
+ * response is retried like for {@link #request}, with the discarded responses closed; a transport
+ * error is not. {@link ActorServerRequestOptions#timeout} bounds the wait for the server to start
+ * responding.
+ */
+ public CompletableFuture requestStream(
+ String path, ActorServerRequestOptions options) {
+ ActorServerRequestOptions opts = options != null ? options : new ActorServerRequestOptions();
+ byte[] body = Json.toRequestBody(opts.bodyValue());
+ return getUrl()
+ .thenCompose(
+ serverUrl ->
+ http.streamRequest(
+ opts.methodValue(),
+ requestUrl(serverUrl, path, opts),
+ body,
+ contentTypeOf(opts),
+ opts.headersValue(),
+ opts.timeoutValue(),
+ RequestPolicy.server(opts.compressionValue())))
+ .thenApply(
+ response ->
+ new ActorServerStreamResponse(
+ response.statusCode(), response.headers(), response.body()));
+ }
+
+ /** Fetches the Actor and checks that it is a Server Actor. */
+ private CompletableFuture fetchServerActor(Timeout timeout) {
+ return ctx.getResourceRequired("", new QueryParams(), Actor.class, timeout)
+ .thenApply(
+ actor -> {
+ String standbyUrl = actor == null ? null : actor.getStandbyUrl();
+ if (standbyUrl == null || standbyUrl.isEmpty()) {
+ throw new ServerActorException("Actor " + id + " is not a Server Actor.");
+ }
+ return actor;
+ });
+ }
+
+ /** The tag or number of the build that serves the server interface. */
+ private static String buildRefOf(Actor actor) {
+ ActorStandby standby = actor.getActorStandby();
+ String build = standby == null ? null : standby.getBuild();
+ return build == null || build.isEmpty() ? DEFAULT_BUILD_TAG : build;
+ }
+
+ /** The id of the build the tag {@code buildRef} points to, or empty if it is not a tag. */
+ private static Optional taggedBuildId(Actor actor, String buildRef) {
+ JsonNode tagged = actor.getTaggedBuilds();
+ JsonNode buildId = tagged == null ? null : tagged.path(buildRef).path("buildId");
+ return buildId != null && buildId.isTextual()
+ ? Optional.of(buildId.asText())
+ : Optional.empty();
+ }
+
+ /**
+ * Looks {@code buildNumber} up in the Actor's builds, which are listed newest first and whose
+ * sequence numbers within a version grow over time, so meeting an older build of the same version
+ * means the build does not exist. Completes with empty for anything that is not a build number.
+ */
+ private CompletableFuture> findBuildIdByNumber(
+ String buildNumber, Timeout timeout) {
+ Optional target = parseBuildNumber(buildNumber);
+ if (target.isEmpty()) {
+ return CompletableFuture.completedFuture(Optional.empty());
+ }
+ ResourceContext builds = ResourceContext.collection(http, ctx.subUrl(""), BUILDS_PATH);
+ return findBuildIdByNumber(builds, buildNumber, target.get(), 0, timeout);
+ }
+
+ private CompletableFuture> findBuildIdByNumber(
+ ResourceContext builds, String buildNumber, int[] target, long offset, Timeout timeout) {
+ QueryParams params = new QueryParams().addBool("desc", true).addLong("offset", offset);
+ return builds
+ .listResource("", params, Build.class, timeout)
+ .thenCompose(
+ page -> {
+ for (Build build : page.getItems()) {
+ if (buildNumber.equals(build.getBuildNumber())) {
+ return CompletableFuture.completedFuture(Optional.of(build.getId()));
+ }
+ if (isOlderBuild(build.getBuildNumber(), target)) {
+ return CompletableFuture.completedFuture(Optional.empty());
+ }
+ }
+ if (page.getItems().isEmpty()) {
+ return CompletableFuture.completedFuture(Optional.empty());
+ }
+ return findBuildIdByNumber(
+ builds, buildNumber, target, offset + page.getItems().size(), timeout);
+ });
+ }
+
+ private CompletableFuture fetchBuild(
+ Optional buildId, String buildRef, Timeout timeout) {
+ if (buildId.isEmpty()) {
+ return CompletableFuture.failedFuture(buildDoesNotExist(buildRef));
+ }
+ return ResourceContext.single(http, baseUrl, ApiPaths.ACTOR_BUILDS, buildId.get())
+ .getResource("", new QueryParams(), Build.class, timeout)
+ .thenApply(build -> build.orElseThrow(() -> buildDoesNotExist(buildRef)));
+ }
+
+ private ServerActorException buildDoesNotExist(String buildRef) {
+ return new ServerActorException("Build " + buildRef + " of Actor " + id + " does not exist.");
+ }
+
+ /** Splits a build number like {@code 0.1.23} into its parts, or empty for anything else. */
+ private static Optional parseBuildNumber(String buildNumber) {
+ if (buildNumber == null) {
+ return Optional.empty();
+ }
+ Matcher match = BUILD_NUMBER_PATTERN.matcher(buildNumber);
+ if (!match.matches()) {
+ return Optional.empty();
+ }
+ return Optional.of(
+ new int[] {
+ Integer.parseInt(match.group(1)),
+ Integer.parseInt(match.group(2)),
+ Integer.parseInt(match.group(3))
+ });
+ }
+
+ /**
+ * Whether {@code buildNumber} belongs to the same version as {@code target} and comes before it.
+ */
+ private static boolean isOlderBuild(String buildNumber, int[] target) {
+ return parseBuildNumber(buildNumber)
+ .filter(parsed -> parsed[0] == target[0] && parsed[1] == target[1] && parsed[2] < target[2])
+ .isPresent();
+ }
+
+ /**
+ * The URL of a request: {@code path} resolved against the server URL, without its fragment and
+ * with the query parameters appended.
+ */
+ private static String requestUrl(
+ String serverUrl, String path, ActorServerRequestOptions options) {
+ String requested = path == null ? ROOT_PATH : path;
+ String withoutFragment = requested.split(FRAGMENT_START, 2)[0];
+ String serverPath = withoutFragment.replaceFirst("^/+", "");
+ String base = serverUrl.replaceFirst("/+$", "");
+ return options.queryParams().applyToUrl(base + "/" + serverPath);
+ }
+
+ /**
+ * The content type of the body: the one the caller set, JSON for a body that is serialized, and
+ * none for a body that is sent as it is.
+ */
+ private static String contentTypeOf(ActorServerRequestOptions options) {
+ String set = options.contentTypeHeader();
+ if (set != null) {
+ return set;
+ }
+ Object body = options.bodyValue();
+ boolean sentAsIs = body == null || body instanceof byte[] || body instanceof String;
+ return sentAsIs ? null : ResourceContext.CONTENT_TYPE_JSON;
+ }
+}
diff --git a/src/main/java/com/apify/client/actor/ActorServerRequestOptions.java b/src/main/java/com/apify/client/actor/ActorServerRequestOptions.java
new file mode 100644
index 0000000..3ca1306
--- /dev/null
+++ b/src/main/java/com/apify/client/actor/ActorServerRequestOptions.java
@@ -0,0 +1,164 @@
+package com.apify.client.actor;
+
+import com.apify.client.http.HttpCompressor;
+import com.apify.client.http.Timeout;
+import com.apify.client.internal.QueryParams;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Configures {@link ActorServerClient#request(String, ActorServerRequestOptions)} and {@link
+ * ActorServerClient#requestStream(String, ActorServerRequestOptions)}. All fields are optional: by
+ * default the request is a {@code GET} without query parameters, headers or body, sent with the
+ * {@link Timeout#LONG} timeout.
+ */
+public final class ActorServerRequestOptions {
+
+ /**
+ * The methods a request can use. {@code CONNECT} is left out, since the JDK HTTP client does not
+ * send it.
+ */
+ private static final Set METHODS =
+ Set.of("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS", "TRACE", "PATCH");
+
+ private String method = "GET";
+ private final Map params = new LinkedHashMap<>();
+ private final Map headers = new LinkedHashMap<>();
+ private Object body;
+ private HttpCompressor compression;
+ private Timeout timeout = Timeout.LONG;
+
+ /**
+ * The HTTP method of the request, in upper case: {@code GET} (the default), {@code HEAD}, {@code
+ * POST}, {@code PUT}, {@code DELETE}, {@code OPTIONS}, {@code TRACE} or {@code PATCH}.
+ *
+ * @throws IllegalArgumentException for any other method
+ */
+ public ActorServerRequestOptions method(String method) {
+ if (method == null || !METHODS.contains(method)) {
+ throw new IllegalArgumentException(
+ "method must be one of " + METHODS.stream().sorted().toList() + ", was: " + method);
+ }
+ this.method = method;
+ return this;
+ }
+
+ /**
+ * Adds a query parameter. A {@code null} value is left out, a boolean is sent as {@code 1} or
+ * {@code 0} and a collection as its comma-joined elements, as for API calls; any other value is
+ * sent as its string form.
+ */
+ public ActorServerRequestOptions param(String name, Object value) {
+ params.put(name, value);
+ return this;
+ }
+
+ /** Adds all the entries of {@code params} as query parameters; see {@link #param}. */
+ public ActorServerRequestOptions params(Map params) {
+ this.params.putAll(params);
+ return this;
+ }
+
+ /** Adds a header to the request. */
+ public ActorServerRequestOptions header(String name, String value) {
+ headers.put(name, value);
+ return this;
+ }
+
+ /**
+ * Adds all the entries of {@code headers} to the request. They reach the Actor's server as they
+ * are, an {@code Authorization} header included: the client's token travels in the {@code
+ * X-Apify-Authorization} header, which the platform consumes, so it never reaches the Actor's
+ * code and the {@code Authorization} header stays free for the Actor's own authentication.
+ */
+ public ActorServerRequestOptions headers(Map headers) {
+ this.headers.putAll(headers);
+ return this;
+ }
+
+ /**
+ * The request body: a {@code byte[]} or a {@link String} is sent as it is (set its {@code
+ * Content-Type} with {@link #header}), and anything else, a {@link Map}, a list or a POJO, is
+ * serialized to JSON and sent as {@code application/json} unless a {@code Content-Type} header is
+ * set.
+ */
+ public ActorServerRequestOptions body(Object body) {
+ this.body = body;
+ return this;
+ }
+
+ /**
+ * Compresses the request body with {@code compression}, under the same conditions as for API
+ * calls: a body that is large enough and has a content type that is not already compressed, and
+ * no {@code Content-Encoding} header of your own. Set it only if the Actor's server decodes the
+ * resulting {@code Content-Encoding}: the body is sent uncompressed by default, since the server
+ * may not.
+ */
+ public ActorServerRequestOptions compression(HttpCompressor compression) {
+ this.compression = compression;
+ return this;
+ }
+
+ /**
+ * The timeout of each attempt. Defaults to {@link Timeout#LONG}, since the first request to an
+ * idle Actor waits for the platform to start a server run. For {@link
+ * ActorServerClient#requestStream} it bounds the wait for the server to start responding.
+ */
+ public ActorServerRequestOptions timeout(Timeout timeout) {
+ if (timeout == null) {
+ throw new IllegalArgumentException("timeout must not be null");
+ }
+ this.timeout = timeout;
+ return this;
+ }
+
+ String methodValue() {
+ return method;
+ }
+
+ Object bodyValue() {
+ return body;
+ }
+
+ Map headersValue() {
+ return Collections.unmodifiableMap(headers);
+ }
+
+ HttpCompressor compressionValue() {
+ return compression;
+ }
+
+ Timeout timeoutValue() {
+ return timeout;
+ }
+
+ /** The {@code Content-Type} header the caller set, ignoring the case of its name, or null. */
+ String contentTypeHeader() {
+ return headers.entrySet().stream()
+ .filter(entry -> "content-type".equals(entry.getKey().toLowerCase(Locale.ROOT)))
+ .map(Map.Entry::getValue)
+ .findFirst()
+ .orElse(null);
+ }
+
+ /** The query parameters, converted as for API calls. */
+ QueryParams queryParams() {
+ QueryParams query = new QueryParams();
+ params.forEach(
+ (name, value) -> {
+ if (value instanceof Boolean flag) {
+ query.addBool(name, flag);
+ } else if (value instanceof Collection> values) {
+ query.addCsv(name, values.stream().map(String::valueOf).collect(Collectors.toList()));
+ } else if (value != null) {
+ query.addString(name, value.toString());
+ }
+ });
+ return query;
+ }
+}
diff --git a/src/main/java/com/apify/client/actor/ActorServerStreamResponse.java b/src/main/java/com/apify/client/actor/ActorServerStreamResponse.java
new file mode 100644
index 0000000..11a9355
--- /dev/null
+++ b/src/main/java/com/apify/client/actor/ActorServerStreamResponse.java
@@ -0,0 +1,26 @@
+package com.apify.client.actor;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.http.HttpHeaders;
+
+/**
+ * A response of an Actor's server whose body is still unread, as returned by {@link
+ * ActorServerClient#requestStream}.
+ *
+ * The body holds its connection open until it is read to the end or closed, so close the
+ * response (it is {@link AutoCloseable}) or its {@link #body()} when done with it.
+ *
+ * @param statusCode the HTTP status code, of an error response too
+ * @param headers the response headers
+ * @param body the response body, unread
+ */
+public record ActorServerStreamResponse(int statusCode, HttpHeaders headers, InputStream body)
+ implements AutoCloseable {
+
+ /** Closes the body stream, releasing the connection. */
+ @Override
+ public void close() throws IOException {
+ body.close();
+ }
+}
diff --git a/src/main/java/com/apify/client/actor/ActorStandby.java b/src/main/java/com/apify/client/actor/ActorStandby.java
index d620b51..77cb864 100644
--- a/src/main/java/com/apify/client/actor/ActorStandby.java
+++ b/src/main/java/com/apify/client/actor/ActorStandby.java
@@ -62,19 +62,28 @@ public Boolean getShouldPassActorInput() {
}
/**
- * Who the Server Actor serves: {@code SINGLE_TENANT} (a private instance per caller) or a {@code
- * MULTI_TENANT_*} pool.
+ * Whether each user gets their own standby runs ({@code SINGLE_TENANT}) or the runs are shared
+ * across users ({@code MULTI_TENANT}). {@code null} when it is not set.
*/
public String getTenancy() {
return tenancy;
}
- /** Whether Console authentication is enabled for the standby URL. */
+ /**
+ * Whether standby requests can be authenticated with an Apify Console session. When creating an
+ * Actor, omit it or set it to {@code false}. When updating an Actor, only Apify administrators
+ * can change it; other users can only send the current value, or {@code false} if it is not set
+ * yet.
+ */
public Boolean getIsConsoleAuthEnabled() {
return isConsoleAuthEnabled;
}
- /** Whether requests to the standby URL may omit the API token. */
+ /**
+ * Whether standby requests can be made without an API token. When creating an Actor, omit it or
+ * set it to {@code false}. When updating an Actor, only Apify administrators can change it; other
+ * users can only send the current value, or {@code false} if it is not set yet.
+ */
public Boolean getIsTokenlessEnabled() {
return isTokenlessEnabled;
}
diff --git a/src/main/java/com/apify/client/actor/ActorVersion.java b/src/main/java/com/apify/client/actor/ActorVersion.java
index d3560c8..cb73510 100644
--- a/src/main/java/com/apify/client/actor/ActorVersion.java
+++ b/src/main/java/com/apify/client/actor/ActorVersion.java
@@ -8,6 +8,7 @@ public final class ActorVersion extends ApifyResource {
private String sourceType;
private String tarballUrl;
private String gitHubGistUrl;
+ private String actorPath;
private Integer githubWebhookId;
private Integer gitlabWebhookId;
private String bitbucketWebhookId;
@@ -22,7 +23,7 @@ public String getSourceType() {
return sourceType;
}
- /** The tarball URL the source is fetched from, for {@code TARBALL} versions. */
+ /** The URL of the zip archive the source is downloaded from, for {@code TARBALL} versions. */
public String getTarballUrl() {
return tarballUrl;
}
@@ -32,6 +33,16 @@ public String getGitHubGistUrl() {
return gitHubGistUrl;
}
+ /**
+ * The Actor's directory inside the uploaded source, as a path relative to the upload root with
+ * {@code /} separators (for example {@code "actors/typescript-actor"}). It applies to {@code
+ * SOURCE_FILES} and {@code TARBALL} versions. {@code null} when the upload root is the Actor's
+ * directory. On a {@code TARBALL} version, only users with write access to the Actor get it.
+ */
+ public String getActorPath() {
+ return actorPath;
+ }
+
/** The ID of the GitHub webhook that triggers builds on push, if configured. */
public Integer getGithubWebhookId() {
return githubWebhookId;
diff --git a/src/main/java/com/apify/client/build/ActorDefinition.java b/src/main/java/com/apify/client/build/ActorDefinition.java
index b96ea02..b6b2a08 100644
--- a/src/main/java/com/apify/client/build/ActorDefinition.java
+++ b/src/main/java/com/apify/client/build/ActorDefinition.java
@@ -77,17 +77,26 @@ public Map getEnvironmentVariables() {
: Collections.unmodifiableMap(environmentVariables);
}
- /** The path to the Dockerfile used for building the Actor on the platform. */
+ /**
+ * The path to the Dockerfile used for building the Actor on the platform, relative to the {@code
+ * .actor/} directory.
+ */
public String getDockerfile() {
return dockerfile;
}
- /** The path to the directory used as the Docker context when building the Actor. */
+ /**
+ * The path to the directory used as the Docker context when building the Actor, relative to the
+ * {@code .actor/} directory.
+ */
public String getDockerContextDir() {
return dockerContextDir;
}
- /** The path to the README file for the Actor; {@code null} if there is none. */
+ /**
+ * The path to the README file for the Actor, relative to the {@code .actor/} directory; {@code
+ * null} if there is none.
+ */
public String getReadme() {
return readme;
}
@@ -102,7 +111,10 @@ public JsonNode getOutput() {
return output;
}
- /** The path to the CHANGELOG file shown in the Actor's information tab; may be {@code null}. */
+ /**
+ * The path to the CHANGELOG file shown in the Actor's information tab, relative to the {@code
+ * .actor/} directory; may be {@code null}.
+ */
public String getChangelog() {
return changelog;
}
diff --git a/src/main/java/com/apify/client/build/BuildActVersion.java b/src/main/java/com/apify/client/build/BuildActVersion.java
index 2e66599..1397531 100644
--- a/src/main/java/com/apify/client/build/BuildActVersion.java
+++ b/src/main/java/com/apify/client/build/BuildActVersion.java
@@ -12,6 +12,7 @@ public final class BuildActVersion extends ApifyResource {
private String versionNumber;
private String gitRepoUrl;
private List sourceFiles;
+ private String actorPath;
/**
* How the version's source was provided (e.g. {@code "SOURCE_FILES"}, {@code "GIT_REPO"}, {@code
@@ -47,4 +48,13 @@ public String getGitRepoUrl() {
public List getSourceFiles() {
return sourceFiles == null ? List.of() : Collections.unmodifiableList(sourceFiles);
}
+
+ /**
+ * The Actor's directory inside the uploaded source, present when the version sets it. It applies
+ * when the source type is {@code SOURCE_FILES} or {@code TARBALL}. On a {@code TARBALL} version,
+ * only users with write access to the Actor get it.
+ */
+ public String getActorPath() {
+ return actorPath;
+ }
}
diff --git a/src/main/java/com/apify/client/dataset/DatasetClient.java b/src/main/java/com/apify/client/dataset/DatasetClient.java
index 9146602..01c3ea1 100644
--- a/src/main/java/com/apify/client/dataset/DatasetClient.java
+++ b/src/main/java/com/apify/client/dataset/DatasetClient.java
@@ -35,7 +35,7 @@ public final class DatasetClient {
/**
* Header reporting the number of rows the API scanned to produce this page, before any item-level
* filtering ({@code clean}/{@code skipEmpty}/{@code skipHidden}) is applied. Absent on older API
- * responses; see {@link PaginationList#getScannedCount()}.
+ * responses; see {@link PaginationList#getCount()}.
*/
private static final String HEADER_PAGINATION_COUNT = "X-Apify-Pagination-Count";
@@ -138,17 +138,18 @@ public Flow.Publisher iterateItems(DatasetListItemsOptions options) {
/**
* Returns a lazy, backpressure-aware publisher over the dataset's items, decoding each into
* {@code itemClass}, fetching pages on demand. The options' {@code limit} caps the total number
- * of items yielded ({@code null} or non-positive = all); {@code chunkSize} is the per-request
- * page size ({@code null} = server default).
+ * of dataset rows read ({@code null} or non-positive = all); {@code chunkSize} is the
+ * per-request page size ({@code null} = server default).
*
* A server-side item filter ({@code skipEmpty}, {@code skipHidden}, {@code clean}, {@code
- * simplified}) or {@code unwind} can make a page's returned item count diverge from the number of
- * rows actually scanned - a filter drops rows (returns fewer), {@code unwind} splits one row's
- * array field into several items (returns more). Where the API reports the scanned count ({@code
- * X-Apify-Pagination-Count}), this iterator advances by that number rather than by the number of
- * items returned, so neither case repeats already-seen items, skips rows, nor ends iteration
- * early. See {@link PaginationList#getScannedCount()} for the one case that header value is not
- * trusted (and why).
+ * simplified}) or {@code unwind} can make a page return fewer or more items than the rows it
+ * covers, so the iterator advances by {@link PaginationList#getCount()} rather than by the items
+ * returned: it never repeats rows, skips rows, or ends early.
+ *
+ *
Without {@code chunkSize}, each request gets the API's default page size, capped by the
+ * remaining {@code limit}. Dataset items have no default limit, so iterating without {@code
+ * chunkSize} loads the whole dataset, or all {@code limit} rows, in one response. Pass {@code
+ * chunkSize} to iterate a large dataset.
*/
public Flow.Publisher iterateItems(
DatasetListItemsOptions options, Long chunkSize, Class itemClass) {
@@ -182,15 +183,17 @@ private CompletableFuture> fetchItemsPage(
resp -> {
JavaType listType = Json.parametric(List.class, Json.type(itemClass));
List items = Json.parse(resp.body(), listType);
- long count = items.size();
+ long returned = items.size();
+ Long scanned = headerLongOrNull(resp, HEADER_PAGINATION_COUNT);
PaginationList result = new PaginationList<>();
result.setItems(items);
- result.setCount(count);
- result.setTotal(headerLong(resp, HEADER_PAGINATION_TOTAL, count));
+ // See PaginationList#getCount for why the header alone is not the count.
+ result.setCount(Math.max(scanned == null ? 0 : scanned, returned));
+ result.setTotal(headerLong(resp, HEADER_PAGINATION_TOTAL, returned));
result.setOffset(headerLong(resp, HEADER_PAGINATION_OFFSET, 0));
- result.setLimit(headerLong(resp, HEADER_PAGINATION_LIMIT, count));
- result.setScannedCount(headerLongOrNull(resp, HEADER_PAGINATION_COUNT));
+ result.setLimit(headerLong(resp, HEADER_PAGINATION_LIMIT, returned));
+ result.setScannedCount(scanned);
if (desc != null) {
result.setDesc(desc);
}
@@ -316,7 +319,11 @@ public CompletableFuture getStatistics() {
* It fetches the dataset, and if the dataset exposes a URL-signing secret key (i.e. it is
* private), appends an HMAC-SHA256 signature so the URL grants access without an API token.
* {@code expiresInSecs} optionally bounds the validity of a signed URL ({@code null} for
- * non-expiring). The URL is built from the configured public base URL.
+ * non-expiring); it must be positive and at most 4611686018427387. The URL is built from the
+ * configured public base URL.
+ *
+ * @throws IllegalArgumentException before any request is sent, if {@code expiresInSecs} is not
+ * {@code null} and is not positive or exceeds that maximum
*/
public CompletableFuture createItemsPublicUrl(
DatasetListItemsOptions options, Long expiresInSecs) {
@@ -330,6 +337,7 @@ public CompletableFuture createItemsPublicUrl(
*/
public CompletableFuture createItemsPublicUrl(
DatasetListItemsOptions options, Long expiresInSecs, DownloadItemsFormat format) {
+ Signatures.requireValidExpiresInSecs(expiresInSecs);
QueryParams params = new QueryParams();
options.apply(params);
if (format != null) {
@@ -362,9 +370,7 @@ private static long headerLong(ApiResponse resp, String name, long fallback) {
/**
* As {@link #headerLong}, but returns {@code null} (rather than a fallback value) when the header
- * is absent, so callers can distinguish "not reported" from any particular number - notably
- * {@code 0}, a value the header can legitimately carry (see {@link
- * PaginationList#getScannedCount()}).
+ * is absent, which {@link PaginationList#getScannedCount()} reports as {@code null}.
*/
private static Long headerLongOrNull(ApiResponse resp, String name) {
var value = resp.headers().firstValueAsLong(name);
diff --git a/src/main/java/com/apify/client/http/ApiResponse.java b/src/main/java/com/apify/client/http/ApiResponse.java
index ecc729d..a71d955 100644
--- a/src/main/java/com/apify/client/http/ApiResponse.java
+++ b/src/main/java/com/apify/client/http/ApiResponse.java
@@ -1,6 +1,8 @@
package com.apify.client.http;
+import com.apify.client.internal.Json;
import java.net.http.HttpHeaders;
+import java.nio.charset.StandardCharsets;
/**
* The parsed result of a single API call: the status code, headers and the fully-buffered response
@@ -17,4 +19,19 @@
* @param headers the response headers
* @param body the fully-buffered response body
*/
-public record ApiResponse(int statusCode, HttpHeaders headers, byte[] body) {}
+public record ApiResponse(int statusCode, HttpHeaders headers, byte[] body) {
+
+ /** The body decoded as UTF-8 text; an empty string for a response without a body. */
+ public String bodyAsString() {
+ return new String(body, StandardCharsets.UTF_8);
+ }
+
+ /**
+ * The body parsed as JSON into {@code type}, for example {@code JsonNode.class} or a POJO.
+ *
+ * @throws tools.jackson.core.JacksonException if the body is not valid JSON for {@code type}
+ */
+ public T bodyAs(Class type) {
+ return Json.parse(body, type);
+ }
+}
diff --git a/src/main/java/com/apify/client/http/ApifyClientException.java b/src/main/java/com/apify/client/http/ApifyClientException.java
index 0fa1d9b..d3b85b3 100644
--- a/src/main/java/com/apify/client/http/ApifyClientException.java
+++ b/src/main/java/com/apify/client/http/ApifyClientException.java
@@ -5,7 +5,7 @@
*
* It is unchecked (a {@link RuntimeException}), matching the reference JavaScript client's
* {@code Error}-based, unchecked error model, so callers are not forced to wrap every call in a
- * {@code try}/{@code catch}. Two concrete subtypes cover the two ways a call can fail:
+ * {@code try}/{@code catch}. Three concrete subtypes cover the ways a call can fail:
*
*
* - {@link ApifyApiException} — the request reached the API, which answered with a non-success
@@ -13,9 +13,11 @@
*
- {@link ApifyTransportException} — the request never produced an API response at all
* (connection failure, DNS, timeout, or a local failure while preparing the request/response,
* e.g. compression).
+ *
- {@link ServerActorException} — a call to an Actor's server cannot be made, since the Actor
+ * is not a Server Actor or the build that serves its server interface does not exist.
*
*
- * Catch this common type to handle both failure modes uniformly, or catch a specific subtype to
+ * Catch this common type to handle all the failure modes uniformly, or catch a specific subtype to
* handle one of them differently.
*/
public abstract class ApifyClientException extends RuntimeException {
diff --git a/src/main/java/com/apify/client/http/DefaultHttpTransport.java b/src/main/java/com/apify/client/http/DefaultHttpTransport.java
index 027ef37..cce9f2b 100644
--- a/src/main/java/com/apify/client/http/DefaultHttpTransport.java
+++ b/src/main/java/com/apify/client/http/DefaultHttpTransport.java
@@ -13,8 +13,9 @@
* native non-blocking {@code sendAsync}.
*
* The per-attempt timeout is applied to each {@link HttpRequest} by the orchestrating client, so
- * this transport sets only a connection timeout of its own. It follows normal redirects and reuses
- * connections from the underlying client's pool.
+ * this transport sets only a connection timeout of its own. It follows normal redirects, except for
+ * a request that asks not to (see {@link HttpTransport#sendAsync(HttpRequest, boolean)}), and
+ * reuses connections from the underlying client's pool.
*/
public final class DefaultHttpTransport implements HttpTransport {
@@ -28,6 +29,12 @@ public final class DefaultHttpTransport implements HttpTransport {
private final HttpClient client;
+ /**
+ * The client that serves a request which must not follow redirects: {@link #client} itself when
+ * it never follows them, otherwise a copy of it that does not. Created on first use.
+ */
+ private volatile HttpClient noRedirectClient;
+
/** Creates a transport with the {@link #DEFAULT_CONNECT_TIMEOUT}. */
public DefaultHttpTransport() {
this(DEFAULT_CONNECT_TIMEOUT);
@@ -62,6 +69,62 @@ public CompletableFuture> sendStreamingAsync(HttpReque
return unwrapTimeout(client.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()));
}
+ @Override
+ public CompletableFuture> sendAsync(
+ HttpRequest request, boolean followRedirects) {
+ return followRedirects
+ ? sendAsync(request)
+ : unwrapTimeout(
+ clientWithoutRedirects().sendAsync(request, HttpResponse.BodyHandlers.ofByteArray()));
+ }
+
+ @Override
+ public CompletableFuture> sendStreamingAsync(
+ HttpRequest request, boolean followRedirects) {
+ return followRedirects
+ ? sendStreamingAsync(request)
+ : unwrapTimeout(
+ clientWithoutRedirects().sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()));
+ }
+
+ /**
+ * The JDK client's redirect policy is fixed when it is built, so a request that must not follow
+ * redirects goes through a client that was built without that policy. A client wrapped by {@link
+ * #DefaultHttpTransport(HttpClient)} is copied with the settings the JDK exposes (it does not
+ * share the original's connection pool).
+ */
+ private HttpClient clientWithoutRedirects() {
+ HttpClient result = noRedirectClient;
+ if (result == null) {
+ synchronized (this) {
+ result = noRedirectClient;
+ if (result == null) {
+ result = copyWithoutRedirects(client);
+ noRedirectClient = result;
+ }
+ }
+ }
+ return result;
+ }
+
+ private static HttpClient copyWithoutRedirects(HttpClient source) {
+ if (source.followRedirects() == HttpClient.Redirect.NEVER) {
+ return source;
+ }
+ HttpClient.Builder builder =
+ HttpClient.newBuilder()
+ .followRedirects(HttpClient.Redirect.NEVER)
+ .version(source.version())
+ .sslContext(source.sslContext())
+ .sslParameters(source.sslParameters());
+ source.connectTimeout().ifPresent(builder::connectTimeout);
+ source.proxy().ifPresent(builder::proxy);
+ source.authenticator().ifPresent(builder::authenticator);
+ source.cookieHandler().ifPresent(builder::cookieHandler);
+ source.executor().ifPresent(builder::executor);
+ return builder.build();
+ }
+
/**
* Rewrites a {@link java.net.http.HttpTimeoutException} the JDK client fails the future with into
* this contract's transport-agnostic {@link HttpTimeoutException}, unwrapping the {@link
diff --git a/src/main/java/com/apify/client/http/HttpTransport.java b/src/main/java/com/apify/client/http/HttpTransport.java
index 074f3b1..d703df2 100644
--- a/src/main/java/com/apify/client/http/HttpTransport.java
+++ b/src/main/java/com/apify/client/http/HttpTransport.java
@@ -41,4 +41,46 @@ public interface HttpTransport {
* stream. Completes exceptionally the same way as {@link #sendAsync(HttpRequest)}.
*/
CompletableFuture> sendStreamingAsync(HttpRequest request);
+
+ /**
+ * As {@link #sendAsync(HttpRequest)}, but a transport that is asked not to follow redirects
+ * returns a redirect response as it is, since the request carries a token that must not follow a
+ * redirect to another host. A request to an Actor's server is sent this way (see {@link
+ * com.apify.client.actor.ActorServerClient#request}).
+ *
+ * The default implementation sends the request as {@link #sendAsync(HttpRequest)} does when
+ * {@code followRedirects} is {@code true}, and otherwise completes the returned future
+ * exceptionally with an {@link UnsupportedOperationException}: a transport that cannot hold back
+ * redirects must not be trusted with such a request. Override it to support them.
+ *
+ * @param followRedirects whether the transport may follow redirects
+ */
+ default CompletableFuture> sendAsync(
+ HttpRequest request, boolean followRedirects) {
+ return followRedirects
+ ? sendAsync(request)
+ : CompletableFuture.failedFuture(redirectsCannotBeDisabled());
+ }
+
+ /**
+ * As {@link #sendStreamingAsync(HttpRequest)}, with the redirect handling and the default
+ * implementation of {@link #sendAsync(HttpRequest, boolean)}.
+ *
+ * @param followRedirects whether the transport may follow redirects
+ */
+ default CompletableFuture> sendStreamingAsync(
+ HttpRequest request, boolean followRedirects) {
+ return followRedirects
+ ? sendStreamingAsync(request)
+ : CompletableFuture.failedFuture(redirectsCannotBeDisabled());
+ }
+
+ /** The failure of the default implementations when redirects must not be followed. */
+ private static UnsupportedOperationException redirectsCannotBeDisabled() {
+ return new UnsupportedOperationException(
+ "This HttpTransport does not support disabling redirects, which a request to an Actor's"
+ + " server needs so that the API token never follows a redirect to another host."
+ + " Override sendAsync(HttpRequest, boolean) and sendStreamingAsync(HttpRequest,"
+ + " boolean) to support it.");
+ }
}
diff --git a/src/main/java/com/apify/client/http/ServerActorException.java b/src/main/java/com/apify/client/http/ServerActorException.java
new file mode 100644
index 0000000..eda7f91
--- /dev/null
+++ b/src/main/java/com/apify/client/http/ServerActorException.java
@@ -0,0 +1,20 @@
+package com.apify.client.http;
+
+/**
+ * Thrown when a call to an Actor's server cannot be made, since the Actor is not a Server Actor, or
+ * since the build that serves its server interface does not exist. The API was reached, but it has
+ * nothing to send the call to, which is why this is neither an {@link ApifyApiException} nor an
+ * {@link ApifyTransportException}.
+ *
+ * An Actor that does not exist at all is reported as a {@link NotFoundError}, as for any other
+ * call.
+ */
+public final class ServerActorException extends ApifyClientException {
+
+ private static final long serialVersionUID = 1L;
+
+ /** Creates the exception with a message that names the Actor or the build. */
+ public ServerActorException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/com/apify/client/internal/AsyncPaginatedPublisher.java b/src/main/java/com/apify/client/internal/AsyncPaginatedPublisher.java
index b3fbf54..2fe01ba 100644
--- a/src/main/java/com/apify/client/internal/AsyncPaginatedPublisher.java
+++ b/src/main/java/com/apify/client/internal/AsyncPaginatedPublisher.java
@@ -24,20 +24,22 @@
* list()}:
*
*
- * - {@code totalLimit} caps the total number of items yielded across all pages ({@code
- * null} = yield everything).
+ *
- {@code totalLimit} caps the total number of rows read across all pages ({@code
+ * null} = read everything). A row is an item unless a page's {@link PaginationList#getCount()
+ * count} differs from the items it returns; all returned items are yielded.
*
- {@code chunkSize} is the per-request page size ({@code null} = let the API choose). Each
- * page requests {@code min(remaining-under-cap, chunkSize)} items so the cap is never
+ * page requests {@code min(remaining-under-cap, chunkSize)} rows so the cap is never
* overshot.
*
*
- * Each page advances the offset by the number of items actually returned, and iteration stops
- * only when the cap is reached or the API returns an empty page. It deliberately does not
- * stop on a short page or a reported {@code total}: the API clamps an over-large requested page
- * size to its own maximum (so a "short" page is common mid-collection), and some endpoints report a
- * {@code total} of {@code 0} or a value that lags right after a write (e.g. dataset items) — either
- * signal would truncate iteration. Terminating on an empty page costs one extra request at the end
- * but yields the complete result in every case.
+ *
Each page advances the offset by the rows it covered, as {@link PaginationList#getCount()}
+ * defines them, capped at the page limit that was requested. Iteration stops only when the cap is
+ * reached or a page covers no rows. It deliberately does not stop on a short page or a
+ * reported {@code total}: the API clamps an over-large requested page size to its own maximum (so a
+ * "short" page is common mid-collection), and some endpoints report a {@code total} of {@code 0} or
+ * a value that lags right after a write (e.g. dataset items) — either signal would truncate
+ * iteration. Terminating on an empty page costs one extra request at the end but yields the
+ * complete result in every case.
*
*
Supports a single subscriber (as permitted by the Reactive Streams specification); subscribing
* twice completes the second subscriber's subscription with an error instead of sharing state
@@ -109,9 +111,10 @@ private final class Session implements Flow.Subscription {
private List buffer = List.of();
private int pos;
private long offset;
- private long yielded;
+ private long rowsRead;
private boolean exhausted;
private boolean fetchInFlight;
+ private Long requestedLimit;
private volatile CompletableFuture> inFlight;
Session(Flow.Subscriber super T> subscriber) {
@@ -207,8 +210,9 @@ private void drainLoop() {
private void fetchNextPage() {
fetchInFlight = true;
- Long capRemaining = totalLimit != null ? totalLimit - yielded : null;
+ Long capRemaining = totalLimit != null ? totalLimit - rowsRead : null;
Long pageLimit = minForLimit(capRemaining, chunkSize);
+ requestedLimit = pageLimit;
CompletableFuture> fetch = fetcher.fetch(offset, pageLimit);
inFlight = fetch;
fetch.whenComplete(
@@ -231,39 +235,19 @@ private void applyPage(PaginationList page) {
buffer = page.getItems();
pos = 0;
int count = buffer.size();
- // Advance the offset (and decide exhaustion) by the number of rows the API scanned, not the
- // number of items it returned: an endpoint with item-level filtering (dataset items' clean /
- // skipEmpty / skipHidden) can scan up to the requested page size while returning fewer rows,
- // or none at all. Using the returned count here would re-scan (and re-yield) already-seen
- // rows, or stop iteration early on a page that filtered out everything but still had more
- // data ahead.
- //
- // getScannedCount() is null on every endpoint that does not report the header at all
- // (everything but dataset items). Where it is reported, a *nonzero* value is always trusted:
- // the returned count can legitimately fall on either side of it (a filter drops rows, so
- // returned < scanned; unwind splits one scanned row's array field into several output items,
- // so returned > scanned), and the scanned count is what offset must advance by in both cases.
- // A reported *zero* is trusted only when the page also returned nothing: scanning zero rows
- // can never produce items, so "0 scanned, N>0 returned" is not a real answer the API can give
- // - it means the header is not actually populated for this request yet (observed in practice
- // as a flat 0 on every page of an unfiltered, non-unwound live-API request that did return
- // real items), and falling back to the returned count there keeps this exactly as safe as
- // before the header existed.
- Long reported = page.getScannedCount();
- long scanned = reported != null && (reported > 0 || count == 0) ? reported : count;
- offset += scanned;
- yielded += count;
- // Defensively trim the last page to the cap in case the server returned more than requested,
- // so the subscriber never sees more than `totalLimit` items.
- if (totalLimit != null && yielded > totalLimit) {
- buffer = buffer.subList(0, count - (int) (yielded - totalLimit));
- yielded = totalLimit;
+ // The rows the page covered, which are not the items it returned: see
+ // PaginationList#getCount. Capped at the rows requested, so an unwound page does not advance
+ // past rows the next request would never read.
+ long scanned = Math.max(page.getCount(), count);
+ if (requestedLimit != null) {
+ scanned = Math.min(scanned, requestedLimit);
}
- // Stop on the cap or a page that scanned nothing (the source is exhausted); never on a short
+ offset += scanned;
+ rowsRead += scanned;
+ // Stop on the cap or a page that covered nothing (the source is exhausted); never on a short
// *returned* page (the API clamps large page sizes, and filtering can legitimately return
- // fewer items than were scanned) or the reported total (unreliable on some endpoints — see
- // class doc).
- if (scanned == 0 || (totalLimit != null && yielded >= totalLimit)) {
+ // fewer items than the rows covered) or the reported total (unreliable on some endpoints).
+ if (scanned == 0 || (totalLimit != null && rowsRead >= totalLimit)) {
exhausted = true;
}
}
diff --git a/src/main/java/com/apify/client/internal/HttpClientCore.java b/src/main/java/com/apify/client/internal/HttpClientCore.java
index af6b17a..983540a 100644
--- a/src/main/java/com/apify/client/internal/HttpClientCore.java
+++ b/src/main/java/com/apify/client/internal/HttpClientCore.java
@@ -28,6 +28,7 @@
import java.util.Locale;
import java.util.Map;
import java.util.Set;
+import java.util.TreeMap;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
@@ -297,7 +298,7 @@ public CompletableFuture call(
return call(method, url, body, contentType, extraHeaders, timeout, false);
}
- /** The canonical overload every other {@code call} convenience overload delegates to. */
+ /** As the canonical overload, for a call to the Apify API. */
public CompletableFuture call(
String method,
String url,
@@ -306,22 +307,39 @@ public CompletableFuture call(
Map extraHeaders,
Timeout timeout,
boolean doNotRetryTimeouts) {
+ return call(
+ method,
+ url,
+ body,
+ contentType,
+ extraHeaders,
+ timeout,
+ doNotRetryTimeouts,
+ RequestPolicy.API);
+ }
+
+ /**
+ * The canonical overload every other {@code call} convenience overload delegates to. {@code
+ * policy} says how this request differs from a call to the Apify API.
+ */
+ public CompletableFuture call(
+ String method,
+ String url,
+ byte[] body,
+ String contentType,
+ Map extraHeaders,
+ Timeout timeout,
+ boolean doNotRetryTimeouts,
+ RequestPolicy policy) {
Duration baseTimeout = resolveTimeout(timeout);
// Compress the body once, up front, so every retry reuses the same encoded payload.
- byte[] requestBody = body;
- Map headers = extraHeaders;
- if (shouldCompress(requestBody, contentType, extraHeaders)) {
- try {
- requestBody = compressor.compress(requestBody);
- } catch (Exception | LinkageError e) {
- // No fallback to another coding: the configured compressor is the only one that runs. A
- // LinkageError is what a native codec that fails to load raises.
- return failedFuture(new ApifyTransportException(e));
- }
- headers = new LinkedHashMap<>(extraHeaders == null ? Map.of() : extraHeaders);
- headers.put(HttpHeaders.CONTENT_ENCODING, compressor.contentEncoding());
+ PreparedBody prepared;
+ try {
+ prepared = prepareBody(body, contentType, extraHeaders, policy);
+ } catch (ApifyTransportException e) {
+ return failedFuture(e);
}
String path = extractPath(url);
@@ -329,17 +347,48 @@ public CompletableFuture call(
return attemptWithRetry(
method,
url,
- requestBody,
+ prepared.body(),
contentType,
- headers,
+ prepared.headers(),
baseTimeout,
doNotRetryTimeouts,
+ policy,
path,
1,
maxAttempts,
retry.minDelayBetweenRetries);
}
+ /** A request body, as it is sent, with the headers that go with it. */
+ private record PreparedBody(byte[] body, Map headers) {}
+
+ /**
+ * Compresses {@code body} when the policy and the body call for it, and adds the matching {@code
+ * Content-Encoding} header.
+ *
+ * @throws ApifyTransportException if the compressor fails
+ */
+ private PreparedBody prepareBody(
+ byte[] body, String contentType, Map extraHeaders, RequestPolicy policy) {
+ HttpCompressor requestCompressor =
+ policy.compressor() != null ? policy.compressor() : compressor;
+ if (!policy.compress() || !shouldCompress(body, contentType, extraHeaders)) {
+ return new PreparedBody(body, extraHeaders);
+ }
+ byte[] compressed;
+ try {
+ compressed = requestCompressor.compress(body);
+ } catch (Exception | LinkageError e) {
+ // No fallback to another coding: the configured compressor is the only one that runs. A
+ // LinkageError is what a native codec that fails to load raises.
+ throw new ApifyTransportException(e);
+ }
+ Map headers =
+ new LinkedHashMap<>(extraHeaders == null ? Map.of() : extraHeaders);
+ headers.put(HttpHeaders.CONTENT_ENCODING, requestCompressor.contentEncoding());
+ return new PreparedBody(compressed, headers);
+ }
+
/**
* Runs one attempt and, on a retryable failure that has not exhausted {@code maxAttempts},
* schedules the next one after a backoff delay - chained via {@link
@@ -354,13 +403,24 @@ private CompletableFuture attemptWithRetry(
Map headers,
Duration baseTimeout,
boolean doNotRetryTimeouts,
+ RequestPolicy policy,
String path,
int attempt,
int maxAttempts,
Duration delay) {
Duration timeout = attemptTimeout(baseTimeout, attempt);
return attempt(
- method, url, body, contentType, headers, timeout, doNotRetryTimeouts, attempt, path)
+ method,
+ url,
+ body,
+ contentType,
+ headers,
+ timeout,
+ doNotRetryTimeouts,
+ policy,
+ attempt,
+ attempt == maxAttempts,
+ path)
.thenCompose(
outcome -> {
if (outcome.success()) {
@@ -383,6 +443,7 @@ private CompletableFuture attemptWithRetry(
headers,
baseTimeout,
doNotRetryTimeouts,
+ policy,
path,
attempt + 1,
maxAttempts,
@@ -403,19 +464,26 @@ private CompletableFuture attempt(
Map extraHeaders,
Duration timeout,
boolean doNotRetryTimeouts,
+ RequestPolicy policy,
int attemptNum,
+ boolean lastAttempt,
String path) {
- return doAttempt(method, url, body, contentType, extraHeaders, timeout)
+ return doAttempt(method, url, body, contentType, extraHeaders, timeout, policy)
.handle(
(resp, err) -> {
if (err == null) {
if (resp.statusCode() < MAX_SUCCESS_STATUS) {
return new AttemptOutcome(resp, true, false, null);
}
+ boolean retryable = isStatusRetryable(resp.statusCode(), policy);
+ // A request that does not throw hands the error response back once no retry
+ // follows.
+ if (!policy.throwOnErrorStatus() && (!retryable || lastAttempt)) {
+ return new AttemptOutcome(resp, true, false, null);
+ }
RuntimeException apiError =
buildApiError(resp.statusCode(), resp.body(), attemptNum, method, path);
- return new AttemptOutcome(
- null, false, isStatusRetryable(resp.statusCode()), apiError);
+ return new AttemptOutcome(null, false, retryable, apiError);
}
Throwable cause = unwrapCompletion(err);
if (cause instanceof CancellationException cancellation) {
@@ -427,8 +495,10 @@ private CompletableFuture attempt(
? apifyTransportException
: new ApifyTransportException(cause);
// Network/timeout failures are retryable, unless the caller opted out of retrying
- // timeouts.
- boolean retryable = !(doNotRetryTimeouts && transportError.isTimeout());
+ // timeouts or the request retries no transport errors at all.
+ boolean retryable =
+ policy.retryTransportErrors()
+ && !(doNotRetryTimeouts && transportError.isTimeout());
return new AttemptOutcome(null, false, retryable, transportError);
});
}
@@ -443,6 +513,21 @@ HttpRequest buildRequest(
String contentType,
Map extraHeaders,
Duration timeout) {
+ return buildRequest(method, url, body, contentType, extraHeaders, timeout, RequestPolicy.API);
+ }
+
+ /**
+ * As {@link #buildRequest(String, String, byte[], String, Map, Duration)}, with the token in the
+ * header the {@code policy} names.
+ */
+ private HttpRequest buildRequest(
+ String method,
+ String url,
+ byte[] body,
+ String contentType,
+ Map extraHeaders,
+ Duration timeout,
+ RequestPolicy policy) {
HttpRequest.BodyPublisher publisher =
body != null
? HttpRequest.BodyPublishers.ofByteArray(body)
@@ -451,31 +536,51 @@ HttpRequest buildRequest(
if (timeout != null) {
b.timeout(timeout);
}
- b.header(HttpHeaders.USER_AGENT, userAgent);
+ // Header names are case-insensitive, so a default header wins over the built-in one of the same
+ // name whatever its case, and a header of the request itself wins over both.
+ Map headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+ headers.put(HttpHeaders.USER_AGENT, userAgent);
if (token != null && !token.isEmpty()) {
- b.header(HttpHeaders.AUTHORIZATION, AUTH_BEARER_PREFIX + token);
+ headers.put(HttpHeaders.AUTHORIZATION, AUTH_BEARER_PREFIX + token);
}
if (contentType != null && !contentType.isEmpty()) {
- b.header(HttpHeaders.CONTENT_TYPE, contentType);
+ headers.put(HttpHeaders.CONTENT_TYPE, contentType);
}
- // setHeader replaces what is already set, so a default header wins over the built-in ones and
- // a header of the request itself wins over both.
- defaultHeaders.forEach(b::setHeader);
+ headers.putAll(defaultHeaders);
+ moveAuthorization(headers, policy.authorizationHeader());
if (extraHeaders != null) {
- extraHeaders.forEach(b::setHeader);
+ headers.putAll(extraHeaders);
}
+ headers.forEach(b::setHeader);
return b.build();
}
+ /**
+ * Moves the {@code Authorization} header of {@code headers}, the client's token or the default
+ * header that replaces it, to the header named {@code name}. Leaves the headers as they are when
+ * {@code name} is {@code Authorization} or there is no such header.
+ */
+ private static void moveAuthorization(Map headers, String name) {
+ if (HttpHeaders.AUTHORIZATION.equalsIgnoreCase(name)) {
+ return;
+ }
+ String authorization = headers.remove(HttpHeaders.AUTHORIZATION);
+ if (authorization != null) {
+ headers.put(name, authorization);
+ }
+ }
+
private CompletableFuture doAttempt(
String method,
String url,
byte[] body,
String contentType,
Map extraHeaders,
- Duration timeout) {
- HttpRequest request = buildRequest(method, url, body, contentType, extraHeaders, timeout);
- return CancellableFuture.wrap(transport.sendAsync(request))
+ Duration timeout,
+ RequestPolicy policy) {
+ HttpRequest request =
+ buildRequest(method, url, body, contentType, extraHeaders, timeout, policy);
+ return CancellableFuture.wrap(transport.sendAsync(request, policy.followRedirects()))
.thenApply(resp -> new ApiResponse(resp.statusCode(), resp.headers(), resp.body()));
}
@@ -613,7 +718,10 @@ public static boolean isCompressibleContentType(String contentType) {
return true;
}
- private static boolean isStatusRetryable(int status) {
+ private static boolean isStatusRetryable(int status, RequestPolicy policy) {
+ if (policy.retryableStatus() != null) {
+ return policy.retryableStatus().test(status);
+ }
return status == RATE_LIMIT_EXCEEDED || status >= MIN_SERVER_ERROR;
}
@@ -632,8 +740,8 @@ private static Duration minDuration(Duration a, Duration b) {
return a.compareTo(b) < 0 ? a : b;
}
- private static CompletableFuture failedFuture(RuntimeException error) {
- CompletableFuture future = new CompletableFuture<>();
+ private static CompletableFuture failedFuture(RuntimeException error) {
+ CompletableFuture future = new CompletableFuture<>();
future.completeExceptionally(error);
return future;
}
@@ -737,8 +845,99 @@ public static String extractPath(String url) {
*/
public CompletableFuture> streamAsync(String url) {
HttpRequest request = buildRequest("GET", url, null, null, null, resolveTimeout(Timeout.LONG));
+ return openStream(request, true);
+ }
+
+ /**
+ * Opens a live streaming response to a request, as {@link #call(String, String, byte[], String,
+ * Map, Timeout, boolean, RequestPolicy)} would send it, but without turning an error status into
+ * an exception: every response is handed back with its body unread, for the caller to inspect and
+ * close. A status the policy retries is retried with the client's retry settings, each discarded
+ * response closed, and the last one is handed back once the retries run out. A transport error is
+ * never retried. The future completes exceptionally with an {@link ApifyTransportException} on a
+ * transport failure.
+ */
+ public CompletableFuture> streamRequest(
+ String method,
+ String url,
+ byte[] body,
+ String contentType,
+ Map extraHeaders,
+ Timeout timeout,
+ RequestPolicy policy) {
+ PreparedBody prepared;
+ try {
+ prepared = prepareBody(body, contentType, extraHeaders, policy);
+ } catch (ApifyTransportException e) {
+ return failedFuture(e);
+ }
+ return streamAttempt(
+ method,
+ url,
+ prepared,
+ contentType,
+ resolveTimeout(timeout),
+ policy,
+ 1,
+ retry.maxRetries + 1,
+ retry.minDelayBetweenRetries);
+ }
+
+ /**
+ * Opens one attempt of {@link #streamRequest} and, when the response has a status the policy
+ * retries and attempts are left, closes it and schedules the next attempt after a backoff delay.
+ */
+ private CompletableFuture> streamAttempt(
+ String method,
+ String url,
+ PreparedBody prepared,
+ String contentType,
+ Duration baseTimeout,
+ RequestPolicy policy,
+ int attempt,
+ int maxAttempts,
+ Duration delay) {
+ HttpRequest request =
+ buildRequest(
+ method,
+ url,
+ prepared.body(),
+ contentType,
+ prepared.headers(),
+ attemptTimeout(baseTimeout, attempt),
+ policy);
+ return openStream(request, policy.followRedirects())
+ .thenCompose(
+ response -> {
+ boolean retryable =
+ isStatusRetryable(response.statusCode(), policy) && attempt < maxAttempts;
+ if (!retryable) {
+ return CompletableFuture.completedFuture(response);
+ }
+ closeQuietly(response.body());
+ Duration nextDelay =
+ minDuration(delay.multipliedBy(BACKOFF_FACTOR), retry.maxDelayBetweenRetries);
+ return Async.delay(randomizedDelay(delay))
+ .thenCompose(
+ unused ->
+ streamAttempt(
+ method,
+ url,
+ prepared,
+ contentType,
+ baseTimeout,
+ policy,
+ attempt + 1,
+ maxAttempts,
+ nextDelay));
+ });
+ }
+
+ private CompletableFuture> openStream(
+ HttpRequest request, boolean followRedirects) {
CancellableFuture> result = new CancellableFuture<>();
- CompletableFuture> exchange = transport.sendStreamingAsync(request);
+ CompletableFuture> exchange =
+ transport.sendStreamingAsync(request, followRedirects);
// Cancelling the returned future aborts the exchange.
result.link(exchange);
exchange.whenComplete(
diff --git a/src/main/java/com/apify/client/internal/HttpHeaders.java b/src/main/java/com/apify/client/internal/HttpHeaders.java
index 552d4aa..d478fb3 100644
--- a/src/main/java/com/apify/client/internal/HttpHeaders.java
+++ b/src/main/java/com/apify/client/internal/HttpHeaders.java
@@ -13,6 +13,13 @@ public final class HttpHeaders {
/** The bearer-token authentication header name. */
public static final String AUTHORIZATION = "Authorization";
+ /**
+ * The header that carries the client's token on a request to an Actor's server. The platform
+ * consumes it before the request reaches the Actor's code, which keeps {@link #AUTHORIZATION}
+ * free for the Actor's own authentication.
+ */
+ public static final String X_APIFY_AUTHORIZATION = "X-Apify-Authorization";
+
/** The request/response body media-type header name. */
public static final String CONTENT_TYPE = "Content-Type";
diff --git a/src/main/java/com/apify/client/internal/RequestPolicy.java b/src/main/java/com/apify/client/internal/RequestPolicy.java
new file mode 100644
index 0000000..51a819c
--- /dev/null
+++ b/src/main/java/com/apify/client/internal/RequestPolicy.java
@@ -0,0 +1,74 @@
+package com.apify.client.internal;
+
+import com.apify.client.http.HttpCompressor;
+import java.util.Set;
+import java.util.function.IntPredicate;
+
+/**
+ * How {@link HttpClientCore} treats a single request, where it differs from a call to the Apify
+ * API: which header carries the token, whether an error status throws, which statuses and transport
+ * errors are retried, whether redirects are followed and how the body is compressed. Internal to
+ * the client.
+ *
+ * @param authorizationHeader the name of the header that carries the client's token
+ * @param throwOnErrorStatus whether an error status that is not retried any more is thrown as an
+ * {@link com.apify.client.http.ApifyApiException}; when {@code false} it is returned as the
+ * response instead
+ * @param retryableStatus tells which error statuses are retried, or {@code null} for rate limits
+ * (429) and server errors (5xx)
+ * @param retryTransportErrors whether a transport error, a timeout included, is retried
+ * @param followRedirects whether the transport may follow redirects
+ * @param compress whether the request body is compressed at all
+ * @param compressor the compressor for this request, or {@code null} for the client's own; only
+ * used when {@code compress} is {@code true}
+ */
+public record RequestPolicy(
+ String authorizationHeader,
+ boolean throwOnErrorStatus,
+ IntPredicate retryableStatus,
+ boolean retryTransportErrors,
+ boolean followRedirects,
+ boolean compress,
+ HttpCompressor compressor) {
+
+ /**
+ * Statuses a request to an Actor's server is retried on. The platform answers with them when it
+ * cannot pass a request to a server run, so a retry usually does not run the Actor's code again.
+ * An Actor that returns one itself gets the request again.
+ */
+ private static final Set SERVER_RETRY_STATUS_CODES = Set.of(429, 503);
+
+ /** A call to the Apify API. */
+ public static final RequestPolicy API =
+ new RequestPolicy(
+ /* authorizationHeader= */ HttpHeaders.AUTHORIZATION,
+ /* throwOnErrorStatus= */ true,
+ /* retryableStatus= */ null,
+ /* retryTransportErrors= */ true,
+ /* followRedirects= */ true,
+ /* compress= */ true,
+ /* compressor= */ null);
+
+ /**
+ * A request to an Actor's server. The token travels in the {@code X-Apify-Authorization} header,
+ * which the platform removes before the request reaches the Actor, so the {@code Authorization}
+ * header stays free for the Actor's own authentication. Every response is returned, error
+ * statuses included. Only the statuses in {@link #SERVER_RETRY_STATUS_CODES} are retried, and no
+ * transport error is, since the request may have reached the Actor before it failed. Redirects
+ * are not followed, since that would send the token to a host the Actor's code picked. The body
+ * is sent uncompressed unless a {@code compressor} is given, since the Actor's server may not
+ * decode a compressed body.
+ *
+ * @param compressor compresses the request body, or {@code null} to send it uncompressed
+ */
+ public static RequestPolicy server(HttpCompressor compressor) {
+ return new RequestPolicy(
+ /* authorizationHeader= */ HttpHeaders.X_APIFY_AUTHORIZATION,
+ /* throwOnErrorStatus= */ false,
+ /* retryableStatus= */ SERVER_RETRY_STATUS_CODES::contains,
+ /* retryTransportErrors= */ false,
+ /* followRedirects= */ false,
+ /* compress= */ compressor != null,
+ /* compressor= */ compressor);
+ }
+}
diff --git a/src/main/java/com/apify/client/internal/ResourceContext.java b/src/main/java/com/apify/client/internal/ResourceContext.java
index d1014be..08c0a12 100644
--- a/src/main/java/com/apify/client/internal/ResourceContext.java
+++ b/src/main/java/com/apify/client/internal/ResourceContext.java
@@ -323,8 +323,8 @@ public CompletableFuture> listResource(
* Builds a lazy, backpressure-aware {@link Flow.Publisher} over an offset/limit-paginated
* endpoint. {@code applyFilters} adds the per-endpoint filter params (everything except {@code
* offset}/{@code limit}, which the publisher drives per page). {@code totalLimit} caps the total
- * items yielded; {@code chunkSize} is the page size (both {@code null} meaning "unbounded" /
- * "server default").
+ * rows read (see {@link AsyncPaginatedPublisher}); {@code chunkSize} is the page size (both
+ * {@code null} meaning "unbounded" / "server default").
*/
public Flow.Publisher iterateResource(
String subPath,
diff --git a/src/main/java/com/apify/client/internal/Signatures.java b/src/main/java/com/apify/client/internal/Signatures.java
index 3d4d25d..986fb00 100644
--- a/src/main/java/com/apify/client/internal/Signatures.java
+++ b/src/main/java/com/apify/client/internal/Signatures.java
@@ -26,6 +26,15 @@ public final class Signatures {
private static final BigInteger BASE62 = BigInteger.valueOf(BASE62_ALPHABET.length());
+ private static final long MILLIS_PER_SECOND = 1000L;
+
+ /**
+ * Longest validity of a signed URL in seconds ({@code 4611686018427387}, about 146 million
+ * years). It is half of what fits in milliseconds in a {@code long}, so the current time plus the
+ * validity cannot overflow.
+ */
+ public static final long MAX_EXPIRES_IN_SECS = Long.MAX_VALUE / MILLIS_PER_SECOND / 2;
+
private Signatures() {}
/**
@@ -68,11 +77,17 @@ static String toBase62(BigInteger value) {
* #createHmacSignature}, then returns the base64url (no padding) encoding of {@code
* "{version}.{expiresAtMillis}.{hmac}"}.
*
+ * The expiry counts from this call, so call it after the storage lookup; otherwise the lookup
+ * time comes off the validity.
+ *
* @param expiresInSecs optional expiry in seconds ({@code null} for a non-expiring URL)
+ * @throws IllegalArgumentException if {@code expiresInSecs} fails {@link
+ * #requireValidExpiresInSecs}
*/
public static String signStorageContent(String secretKey, String resourceId, Long expiresInSecs) {
+ requireValidExpiresInSecs(expiresInSecs);
long expiresAtMillis =
- expiresInSecs != null ? System.currentTimeMillis() + expiresInSecs * 1000L : 0L;
+ expiresInSecs != null ? System.currentTimeMillis() + expiresInSecs * MILLIS_PER_SECOND : 0L;
String version = STORAGE_CONTENT_SIGNATURE_VERSION;
String message = version + "." + expiresAtMillis + "." + resourceId;
String hmac = createHmacSignature(secretKey, message);
@@ -82,6 +97,29 @@ public static String signStorageContent(String secretKey, String resourceId, Lon
.encodeToString(envelope.getBytes(StandardCharsets.UTF_8));
}
+ /**
+ * Checks the validity of a signed URL. {@code null} is a URL that does not expire. A value must
+ * be positive, because {@code 0} or a negative value would sign a URL that has already expired,
+ * and at most {@link #MAX_EXPIRES_IN_SECS}.
+ *
+ * @throws IllegalArgumentException if the value is out of range
+ */
+ public static void requireValidExpiresInSecs(Long expiresInSecs) {
+ if (expiresInSecs == null) {
+ return;
+ }
+ if (expiresInSecs <= 0) {
+ throw new IllegalArgumentException(
+ "expiresInSecs must be positive, got "
+ + expiresInSecs
+ + "; use null for a URL that does not expire");
+ }
+ if (expiresInSecs > MAX_EXPIRES_IN_SECS) {
+ throw new IllegalArgumentException(
+ "expiresInSecs must be at most " + MAX_EXPIRES_IN_SECS + ", got " + expiresInSecs);
+ }
+ }
+
private static byte[] hmacSha256(String secretKey, String message) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
diff --git a/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java b/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java
index b65c1b9..054024f 100644
--- a/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java
+++ b/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java
@@ -382,7 +382,11 @@ public CompletableFuture getRecordPublicUrl(String key) {
/**
* Builds a public URL for listing this store's keys. As with {@link #getRecordPublicUrl}, a
* signature is appended for private stores. {@code expiresInSecs} optionally bounds the validity
- * of a signed URL ({@code null} for non-expiring).
+ * of a signed URL ({@code null} for non-expiring); it must be positive and at most
+ * 4611686018427387.
+ *
+ * @throws IllegalArgumentException before any request is sent, if {@code expiresInSecs} is not
+ * {@code null} and is not positive or exceeds that maximum
*/
public CompletableFuture createKeysPublicUrl(Long expiresInSecs) {
return createKeysPublicUrl(new ListKeysOptions(), expiresInSecs);
@@ -393,10 +397,14 @@ public CompletableFuture createKeysPublicUrl(Long expiresInSecs) {
* ({@code limit}, {@code prefix}, {@code collection}, {@code exclusiveStartKey}) into the URL. As
* with {@link #getRecordPublicUrl}, a signature is appended for private stores unless the caller
* already supplied one. {@code expiresInSecs} optionally bounds the validity of a signed URL
- * ({@code null} for non-expiring).
+ * ({@code null} for non-expiring); it must be positive and at most 4611686018427387.
+ *
+ * @throws IllegalArgumentException before any request is sent, if {@code expiresInSecs} is not
+ * {@code null} and is not positive or exceeds that maximum
*/
public CompletableFuture createKeysPublicUrl(
ListKeysOptions options, Long expiresInSecs) {
+ Signatures.requireValidExpiresInSecs(expiresInSecs);
QueryParams params = new QueryParams();
options.apply(params);
if (options.signatureValue() != null) {
diff --git a/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java b/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java
index b87200b..5c70c7d 100644
--- a/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java
+++ b/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java
@@ -778,9 +778,9 @@ private void applyPage(RequestsList page, Long capRemaining) {
buffer = page.getItems();
pos = 0;
nextCursor = page.getNextCursor();
- // Defensively trim to the cap in case the server returned more than requested, matching
- // AsyncPaginatedPublisher's same guard, so a subscriber never sees more than `totalLimit`
- // items even if the server ignores (or overshoots) the requested per-page `limit`.
+ // Defensively trim to the cap in case the server returned more than requested, so a
+ // subscriber never sees more than `totalLimit` items even if the server ignores (or
+ // overshoots) the requested per-page `limit`.
if (capRemaining != null && buffer.size() > capRemaining) {
buffer = buffer.subList(0, capRemaining.intValue());
}
diff --git a/src/main/java/com/apify/client/run/RunDatasetItems.java b/src/main/java/com/apify/client/run/RunDatasetItems.java
index aae86c4..a31438d 100644
--- a/src/main/java/com/apify/client/run/RunDatasetItems.java
+++ b/src/main/java/com/apify/client/run/RunDatasetItems.java
@@ -67,8 +67,8 @@ private enum Phase {
}
/**
- * Reads the next page. Completes with a page whose scanned count is non-zero while there may be
- * more rows, and with an empty page that scanned nothing once iteration is over.
+ * Reads the next page. Completes with a page whose count is non-zero while there may be more
+ * rows, and with an empty page of count zero once iteration is over.
*/
CompletableFuture> next() {
switch (phase) {
@@ -131,17 +131,16 @@ private CompletableFuture> tail() {
return page(position, limit)
.thenCompose(
page -> {
- Long scanned = page.getScannedCount();
- boolean empty = page.getItems().isEmpty() && (scanned == null || scanned == 0);
- if (!empty) {
+ // Only a page that covered no rows marks the end, as filters can shorten a full one.
+ if (page.getCount() > 0) {
return CompletableFuture.completedFuture(advance(page, limit));
}
if (!access.dropsRows(options)) {
return finish();
}
- // A page that clean, skipEmpty or unwind emptied past a lagging itemCount reports no
- // scanned rows either, so a read of the next row without those filters (they would
- // empty it again) tells whether any rows are left.
+ // A page that clean, skipEmpty or unwind emptied past a lagging itemCount covers no
+ // rows either, so a read of the next row without those filters (they would empty it
+ // again) tells whether any rows are left.
return dataset
.listItems(access.plainPage(position, 1L), JsonNode.class)
.thenCompose(
@@ -165,20 +164,20 @@ private CompletableFuture> page(long offset, long limit) {
return dataset.listItems(access.page(options, offset, limit), itemClass);
}
- /** Moves past the {@code limit} rows a page covered and marks it as having scanned them. */
+ /** Moves past the {@code limit} rows a page covered and marks it as having covered them. */
private PaginationList advance(PaginationList page, long limit) {
position += limit;
if (end != null && position >= end && phase == Phase.TAIL) {
phase = Phase.DONE;
}
- page.setScannedCount(Math.max(limit, 1));
+ page.setCount(Math.max(limit, 1));
return page;
}
private PaginationList endOfData() {
PaginationList page = new PaginationList<>();
page.setItems(List.of());
- page.setScannedCount(0L);
+ page.setCount(0);
return page;
}
}
diff --git a/src/test/java/com/apify/client/ActorServerClientTest.java b/src/test/java/com/apify/client/ActorServerClientTest.java
new file mode 100644
index 0000000..2bb7ebb
--- /dev/null
+++ b/src/test/java/com/apify/client/ActorServerClientTest.java
@@ -0,0 +1,985 @@
+package com.apify.client;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.apify.client.actor.ActorClient;
+import com.apify.client.actor.ActorServerClient;
+import com.apify.client.actor.ActorServerRequestOptions;
+import com.apify.client.actor.ActorServerStreamResponse;
+import com.apify.client.http.ApiResponse;
+import com.apify.client.http.ApifyTransportException;
+import com.apify.client.http.DefaultHttpTransport;
+import com.apify.client.http.GzipHttpCompressor;
+import com.apify.client.http.HttpTransport;
+import com.apify.client.http.NotFoundError;
+import com.apify.client.http.ServerActorException;
+import com.apify.client.http.Timeout;
+import com.apify.client.internal.HttpClientCore;
+import com.apify.client.internal.Json;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
+import java.util.zip.GZIPInputStream;
+import java.util.zip.GZIPOutputStream;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import tools.jackson.databind.JsonNode;
+
+/**
+ * Tests of {@link ActorServerClient} against a local HTTP server that plays both the Apify API and
+ * the Actor's server, so the headers, redirects and dropped connections are real network behavior.
+ * Token-free.
+ */
+class ActorServerClientTest {
+
+ private static final String ACTOR_ID = "test-actor-id";
+ private static final String ACTOR_PATH = "/v2/actors/" + ACTOR_ID;
+ private static final String SERVER_PREFIX = "/server";
+ private static final JsonNode OPENAPI_SPEC =
+ Json.toTree(Map.of("openapi", "3.0.0", "paths", Map.of()));
+ private static final String TOKEN = "test-token";
+
+ private TestHttpServer server;
+
+ /** The Actor the API returns; null answers 404, as for an Actor that does not exist. */
+ private Map actor;
+
+ /** Build numbers of the Actor's builds by build id, newest first, as the builds list has them. */
+ private Map builds;
+
+ /** How many builds the builds list returns per page. */
+ private int buildsPerPage;
+
+ /** The {@code actorDefinition} every build the API returns has; null for none. */
+ private Map actorDefinition;
+
+ /** Statuses the server answers {@code /flaky} with, one per request, before it answers 200. */
+ private final List flakyStatuses = Collections.synchronizedList(new ArrayList<>());
+
+ @BeforeEach
+ void startServer() throws IOException {
+ server = new TestHttpServer(this::answer);
+ actor = serverActor(Map.of());
+ builds = new LinkedHashMap<>();
+ buildsPerPage = Integer.MAX_VALUE;
+ actorDefinition = Map.of("webServerSchema", Json.toTree(OPENAPI_SPEC));
+ }
+
+ @AfterEach
+ void stopServer() throws IOException {
+ server.close();
+ }
+
+ private Map serverActor(Map fields) {
+ Map result = new LinkedHashMap<>();
+ result.put("id", ACTOR_ID);
+ result.put("name", "test-actor");
+ result.put("actorStandby", Map.of("isEnabled", true));
+ result.put(
+ "taggedBuilds",
+ Map.of(
+ "latest", Map.of("buildId", "latest-build-id", "buildNumber", "0.0.2"),
+ "beta", Map.of("buildId", "beta-build-id", "buildNumber", "0.0.3")));
+ result.put("standbyUrl", server.url() + SERVER_PREFIX);
+ result.putAll(fields);
+ return result;
+ }
+
+ private TestHttpServer.Reply answer(TestHttpServer.Received request) {
+ String path = request.path();
+ if (path.equals(ACTOR_PATH)) {
+ return actor == null
+ ? TestHttpServer.Reply.json(
+ 404,
+ "{\"error\":{\"type\":\"record-not-found\",\"message\":\"Actor was not found\"}}")
+ : data(actor);
+ }
+ if (path.equals(ACTOR_PATH + "/builds")) {
+ return buildList(request);
+ }
+ if (path.startsWith("/v2/actor-builds/")) {
+ Map build = new LinkedHashMap<>();
+ build.put("id", path.substring(path.lastIndexOf('/') + 1));
+ build.put("actorDefinition", actorDefinition);
+ return data(build);
+ }
+ if (path.equals(SERVER_PREFIX + "/flaky") && !flakyStatuses.isEmpty()) {
+ return TestHttpServer.Reply.json(flakyStatuses.remove(0), "{\"error\":\"flaky\"}");
+ }
+ if (path.equals(SERVER_PREFIX + "/hang-up")) {
+ return TestHttpServer.Reply.drop();
+ }
+ if (path.equals(SERVER_PREFIX + "/missing")) {
+ return TestHttpServer.Reply.of(404, "text/plain", "Not here");
+ }
+ return TestHttpServer.Reply.json(200, "{\"ok\":true}");
+ }
+
+ private TestHttpServer.Reply buildList(TestHttpServer.Received request) {
+ long offset = 0;
+ for (String pair : request.query().split("&")) {
+ if (pair.startsWith("offset=")) {
+ offset = Long.parseLong(pair.substring("offset=".length()));
+ }
+ }
+ List