diff --git a/CHANGELOG.md b/CHANGELOG.md index d6050f7..7f1959d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,66 @@ All notable changes to the Apify Java client are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.12.0] - 2026-10-30 + +### Added + +- Timeout tiers: `Timeout` (`SHORT`, `MEDIUM`, `LONG`, `NO_TIMEOUT`, `of(Duration)`), the builder + setters `timeoutShort`, `timeoutMedium`, `timeoutLong` (defaults 5s, 30s, 360s) and `timeoutMax` + (cap of a single attempt, default 360s), and `ApifyClient.withTimeout(Timeout)` to replace the tier + of every request a client sends. Every method now picks its tier as the reference client does + (metadata reads and writes `SHORT`; listing, batch and trigger calls `MEDIUM`; downloads, uploads + and streaming `LONG`), a request with `waitForFinish` adds the hold to its tier, and the polling of + `waitForFinish`/`call` has no timeout. `SetRecordOptions.timeout(Timeout)`. +- Configurable request compression: `HttpCompressor`, `BrotliHttpCompressor` (quality 0-11) and + `GzipHttpCompressor` (level 1-9, default 6) with `ApifyClientBuilder.compression(...)`. +- `ApifyClientBuilder.header(name, value)`: default headers sent with every request. +- `RunClient.iterateDatasetItems(...)`: iterate the items of a run's default dataset while the run + is still producing them. +- `RunListOptions.deployment(...)` and `StoreListOptions.includeInputSchema(...)`. +- `UserClient.limits()`, `updateLimits(...)` and `monthlyUsage(...)` work for any user id. +- Typed fields: `ActorRun.getStandby()` (`ActorRunStandby`), `getConsoleUrl()`, `getOutput()`, + `getEventUsage()`, `getAccountedChargedEventCounts()`, `getExternalChargedTotalUsd()`, + `getIntegrationTracking()`, `getCreatedByOrganizationMemberUserId()`; `ActorRunMeta` origin ids; + `title`, `consoleUrl` and public URLs on `Dataset`, `KeyValueStore`, `KeyValueStoreKey` and + `RequestQueue`; `Webhook` integration fields; `Schedule.getUiState()`; `Task.getStandbyUrl()`; + `TaskStats.getLastRunStartedAt()`; `Build` and `BuildOptions` fields; `ActorVersion` webhook ids + and source URLs; `ActorEnvVar.getValueHash()`; `ActorStandby` tenancy and auth flags; the + `maxTotalChargeUsd` run options. + +### Changed + +- Updated to Apify OpenAPI specification `v2-2026-10-29T100357Z`. +- The `format: uri` fields of the models are returned in the normalized WHATWG form (empty path `/`, + lowercase punycode host, default port dropped, unsafe characters percent-encoded). +- A resource client's id is percent-encoded into a single path segment, and an empty id or a bare + `.`/`..` is rejected with `IllegalArgumentException` instead of addressing the collection. +- `ApifyClientBuilder.timeout(Duration)` is deprecated: it is an alias of `timeoutMax(Duration)`. +- The silent fallback from a failing brotli encode to gzip is gone: the configured compressor is the + only one that runs, and its failure fails the request. Brotli stays the default (quality 6) where + its native library loads, gzip elsewhere. +- `batchAddRequests` serializes each request once instead of twice. +- A `waitForFinish` hold extends the timeout of its request (the tier's plus the hold) instead of + being clamped below it; a hold is cut down only to what `timeoutMax` (or a `withTimeout` override) + leaves, so the API still answers before the client gives up. The polls of `waitForFinish`/`call` + have no timeout and ignore `withTimeout`. +- `Build.getChangelog()` is deprecated, as the specification deprecates `Build.changelog`. +- The integration tests wait up to 90 s for a just-created resource to appear in a listing. + +### Fixed + +- A `pushItems` request that times out is no longer retried, so the items cannot be stored twice. +- Cancelling a future the client returned (`CompletableFuture.cancel`) now aborts the request in + flight and stops the retries, `waitForFinish`/`call` polling and iteration pages that would follow; + cancelling a derived stage (`thenApply`, `thenCompose`, ...) cancels the call it comes from. + Cancelling `call(...)` also stops its log redirection, and closing a `StreamedLog` while its + stream is still opening launches no reader thread (the `start()` future is cancelled). See the + Cancellation section of the README. +- `pushItems` rejects what the API rejects (anything but an object or an array of objects) before a + request is sent, and sends a pre-serialized `String` as it is. +- An Actor or task input given as `byte[]` or `String` is sent as it is instead of being wrapped in + a second layer of JSON. + ## [0.11.0] - 2026-10-26 ### Added diff --git a/README.md b/README.md index b3b3f8a..7f9d1c7 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Maven (Maven Central is a default repository, so no extra configuration is neede com.apify apify-client - 0.11.0 + 0.12.0 ``` @@ -35,7 +35,7 @@ repositories { } dependencies { - implementation 'com.apify:apify-client:0.11.0' + implementation 'com.apify:apify-client:0.12.0' } ``` @@ -133,7 +133,12 @@ ApifyClient configured = .baseUrl("https://api.apify.com") // /v2 is appended automatically .maxRetries(8) .minDelayBetweenRetries(Duration.ofMillis(500)) - .timeout(Duration.ofSeconds(360)) + .timeoutShort(Duration.ofSeconds(5)) + .timeoutMedium(Duration.ofSeconds(30)) + .timeoutLong(Duration.ofSeconds(360)) + .timeoutMax(Duration.ofSeconds(360)) + .compression(new GzipHttpCompressor(6)) + .header("X-Integration", "my-platform") .userAgentSuffix("MyTool/1.0") .build(); ``` @@ -152,15 +157,99 @@ validated at call time (see below), not deferred to `build()`. | `publicBaseUrl(String)` | `String` | same as `baseUrl` | non-null, non-blank | | `maxRetries(int)` | `int` | `8` | `>= 0` | | `minDelayBetweenRetries(Duration)` | `Duration` | `500ms` | non-null, `>= 0` | -| `maxDelayBetweenRetries(Duration)` | `Duration` | same as `timeout` (`360s`) | non-null, `>= 0` | -| `timeout(Duration)` | `Duration` | `360s` | non-null, strictly `> 0` | +| `maxDelayBetweenRetries(Duration)` | `Duration` | same as `timeoutMax` (`360s`) | non-null, `>= 0` | +| `timeoutShort(Duration)` | `Duration` | `5s` | non-null, strictly `> 0` | +| `timeoutMedium(Duration)` | `Duration` | `30s` | non-null, strictly `> 0` | +| `timeoutLong(Duration)` | `Duration` | `360s` | non-null, strictly `> 0` | +| `timeoutMax(Duration)` | `Duration` | `360s` | non-null, strictly `> 0` | +| `compression(HttpCompressor)` | `HttpCompressor` | `BrotliHttpCompressor` (quality 6), or `GzipHttpCompressor` where the brotli native library is unavailable | non-null | +| `header(String, String)` | `String`, `String` | none | non-blank name, non-null value | | `userAgentSuffix(String)` | `String` | none (no suffix appended) | any string | | `httpTransport(HttpTransport)` | `HttpTransport` | `new DefaultHttpTransport()` | non-null | -A violated range throws `IllegalArgumentException` from the setter itself. `timeout` is the ceiling -each retry attempt's per-request (socket) timeout grows toward — not a wall-clock bound on the -cumulative time across all retries; the connection-establishment timeout is a separate, -transport-level setting (see `DefaultHttpTransport`'s constructors below). +A violated range throws `IllegalArgumentException` from the setter itself. The deprecated +`timeout(Duration)` is an alias of `timeoutMax(Duration)`. + +### Timeouts + +Every method picks the timeout tier that fits the expected duration of its request: `short` (5s) for +simple metadata reads and writes, `medium` (30s) for listing, batch and trigger operations, `long` +(360s) for downloads, uploads and streaming. The three durations are set with `timeoutShort`, +`timeoutMedium` and `timeoutLong`. A method that asks the API to hold the response with +`waitForFinish` (`getWithWait`, `start`, building an Actor, ...) gets its tier plus that hold (at +most 60s), so the client does not abort a request the API is still holding. The polling of +`waitForFinish(...)`/`call(...)` and the start inside `call(...)` have no timeout. + +`timeoutMax` caps the timeout of a single request attempt. The first attempt gets the timeout of the +method's tier, each retry doubles it, and none exceeds `timeoutMax`; a tier or timeout above the cap +is capped and logged as a warning. It bounds each attempt, not the wall-clock time across all +retries. The connection-establishment timeout is a separate, transport-level setting (see +`DefaultHttpTransport`'s constructors below). + +A call that legitimately needs a different timeout gets it from `ApifyClient.withTimeout(Timeout)`, +which returns a client that sends every request with that timeout instead of the method's tier. A +`Timeout` is a tier (`Timeout.SHORT`, `MEDIUM`, `LONG`), an exact duration (`Timeout.ofSeconds(120)`, +`Timeout.of(Duration)`) or `Timeout.NO_TIMEOUT` (the request is never aborted, so a stalled connection +is not retried either): + +```java +ApifyClient client = ApifyClient.create("my-api-token"); +List items = + client + .withTimeout(Timeout.ofSeconds(900)) + .dataset("my-dataset-id") + .listItems(new DatasetListItemsOptions()) + .join() + .getItems(); +``` + +`KeyValueStoreClient.setRecord(...)` additionally takes the timeout in its `SetRecordOptions`. + +A `pushItems` request that times out is not retried, since the API may already have stored the items. + +A `waitForFinish` hold is cut down to what the request timeout can cover, so the API answers before +the client gives up on the request. When the tier plus the hold fits under `timeoutMax`, the full +hold is sent with the tier plus the hold as the timeout. Otherwise the request is sent with a +`timeoutMax` timeout and a hold of the requested hold or `timeoutMax` less 5 seconds, whichever is +smaller (0 if `timeoutMax` is 5 seconds or less). Under a `withTimeout` override (which is capped at +`timeoutMax` like any timeout) the hold is at most the override less 5 seconds. The polls of +`waitForFinish(...)` and `call(...)` are not affected by `withTimeout`: they have no timeout and +always hold for up to a minute, so cancel the returned future (see below) to stop one that never +answers. + +### Cancellation + +Every method returns a `CompletableFuture` (or a `Flow.Publisher`), and cancelling it cancels the +work behind it: `future.cancel(true)` aborts the request in flight and stops the retries, the +`waitForFinish`/`call` polling and the pages of an iteration that would follow; cancelling the +`Flow.Subscription` of a publisher does the same for the page being fetched. This is the way out of a +request that has no timeout (the polls of `waitForFinish`/`call` and `Timeout.NO_TIMEOUT`): + +```java +CompletableFuture call = + client.actor("my-actor").call(null, new ActorCallOptions(), null); // redirects the run's log +// ... later, when the result is no longer needed: +call.cancel(true); // stops the polling and the log redirection; the run itself keeps running +``` + +A cancelled future completes with a `CancellationException`. Cancelling a stage derived from a +client future (`future.thenApply(...)`, `.thenCompose(...)`) cancels the call it comes from, and so +fails the other stages derived from the same future with a `CancellationException` too; derive one +stage per call, or cancel the future the client returned. Cancelling does not undo what the API +already did: a started run keeps running (abort it with `RunClient.abort(...)`). + +### Request compression and default headers + +Request bodies of at least 1 KiB with a compressible content type are compressed with the +configured `HttpCompressor`: `BrotliHttpCompressor` (quality 0-11, default 6; used by default where +its native library is available) or `GzipHttpCompressor` (level 1-9, default 6), or your own +implementation of the interface. A failing compressor fails the request; the client does not fall +back to another coding. A body that already carries a `Content-Encoding`, and already-compressed +content types (images, archives, ...), are sent as they are. + +`header(name, value)` adds a header to every request, such as an integration platform marker. It +replaces a built-in header of the same name (`User-Agent`, `Authorization`) and is replaced by a +header a request sets itself. `publicBaseUrl` does not affect ordinary API requests (those always use `baseUrl`); it only changes the origin embedded in the handful of public, directly-fetchable URLs the client builds without a @@ -280,10 +369,11 @@ A few methods validate their own preconditions synchronously, before ever buildi future, and throw a plain JDK exception directly from the call site instead, not an `ApifyClientException` subtype (so no `.join()`/`.get()` unwrapping is involved for these): `ApifyClient.setStatusMessage(message, options)` throws `IllegalStateException` if the -`ACTOR_RUN_ID` environment variable is not set, `ApifyClient.me()`'s limits methods (`limits()`, -`updateLimits(...)`, `monthlyUsage(...)`) throw the same `IllegalStateException` if called on a -`UserClient` that is not `me()`, and `ApifyClientBuilder`'s setters throw `IllegalArgumentException` -on an invalid configuration value (e.g. a negative retry count). These are local, no-request-sent +`ACTOR_RUN_ID` environment variable is not set, `ApifyClientBuilder`'s setters throw +`IllegalArgumentException` on an invalid configuration value (e.g. a negative retry count), a +resource client's id is rejected with `IllegalArgumentException` when it is empty or a bare `.`/`..` +(any other id is percent-encoded into one path segment), and `DatasetClient.pushItems(...)` throws +it for items the API would reject (anything but an object or an array of objects). These are local, no-request-sent failures, not something the API responded with or the transport failed to deliver, which is why they stay outside the request/transport exception hierarchy above — catch `IllegalStateException`/`IllegalArgumentException` separately if you call any of them. @@ -317,10 +407,10 @@ try { The public `com.apify.client.Version` class (`import com.apify.client.Version;`) exposes two constants: -- `Version.CLIENT_VERSION` — the semantic version of this client (`0.11.0`). +- `Version.CLIENT_VERSION` — the semantic version of this client (`0.12.0`). - `Version.API_SPEC_VERSION` — the version of the [Apify OpenAPI specification](https://docs.apify.com/api/openapi.json) (its `info.version` field) that this client's endpoints, parameters and models were last generated - and checked against (`v2-2026-10-26T112451Z`). It is a snapshot, not a live compatibility + and checked against (`v2-2026-10-29T100357Z`). It is a snapshot, not a live compatibility guarantee: the client keeps working against newer, backward-compatible spec revisions, but a feature added to the API after this snapshot has no corresponding method here yet. diff --git a/docs/README.md b/docs/README.md index 92fa938..3138800 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,8 +16,10 @@ ApifyClient client = ApifyClient.create("my-api-token"); ``` `ApifyClient.create` takes the token as an explicit argument — it does not read `APIFY_TOKEN` -automatically. Use `ApifyClient.builder()` for non-default settings (base URL, retries, timeout, -user-agent suffix, custom HTTP transport). +automatically. Use `ApifyClient.builder()` for non-default settings (base URL, retries, timeout tiers, +compression, default headers, user-agent suffix, custom HTTP transport). `client.withTimeout(...)` +returns a client that sends every request with a different timeout (see +[Timeouts](../README.md#timeouts)). Get your API token from the [Apify Console → Settings → API & Integrations](https://console.apify.com/settings/integrations). @@ -39,10 +41,10 @@ option types, so import each resource you use from its own package: | Package | Contains | |---|---| | `com.apify.client` (root) | `ApifyClient`, `ApifyClientBuilder`, `Version`, `PaginationList`, `ListOptions`, `StorageListOptions`, `ApifyResource` | -| `com.apify.client.http` | `ApifyClientException`, `ApifyApiException`, `ApifyTransportException`, `HttpTransport`, `DefaultHttpTransport`, `HttpTimeoutException` | +| `com.apify.client.http` | `ApifyClientException`, `ApifyApiException`, `ApifyTransportException`, `HttpTransport`, `DefaultHttpTransport`, `HttpTimeoutException`, `Timeout`, `HttpCompressor`, `BrotliHttpCompressor`, `GzipHttpCompressor` | | `com.apify.client.actor` | `Actor`, `ActorClient`, `ActorCollectionClient`, `ActorListOptions`, `ActorStartOptions`, `ActorCallOptions`, `ActorBuildOptions`, `ActorStandby`, `ActorVersion`, `SourceCodeFile`, `ActorVersionClient`, `ActorVersionCollectionClient`, `ActorEnvVar`, `ActorEnvVarClient`, `ActorEnvVarCollectionClient`, `ValidateInputOptions`, `ActorStats`, `ActorDefaultRunOptions` | | `com.apify.client.build` | `Build`, `BuildClient`, `BuildCollectionClient`, `BuildActVersion`, `ActorDefinition`, `ActorDefinitionMeta`, `ActorDefinitionStorages`, `BuildMeta`, `BuildStats`, `BuildOptions`, `BuildUsage` | -| `com.apify.client.run` | `ActorRun`, `ActorRunStats`, `ActorRunOptions`, `ActorRunMeta`, `ActorRunUsage`, `RunClient`, `RunCollectionClient`, `RunListOptions`, `LastRunOptions`, `MetamorphOptions`, `RunChargeOptions`, `RunResurrectOptions`, `SetStatusMessageOptions` | +| `com.apify.client.run` | `ActorRun`, `ActorRunStats`, `ActorRunOptions`, `ActorRunMeta`, `ActorRunStandby`, `ActorRunUsage`, `RunClient`, `RunCollectionClient`, `RunListOptions`, `LastRunOptions`, `MetamorphOptions`, `RunChargeOptions`, `RunResurrectOptions`, `SetStatusMessageOptions` | | `com.apify.client.dataset` | `Dataset`, `DatasetClient`, `DatasetCollectionClient`, `DatasetListItemsOptions`, `DatasetDownloadOptions`, `DownloadItemsFormat` | | `com.apify.client.keyvalue` | `KeyValueStore`, `KeyValueStoreClient`, `KeyValueStoreCollectionClient`, `KeyValueStoreRecord`, `KeyValueStoreKeysPage`, `KeyValueStoreKey`, `GetRecordOptions`, `SetRecordOptions`, `ListKeysOptions` | | `com.apify.client.requestqueue` | `RequestQueue`, `RequestQueueClient`, `RequestQueueCollectionClient`, `RequestQueueRequest`, `RequestQueueHead`, `LockedRequestQueueHead`, `RequestQueueOperationInfo`, `RequestLockInfo`, `UnlockRequestsResult`, `RequestsList`, `BatchAddResult`, `BatchDeleteResult`, `DeletedRequestInfo`, `ListRequestsOptions`, `BatchAddRequestsOptions` | @@ -71,13 +73,21 @@ Raw-JSON return values use Jackson's `tools.jackson.databind.JsonNode`. Jackson `tools.jackson.*` package, not the older `com.fasterxml.jackson.databind.*` from Jackson 2) is a transitive dependency of this client, so it is already on your classpath. +## Cancellation + +Cancelling a future a client method returned (`future.cancel(true)`), or the `Flow.Subscription` of +an iteration, aborts the request in flight and stops the retries, the `waitForFinish`/`call` polling +and the pages that would follow; see [Cancellation](../README.md#cancellation) for the details and +the caveat about derived stages. A cancelled `call(...)` also stops redirecting the run's log, but +the run itself keeps running. + ## Raw JSON values A few methods return data whose shape is not modelled by this client and is instead exposed as a Jackson `JsonNode` (or accept an arbitrary `Object` serialized to JSON): - Read, returning a required `JsonNode` (never absent; a 404 throws rather than returning empty — - see [error handling](../README.md#error-handling)): `me().monthlyUsage(...)`, `me().limits()`, + see [error handling](../README.md#error-handling)): `me().monthlyUsage(...)`, `me().limits()` (and the same on `user(id)`), `dataset(id).getStatistics()`, `task(id).getInput()`. - Read, returning `Optional` (empty when the underlying resource has none): `build(id).getOpenApiDefinition()`. diff --git a/docs/actors.md b/docs/actors.md index 43c1f3f..f4e5a70 100644 --- a/docs/actors.md +++ b/docs/actors.md @@ -46,7 +46,7 @@ Actor created = client.actors().create(Map.of( | `get()` | Fetch the Actor. Completes with `Optional`. | | `update(Object)` | Update the Actor with the given fields. Completes with `Actor`. | | `delete()` | Delete the Actor. | -| `start(Object input, ActorStartOptions)` | Start a run, returning immediately. Completes with `ActorRun`. | +| `start(Object input, ActorStartOptions)` | Start a run, returning immediately. Completes with `ActorRun`. The `input` is any JSON-serializable value, or a `byte[]`/`String` that is sent as it is (set the matching `ActorStartOptions.contentType(...)`), or `null`. | | `call(Object input, ActorStartOptions, Long waitSecs)` | Start a run and poll until it finishes (`null` waits indefinitely); does **not** stream the run's log. Completes with `ActorRun`. | | `call(Object input, ActorCallOptions, Long waitSecs)` | As above, additionally streaming the run's log for the duration of the wait by default (matching the reference client's `call` defaulting `options.log` to `'default'`). Use `ActorCallOptions.disableLogStreaming()` to opt out, or `logOptions(StreamedLogOptions)` for a custom destination. | | `validateInput(Object input)` / `validateInput(Object input, ValidateInputOptions)` | Validate an input against the Actor's input schema. Completes with `boolean`. `ValidateInputOptions` fields (optional): `build` (`String`), `contentType` (`String`). | diff --git a/docs/builds.md b/docs/builds.md index 7255e2e..cdc606e 100644 --- a/docs/builds.md +++ b/docs/builds.md @@ -15,7 +15,7 @@ builds) and a single build with `client.build(id)`. | Method | Description | |---|---| | `get()` | Fetch the build. Completes with `Optional`. | -| `getWithWait(Long waitForFinishSecs)` | Fetch, optionally waiting server-side for the build to finish (clamped to the request timeout; the API caps server-side waiting at 60s). Completes with `Optional`. | +| `getWithWait(Long waitForFinishSecs)` | Fetch, optionally waiting server-side for the build to finish (the API caps server-side waiting at 60s; the request timeout is extended by that hold). Completes with `Optional`. | | `abort()` | Abort the build. Completes with `Build`. | | `delete()` | Delete the build. | | `waitForFinish(Long waitSecs)` | Poll until the build finishes (`null` waits indefinitely); see [the run equivalent](runs.md#runclient) for its 404 handling. Completes with `Build`. | diff --git a/docs/misc.md b/docs/misc.md index 63b8773..8db218d 100644 --- a/docs/misc.md +++ b/docs/misc.md @@ -39,12 +39,12 @@ still available via the inherited `getExtra()` (see | Method | Description | |---|---| | `get()` | Fetch the user. Completes with `Optional` (private details for `me()` via `getExtra()`). | -| `monthlyUsage()` / `monthlyUsage(String date)` | Account monthly usage (`me()` only). `date` (any day within the target month, formatted `YYYY-MM-DD`) reports that month; `null`/empty reports the current month. Completes with `JsonNode`. | -| `limits()` | Account resource limits (`me()` only). Completes with `JsonNode`. | -| `updateLimits(Object)` | Update account limits (`me()` only). Completes with no value (`CompletableFuture`). | +| `monthlyUsage()` / `monthlyUsage(String date)` | The user's monthly usage. `date` (any day within the target month, formatted `YYYY-MM-DD`) reports that month; `null`/empty reports the current month. Completes with `JsonNode`. | +| `limits()` | The user's account and usage limits. Completes with `JsonNode`. | +| `updateLimits(Object)` | Update the user's account and usage limits. Completes with no value (`CompletableFuture`). | -The usage/limits methods are only available for `me()`; calling them on `user(id)` throws -`IllegalStateException`. +The usage/limits methods work on `me()` and on `user(id)` (`/v2/users/{userId}/usage/monthly` and +`/v2/users/{userId}/limits`), subject to what the API token may access. ```java Optional me = client.me().get().join(); diff --git a/docs/runs.md b/docs/runs.md index c19d478..de5a4f0 100644 --- a/docs/runs.md +++ b/docs/runs.md @@ -11,6 +11,9 @@ Access the run collection with `client.runs()` (or `client.actor(id).runs()` / | `iterate(ListOptions, RunListOptions, Long chunkSize)` | Lazy `Flow.Publisher` over all matching runs; the options' `limit` caps the total yielded (`null`/unset or non-positive = all), `chunkSize` sets the per-request page size (`null` = server default). | `RunListOptions` adds `status(List)` (e.g. `SUCCEEDED`, `RUNNING`; sent comma-separated), +`deployment(List)` (filter Actor Standby runs by serving pool: `SINGLE_TENANT`, +`MULTI_TENANT_FREE_USERS`, `MULTI_TENANT_PAYING_USERS`, `MULTI_TENANT_OWNER_PAID`; needs a `status` +filter with only non-terminal statuses, declared by `client.runs()` and `client.actor(id).runs()`), declared by every scope's endpoint (`client.runs()`, `client.actor(id).runs()`, `client.task(id).runs()`), and `startedAfter(String)` / `startedBefore(String)` (ISO-8601), declared only by the account-wide `client.runs()` and the Actor-scoped `client.actor(id).runs()` endpoints — @@ -29,7 +32,7 @@ PaginationList runs = client.runs().list( | Method | Description | |---|---| | `get()` | Fetch the run. Completes with `Optional`. | -| `getWithWait(Long waitForFinishSecs)` | Fetch, optionally waiting server-side for the run to finish (clamped to the request timeout; the API caps server-side waiting at 60s). Completes with `Optional`. | +| `getWithWait(Long waitForFinishSecs)` | Fetch, optionally waiting server-side for the run to finish (the API caps server-side waiting at 60s; the request timeout is extended by that hold). Completes with `Optional`. | | `update(Object)` | Update the run. Completes with `ActorRun`. | | `delete()` | Delete the run. | | `abort(Boolean gracefully)` | Abort the run (`null` = server default). Completes with `ActorRun`. | @@ -39,6 +42,7 @@ PaginationList runs = client.runs().list( | `charge(RunChargeOptions)` | Charge a pay-per-event run for a named event. Completes with no value (`CompletableFuture`). | | `waitForFinish(Long waitSecs)` | Poll until the run finishes (`null` waits indefinitely). Completes with `ActorRun`. | | `dataset()` / `keyValueStore()` / `requestQueue()` | Clients for the run's default storages. | +| `iterateDatasetItems(DatasetListItemsOptions)` / `iterateDatasetItems(DatasetListItemsOptions, Long chunkSize, Long pollIntervalSecs, Class)` | A lazy `Flow.Publisher` over the items of the run's default dataset **while the run is still producing them**; it ends once the run has finished and every item has been read. While the run is active, each poll yields the rows below the dataset's `itemCount` and then waits up to `pollIntervalSecs` (default 5) for the run to finish; afterwards the rows past the lagging `itemCount` are read a page at a time. The options' `offset`/`limit` bound the dataset *rows* scanned (`desc` is ignored), `chunkSize` is the rows per request (default 1000). On a `lastRun()` client it sticks to the run its first request resolves to. | | `log()` | A `LogClient` for the run's log (see [Store, users & logs](misc.md#logs--clientlogid)). | | `getStreamedLog()` | Completes with a `StreamedLog` that redirects the live log to a default per-run logger. | | `getStreamedLog(StreamedLogOptions)` | As above, with a custom destination / options. | @@ -115,6 +119,15 @@ The status is one of `READY`, `RUNNING`, `SUCCEEDED`, `FAILED`, `TIMING-OUT`, `T `ABORTING`, `ABORTED`; `isTerminal()` is true for the finished states (`SUCCEEDED`, `FAILED`, `TIMED-OUT`, `ABORTED`). +`ActorRun` also exposes the Server Actor deployment that served the run, `getStandby()` +(`ActorRunStandby`; `getDeployment()` is `SINGLE_TENANT` or one of the `MULTI_TENANT_*` pools, `null` +for a run not started by a Server Actor request; charges in a multi-tenant pool need +`RunChargeOptions.requestId(...)`), and `getConsoleUrl()`, `getOutput()` (`Map`), +`getEventUsage()` (`Map`), `getAccountedChargedEventCounts()`, +`getExternalChargedTotalUsd()`, `getIntegrationTracking()` and +`getCreatedByOrganizationMemberUserId()`; `getMeta()` adds `getActorRunId()`, +`getWebhookDispatchId()`, `getScheduledAct2Id()` and `getScheduledActorTaskId()`. + `ActorRun` additionally exposes: `getGeneralAccess()` (`String`; who can access the run without owning it, e.g. `"ANYONE_WITH_ID_CAN_READ"`, `"RESTRICTED"`, or `null` to follow the account/Actor default), `getChargedEventCounts()` (`Map`; per-event-type charge counts for diff --git a/docs/storages.md b/docs/storages.md index f4d6802..56c940c 100644 --- a/docs/storages.md +++ b/docs/storages.md @@ -13,9 +13,17 @@ getters `getId()`, `getName()`, `getUserId()`, `getCreatedAt()` (`Instant`), and | Model | Common getters | Type-specific | |---|---|---| -| `Dataset` | `getId`, `getName`, `getUserId`, `getCreatedAt`, `getModifiedAt` | `getItemCount()` (`long`) | -| `KeyValueStore` | `getId`, `getName`, `getUserId`, `getCreatedAt`, `getModifiedAt` | — | -| `RequestQueue` | `getId`, `getName`, `getUserId`, `getCreatedAt`, `getModifiedAt` | `getTotalRequestCount()` (`long`) | +| `Dataset` | `getId`, `getName`, `getTitle`, `getUserId`, `getCreatedAt`, `getModifiedAt`, `getConsoleUrl` | `getItemCount()` (`long`), `getItemsPublicUrl()` | +| `KeyValueStore` | `getId`, `getName`, `getTitle`, `getUserId`, `getCreatedAt`, `getModifiedAt`, `getConsoleUrl` | `getKeysPublicUrl()`, `getRecordsPublicUrl()` | +| `RequestQueue` | `getId`, `getName`, `getTitle`, `getUserId`, `getCreatedAt`, `getModifiedAt`, `getConsoleUrl` | `getTotalRequestCount()` (`long`) | + +The URL fields of every model (`getConsoleUrl()`, `getItemsPublicUrl()`, `getContainerUrl()`, a +webhook's `getRequestUrl()`, ...) are the fields the OpenAPI spec marks `format: uri`. They are +returned normalized, the way a WHATWG URL serializer writes them, so every Apify client returns the +same string: an empty path becomes `/`, the scheme and host are lowercased (a non-ASCII host is +punycoded), a default port is dropped and unsafe characters are percent-encoded. Compare such a field +against a stored URL after normalizing the stored one too. A value that is not an absolute URL is +returned as it is. `Dataset`, `KeyValueStore`, `RequestQueue` and `RequestQueueRequest` all extend `ApifyResource`, so any field not covered by a typed getter above is still available via the inherited `getExtra()` @@ -45,7 +53,7 @@ any field not covered by a typed getter above is still available via the inherit | `iterateItems(DatasetListItemsOptions, Long chunkSize, Class)` | As above, decoded into `T`. Returns `Flow.Publisher`. For typed iteration at the server-default page size, pass a `null` chunk size: `iterateItems(opts, null, T.class)`. | | `downloadItems(DownloadItemsFormat, DatasetDownloadOptions)` | Serialized bytes. `DownloadItemsFormat` is one of `JSON`, `JSONL`, `CSV`, `XLSX`, `XML`, `RSS`, `HTML`. Completes with `byte[]`. | | `streamItems(DownloadItemsFormat, DatasetDownloadOptions)` | As above, but resolves to a live `InputStream` over the export instead of fully buffering it, so a large export is processed in chunks. The caller closes the stream (or reads it to the end). Throws on any error response, including a 404. | -| `pushItems(Object)` | Push a single item or a list of items. Completes with no value (`CompletableFuture`). | +| `pushItems(Object)` | Push a single item (a `Map`, a POJO, a `JsonNode` object, ...), a list/array of items, or a `String` that already is the JSON serialization of either (sent as it is). The API accepts only an object or an array of objects, so anything else (a number, a plain string, an array of strings) throws `IllegalArgumentException` before a request is sent. A push that times out is not retried, so items cannot be stored twice. Completes with no value (`CompletableFuture`). | | `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, DownloadItemsFormat format)` | As above, with the URL's serialization `format` set explicitly. | @@ -127,7 +135,7 @@ datasets. | `recordExists(String key)` | Whether a record exists. Completes with `boolean`. | | `getRecord(String key)` / `getRecord(String key, GetRecordOptions)` | Fetch a record. Completes with `Optional`. | | `setRecord(String key, byte[] value, String contentType)` | Store raw bytes. Completes with no value (`CompletableFuture`). | -| `setRecord(String key, byte[] value, String contentType, SetRecordOptions)` | Store raw bytes with write options (`SetRecordOptions`: `timeoutSecs` (`Long`), `doNotRetryTimeouts` (`Boolean`), `contentEncoding` (`String`, see below)). Completes with no value. | +| `setRecord(String key, byte[] value, String contentType, SetRecordOptions)` | Store raw bytes with write options (`SetRecordOptions`: `timeoutSecs` (`Long`) or `timeout` (`Timeout`: a tier, an exact duration or `NO_TIMEOUT`; the default is the `LONG` tier), `doNotRetryTimeouts` (`Boolean`), `contentEncoding` (`String`, see below)). Completes with no value. | | `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`. | diff --git a/pom.xml b/pom.xml index fbe46db..bbb34ab 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.apify apify-client - 0.11.0 + 0.12.0 jar Apify Java Client diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml index 39df467..4c5a74c 100644 --- a/spotbugs-exclude.xml +++ b/spotbugs-exclude.xml @@ -18,7 +18,7 @@ 2. UWF_UNWRITTEN_FIELD / UWF_UNWRITTEN_PUBLIC_OR_PROTECTED_FIELD on Jackson-populated DTOs/response shapes ({@code KeyValueStoreKey}, {@code DeletedRequestInfo}, {@code ActorRunMeta}, {@code - TaskOptions}, {@code WebhookLastDispatch}, {@code ActorDefaultRunOptions}, {@code BuildActVersion}, + TaskOptions}, {@code WebhookLastDispatch}, {@code ActorDefaultRunOptions}, {@code ActorRunStandby}, {@code TaskStats}, {@code BuildActVersion}, {@code ActorDefinitionMeta}, {@code ActorDefinitionStorages}, {@code BuildMeta}, {@code BuildOptions}, {@code BuildStats}, {@code PricingInfo}, {@code ProxyGroup}, {@code UserProxy}, {@code WebhookDispatchEventData}, {@code WebhookDispatchWebhookInfo}, the internal @@ -26,13 +26,12 @@ deserializer via reflection, which SpotBugs cannot see - a known false positive for data-binding models. - 3. EI_EXPOSE_REP / EI_EXPOSE_REP2 on {@code ApiResponse} and {@code HttpClientCore$Compressed}'s - {@code body} component: both are {@code record}s whose {@code byte[] body} intentionally holds - the raw payload without a defensive copy (the payload can be large, e.g. dataset exports, so + 3. EI_EXPOSE_REP / EI_EXPOSE_REP2 on {@code ApiResponse}'s {@code body} component: a {@code + record} whose {@code byte[] body} intentionally holds the raw payload without a defensive copy (the payload can be large, e.g. dataset exports, so copying it on every construction/access would be wasteful). A plain field of the same shape is not flagged (SpotBugs' constructor/getter analysis does not trace a raw public field the way it - traces a record's synthesized accessor and canonical constructor), so this pair of exclusions is - specific to the record form. + traces a record's synthesized accessor and canonical constructor), so this exclusion is specific + to the record form. 4. EI_EXPOSE_REP on the specific getters listed below, each returning a field whose type extends the shared {@code ApifyResource} forward-compatibility base (an {@code extra} catch-all map + @@ -75,6 +74,8 @@ + + @@ -95,10 +96,7 @@ - - - - + @@ -139,6 +137,7 @@ + diff --git a/src/main/java/com/apify/client/ApifyClient.java b/src/main/java/com/apify/client/ApifyClient.java index 7dc81e3..2ee3ab6 100644 --- a/src/main/java/com/apify/client/ApifyClient.java +++ b/src/main/java/com/apify/client/ApifyClient.java @@ -8,6 +8,7 @@ import com.apify.client.dataset.DatasetCollectionClient; import com.apify.client.http.DefaultHttpTransport; import com.apify.client.http.HttpTransport; +import com.apify.client.http.Timeout; import com.apify.client.internal.ApiPaths; import com.apify.client.internal.HttpClientCore; import com.apify.client.keyvalue.KeyValueStoreClient; @@ -99,6 +100,26 @@ public static ApifyClientBuilder builder() { return new ApifyClientBuilder(); } + /** + * Returns a client that sends every request with the given {@link Timeout} instead of the tier + * each method would pick, for a call that legitimately needs more (or less) time than its + * default. The returned client shares this client's transport, token and retry policy, and so do + * all the resource clients obtained from it: + * + *
{@code
+   * client.withTimeout(Timeout.ofSeconds(120)).dataset(id).listItems(options);
+   * }
+ * + * A timeout above the builder's {@code timeoutMax} is capped at it. For a method that asks the + * API to hold the response with {@code waitForFinish}, the given timeout is kept and the hold is + * cut to what it covers, the timeout less 5 seconds, so the API answers before the client gives + * up on the request. The polling of {@code waitForFinish}/{@code call} is not affected: it has no + * timeout, and cancelling the returned future is the way to stop one that never answers. + */ + public ApifyClient withTimeout(Timeout timeout) { + return new ApifyClient(http.withTimeout(timeout), baseUrl, publicBaseUrl); + } + /** * Returns the {@code User-Agent} header value this client sends. Not part of the public API * (there is no supported use for an end user to read it back); package-private only so the test diff --git a/src/main/java/com/apify/client/ApifyClientBuilder.java b/src/main/java/com/apify/client/ApifyClientBuilder.java index 5f572c9..d020d00 100644 --- a/src/main/java/com/apify/client/ApifyClientBuilder.java +++ b/src/main/java/com/apify/client/ApifyClientBuilder.java @@ -1,13 +1,17 @@ package com.apify.client; import com.apify.client.http.DefaultHttpTransport; +import com.apify.client.http.HttpCompressor; import com.apify.client.http.HttpTransport; import com.apify.client.http.RetryConfig; +import com.apify.client.http.Timeout; import com.apify.client.internal.HttpClientCore; import java.net.URI; import java.net.URISyntaxException; import java.time.Duration; +import java.util.LinkedHashMap; import java.util.Locale; +import java.util.Map; import java.util.function.BooleanSupplier; /** @@ -21,7 +25,7 @@ public final class ApifyClientBuilder { static final int DEFAULT_MAX_RETRIES = 8; static final Duration DEFAULT_MIN_DELAY = Duration.ofMillis(500); - static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(360); + static final Duration DEFAULT_TIMEOUT = RetryConfig.DEFAULT_TIMEOUT_LONG; /** Environment variable that signals the client is running on the Apify platform. */ static final String ENV_IS_AT_HOME = "APIFY_IS_AT_HOME"; @@ -32,9 +36,14 @@ public final class ApifyClientBuilder { private int maxRetries = DEFAULT_MAX_RETRIES; private Duration minDelayBetweenRetries = DEFAULT_MIN_DELAY; private Duration maxDelayBetweenRetries = DEFAULT_TIMEOUT; - private Duration timeout = DEFAULT_TIMEOUT; + private Duration timeoutShort = RetryConfig.DEFAULT_TIMEOUT_SHORT; + private Duration timeoutMedium = RetryConfig.DEFAULT_TIMEOUT_MEDIUM; + private Duration timeoutLong = RetryConfig.DEFAULT_TIMEOUT_LONG; + private Duration timeoutMax = DEFAULT_TIMEOUT; private String userAgentSuffix; private HttpTransport httpTransport; + private HttpCompressor compression; + private final Map defaultHeaders = new LinkedHashMap<>(); private BooleanSupplier isAtHomeFn = ApifyClientBuilder::defaultIsAtHome; ApifyClientBuilder() {} @@ -74,7 +83,10 @@ public ApifyClientBuilder minDelayBetweenRetries(Duration minDelayBetweenRetries return this; } - /** Sets the maximum (exponentially-grown) delay between retries (default equals the timeout). */ + /** + * Sets the maximum (exponentially-grown) delay between retries (default 360s, as {@link + * #timeoutMax(Duration)}). + */ public ApifyClientBuilder maxDelayBetweenRetries(Duration maxDelayBetweenRetries) { this.maxDelayBetweenRetries = requireNonNegative(maxDelayBetweenRetries, "maxDelayBetweenRetries"); @@ -82,13 +94,54 @@ public ApifyClientBuilder maxDelayBetweenRetries(Duration maxDelayBetweenRetries } /** - * Sets the maximum per-attempt request (socket) timeout (default 360s): the ceiling each retry - * attempt's timeout grows toward, not a wall-clock bound on the cumulative time across retries. - * The connection-establishment timeout is separate and configured on the {@link HttpTransport} - * (see {@link DefaultHttpTransport#DefaultHttpTransport(Duration)}). + * Sets the duration of the {@link Timeout#SHORT} tier (default 5s), used by simple metadata reads + * and writes. + */ + public ApifyClientBuilder timeoutShort(Duration timeoutShort) { + this.timeoutShort = requirePositive(timeoutShort, "timeoutShort"); + return this; + } + + /** + * Sets the duration of the {@link Timeout#MEDIUM} tier (default 30s), used by listing, batch and + * trigger operations. + */ + public ApifyClientBuilder timeoutMedium(Duration timeoutMedium) { + this.timeoutMedium = requirePositive(timeoutMedium, "timeoutMedium"); + return this; + } + + /** + * Sets the duration of the {@link Timeout#LONG} tier (default 360s), used by downloads, uploads + * and streaming. */ + public ApifyClientBuilder timeoutLong(Duration timeoutLong) { + this.timeoutLong = requirePositive(timeoutLong, "timeoutLong"); + return this; + } + + /** + * Sets the cap on the timeout of a single request attempt (default 360s). The first attempt gets + * the timeout of its tier (or the one given with {@link ApifyClient#withTimeout(Timeout)}), each + * retry doubles it, and none exceeds this cap; a tier or timeout above it is capped, with a + * logged warning. It bounds each attempt, not the cumulative time across retries. The + * connection-establishment timeout is separate and configured on the {@link HttpTransport} (see + * {@link DefaultHttpTransport#DefaultHttpTransport(Duration)}). + */ + public ApifyClientBuilder timeoutMax(Duration timeoutMax) { + this.timeoutMax = requirePositive(timeoutMax, "timeoutMax"); + return this; + } + + /** + * Sets the cap on the timeout of a single request attempt. + * + * @deprecated the same as {@link #timeoutMax(Duration)}, which names what it does now that the + * timeout of a request comes from a tier; use that instead. + */ + @Deprecated public ApifyClientBuilder timeout(Duration timeout) { - this.timeout = requirePositive(timeout, "timeout"); + this.timeoutMax = requirePositive(timeout, "timeout"); return this; } @@ -107,6 +160,36 @@ public ApifyClientBuilder httpTransport(HttpTransport httpTransport) { return this; } + /** + * Sets how request bodies of at least 1 KiB are compressed (default {@link + * com.apify.client.http.BrotliHttpCompressor} where its native library is available, {@link + * com.apify.client.http.GzipHttpCompressor} elsewhere). Pass a configured {@code + * BrotliHttpCompressor}/{@code GzipHttpCompressor} to tune the quality, or your own {@link + * HttpCompressor}. + */ + public ApifyClientBuilder compression(HttpCompressor compression) { + if (compression == null) { + throw new IllegalArgumentException("compression must not be null"); + } + this.compression = compression; + return this; + } + + /** + * Adds a header sent with every request, such as an integration platform marker. It replaces a + * built-in header of the same name ({@code User-Agent}, {@code Authorization}) and is replaced by + * a header a request sets itself. Header names are compared case-insensitively. + */ + public ApifyClientBuilder header(String name, String value) { + requireNonBlank(name, "header name"); + if (value == null) { + throw new IllegalArgumentException("header value must not be null"); + } + defaultHeaders.keySet().removeIf(existing -> existing.equalsIgnoreCase(name)); + defaultHeaders.put(name, value); + return this; + } + /** Validates a required, non-blank string argument, returning it unchanged. */ private static String requireNonBlank(String value, String argName) { if (value == null || value.isBlank()) { @@ -160,8 +243,16 @@ public ApifyClient build() { HttpTransport transport = httpTransport != null ? httpTransport : new DefaultHttpTransport(); String userAgent = buildUserAgent(userAgentSuffix, isAtHomeFn); RetryConfig retry = - new RetryConfig(maxRetries, minDelayBetweenRetries, maxDelayBetweenRetries, timeout); - HttpClientCore http = new HttpClientCore(transport, token, userAgent, retry); + new RetryConfig( + maxRetries, + minDelayBetweenRetries, + maxDelayBetweenRetries, + timeoutShort, + timeoutMedium, + timeoutLong, + timeoutMax); + HttpClientCore http = + new HttpClientCore(transport, token, userAgent, retry, compression, defaultHeaders); String apiBase = normalizeApiUrl(baseUrl); String publicBase = normalizeApiUrl(publicBaseUrl != null ? publicBaseUrl : baseUrl); diff --git a/src/main/java/com/apify/client/Publishers.java b/src/main/java/com/apify/client/Publishers.java index cf10cfa..fcb1926 100644 --- a/src/main/java/com/apify/client/Publishers.java +++ b/src/main/java/com/apify/client/Publishers.java @@ -1,5 +1,6 @@ package com.apify.client; +import com.apify.client.internal.CancellableFuture; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentLinkedQueue; @@ -30,13 +31,20 @@ private Publishers() {} * publisher signals completion, or exceptionally with whatever error the publisher signalled. */ public static CompletableFuture> collect(Flow.Publisher publisher) { - CompletableFuture> result = new CompletableFuture<>(); + CompletableFuture> result = new CancellableFuture<>(); ConcurrentLinkedQueue items = new ConcurrentLinkedQueue<>(); AtomicBoolean done = new AtomicBoolean(); publisher.subscribe( new Flow.Subscriber<>() { @Override public void onSubscribe(Flow.Subscription subscription) { + // Cancelling the returned future cancels the subscription (and so the page in flight). + result.whenComplete( + (list, error) -> { + if (result.isCancelled()) { + subscription.cancel(); + } + }); subscription.request(Long.MAX_VALUE); } diff --git a/src/main/java/com/apify/client/Version.java b/src/main/java/com/apify/client/Version.java index 638cb83..2e3d187 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.11.0"; + public static final String CLIENT_VERSION = "0.12.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-26T112451Z"; + public static final String API_SPEC_VERSION = "v2-2026-10-29T100357Z"; private Version() {} } diff --git a/src/main/java/com/apify/client/actor/ActorClient.java b/src/main/java/com/apify/client/actor/ActorClient.java index ed427ff..36666dc 100644 --- a/src/main/java/com/apify/client/actor/ActorClient.java +++ b/src/main/java/com/apify/client/actor/ActorClient.java @@ -4,6 +4,7 @@ import com.apify.client.build.Build; import com.apify.client.build.BuildClient; import com.apify.client.build.BuildCollectionClient; +import com.apify.client.http.Timeout; import com.apify.client.internal.ApiPaths; import com.apify.client.internal.HttpClientCore; import com.apify.client.internal.Json; @@ -62,7 +63,8 @@ public CompletableFuture delete() { /** * Starts the Actor and completes with the created run as soon as it exists (no waiting). {@code - * input} is any JSON-serializable value (or {@code null} for no input). + * input} is any JSON-serializable value, a {@code byte[]} or {@link String} that is sent as it is + * (set the content type with the options' {@code contentType}), or {@code null} for no input. */ public CompletableFuture start(Object input, ActorStartOptions options) { return RunStartSupport.start( @@ -126,18 +128,24 @@ public CompletableFuture validateInput(Object input) { /** * Validates {@code input} against the Actor's input schema and completes with whether it is - * valid. {@code input} is any JSON-serializable value (or {@code null}). {@code options} may pin - * the build whose schema is used and the content type of the input body. + * valid. {@code input} is any JSON-serializable value, a {@code byte[]} or {@link String} that is + * sent as it is, or {@code null}. {@code options} may pin the build whose schema is used and the + * content type of the input body. */ public CompletableFuture validateInput(Object input, ValidateInputOptions options) { ValidateInputOptions opts = options == null ? new ValidateInputOptions() : options; QueryParams params = new QueryParams(); opts.apply(params); - byte[] body = input == null ? null : Json.toBytes(input); + byte[] body = Json.toRequestBody(input); // The validate-input endpoint returns a bare {"valid": } object, not the standard // {"data": ...} envelope, so parse it without unwrapping. return ctx.postWithBodyNoEnvelope( - "validate-input", params, body, opts.contentTypeOrDefault(), JsonNode.class) + "validate-input", + params, + body, + opts.contentTypeOrDefault(), + JsonNode.class, + Timeout.SHORT) .thenApply( result -> { JsonNode valid = result == null ? null : result.get("valid"); @@ -153,7 +161,11 @@ public CompletableFuture build(String versionNumber, ActorBuildOptions op QueryParams params = new QueryParams(); params.addString("version", versionNumber); options.apply(params); - return ctx.postWithBody("builds", params, null, ResourceContext.CONTENT_TYPE_JSON, Build.class); + HttpClientCore.WaitHold hold = + ctx.http.waitHold(Timeout.MEDIUM, params.getLong("waitForFinish")); + params.setLong("waitForFinish", hold.waitForFinishSecs()); + return ctx.postWithBody( + "builds", params, null, ResourceContext.CONTENT_TYPE_JSON, Build.class, hold.timeout()); } /** @@ -163,10 +175,9 @@ public CompletableFuture build(String versionNumber, ActorBuildOptions op */ public CompletableFuture defaultBuild(Long waitForFinish) { QueryParams params = new QueryParams(); - // Clamp like the getWithWait twins so a large wait paired with a short client timeout cannot - // abort every attempt and burn the retry budget (the API caps server-side waiting at 60s). - params.addLong("waitForFinish", ctx.clampServerWait(waitForFinish)); - return ctx.getResourceRequired("builds/default", params, Build.class) + HttpClientCore.WaitHold hold = ctx.http.waitHold(Timeout.SHORT, waitForFinish); + params.addLong("waitForFinish", hold.waitForFinishSecs()); + return ctx.getResourceRequired("builds/default", params, Build.class, hold.timeout()) .thenApply(build -> new BuildClient(http, baseUrl, build.getId())); } diff --git a/src/main/java/com/apify/client/actor/ActorCollectionClient.java b/src/main/java/com/apify/client/actor/ActorCollectionClient.java index 9a829c2..75dc2ce 100644 --- a/src/main/java/com/apify/client/actor/ActorCollectionClient.java +++ b/src/main/java/com/apify/client/actor/ActorCollectionClient.java @@ -1,5 +1,6 @@ package com.apify.client.actor; +import com.apify.client.http.Timeout; import com.apify.client.internal.AbstractCollectionClient; import com.apify.client.internal.ApiPaths; import com.apify.client.internal.HttpClientCore; @@ -19,6 +20,6 @@ public ActorCollectionClient(HttpClientCore http, String baseUrl) { /** Creates a new Actor. {@code actor} is any JSON-serializable Actor definition. */ public CompletableFuture create(Object actor) { - return ctx.createResource(new QueryParams(), actor, Actor.class); + return ctx.createResource(new QueryParams(), actor, Actor.class, Timeout.MEDIUM); } } diff --git a/src/main/java/com/apify/client/actor/ActorDefaultRunOptions.java b/src/main/java/com/apify/client/actor/ActorDefaultRunOptions.java index 4ba7cfd..8047863 100644 --- a/src/main/java/com/apify/client/actor/ActorDefaultRunOptions.java +++ b/src/main/java/com/apify/client/actor/ActorDefaultRunOptions.java @@ -8,6 +8,8 @@ public final class ActorDefaultRunOptions extends ApifyResource { private Long timeoutSecs; private Long memoryMbytes; private Boolean restartOnError; + private Double maxTotalChargeUsd; + private Boolean isMaxTotalChargeUsdSetByUser; /** The tag or number of the build run by default. */ public String getBuild() { @@ -28,4 +30,14 @@ public Long getMemoryMbytes() { public Boolean getRestartOnError() { return restartOnError; } + + /** The default maximum total charge in USD for a pay-per-event run. */ + public Double getMaxTotalChargeUsd() { + return maxTotalChargeUsd; + } + + /** Whether {@link #getMaxTotalChargeUsd()} was set by the user rather than defaulted. */ + public Boolean getIsMaxTotalChargeUsdSetByUser() { + return isMaxTotalChargeUsdSetByUser; + } } diff --git a/src/main/java/com/apify/client/actor/ActorEnvVar.java b/src/main/java/com/apify/client/actor/ActorEnvVar.java index d118105..db758bb 100644 --- a/src/main/java/com/apify/client/actor/ActorEnvVar.java +++ b/src/main/java/com/apify/client/actor/ActorEnvVar.java @@ -7,6 +7,7 @@ public final class ActorEnvVar extends ApifyResource { private String name; private String value; private Boolean isSecret; + private String valueHash; public ActorEnvVar() {} @@ -47,4 +48,9 @@ public ActorEnvVar setIsSecret(Boolean isSecret) { this.isSecret = isSecret; return this; } + + /** A hash of the variable value, returned for secret variables whose value is hidden. */ + public String getValueHash() { + return valueHash; + } } diff --git a/src/main/java/com/apify/client/actor/ActorEnvVarCollectionClient.java b/src/main/java/com/apify/client/actor/ActorEnvVarCollectionClient.java index 009bda3..cfe26d2 100644 --- a/src/main/java/com/apify/client/actor/ActorEnvVarCollectionClient.java +++ b/src/main/java/com/apify/client/actor/ActorEnvVarCollectionClient.java @@ -1,6 +1,7 @@ package com.apify.client.actor; import com.apify.client.PaginationList; +import com.apify.client.http.Timeout; import com.apify.client.internal.HttpClientCore; import com.apify.client.internal.ListPublisher; import com.apify.client.internal.QueryParams; @@ -21,7 +22,7 @@ public final class ActorEnvVarCollectionClient { /** Lists the version's environment variables. */ public CompletableFuture> list() { - return ctx.listResource("", new QueryParams(), ActorEnvVar.class); + return ctx.listResource("", new QueryParams(), ActorEnvVar.class, Timeout.SHORT); } /** diff --git a/src/main/java/com/apify/client/actor/ActorStandby.java b/src/main/java/com/apify/client/actor/ActorStandby.java index 688b733..d620b51 100644 --- a/src/main/java/com/apify/client/actor/ActorStandby.java +++ b/src/main/java/com/apify/client/actor/ActorStandby.java @@ -12,6 +12,9 @@ public final class ActorStandby extends ApifyResource { private Long maxRequestsPerActorRun; private Long memoryMbytes; private Boolean shouldPassActorInput; + private String tenancy; + private Boolean isConsoleAuthEnabled; + private Boolean isTokenlessEnabled; /** * Whether standby mode is enabled. Only present on {@link Actor#getActorStandby()} (the API @@ -57,4 +60,22 @@ public Long getMemoryMbytes() { public Boolean getShouldPassActorInput() { return shouldPassActorInput; } + + /** + * Who the Server Actor serves: {@code SINGLE_TENANT} (a private instance per caller) or a {@code + * MULTI_TENANT_*} pool. + */ + public String getTenancy() { + return tenancy; + } + + /** Whether Console authentication is enabled for the standby URL. */ + public Boolean getIsConsoleAuthEnabled() { + return isConsoleAuthEnabled; + } + + /** Whether requests to the standby URL may omit the API token. */ + 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 e114b4e..d3560c8 100644 --- a/src/main/java/com/apify/client/actor/ActorVersion.java +++ b/src/main/java/com/apify/client/actor/ActorVersion.java @@ -6,6 +6,11 @@ public final class ActorVersion extends ApifyResource { private String versionNumber; private String sourceType; + private String tarballUrl; + private String gitHubGistUrl; + private Integer githubWebhookId; + private Integer gitlabWebhookId; + private String bitbucketWebhookId; /** The version identifier (e.g. {@code "0.1"}). */ public String getVersionNumber() { @@ -16,4 +21,29 @@ public String getVersionNumber() { public String getSourceType() { return sourceType; } + + /** The tarball URL the source is fetched from, for {@code TARBALL} versions. */ + public String getTarballUrl() { + return tarballUrl; + } + + /** The GitHub Gist URL the source is fetched from, for {@code GITHUB_GIST} versions. */ + public String getGitHubGistUrl() { + return gitHubGistUrl; + } + + /** The ID of the GitHub webhook that triggers builds on push, if configured. */ + public Integer getGithubWebhookId() { + return githubWebhookId; + } + + /** The ID of the GitLab webhook that triggers builds on push, if configured. */ + public Integer getGitlabWebhookId() { + return gitlabWebhookId; + } + + /** The ID of the Bitbucket webhook that triggers builds on push, if configured. */ + public String getBitbucketWebhookId() { + return bitbucketWebhookId; + } } diff --git a/src/main/java/com/apify/client/actor/ActorVersionCollectionClient.java b/src/main/java/com/apify/client/actor/ActorVersionCollectionClient.java index 4b18c98..af0c20a 100644 --- a/src/main/java/com/apify/client/actor/ActorVersionCollectionClient.java +++ b/src/main/java/com/apify/client/actor/ActorVersionCollectionClient.java @@ -2,6 +2,7 @@ import com.apify.client.ListOptions; import com.apify.client.PaginationList; +import com.apify.client.http.Timeout; import com.apify.client.internal.HttpClientCore; import com.apify.client.internal.ListPublisher; import com.apify.client.internal.QueryParams; @@ -27,7 +28,7 @@ public final class ActorVersionCollectionClient { * case; use {@link #iterate(ListOptions)}'s {@code limit} to cap the number returned client-side. */ public CompletableFuture> list(ListOptions options) { - return ctx.listResource("", new QueryParams(), ActorVersion.class); + return ctx.listResource("", new QueryParams(), ActorVersion.class, Timeout.SHORT); } /** diff --git a/src/main/java/com/apify/client/build/Build.java b/src/main/java/com/apify/client/build/Build.java index 38b159f..8a60e80 100644 --- a/src/main/java/com/apify/client/build/Build.java +++ b/src/main/java/com/apify/client/build/Build.java @@ -22,6 +22,11 @@ public final class Build extends ApifyResource { private BuildUsage usage; private BuildUsage usageUsd; private Double usageTotalUsd; + private String changelog; + private Integer exitCode; + private String gitCommitId; + private String gitBranchName; + private String createdByOrganizationMemberUserId; /** The unique build ID. */ public String getId() { @@ -121,4 +126,34 @@ public Double getUsageTotalUsd() { public boolean isTerminal() { return Statuses.isTerminal(status); } + + /** + * The changelog of the Actor version built. + * + * @deprecated The OpenAPI specification marks {@code Build.changelog} as deprecated. + */ + @Deprecated + public String getChangelog() { + return changelog; + } + + /** The exit code of the build process. */ + public Integer getExitCode() { + return exitCode; + } + + /** The Git commit the build was created from, for Git-sourced versions. */ + public String getGitCommitId() { + return gitCommitId; + } + + /** The Git branch the build was created from, for Git-sourced versions. */ + public String getGitBranchName() { + return gitBranchName; + } + + /** The organization member who started the build, when started on behalf of an organization. */ + public String getCreatedByOrganizationMemberUserId() { + return createdByOrganizationMemberUserId; + } } diff --git a/src/main/java/com/apify/client/build/BuildClient.java b/src/main/java/com/apify/client/build/BuildClient.java index 5a22197..4004234 100644 --- a/src/main/java/com/apify/client/build/BuildClient.java +++ b/src/main/java/com/apify/client/build/BuildClient.java @@ -1,5 +1,6 @@ package com.apify.client.build; +import com.apify.client.http.Timeout; import com.apify.client.internal.ApiPaths; import com.apify.client.internal.HttpClientCore; import com.apify.client.internal.Json; @@ -27,20 +28,20 @@ public CompletableFuture> get() { /** * Fetches the build, optionally asking the API to wait up to {@code waitForFinishSecs} seconds - * for the build to finish before responding. The value is clamped so the server always responds - * before the client's per-request timeout; the API itself caps server-side waiting at 60 seconds. - * Pass {@code null} for an immediate fetch. + * for the build to finish before responding; the API itself caps server-side waiting at 60 + * seconds, and the request timeout is extended by that hold. Pass {@code null} for an immediate + * fetch. */ public CompletableFuture> getWithWait(Long waitForFinishSecs) { QueryParams params = new QueryParams(); - // Clamp to the client's per-request timeout so a short custom timeout doesn't abort the call. - params.addLong("waitForFinish", ctx.clampServerWait(waitForFinishSecs)); - return ctx.getResource("", params, Build.class); + HttpClientCore.WaitHold hold = ctx.http.waitHold(Timeout.SHORT, waitForFinishSecs); + params.addLong("waitForFinish", hold.waitForFinishSecs()); + return ctx.getResource("", params, Build.class, hold.timeout()); } /** Aborts the build and returns its updated state. */ public CompletableFuture abort() { - return ctx.postWithBody("abort", new QueryParams(), null, "", Build.class); + return ctx.postWithBody("abort", new QueryParams(), null, "", Build.class, Timeout.SHORT); } /** Deletes the build. */ @@ -61,7 +62,7 @@ public CompletableFuture waitForFinish(Long waitSecs) { * result is the raw OpenAPI document. */ public CompletableFuture> getOpenApiDefinition() { - return ctx.getRaw("openapi.json", new QueryParams()) + return ctx.getRaw("openapi.json", new QueryParams(), Timeout.MEDIUM) .thenApply( resp -> resp == null diff --git a/src/main/java/com/apify/client/build/BuildOptions.java b/src/main/java/com/apify/client/build/BuildOptions.java index 8ebd87b..d4b8544 100644 --- a/src/main/java/com/apify/client/build/BuildOptions.java +++ b/src/main/java/com/apify/client/build/BuildOptions.java @@ -8,6 +8,8 @@ public final class BuildOptions extends ApifyResource { private Boolean betaPackages; private Long memoryMbytes; private Long diskMbytes; + private String tag; + private Boolean debug; /** Whether the build reused a cached image layer. */ public Boolean getUseCache() { @@ -28,4 +30,14 @@ public Long getMemoryMbytes() { public Long getDiskMbytes() { return diskMbytes; } + + /** The build tag requested for the build. */ + public String getTag() { + return tag; + } + + /** Whether the build was run in debug mode. */ + public Boolean getDebug() { + return debug; + } } diff --git a/src/main/java/com/apify/client/dataset/Dataset.java b/src/main/java/com/apify/client/dataset/Dataset.java index 06c941d..8dc385c 100644 --- a/src/main/java/com/apify/client/dataset/Dataset.java +++ b/src/main/java/com/apify/client/dataset/Dataset.java @@ -1,7 +1,9 @@ package com.apify.client.dataset; import com.apify.client.ApifyResource; +import com.apify.client.internal.NormalizedUrlDeserializer; import java.time.Instant; +import tools.jackson.databind.annotation.JsonDeserialize; /** A dataset stores structured results from Actor runs. */ public final class Dataset extends ApifyResource { @@ -11,6 +13,13 @@ public final class Dataset extends ApifyResource { private Instant createdAt; private Instant modifiedAt; private long itemCount; + private String title; + + @JsonDeserialize(using = NormalizedUrlDeserializer.class) + private String consoleUrl; + + @JsonDeserialize(using = NormalizedUrlDeserializer.class) + private String itemsPublicUrl; /** The unique dataset ID. */ public String getId() { @@ -41,4 +50,22 @@ public Instant getModifiedAt() { public long getItemCount() { return itemCount; } + + /** The human-readable title of the dataset. */ + public String getTitle() { + return title; + } + + /** The URL of the dataset in the Apify Console (normalized). */ + public String getConsoleUrl() { + return consoleUrl; + } + + /** + * A public URL of the dataset items (signed when the dataset is not publicly accessible; + * normalized). + */ + public String getItemsPublicUrl() { + return itemsPublicUrl; + } } diff --git a/src/main/java/com/apify/client/dataset/DatasetClient.java b/src/main/java/com/apify/client/dataset/DatasetClient.java index 3e35d6c..9146602 100644 --- a/src/main/java/com/apify/client/dataset/DatasetClient.java +++ b/src/main/java/com/apify/client/dataset/DatasetClient.java @@ -2,6 +2,7 @@ import com.apify.client.PaginationList; import com.apify.client.http.ApiResponse; +import com.apify.client.http.Timeout; import com.apify.client.internal.ApiPaths; import com.apify.client.internal.AsyncPaginatedPublisher; import com.apify.client.internal.Extras; @@ -11,6 +12,7 @@ import com.apify.client.internal.ResourceContext; import com.apify.client.internal.Signatures; import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; @@ -175,7 +177,7 @@ public Flow.Publisher iterateItems( private CompletableFuture> fetchItemsPage( QueryParams params, Boolean desc, Class itemClass) { String url = ctx.mergedParams(params).applyToUrl(ctx.subUrl("items")); - return http.call("GET", url, null, "", http.baseRequestTimeout()) + return http.call("GET", url, null, "", Timeout.LONG) .thenApply( resp -> { JavaType listType = Json.parametric(List.class, Json.type(itemClass)); @@ -204,7 +206,7 @@ private CompletableFuture> fetchItemsPage( public CompletableFuture downloadItems( DownloadItemsFormat format, DatasetDownloadOptions options) { String url = itemsUrl(format, options); - return http.call("GET", url, null, "", http.baseRequestTimeout()).thenApply(ApiResponse::body); + return http.call("GET", url, null, "", Timeout.LONG).thenApply(ApiResponse::body); } /** @@ -232,22 +234,71 @@ private String itemsUrl(DownloadItemsFormat format, DatasetDownloadOptions optio } /** - * Pushes one or more items to the dataset. {@code items} must serialize to a JSON object or an - * array of objects. + * Pushes one or more items to the dataset. The API accepts only a JSON object or an array of + * objects, so {@code items} must be one of: + * + *
    + *
  • a single item that serializes to a JSON object (a {@link java.util.Map}, a POJO, a {@link + * JsonNode} object, ...); + *
  • an array or {@link java.util.Collection} of such items; + *
  • a {@link String} that already is the JSON serialization of either; it is sent as it is. + *
+ * + * Anything else (a plain string, a number, an array of strings, ...) is rejected with an {@link + * IllegalArgumentException} before a request is sent, since the API would reject it anyway. A + * push that times out is not retried, so the items cannot be stored twice. */ public CompletableFuture pushItems(Object items) { + byte[] body = items instanceof String json ? serializedItems(json) : serializedItems(items); // Route through mergedParams like every sibling method, so a context seeded with pinned filters // (e.g. actor(id).lastRun(...).dataset()) targets the same run's dataset on write as on read. String url = ctx.mergedParams(new QueryParams()).applyToUrl(ctx.subUrl("items")); return http.call( - "POST", - url, - Json.toBytes(items), - ResourceContext.CONTENT_TYPE_JSON_CHARSET, - http.baseRequestTimeout()) + "POST", url, body, ResourceContext.CONTENT_TYPE_JSON_CHARSET, Timeout.MEDIUM, true) .thenApply(resp -> null); } + /** Validates a pre-serialized {@code pushItems} payload and returns its bytes unchanged. */ + private static byte[] serializedItems(String json) { + byte[] bytes = json.getBytes(StandardCharsets.UTF_8); + JsonNode tree; + try { + tree = Json.parse(bytes, JsonNode.class); + } catch (RuntimeException e) { + throw new IllegalArgumentException( + "pushItems: a String must be the JSON serialization of an item or an array of items", e); + } + requireItemShape(tree); + return bytes; + } + + /** Serializes a {@code pushItems} payload after checking that it has an accepted shape. */ + private static byte[] serializedItems(Object items) { + JsonNode tree = items == null ? null : Json.toTree(items); + requireItemShape(tree); + return Json.toBytes(tree); + } + + private static void requireItemShape(JsonNode tree) { + boolean valid = tree != null && (tree.isObject() || isArrayOfObjects(tree)); + if (!valid) { + throw new IllegalArgumentException( + "pushItems: items must be a JSON object or an array of JSON objects"); + } + } + + private static boolean isArrayOfObjects(JsonNode tree) { + if (!tree.isArray()) { + return false; + } + for (JsonNode element : tree) { + if (!element.isObject()) { + return false; + } + } + return true; + } + /** * Returns statistical information about the dataset. * @@ -256,7 +307,7 @@ public CompletableFuture pushItems(Object items) { * dataset is gone", so a missing dataset is reported as a failure here too. */ public CompletableFuture getStatistics() { - return ctx.getResourceRequired("statistics", new QueryParams(), JsonNode.class); + return ctx.getResourceRequired("statistics", new QueryParams(), JsonNode.class, Timeout.SHORT); } /** diff --git a/src/main/java/com/apify/client/dataset/DatasetListItemsOptions.java b/src/main/java/com/apify/client/dataset/DatasetListItemsOptions.java index 6960086..0e6399a 100644 --- a/src/main/java/com/apify/client/dataset/DatasetListItemsOptions.java +++ b/src/main/java/com/apify/client/dataset/DatasetListItemsOptions.java @@ -1,5 +1,6 @@ package com.apify.client.dataset; +import com.apify.client.internal.DatasetItemsOptionsAccess; import com.apify.client.internal.QueryParams; import java.util.List; @@ -121,6 +122,72 @@ public DatasetListItemsOptions signature(String signature) { return this; } + static { + DatasetItemsOptionsAccess.register( + new DatasetItemsOptionsAccess() { + @Override + public long offset(DatasetListItemsOptions options) { + return options.offset != null && options.offset > 0 ? options.offset : 0; + } + + @Override + public Long limit(DatasetListItemsOptions options) { + return options.limit; + } + + @Override + public boolean dropsRows(DatasetListItemsOptions options) { + return Boolean.TRUE.equals(options.clean) + || Boolean.TRUE.equals(options.skipEmpty) + || (options.unwind != null && !options.unwind.isEmpty()); + } + + @Override + public DatasetListItemsOptions snapshot(DatasetListItemsOptions options) { + DatasetListItemsOptions c = + options != null ? options.copy() : new DatasetListItemsOptions(); + c.desc = null; + return c; + } + + @Override + public DatasetListItemsOptions page( + DatasetListItemsOptions options, long offset, long limit) { + DatasetListItemsOptions c = options.copy(); + c.desc = null; + c.offset = offset; + c.limit = limit; + return c; + } + + @Override + public DatasetListItemsOptions plainPage(long offset, long limit) { + return new DatasetListItemsOptions().offset(offset).limit(limit); + } + }); + } + + /** Returns an independent copy of these options. */ + private DatasetListItemsOptions copy() { + DatasetListItemsOptions c = new DatasetListItemsOptions(); + c.offset = offset; + c.limit = limit; + c.desc = desc; + c.fields = fields; + c.outputFields = outputFields; + c.omit = omit; + c.skipEmpty = skipEmpty; + c.skipHidden = skipHidden; + c.clean = clean; + c.unwind = unwind; + c.flatten = flatten; + c.view = view; + c.simplified = simplified; + c.skipFailedPages = skipFailedPages; + c.signature = signature; + return c; + } + Boolean descValue() { return desc; } diff --git a/src/main/java/com/apify/client/http/BrotliHttpCompressor.java b/src/main/java/com/apify/client/http/BrotliHttpCompressor.java new file mode 100644 index 0000000..95942f5 --- /dev/null +++ b/src/main/java/com/apify/client/http/BrotliHttpCompressor.java @@ -0,0 +1,73 @@ +package com.apify.client.http; + +import com.aayushatharva.brotli4j.Brotli4jLoader; +import com.aayushatharva.brotli4j.encoder.Encoder; +import java.io.IOException; + +/** + * An {@link HttpCompressor} that brotli-encodes request bodies ({@code Content-Encoding: br}). + * + *

It needs a platform-specific native library bundled with {@code brotli4j}; {@link + * #isAvailable()} tells whether one loaded. The client uses this compressor by default where it is + * available and gzip elsewhere. + */ +public final class BrotliHttpCompressor implements HttpCompressor { + + private static final int MAX_QUALITY = 11; + + private static final int DEFAULT_QUALITY = 6; + + private final int quality; + + /** + * Whether the brotli native library loaded for the running platform. Brotli needs a native codec + * that is not bundled for every OS and CPU; the check never throws. + */ + public static boolean isAvailable() { + try { + Brotli4jLoader.ensureAvailability(); + return true; + } catch (Throwable t) { + // Catch Throwable: native loading raises UnsatisfiedLinkError/NoClassDefFoundError, not only + // Exception, and "unavailable" is the answer in every such case. + return false; + } + } + + /** + * A compressor with the default quality, 6. + * + * @throws IllegalStateException if the brotli native library is not available on this platform + */ + public BrotliHttpCompressor() { + this(DEFAULT_QUALITY); + } + + /** + * A compressor with the given quality. + * + * @param quality the compression quality from 0 (fastest) to 11 (smallest output) + * @throws IllegalArgumentException if {@code quality} is outside 0-11 + * @throws IllegalStateException if the brotli native library is not available on this platform + */ + public BrotliHttpCompressor(int quality) { + if (quality < 0 || quality > MAX_QUALITY) { + throw new IllegalArgumentException("brotli quality must be between 0 and 11, was " + quality); + } + if (!isAvailable()) { + throw new IllegalStateException( + "the brotli native library is not available on this platform; use GzipHttpCompressor"); + } + this.quality = quality; + } + + @Override + public String contentEncoding() { + return "br"; + } + + @Override + public byte[] compress(byte[] data) throws IOException { + return Encoder.compress(data, new Encoder.Parameters().setQuality(quality)); + } +} diff --git a/src/main/java/com/apify/client/http/DefaultHttpTransport.java b/src/main/java/com/apify/client/http/DefaultHttpTransport.java index 325da87..027ef37 100644 --- a/src/main/java/com/apify/client/http/DefaultHttpTransport.java +++ b/src/main/java/com/apify/client/http/DefaultHttpTransport.java @@ -69,6 +69,13 @@ public CompletableFuture> sendStreamingAsync(HttpReque */ private static CompletableFuture unwrapTimeout(CompletableFuture future) { CompletableFuture result = new CompletableFuture<>(); + // Cancelling the returned future aborts the exchange the JDK client has in flight. + result.whenComplete( + (value, error) -> { + if (result.isCancelled()) { + future.cancel(true); + } + }); future.whenComplete( (value, error) -> { if (error == null) { diff --git a/src/main/java/com/apify/client/http/GzipHttpCompressor.java b/src/main/java/com/apify/client/http/GzipHttpCompressor.java new file mode 100644 index 0000000..2ba3514 --- /dev/null +++ b/src/main/java/com/apify/client/http/GzipHttpCompressor.java @@ -0,0 +1,56 @@ +package com.apify.client.http; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.zip.GZIPOutputStream; + +/** An {@link HttpCompressor} that gzip-encodes request bodies ({@code Content-Encoding: gzip}). */ +public final class GzipHttpCompressor implements HttpCompressor { + + /** The highest compression level. */ + private static final int MAX_LEVEL = 9; + + /** zlib's own default; higher levels cost about twice the CPU for about a percent fewer bytes. */ + public static final int DEFAULT_LEVEL = 6; + + private static final int MIN_LEVEL = 1; + + private final int level; + + /** A compressor with the default level, {@value #DEFAULT_LEVEL}. */ + public GzipHttpCompressor() { + this(DEFAULT_LEVEL); + } + + /** + * A compressor with the given level. + * + * @param level the compression level from 1 (fastest) to 9 (smallest output) + * @throws IllegalArgumentException if {@code level} is outside 1-9 + */ + public GzipHttpCompressor(int level) { + if (level < MIN_LEVEL || level > MAX_LEVEL) { + throw new IllegalArgumentException("gzip level must be between 1 and 9, was " + level); + } + this.level = level; + } + + @Override + public String contentEncoding() { + return "gzip"; + } + + @Override + public byte[] compress(byte[] data) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(data.length); + try (GZIPOutputStream gzip = + new GZIPOutputStream(out) { + { + def.setLevel(level); + } + }) { + gzip.write(data); + } + return out.toByteArray(); + } +} diff --git a/src/main/java/com/apify/client/http/HttpCompressor.java b/src/main/java/com/apify/client/http/HttpCompressor.java new file mode 100644 index 0000000..6b4a3fe --- /dev/null +++ b/src/main/java/com/apify/client/http/HttpCompressor.java @@ -0,0 +1,28 @@ +package com.apify.client.http; + +import java.io.IOException; + +/** + * Compresses request bodies before they are sent. The client compresses a body only when it is at + * least 1 KiB, has a compressible content type and carries no {@code Content-Encoding} header of + * its own; the configured compressor then encodes it and its {@link #contentEncoding()} is sent as + * the {@code Content-Encoding} header. Streams are never compressed. + * + *

Use {@link BrotliHttpCompressor} (the default, where its native library is available) or + * {@link GzipHttpCompressor}, or implement this interface for another coding. Configure it with + * {@link com.apify.client.ApifyClientBuilder#compression(HttpCompressor)}. Implementations must be + * safe for concurrent use. + */ +public interface HttpCompressor { + + /** The {@code Content-Encoding} token that describes the output of {@link #compress}. */ + String contentEncoding(); + + /** + * Compresses {@code data}. A failure fails the request: the client does not fall back to another + * coding. + * + * @throws IOException if the data cannot be compressed + */ + byte[] compress(byte[] data) throws IOException; +} diff --git a/src/main/java/com/apify/client/http/RetryConfig.java b/src/main/java/com/apify/client/http/RetryConfig.java index 4472593..01a8ce6 100644 --- a/src/main/java/com/apify/client/http/RetryConfig.java +++ b/src/main/java/com/apify/client/http/RetryConfig.java @@ -4,6 +4,16 @@ /** Retry/timeout policy for the orchestrating HTTP client. Internal to the client. */ public final class RetryConfig { + + /** Default duration of the {@link Timeout#SHORT} tier. */ + public static final Duration DEFAULT_TIMEOUT_SHORT = Duration.ofSeconds(5); + + /** Default duration of the {@link Timeout#MEDIUM} tier. */ + public static final Duration DEFAULT_TIMEOUT_MEDIUM = Duration.ofSeconds(30); + + /** Default duration of the {@link Timeout#LONG} tier. */ + public static final Duration DEFAULT_TIMEOUT_LONG = Duration.ofSeconds(360); + /** * Maximum number of retries (the request is attempted up to {@code maxRetries + 1} times). Public * (not just within this package) so the internal HTTP client, which lives in a separate, @@ -17,21 +27,52 @@ public final class RetryConfig { /** Upper bound on the (exponentially growing) inter-retry delay. */ public final Duration maxDelayBetweenRetries; + /** Duration of the {@link Timeout#SHORT} tier. */ + public final Duration timeoutShort; + + /** Duration of the {@link Timeout#MEDIUM} tier. */ + public final Duration timeoutMedium; + + /** Duration of the {@link Timeout#LONG} tier. */ + public final Duration timeoutLong; + /** - * Maximum per-attempt request (socket) timeout, and the default base timeout for a call. Each - * attempt's timeout may grow from a smaller per-call base but is capped at this value. This - * bounds a single attempt, not the cumulative time across all retries. + * Cap on the timeout of a single request attempt. The first attempt gets the requested timeout, + * each retry doubles it, and none exceeds this value. This bounds a single attempt, not the + * cumulative time across all retries. */ public final Duration timeout; + /** A policy with the default tier durations and the given cap on a single attempt. */ public RetryConfig( int maxRetries, Duration minDelayBetweenRetries, Duration maxDelayBetweenRetries, Duration timeout) { + this( + maxRetries, + minDelayBetweenRetries, + maxDelayBetweenRetries, + DEFAULT_TIMEOUT_SHORT, + DEFAULT_TIMEOUT_MEDIUM, + DEFAULT_TIMEOUT_LONG, + timeout); + } + + public RetryConfig( + int maxRetries, + Duration minDelayBetweenRetries, + Duration maxDelayBetweenRetries, + Duration timeoutShort, + Duration timeoutMedium, + Duration timeoutLong, + Duration timeoutMax) { this.maxRetries = maxRetries; this.minDelayBetweenRetries = minDelayBetweenRetries; this.maxDelayBetweenRetries = maxDelayBetweenRetries; - this.timeout = timeout; + this.timeoutShort = timeoutShort; + this.timeoutMedium = timeoutMedium; + this.timeoutLong = timeoutLong; + this.timeout = timeoutMax; } } diff --git a/src/main/java/com/apify/client/http/Timeout.java b/src/main/java/com/apify/client/http/Timeout.java new file mode 100644 index 0000000..5a423d3 --- /dev/null +++ b/src/main/java/com/apify/client/http/Timeout.java @@ -0,0 +1,111 @@ +package com.apify.client.http; + +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; + +/** + * The timeout of a single API request attempt: one of the preconfigured {@linkplain Tier tiers}, an + * exact duration, or {@link #NO_TIMEOUT}. + * + *

Every client method picks the tier that fits the expected duration of its request: {@link + * #SHORT} for simple metadata reads and writes, {@link #MEDIUM} for listing, batch and trigger + * operations, {@link #LONG} for downloads, uploads and streaming. The duration of each tier is set + * on the {@link com.apify.client.ApifyClientBuilder}. Pass a {@code Timeout} to {@link + * com.apify.client.ApifyClient#withTimeout(Timeout)} to replace the tier of every request a client + * sends. + * + *

A tier or an explicit duration above the builder's {@code timeoutMax} is capped at it, and the + * client logs a warning when that happens. The timeout applies to each attempt separately and + * doubles with every retry, up to the same cap. With {@link #NO_TIMEOUT} the client never aborts + * the request, so a connection that stalls is not retried either. + */ +public final class Timeout { + + /** The preconfigured timeout tiers. */ + public enum Tier { + /** Simple metadata reads and writes (default 5 seconds). */ + SHORT, + /** Listing, batch and trigger operations (default 30 seconds). */ + MEDIUM, + /** Downloads, uploads and streaming (default 360 seconds). */ + LONG + } + + /** The {@link Tier#SHORT} tier. */ + public static final Timeout SHORT = new Timeout(Tier.SHORT, null, false); + + /** The {@link Tier#MEDIUM} tier. */ + public static final Timeout MEDIUM = new Timeout(Tier.MEDIUM, null, false); + + /** The {@link Tier#LONG} tier. */ + public static final Timeout LONG = new Timeout(Tier.LONG, null, false); + + /** Lets the request run for as long as it takes. */ + public static final Timeout NO_TIMEOUT = new Timeout(null, null, true); + + private final Tier tier; + private final Duration duration; + private final boolean noTimeout; + + private Timeout(Tier tier, Duration duration, boolean noTimeout) { + this.tier = tier; + this.duration = duration; + this.noTimeout = noTimeout; + } + + /** + * An exact per-attempt timeout. + * + * @throws IllegalArgumentException if {@code duration} is null, zero or negative; use {@link + * #NO_TIMEOUT} to run without a timeout + */ + public static Timeout of(Duration duration) { + if (duration == null || duration.isZero() || duration.isNegative()) { + throw new IllegalArgumentException( + "timeout must be strictly positive (use Timeout.NO_TIMEOUT to run without one)"); + } + return new Timeout(null, duration, false); + } + + /** An exact per-attempt timeout in seconds; see {@link #of(Duration)}. */ + public static Timeout ofSeconds(long seconds) { + return of(Duration.ofSeconds(seconds)); + } + + /** The tier this timeout names, or empty for an exact duration and for {@link #NO_TIMEOUT}. */ + public Optional tier() { + return Optional.ofNullable(tier); + } + + /** The exact duration, or empty for a tier and for {@link #NO_TIMEOUT}. */ + public Optional duration() { + return Optional.ofNullable(duration); + } + + /** Whether this is {@link #NO_TIMEOUT}. */ + public boolean isNoTimeout() { + return noTimeout; + } + + @Override + public boolean equals(Object o) { + return o instanceof Timeout other + && tier == other.tier + && noTimeout == other.noTimeout + && Objects.equals(duration, other.duration); + } + + @Override + public int hashCode() { + return Objects.hash(tier, duration, noTimeout); + } + + @Override + public String toString() { + if (noTimeout) { + return "Timeout.NO_TIMEOUT"; + } + return tier != null ? "Timeout." + tier : "Timeout.of(" + duration + ")"; + } +} diff --git a/src/main/java/com/apify/client/internal/Async.java b/src/main/java/com/apify/client/internal/Async.java index e773c8e..4c0aea8 100644 --- a/src/main/java/com/apify/client/internal/Async.java +++ b/src/main/java/com/apify/client/internal/Async.java @@ -4,6 +4,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; @@ -32,11 +33,22 @@ public final class Async { private Async() {} - /** Returns a future that completes after {@code delay} has elapsed (never negative/blocking). */ + /** + * Returns a future that completes after {@code delay} has elapsed (never negative/blocking). + * Cancelling it cancels the timer. + */ public static CompletableFuture delay(Duration delay) { - CompletableFuture future = new CompletableFuture<>(); + CancellableFuture future = new CancellableFuture<>(); long millis = Math.max(0, delay.toMillis()); - SCHEDULER.schedule(() -> future.complete(null), millis, TimeUnit.MILLISECONDS); + ScheduledFuture timer = + SCHEDULER.schedule(() -> future.complete(null), millis, TimeUnit.MILLISECONDS); + // A cancelled delay releases its timer instead of firing into a future nobody waits for. + future.whenComplete( + (unused, error) -> { + if (future.isCancelled()) { + timer.cancel(false); + } + }); return future; } } diff --git a/src/main/java/com/apify/client/internal/AsyncPaginatedPublisher.java b/src/main/java/com/apify/client/internal/AsyncPaginatedPublisher.java index 155cfbf..b3fbf54 100644 --- a/src/main/java/com/apify/client/internal/AsyncPaginatedPublisher.java +++ b/src/main/java/com/apify/client/internal/AsyncPaginatedPublisher.java @@ -112,6 +112,7 @@ private final class Session implements Flow.Subscription { private long yielded; private boolean exhausted; private boolean fetchInFlight; + private volatile CompletableFuture inFlight; Session(Flow.Subscriber subscriber) { this.subscriber = subscriber; @@ -141,6 +142,11 @@ public void request(long n) { @Override public void cancel() { cancelled.set(true); + // Abort the page being fetched, with its request and retries. + CompletableFuture fetch = inFlight; + if (fetch != null) { + fetch.cancel(true); + } } /** Enters the drain loop unless another drain (of this same session) is already running. */ @@ -203,22 +209,22 @@ private void fetchNextPage() { fetchInFlight = true; Long capRemaining = totalLimit != null ? totalLimit - yielded : null; Long pageLimit = minForLimit(capRemaining, chunkSize); - fetcher - .fetch(offset, pageLimit) - .whenComplete( - (page, error) -> { - fetchInFlight = false; - if (cancelled.get()) { - draining.set(false); - return; - } - if (error != null) { - terminate(() -> subscriber.onError(HttpClientCore.unwrapCompletion(error))); - return; - } - applyPage(page); - drainLoop(); - }); + CompletableFuture> fetch = fetcher.fetch(offset, pageLimit); + inFlight = fetch; + fetch.whenComplete( + (page, error) -> { + fetchInFlight = false; + if (cancelled.get()) { + draining.set(false); + return; + } + if (error != null) { + terminate(() -> subscriber.onError(HttpClientCore.unwrapCompletion(error))); + return; + } + applyPage(page); + drainLoop(); + }); } private void applyPage(PaginationList page) { diff --git a/src/main/java/com/apify/client/internal/CancellableFuture.java b/src/main/java/com/apify/client/internal/CancellableFuture.java new file mode 100644 index 0000000..16d42f0 --- /dev/null +++ b/src/main/java/com/apify/client/internal/CancellableFuture.java @@ -0,0 +1,182 @@ +package com.apify.client.internal; + +import java.lang.ref.WeakReference; +import java.util.List; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.function.Function; + +/** + * A {@link CompletableFuture} whose cancellation reaches the work behind it. Cancelling a plain + * {@code CompletableFuture} completes only that future: the request it waits for, the retry or poll + * chain that produced it and the timers that drive it keep running. Every future the client returns + * is a {@code CancellableFuture}, and so is every stage derived from it with {@code thenApply}, + * {@code thenCompose}, {@code handle}, ...: cancelling the future a caller holds cancels each + * upstream stage (and the future {@code thenCompose} is waiting on) down to the HTTP request in + * flight, and stops the retries, polls and pages that would follow. Internal to the client. + */ +public final class CancellableFuture extends CompletableFuture { + + /** + * The futures this one depends on, cancelled together with it. Held weakly: a future that is + * still pending is kept alive by whatever will complete it (the request in flight, the timer, the + * stage it feeds), and holding a completed one strongly would keep its result (a response body, + * say) reachable from every later stage for as long as the call lives, which for a long {@code + * waitForFinish} poll loop is unbounded. + */ + private final ConcurrentLinkedQueue>> upstream = + new ConcurrentLinkedQueue<>(); + + public CancellableFuture() {} + + /** + * A future that completes as {@code source} does and, when cancelled, cancels {@code source}. + * Wraps the future a transport returns, so cancelling a call aborts the request in flight. + */ + public static CancellableFuture wrap(CompletionStage source) { + CancellableFuture result = new CancellableFuture<>(); + CompletableFuture sourceFuture = source.toCompletableFuture(); + result.link(sourceFuture); + sourceFuture.whenComplete( + (value, error) -> { + if (error == null) { + result.complete(value); + } else { + result.completeExceptionally(error); + } + }); + return result; + } + + /** An already completed future. */ + public static CancellableFuture completed(T value) { + CancellableFuture future = new CancellableFuture<>(); + future.complete(value); + return future; + } + + /** An already failed future. */ + public static CancellableFuture failed(Throwable error) { + CancellableFuture future = new CancellableFuture<>(); + future.completeExceptionally(error); + return future; + } + + /** A future that completes once all of {@code futures} have, and cancels them when cancelled. */ + public static CancellableFuture allOf(List> futures) { + CancellableFuture result = new CancellableFuture<>(); + futures.forEach(result::link); + CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)) + .whenComplete( + (unused, error) -> { + if (error == null) { + result.complete(null); + } else { + result.completeExceptionally(error); + } + }); + return result; + } + + /** Makes {@code other} be cancelled whenever this future is (now, if it already is). */ + public void link(CompletableFuture other) { + upstream.add(new WeakReference<>(other)); + if (isCancelled()) { + other.cancel(true); + } + } + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + boolean cancelled = super.cancel(mayInterruptIfRunning); + if (cancelled) { + for (WeakReference> reference : upstream) { + CompletableFuture source = reference.get(); + if (source != null) { + source.cancel(mayInterruptIfRunning); + } + } + } + return cancelled; + } + + /** + * A defensive copy: completes with this future's outcome, but cancelling the copy does not cancel + * this future or its request. + */ + @Override + public CompletableFuture copy() { + CancellableFuture copy = new CancellableFuture<>(); + whenComplete( + (value, error) -> { + if (error == null) { + copy.complete(value); + } else { + copy.completeExceptionally( + error instanceof CompletionException ? error : new CompletionException(error)); + } + }); + return copy; + } + + /** Every stage derived from this one cancels this one when it is cancelled. */ + @Override + public CompletableFuture newIncompleteFuture() { + CancellableFuture dependent = new CancellableFuture<>(); + dependent.upstream.add(new WeakReference<>(this)); + return dependent; + } + + /** + * As {@link CompletableFuture#thenCompose}, additionally linking the future {@code fn} returns, + * so cancelling the composed stage cancels the work it was waiting on, and skipping {@code fn} + * altogether once the composed stage is cancelled (so a cancelled call sends no further request). + */ + @Override + public CompletableFuture thenCompose( + Function> fn) { + Composition composition = new Composition<>(); + CompletableFuture composed = + super.thenCompose( + value -> { + CancellableFuture dependent = composition.dependent; + if (dependent != null && dependent.isCancelled()) { + return failed(new CancellationException()); + } + CompletionStage inner = fn.apply(value); + CompletableFuture innerFuture = inner.toCompletableFuture(); + synchronized (composition) { + dependent = composition.dependent; + if (dependent == null) { + composition.inner = innerFuture; + } + } + if (dependent != null) { + dependent.link(innerFuture); + } + return inner; + }); + CancellableFuture dependent = (CancellableFuture) composed; + CompletableFuture early; + synchronized (composition) { + composition.dependent = dependent; + early = composition.inner; + composition.inner = null; + } + if (early != null) { + dependent.link(early); + } + return composed; + } + + /** + * Hands the composed stage and the future {@code fn} returned to each other, whichever is first. + */ + private static final class Composition { + volatile CancellableFuture dependent; + CompletableFuture inner; + } +} diff --git a/src/main/java/com/apify/client/internal/DatasetItemsOptionsAccess.java b/src/main/java/com/apify/client/internal/DatasetItemsOptionsAccess.java new file mode 100644 index 0000000..6139505 --- /dev/null +++ b/src/main/java/com/apify/client/internal/DatasetItemsOptionsAccess.java @@ -0,0 +1,56 @@ +package com.apify.client.internal; + +import com.apify.client.dataset.DatasetListItemsOptions; + +/** + * Cross-package access to the item options of {@link DatasetListItemsOptions}, whose accessors are + * package-private because only the client itself needs them. {@code DatasetListItemsOptions} + * registers its implementation when it is loaded, so code in another package (the run client's + * dataset iteration) can read and derive options without those accessors being public API. Internal + * to the client. + */ +public interface DatasetItemsOptionsAccess { + + /** The registered implementation; the options class is loaded before it is looked up. */ + static DatasetItemsOptionsAccess get() { + // Loading the options class runs its static initializer, which registers the implementation. + new DatasetListItemsOptions(); + return Holder.instance; + } + + /** Registers the implementation; called once by {@link DatasetListItemsOptions}. */ + static void register(DatasetItemsOptionsAccess access) { + Holder.instance = access; + } + + /** The first row {@code options} asks for, or 0. */ + long offset(DatasetListItemsOptions options); + + /** The maximum number of rows {@code options} asks for, or {@code null}. */ + Long limit(DatasetListItemsOptions options); + + /** + * Whether {@code clean}, {@code skipEmpty} or {@code unwind} is set: the server-side filters that + * can leave a page of existing rows without a single returned item. + */ + boolean dropsRows(DatasetListItemsOptions options); + + /** + * An independent copy of {@code options} (the defaults for {@code null}) for reading rows in the + * order they were pushed, so a later change of the caller's options cannot reach the iteration. + */ + DatasetListItemsOptions snapshot(DatasetListItemsOptions options); + + /** A copy of {@code options} for the given page of rows, in the order they were pushed. */ + DatasetListItemsOptions page(DatasetListItemsOptions options, long offset, long limit); + + /** Options for the given page of rows with none of the item filters of the caller. */ + DatasetListItemsOptions plainPage(long offset, long limit); + + /** Holds the registered implementation. */ + final class Holder { + private static volatile DatasetItemsOptionsAccess instance; + + private Holder() {} + } +} diff --git a/src/main/java/com/apify/client/internal/HttpClientCore.java b/src/main/java/com/apify/client/internal/HttpClientCore.java index 4eaea99..af6b17a 100644 --- a/src/main/java/com/apify/client/internal/HttpClientCore.java +++ b/src/main/java/com/apify/client/internal/HttpClientCore.java @@ -1,20 +1,21 @@ package com.apify.client.internal; -import com.aayushatharva.brotli4j.Brotli4jLoader; -import com.aayushatharva.brotli4j.encoder.Encoder; import com.apify.client.http.ApiResponse; import com.apify.client.http.ApifyApiException; import com.apify.client.http.ApifyTransportException; +import com.apify.client.http.BrotliHttpCompressor; import com.apify.client.http.ConflictError; import com.apify.client.http.ForbiddenError; +import com.apify.client.http.GzipHttpCompressor; +import com.apify.client.http.HttpCompressor; import com.apify.client.http.HttpTransport; import com.apify.client.http.InvalidRequestError; import com.apify.client.http.NotFoundError; import com.apify.client.http.RateLimitError; import com.apify.client.http.RetryConfig; import com.apify.client.http.ServerError; +import com.apify.client.http.Timeout; import com.apify.client.http.UnauthorizedError; -import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.net.URI; @@ -27,11 +28,14 @@ import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.ThreadLocalRandom; -import java.util.zip.GZIPOutputStream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * The orchestrating HTTP client shared by every resource client. It owns the transport, the @@ -64,84 +68,207 @@ public final class HttpClientCore { */ private static final int MIN_COMPRESS_BYTES = 1024; - /** {@code Content-Encoding} value for brotli-compressed bodies (matches the reference client). */ - private static final String ENCODING_BROTLI = "br"; - - /** {@code Content-Encoding} value for gzip-compressed bodies (the fallback coding). */ - private static final String ENCODING_GZIP = "gzip"; - /** Prefix required before the token value in the {@code Authorization} header. */ private static final String AUTH_BEARER_PREFIX = "Bearer "; + /** The API holds a {@code waitForFinish} request for at most this many seconds. */ + private static final long MAX_HOLD_SECS = 60; + /** - * Whether a brotli native codec loaded for the running platform. Resolved once at class load: - * brotli4j needs a platform-specific native library, so on platforms without one (or if loading - * fails) the client falls back to gzip. Mirrors the reference JS client, which prefers brotli and - * falls back to gzip. + * Seconds of a caller-chosen request timeout kept free of a {@code waitForFinish} hold, so the + * API answers before the client's own timeout fires. */ - private static final boolean BROTLI_AVAILABLE = detectBrotli(); + private static final long HOLD_MARGIN_SECS = 5; - private static boolean detectBrotli() { - try { - Brotli4jLoader.ensureAvailability(); - return true; - } catch (Throwable t) { - // Native codec unavailable (no bundled binary for this OS/arch, or a link error). Catch - // Throwable because native loading can raise UnsatisfiedLinkError/NoClassDefFoundError, not - // just Exception. The client stays fully functional using gzip. - return false; - } - } + private static final double MILLIS_PER_SECOND = 1000.0; - /** - * Reports whether the brotli path is active on this platform. Public so tests outside this - * package (which lives in the non-exported {@code com.apify.client.internal} package) can assert - * on it. - */ - public static boolean brotliAvailable() { - return BROTLI_AVAILABLE; - } + private static final Logger LOG = LoggerFactory.getLogger(HttpClientCore.class); private final HttpTransport transport; private final String token; private final String userAgent; private final RetryConfig retry; + private final HttpCompressor compressor; + private final Map defaultHeaders; + /** When set, replaces the timeout every request would otherwise get. */ + private final Timeout timeoutOverride; + + /** Requested timeouts already warned about, shared by every copy made by {@link #withTimeout}. */ + private final Set warnedCappedTimeouts; + + /** + * @param compressor compresses large request bodies; {@code null} picks brotli where its native + * library is available and gzip elsewhere + * @param defaultHeaders headers sent with every request; they replace the built-in {@code + * User-Agent} and {@code Authorization} headers of the same name and are replaced by a header + * the request sets itself + */ public HttpClientCore( - HttpTransport transport, String token, String userAgent, RetryConfig retry) { + HttpTransport transport, + String token, + String userAgent, + RetryConfig retry, + HttpCompressor compressor, + Map defaultHeaders) { + this( + transport, + token, + userAgent, + retry, + compressor != null ? compressor : defaultCompressor(), + defaultHeaders == null ? Map.of() : Map.copyOf(defaultHeaders), + null, + ConcurrentHashMap.newKeySet()); + } + + private static HttpCompressor defaultCompressor() { + return BrotliHttpCompressor.isAvailable() + ? new BrotliHttpCompressor() + : new GzipHttpCompressor(); + } + + private HttpClientCore( + HttpTransport transport, + String token, + String userAgent, + RetryConfig retry, + HttpCompressor compressor, + Map defaultHeaders, + Timeout timeoutOverride, + Set warnedCappedTimeouts) { this.transport = transport; this.token = token; this.userAgent = userAgent; this.retry = retry; + this.compressor = compressor; + this.defaultHeaders = defaultHeaders; + this.timeoutOverride = timeoutOverride; + this.warnedCappedTimeouts = warnedCappedTimeouts; } public String userAgent() { return userAgent; } - /** The configured maximum per-attempt request timeout, in whole seconds. */ - public long requestTimeoutSeconds() { - return retry.timeout.getSeconds(); + /** + * A copy of this client that sends every request with {@code timeout} instead of the tier the + * calling method would pick. The transport, token and retry policy are shared. + */ + public HttpClientCore withTimeout(Timeout timeout) { + if (timeout == null) { + throw new IllegalArgumentException("timeout must not be null"); + } + return new HttpClientCore( + transport, + token, + userAgent, + retry, + compressor, + defaultHeaders, + timeout, + warnedCappedTimeouts); } /** - * The default per-attempt request timeout used as the base for standard API calls. It is the - * client's configured timeout (single source of truth), so a caller-configured timeout applies to - * the first attempt too. Individual calls may pass a smaller base (e.g. key-value-store uploads), - * which then grows back up toward this value on retries. + * This client without the override set by {@link #withTimeout}: every request gets the timeout of + * its own method again. */ - public Duration baseRequestTimeout() { - return retry.timeout; + public HttpClientCore withoutTimeoutOverride() { + if (timeoutOverride == null) { + return this; + } + return new HttpClientCore( + transport, token, userAgent, retry, compressor, defaultHeaders, null, warnedCappedTimeouts); } + /** + * Resolves a timeout to the duration of its first attempt: a tier to its configured duration, an + * exact timeout to itself, and {@link Timeout#NO_TIMEOUT} to {@code null} (no timeout). A value + * above the configured maximum is capped at it, which logs a warning once per value, since the + * request does not get the whole time it asked for. The override set by {@link #withTimeout} + * takes precedence over {@code requested}. + */ + public Duration resolveTimeout(Timeout requested) { + Timeout effective = timeoutOverride != null ? timeoutOverride : requested; + if (effective.isNoTimeout()) { + return null; + } + Duration duration = + effective + .duration() + .orElseGet( + () -> + switch (effective.tier().orElseThrow()) { + case SHORT -> retry.timeoutShort; + case MEDIUM -> retry.timeoutMedium; + case LONG -> retry.timeoutLong; + }); + if (duration.compareTo(retry.timeout) > 0) { + if (warnedCappedTimeouts.add(duration)) { + LOG.warn( + "The requested timeout of {}s exceeds timeoutMax ({}s) and is capped at it. Raise" + + " timeoutMax on the client builder to allow longer request timeouts.", + duration.toMillis() / MILLIS_PER_SECOND, + retry.timeout.toMillis() / MILLIS_PER_SECOND); + } + return retry.timeout; + } + return duration; + } + + /** + * The {@code waitForFinish} value to send and the timeout to send it with, for a request that + * asks the API to hold its response for up to {@code requestedHold} seconds. + * + *

The hold is at most {@link #MAX_HOLD_SECS} and is cut down to what the request timeout can + * cover, so the client never aborts a request the API is still holding (which, for a start or a + * build, would retry a call that creates a resource). Without an override, when the tier plus the + * hold fits under {@code timeoutMax} the timeout is the tier's plus the hold; otherwise the + * timeout is {@code timeoutMax} and the hold is at most {@code timeoutMax} less a safety margin + * (0 if that is not positive). An override set by {@link #withTimeout} is kept as given, and the + * hold is limited to it less a safety margin. + */ + public WaitHold waitHold(Timeout tier, Long requestedHold) { + if (requestedHold == null) { + return new WaitHold(null, tier); + } + long hold = Math.min(Math.max(requestedHold, 0), MAX_HOLD_SECS); + Duration base = resolveTimeout(tier); + if (base == null) { + return new WaitHold(hold, tier); + } + if (timeoutOverride != null) { + long coverable = Math.max(0, base.toSeconds() - HOLD_MARGIN_SECS); + return new WaitHold(Math.min(hold, coverable), tier); + } + long room = retry.timeout.toSeconds() - base.toSeconds(); + if (room < hold) { + // timeoutMax leaves less above the tier than the hold: keep the hold the caller asked for as + // far as the cap allows, the same way an explicit timeout override does. + long coverable = Math.max(0, retry.timeout.toSeconds() - HOLD_MARGIN_SECS); + return new WaitHold(Math.min(hold, coverable), Timeout.of(retry.timeout)); + } + return new WaitHold(hold, hold > 0 ? Timeout.of(base.plusSeconds(hold)) : tier); + } + + /** + * The outcome of {@link #waitHold}. + * + * @param waitForFinishSecs the {@code waitForFinish} query value to send, or {@code null} for + * none + * @param timeout the timeout to send the request with + */ + public record WaitHold(Long waitForFinishSecs, Timeout timeout) {} + /** Sends a request with auth, User-Agent and the retry policy applied. */ public CompletableFuture call( - String method, String url, byte[] body, String contentType, Duration baseTimeout) { - return call(method, url, body, contentType, null, baseTimeout, false); + String method, String url, byte[] body, String contentType, Timeout timeout) { + return call(method, url, body, contentType, null, timeout, false); } /** - * Like {@link #call(String, String, byte[], String, Duration)} but, when {@code + * Like {@link #call(String, String, byte[], String, Timeout)} but, when {@code * doNotRetryTimeouts} is {@code true}, a transport timeout is treated as a terminal failure * rather than being retried (other transport/network errors and retryable statuses are still * retried). @@ -151,13 +278,13 @@ public CompletableFuture call( String url, byte[] body, String contentType, - Duration baseTimeout, + Timeout timeout, boolean doNotRetryTimeouts) { - return call(method, url, body, contentType, null, baseTimeout, doNotRetryTimeouts); + return call(method, url, body, contentType, null, timeout, doNotRetryTimeouts); } /** - * Like {@link #call(String, String, byte[], String, Duration)} but additionally sets the given + * Like {@link #call(String, String, byte[], String, Timeout)} but additionally sets the given * extra headers on every attempt. */ public CompletableFuture call( @@ -166,8 +293,8 @@ public CompletableFuture call( byte[] body, String contentType, Map extraHeaders, - Duration baseTimeout) { - return call(method, url, body, contentType, extraHeaders, baseTimeout, false); + Timeout timeout) { + return call(method, url, body, contentType, extraHeaders, timeout, false); } /** The canonical overload every other {@code call} convenience overload delegates to. */ @@ -177,17 +304,24 @@ public CompletableFuture call( byte[] body, String contentType, Map extraHeaders, - Duration baseTimeout, + Timeout timeout, boolean doNotRetryTimeouts) { + 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)) { - Compressed compressed = compress(requestBody, BROTLI_AVAILABLE); - requestBody = compressed.body(); + 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, compressed.encoding()); + headers.put(HttpHeaders.CONTENT_ENCODING, compressor.contentEncoding()); } String path = extractPath(url); @@ -284,6 +418,10 @@ private CompletableFuture attempt( null, false, isStatusRetryable(resp.statusCode()), apiError); } Throwable cause = unwrapCompletion(err); + if (cause instanceof CancellationException cancellation) { + // The caller cancelled the call: end it, never retry. + return new AttemptOutcome(null, false, false, cancellation); + } ApifyTransportException transportError = cause instanceof ApifyTransportException apifyTransportException ? apifyTransportException @@ -309,8 +447,10 @@ HttpRequest buildRequest( body != null ? HttpRequest.BodyPublishers.ofByteArray(body) : HttpRequest.BodyPublishers.noBody(); - HttpRequest.Builder b = - HttpRequest.newBuilder(URI.create(url)).method(method, publisher).timeout(timeout); + HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(url)).method(method, publisher); + if (timeout != null) { + b.timeout(timeout); + } b.header(HttpHeaders.USER_AGENT, userAgent); if (token != null && !token.isEmpty()) { b.header(HttpHeaders.AUTHORIZATION, AUTH_BEARER_PREFIX + token); @@ -318,8 +458,11 @@ HttpRequest buildRequest( if (contentType != null && !contentType.isEmpty()) { b.header(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); if (extraHeaders != null) { - extraHeaders.forEach(b::header); + extraHeaders.forEach(b::setHeader); } return b.build(); } @@ -332,8 +475,7 @@ private CompletableFuture doAttempt( Map extraHeaders, Duration timeout) { HttpRequest request = buildRequest(method, url, body, contentType, extraHeaders, timeout); - return transport - .sendAsync(request) + return CancellableFuture.wrap(transport.sendAsync(request)) .thenApply(resp -> new ApiResponse(resp.statusCode(), resp.headers(), resp.body())); } @@ -346,6 +488,9 @@ private CompletableFuture doAttempt( * across all retries. */ private Duration attemptTimeout(Duration base, int attempt) { + if (base == null) { + return null; // Timeout.NO_TIMEOUT + } Duration scaled = base; for (int i = 1; i < attempt; i++) { scaled = scaled.multipliedBy(2); @@ -436,8 +581,8 @@ private static boolean shouldCompress( * ending in {@code +json}/{@code +xml} (e.g. {@code image/svg+xml}). A missing content type is * assumed compressible. Matches the reference JS client's {@code isCompressibleContentType}. * - *

Public (like {@link #compress} and {@link #brotliAvailable}) so {@code CompressionTest}, - * outside this non-exported package, can exercise the classification directly. + *

Public so {@code CompressionTest}, outside this non-exported package, can exercise the + * classification directly. */ public static boolean isCompressibleContentType(String contentType) { if (contentType == null || contentType.isEmpty()) { @@ -468,70 +613,6 @@ public static boolean isCompressibleContentType(String contentType) { return true; } - /** - * A compressed request body together with the {@code Content-Encoding} token that describes it. - * - * @param body the compressed request body - * @param encoding the {@code Content-Encoding} token describing {@code body} - */ - public record Compressed(byte[] body, String encoding) {} - - /** - * Compresses a request body, preferring brotli and falling back to gzip, matching the reference - * JS client. When {@code preferBrotli} is {@code true} the body is brotli-encoded ({@code - * Content-Encoding: br}); otherwise it is gzip-encoded ({@code Content-Encoding: gzip}). Callers - * pass {@link #BROTLI_AVAILABLE}; making the coding an explicit parameter keeps this a pure - * function of its inputs rather than of hidden static state. Public so {@code CompressionTest} - * (outside this non-exported package) can exercise it directly. - * - *

If the brotli path itself fails despite the native codec having loaded, this falls back to - * gzip rather than failing the whole request - matching the reference JS client, which since apify-client-js#990 keys the - * fallback on compression actually failing rather than only on an upfront availability check. - * Catches {@code Throwable}, not just {@code RuntimeException}: a native codec that loaded - * successfully at startup can still fail a specific call with an {@code Error} (e.g. a partial or - * mismatched native library surfacing {@link UnsatisfiedLinkError} only once a method is actually - * invoked), the same reason {@link #detectBrotli()} itself catches {@code Throwable} rather than - * {@code Exception}. - */ - public static Compressed compress(byte[] data, boolean preferBrotli) { - if (preferBrotli) { - try { - return new Compressed(brotli(data), ENCODING_BROTLI); - } catch (Throwable t) { - return new Compressed(gzip(data), ENCODING_GZIP); - } - } - return new Compressed(gzip(data), ENCODING_GZIP); - } - - /** Brotli-compresses a request body using the loaded native codec. */ - private static byte[] brotli(byte[] data) { - try { - return Encoder.compress(data); - } catch (IOException e) { - // Encoding an in-memory byte[] performs no real I/O, so this is unreachable in practice. - throw new ApifyTransportException(e); - } - } - - /** - * Gzip-compresses a request body. Used as the fallback coding when no brotli native codec is - * available for the running platform; the JDK always ships a gzip codec ({@link - * GZIPOutputStream}). - */ - private static byte[] gzip(byte[] data) { - ByteArrayOutputStream out = new ByteArrayOutputStream(data.length); - try (GZIPOutputStream gz = new GZIPOutputStream(out)) { - gz.write(data); - } catch (IOException e) { - // Compressing an in-memory byte[] cannot perform real I/O, so this is unreachable in - // practice. - throw new ApifyTransportException(e); - } - return out.toByteArray(); - } - private static boolean isStatusRetryable(int status) { return status == RATE_LIMIT_EXCEEDED || status >= MIN_SERVER_ERROR; } @@ -655,18 +736,24 @@ public static String extractPath(String url) { * matching {@link HttpTransport}'s contract. */ public CompletableFuture> streamAsync(String url) { - HttpRequest request = buildRequest("GET", url, null, null, null, retry.timeout); - CompletableFuture> result = new CompletableFuture<>(); - transport - .sendStreamingAsync(request) - .whenComplete( - (resp, err) -> { - if (err == null) { - result.complete(resp); - } else { - result.completeExceptionally(new ApifyTransportException(unwrapCompletion(err))); - } - }); + HttpRequest request = buildRequest("GET", url, null, null, null, resolveTimeout(Timeout.LONG)); + CancellableFuture> result = new CancellableFuture<>(); + CompletableFuture> exchange = transport.sendStreamingAsync(request); + // Cancelling the returned future aborts the exchange. + result.link(exchange); + exchange.whenComplete( + (resp, err) -> { + Throwable cause = err == null ? null : unwrapCompletion(err); + if (cause == null) { + if (!result.complete(resp)) { + closeQuietly(resp.body()); + } + } else if (cause instanceof CancellationException) { + result.cancel(true); + } else { + result.completeExceptionally(new ApifyTransportException(cause)); + } + }); return result; } @@ -678,15 +765,37 @@ public CompletableFuture> streamAsync(String url) { * dataset export streaming) so the drain-and-throw logic lives in one place. */ public CompletableFuture streamOrThrow(String url) { - return streamAsync(url) - .thenApply( - resp -> { - if (resp.statusCode() >= MAX_SUCCESS_STATUS) { - byte[] body = drainQuietly(resp.body()); - throw buildApiError(resp.statusCode(), body, 1, "GET", extractPath(url)); - } - return resp.body(); - }); + CancellableFuture result = new CancellableFuture<>(); + CompletableFuture> stream = streamAsync(url); + result.link(stream); + stream.whenComplete( + (resp, err) -> { + if (err != null) { + Throwable cause = unwrapCompletion(err); + if (cause instanceof CancellationException) { + result.cancel(true); + } else { + result.completeExceptionally(cause); + } + return; + } + if (resp.statusCode() >= MAX_SUCCESS_STATUS) { + byte[] body = drainQuietly(resp.body()); + result.completeExceptionally( + buildApiError(resp.statusCode(), body, 1, "GET", extractPath(url))); + } else if (!result.complete(resp.body())) { + closeQuietly(resp.body()); + } + }); + return result; + } + + private static void closeQuietly(InputStream in) { + try { + in.close(); + } catch (IOException e) { + // Nothing is reading the stream any more; a failed close has nobody to report to. + } } /** diff --git a/src/main/java/com/apify/client/internal/Json.java b/src/main/java/com/apify/client/internal/Json.java index f1d1096..da54257 100644 --- a/src/main/java/com/apify/client/internal/Json.java +++ b/src/main/java/com/apify/client/internal/Json.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonSetter; import com.fasterxml.jackson.annotation.Nulls; +import java.nio.charset.StandardCharsets; import tools.jackson.core.JacksonException; import tools.jackson.core.type.TypeReference; import tools.jackson.databind.DeserializationFeature; @@ -68,6 +69,31 @@ public static byte[] toBytes(Object value) { return MAPPER.writeValueAsBytes(value); } + /** + * The request body for a caller-supplied payload (an Actor or task input): a {@code byte[]} and a + * {@link String} are already serialized and sent as they are (a string is UTF-8 encoded), so an + * input read from a file, or the exact bytes of a non-JSON content type, are not wrapped in a + * second layer of JSON. Anything else is serialized to JSON. A {@code null} payload sends no + * body. + */ + public static byte[] toRequestBody(Object payload) { + if (payload == null) { + return null; + } + if (payload instanceof byte[] bytes) { + return bytes; + } + if (payload instanceof String text) { + return text.getBytes(StandardCharsets.UTF_8); + } + return toBytes(payload); + } + + /** Converts a value to its JSON tree, as {@link #toBytes} would serialize it. */ + public static JsonNode toTree(Object value) { + return MAPPER.valueToTree(value); + } + /** Parses JSON bytes into the given type. */ public static T parse(byte[] body, JavaType type) { return MAPPER.readValue(body, type); diff --git a/src/main/java/com/apify/client/internal/ListPublisher.java b/src/main/java/com/apify/client/internal/ListPublisher.java index 05f38a3..7aa6188 100644 --- a/src/main/java/com/apify/client/internal/ListPublisher.java +++ b/src/main/java/com/apify/client/internal/ListPublisher.java @@ -79,6 +79,7 @@ public void request(long n) { @Override public void cancel() { cancelled.set(true); + source.cancel(true); } private void drain() { diff --git a/src/main/java/com/apify/client/internal/NormalizedUrlDeserializer.java b/src/main/java/com/apify/client/internal/NormalizedUrlDeserializer.java new file mode 100644 index 0000000..a2659a6 --- /dev/null +++ b/src/main/java/com/apify/client/internal/NormalizedUrlDeserializer.java @@ -0,0 +1,22 @@ +package com.apify.client.internal; + +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.deser.std.StdDeserializer; + +/** + * Deserializes a {@code format: uri} string field of an API response into its {@linkplain + * UrlNormalizer#normalize normalized} form. Internal to the client; models reference it with + * {@code @JsonDeserialize}. + */ +public final class NormalizedUrlDeserializer extends StdDeserializer { + + public NormalizedUrlDeserializer() { + super(String.class); + } + + @Override + public String deserialize(JsonParser p, DeserializationContext ctxt) { + return UrlNormalizer.normalize(p.getValueAsString()); + } +} diff --git a/src/main/java/com/apify/client/internal/QueryParams.java b/src/main/java/com/apify/client/internal/QueryParams.java index a0ca627..eca5c53 100644 --- a/src/main/java/com/apify/client/internal/QueryParams.java +++ b/src/main/java/com/apify/client/internal/QueryParams.java @@ -34,6 +34,12 @@ public QueryParams addLong(String key, Long value) { return this; } + /** Sets an integer parameter, replacing any earlier value; a {@code null} value removes it. */ + public QueryParams setLong(String key, Long value) { + pairs.removeIf(pair -> pair.key().equals(key)); + return addLong(key, value); + } + /** Adds a floating-point parameter if the value is non-null. */ public QueryParams addDouble(String key, Double value) { if (value != null) { @@ -64,6 +70,16 @@ public QueryParams addRaw(String key, String value) { return this; } + /** The value of the first {@code key} parameter parsed as an integer, or {@code null}. */ + public Long getLong(String key) { + for (Pair p : pairs) { + if (p.key().equals(key)) { + return Long.valueOf(p.value()); + } + } + return null; + } + boolean isEmpty() { return pairs.isEmpty(); } diff --git a/src/main/java/com/apify/client/internal/ResourceContext.java b/src/main/java/com/apify/client/internal/ResourceContext.java index c66c86e..d1014be 100644 --- a/src/main/java/com/apify/client/internal/ResourceContext.java +++ b/src/main/java/com/apify/client/internal/ResourceContext.java @@ -4,6 +4,7 @@ import com.apify.client.http.ApiResponse; import com.apify.client.http.ApifyApiException; import com.apify.client.http.ApifyTransportException; +import com.apify.client.http.Timeout; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.time.Duration; @@ -35,13 +36,6 @@ public final class ResourceContext { /** Server-side waitForFinish chunk size (the API caps server waiting at 60 seconds). */ private static final long WAIT_REQUEST_SECS = 60; - /** - * Safety margin subtracted from the configured per-request timeout when choosing the server-side - * {@code waitForFinish} value, so the server responds before the client's socket timeout fires - * (otherwise a short client timeout would abort every healthy poll and burn all retries). - */ - private static final long WAIT_TIMEOUT_MARGIN_SECS = 5; - /** * Finite upper bound used when the caller asks to wait "indefinitely" ({@code waitSecs == null}). * The API will not accept "Infinity", and an unbounded loop can spin forever on a transient 404; @@ -140,7 +134,7 @@ public static ResourceContext nestedCollection( public static ResourceContext single( HttpClientCore http, String baseUrl, String resourcePath, String id) { return new ResourceContext( - http, baseUrl + "/" + resourcePath + "/" + toSafeId(id), baseUrl, true); + http, baseUrl + "/" + resourcePath + "/" + encodePathSegment(toSafeId(id)), baseUrl, true); } /** A copy of this context with the origin used to build public URLs overridden. */ @@ -190,12 +184,17 @@ public ResourceContext seedParams(QueryParams inherited) { public CompletableFuture> getResource( String subPath, QueryParams params, JavaType dataType) { + return getResource(subPath, params, dataType, Timeout.SHORT); + } + + public CompletableFuture> getResource( + String subPath, QueryParams params, JavaType dataType, Timeout timeout) { // ofNullable, not of: an HTTP 200 with body {"data": null} unwraps to null, which is a valid // "no resource" answer rather than a programming error — never surface it as a raw NPE. // Explicit witness: getResourceRequired is the *receiver* of .handle below (not an argument // to another generic call), so unlike a direct `return`, its type parameter cannot be inferred // from the surrounding target type and must be pinned here. - return this.getResourceRequired(subPath, params, dataType) + return this.getResourceRequired(subPath, params, dataType, timeout) .handle( (value, error) -> { if (error == null) { @@ -214,10 +213,20 @@ public CompletableFuture> getResource( return getResource(subPath, params, Json.type(dataClass)); } + public CompletableFuture> getResource( + String subPath, QueryParams params, Class dataClass, Timeout timeout) { + return getResource(subPath, params, Json.type(dataClass), timeout); + } + public CompletableFuture getResourceRequired( String subPath, QueryParams params, JavaType dataType) { + return getResourceRequired(subPath, params, dataType, Timeout.SHORT); + } + + public CompletableFuture getResourceRequired( + String subPath, QueryParams params, JavaType dataType, Timeout timeout) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); - return http.call("GET", u, null, "", http.baseRequestTimeout()) + return http.call("GET", u, null, "", timeout) .thenApply(resp -> Json.parseData(resp.body(), dataType)); } @@ -226,6 +235,11 @@ public CompletableFuture getResourceRequired( return getResourceRequired(subPath, params, Json.type(dataClass)); } + public CompletableFuture getResourceRequired( + String subPath, QueryParams params, Class dataClass, Timeout timeout) { + return getResourceRequired(subPath, params, Json.type(dataClass), timeout); + } + /** * Like {@link #getResource}, but a 404 is only swallowed to {@link Optional#empty()} when this * context {@link #ownId addresses its resource by an explicit id}. Otherwise (a nested path with @@ -236,10 +250,16 @@ public CompletableFuture getResourceRequired( */ public CompletableFuture> getResourceUnlessAmbiguous( String subPath, QueryParams params, JavaType dataType) { + return getResourceUnlessAmbiguous(subPath, params, dataType, Timeout.SHORT); + } + + public CompletableFuture> getResourceUnlessAmbiguous( + String subPath, QueryParams params, JavaType dataType, Timeout timeout) { if (ownId) { - return getResource(subPath, params, dataType); + return getResource(subPath, params, dataType, timeout); } - return this.getResourceRequired(subPath, params, dataType).thenApply(Optional::ofNullable); + return this.getResourceRequired(subPath, params, dataType, timeout) + .thenApply(Optional::ofNullable); } public CompletableFuture> getResourceUnlessAmbiguous( @@ -248,15 +268,20 @@ public CompletableFuture> getResourceUnlessAmbiguous( } public CompletableFuture updateResource(String subPath, Object body, Class dataClass) { + return updateResource(subPath, body, dataClass, Timeout.SHORT); + } + + public CompletableFuture updateResource( + String subPath, Object body, Class dataClass, Timeout timeout) { String u = mergedParams(new QueryParams()).applyToUrl(subUrl(subPath)); - return http.call("PUT", u, Json.toBytes(body), CONTENT_TYPE_JSON, http.baseRequestTimeout()) + return http.call("PUT", u, Json.toBytes(body), CONTENT_TYPE_JSON, timeout) .thenApply(resp -> Json.parseData(resp.body(), dataClass)); } /** Performs a DELETE; a not-found is treated as a successful no-op. */ public CompletableFuture deleteResource(String subPath) { String u = mergedParams(new QueryParams()).applyToUrl(subUrl(subPath)); - return http.call("DELETE", u, null, "", http.baseRequestTimeout()) + return http.call("DELETE", u, null, "", Timeout.SHORT) .handle( (resp, error) -> { if (error == null) { @@ -280,13 +305,18 @@ public CompletableFuture deleteResourceUnlessAmbiguous(String subPath) { return deleteResource(subPath); } String u = mergedParams(new QueryParams()).applyToUrl(subUrl(subPath)); - return http.call("DELETE", u, null, "", http.baseRequestTimeout()).thenApply(resp -> null); + return http.call("DELETE", u, null, "", Timeout.SHORT).thenApply(resp -> null); } public CompletableFuture> listResource( String subPath, QueryParams params, Class itemClass) { + return listResource(subPath, params, itemClass, Timeout.MEDIUM); + } + + public CompletableFuture> listResource( + String subPath, QueryParams params, Class itemClass, Timeout timeout) { JavaType listType = Json.parametric(PaginationList.class, Json.type(itemClass)); - return getResourceRequired(subPath, params, listType); + return getResourceRequired(subPath, params, listType, timeout); } /** @@ -320,8 +350,13 @@ public Flow.Publisher iterateResource( public CompletableFuture createResource( QueryParams params, Object body, Class dataClass) { + return createResource(params, body, dataClass, Timeout.SHORT); + } + + public CompletableFuture createResource( + QueryParams params, Object body, Class dataClass, Timeout timeout) { String u = mergedParams(params).applyToUrl(subUrl("")); - return http.call("POST", u, Json.toBytes(body), CONTENT_TYPE_JSON, http.baseRequestTimeout()) + return http.call("POST", u, Json.toBytes(body), CONTENT_TYPE_JSON, timeout) .thenApply(resp -> Json.parseData(resp.body(), dataClass)); } @@ -354,21 +389,30 @@ public CompletableFuture getOrCreateNamed(String name, Object body, Class } String u = params.applyToUrl(subUrl("")); byte[] bodyBytes = body == null ? null : Json.toBytes(body); - return http.call( - "POST", u, bodyBytes, body == null ? "" : CONTENT_TYPE_JSON, http.baseRequestTimeout()) + return http.call("POST", u, bodyBytes, body == null ? "" : CONTENT_TYPE_JSON, Timeout.SHORT) .thenApply(resp -> Json.parseData(resp.body(), dataClass)); } /** POST with a raw body (optional) and content type, unwrapping the data envelope. */ public CompletableFuture postWithBody( - String subPath, QueryParams params, byte[] body, String contentType, Class dataClass) { - return postWithBody(subPath, params, body, contentType, Json.type(dataClass)); + String subPath, + QueryParams params, + byte[] body, + String contentType, + Class dataClass, + Timeout timeout) { + return postWithBody(subPath, params, body, contentType, Json.type(dataClass), timeout); } public CompletableFuture postWithBody( - String subPath, QueryParams params, byte[] body, String contentType, JavaType dataType) { + String subPath, + QueryParams params, + byte[] body, + String contentType, + JavaType dataType, + Timeout timeout) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); - return http.call("POST", u, body, contentType, http.baseRequestTimeout()) + return http.call("POST", u, body, contentType, timeout) .thenApply(resp -> Json.parseData(resp.body(), dataType)); } @@ -378,17 +422,27 @@ public CompletableFuture postWithBody( * plain object rather than the standard data envelope. */ public CompletableFuture postWithBodyNoEnvelope( - String subPath, QueryParams params, byte[] body, String contentType, Class dataClass) { + String subPath, + QueryParams params, + byte[] body, + String contentType, + Class dataClass, + Timeout timeout) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); - return http.call("POST", u, body, contentType, http.baseRequestTimeout()) + return http.call("POST", u, body, contentType, timeout) .thenApply(resp -> Json.parse(resp.body(), dataClass)); } /** PUT with a raw body (optional) and content type, unwrapping the data envelope. */ public CompletableFuture putWithBody( - String subPath, QueryParams params, byte[] body, String contentType, Class dataClass) { + String subPath, + QueryParams params, + byte[] body, + String contentType, + Class dataClass, + Timeout timeout) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); - return http.call("PUT", u, body, contentType, http.baseRequestTimeout()) + return http.call("PUT", u, body, contentType, timeout) .thenApply(resp -> Json.parseData(resp.body(), dataClass)); } @@ -398,26 +452,32 @@ public CompletableFuture putWithBody( * response is a plain object rather than the standard data envelope. */ public CompletableFuture putWithBodyNoEnvelope( - String subPath, QueryParams params, byte[] body, String contentType, Class dataClass) { + String subPath, + QueryParams params, + byte[] body, + String contentType, + Class dataClass, + Timeout timeout) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); - return http.call("PUT", u, body, contentType, http.baseRequestTimeout()) + return http.call("PUT", u, body, contentType, timeout) .thenApply(resp -> Json.parse(resp.body(), dataClass)); } /** DELETE with a JSON body (used for batch request deletion), unwrapping the data envelope. */ public CompletableFuture deleteWithBody( - String subPath, QueryParams params, Object body, Class dataClass) { + String subPath, QueryParams params, Object body, Class dataClass, Timeout timeout) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); - return http.call("DELETE", u, Json.toBytes(body), CONTENT_TYPE_JSON, http.baseRequestTimeout()) + return http.call("DELETE", u, Json.toBytes(body), CONTENT_TYPE_JSON, timeout) .thenApply(resp -> Json.parseData(resp.body(), dataClass)); } /** * GET returning the raw response (no data envelope). Completes with {@code null} on not-found. */ - public CompletableFuture getRaw(String subPath, QueryParams params) { + public CompletableFuture getRaw( + String subPath, QueryParams params, Timeout timeout) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); - return http.call("GET", u, null, "", http.baseRequestTimeout()) + return http.call("GET", u, null, "", timeout) .handle( (resp, error) -> { if (error == null) { @@ -434,23 +494,26 @@ public CompletableFuture getRaw(String subPath, QueryParams params) /** * As {@link #getRaw}, but a 404 is not swallowed - it propagates as an {@link ApifyApiException}. */ - public CompletableFuture getRawRequired(String subPath, QueryParams params) { + public CompletableFuture getRawRequired( + String subPath, QueryParams params, Timeout timeout) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); - return http.call("GET", u, null, "", http.baseRequestTimeout()); + return http.call("GET", u, null, "", timeout); } /** * Like {@link #getRaw}, but - mirroring {@link #getResourceUnlessAmbiguous} - a 404 is only * swallowed to {@code null} when this context addresses its resource by an explicit id. */ - public CompletableFuture getRawUnlessAmbiguous(String subPath, QueryParams params) { - return ownId ? getRaw(subPath, params) : getRawRequired(subPath, params); + public CompletableFuture getRawUnlessAmbiguous( + String subPath, QueryParams params, Timeout timeout) { + return ownId ? getRaw(subPath, params, timeout) : getRawRequired(subPath, params, timeout); } /** HEAD request; completes with whether the resource exists. */ - public CompletableFuture headExists(String subPath, QueryParams params) { + public CompletableFuture headExists( + String subPath, QueryParams params, Timeout timeout) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); - return http.call("HEAD", u, null, "", http.baseRequestTimeout()) + return http.call("HEAD", u, null, "", timeout) .handle( (resp, error) -> { if (error == null) { @@ -467,7 +530,7 @@ public CompletableFuture headExists(String subPath, QueryParams params) /** PUT with raw bytes and a content type (used for key-value-store record uploads). */ public CompletableFuture putRaw( String subPath, QueryParams params, byte[] body, String contentType) { - return putRaw(subPath, params, body, contentType, http.baseRequestTimeout(), false); + return putRaw(subPath, params, body, contentType, Timeout.SHORT, false); } /** @@ -481,16 +544,16 @@ public CompletableFuture putRaw( QueryParams params, byte[] body, String contentType, - Duration timeout, + Timeout timeout, boolean doNotRetryTimeouts) { return putRaw(subPath, params, body, contentType, null, timeout, doNotRetryTimeouts); } /** - * As {@link #putRaw(String, QueryParams, byte[], String, Duration, boolean)}, additionally - * setting {@code extraHeaders} on the request (used to forward a caller-supplied {@code - * Content-Encoding} on a key-value-store record upload - see {@link HttpHeaders#CONTENT_ENCODING} - * - which also stops {@link HttpClientCore} from compressing the already-encoded body again). + * As {@link #putRaw(String, QueryParams, byte[], String, Timeout, boolean)}, additionally setting + * {@code extraHeaders} on the request (used to forward a caller-supplied {@code Content-Encoding} + * on a key-value-store record upload - see {@link HttpHeaders#CONTENT_ENCODING} - which also + * stops {@link HttpClientCore} from compressing the already-encoded body again). */ public CompletableFuture putRaw( String subPath, @@ -498,38 +561,13 @@ public CompletableFuture putRaw( byte[] body, String contentType, Map extraHeaders, - Duration timeout, + Timeout timeout, boolean doNotRetryTimeouts) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); return http.call("PUT", u, body, contentType, extraHeaders, timeout, doNotRetryTimeouts) .thenApply(resp -> null); } - /** - * The largest server-side {@code waitForFinish} value that is safe to send: below the configured - * per-request timeout by a safety margin (or the API's 60s cap when no finite timeout is set), so - * the server always responds before the client's socket timeout fires. - */ - private long serverWaitCapSecs() { - long configuredTimeoutSecs = http.requestTimeoutSeconds(); - return configuredTimeoutSecs > 0 - ? Math.max(0, configuredTimeoutSecs - WAIT_TIMEOUT_MARGIN_SECS) - : WAIT_REQUEST_SECS; - } - - /** - * Clamps a caller-supplied server-side {@code waitForFinish} value (seconds) to {@link - * #serverWaitCapSecs()}, so a synchronous get/wait can never ask the server to hold the - * connection longer than the client's own per-request timeout. Returns {@code null} for a {@code - * null} input (no server-side wait requested). - */ - public Long clampServerWait(Long waitForFinishSecs) { - if (waitForFinishSecs == null) { - return null; - } - return Math.min(Math.max(0, waitForFinishSecs), serverWaitCapSecs()); - } - /** * Polls a GET endpoint with {@code waitForFinish} until the resource reaches a terminal state or * the wait budget elapses. {@code waitSecs == null} means "wait indefinitely", implemented as a @@ -557,9 +595,13 @@ public CompletableFuture waitForFinish( : MAX_WAIT_FOR_FINISH_SECS; long budgetMillis = effectiveWaitSecs * 1000L; long start = System.currentTimeMillis(); - long serverWaitCap = serverWaitCapSecs(); - return pollOnce( - dataType, isTerminal, resourceName, start, budgetMillis, serverWaitCap, null, null); + // The polls have no timeout, and a withTimeout override does not apply to them: it would cut + // every hold short (a 5 s override would poll four times a second), and cancelling the returned + // future is the way out of a poll that never answers. + ResourceContext polling = + new ResourceContext( + http.withoutTimeoutOverride(), url, baseParams, apiOrigin, publicOrigin, ownId); + return polling.pollOnce(dataType, isTerminal, resourceName, start, budgetMillis, null, null); } /** @@ -594,18 +636,16 @@ private CompletableFuture pollOnce( String resourceName, long start, long budgetMillis, - long serverWaitCap, T lastSeen, Long absentSinceMillis) { long elapsed = System.currentTimeMillis() - start; long remainingSecs = (budgetMillis - elapsed) / 1000L; - long requestSecs = - Math.min(Math.min(Math.max(remainingSecs, 0), WAIT_REQUEST_SECS), serverWaitCap); + long requestSecs = Math.min(Math.max(remainingSecs, 0), WAIT_REQUEST_SECS); QueryParams params = new QueryParams(); params.addLong("waitForFinish", requestSecs); - return this.getResourceRequired("", params, dataType) + return this.getResourceRequired("", params, dataType, Timeout.NO_TIMEOUT) .handle( (value, error) -> { if (error == null) { @@ -653,7 +693,6 @@ private CompletableFuture pollOnce( resourceName, start, budgetMillis, - serverWaitCap, seen, absentSince)); }); @@ -715,7 +754,15 @@ public static String encodePathSegment(String input) { throw new IllegalArgumentException( "a URL path segment must not be empty or a dot segment (\".\" or \"..\"), was: " + input); } - return URLEncoder.encode(input, StandardCharsets.UTF_8).replace("+", "%20"); + // URLEncoder targets form encoding: it writes a space as "+" and escapes "~" and the + // characters of encodeURIComponent's unreserved set, none of which a path segment needs. + return URLEncoder.encode(input, StandardCharsets.UTF_8) + .replace("+", "%20") + .replace("%7E", "~") + .replace("%21", "!") + .replace("%27", "'") + .replace("%28", "(") + .replace("%29", ")"); } /** Extracts the origin ({@code scheme://host[:port]}) from a URL, dropping any path. */ diff --git a/src/main/java/com/apify/client/internal/RunStartSupport.java b/src/main/java/com/apify/client/internal/RunStartSupport.java index c02ce02..8f526e8 100644 --- a/src/main/java/com/apify/client/internal/RunStartSupport.java +++ b/src/main/java/com/apify/client/internal/RunStartSupport.java @@ -3,6 +3,7 @@ import com.apify.client.ApifyClient; import com.apify.client.actor.ActorStartOptions; import com.apify.client.http.ApifyApiException; +import com.apify.client.http.Timeout; import com.apify.client.log.StreamedLog; import com.apify.client.log.StreamedLogOptions; import com.apify.client.run.ActorRun; @@ -10,6 +11,8 @@ import java.time.Duration; import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -57,10 +60,10 @@ private RunStartSupport() {} /** * Starts a run on {@code ctx}'s {@code runs} sub-resource and completes with the created run as - * soon as it exists (no waiting). {@code input} is any JSON-serializable value, or {@code null} - * for no input. {@code applyParams} applies every start-option query parameter (build, memory, - * timeout, ...) the caller's options type configures; {@code contentType} is the input body's - * content type. + * soon as it exists (no waiting). {@code input} is any JSON-serializable value, a {@code byte[]} + * or {@link String} sent as it is, or {@code null} for no input. {@code applyParams} applies + * every start-option query parameter (build, memory, timeout, ...) the caller's options type + * configures; {@code contentType} is the input body's content type. * * @param waitForResourcesSecs mirrors {@link ActorStartOptions#waitForResources(Long)}: {@code * null} does not retry a start rejected for lack of resources (the default); {@link @@ -73,18 +76,41 @@ public static CompletableFuture start( Consumer applyParams, String contentType, Long waitForResourcesSecs) { + return start(ctx, input, applyParams, contentType, waitForResourcesSecs, null); + } + + /** + * As {@link #start(ResourceContext, Object, Consumer, String, Long)}, with an explicit request + * timeout; {@code null} picks the {@link Timeout#MEDIUM} tier extended by the start's {@code + * waitForFinish} hold, so the client does not abort a request the API is still holding. + */ + private static CompletableFuture start( + ResourceContext ctx, + Object input, + Consumer applyParams, + String contentType, + Long waitForResourcesSecs, + Timeout timeout) { QueryParams params = new QueryParams(); applyParams.accept(params); - byte[] body = input == null ? null : Json.toBytes(input); + byte[] body = Json.toRequestBody(input); + Timeout requestTimeout = timeout; + if (requestTimeout == null) { + HttpClientCore.WaitHold hold = + ctx.http.waitHold(Timeout.MEDIUM, params.getLong("waitForFinish")); + params.setLong("waitForFinish", hold.waitForFinishSecs()); + requestTimeout = hold.timeout(); + } if (waitForResourcesSecs == null) { - return ctx.postWithBody("runs", params, body, contentType, ActorRun.class); + return ctx.postWithBody("runs", params, body, contentType, ActorRun.class, requestTimeout); } long deadlineMillis = waitForResourcesSecs == ActorStartOptions.WAIT_FOR_RESOURCES_INDEFINITELY ? Long.MAX_VALUE : System.currentTimeMillis() + Math.min(Math.max(0, waitForResourcesSecs), MAX_WAIT_FOR_RESOURCES_SECS) * 1000L; - return startRetryingForResources(ctx, params, body, contentType, deadlineMillis); + return startRetryingForResources( + ctx, params, body, contentType, requestTimeout, deadlineMillis); } /** @@ -98,8 +124,9 @@ private static CompletableFuture startRetryingForResources( QueryParams params, byte[] body, String contentType, + Timeout timeout, long deadlineMillis) { - return ctx.postWithBody("runs", params, body, contentType, ActorRun.class) + return ctx.postWithBody("runs", params, body, contentType, ActorRun.class, timeout) .handle( (run, error) -> { if (error == null) { @@ -125,7 +152,7 @@ private static CompletableFuture startRetryingForResources( .thenCompose( unused -> startRetryingForResources( - ctx, params, body, contentType, deadlineMillis)); + ctx, params, body, contentType, timeout, deadlineMillis)); }) .thenCompose(future -> future); } @@ -144,7 +171,7 @@ public static CompletableFuture call( String contentType, Long waitSecs, Long waitForResourcesSecs) { - return start(ctx, input, applyParams, contentType, waitForResourcesSecs) + return start(ctx, input, applyParams, contentType, waitForResourcesSecs, Timeout.NO_TIMEOUT) .thenCompose(run -> root.run(run.getId()).waitForFinish(waitSecs)); } @@ -173,40 +200,62 @@ public static CompletableFuture callWithLogStreaming( StreamedLogOptions logOptions, Long waitSecs, Long waitForResourcesSecs) { - return start(ctx, input, applyParams, contentType, waitForResourcesSecs) - .thenCompose( - run -> { - RunClient runClient = root.run(run.getId()); - CompletableFuture streamedLogFuture = - logStreamingEnabled - ? startStreamedLogQuietly(runClient, logOptions) - : CompletableFuture.completedFuture(null); - return streamedLogFuture.thenCompose( - streamedLog -> - runClient - .waitForFinish(waitSecs) - .whenComplete( - (result, error) -> { - if (streamedLog != null) { - streamedLog.close(); - } - })); - }); + AtomicReference opened = new AtomicReference<>(); + AtomicBoolean finished = new AtomicBoolean(); + CompletableFuture result = + start(ctx, input, applyParams, contentType, waitForResourcesSecs, Timeout.NO_TIMEOUT) + .thenCompose( + run -> { + RunClient runClient = root.run(run.getId()); + CompletableFuture logStarted = + logStreamingEnabled + ? startStreamedLogQuietly(runClient, logOptions, opened, finished) + : CancellableFuture.completed(null); + return logStarted.thenCompose(unused -> runClient.waitForFinish(waitSecs)); + }); + // Closed from a callback of the returned future itself, which runs whether the call finishes, + // fails or is cancelled (a stage that is cancelled never runs its own callbacks). + result.whenComplete( + (run, error) -> { + finished.set(true); + StreamedLog streamedLog = opened.get(); + if (streamedLog != null) { + streamedLog.close(); + } + }); + return result; } /** * Starts {@code call}'s default log streaming, swallowing (rather than propagating) any failure - * to open the live log stream, so a transient log-endpoint issue cannot abort the run itself. - * Completes with the started {@link StreamedLog}, or {@code null} if it could not be started. + * to open the live log stream, so a transient log-endpoint issue cannot abort the run itself. The + * {@link StreamedLog} is handed to {@code opened} as soon as it exists, before it is started, so + * a call that ends while the stream is still opening can close it; one that has already ended + * ({@code finished}) closes it right away. */ - private static CompletableFuture startStreamedLogQuietly( - RunClient runClient, StreamedLogOptions logOptions) { + private static CompletableFuture startStreamedLogQuietly( + RunClient runClient, + StreamedLogOptions logOptions, + AtomicReference opened, + AtomicBoolean finished) { CompletableFuture created = logOptions != null ? runClient.getStreamedLog(logOptions) : runClient.getStreamedLog(); return created .thenCompose( - streamedLog -> - streamedLog.start().handle((unused, error) -> error == null ? streamedLog : null)) - .exceptionally(error -> null); + streamedLog -> { + opened.set(streamedLog); + if (finished.get()) { + streamedLog.close(); + return CancellableFuture.completed(null); + } + CompletableFuture started = streamedLog.start(); + if (finished.get()) { + // The call ended between the check above and the start: close() while the stream + // is opening makes it launch no reader. + streamedLog.close(); + } + return started; + }) + .handle((unused, error) -> (Void) null); } } diff --git a/src/main/java/com/apify/client/internal/UrlNormalizer.java b/src/main/java/com/apify/client/internal/UrlNormalizer.java new file mode 100644 index 0000000..83c8933 --- /dev/null +++ b/src/main/java/com/apify/client/internal/UrlNormalizer.java @@ -0,0 +1,194 @@ +package com.apify.client.internal; + +import java.net.IDN; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Normalizes absolute URLs the way the WHATWG URL serializer does, so every Apify client returns + * the same string for the same {@code format: uri} field of the API: lowercase scheme and host, + * punycode host, default port dropped, an empty path serialized as {@code /}, dot segments + * resolved, and unsafe characters percent-encoded. Internal to the client. + */ +public final class UrlNormalizer { + + /** + * {@code scheme://authority[path][?query][#fragment]}, the only shape the spec's URL fields have. + */ + private static final Pattern URL = + Pattern.compile( + "^(?[A-Za-z][A-Za-z0-9+.-]*)://(?[^/?#]*)(?[^?#]*)" + + "(?\\?[^#]*)?(?#.*)?$", + Pattern.DOTALL); + + /** The highest TCP port; a larger one makes the URL invalid. */ + private static final int MAX_PORT = 65535; + + /** Schemes with a default port and a mandatory (possibly empty) path. */ + private static final Map SPECIAL_SCHEMES = + Map.of("http", 80, "https", 443, "ws", 80, "wss", 443, "ftp", 21); + + private UrlNormalizer() {} + + /** + * Returns the normalized form of {@code value}. A {@code null} value, and a value that is not an + * absolute {@code scheme://host...} URL, is returned unchanged: the client never fails to read a + * response because of one odd URL field. + */ + public static String normalize(String value) { + if (value == null) { + return null; + } + Matcher m = URL.matcher(value.strip()); + if (!m.matches()) { + return value; + } + String scheme = m.group("scheme").toLowerCase(Locale.ROOT); + Integer defaultPort = SPECIAL_SCHEMES.get(scheme); + boolean special = defaultPort != null; + + String authority = m.group("authority"); + String userInfo = ""; + int at = authority.lastIndexOf('@'); + if (at >= 0) { + String encoded = encodeUserInfo(authority.substring(0, at)); + userInfo = encoded.isEmpty() ? "" : encoded + "@"; + authority = authority.substring(at + 1); + } + String host = authority; + String port = ""; + int colon = authority.lastIndexOf(':'); + boolean hasPortSeparator = colon >= 0 && authority.indexOf(']') < colon; + if (hasPortSeparator) { + host = authority.substring(0, colon); + port = authority.substring(colon + 1); + } + // An empty host is valid only for a non-special scheme without credentials or a port separator. + if (host.isEmpty() && (special || at >= 0 || hasPortSeparator)) { + return value; + } + host = normalizeHost(host); + if (host == null || !port.matches("\\d*")) { + return value; + } + // Leading zeros do not count; a port with more digits than MAX_PORT has is above it, which also + // keeps parseInt from overflowing. + String digits = port.replaceFirst("^0+(?=\\d)", ""); + if (digits.length() > String.valueOf(MAX_PORT).length()) { + return value; + } + port = ""; + if (!digits.isEmpty()) { + int number = Integer.parseInt(digits); + if (number > MAX_PORT) { + return value; + } + if (defaultPort == null || number != defaultPort) { + port = ":" + number; + } + } + + String rawPath = special ? m.group("path").replace('\\', '/') : m.group("path"); + String path = + special ? resolveDotSegments(encode(rawPath, PATH_UNSAFE)) : encode(rawPath, PATH_UNSAFE); + if (special && path.isEmpty()) { + path = "/"; + } + String rawQuery = m.group("query"); + String query = + rawQuery == null ? "" : encode(rawQuery, special ? SPECIAL_QUERY_UNSAFE : QUERY_UNSAFE); + String rawFragment = m.group("fragment"); + String fragment = rawFragment == null ? "" : encode(rawFragment, FRAGMENT_UNSAFE); + return scheme + "://" + userInfo + host + port + path + query + fragment; + } + + /** + * Encodes the {@code username[:password]} part of the authority: each of the two is encoded on + * its own, so the separator between them stays a colon, and an empty one is dropped. + */ + private static String encodeUserInfo(String userInfo) { + int colon = userInfo.indexOf(':'); + String username = encode(colon < 0 ? userInfo : userInfo.substring(0, colon), USERINFO_UNSAFE); + String password = colon < 0 ? "" : encode(userInfo.substring(colon + 1), USERINFO_UNSAFE); + // An empty username or password is dropped, along with the separator when the password is. + return password.isEmpty() ? username : username + ":" + password; + } + + /** + * Lowercases the host and converts a non-ASCII one to punycode; {@code null} if it is invalid. + */ + private static String normalizeHost(String host) { + if (host.startsWith("[")) { + return host.toLowerCase(Locale.ROOT); + } + try { + return IDN.toASCII(host, IDN.ALLOW_UNASSIGNED).toLowerCase(Locale.ROOT); + } catch (IllegalArgumentException e) { + return null; + } + } + + /** Resolves {@code .} and {@code ..} segments of an already-encoded path. */ + private static String resolveDotSegments(String path) { + if (path.isEmpty()) { + return path; + } + List out = new ArrayList<>(); + String[] segments = path.substring(1).split("/", -1); + for (int i = 0; i < segments.length; i++) { + String seg = segments[i]; + boolean last = i == segments.length - 1; + String lower = seg.toLowerCase(Locale.ROOT); + boolean dotDot = + lower.equals("..") + || lower.equals(".%2e") + || lower.equals("%2e.") + || lower.equals("%2e%2e"); + boolean dot = lower.equals(".") || lower.equals("%2e"); + if (dotDot) { + if (!out.isEmpty()) { + out.remove(out.size() - 1); + } + if (last) { + out.add(""); + } + } else if (dot) { + if (last) { + out.add(""); + } + } else { + out.add(seg); + } + } + return "/" + String.join("/", out); + } + + private static final String USERINFO_UNSAFE = " \"#<>?`{}/:;=@[\\]^|"; + private static final String PATH_UNSAFE = " \"#<>?`{}"; + private static final String QUERY_UNSAFE = " \"#<>"; + private static final String SPECIAL_QUERY_UNSAFE = " \"#<>'"; + private static final String FRAGMENT_UNSAFE = " \"<>`"; + + /** + * Percent-encodes the ASCII characters in {@code unsafe}, control characters and every non-ASCII + * character (as UTF-8). An existing {@code %XX} escape is kept as it is. + */ + private static String encode(String s, String unsafe) { + StringBuilder sb = new StringBuilder(s.length()); + byte[] bytes = s.getBytes(StandardCharsets.UTF_8); + for (byte b : bytes) { + int c = b & 0xFF; + if (c < 0x20 || c >= 0x7F || unsafe.indexOf(c) >= 0) { + sb.append('%').append(String.format("%02X", c)); + } else { + sb.append((char) c); + } + } + return sb.toString(); + } +} diff --git a/src/main/java/com/apify/client/keyvalue/KeyValueStore.java b/src/main/java/com/apify/client/keyvalue/KeyValueStore.java index e3202ea..73555b9 100644 --- a/src/main/java/com/apify/client/keyvalue/KeyValueStore.java +++ b/src/main/java/com/apify/client/keyvalue/KeyValueStore.java @@ -1,7 +1,9 @@ package com.apify.client.keyvalue; import com.apify.client.ApifyResource; +import com.apify.client.internal.NormalizedUrlDeserializer; import java.time.Instant; +import tools.jackson.databind.annotation.JsonDeserialize; /** A key-value store holds arbitrary data records. */ public final class KeyValueStore extends ApifyResource { @@ -10,6 +12,16 @@ public final class KeyValueStore extends ApifyResource { private String userId; private Instant createdAt; private Instant modifiedAt; + private String title; + + @JsonDeserialize(using = NormalizedUrlDeserializer.class) + private String consoleUrl; + + @JsonDeserialize(using = NormalizedUrlDeserializer.class) + private String keysPublicUrl; + + @JsonDeserialize(using = NormalizedUrlDeserializer.class) + private String recordsPublicUrl; /** The unique store ID. */ public String getId() { @@ -35,4 +47,29 @@ public Instant getCreatedAt() { public Instant getModifiedAt() { return modifiedAt; } + + /** The human-readable title of the store. */ + public String getTitle() { + return title; + } + + /** The URL of the store in the Apify Console (normalized). */ + public String getConsoleUrl() { + return consoleUrl; + } + + /** + * A public URL of the store keys (signed when the store is not publicly accessible; normalized). + */ + public String getKeysPublicUrl() { + return keysPublicUrl; + } + + /** + * A public base URL of the store records (signed when the store is not publicly accessible; + * normalized). + */ + public String getRecordsPublicUrl() { + return recordsPublicUrl; + } } diff --git a/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java b/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java index 4b7e3fe..b65c1b9 100644 --- a/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java +++ b/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java @@ -1,5 +1,6 @@ package com.apify.client.keyvalue; +import com.apify.client.http.Timeout; import com.apify.client.internal.ApiPaths; import com.apify.client.internal.Extras; import com.apify.client.internal.HttpClientCore; @@ -8,7 +9,6 @@ import com.apify.client.internal.QueryParams; import com.apify.client.internal.ResourceContext; import com.apify.client.internal.Signatures; -import java.time.Duration; import java.util.List; import java.util.Map; import java.util.Optional; @@ -65,7 +65,7 @@ public CompletableFuture> get() { /** Updates the store metadata (e.g. name) and returns the updated object. */ public CompletableFuture update(Object newFields) { - return ctx.updateResource("", newFields, KeyValueStore.class); + return ctx.updateResource("", newFields, KeyValueStore.class, Timeout.LONG); } /** @@ -80,7 +80,7 @@ public CompletableFuture delete() { public CompletableFuture listKeys(ListKeysOptions options) { QueryParams params = new QueryParams(); options.apply(params); - return ctx.getResourceRequired("keys", params, KeyValueStoreKeysPage.class); + return ctx.getResourceRequired("keys", params, KeyValueStoreKeysPage.class, Timeout.MEDIUM); } /** @@ -150,6 +150,7 @@ private final class Session implements Flow.Subscription { private final AtomicLong requested = new AtomicLong(); private final AtomicBoolean draining = new AtomicBoolean(); private final AtomicBoolean cancelled = new AtomicBoolean(); + private volatile CompletableFuture inFlight; private final AtomicBoolean terminated = new AtomicBoolean(); // Only touched from within the draining-guarded section; see AsyncPaginatedPublisher.Session. @@ -182,6 +183,10 @@ public void request(long n) { @Override public void cancel() { cancelled.set(true); + CompletableFuture fetch = inFlight; + if (fetch != null) { + fetch.cancel(true); + } } private void drain() { @@ -228,23 +233,25 @@ private void fetchPage() { params.addLong("limit", pageLimit); params.addString("exclusiveStartKey", cursor); params.extend(filters); - ctx.getResourceRequired("keys", params, KeyValueStoreKeysPage.class) - .whenComplete( - (page, error) -> { - if (cancelled.get()) { - draining.set(false); - return; - } - if (error != null) { - if (terminated.compareAndSet(false, true)) { - subscriber.onError(HttpClientCore.unwrapCompletion(error)); - } - draining.set(false); - return; - } - applyPage(page); - drainLoop(); - }); + CompletableFuture fetch = + ctx.getResourceRequired("keys", params, KeyValueStoreKeysPage.class, Timeout.MEDIUM); + inFlight = fetch; + fetch.whenComplete( + (page, error) -> { + if (cancelled.get()) { + draining.set(false); + return; + } + if (error != null) { + if (terminated.compareAndSet(false, true)) { + subscriber.onError(HttpClientCore.unwrapCompletion(error)); + } + draining.set(false); + return; + } + applyPage(page); + drainLoop(); + }); } private void applyPage(KeyValueStoreKeysPage page) { @@ -275,7 +282,8 @@ private void applyPage(KeyValueStoreKeysPage page) { /** Reports whether a record with the given key exists. */ public CompletableFuture recordExists(String key) { - return ctx.headExists("records/" + ResourceContext.encodePathSegment(key), new QueryParams()); + return ctx.headExists( + "records/" + ResourceContext.encodePathSegment(key), new QueryParams(), Timeout.LONG); } /** @@ -291,7 +299,7 @@ public CompletableFuture> getRecord( String key, GetRecordOptions options) { QueryParams params = new QueryParams(); options.apply(params); - return ctx.getRaw("records/" + ResourceContext.encodePathSegment(key), params) + return ctx.getRaw("records/" + ResourceContext.encodePathSegment(key), params, Timeout.LONG) .thenApply( resp -> { if (resp == null) { @@ -326,10 +334,7 @@ public CompletableFuture setRecord( } Map extraHeaders = contentEncoding == null ? null : Map.of(HttpHeaders.CONTENT_ENCODING, contentEncoding); - Duration timeout = - options.timeoutSecsValue() != null - ? Duration.ofSeconds(options.timeoutSecsValue()) - : ctx.http.baseRequestTimeout(); + Timeout timeout = options.timeoutValue() != null ? options.timeoutValue() : Timeout.LONG; return ctx.putRaw( "records/" + ResourceContext.encodePathSegment(key), new QueryParams(), diff --git a/src/main/java/com/apify/client/keyvalue/KeyValueStoreKey.java b/src/main/java/com/apify/client/keyvalue/KeyValueStoreKey.java index 6b57461..38a691d 100644 --- a/src/main/java/com/apify/client/keyvalue/KeyValueStoreKey.java +++ b/src/main/java/com/apify/client/keyvalue/KeyValueStoreKey.java @@ -1,12 +1,17 @@ package com.apify.client.keyvalue; import com.apify.client.ApifyResource; +import com.apify.client.internal.NormalizedUrlDeserializer; +import tools.jackson.databind.annotation.JsonDeserialize; /** A single key listed from a key-value store. */ public final class KeyValueStoreKey extends ApifyResource { private String key; private long size; + @JsonDeserialize(using = NormalizedUrlDeserializer.class) + private String recordPublicUrl; + /** The record key. */ public String getKey() { return key; @@ -16,4 +21,9 @@ public String getKey() { public long getSize() { return size; } + + /** A public URL of the record (signed when the store is not publicly accessible; normalized). */ + public String getRecordPublicUrl() { + return recordPublicUrl; + } } diff --git a/src/main/java/com/apify/client/keyvalue/SetRecordOptions.java b/src/main/java/com/apify/client/keyvalue/SetRecordOptions.java index 24891cd..3017815 100644 --- a/src/main/java/com/apify/client/keyvalue/SetRecordOptions.java +++ b/src/main/java/com/apify/client/keyvalue/SetRecordOptions.java @@ -1,22 +1,34 @@ package com.apify.client.keyvalue; +import com.apify.client.http.Timeout; + /** * Write options for {@link KeyValueStoreClient#setRecord(String, byte[], String, * SetRecordOptions)}, mirroring the reference client's {@code timeoutSecs}/{@code * doNotRetryTimeouts}/{@code contentEncoding}. */ public final class SetRecordOptions { - private Long timeoutSecs; + private Timeout timeout; private Boolean doNotRetryTimeouts; private String contentEncoding; /** - * Per-request timeout for the upload, in seconds. Use it to shorten the wait for this upload; - * defaults to (and is capped at) the client's configured overall request timeout, so a value - * larger than that timeout has no effect. + * Per-request timeout for the upload, in seconds. Shorthand for {@link #timeout(Timeout) + * timeout(Timeout.ofSeconds(timeoutSecs))}; a {@code null} or non-positive value leaves the + * default. */ public SetRecordOptions timeoutSecs(Long timeoutSecs) { - this.timeoutSecs = timeoutSecs; + this.timeout = timeoutSecs == null || timeoutSecs <= 0 ? null : Timeout.ofSeconds(timeoutSecs); + return this; + } + + /** + * Timeout of the upload request: a tier, an exact duration or {@link Timeout#NO_TIMEOUT}. + * Defaults to {@link Timeout#LONG}. A value above the client's {@code timeoutMax} is capped at + * it. + */ + public SetRecordOptions timeout(Timeout timeout) { + this.timeout = timeout; return this; } @@ -46,8 +58,8 @@ public SetRecordOptions contentEncoding(String contentEncoding) { return this; } - Long timeoutSecsValue() { - return timeoutSecs; + Timeout timeoutValue() { + return timeout; } boolean doNotRetryTimeoutsValue() { diff --git a/src/main/java/com/apify/client/log/LogClient.java b/src/main/java/com/apify/client/log/LogClient.java index 7d5edae..e8e0def 100644 --- a/src/main/java/com/apify/client/log/LogClient.java +++ b/src/main/java/com/apify/client/log/LogClient.java @@ -1,5 +1,6 @@ package com.apify.client.log; +import com.apify.client.http.Timeout; import com.apify.client.internal.ApiPaths; import com.apify.client.internal.HttpClientCore; import com.apify.client.internal.QueryParams; @@ -49,7 +50,7 @@ public CompletableFuture> get() { public CompletableFuture> get(LogOptions options) { QueryParams params = new QueryParams(); options.apply(params); - return ctx.getRawUnlessAmbiguous("", params) + return ctx.getRawUnlessAmbiguous("", params, Timeout.LONG) .thenApply( resp -> resp == null @@ -71,7 +72,7 @@ public CompletableFuture> getAsBytes() { public CompletableFuture> getAsBytes(LogOptions options) { QueryParams params = new QueryParams(); options.apply(params); - return ctx.getRawUnlessAmbiguous("", params) + return ctx.getRawUnlessAmbiguous("", params, Timeout.LONG) .thenApply(resp -> resp == null ? Optional.empty() : Optional.of(resp.body())); } diff --git a/src/main/java/com/apify/client/log/StreamedLog.java b/src/main/java/com/apify/client/log/StreamedLog.java index c2497d5..ce39d38 100644 --- a/src/main/java/com/apify/client/log/StreamedLog.java +++ b/src/main/java/com/apify/client/log/StreamedLog.java @@ -2,6 +2,7 @@ import com.apify.client.http.ApifyApiException; import com.apify.client.internal.Async; +import com.apify.client.internal.CancellableFuture; import com.apify.client.internal.HttpClientCore; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -103,6 +104,12 @@ public final class StreamedLog implements AutoCloseable { */ private boolean starting; + /** + * Set by {@link #close()} while a {@link #start()} is still opening its stream: the stream is + * then closed as soon as it opens, and no reader thread is launched. + */ + private boolean closeRequested; + public StreamedLog(LogClient logClient, Consumer destination, boolean fromStart) { this.logClient = logClient; this.destination = destination; @@ -119,6 +126,10 @@ public StreamedLog(LogClient logClient, Consumer destination, boolean fr * leaving the reader blocked on a live read that never returns and {@code join()} hanging * forever. * + *

Cancelling the returned future cancels the stream-open request. {@link #close()} while the + * stream is still opening closes the stream once it opens, launches no reader and cancels the + * returned future. + * * @throws IllegalStateException if redirection is already running (or a previous {@link #start()} * call's stream-open is still in flight) * @implNote the returned future completes exceptionally with an {@link ApifyApiException} if the @@ -132,26 +143,47 @@ public synchronized CompletableFuture start() { starting = true; stopLogging = false; forwardingFailed = false; - return logClient.stream(new LogOptions().raw(true)) - .handle( - (stream, error) -> { - synchronized (StreamedLog.this) { - starting = false; - if (error != null) { - Throwable cause = HttpClientCore.unwrapCompletion(error); - if (cause instanceof RuntimeException runtimeException) { - throw runtimeException; - } - throw new IllegalStateException("failed to open log stream", cause); - } - activeStream = stream; - Thread thread = new Thread(this::streamLog, "apify-streamed-log"); - thread.setDaemon(true); - streamingThread = thread; - thread.start(); - } - return null; - }); + CancellableFuture started = new CancellableFuture<>(); + CompletableFuture opening = logClient.stream(new LogOptions().raw(true)); + // Cancelling the returned future cancels the stream-open request. The callback below hangs off + // the open request itself, so it runs however that ends and always resets the opening state. + started.link(opening); + opening.whenComplete( + (stream, error) -> { + // The outcome is decided under the lock but delivered after it is released: the stages + // chained on the returned future run on this thread, and one of them may wait for a + // destination consumer that itself calls close() (which takes the lock). + Runnable outcome; + synchronized (StreamedLog.this) { + starting = false; + if (error != null) { + closeRequested = false; + Throwable cause = HttpClientCore.unwrapCompletion(error); + Throwable failure = + cause instanceof RuntimeException + ? cause + : new IllegalStateException("failed to open log stream", cause); + outcome = () -> started.completeExceptionally(failure); + } else if (closeRequested || started.isCancelled()) { + // Closed (or cancelled) while the stream was opening: do not launch a reader for it. + closeRequested = false; + outcome = + () -> { + closeQuietly(stream); + started.cancel(true); + }; + } else { + activeStream = stream; + Thread thread = new Thread(this::streamLog, "apify-streamed-log"); + thread.setDaemon(true); + streamingThread = thread; + thread.start(); + outcome = () -> started.complete(null); + } + } + outcome.run(); + }); + return started; } /** @@ -176,12 +208,17 @@ public synchronized void stop() { } /** - * Stops redirection if it is still running; a no-op otherwise. Fully idempotent and safe under a - * concurrent {@link #stop()} or a double {@code close()}: the running-check and the stop happen - * atomically under the monitor, so unlike {@link #stop()} this never throws. + * Stops redirection if it is still running; a no-op otherwise. If {@link #start()} is still + * opening the log stream, the stream is closed as soon as it opens, no reader is launched and the + * future {@code start()} returned is cancelled. Fully idempotent and safe under a concurrent + * {@link #stop()} or a double {@code close()}: the running-check and the stop happen atomically + * under the monitor, so unlike {@link #stop()} this never throws. */ @Override public synchronized void close() { + if (starting) { + closeRequested = true; + } if (streamingThread != null) { stopStreaming(); } diff --git a/src/main/java/com/apify/client/requestqueue/RequestQueue.java b/src/main/java/com/apify/client/requestqueue/RequestQueue.java index d52c1eb..c380ab0 100644 --- a/src/main/java/com/apify/client/requestqueue/RequestQueue.java +++ b/src/main/java/com/apify/client/requestqueue/RequestQueue.java @@ -1,7 +1,9 @@ package com.apify.client.requestqueue; import com.apify.client.ApifyResource; +import com.apify.client.internal.NormalizedUrlDeserializer; import java.time.Instant; +import tools.jackson.databind.annotation.JsonDeserialize; /** A request queue stores URLs to be crawled. */ public final class RequestQueue extends ApifyResource { @@ -11,6 +13,10 @@ public final class RequestQueue extends ApifyResource { private Instant createdAt; private Instant modifiedAt; private long totalRequestCount; + private String title; + + @JsonDeserialize(using = NormalizedUrlDeserializer.class) + private String consoleUrl; /** The unique queue ID. */ public String getId() { @@ -41,4 +47,14 @@ public Instant getModifiedAt() { public long getTotalRequestCount() { return totalRequestCount; } + + /** The human-readable title of the queue. */ + public String getTitle() { + return title; + } + + /** The URL of the queue in the Apify Console (normalized). */ + public String getConsoleUrl() { + return consoleUrl; + } } diff --git a/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java b/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java index 2363e01..b87200b 100644 --- a/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java +++ b/src/main/java/com/apify/client/requestqueue/RequestQueueClient.java @@ -2,12 +2,15 @@ import com.apify.client.http.ApifyApiException; import com.apify.client.http.ApifyTransportException; +import com.apify.client.http.Timeout; import com.apify.client.internal.ApiPaths; import com.apify.client.internal.Async; +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.ResourceContext; +import java.io.ByteArrayOutputStream; import java.time.Duration; import java.util.ArrayList; import java.util.HashSet; @@ -126,7 +129,7 @@ public CompletableFuture listHead(Long limit) { QueryParams params = new QueryParams(); params.addLong("limit", limit); withClientKey(params); - return ctx.getResourceRequired("head", params, RequestQueueHead.class); + return ctx.getResourceRequired("head", params, RequestQueueHead.class, Timeout.SHORT); } /** Adds a request to the queue. If {@code forefront} is true, it is added to the front. */ @@ -140,7 +143,8 @@ public CompletableFuture addRequest( params, Json.toBytes(request), ResourceContext.CONTENT_TYPE_JSON, - RequestQueueOperationInfo.class); + RequestQueueOperationInfo.class, + Timeout.SHORT); } /** Fetches a request by ID, or empty if it does not exist. */ @@ -148,7 +152,8 @@ public CompletableFuture> getRequest(String id) { return ctx.getResource( "requests/" + ResourceContext.encodePathSegment(id), new QueryParams(), - RequestQueueRequest.class); + RequestQueueRequest.class, + Timeout.SHORT); } /** @@ -165,7 +170,8 @@ public CompletableFuture updateRequest( params, Json.toBytes(request), ResourceContext.CONTENT_TYPE_JSON, - RequestQueueOperationInfo.class); + RequestQueueOperationInfo.class, + Timeout.MEDIUM); } /** Deletes a request by ID. */ @@ -174,7 +180,7 @@ public CompletableFuture deleteRequest(String id) { String url = ctx.mergedParams(params) .applyToUrl(ctx.subUrl("requests/" + ResourceContext.encodePathSegment(id))); - return http.call("DELETE", url, null, "", http.baseRequestTimeout()) + return http.call("DELETE", url, null, "", Timeout.SHORT) .handle( (resp, error) -> { if (error == null) { @@ -199,7 +205,8 @@ public CompletableFuture listAndLockHead(long lockSecs, QueryParams params = new QueryParams(); params.addLong("lockSecs", lockSecs).addLong("limit", limit); withClientKey(params); - return ctx.postWithBody("head/lock", params, null, "", LockedRequestQueueHead.class); + return ctx.postWithBody( + "head/lock", params, null, "", LockedRequestQueueHead.class, Timeout.MEDIUM); } /** @@ -245,11 +252,18 @@ public CompletableFuture batchAddRequests( MAX_PAYLOAD_SIZE_BYTES - (long) Math.ceil(MAX_PAYLOAD_SIZE_BYTES * PAYLOAD_SAFETY_BUFFER_PERCENT); - List> chunks = new ArrayList<>(); - for (int start = 0; start < requests.size(); ) { - int end = Math.min(start + MAX_REQUESTS_PER_BATCH, requests.size()); - List countSlice = requests.subList(start, end); - List chunk = sliceByByteLength(countSlice, payloadSizeLimitBytes, start); + // Serialize every request exactly once: the byte lengths decide the chunk boundaries and the + // same bytes are joined into each chunk's request body (and into the body of a retry). + List entries = new ArrayList<>(requests.size()); + for (RequestQueueRequest request : requests) { + entries.add(new SerializedRequest(request, Json.toBytes(request))); + } + + List> chunks = new ArrayList<>(); + for (int start = 0; start < entries.size(); ) { + int end = Math.min(start + MAX_REQUESTS_PER_BATCH, entries.size()); + List countSlice = entries.subList(start, end); + List chunk = sliceByByteLength(countSlice, payloadSizeLimitBytes, start); chunks.add(new ArrayList<>(chunk)); start += chunk.size(); } @@ -267,7 +281,7 @@ public CompletableFuture batchAddRequests( for (int w = 0; w < workerCount; w++) { workers.add(runBatchAddWorker(chunks, nextChunkIndex, resultsByChunk, forefront, options)); } - return CompletableFuture.allOf(workers.toArray(CompletableFuture[]::new)) + return CancellableFuture.allOf(workers) .thenApply( unused -> { BatchAddResult merged = new BatchAddResult(); @@ -286,7 +300,7 @@ public CompletableFuture batchAddRequests( * worker is a chain of async continuations, not a blocked thread. */ private CompletableFuture runBatchAddWorker( - List> chunks, + List> chunks, AtomicInteger nextChunkIndex, BatchAddResult[] resultsByChunk, boolean forefront, @@ -313,17 +327,17 @@ private CompletableFuture runBatchAddWorker( * @throws IllegalArgumentException if any individual request's own JSON encoding already exceeds * {@code maxByteLength} (no chunk size could ever fit it) */ - private static List sliceByByteLength( - List requests, long maxByteLength, int startIndex) { - if (jsonByteLength(requests) < maxByteLength) { + private static List sliceByByteLength( + List requests, long maxByteLength, int startIndex) { + if (jsonArrayByteLength(requests) < maxByteLength) { return requests; } - List sliced = new ArrayList<>(); + List sliced = new ArrayList<>(); long byteLength = EMPTY_JSON_ARRAY_BYTES; for (int i = 0; i < requests.size(); i++) { - RequestQueueRequest request = requests.get(i); - long itemBytes = jsonByteLength(request); + SerializedRequest request = requests.get(i); + long itemBytes = request.json().length; if (itemBytes > maxByteLength) { throw new IllegalArgumentException( "RequestQueueClient.batchAddRequests: the request at index " @@ -334,8 +348,8 @@ private static List sliceByByteLength( } // Every element but the first is preceded by a comma in the serialized array (`[a,b,c]` has // count-1 commas); account for it here so this incremental estimate agrees exactly with - // jsonByteLength(requests)'s full-array measurement above, rather than relying on being - // slightly under it. + // jsonArrayByteLength's full-array measurement above, rather than relying on being slightly + // under it. long commaBytes = sliced.isEmpty() ? 0 : 1; if (byteLength + commaBytes + itemBytes >= maxByteLength) { break; @@ -352,9 +366,30 @@ private static List sliceByByteLength( return sliced; } - /** The byte length of {@code value}'s JSON encoding, as sent on the wire. */ - private static long jsonByteLength(Object value) { - return Json.toBytes(value).length; + /** A request together with its JSON encoding, so it is serialized only once. */ + private record SerializedRequest(RequestQueueRequest request, byte[] json) {} + + /** The byte length of the JSON array that joins the given requests' encodings. */ + private static long jsonArrayByteLength(List requests) { + long length = EMPTY_JSON_ARRAY_BYTES; + for (SerializedRequest request : requests) { + length += request.json().length; + } + return requests.isEmpty() ? length : length + requests.size() - 1; + } + + /** The JSON array of the given requests' encodings, as sent in a batch request body. */ + private static byte[] jsonArray(List requests) { + ByteArrayOutputStream out = new ByteArrayOutputStream((int) jsonArrayByteLength(requests)); + out.write('['); + for (int i = 0; i < requests.size(); i++) { + if (i > 0) { + out.write(','); + } + out.writeBytes(requests.get(i).json()); + } + out.write(']'); + return out.toByteArray(); } /** @@ -368,7 +403,7 @@ private static long jsonByteLength(Object value) { * ApifyApiException} and {@link com.apify.client.http.ApifyTransportException}. */ private CompletableFuture batchAddChunkWithRetries( - List chunk, boolean forefront, BatchAddRequestsOptions options) { + List chunk, boolean forefront, BatchAddRequestsOptions options) { int maxRetries = options.maxUnprocessedRequestsRetriesValue(); long minDelayMillis = options.minDelayBetweenUnprocessedRequestsRetriesMillisValue(); return batchAddAttempt(chunk, chunk, List.of(), forefront, 0, maxRetries, minDelayMillis); @@ -376,8 +411,8 @@ private CompletableFuture batchAddChunkWithRetries( /** One attempt of the retry loop backing {@link #batchAddChunkWithRetries}. */ private CompletableFuture batchAddAttempt( - List chunk, - List toSend, + List chunk, + List toSend, List processedSoFar, boolean forefront, int attempt, @@ -404,7 +439,7 @@ private CompletableFuture batchAddAttempt( if (response != null) { processed.addAll(response.getProcessedRequests()); } - List stillRemaining = requestsNotYetProcessed(chunk, processed); + List stillRemaining = requestsNotYetProcessed(chunk, processed); // Any client-level failure (response == null, i.e. the batchAddChunk call above // failed) stops retrying this chunk immediately; whatever has not been confirmed // processed is reported as unprocessed below, never thrown - matching the reference @@ -429,27 +464,30 @@ private CompletableFuture batchAddAttempt( } private static BatchAddResult finish( - List chunk, List processed) { + List chunk, List processed) { BatchAddResult result = new BatchAddResult(); result.setProcessedRequests(processed); // Unprocessed = everything sent minus everything acknowledged processed. Computing it here // (instead of trusting the last response) stays correct even if the API returns fewer entries. - result.setUnprocessedRequests(requestsNotYetProcessed(chunk, processed)); + result.setUnprocessedRequests( + requestsNotYetProcessed(chunk, processed).stream() + .map(SerializedRequest::request) + .toList()); return result; } - private static List requestsNotYetProcessed( - List chunk, List processed) { + private static List requestsNotYetProcessed( + List chunk, List processed) { Set processedKeys = new HashSet<>(); for (RequestQueueOperationInfo info : processed) { if (info.getUniqueKey() != null) { processedKeys.add(info.getUniqueKey()); } } - List remaining = new ArrayList<>(); - for (RequestQueueRequest request : chunk) { - if (!processedKeys.contains(request.getUniqueKey())) { - remaining.add(request); + List remaining = new ArrayList<>(); + for (SerializedRequest entry : chunk) { + if (!processedKeys.contains(entry.request().getUniqueKey())) { + remaining.add(entry); } } return remaining; @@ -470,16 +508,17 @@ private static Duration backoffDelay(int attempt, long minDelayMillis) { } private CompletableFuture batchAddChunk( - List requests, boolean forefront) { + List requests, boolean forefront) { QueryParams params = new QueryParams(); params.addBool("forefront", forefront); withClientKey(params); return ctx.postWithBody( "requests/batch", params, - Json.toBytes(requests), + jsonArray(requests), ResourceContext.CONTENT_TYPE_JSON, - Json.type(BatchAddResult.class)); + Json.type(BatchAddResult.class), + Timeout.MEDIUM); } /** @@ -488,7 +527,8 @@ private CompletableFuture batchAddChunk( */ public CompletableFuture batchDeleteRequests(Object requests) { QueryParams params = withClientKey(new QueryParams()); - return ctx.deleteWithBody("requests/batch", params, requests, BatchDeleteResult.class); + return ctx.deleteWithBody( + "requests/batch", params, requests, BatchDeleteResult.class, Timeout.SHORT); } /** Lists the queue's requests with pagination. */ @@ -497,7 +537,7 @@ public CompletableFuture listRequests(ListRequestsOptions options) QueryParams params = new QueryParams(); options.apply(params); withClientKey(params); - return ctx.getResourceRequired("requests", params, RequestsList.class); + return ctx.getResourceRequired("requests", params, RequestsList.class, Timeout.MEDIUM); } /** @@ -514,7 +554,8 @@ public CompletableFuture prolongRequestLock( params, null, "", - RequestLockInfo.class); + RequestLockInfo.class, + Timeout.MEDIUM); } /** @@ -528,7 +569,7 @@ public CompletableFuture deleteRequestLock(String id, boolean forefront) { String url = ctx.mergedParams(params) .applyToUrl(ctx.subUrl("requests/" + ResourceContext.encodePathSegment(id) + "/lock")); - return http.call("DELETE", url, null, "", http.baseRequestTimeout()) + return http.call("DELETE", url, null, "", Timeout.SHORT) .handle( (resp, error) -> { if (error == null) { @@ -546,7 +587,8 @@ public CompletableFuture deleteRequestLock(String id, boolean forefront) { /** Releases all locks the client holds on this queue's requests. */ public CompletableFuture unlockRequests() { QueryParams params = withClientKey(new QueryParams()); - return ctx.postWithBody("requests/unlock", params, null, "", UnlockRequestsResult.class); + return ctx.postWithBody( + "requests/unlock", params, null, "", UnlockRequestsResult.class, Timeout.LONG); } /** @@ -619,6 +661,7 @@ private final class Session implements Flow.Subscription { private final AtomicLong requested = new AtomicLong(); private final AtomicBoolean draining = new AtomicBoolean(); private final AtomicBoolean cancelled = new AtomicBoolean(); + private volatile CompletableFuture inFlight; private final AtomicBoolean terminated = new AtomicBoolean(); // Only touched from within the draining-guarded section; see AsyncPaginatedPublisher.Session. @@ -650,6 +693,10 @@ public void request(long n) { @Override public void cancel() { cancelled.set(true); + CompletableFuture fetch = inFlight; + if (fetch != null) { + fetch.cancel(true); + } } private void drain() { @@ -705,23 +752,25 @@ private void fetchPage() { } params.addCsv("filter", filter); withClientKey(params); - ctx.getResourceRequired("requests", params, RequestsList.class) - .whenComplete( - (page, error) -> { - if (cancelled.get()) { - draining.set(false); - return; - } - if (error != null) { - if (terminated.compareAndSet(false, true)) { - subscriber.onError(HttpClientCore.unwrapCompletion(error)); - } - draining.set(false); - return; - } - applyPage(page, capRemaining); - drainLoop(); - }); + CompletableFuture fetch = + ctx.getResourceRequired("requests", params, RequestsList.class, Timeout.MEDIUM); + inFlight = fetch; + fetch.whenComplete( + (page, error) -> { + if (cancelled.get()) { + draining.set(false); + return; + } + if (error != null) { + if (terminated.compareAndSet(false, true)) { + subscriber.onError(HttpClientCore.unwrapCompletion(error)); + } + draining.set(false); + return; + } + applyPage(page, capRemaining); + drainLoop(); + }); } private void applyPage(RequestsList page, Long capRemaining) { diff --git a/src/main/java/com/apify/client/run/ActorRun.java b/src/main/java/com/apify/client/run/ActorRun.java index 059ddd7..194de9c 100644 --- a/src/main/java/com/apify/client/run/ActorRun.java +++ b/src/main/java/com/apify/client/run/ActorRun.java @@ -1,11 +1,13 @@ package com.apify.client.run; import com.apify.client.ApifyResource; +import com.apify.client.internal.NormalizedUrlDeserializer; import com.apify.client.internal.Statuses; import java.time.Instant; import java.util.Collections; import java.util.Map; import tools.jackson.databind.JsonNode; +import tools.jackson.databind.annotation.JsonDeserialize; /** A single execution of an Actor. */ public final class ActorRun extends ApifyResource { @@ -21,7 +23,10 @@ public final class ActorRun extends ApifyResource { private String defaultDatasetId; private String defaultKeyValueStoreId; private String defaultRequestQueueId; + + @JsonDeserialize(using = NormalizedUrlDeserializer.class) private String containerUrl; + private String generalAccess; private Map chargedEventCounts; private JsonNode pricingInfo; @@ -36,6 +41,18 @@ public final class ActorRun extends ApifyResource { private Boolean isContainerServerReady; private String gitBranchName; private JsonNode storageIds; + private ActorRunStandby standby; + private Map accountedChargedEventCounts; + private Map eventUsage; + private Double externalChargedTotalUsd; + private JsonNode integrationTracking; + private String createdByOrganizationMemberUserId; + + @JsonDeserialize(using = NormalizedUrlDeserializer.class) + private String consoleUrl; + + @JsonDeserialize(contentUsing = NormalizedUrlDeserializer.class) + private Map output; /** The unique run ID. */ public String getId() { @@ -199,4 +216,49 @@ public JsonNode getStorageIds() { public boolean isTerminal() { return Statuses.isTerminal(status); } + + /** + * The Server Actor deployment that served this run; {@code null} for runs not started by a Server + * Actor request. + */ + public ActorRunStandby getStandby() { + return standby; + } + + /** The charged event counts already accounted for in billing. */ + public Map getAccountedChargedEventCounts() { + return accountedChargedEventCounts == null + ? null + : Collections.unmodifiableMap(accountedChargedEventCounts); + } + + /** Usage of each pay-per-event event, keyed by event name. */ + public Map getEventUsage() { + return eventUsage == null ? null : Collections.unmodifiableMap(eventUsage); + } + + /** The total USD charged for the run outside the platform billing. */ + public Double getExternalChargedTotalUsd() { + return externalChargedTotalUsd; + } + + /** Tracking of the third-party integration that started the run, if any. */ + public JsonNode getIntegrationTracking() { + return integrationTracking; + } + + /** The organization member who started the run, when started on behalf of an organization. */ + public String getCreatedByOrganizationMemberUserId() { + return createdByOrganizationMemberUserId; + } + + /** The URL of the run in the Apify Console. */ + public String getConsoleUrl() { + return consoleUrl; + } + + /** URLs of the run output, keyed by output name. */ + public Map getOutput() { + return output == null ? null : Collections.unmodifiableMap(output); + } } diff --git a/src/main/java/com/apify/client/run/ActorRunMeta.java b/src/main/java/com/apify/client/run/ActorRunMeta.java index be5181f..e49b721 100644 --- a/src/main/java/com/apify/client/run/ActorRunMeta.java +++ b/src/main/java/com/apify/client/run/ActorRunMeta.java @@ -10,6 +10,10 @@ public final class ActorRunMeta extends ApifyResource { private String userAgent; private String scheduleId; private Instant scheduledAt; + private String actorRunId; + private String webhookDispatchId; + private String scheduledAct2Id; + private String scheduledActorTaskId; /** What triggered the run (e.g. {@code "WEB"}, {@code "API"}, {@code "SCHEDULER"}). */ public String getOrigin() { @@ -35,4 +39,24 @@ public String getScheduleId() { public Instant getScheduledAt() { return scheduledAt; } + + /** The ID of the Actor run that started this run, if it was started by another run. */ + public String getActorRunId() { + return actorRunId; + } + + /** The ID of the webhook dispatch that started this run, if any. */ + public String getWebhookDispatchId() { + return webhookDispatchId; + } + + /** The ID of the scheduled Actor that started this run, if any. */ + public String getScheduledAct2Id() { + return scheduledAct2Id; + } + + /** The ID of the scheduled Actor task that started this run, if any. */ + public String getScheduledActorTaskId() { + return scheduledActorTaskId; + } } diff --git a/src/main/java/com/apify/client/run/ActorRunOptions.java b/src/main/java/com/apify/client/run/ActorRunOptions.java index 1a9b504..6e7de39 100644 --- a/src/main/java/com/apify/client/run/ActorRunOptions.java +++ b/src/main/java/com/apify/client/run/ActorRunOptions.java @@ -15,6 +15,7 @@ public final class ActorRunOptions extends ApifyResource { private Long maxItems; private Double maxTotalChargeUsd; private Boolean restartOnError; + private Boolean isMaxTotalChargeUsdSetByUser; /** The tag or number of the build that was run. */ public String getBuild() { @@ -50,4 +51,9 @@ public Double getMaxTotalChargeUsd() { public Boolean getRestartOnError() { return restartOnError; } + + /** Whether {@link #getMaxTotalChargeUsd()} was set by the user rather than defaulted. */ + public Boolean getIsMaxTotalChargeUsdSetByUser() { + return isMaxTotalChargeUsdSetByUser; + } } diff --git a/src/main/java/com/apify/client/run/ActorRunStandby.java b/src/main/java/com/apify/client/run/ActorRunStandby.java new file mode 100644 index 0000000..594baed --- /dev/null +++ b/src/main/java/com/apify/client/run/ActorRunStandby.java @@ -0,0 +1,18 @@ +package com.apify.client.run; + +import com.apify.client.ApifyResource; + +/** The Server Actor (Actor Standby) deployment that served an {@link ActorRun}. */ +public final class ActorRunStandby extends ApifyResource { + private String deployment; + + /** + * Which pool the run serves: {@code SINGLE_TENANT} is a private instance in the caller's account, + * while the {@code MULTI_TENANT_FREE_USERS}, {@code MULTI_TENANT_PAYING_USERS} and {@code + * MULTI_TENANT_OWNER_PAID} pools run in the Actor owner's account and serve many callers (charges + * in them require a {@link RunChargeOptions#requestId(String) request ID}). + */ + public String getDeployment() { + return deployment; + } +} diff --git a/src/main/java/com/apify/client/run/RunClient.java b/src/main/java/com/apify/client/run/RunClient.java index 2236931..2dd5570 100644 --- a/src/main/java/com/apify/client/run/RunClient.java +++ b/src/main/java/com/apify/client/run/RunClient.java @@ -2,6 +2,10 @@ import com.apify.client.ApifyClient; import com.apify.client.dataset.DatasetClient; +import com.apify.client.dataset.DatasetListItemsOptions; +import com.apify.client.http.Timeout; +import com.apify.client.internal.AsyncPaginatedPublisher; +import com.apify.client.internal.CancellableFuture; import com.apify.client.internal.HttpClientCore; import com.apify.client.internal.Json; import com.apify.client.internal.QueryParams; @@ -15,10 +19,12 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Flow; import java.util.concurrent.ThreadLocalRandom; import java.util.function.Consumer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import tools.jackson.databind.JsonNode; /** * A client for a specific Actor run. @@ -81,15 +87,15 @@ public CompletableFuture> get() { /** * Fetches the run, optionally asking the API to wait up to {@code waitForFinishSecs} seconds for - * the run to reach a terminal state before responding. The value is clamped so the server always - * responds before the client's per-request timeout; the API itself caps server-side waiting at 60 - * seconds. Pass {@code null} for an immediate fetch. + * the run to reach a terminal state before responding; the API itself caps server-side waiting at + * 60 seconds, and the request timeout is extended by that hold. Pass {@code null} for an + * immediate fetch. */ public CompletableFuture> getWithWait(Long waitForFinishSecs) { QueryParams params = new QueryParams(); - // Clamp to the client's per-request timeout so a short custom timeout doesn't abort the call. - params.addLong("waitForFinish", ctx.clampServerWait(waitForFinishSecs)); - return ctx.getResource("", params, ActorRun.class); + HttpClientCore.WaitHold hold = ctx.http.waitHold(Timeout.SHORT, waitForFinishSecs); + params.addLong("waitForFinish", hold.waitForFinishSecs()); + return ctx.getResource("", params, ActorRun.class, hold.timeout()); } /** Updates the run with the given fields and returns the updated object. */ @@ -110,14 +116,15 @@ public CompletableFuture delete() { public CompletableFuture abort(Boolean gracefully) { QueryParams params = new QueryParams(); params.addBool("gracefully", gracefully); - return ctx.postWithBody("abort", params, null, "", ActorRun.class); + return ctx.postWithBody("abort", params, null, "", ActorRun.class, Timeout.MEDIUM); } /** * Transforms the run into a run of another Actor with a new input. {@code targetActorId} is the * Actor to metamorph into ({@code username/actor-name} or the plain Actor id; normalized to the * URL-safe {@code username~actor-name} form before sending, matching the reference client's - * {@code _toSafeId}); {@code input} is the new input ({@code null} for none). + * {@code _toSafeId}); {@code input} is the new input: a JSON-serializable value, a {@code byte[]} + * or {@link String} that is sent as it is, or {@code null} for none. */ public CompletableFuture metamorph( String targetActorId, Object input, MetamorphOptions options) { @@ -132,14 +139,14 @@ public CompletableFuture metamorph( if (options.buildValue() != null && !options.buildValue().isEmpty()) { params.addString("build", options.buildValue()); } - byte[] body = input == null ? null : Json.toBytes(input); + byte[] body = Json.toRequestBody(input); return ctx.postWithBody( - "metamorph", params, body, options.contentTypeOrDefault(), ActorRun.class); + "metamorph", params, body, options.contentTypeOrDefault(), ActorRun.class, Timeout.MEDIUM); } /** Reboots the run (restarts its container while keeping the same run). */ public CompletableFuture reboot() { - return ctx.postWithBody("reboot", new QueryParams(), null, "", ActorRun.class); + return ctx.postWithBody("reboot", new QueryParams(), null, "", ActorRun.class, Timeout.MEDIUM); } /** @@ -152,7 +159,7 @@ public CompletableFuture resurrect(RunResurrectOptions options) { } QueryParams params = new QueryParams(); options.apply(params); - return ctx.postWithBody("resurrect", params, null, "", ActorRun.class); + return ctx.postWithBody("resurrect", params, null, "", ActorRun.class, Timeout.MEDIUM); } /** @@ -190,7 +197,7 @@ public CompletableFuture charge(RunChargeOptions options) { Json.toBytes(body), ResourceContext.CONTENT_TYPE_JSON, headers, - ctx.http.baseRequestTimeout()) + Timeout.SHORT) .thenApply(resp -> null); } @@ -225,6 +232,60 @@ public DatasetClient dataset() { return DatasetClient.nested(ctx.http, ctx.subUrl(""), "dataset", ctx.baseParams); } + /** Default number of dataset rows requested per API call by {@link #iterateDatasetItems}. */ + private static final long DEFAULT_ITERATE_CHUNK_SIZE = 1000; + + /** Default number of seconds {@link #iterateDatasetItems} waits for the run between polls. */ + private static final long DEFAULT_ITERATE_POLL_INTERVAL_SECS = 5; + + /** + * Iterates over the items of the run's default dataset while the run is still producing them, + * decoding each into a generic {@link JsonNode}. See {@link #iterateDatasetItems( + * DatasetListItemsOptions, Long, Long, Class)}. + */ + public Flow.Publisher iterateDatasetItems(DatasetListItemsOptions options) { + return iterateDatasetItems(options, null, null, JsonNode.class); + } + + /** + * Iterates over the items of the run's default dataset while the run is still producing them, + * decoding each item into {@code itemClass}: a lazy, backpressure-aware publisher that ends once + * the run has finished and every item has been read. + * + *

While the run has not finished, each poll yields the rows below the dataset's {@code + * itemCount} and then waits up to {@code pollIntervalSecs} for the run to finish, so the last + * rows are read as soon as it does. Once the run is terminal, the rows past the (lagging) {@code + * itemCount} are read a page at a time until none are left. On a client from {@code lastRun()}, + * the iteration sticks to the run that its first request resolves to. + * + * @param options the items options: {@code offset} (rows to skip) and {@code limit} (the maximum + * number of dataset rows to scan: fewer items are yielded when filters drop some, + * more when {@code unwind} splits a row into several), and the item filters ({@code clean}, + * {@code fields}, {@code omit}, {@code unwind}, {@code skipEmpty}, {@code skipHidden}, ...). + * {@code desc} is ignored, since rows are read in the order they were pushed. May be {@code + * null}. + * @param chunkSize the maximum number of dataset rows requested per API call; {@code null} means + * 1000 + * @param pollIntervalSecs how long to wait for the run to finish between polls, in seconds; + * {@code null} means 5 + * @param itemClass the type each item is decoded into + */ + public Flow.Publisher iterateDatasetItems( + DatasetListItemsOptions options, Long chunkSize, Long pollIntervalSecs, Class itemClass) { + long pageSize = chunkSize != null && chunkSize > 0 ? chunkSize : DEFAULT_ITERATE_CHUNK_SIZE; + long poll = + pollIntervalSecs != null && pollIntervalSecs >= 0 + ? pollIntervalSecs + : DEFAULT_ITERATE_POLL_INTERVAL_SECS; + RunDatasetItems items = new RunDatasetItems<>(this, options, pageSize, poll, itemClass); + return new AsyncPaginatedPublisher<>(null, null, null, (offset, limit) -> items.next()); + } + + /** The client for {@code run}: this one, or a direct client for its id if this is a last-run. */ + RunClient pinned(ActorRun run) { + return run.getId() == null || run.getId().equals(id) ? this : root.run(run.getId()); + } + /** A client for this run's default key-value store. */ public KeyValueStoreClient keyValueStore() { return KeyValueStoreClient.nested(ctx.http, ctx.subUrl(""), "key-value-store", ctx.baseParams); @@ -273,11 +334,11 @@ public CompletableFuture getStreamedLog() { public CompletableFuture getStreamedLog(StreamedLogOptions options) { Consumer destination = options.destination(); if (destination != null) { - return CompletableFuture.completedFuture( + return CancellableFuture.completed( new StreamedLog(log(), destination, options.fromStartValue())); } if (options.prefixValue() != null) { - return CompletableFuture.completedFuture( + return CancellableFuture.completed( new StreamedLog( log(), defaultLogDestination(options.prefixValue()), options.fromStartValue())); } diff --git a/src/main/java/com/apify/client/run/RunDatasetItems.java b/src/main/java/com/apify/client/run/RunDatasetItems.java new file mode 100644 index 0000000..aae86c4 --- /dev/null +++ b/src/main/java/com/apify/client/run/RunDatasetItems.java @@ -0,0 +1,184 @@ +package com.apify.client.run; + +import com.apify.client.PaginationList; +import com.apify.client.dataset.Dataset; +import com.apify.client.dataset.DatasetClient; +import com.apify.client.dataset.DatasetListItemsOptions; +import com.apify.client.internal.DatasetItemsOptionsAccess; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import tools.jackson.databind.JsonNode; + +/** + * The paging state machine behind {@link RunClient#iterateDatasetItems}: it reads a run's default + * dataset while the run is still producing items, one page per {@link #next()} call. + * + *

While the run has not finished, each round reads the rows below the dataset's {@code + * itemCount} and then waits up to the poll interval for the run to finish, so the last rows are + * read as soon as it does. Each page is requested with a {@code limit} that ends at {@code + * itemCount}, so it covers exactly the rows it asks for, whatever the filters or {@code unwind} do + * to the items. {@code itemCount} lags a few seconds behind the pushed items, so once the run is + * terminal the rows past it are read a page at a time until none are left. + * + *

Not thread-safe: the publisher that drives it fetches one page at a time. + */ +final class RunDatasetItems { + + private final RunClient runClient; + private final DatasetListItemsOptions options; + private final Class itemClass; + private final DatasetItemsOptionsAccess access; + private final long pageSize; + private final long pollIntervalSecs; + + /** The first row this iteration must not read, or {@code null} for no limit. */ + private final Long end; + + private enum Phase { + START, + POLL, + TAIL, + DONE + } + + private Phase phase = Phase.START; + private long position; + private long itemCount; + private boolean finished; + private DatasetClient dataset; + private RunClient resolvedRun; + + RunDatasetItems( + RunClient runClient, + DatasetListItemsOptions options, + long pageSize, + long pollIntervalSecs, + Class itemClass) { + this.runClient = runClient; + this.access = DatasetItemsOptionsAccess.get(); + this.options = access.snapshot(options); + this.itemClass = itemClass; + this.pageSize = pageSize; + this.pollIntervalSecs = pollIntervalSecs; + this.position = access.offset(this.options); + Long limit = access.limit(this.options); + this.end = limit != null && limit > 0 ? position + limit : null; + } + + /** + * 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. + */ + CompletableFuture> next() { + switch (phase) { + case START: + return runClient.get().thenCompose(run -> start(run)).thenCompose(unused -> next()); + case POLL: + return poll(); + case TAIL: + return tail(); + default: + return CompletableFuture.completedFuture(endOfData()); + } + } + + private CompletableFuture start(Optional run) { + // A last-run client resolves runs/last on each request, so a newer run would swap the dataset + // mid-iteration; pin the run its first request resolves to. + resolvedRun = run.isPresent() ? runClient.pinned(run.get()) : runClient; + dataset = resolvedRun.dataset(); + finished = run.isEmpty() || run.get().isTerminal(); + phase = Phase.POLL; + return refreshItemCount(); + } + + private CompletableFuture refreshItemCount() { + return dataset + .get() + .thenAccept( + ds -> { + long count = ds.map(Dataset::getItemCount).orElse(0L); + itemCount = end != null ? Math.min(count, end) : count; + }); + } + + private CompletableFuture> poll() { + if (position < itemCount) { + long limit = Math.min(pageSize, itemCount - position); + return readPage(limit); + } + if (end != null && position >= end) { + phase = Phase.DONE; + return CompletableFuture.completedFuture(endOfData()); + } + if (finished) { + phase = Phase.TAIL; + return tail(); + } + return resolvedRun + .waitForFinish(pollIntervalSecs) + .thenCompose( + run -> { + finished = run.isTerminal(); + return refreshItemCount(); + }) + .thenCompose(unused -> next()); + } + + private CompletableFuture> tail() { + long limit = end != null ? Math.min(pageSize, end - position) : pageSize; + return page(position, limit) + .thenCompose( + page -> { + Long scanned = page.getScannedCount(); + boolean empty = page.getItems().isEmpty() && (scanned == null || scanned == 0); + if (!empty) { + 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. + return dataset + .listItems(access.plainPage(position, 1L), JsonNode.class) + .thenCompose( + probe -> + probe.getItems().isEmpty() + ? finish() + : CompletableFuture.completedFuture(advance(page, limit))); + }); + } + + private CompletableFuture> finish() { + phase = Phase.DONE; + return CompletableFuture.completedFuture(endOfData()); + } + + private CompletableFuture> readPage(long limit) { + return page(position, limit).thenApply(page -> advance(page, limit)); + } + + 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. */ + 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)); + return page; + } + + private PaginationList endOfData() { + PaginationList page = new PaginationList<>(); + page.setItems(List.of()); + page.setScannedCount(0L); + return page; + } +} diff --git a/src/main/java/com/apify/client/run/RunListOptions.java b/src/main/java/com/apify/client/run/RunListOptions.java index f77dee4..e97ee85 100644 --- a/src/main/java/com/apify/client/run/RunListOptions.java +++ b/src/main/java/com/apify/client/run/RunListOptions.java @@ -8,12 +8,14 @@ * Run-specific filters for {@link RunCollectionClient#list(ListOptions, RunListOptions)}. The * {@code startedAfter}/{@code startedBefore} filters are honoured by the top-level ({@code * /v2/actor-runs}) and Actor-scoped ({@code /v2/actors/{actorId}/runs}) run collections; the - * task-scoped run collection accepts only {@code status}. + * task-scoped run collection accepts only {@code status}. {@code deployment} is honoured by the + * top-level and Actor-scoped collections. */ public final class RunListOptions { private List status; private String startedAfter; private String startedBefore; + private List deployment; /** * Filter by one or more run statuses (e.g. {@code "SUCCEEDED"}, {@code "RUNNING"}). Sent as a @@ -36,8 +38,21 @@ public RunListOptions startedBefore(String startedBefore) { return this; } + /** + * Filter Actor Standby runs by the deployment pool that serves them: {@code SINGLE_TENANT}, + * {@code MULTI_TENANT_FREE_USERS}, {@code MULTI_TENANT_PAYING_USERS} or {@code + * MULTI_TENANT_OWNER_PAID}. Sent as a comma-separated list. The API requires the {@link + * #status(List) status} filter to be set too, with only non-terminal statuses ({@code READY}, + * {@code RUNNING}, {@code TIMING-OUT} or {@code ABORTING}). + */ + public RunListOptions deployment(List deployment) { + this.deployment = deployment == null ? null : List.copyOf(deployment); + return this; + } + void apply(QueryParams q) { q.addCsv("status", status) + .addCsv("deployment", deployment) .addString("startedAfter", startedAfter) .addString("startedBefore", startedBefore); } diff --git a/src/main/java/com/apify/client/schedule/Schedule.java b/src/main/java/com/apify/client/schedule/Schedule.java index 28ddb92..e8f1e00 100644 --- a/src/main/java/com/apify/client/schedule/Schedule.java +++ b/src/main/java/com/apify/client/schedule/Schedule.java @@ -23,6 +23,8 @@ public final class Schedule extends ApifyResource { private Instant lastRunAt; private List actions = List.of(); private ScheduleNotifications notifications; + private JsonNode uiState; + private JsonNode integrationTracking; /** The unique schedule ID. */ public String getId() { @@ -107,4 +109,17 @@ public List getActions() { public ScheduleNotifications getNotifications() { return notifications; } + + /** + * The state of the Console schedule editor; {@code null} once an API update of the cron + * expression or timezone has dropped it. + */ + public JsonNode getUiState() { + return uiState; + } + + /** Tracking of the third-party integration that created the schedule, if any. */ + public JsonNode getIntegrationTracking() { + return integrationTracking; + } } diff --git a/src/main/java/com/apify/client/schedule/ScheduleClient.java b/src/main/java/com/apify/client/schedule/ScheduleClient.java index 8c1d06b..86b7654 100644 --- a/src/main/java/com/apify/client/schedule/ScheduleClient.java +++ b/src/main/java/com/apify/client/schedule/ScheduleClient.java @@ -1,5 +1,6 @@ package com.apify.client.schedule; +import com.apify.client.http.Timeout; import com.apify.client.internal.ApiPaths; import com.apify.client.internal.HttpClientCore; import com.apify.client.internal.Json; @@ -42,6 +43,6 @@ public CompletableFuture delete() { */ public CompletableFuture> getLog() { JavaType listType = Json.parametric(List.class, Json.type(ScheduleInvoked.class)); - return ctx.getResourceRequired("log", new QueryParams(), listType); + return ctx.getResourceRequired("log", new QueryParams(), listType, Timeout.MEDIUM); } } diff --git a/src/main/java/com/apify/client/store/ActorStoreListItem.java b/src/main/java/com/apify/client/store/ActorStoreListItem.java index cd2fde8..ed25137 100644 --- a/src/main/java/com/apify/client/store/ActorStoreListItem.java +++ b/src/main/java/com/apify/client/store/ActorStoreListItem.java @@ -2,6 +2,8 @@ import com.apify.client.ApifyResource; import com.apify.client.actor.ActorStats; +import com.apify.client.internal.NormalizedUrlDeserializer; +import tools.jackson.databind.annotation.JsonDeserialize; /** An Actor as listed in the Apify Store. */ public final class ActorStoreListItem extends ApifyResource { @@ -13,8 +15,13 @@ public final class ActorStoreListItem extends ApifyResource { private ActorStats stats; private PricingInfo currentPricingInfo; private String pictureUrl; + + @JsonDeserialize(using = NormalizedUrlDeserializer.class) private String userPictureUrl; + + @JsonDeserialize(using = NormalizedUrlDeserializer.class) private String url; + private String readmeSummary; /** The unique Actor ID. */ diff --git a/src/main/java/com/apify/client/store/StoreListOptions.java b/src/main/java/com/apify/client/store/StoreListOptions.java index df086b3..69ce18e 100644 --- a/src/main/java/com/apify/client/store/StoreListOptions.java +++ b/src/main/java/com/apify/client/store/StoreListOptions.java @@ -15,6 +15,7 @@ public final class StoreListOptions implements ListOptionsLike { private Boolean includeUnrunnableActors; private Boolean allowsAgenticUsers; private String responseFormat; + private Boolean includeInputSchema; /** Number of Actors to skip. */ public StoreListOptions offset(Long offset) { @@ -83,6 +84,16 @@ public StoreListOptions responseFormat(String responseFormat) { return this; } + /** + * Include a compact {@code inputSchema} (property types and required properties only) of each + * Actor's default build in the results, available through {@code ActorStoreListItem#getExtra()}. + * With this flag the API defaults {@code limit} to, and caps it at, {@code 10}. + */ + public StoreListOptions includeInputSchema(Boolean includeInputSchema) { + this.includeInputSchema = includeInputSchema; + return this; + } + @Override public Long limitValue() { return limit; @@ -111,6 +122,7 @@ public void applyFilters(QueryParams q) { .addString("pricingModel", pricingModel) .addBool("includeUnrunnableActors", includeUnrunnableActors) .addBool("allowsAgenticUsers", allowsAgenticUsers) - .addString("responseFormat", responseFormat); + .addString("responseFormat", responseFormat) + .addBool("includeInputSchema", includeInputSchema); } } diff --git a/src/main/java/com/apify/client/task/Task.java b/src/main/java/com/apify/client/task/Task.java index d60d939..303003c 100644 --- a/src/main/java/com/apify/client/task/Task.java +++ b/src/main/java/com/apify/client/task/Task.java @@ -2,8 +2,10 @@ import com.apify.client.ApifyResource; import com.apify.client.actor.ActorStandby; +import com.apify.client.internal.NormalizedUrlDeserializer; import java.time.Instant; import tools.jackson.databind.JsonNode; +import tools.jackson.databind.annotation.JsonDeserialize; /** A pre-configured Actor run (an Actor task). */ public final class Task extends ApifyResource { @@ -22,6 +24,9 @@ public final class Task extends ApifyResource { private Boolean isPublic; private TaskPublicConfig publicConfig; + @JsonDeserialize(using = NormalizedUrlDeserializer.class) + private String standbyUrl; + /** The unique task ID. */ public String getId() { return id; @@ -100,4 +105,9 @@ public Boolean getIsPublic() { public TaskPublicConfig getPublicConfig() { return publicConfig; } + + /** The Actor Standby URL of the task, if standby is enabled (normalized). */ + public String getStandbyUrl() { + return standbyUrl; + } } diff --git a/src/main/java/com/apify/client/task/TaskClient.java b/src/main/java/com/apify/client/task/TaskClient.java index 2b74c4f..cc5e6f6 100644 --- a/src/main/java/com/apify/client/task/TaskClient.java +++ b/src/main/java/com/apify/client/task/TaskClient.java @@ -1,6 +1,7 @@ package com.apify.client.task; import com.apify.client.ApifyClient; +import com.apify.client.http.Timeout; import com.apify.client.internal.ApiPaths; import com.apify.client.internal.HttpClientCore; import com.apify.client.internal.Json; @@ -138,7 +139,7 @@ public CompletableFuture call(Object input, TaskCallOptions options, L * is gone", so a missing task is reported as a failure here too. */ public CompletableFuture getInput() { - return ctx.getRawRequired("input", new QueryParams()) + return ctx.getRawRequired("input", new QueryParams(), Timeout.SHORT) .thenApply(resp -> Json.parse(resp.body(), JsonNode.class)); } @@ -149,7 +150,8 @@ public CompletableFuture updateInput(Object input) { new QueryParams(), Json.toBytes(input), ResourceContext.CONTENT_TYPE_JSON, - JsonNode.class); + JsonNode.class, + Timeout.SHORT); } /** diff --git a/src/main/java/com/apify/client/task/TaskCollectionClient.java b/src/main/java/com/apify/client/task/TaskCollectionClient.java index 298920b..7b6bab0 100644 --- a/src/main/java/com/apify/client/task/TaskCollectionClient.java +++ b/src/main/java/com/apify/client/task/TaskCollectionClient.java @@ -1,6 +1,7 @@ package com.apify.client.task; import com.apify.client.ListOptions; +import com.apify.client.http.Timeout; import com.apify.client.internal.AbstractCollectionClient; import com.apify.client.internal.ApiPaths; import com.apify.client.internal.HttpClientCore; @@ -20,6 +21,6 @@ public TaskCollectionClient(HttpClientCore http, String baseUrl) { /** Creates a new task. {@code task} is any JSON-serializable task definition. */ public CompletableFuture create(Object task) { - return ctx.createResource(new QueryParams(), task, Task.class); + return ctx.createResource(new QueryParams(), task, Task.class, Timeout.MEDIUM); } } diff --git a/src/main/java/com/apify/client/task/TaskOptions.java b/src/main/java/com/apify/client/task/TaskOptions.java index 238067b..244731d 100644 --- a/src/main/java/com/apify/client/task/TaskOptions.java +++ b/src/main/java/com/apify/client/task/TaskOptions.java @@ -8,6 +8,8 @@ public final class TaskOptions extends ApifyResource { private Long timeoutSecs; private Long memoryMbytes; private Boolean restartOnError; + private Double maxTotalChargeUsd; + private Boolean isMaxTotalChargeUsdSetByUser; /** The tag or number of the build the task runs by default. */ public String getBuild() { @@ -28,4 +30,14 @@ public Long getMemoryMbytes() { public Boolean getRestartOnError() { return restartOnError; } + + /** The maximum total charge in USD for a pay-per-event run of the task. */ + public Double getMaxTotalChargeUsd() { + return maxTotalChargeUsd; + } + + /** Whether {@link #getMaxTotalChargeUsd()} was set by the user rather than defaulted. */ + public Boolean getIsMaxTotalChargeUsdSetByUser() { + return isMaxTotalChargeUsdSetByUser; + } } diff --git a/src/main/java/com/apify/client/task/TaskStats.java b/src/main/java/com/apify/client/task/TaskStats.java index 571d898..36a5745 100644 --- a/src/main/java/com/apify/client/task/TaskStats.java +++ b/src/main/java/com/apify/client/task/TaskStats.java @@ -1,13 +1,20 @@ package com.apify.client.task; import com.apify.client.ApifyResource; +import java.time.Instant; /** Usage statistics for a {@link Task}. */ public final class TaskStats extends ApifyResource { private long totalRuns; + private Instant lastRunStartedAt; /** The total number of runs started from this task. */ public long getTotalRuns() { return totalRuns; } + + /** When the most recent run of the task started. */ + public Instant getLastRunStartedAt() { + return lastRunStartedAt; + } } diff --git a/src/main/java/com/apify/client/user/UserClient.java b/src/main/java/com/apify/client/user/UserClient.java index b414c11..cc14214 100644 --- a/src/main/java/com/apify/client/user/UserClient.java +++ b/src/main/java/com/apify/client/user/UserClient.java @@ -12,9 +12,9 @@ /** * A client for accessing user data ({@code /v2/users/{userId}} or {@code /v2/users/me}). * - *

For the current user ({@code me}), it also exposes account usage and limits. Those endpoints - * only exist for {@code me} and throw {@link IllegalStateException} if called on another user's - * client. + *

It also exposes the user's monthly usage and account limits ({@code + * /v2/users/{userId}/usage/monthly} and {@code /v2/users/{userId}/limits}), for {@code me} or for a + * user id the API token may access. */ public final class UserClient { @@ -22,11 +22,9 @@ public final class UserClient { public static final String ME = "me"; private final ResourceContext ctx; - private final boolean isMe; public UserClient(HttpClientCore http, String baseUrl, String id) { this.ctx = ResourceContext.single(http, baseUrl, ApiPaths.USERS, id); - this.isMe = ME.equals(id); } /** @@ -39,19 +37,18 @@ public CompletableFuture> get() { } /** - * Fetches the current account's monthly usage for the current month. Only available for {@code - * me}. Completes with the raw JSON usage report. + * Fetches the user's monthly usage for the current month. Completes with the raw JSON usage + * report. */ public CompletableFuture monthlyUsage() { return monthlyUsage(""); } /** - * Fetches the current account's monthly usage for the month containing the given date (formatted - * as {@code YYYY-MM-DD}). An empty date reports the current month. Only available for {@code me}. + * Fetches the user's monthly usage for the month containing the given date (formatted as {@code + * YYYY-MM-DD}). An empty date reports the current month. */ public CompletableFuture monthlyUsage(String date) { - requireMe(); QueryParams params = new QueryParams(); if (date != null && !date.isEmpty()) { params.addString("date", date); @@ -59,26 +56,17 @@ public CompletableFuture monthlyUsage(String date) { return ctx.getResourceRequired("usage/monthly", params, JsonNode.class); } - /** Fetches the current account's resource limits. Only available for {@code me}. */ + /** Fetches the user's account and usage limits. */ public CompletableFuture limits() { - requireMe(); return ctx.getResourceRequired("limits", new QueryParams(), JsonNode.class); } - /** Updates the current account's resource limits. Only available for {@code me}. */ + /** Updates the user's account and usage limits. */ public CompletableFuture updateLimits(Object newLimits) { - requireMe(); // Route through the shared PUT primitive for consistency with every other resource client's // update-style call; the response body (the updated limits) is intentionally not parsed, // matching the reference client's void-returning updateLimits. return ctx.putRaw( "limits", new QueryParams(), Json.toBytes(newLimits), ResourceContext.CONTENT_TYPE_JSON); } - - private void requireMe() { - if (!isMe) { - throw new IllegalStateException( - "this operation is only available for the current user (use me())"); - } - } } diff --git a/src/main/java/com/apify/client/user/UserProfile.java b/src/main/java/com/apify/client/user/UserProfile.java index 857eb0d..909c896 100644 --- a/src/main/java/com/apify/client/user/UserProfile.java +++ b/src/main/java/com/apify/client/user/UserProfile.java @@ -1,15 +1,23 @@ package com.apify.client.user; import com.apify.client.ApifyResource; +import com.apify.client.internal.NormalizedUrlDeserializer; +import tools.jackson.databind.annotation.JsonDeserialize; /** The public profile of a {@link User}. */ public final class UserProfile extends ApifyResource { private String bio; private String readme; private String name; + + @JsonDeserialize(using = NormalizedUrlDeserializer.class) private String pictureUrl; + private String githubUsername; + + @JsonDeserialize(using = NormalizedUrlDeserializer.class) private String websiteUrl; + private String twitterUsername; /** A short biography, if set. */ diff --git a/src/main/java/com/apify/client/webhook/Webhook.java b/src/main/java/com/apify/client/webhook/Webhook.java index bddf348..5e82441 100644 --- a/src/main/java/com/apify/client/webhook/Webhook.java +++ b/src/main/java/com/apify/client/webhook/Webhook.java @@ -1,10 +1,12 @@ package com.apify.client.webhook; import com.apify.client.ApifyResource; +import com.apify.client.internal.NormalizedUrlDeserializer; import java.time.Instant; import java.util.Collections; import java.util.List; import tools.jackson.databind.JsonNode; +import tools.jackson.databind.annotation.JsonDeserialize; /** A webhook notifies an external service when specific events occur. */ public final class Webhook extends ApifyResource { @@ -18,12 +20,23 @@ public final class Webhook extends ApifyResource { private JsonNode condition; private boolean ignoreSslErrors; private boolean doNotRetry; + + @JsonDeserialize(using = NormalizedUrlDeserializer.class) private String requestUrl; + private String payloadTemplate; private String headersTemplate; private String description; private WebhookLastDispatch lastDispatch; private WebhookStats stats; + private Boolean isApifyIntegration; + private Boolean isEnabled; + private String actionType; + private String title; + private JsonNode slackMessage; + private JsonNode googleMail; + private JsonNode googleDrive; + private JsonNode githubIssue; /** The unique webhook ID. */ public String getId() { @@ -120,4 +133,46 @@ public WebhookLastDispatch getLastDispatch() { public WebhookStats getStats() { return stats; } + + /** Whether the webhook was created by an Apify integration (e.g. Slack or Google Drive). */ + public Boolean getIsApifyIntegration() { + return isApifyIntegration; + } + + /** Whether the webhook is enabled. */ + public Boolean getIsEnabled() { + return isEnabled; + } + + /** + * The kind of action the webhook performs (e.g. {@code "HTTP_REQUEST"} or an integration action). + */ + public String getActionType() { + return actionType; + } + + /** The human-readable title of the webhook. */ + public String getTitle() { + return title; + } + + /** Slack message configuration, present for Slack integration webhooks. */ + public JsonNode getSlackMessage() { + return slackMessage; + } + + /** Google Mail configuration, present for Google Mail integration webhooks. */ + public JsonNode getGoogleMail() { + return googleMail; + } + + /** Google Drive configuration, present for Google Drive integration webhooks. */ + public JsonNode getGoogleDrive() { + return googleDrive; + } + + /** GitHub issue configuration, present for GitHub integration webhooks. */ + public JsonNode getGithubIssue() { + return githubIssue; + } } diff --git a/src/main/java/com/apify/client/webhook/WebhookClient.java b/src/main/java/com/apify/client/webhook/WebhookClient.java index 20f02c8..189c1fc 100644 --- a/src/main/java/com/apify/client/webhook/WebhookClient.java +++ b/src/main/java/com/apify/client/webhook/WebhookClient.java @@ -1,5 +1,6 @@ package com.apify.client.webhook; +import com.apify.client.http.Timeout; import com.apify.client.internal.ApiPaths; import com.apify.client.internal.HttpClientCore; import com.apify.client.internal.QueryParams; @@ -34,7 +35,8 @@ public CompletableFuture delete() { /** Dispatches the webhook immediately and completes with the resulting dispatch. */ public CompletableFuture test() { - return ctx.postWithBody("test", new QueryParams(), null, "", WebhookDispatch.class); + return ctx.postWithBody( + "test", new QueryParams(), null, "", WebhookDispatch.class, Timeout.MEDIUM); } /** A client for this webhook's dispatch collection. */ diff --git a/src/main/java/com/apify/client/webhook/WebhookDispatchWebhookInfo.java b/src/main/java/com/apify/client/webhook/WebhookDispatchWebhookInfo.java index 750ab6b..35de6e1 100644 --- a/src/main/java/com/apify/client/webhook/WebhookDispatchWebhookInfo.java +++ b/src/main/java/com/apify/client/webhook/WebhookDispatchWebhookInfo.java @@ -1,13 +1,17 @@ package com.apify.client.webhook; import com.apify.client.ApifyResource; +import com.apify.client.internal.NormalizedUrlDeserializer; +import tools.jackson.databind.annotation.JsonDeserialize; /** * A summary of the {@link Webhook} that produced a {@link WebhookDispatch} (only the subset of * fields the API includes alongside a dispatch, not the full webhook object). */ public final class WebhookDispatchWebhookInfo extends ApifyResource { + @JsonDeserialize(using = NormalizedUrlDeserializer.class) private String requestUrl; + private boolean isAdHoc; /** diff --git a/src/test/java/com/apify/client/CancellationTest.java b/src/test/java/com/apify/client/CancellationTest.java new file mode 100644 index 0000000..94e6424 --- /dev/null +++ b/src/test/java/com/apify/client/CancellationTest.java @@ -0,0 +1,448 @@ +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import com.apify.client.actor.ActorCallOptions; +import com.apify.client.actor.ActorListOptions; +import com.apify.client.actor.ActorStartOptions; +import com.apify.client.http.HttpTransport; +import com.apify.client.http.Timeout; +import com.apify.client.internal.CancellableFuture; +import com.apify.client.log.StreamedLog; +import com.apify.client.log.StreamedLogOptions; +import com.apify.client.requestqueue.RequestQueueRequest; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.ref.WeakReference; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BooleanSupplier; +import org.junit.jupiter.api.Test; + +/** + * Cancelling a future the client returned cancels the work behind it: the request in flight, the + * retries, the polling and the pages that would follow. + */ +class CancellationTest { + + private static final String RUN_JSON = "{\"data\":{\"id\":\"r1\",\"status\":\"RUNNING\"}}"; + + private static ApifyClient client(HttpTransport backend, int maxRetries) { + return ApifyClient.builder() + .token("t") + .httpTransport(backend) + .maxRetries(maxRetries) + .minDelayBetweenRetries(Duration.ofMillis(300)) + .build(); + } + + private static void await(BooleanSupplier condition) throws InterruptedException { + for (int i = 0; i < 200 && !condition.getAsBoolean(); i++) { + Thread.sleep(10); + } + assertTrue(condition.getAsBoolean(), "condition not reached in time"); + } + + @Test + void cancellingARequestCancelsTheTransportExchange() throws InterruptedException { + MockTransport backend = MockTransport.ofHanging(request -> true); + CompletableFuture future = client(backend, 0).run("r1").get(); + await(() -> backend.calls == 1); + + assertTrue(future.cancel(true)); + + assertTrue(future.isCancelled()); + assertTrue(backend.allHungCancelled(), "the request in flight is aborted"); + assertThrows(CancellationException.class, future::join); + } + + @Test + void cancellingADerivedStageCancelsTheCallItComesFrom() throws InterruptedException { + MockTransport backend = MockTransport.ofHanging(request -> true); + CompletableFuture derived = + client(backend, 0) + .run("r1") + .get() + .thenApply(run -> "x") + .thenCompose(CompletableFuture::completedFuture); + await(() -> backend.calls == 1); + + derived.cancel(true); + + assertTrue(backend.allHungCancelled()); + } + + @Test + void cancellingDuringTheBackoffStopsTheRetries() throws InterruptedException { + MockTransport backend = MockTransport.ofConstant(503, "{}"); + CompletableFuture future = client(backend, 5).run("r1").get(); + await(() -> backend.calls == 1); + + future.cancel(true); + Thread.sleep(900); // longer than the first backoff delay (300-600 ms) + + assertEquals(1, backend.calls, "a cancelled call is not retried"); + } + + @Test + void cancellingAWaitForFinishStopsThePolling() throws InterruptedException { + MockTransport backend = MockTransport.ofHanging(request -> false); + // The run never finishes: every poll answers RUNNING at once. + CompletableFuture future = client(backend, 0).run("r1").waitForFinish(600L); + await(() -> backend.calls >= 2); + + future.cancel(true); + int callsAtCancel = backend.calls; + Thread.sleep(600); + + assertTrue(backend.calls <= callsAtCancel + 1, "polling stops: " + backend.calls); + assertTrue(future.isCancelled()); + } + + @Test + void cancellingAHangingPollAbortsItsRequest() throws InterruptedException { + MockTransport backend = MockTransport.ofHanging(request -> true); + CompletableFuture future = client(backend, 0).run("r1").waitForFinish(600L); + await(() -> backend.calls == 1); + + future.cancel(true); + + assertTrue(backend.allHungCancelled(), "a stalled poll can be escaped by cancelling"); + Thread.sleep(300); + assertEquals(1, backend.calls); + } + + @Test + void cancellingCallStopsItsPolling() throws InterruptedException { + MockTransport backend = MockTransport.ofHanging(request -> request.method().equals("GET")); + CompletableFuture future = + client(backend, 0).actor("a1").call(null, new ActorStartOptions(), null); + await(() -> backend.hung.size() == 1); + + future.cancel(true); + + assertTrue(backend.allHungCancelled()); + } + + @Test + void cancellingACollectedIterationCancelsThePageInFlight() throws InterruptedException { + MockTransport backend = MockTransport.ofHanging(request -> true); + CompletableFuture future = + Publishers.collect(client(backend, 0).actors().iterate(new ActorListOptions(), 10L)); + await(() -> backend.calls == 1); + + future.cancel(true); + + assertTrue(backend.allHungCancelled()); + } + + @Test + void cancellingABatchAddCancelsTheChunksInFlight() throws InterruptedException { + MockTransport backend = MockTransport.ofHanging(request -> true); + List requests = + List.of( + new RequestQueueRequest().setUrl("https://a.test").setUniqueKey("a"), + new RequestQueueRequest().setUrl("https://b.test").setUniqueKey("b")); + CompletableFuture future = + client(backend, 0).requestQueue("q1").batchAddRequests(requests, false); + await(() -> backend.calls == 1); + + future.cancel(true); + + assertTrue(backend.allHungCancelled()); + assertFalse(future.isDone() && !future.isCancelled()); + } + + @Test + void cancellingCallStopsTheLogRedirection() throws Exception { + MockTransport backend = + MockTransport.ofHanging( + request -> + request.uri().getQuery() != null + && request.uri().getQuery().contains("waitForFinish")); + AtomicBoolean streamClosed = new AtomicBoolean(); + backend.scriptStream( + 200, + new InputStream() { + private final byte[] data = + "2999-01-01T00:00:00.000Z hello\n".getBytes(StandardCharsets.UTF_8); + private final CountDownLatch closed = new CountDownLatch(1); + private int pos; + + // Serves one log line, then blocks like a live log with nothing more to say. + @Override + public synchronized int read(byte[] b, int off, int len) throws IOException { + if (pos < data.length) { + int n = Math.min(len, data.length - pos); + System.arraycopy(data, pos, b, off, n); + pos += n; + return n; + } + try { + closed.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + throw new IOException("stream closed"); + } + + @Override + public int read() throws IOException { + byte[] one = new byte[1]; + int n = read(one, 0, 1); + return n == -1 ? -1 : one[0] & 0xff; + } + + @Override + public void close() { + streamClosed.set(true); + closed.countDown(); + } + }); + CompletableFuture future = + client(backend, 0).actor("a1").call(null, new ActorCallOptions(), null); + // The start, the run fetch for the log prefix, the poll (hung) and the log stream itself. + await(() -> backend.hung.size() == 1 && backend.calls >= 4); + + future.cancel(true); + await(streamClosed::get); + assertTrue(backend.allHungCancelled()); + } + + @Test + void pollsIgnoreAWithTimeoutOverrideAndKeepHoldingTheFullMinute() { + MockTransport backend = + MockTransport.ofConstant(200, "{\"data\":{\"id\":\"r1\",\"status\":\"SUCCEEDED\"}}"); + client(backend, 0).withTimeout(Timeout.ofSeconds(5)).run("r1").waitForFinish(120L).join(); + assertTrue(backend.lastUrl.contains("waitForFinish=60"), backend.lastUrl); + assertEquals(Optional.empty(), backend.lastTimeout, "a poll has no timeout"); + } + + @Test + void aLongPollLoopDoesNotKeepEarlierResponsesReachable() throws InterruptedException { + // Each poll answers with a 1 MiB body; the loop is still pending after several polls. The + // future the caller holds must not keep the earlier polls' responses (or parsed runs) alive. + // The test needs the JVM to honour System.gc(); skip it where it does not + // (-XX:+DisableExplicitGC). + WeakReference control = new WeakReference<>(new Object()); + System.gc(); + assumeTrue(control.get() == null, "System.gc() is a no-op"); + + List> bodies = new CopyOnWriteArrayList<>(); + String padding = "x".repeat(1 << 20); + MockTransport backend = + MockTransport.ofRouter( + request -> { + MockTransport.Scripted response = + MockTransport.ok( + 200, + "{\"data\":{\"id\":\"r1\",\"status\":\"RUNNING\",\"statusMessage\":\"" + + padding + + "\"}}"); + bodies.add(new WeakReference<>(response.body)); + return response; + }); + CompletableFuture pending = client(backend, 0).run("r1").waitForFinish(600L); + await(() -> backend.calls >= 8); + + int collected = 0; + for (int attempt = 0; attempt < 20 && collected < 4; attempt++) { + System.gc(); + Thread.sleep(50); + collected = 0; + for (var reference : List.copyOf(bodies).subList(0, bodies.size() - 2)) { + if (reference.get() == null) { + collected++; + } + } + } + pending.cancel(true); + assertTrue( + collected >= 4, "earlier poll responses stay reachable: only " + collected + " freed"); + } + + @Test + void cancellingCallWithACustomLogDestinationStillStopsThePolling() throws InterruptedException { + MockTransport backend = + MockTransport.ofHanging( + request -> + request.uri().getQuery() != null + && request.uri().getQuery().contains("waitForFinish")); + backend.scriptStream(200, "2999-01-01T00:00:00.000Z hello\n"); + var options = new ActorCallOptions().logOptions(new StreamedLogOptions().toLog(line -> {})); + CompletableFuture future = client(backend, 0).actor("a1").call(null, options, null); + await(() -> backend.hung.size() == 1); + + future.cancel(true); + + assertTrue(backend.allHungCancelled(), "the poll is aborted with a custom log destination"); + } + + @Test + void aCancelBeforeTheLogStreamOpensLeavesNoReaderThread() throws InterruptedException { + MockTransport backend = + MockTransport.ofHanging( + request -> + request.uri().getQuery() != null + && request.uri().getQuery().contains("waitForFinish")); + backend.scriptStream(200, "2999-01-01T00:00:00.000Z hello\n"); + CompletableFuture gate = new CompletableFuture<>(); + backend.gateStreams(gate); // the log stream does not open until the gate is released + long readersBefore = readerThreads(); + CompletableFuture future = + client(backend, 0).actor("a1").call(null, new ActorCallOptions(), null); + await(() -> backend.calls >= 3); // start, run fetch for the prefix, and the stream open request + + future.cancel(true); + gate.complete(null); // the held-back response arrives for an exchange that was cancelled + Thread.sleep(300); + + assertEquals(readersBefore, readerThreads(), "no log reader thread is left behind"); + } + + @Test + void closingAStreamedLogWhileItsStreamOpensLaunchesNoReader() throws Exception { + MockTransport backend = MockTransport.ofConstant(200, "{\"data\":{\"id\":\"r1\"}}"); + backend.scriptStream(200, "2999-01-01T00:00:00.000Z hello\n"); + CompletableFuture gate = new CompletableFuture<>(); + backend.gateStreams(gate); + long readersBefore = readerThreads(); + var log = + client(backend, 0) + .run("r1") + .getStreamedLog(new StreamedLogOptions().toLog(line -> {})) + .join(); + + CompletableFuture started = log.start(); + log.close(); + gate.complete(null); + Thread.sleep(300); + + assertEquals(readersBefore, readerThreads()); + assertTrue(started.isCancelled(), "the start that was closed is cancelled"); + log.start().join(); // a closed-while-opening log can be started again + log.close(); + } + + @Test + void aDestinationThatClosesTheLogCannotDeadlockTheStageChainedOnStart() throws Exception { + MockTransport backend = MockTransport.ofConstant(200, "{\"data\":{\"id\":\"r1\"}}"); + backend.scriptStream(200, "2999-01-01T00:00:00.000Z hello\n"); + CompletableFuture gate = new CompletableFuture<>(); + backend.gateStreams(gate); + CompletableFuture sawLine = new CompletableFuture<>(); + var holder = new AtomicReference(); + holder.set( + client(backend, 0) + .run("r1") + .getStreamedLog( + new StreamedLogOptions() + .toLog( + line -> { + holder.get().close(); // allowed from the destination: takes the lock + sawLine.complete(null); + })) + .join()); + + // A stage chained on start() waits for the destination, which needs the StreamedLog lock: the + // future must therefore be completed after the lock is released. + CompletableFuture chained = + holder.get().start().thenRun(() -> sawLine.orTimeout(5, TimeUnit.SECONDS).join()); + gate.complete(null); + + chained.get(10, TimeUnit.SECONDS); + } + + private static long readerThreads() { + return Thread.getAllStackTraces().keySet().stream() + .filter(thread -> thread.getName().equals("apify-streamed-log") && thread.isAlive()) + .count(); + } + + @Test + void aCancelThatRacesAStreamResponseClosesItsBody() throws Exception { + AtomicBoolean closed = new AtomicBoolean(); + InputStream body = + new ByteArrayInputStream(new byte[0]) { + @Override + public void close() { + closed.set(true); + } + }; + MockTransport mock = MockTransport.ofConstant(200, ""); + mock.scriptStream(200, body); + HttpRequest request = HttpRequest.newBuilder(URI.create("https://x/y")).build(); + HttpResponse response = mock.sendStreamingAsync(request).join(); + // An exchange that has already produced its response: cancelling it is a no-op, as in the JDK + // client once the headers have arrived but before the callback has run. + CompletableFuture> exchange = + new CompletableFuture<>() { + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + return false; + } + }; + HttpTransport transport = + new HttpTransport() { + @Override + public CompletableFuture> sendAsync(HttpRequest r) { + return mock.sendAsync(r); + } + + @Override + public CompletableFuture> sendStreamingAsync(HttpRequest r) { + return exchange; + } + }; + CompletableFuture stream = client(transport, 0).run("r1").log().stream(); + + assertTrue(stream.cancel(true)); + exchange.complete(response); + + await(closed::get); + } + + @Test + void cancellingACopyDoesNotCancelTheOriginal() throws InterruptedException { + MockTransport backend = MockTransport.ofHanging(request -> true); + CompletableFuture shared = client(backend, 0).run("r1").get(); + await(() -> backend.calls == 1); + + assertTrue(shared.copy().cancel(true)); + + assertFalse(shared.isCancelled()); + assertFalse(backend.allHungCancelled(), "the request behind the original keeps running"); + } + + @Test + void aCopyOfAFailedFutureCompletesWithACompletionException() { + CancellableFuture original = new CancellableFuture<>(); + IllegalStateException failure = new IllegalStateException("boom"); + CompletableFuture seen = new CompletableFuture<>(); + original.copy().whenComplete((value, error) -> seen.complete(error)); + + original.completeExceptionally(failure); + + Throwable error = seen.join(); + assertTrue(error instanceof CompletionException, String.valueOf(error)); + assertEquals(failure, error.getCause()); + } +} diff --git a/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java b/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java index 62b5771..a87dfa3 100644 --- a/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java +++ b/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java @@ -421,41 +421,7 @@ void getWithWaitForwardsWaitForFinish() { } @Test - void getWithWaitClampsServerWaitToConfiguredTimeout() { - // With a 10s per-request timeout, a caller asking for waitForFinish=60 must be clamped below - // the timeout (10 - 5s margin = 5) so the get can't abort itself on the socket timeout. - MockTransport backend = - MockTransport.ofConstant(200, "{\"data\":{\"id\":\"r1\",\"status\":\"RUNNING\"}}"); - ApifyClient client = - ApifyClient.builder() - .token("t") - .httpTransport(backend) - .maxRetries(0) - .timeout(Duration.ofSeconds(10)) - .build(); - client.run("r1").getWithWait(60L).join(); - assertTrue(backend.lastUrl.contains("waitForFinish=5"), backend.lastUrl); - assertFalse(backend.lastUrl.contains("waitForFinish=60"), backend.lastUrl); - } - - @Test - void waitForFinishClampsServerWaitToConfiguredTimeout() { - MockTransport backend = - MockTransport.ofConstant(200, "{\"data\":{\"id\":\"r1\",\"status\":\"SUCCEEDED\"}}"); - ApifyClient client = - ApifyClient.builder() - .token("t") - .httpTransport(backend) - .maxRetries(0) - .minDelayBetweenRetries(Duration.ofMillis(1)) - .timeout(Duration.ofSeconds(20)) - .build(); - client.run("r1").waitForFinish(120L).join(); - // 20s timeout - 5s margin = 15s server wait cap (below the 60s API cap and the 120s budget). - assertTrue(backend.lastUrl.contains("waitForFinish=15"), backend.lastUrl); - } - - @Test + @SuppressWarnings("deprecation") // the deprecated timeout(Duration) alias is what is under test void builderRejectsZeroOrNegativeTimeout() { // Unlike the delay params (where zero legitimately means "no delay"), a zero/negative timeout // must be rejected at build time: it would otherwise build a client whose first request fails diff --git a/src/test/java/com/apify/client/ClientInputTest.java b/src/test/java/com/apify/client/ClientInputTest.java new file mode 100644 index 0000000..d3cf95f --- /dev/null +++ b/src/test/java/com/apify/client/ClientInputTest.java @@ -0,0 +1,277 @@ +package com.apify.client; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.apify.client.actor.ActorStartOptions; +import com.apify.client.run.ActorRun; +import com.apify.client.run.RunListOptions; +import com.apify.client.store.StoreListOptions; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Offline tests for what the client sends: pushed dataset items, Actor inputs, default headers, + * path-safe resource ids, and the query parameters added with the OpenAPI spec updates. + */ +class ClientInputTest { + + private static final String RUN_JSON = "{\"data\":{\"id\":\"r1\",\"status\":\"READY\"}}"; + + private static ApifyClient client(MockTransport backend) { + return ApifyClient.builder().token("t").httpTransport(backend).maxRetries(0).build(); + } + + // ---- pushItems accepts only what the API accepts -------------------------------------------- + + @Test + void pushItemsSendsAnObjectOrAnArrayOfObjects() { + MockTransport backend = MockTransport.ofConstant(201, "{}"); + client(backend).dataset("d").pushItems(Map.of("a", 1)).join(); + assertEquals("{\"a\":1}", backend.lastBody); + + client(backend).dataset("d").pushItems(List.of(Map.of("a", 1), Map.of("b", 2))).join(); + assertEquals("[{\"a\":1},{\"b\":2}]", backend.lastBody); + } + + @Test + void pushItemsSendsAPreSerializedStringAsItIs() { + MockTransport backend = MockTransport.ofConstant(201, "{}"); + client(backend).dataset("d").pushItems("[{\"a\": 1}, {\"b\": 2}]").join(); + assertEquals("[{\"a\": 1}, {\"b\": 2}]", backend.lastBody, "not wrapped in a JSON string"); + } + + @Test + void pushItemsRejectsShapesTheApiWouldReject() { + MockTransport backend = MockTransport.ofConstant(201, "{}"); + var dataset = client(backend).dataset("d"); + assertThrows(IllegalArgumentException.class, () -> dataset.pushItems(null)); + assertThrows(IllegalArgumentException.class, () -> dataset.pushItems(42)); + assertThrows(IllegalArgumentException.class, () -> dataset.pushItems(List.of("a", "b"))); + assertThrows(IllegalArgumentException.class, () -> dataset.pushItems(new String[] {"a"})); + assertThrows(IllegalArgumentException.class, () -> dataset.pushItems("just text")); + assertThrows(IllegalArgumentException.class, () -> dataset.pushItems("[1, 2]")); + assertThrows( + IllegalArgumentException.class, () -> dataset.pushItems(List.of(Map.of("a", 1), "b"))); + assertEquals(0, backend.calls, "nothing is sent for an invalid payload"); + } + + // ---- Actor input ----------------------------------------------------------------------------- + + @Test + void aByteArrayInputIsSentAsRawBytes() { + MockTransport backend = MockTransport.ofConstant(201, RUN_JSON); + byte[] input = "{\"x\": 1}".getBytes(StandardCharsets.UTF_8); + client(backend).actor("a").start(input, new ActorStartOptions()).join(); + assertArrayEquals(input, backend.lastBodyBytes); + } + + @Test + void aStringInputIsSentAsItIsWithTheGivenContentType() { + MockTransport backend = MockTransport.ofConstant(201, RUN_JSON); + client(backend) + .actor("a") + .start("a,b\n1,2", new ActorStartOptions().contentType("text/csv")) + .join(); + assertEquals("a,b\n1,2", backend.lastBody); + assertEquals("text/csv", backend.lastHeaders.firstValue("Content-Type").orElse(null)); + } + + @Test + void anObjectInputIsSerializedToJson() { + MockTransport backend = MockTransport.ofConstant(201, RUN_JSON); + client(backend).actor("a").start(Map.of("k", "v"), new ActorStartOptions()).join(); + assertEquals("{\"k\":\"v\"}", backend.lastBody); + } + + // ---- default headers ------------------------------------------------------------------------- + + @Test + void defaultHeadersAreSentWithEveryRequestAndWinOverTheBuiltInOnes() { + MockTransport backend = MockTransport.ofConstant(200, RUN_JSON); + ApifyClient client = + ApifyClient.builder() + .token("t") + .httpTransport(backend) + .maxRetries(0) + .header("X-Integration", "my-platform") + .header("user-agent", "custom-agent/1") + .build(); + client.run("r1").get().join(); + assertEquals("my-platform", backend.lastHeaders.firstValue("X-Integration").orElse(null)); + assertEquals( + List.of("custom-agent/1"), + backend.lastHeaders.allValues("User-Agent"), + "replaced, not added"); + assertEquals("Bearer t", backend.lastHeaders.firstValue("Authorization").orElse(null)); + } + + @Test + void aHeaderSetAgainReplacesTheEarlierOneCaseInsensitively() { + MockTransport backend = MockTransport.ofConstant(200, RUN_JSON); + ApifyClient.builder() + .token("t") + .httpTransport(backend) + .header("X-A", "1") + .header("x-a", "2") + .build() + .run("r1") + .get() + .join(); + assertEquals(List.of("2"), backend.lastHeaders.allValues("X-A")); + } + + @Test + void builderRejectsABlankHeaderNameAndANullValue() { + assertThrows(IllegalArgumentException.class, () -> ApifyClient.builder().header(" ", "v")); + assertThrows(IllegalArgumentException.class, () -> ApifyClient.builder().header("X", null)); + } + + // ---- path-safe resource ids ------------------------------------------------------------------ + + @Test + void resourceIdsAreEncodedIntoASinglePathSegment() { + MockTransport backend = MockTransport.ofConstant(200, RUN_JSON); + ApifyClient client = client(backend); + client.run("r1?x=1#frag").get().join(); + assertTrue(backend.lastUrl.contains("/actor-runs/r1%3Fx%3D1%23frag"), backend.lastUrl); + + client.run("../../users/me").get().join(); + assertTrue(backend.lastUrl.contains("/actor-runs/..~..~users~me"), backend.lastUrl); + + client.actor("apify/hello-world").get().join(); + assertTrue(backend.lastUrl.contains("/actors/apify~hello-world"), backend.lastUrl); + } + + @Test + void anEmptyOrDotResourceIdIsRejectedInsteadOfAddressingTheCollection() { + ApifyClient client = client(MockTransport.ofConstant(200, RUN_JSON)); + assertThrows(IllegalArgumentException.class, () -> client.run("")); + assertThrows(IllegalArgumentException.class, () -> client.run("..")); + assertThrows(IllegalArgumentException.class, () -> client.dataset(".")); + } + + // ---- users by id ----------------------------------------------------------------------------- + + @Test + void limitsAndMonthlyUsageAreAvailableForAnyUserId() { + MockTransport backend = MockTransport.ofConstant(200, "{\"data\":{}}"); + client(backend).user("someone").limits().join(); + assertTrue(backend.lastUrl.endsWith("/users/someone/limits"), backend.lastUrl); + + client(backend).user("someone").monthlyUsage("2026-10-01").join(); + assertTrue( + backend.lastUrl.contains("/users/someone/usage/monthly?date=2026-10-01"), backend.lastUrl); + + client(backend).user("someone").updateLimits(Map.of("maxMonthlyUsageUsd", 5)).join(); + assertEquals("PUT", backend.lastMethod); + assertTrue(backend.lastUrl.endsWith("/users/someone/limits"), backend.lastUrl); + } + + // ---- query parameters added with the spec ---------------------------------------------------- + + @Test + void runListsFilterByStandbyDeployment() { + MockTransport backend = MockTransport.ofConstant(200, "{\"data\":{\"items\":[],\"total\":0}}"); + RunListOptions filter = + new RunListOptions() + .status(List.of("RUNNING")) + .deployment(List.of("SINGLE_TENANT", "MULTI_TENANT_FREE_USERS")); + client(backend).runs().list(new ListOptions(), filter).join(); + assertTrue( + backend.lastUrl.contains("deployment=SINGLE_TENANT%2CMULTI_TENANT_FREE_USERS"), + backend.lastUrl); + client(backend).actor("a").runs().list(new ListOptions(), filter).join(); + assertTrue(backend.lastUrl.contains("deployment="), backend.lastUrl); + } + + @Test + void storeListCanIncludeTheInputSchema() { + MockTransport backend = MockTransport.ofConstant(200, "{\"data\":{\"items\":[],\"total\":0}}"); + client(backend).store().list(new StoreListOptions().includeInputSchema(true)).join(); + assertTrue(backend.lastUrl.contains("includeInputSchema=1"), backend.lastUrl); + } + + // ---- new response fields --------------------------------------------------------------------- + + @Test + void runExposesTheServerActorDeploymentAndTheNewFields() { + MockTransport backend = + MockTransport.ofConstant( + 200, + "{\"data\":{\"id\":\"r1\",\"standby\":{\"deployment\":\"MULTI_TENANT_PAYING_USERS\"}," + + "\"consoleUrl\":\"https://console.apify.com/actors/runs/r1\"," + + "\"externalChargedTotalUsd\":0.5," + + "\"eventUsage\":{\"x\":{\"eventTotalUsd\":1.0,\"eventTitle\":\"X\"}}," + + "\"meta\":{\"origin\":\"API\",\"actorRunId\":\"parent\"}," + + "\"options\":{\"isMaxTotalChargeUsdSetByUser\":true}}}"); + ActorRun run = client(backend).run("r1").get().join().orElseThrow(); + assertEquals("MULTI_TENANT_PAYING_USERS", run.getStandby().getDeployment()); + assertEquals(0.5, run.getExternalChargedTotalUsd()); + assertEquals("parent", run.getMeta().getActorRunId()); + assertTrue(run.getOptions().getIsMaxTotalChargeUsdSetByUser()); + assertEquals(1.0, run.getEventUsage().get("x").get("eventTotalUsd").asDouble()); + } + + @Test + void aRunWithoutStandbyHasNone() { + MockTransport backend = + MockTransport.ofConstant(200, "{\"data\":{\"id\":\"r1\",\"standby\":null}}"); + assertEquals(null, client(backend).run("r1").get().join().orElseThrow().getStandby()); + } + + @Test + void scheduleToleratesANullUiState() { + MockTransport backend = + MockTransport.ofConstant( + 200, "{\"data\":{\"id\":\"s1\",\"cronExpression\":\"* * * * *\",\"uiState\":null}}"); + var schedule = client(backend).schedule("s1").get().join().orElseThrow(); + assertEquals(null, schedule.getUiState()); + + backend = + MockTransport.ofConstant( + 200, "{\"data\":{\"id\":\"s1\",\"uiState\":{\"mode\":\"advanced\"}}}"); + schedule = client(backend).schedule("s1").get().join().orElseThrow(); + assertEquals("advanced", schedule.getUiState().get("mode").asString()); + } + + @Test + void aPendingRequestKeepsANullHandledAt() { + MockTransport backend = + MockTransport.ofConstant( + 200, + "{\"data\":{\"id\":\"q1\",\"uniqueKey\":\"u\",\"url\":\"https://x.test\",\"handledAt\":null}}"); + var request = client(backend).requestQueue("q").getRequest("q1").join().orElseThrow(); + assertEquals(null, request.getHandledAt()); + assertFalse(backend.lastUrl.isEmpty()); + } + + @Test + void webhookIntegrationFieldsAreTyped() { + MockTransport backend = + MockTransport.ofConstant( + 200, + "{\"data\":{\"id\":\"w1\",\"isApifyIntegration\":true,\"isEnabled\":true," + + "\"actionType\":\"SLACK_MESSAGE\",\"title\":\"T\"," + + "\"slackMessage\":{\"channelId\":\"C1\"}}}"); + var webhook = client(backend).webhook("w1").get().join().orElseThrow(); + assertTrue(webhook.getIsApifyIntegration()); + assertEquals("SLACK_MESSAGE", webhook.getActionType()); + assertEquals("C1", webhook.getSlackMessage().get("channelId").asString()); + } + + @Test + void clientIsBuildableWithTheLegacyAndNewTimeoutSettersTogether() { + ApifyClient.builder() + .token("t") + .timeoutShort(Duration.ofSeconds(1)) + .timeoutMax(Duration.ofSeconds(30)) + .build(); + } +} diff --git a/src/test/java/com/apify/client/CompressionTest.java b/src/test/java/com/apify/client/CompressionTest.java index 4392dd2..4709c92 100644 --- a/src/test/java/com/apify/client/CompressionTest.java +++ b/src/test/java/com/apify/client/CompressionTest.java @@ -3,34 +3,36 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assumptions.assumeTrue; import com.aayushatharva.brotli4j.Brotli4jLoader; import com.aayushatharva.brotli4j.decoder.Decoder; import com.aayushatharva.brotli4j.decoder.DirectDecompress; +import com.apify.client.http.ApifyTransportException; +import com.apify.client.http.BrotliHttpCompressor; +import com.apify.client.http.GzipHttpCompressor; +import com.apify.client.http.HttpCompressor; import com.apify.client.internal.HttpClientCore; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; import java.util.Locale; +import java.util.Random; +import java.util.concurrent.CompletionException; import java.util.zip.GZIPInputStream; import org.junit.jupiter.api.Test; /** * Offline tests for request-body compression. Mirrors the reference JS client: bodies of at least * 1024 bytes are compressed and announced via {@code Content-Encoding}, smaller bodies are sent - * verbatim. The client prefers brotli ({@code br}) and falls back to gzip; both codings are - * exercised here — the brotli/gzip decision (a pure function) directly, and the live client path - * via a real request. - * - *

Not exercised here: {@code HttpClientCore.compress}'s per-call fallback from a - * failing brotli encode to gzip (as opposed to the upfront {@code BROTLI_AVAILABLE} check, - * which the brotli-path tests below do exercise). Accepted gap: brotli4j's {@code Encoder.compress} - * has no portable way to be forced to throw once the native codec has loaded, and the fallback only - * chooses between two codings already covered above. + * verbatim. The client uses brotli ({@code br}) by default where its native codec is available and + * gzip elsewhere; the compressor is configurable, and its failure fails the request. Both + * compressors are exercised directly, and the live client path via a real request. */ class CompressionTest { @@ -82,28 +84,165 @@ private static boolean nativeBrotliExpected() { return linux && commonArch && !musl; } - // --- Pure encoding decision: both paths are directly reachable and verified round-trip. --- + // --- The compressors themselves: both codings are reachable and verified round-trip. --- @Test - void brotliPathEncodesAsBrAndRoundTrips() throws IOException { - assumeTrue(HttpClientCore.brotliAvailable(), "no brotli native codec on this platform"); + void brotliCompressorEncodesAsBrAndRoundTrips() throws IOException { + assumeTrue(BrotliHttpCompressor.isAvailable(), "no brotli native codec on this platform"); byte[] payload = payload(4096, (byte) 'a'); - HttpClientCore.Compressed c = HttpClientCore.compress(payload, true); + HttpCompressor compressor = new BrotliHttpCompressor(); + byte[] compressed = compressor.compress(payload); - assertEquals("br", c.encoding(), "brotli path must announce Content-Encoding: br"); - assertTrue(c.body().length < payload.length, "brotli-compressed body should be smaller"); - assertArrayEquals( - payload, unbrotli(c.body()), "server must recover the original body from brotli"); + assertEquals("br", compressor.contentEncoding()); + assertTrue(compressed.length < payload.length, "brotli-compressed body should be smaller"); + assertArrayEquals(payload, unbrotli(compressed)); } @Test - void gzipPathEncodesAsGzipAndRoundTrips() throws IOException { + void gzipCompressorEncodesAsGzipAndRoundTrips() throws IOException { byte[] payload = payload(4096, (byte) 'a'); - HttpClientCore.Compressed c = HttpClientCore.compress(payload, false); + HttpCompressor compressor = new GzipHttpCompressor(); + byte[] compressed = compressor.compress(payload); - assertEquals("gzip", c.encoding(), "gzip fallback must announce Content-Encoding: gzip"); - assertTrue(c.body().length < payload.length, "gzip-compressed body should be smaller"); - assertArrayEquals(payload, gunzip(c.body()), "server must recover the original body from gzip"); + assertEquals("gzip", compressor.contentEncoding()); + assertTrue(compressed.length < payload.length, "gzip-compressed body should be smaller"); + assertArrayEquals(payload, gunzip(compressed)); + } + + @Test + void gzipDefaultsToLevelSix() throws IOException { + assertEquals(6, GzipHttpCompressor.DEFAULT_LEVEL); + Random random = new Random(7); + String[] words = new String[300]; + for (int i = 0; i < words.length; i++) { + StringBuilder word = new StringBuilder(); + for (int j = 3 + random.nextInt(6); j > 0; j--) { + word.append((char) ('a' + random.nextInt(26))); + } + words[i] = word.toString(); + } + StringBuilder text = new StringBuilder(); + while (text.length() < 200_000) { + text.append(words[random.nextInt(words.length)]).append(' '); + } + byte[] payload = text.toString().getBytes(StandardCharsets.UTF_8); + + byte[] byDefault = new GzipHttpCompressor().compress(payload); + + assertArrayEquals(new GzipHttpCompressor(6).compress(payload), byDefault); + assertFalse( + Arrays.equals(new GzipHttpCompressor(9).compress(payload), byDefault), + "the payload must tell level 6 from level 9"); + } + + @Test + void gzipLevelTradesSizeForSpeed() throws IOException { + byte[] payload = new byte[64 * 1024]; + new Random(7).nextBytes(payload); + for (int i = 0; i < payload.length; i += 3) { + payload[i] = 'x'; // partly compressible, so the levels differ + } + byte[] fastest = new GzipHttpCompressor(1).compress(payload); + byte[] smallest = new GzipHttpCompressor(9).compress(payload); + + assertArrayEquals(payload, gunzip(fastest)); + assertArrayEquals(payload, gunzip(smallest)); + assertTrue(smallest.length <= fastest.length, "level 9 must not be larger than level 1"); + } + + @Test + void compressorsRejectAnOutOfRangeQualityAtConstruction() { + assertThrows(IllegalArgumentException.class, () -> new GzipHttpCompressor(0)); + assertThrows(IllegalArgumentException.class, () -> new GzipHttpCompressor(10)); + assertThrows(IllegalArgumentException.class, () -> new BrotliHttpCompressor(-1)); + assertThrows(IllegalArgumentException.class, () -> new BrotliHttpCompressor(12)); + } + + // --- The configured compressor is the one the client runs, with no silent fallback. --- + + @Test + void configuredGzipCompressorIsUsedForLargeBodies() throws IOException { + MockTransport backend = MockTransport.ofConstant(201, ""); + byte[] payload = payload(4096, (byte) 'a'); + + ApifyClient.builder() + .token("t") + .httpTransport(backend) + .maxRetries(0) + .compression(new GzipHttpCompressor(3)) + .build() + .keyValueStore(STORE_ID) + .setRecord(RECORD_KEY, payload, CONTENT_TYPE) + .join(); + + assertEquals("gzip", backend.lastHeaders.firstValue("Content-Encoding").orElse(null)); + assertArrayEquals(payload, gunzip(backend.lastBodyBytes)); + } + + @Test + void customCompressorIsUsedAndItsFailureFailsTheRequest() { + HttpCompressor failing = + new HttpCompressor() { + @Override + public String contentEncoding() { + return "x-custom"; + } + + @Override + public byte[] compress(byte[] data) throws IOException { + throw new IOException("boom"); + } + }; + MockTransport backend = MockTransport.ofConstant(201, ""); + ApifyClient client = + ApifyClient.builder() + .token("t") + .httpTransport(backend) + .maxRetries(0) + .compression(failing) + .build(); + + var future = + client + .keyValueStore(STORE_ID) + .setRecord(RECORD_KEY, payload(4096, (byte) 'a'), CONTENT_TYPE); + + var error = assertThrows(CompletionException.class, future::join); + assertTrue(error.getCause() instanceof ApifyTransportException); + assertEquals(0, backend.calls, "a body that could not be compressed must not be sent"); + } + + @Test + void aCompressorThatThrowsAnErrorFailsTheFutureInsteadOfThrowing() { + HttpCompressor broken = + new HttpCompressor() { + @Override + public String contentEncoding() { + return "x-broken"; + } + + @Override + public byte[] compress(byte[] data) { + throw new UnsatisfiedLinkError("native codec missing"); + } + }; + MockTransport backend = MockTransport.ofConstant(201, ""); + ApifyClient client = + ApifyClient.builder().token("t").httpTransport(backend).compression(broken).build(); + + var future = + client + .keyValueStore(STORE_ID) + .setRecord(RECORD_KEY, payload(4096, (byte) 'a'), CONTENT_TYPE); + + var error = assertThrows(CompletionException.class, future::join); + assertTrue(error.getCause() instanceof ApifyTransportException); + assertEquals(0, backend.calls); + } + + @Test + void builderRejectsNullCompression() { + assertThrows(IllegalArgumentException.class, () -> ApifyClient.builder().compression(null)); } // --- Brotli native codec must load where a native is bundled (CI/runtime), so brotli runs. --- @@ -112,7 +251,7 @@ void gzipPathEncodesAsGzipAndRoundTrips() throws IOException { void brotliNativeCodecLoadsWhereBundled() { assumeTrue(nativeBrotliExpected(), "no brotli native bundled for this platform; gzip fallback"); assertTrue( - HttpClientCore.brotliAvailable(), + BrotliHttpCompressor.isAvailable(), "brotli native codec must load on glibc x86_64/aarch64 so the preferred brotli path runs"); } @@ -125,7 +264,7 @@ void largeBodyIsCompressedWithPreferredEncoding() throws IOException { client(backend).keyValueStore(STORE_ID).setRecord(RECORD_KEY, payload, CONTENT_TYPE).join(); - boolean brotli = HttpClientCore.brotliAvailable(); + boolean brotli = BrotliHttpCompressor.isAvailable(); String expected = brotli ? "br" : "gzip"; assertEquals( expected, @@ -201,7 +340,7 @@ void rawFormatUnderCompressedPrefixIsStillCompressed() throws IOException { // image/bmp sits under the "image/" prefix but is raw, so it should still be compressed. client(backend).keyValueStore(STORE_ID).setRecord(RECORD_KEY, payload, "image/bmp").join(); - boolean brotli = HttpClientCore.brotliAvailable(); + boolean brotli = BrotliHttpCompressor.isAvailable(); assertEquals( brotli ? "br" : "gzip", backend.lastHeaders.firstValue("Content-Encoding").orElse(null)); byte[] recovered = brotli ? unbrotli(backend.lastBodyBytes) : gunzip(backend.lastBodyBytes); @@ -215,7 +354,7 @@ void bodyExactlyAtThresholdIsCompressed() throws IOException { client(backend).keyValueStore(STORE_ID).setRecord(RECORD_KEY, payload, CONTENT_TYPE).join(); - boolean brotli = HttpClientCore.brotliAvailable(); + boolean brotli = BrotliHttpCompressor.isAvailable(); assertEquals( brotli ? "br" : "gzip", backend.lastHeaders.firstValue("Content-Encoding").orElse(null), diff --git a/src/test/java/com/apify/client/MockTransport.java b/src/test/java/com/apify/client/MockTransport.java index e013f8a..c9a9cec 100644 --- a/src/test/java/com/apify/client/MockTransport.java +++ b/src/test/java/com/apify/client/MockTransport.java @@ -2,6 +2,7 @@ import com.apify.client.http.HttpTimeoutException; import com.apify.client.http.HttpTransport; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; @@ -12,13 +13,18 @@ import java.net.http.HttpResponse; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.ArrayList; +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.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Flow; +import java.util.function.Function; +import java.util.function.Predicate; import javax.net.ssl.SSLSession; /** @@ -57,6 +63,31 @@ static final class Scripted { final List bodies = new ArrayList<>(); final List urls = new ArrayList<>(); + /** The per-attempt timeout of the last request, empty when the request had none. */ + Optional lastTimeout = Optional.empty(); + + /** The per-attempt timeout of every request, in order. */ + final List> timeouts = new ArrayList<>(); + + /** + * Requests this predicate accepts are never answered; their futures are kept in {@link #hung}. + */ + private Predicate hangWhen = request -> false; + + /** The futures of the requests that were left unanswered. */ + final List>> hung = new CopyOnWriteArrayList<>(); + + /** When set, streaming responses are delivered only once this gate completes. */ + private volatile CompletableFuture streamGate; + + /** Holds back every streaming response until {@code gate} completes. */ + void gateStreams(CompletableFuture gate) { + this.streamGate = gate; + } + + /** When set, answers each request itself instead of serving the scripted queue. */ + private Function router; + /** One scripted response for {@link #sendStreamingAsync}: a status + ready-made body stream. */ private static final class StreamEntry { final int status; @@ -82,6 +113,25 @@ private static final class StreamEntry { this.responses = responses; } + /** A transport that answers every request with {@code router}'s response for it. */ + static MockTransport ofRouter(Function router) { + MockTransport transport = new MockTransport(List.of(new Scripted(500, "no route", null))); + transport.router = router; + return transport; + } + + /** A transport that never answers the requests {@code hangWhen} accepts. */ + static MockTransport ofHanging(Predicate hangWhen) { + MockTransport transport = ofConstant(200, "{\"data\":{\"id\":\"r1\",\"status\":\"RUNNING\"}}"); + transport.hangWhen = hangWhen; + return transport; + } + + /** Whether every request left unanswered has been cancelled by the client. */ + boolean allHungCancelled() { + return !hung.isEmpty() && hung.stream().allMatch(CompletableFuture::isCancelled); + } + static MockTransport ofConstant(int status, String body) { return new MockTransport(List.of(new Scripted(status, body, null))); } @@ -114,15 +164,22 @@ public synchronized CompletableFuture> sendAsync(HttpReques lastUrl = request.uri().toString(); urls.add(lastUrl); lastMethod = request.method(); + lastTimeout = request.timeout(); + timeouts.add(lastTimeout); lastBodyBytes = readBody(request); lastBody = lastBodyBytes == null ? null : new String(lastBodyBytes, StandardCharsets.UTF_8); if (lastBody != null) { bodies.add(lastBody); } + if (hangWhen.test(request)) { + CompletableFuture> never = new CompletableFuture<>(); + hung.add(never); + return never; + } if (idx >= responses.size()) { idx = responses.size() - 1; // repeat the last entry } - Scripted r = responses.get(idx); + Scripted r = router != null ? router.apply(request) : responses.get(idx); if (r.error != null) { CompletableFuture> failed = new CompletableFuture<>(); failed.completeExceptionally(r.error); @@ -139,8 +196,7 @@ public synchronized CompletableFuture> sendAsync(HttpReques */ synchronized void scriptStream(int status, String body) { streamQueue.add( - new StreamEntry( - status, new java.io.ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)))); + new StreamEntry(status, new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)))); } /** @@ -158,16 +214,19 @@ public synchronized CompletableFuture> sendStreamingAs calls++; lastUrl = request.uri().toString(); lastMethod = request.method(); + HttpResponse response; if (streamQueue.isEmpty()) { Scripted r = responses.get(0); - return CompletableFuture.completedFuture( - new FakeStreamResponse( - request.uri(), r.status, new java.io.ByteArrayInputStream(r.body))); + response = new FakeStreamResponse(request.uri(), r.status, new ByteArrayInputStream(r.body)); + } else { + int idx = Math.min(streamCallIndex++, streamQueue.size() - 1); + StreamEntry entry = streamQueue.get(idx); + response = new FakeStreamResponse(request.uri(), entry.status, entry.body); } - int idx = Math.min(streamCallIndex++, streamQueue.size() - 1); - StreamEntry entry = streamQueue.get(idx); - return CompletableFuture.completedFuture( - new FakeStreamResponse(request.uri(), entry.status, entry.body)); + CompletableFuture gate = streamGate; + return gate == null + ? CompletableFuture.completedFuture(response) + : gate.thenApply(unused -> response); } private static byte[] readBody(HttpRequest request) { @@ -246,7 +305,7 @@ public Optional> previousResponse() { @Override public HttpHeaders headers() { - Map> multi = new java.util.LinkedHashMap<>(); + Map> multi = new LinkedHashMap<>(); headers.forEach((k, v) -> multi.put(k, List.of(v))); return HttpHeaders.of(multi, (a, b) -> true); } diff --git a/src/test/java/com/apify/client/RunDatasetItemsTest.java b/src/test/java/com/apify/client/RunDatasetItemsTest.java new file mode 100644 index 0000000..e6dda8a --- /dev/null +++ b/src/test/java/com/apify/client/RunDatasetItemsTest.java @@ -0,0 +1,234 @@ +package com.apify.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.apify.client.dataset.DatasetListItemsOptions; +import com.apify.client.run.LastRunOptions; +import java.net.URI; +import java.net.http.HttpRequest; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.JsonNode; + +/** + * {@code RunClient.iterateDatasetItems}: iterating a run's dataset while the run is still producing + * items, against a scripted run and dataset. + */ +class RunDatasetItemsTest { + + /** A fake run + dataset: {@code itemCount} grows by one batch per run poll. */ + private static final class FakeRun { + final List rows = new ArrayList<>(); + final List requests = new ArrayList<>(); + int itemCountReported; + String status = "RUNNING"; + final AtomicInteger runGets = new AtomicInteger(); + // Rows made visible per poll of the run, then the run finishes with `lagging` rows not yet + // counted. + final int rowsPerPoll; + final int totalRows; + final int laggingAtFinish; + + FakeRun(int totalRows, int rowsPerPoll, int laggingAtFinish) { + this.totalRows = totalRows; + this.rowsPerPoll = rowsPerPoll; + this.laggingAtFinish = laggingAtFinish; + for (int i = 0; i < totalRows; i++) { + rows.add("{\"n\":" + i + "}"); + } + } + + /** Replaces the first {@code count} rows with empty items. */ + FakeRun withEmptyHead(int count) { + for (int i = 0; i < count; i++) { + rows.set(i, "{}"); + } + return this; + } + + MockTransport.Scripted answer(HttpRequest request) { + URI uri = request.uri(); + String path = uri.getPath(); + String query = uri.getQuery() == null ? "" : uri.getQuery(); + requests.add(path + (query.isEmpty() ? "" : "?" + query)); + if (path.endsWith("/actor-runs/r1")) { + int poll = runGets.incrementAndGet(); + // The first get resolves the run; each later poll reveals another batch, then finishes. + int visible = Math.min(poll * rowsPerPoll, totalRows); + boolean done = visible >= totalRows; + itemCountReported = done ? totalRows - laggingAtFinish : visible; + status = done ? "SUCCEEDED" : "RUNNING"; + return MockTransport.ok(200, "{\"data\":{\"id\":\"r1\",\"status\":\"" + status + "\"}}"); + } + if (path.endsWith("/actor-runs/r1/dataset")) { + return MockTransport.ok( + 200, "{\"data\":{\"id\":\"d1\",\"itemCount\":" + itemCountReported + "}}"); + } + if (path.endsWith("/actor-runs/r1/dataset/items")) { + Map params = new HashMap<>(); + for (String pair : query.split("&")) { + String[] kv = pair.split("=", 2); + params.put(kv[0], kv.length > 1 ? kv[1] : ""); + } + int offset = Integer.parseInt(params.getOrDefault("offset", "0")); + int limit = Integer.parseInt(params.getOrDefault("limit", "1000")); + int end = Math.min(totalRows, offset + limit); + List scanned = offset >= end ? List.of() : rows.subList(offset, end); + // skipEmpty drops the empty rows from what is returned, but they were still scanned. + List page = + "1".equals(params.get("skipEmpty")) + ? scanned.stream().filter(row -> !row.equals("{}")).toList() + : scanned; + return MockTransport.ok( + 200, + "[" + String.join(",", page) + "]", + Map.of("X-Apify-Pagination-Count", String.valueOf(scanned.size()))); + } + return MockTransport.ok(404, "{\"error\":{\"type\":\"record-not-found\",\"message\":\"x\"}}"); + } + } + + private static ApifyClient client(Function router) { + return ApifyClient.builder() + .token("t") + .httpTransport(MockTransport.ofRouter(router)) + .maxRetries(0) + .minDelayBetweenRetries(Duration.ofMillis(1)) + .build(); + } + + private static List ns(List items) { + return items.stream().map(n -> n.get("n").asInt()).toList(); + } + + private static List range(int fromInclusive, int toExclusive) { + List out = new ArrayList<>(); + for (int i = fromInclusive; i < toExclusive; i++) { + out.add(i); + } + return out; + } + + @Test + void readsRowsWhileTheRunProducesThemAndTheTailAfterItFinishes() { + // 7 rows, 3 become visible per poll; when the run finishes the counter still lags by 2 rows. + FakeRun fake = new FakeRun(7, 3, 2); + List items = + Publishers.collect( + client(fake::answer) + .run("r1") + .iterateDatasetItems(new DatasetListItemsOptions(), 2L, 0L, JsonNode.class)) + .join(); + assertEquals(range(0, 7), ns(items), "every row exactly once, in order"); + } + + @Test + void aFinishedRunIsReadInPagesOfTheChunkSize() { + FakeRun fake = new FakeRun(5, 10, 0); + List items = + Publishers.collect( + client(fake::answer) + .run("r1") + .iterateDatasetItems(new DatasetListItemsOptions(), 2L, 0L, JsonNode.class)) + .join(); + assertEquals(range(0, 5), ns(items)); + long itemRequests = fake.requests.stream().filter(r -> r.contains("/dataset/items")).count(); + assertTrue(itemRequests >= 3, "5 rows in pages of 2 need at least 3 item requests"); + assertTrue( + fake.requests.stream() + .filter(r -> r.contains("/dataset/items")) + .anyMatch(r -> r.contains("limit=2")), + fake.requests.toString()); + } + + @Test + void offsetAndLimitBoundTheRowsThatAreRead() { + FakeRun fake = new FakeRun(20, 20, 0); + List items = + Publishers.collect( + client(fake::answer) + .run("r1") + .iterateDatasetItems( + new DatasetListItemsOptions().offset(4L).limit(6L), 4L, 0L, JsonNode.class)) + .join(); + assertEquals(range(4, 10), ns(items)); + } + + @Test + void anEmptyDatasetEndsTheIterationOnceTheRunHasFinished() { + FakeRun fake = new FakeRun(0, 1, 0); + List items = + Publishers.collect(client(fake::answer).run("r1").iterateDatasetItems(null)).join(); + assertTrue(items.isEmpty()); + } + + @Test + void orderIsNeverReversedByDesc() { + FakeRun fake = new FakeRun(4, 4, 0); + List items = + Publishers.collect( + client(fake::answer) + .run("r1") + .iterateDatasetItems(new DatasetListItemsOptions().desc(true))) + .join(); + assertEquals(range(0, 4), ns(items)); + assertTrue( + fake.requests.stream().noneMatch(r -> r.contains("desc=")), fake.requests.toString()); + } + + @Test + void theTailKeepsReadingPastRowsAFilterEmptiedAndLaggingCounts() { + // Two empty rows, then two with data; the run has finished but the dataset's itemCount still + // reads 0, so every row is read by the tail. skipEmpty empties the first page entirely, which + // must not end the iteration: the next row is probed without the filter. + FakeRun fake = new FakeRun(4, 4, 4).withEmptyHead(2); + List items = + Publishers.collect( + client(fake::answer) + .run("r1") + .iterateDatasetItems( + new DatasetListItemsOptions().skipEmpty(true), 2L, 0L, JsonNode.class)) + .join(); + assertEquals(List.of(2, 3), ns(items)); + } + + @Test + void aLastRunClientStaysOnTheRunItFirstResolvedTo() { + FakeRun fake = new FakeRun(4, 2, 0); + AtomicInteger lastCalls = new AtomicInteger(); + List paths = new ArrayList<>(); + Function router = + request -> { + String path = request.uri().getPath(); + paths.add(path); + if (path.endsWith("/actors/a/runs/last")) { + String id = lastCalls.incrementAndGet() == 1 ? "r1" : "r2"; + return MockTransport.ok( + 200, "{\"data\":{\"id\":\"" + id + "\",\"status\":\"RUNNING\"}}"); + } + return fake.answer(request); + }; + List items = + Publishers.collect( + client(router) + .actor("a") + .lastRun(new LastRunOptions()) + .iterateDatasetItems(new DatasetListItemsOptions(), 2L, 0L, JsonNode.class)) + .join(); + + assertEquals(range(0, 4), ns(items)); + assertTrue( + paths.stream() + .filter(p -> !p.endsWith("/runs/last")) + .allMatch(p -> p.contains("/actor-runs/r1")), + paths.toString()); + assertTrue(paths.stream().noneMatch(p -> p.contains("r2")), paths.toString()); + } +} diff --git a/src/test/java/com/apify/client/TimeoutTierTest.java b/src/test/java/com/apify/client/TimeoutTierTest.java new file mode 100644 index 0000000..036fc97 --- /dev/null +++ b/src/test/java/com/apify/client/TimeoutTierTest.java @@ -0,0 +1,295 @@ +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.apify.client.actor.ActorListOptions; +import com.apify.client.actor.ActorStartOptions; +import com.apify.client.dataset.DatasetListItemsOptions; +import com.apify.client.http.ApifyTransportException; +import com.apify.client.http.Timeout; +import com.apify.client.keyvalue.SetRecordOptions; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletionException; +import org.junit.jupiter.api.Test; + +/** + * Offline tests for the timeout tiers: which tier each kind of method picks, how the builder + * configures the tiers and the cap, how {@link ApifyClient#withTimeout(Timeout)} overrides them, + * and how a retry grows the timeout of an attempt. + */ +class TimeoutTierTest { + + private static final String RUN_JSON = "{\"data\":{\"id\":\"r1\",\"status\":\"RUNNING\"}}"; + + private static ApifyClientBuilder builder(MockTransport backend) { + return ApifyClient.builder() + .token("t") + .httpTransport(backend) + .maxRetries(0) + .minDelayBetweenRetries(Duration.ofMillis(1)); + } + + private static ApifyClient client(MockTransport backend) { + return builder(backend).build(); + } + + private static Optional secs(long seconds) { + return Optional.of(Duration.ofSeconds(seconds)); + } + + @Test + void metadataReadUsesTheShortTier() { + MockTransport backend = MockTransport.ofConstant(200, RUN_JSON); + client(backend).run("r1").get().join(); + assertEquals(secs(5), backend.lastTimeout); + } + + @Test + void listingUsesTheMediumTier() { + MockTransport backend = MockTransport.ofConstant(200, "{\"data\":{\"items\":[],\"total\":0}}"); + client(backend).actors().list(new ActorListOptions()).join(); + assertEquals(secs(30), backend.lastTimeout); + } + + @Test + void downloadsAndRecordReadsUseTheLongTier() { + MockTransport backend = MockTransport.ofConstant(200, "[]"); + client(backend).dataset("d1").listItems(new DatasetListItemsOptions()).join(); + assertEquals(secs(360), backend.lastTimeout); + + backend = MockTransport.ofConstant(200, "abc"); + client(backend).keyValueStore("s1").getRecord("k").join(); + assertEquals(secs(360), backend.lastTimeout); + } + + @Test + void keyValueStoreUpdateAndRequestQueueUnlockFollowTheReferenceTiers() { + MockTransport backend = MockTransport.ofConstant(200, "{\"data\":{\"id\":\"s1\"}}"); + client(backend).keyValueStore("s1").update(Map.of("name", "x")).join(); + assertEquals(secs(360), backend.lastTimeout); + + backend = MockTransport.ofConstant(200, "{\"data\":{\"unlockedCount\":0}}"); + client(backend).requestQueue("q1").unlockRequests().join(); + assertEquals(secs(360), backend.lastTimeout); + } + + @Test + void startIsMediumExtendedByTheWaitForFinishHold() { + MockTransport backend = MockTransport.ofConstant(201, RUN_JSON); + client(backend).actor("a1").start(null, new ActorStartOptions()).join(); + assertEquals(secs(30), backend.lastTimeout, "no hold: the plain medium tier"); + + client(backend).actor("a1").start(null, new ActorStartOptions().waitForFinish(20L)).join(); + assertEquals(secs(50), backend.lastTimeout, "medium tier plus the 20s hold"); + + client(backend).actor("a1").start(null, new ActorStartOptions().waitForFinish(500L)).join(); + assertEquals(secs(90), backend.lastTimeout, "the hold is capped at the API's 60s"); + } + + @Test + void getWithWaitIsShortExtendedByTheHold() { + MockTransport backend = MockTransport.ofConstant(200, RUN_JSON); + client(backend).run("r1").getWithWait(30L).join(); + assertTrue(backend.lastUrl.contains("waitForFinish=30"), backend.lastUrl); + assertEquals(secs(35), backend.lastTimeout); + } + + @Test + void waitForFinishPollsHaveNoTimeoutAndHoldForTheRemainingBudget() { + MockTransport backend = + MockTransport.ofConstant(200, "{\"data\":{\"id\":\"r1\",\"status\":\"SUCCEEDED\"}}"); + client(backend).run("r1").waitForFinish(120L).join(); + assertEquals(Optional.empty(), backend.lastTimeout, "polls run without a timeout"); + assertTrue(backend.lastUrl.contains("waitForFinish=60"), backend.lastUrl); + } + + @Test + void callSendsTheStartWithoutATimeout() { + MockTransport backend = + MockTransport.ofRouter( + request -> + request.method().equals("POST") + ? MockTransport.ok(201, RUN_JSON) + : MockTransport.ok(200, "{\"data\":{\"id\":\"r1\",\"status\":\"SUCCEEDED\"}}")); + client(backend).actor("a1").call(null, new ActorStartOptions(), 10L).join(); + assertEquals(Optional.empty(), backend.timeouts.get(0), "the start inside call()"); + } + + @Test + void builderConfiguresEveryTier() { + MockTransport backend = MockTransport.ofConstant(200, RUN_JSON); + ApifyClient client = + builder(backend) + .timeoutShort(Duration.ofSeconds(2)) + .timeoutMedium(Duration.ofSeconds(7)) + .timeoutLong(Duration.ofSeconds(90)) + .build(); + client.run("r1").get().join(); + assertEquals(secs(2), backend.lastTimeout); + client.runs().list(new ListOptions(), null).join(); + assertEquals(secs(7), backend.lastTimeout); + client.keyValueStore("s").getRecord("k").join(); + assertEquals(secs(90), backend.lastTimeout); + } + + @Test + void aTierAboveTimeoutMaxIsCapped() { + MockTransport backend = MockTransport.ofConstant(200, RUN_JSON); + ApifyClient client = + builder(backend) + .timeoutShort(Duration.ofMinutes(10)) + .timeoutMax(Duration.ofMinutes(1)) + .build(); + client.run("r1").get().join(); + assertEquals(secs(60), backend.lastTimeout); + } + + @Test + void withTimeoutReplacesTheTierOfEveryRequest() { + MockTransport backend = MockTransport.ofConstant(200, RUN_JSON); + ApifyClient client = client(backend); + + client.withTimeout(Timeout.ofSeconds(120)).run("r1").get().join(); + assertEquals(secs(120), backend.lastTimeout); + + client.withTimeout(Timeout.LONG).run("r1").get().join(); + assertEquals(secs(360), backend.lastTimeout); + + client.withTimeout(Timeout.NO_TIMEOUT).run("r1").get().join(); + assertEquals(Optional.empty(), backend.lastTimeout); + + client.run("r1").get().join(); + assertEquals(secs(5), backend.lastTimeout, "the original client keeps its own tiers"); + } + + @Test + void withTimeoutIsKeptAndTheHoldIsCutToWhatItCovers() { + MockTransport backend = MockTransport.ofConstant(200, RUN_JSON); + client(backend).withTimeout(Timeout.ofSeconds(10)).run("r1").getWithWait(30L).join(); + assertEquals(secs(10), backend.lastTimeout, "the override is used as given"); + assertTrue( + backend.lastUrl.contains("waitForFinish=5"), "10s less the 5s margin: " + backend.lastUrl); + } + + @Test + void aHoldIsCutToTimeoutMaxLessTheMargin() { + MockTransport backend = MockTransport.ofConstant(200, RUN_JSON); + ApifyClient client = builder(backend).timeoutMax(Duration.ofSeconds(12)).build(); + + client.run("r1").getWithWait(30L).join(); + assertTrue(backend.lastUrl.contains("waitForFinish=7"), "5s tier, 12s cap: " + backend.lastUrl); + assertEquals(secs(12), backend.lastTimeout, "the request is not given up before the hold ends"); + + client.actor("a1").start(null, new ActorStartOptions().waitForFinish(60L)).join(); + assertTrue( + backend.lastUrl.contains("waitForFinish=7"), + "the 30s medium tier is capped at 12s: the hold is cut to the cap less the margin: " + + backend.lastUrl); + assertEquals(secs(12), backend.lastTimeout); + } + + @Test + void startKeepsWaitForFinishUnderTheDeprecatedTimeoutAlias() { + MockTransport backend = MockTransport.ofConstant(200, RUN_JSON); + ApifyClient client = builder(backend).timeout(Duration.ofSeconds(30)).build(); + + client.actor("a1").start(null, new ActorStartOptions().waitForFinish(20L)).join(); + + assertTrue(backend.lastUrl.contains("waitForFinish=20"), backend.lastUrl); + } + + @Test + void aTimedOutStartIsRetriedLikeAnyOtherRequest() { + MockTransport backend = + new MockTransport(List.of(MockTransport.timeoutError(), MockTransport.ok(201, RUN_JSON))); + ApifyClient client = builder(backend).maxRetries(3).build(); + client.actor("a1").start(null, new ActorStartOptions()).join(); + assertEquals(2, backend.calls); + } + + @Test + void aRetryDoublesTheTimeoutUpToTheCap() { + MockTransport backend = + new MockTransport( + List.of( + MockTransport.ok(500, "{}"), + MockTransport.ok(500, "{}"), + MockTransport.ok(500, "{}"), + MockTransport.ok(200, RUN_JSON))); + ApifyClient client = builder(backend).maxRetries(5).timeoutMax(Duration.ofSeconds(15)).build(); + client.run("r1").get().join(); + assertEquals(List.of(secs(5), secs(10), secs(15), secs(15)), backend.timeouts); + } + + @Test + void aTimedOutPushIsNotRetriedSoItemsAreNotStoredTwice() { + MockTransport backend = + new MockTransport(List.of(MockTransport.timeoutError(), MockTransport.ok(201, "{}"))); + ApifyClient client = builder(backend).maxRetries(3).build(); + + var future = client.dataset("d1").pushItems(Map.of("a", 1)); + var error = assertThrows(CompletionException.class, future::join); + assertTrue(error.getCause() instanceof ApifyTransportException); + assertEquals(1, backend.calls, "a push that timed out must not be sent again"); + } + + @Test + void setRecordTakesATierAnExactDurationOrNoTimeout() { + MockTransport backend = MockTransport.ofConstant(201, ""); + ApifyClient client = client(backend); + byte[] value = {1}; + + client.keyValueStore("s").setRecord("k", value, "text/plain").join(); + assertEquals(secs(360), backend.lastTimeout, "defaults to the long tier"); + + client + .keyValueStore("s") + .setRecord("k", value, "text/plain", new SetRecordOptions().timeoutSecs(12L)) + .join(); + assertEquals(secs(12), backend.lastTimeout); + + client + .keyValueStore("s") + .setRecord("k", value, "text/plain", new SetRecordOptions().timeout(Timeout.SHORT)) + .join(); + assertEquals(secs(5), backend.lastTimeout); + + client + .keyValueStore("s") + .setRecord("k", value, "text/plain", new SetRecordOptions().timeout(Timeout.NO_TIMEOUT)) + .join(); + assertEquals(Optional.empty(), backend.lastTimeout); + } + + @Test + void timeoutsAreValidated() { + assertThrows(IllegalArgumentException.class, () -> Timeout.of(Duration.ZERO)); + assertThrows(IllegalArgumentException.class, () -> Timeout.ofSeconds(-1)); + assertThrows(IllegalArgumentException.class, () -> Timeout.of(null)); + assertThrows( + IllegalArgumentException.class, () -> ApifyClient.builder().timeoutShort(Duration.ZERO)); + assertThrows(IllegalArgumentException.class, () -> ApifyClient.builder().timeoutMedium(null)); + assertThrows( + IllegalArgumentException.class, + () -> ApifyClient.builder().timeoutLong(Duration.ofSeconds(-1))); + assertThrows( + IllegalArgumentException.class, () -> ApifyClient.builder().timeoutMax(Duration.ZERO)); + assertThrows(IllegalArgumentException.class, () -> ApifyClient.create("t").withTimeout(null)); + } + + @Test + void timeoutValuesDescribeThemselves() { + assertEquals(Timeout.ofSeconds(3), Timeout.of(Duration.ofSeconds(3))); + assertFalse(Timeout.SHORT.equals(Timeout.MEDIUM)); + assertEquals(Optional.of(Timeout.Tier.LONG), Timeout.LONG.tier()); + assertTrue(Timeout.NO_TIMEOUT.isNoTimeout()); + assertEquals(Optional.of(Duration.ofSeconds(3)), Timeout.ofSeconds(3).duration()); + assertEquals("Timeout.NO_TIMEOUT", Timeout.NO_TIMEOUT.toString()); + } +} diff --git a/src/test/java/com/apify/client/UrlNormalizationTest.java b/src/test/java/com/apify/client/UrlNormalizationTest.java new file mode 100644 index 0000000..29d0d2a --- /dev/null +++ b/src/test/java/com/apify/client/UrlNormalizationTest.java @@ -0,0 +1,161 @@ +package com.apify.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.apify.client.dataset.Dataset; +import com.apify.client.internal.Json; +import com.apify.client.internal.UrlNormalizer; +import com.apify.client.run.ActorRun; +import com.apify.client.webhook.Webhook; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +/** + * The spec's {@code format: uri} fields come back in the WHATWG URL serialization, so every Apify + * client returns the same string for the same field. + */ +class UrlNormalizationTest { + + @Test + void emptyPathBecomesASlashAndTheHostIsLowercased() { + assertEquals("https://example.com/", UrlNormalizer.normalize("https://EXAMPLE.com")); + assertEquals("http://example.com/a", UrlNormalizer.normalize("HTTP://Example.COM/a")); + } + + @Test + void defaultPortsAreDroppedAndOthersKept() { + assertEquals("https://example.com/", UrlNormalizer.normalize("https://example.com:443")); + assertEquals("http://example.com/", UrlNormalizer.normalize("http://example.com:80/")); + assertEquals( + "https://example.com:8443/x", UrlNormalizer.normalize("https://example.com:8443/x")); + assertEquals("http://example.com:443/", UrlNormalizer.normalize("http://example.com:443/")); + } + + @Test + void nonAsciiHostsArePunycoded() { + assertEquals( + "https://xn--bcher-kva.example/", UrlNormalizer.normalize("https://bücher.example")); + } + + @Test + void unsafeCharactersArePercentEncodedAndExistingEscapesKept() { + assertEquals( + "https://example.com/a%20b?q=%22x%22#f", + UrlNormalizer.normalize("https://example.com/a b?q=\"x\"#f")); + assertEquals("https://example.com/%C3%A9", UrlNormalizer.normalize("https://example.com/é")); + assertEquals("https://example.com/a%2Fb", UrlNormalizer.normalize("https://example.com/a%2Fb")); + } + + @Test + void dotSegmentsAreResolved() { + assertEquals("https://example.com/b", UrlNormalizer.normalize("https://example.com/a/../b")); + assertEquals("https://example.com/a/", UrlNormalizer.normalize("https://example.com/a/./")); + assertEquals("https://example.com/", UrlNormalizer.normalize("https://example.com/..")); + } + + @Test + void queryAndFragmentAreKept() { + assertEquals( + "https://example.com/?a=1&b=2#top", + UrlNormalizer.normalize("https://example.com?a=1&b=2#top")); + } + + @Test + void aNormalizedUrlStaysTheSame() { + String url = "https://console.apify.com/actors/runs/abc?tab=output"; + assertEquals(url, UrlNormalizer.normalize(url)); + } + + @Test + void userInfoKeepsItsSeparatorAndEncodesEachPart() { + assertEquals( + "https://user:pass@example.com/hook", + UrlNormalizer.normalize("https://user:pass@example.com/hook")); + assertEquals( + "https://us%20er:p%40ss@example.com/", + UrlNormalizer.normalize("https://us er:p@ss@Example.com")); + assertEquals( + "https://token@example.com/", UrlNormalizer.normalize("https://token@example.com")); + } + + @Test + void emptyUserInfoAndPasswordAreDropped() { + assertEquals("https://user@h.test/", UrlNormalizer.normalize("https://user:@h.test/")); + assertEquals("https://h.test/", UrlNormalizer.normalize("https://@h.test/")); + assertEquals("https://h.test/", UrlNormalizer.normalize("https://:@h.test")); + } + + @Test + void anEmptyHostWithCredentialsOrAPortIsInvalid() { + assertEquals("foo://@/", UrlNormalizer.normalize("foo://@/")); + assertEquals("foo://user@/", UrlNormalizer.normalize("foo://user@/")); + assertEquals("foo://:80/", UrlNormalizer.normalize("foo://:80/")); + assertEquals("foo://:/", UrlNormalizer.normalize("foo://:/")); + assertEquals("foo:///path", UrlNormalizer.normalize("foo:///path")); + } + + @Test + void leadingZerosOfAPortDoNotCount() { + assertEquals("https://h.test/x", UrlNormalizer.normalize("https://h.test:0000443/x")); + assertEquals("http://h.test/", UrlNormalizer.normalize("http://h.test:00080/")); + assertEquals("https://h.test:8080/", UrlNormalizer.normalize("https://h.test:008080/")); + assertEquals("https://h.test:0/", UrlNormalizer.normalize("https://h.test:000/")); + } + + @Test + void anInvalidPortLeavesTheValueAlone() { + assertEquals( + "https://example.com:99999999999/", + UrlNormalizer.normalize("https://example.com:99999999999/")); + assertEquals( + "https://example.com:65536/", UrlNormalizer.normalize("https://example.com:65536/")); + assertEquals( + "https://example.com:65535/", UrlNormalizer.normalize("https://example.com:65535")); + assertEquals("https://example.com:abc/", UrlNormalizer.normalize("https://example.com:abc/")); + } + + @Test + void valuesThatAreNotAbsoluteUrlsAreLeftAlone() { + assertNull(UrlNormalizer.normalize(null)); + assertEquals("not a url", UrlNormalizer.normalize("not a url")); + assertEquals("/relative/path", UrlNormalizer.normalize("/relative/path")); + assertEquals("https:///nohost", UrlNormalizer.normalize("https:///nohost")); + } + + @Test + void responseModelsNormalizeTheirUrlFields() { + String run = + "{\"id\":\"r1\",\"consoleUrl\":\"https://CONSOLE.apify.com\"," + + "\"containerUrl\":\"https://abc.runs.apify.net:443\"," + + "\"output\":{\"dataset\":\"https://api.apify.com:443/v2/datasets/d\"}}"; + ActorRun parsed = Json.parse(run.getBytes(StandardCharsets.UTF_8), ActorRun.class); + assertEquals("https://console.apify.com/", parsed.getConsoleUrl()); + assertEquals("https://abc.runs.apify.net/", parsed.getContainerUrl()); + assertEquals("https://api.apify.com/v2/datasets/d", parsed.getOutput().get("dataset")); + + Dataset dataset = + Json.parse( + "{\"id\":\"d\",\"itemsPublicUrl\":\"https://api.apify.com\",\"title\":\"T\"}" + .getBytes(StandardCharsets.UTF_8), + Dataset.class); + assertEquals("https://api.apify.com/", dataset.getItemsPublicUrl()); + assertEquals("T", dataset.getTitle()); + + Webhook webhook = + Json.parse( + "{\"id\":\"w\",\"requestUrl\":\"https://Example.com/hook\"}" + .getBytes(StandardCharsets.UTF_8), + Webhook.class); + assertEquals("https://example.com/hook", webhook.getRequestUrl()); + } + + @Test + void aNullUrlFieldStaysNull() { + ActorRun parsed = + Json.parse( + "{\"id\":\"r1\",\"containerUrl\":null}".getBytes(StandardCharsets.UTF_8), + ActorRun.class); + assertNull(parsed.getContainerUrl()); + } +} diff --git a/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java b/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java index 0f9a872..8a523f1 100644 --- a/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java @@ -10,6 +10,7 @@ import com.apify.client.actor.ActorClient; import com.apify.client.actor.ActorStartOptions; import com.apify.client.dataset.DatasetListItemsOptions; +import com.apify.client.http.Timeout; import com.apify.client.keyvalue.ListKeysOptions; import com.apify.client.log.StreamedLog; import com.apify.client.log.StreamedLogOptions; @@ -24,6 +25,7 @@ import java.util.Optional; import java.util.concurrent.CopyOnWriteArrayList; import org.junit.jupiter.api.Test; +import tools.jackson.databind.JsonNode; class ActorRunIntegrationTest extends IntegrationBase { @@ -46,6 +48,54 @@ void listRuns() { assertTrue(page.getTotal() >= 0); } + @Test + void listRunsFilteredByStandbyDeployment() { + ApifyClient client = requireClient(); + // The deployment filter needs a status filter with only non-terminal statuses; whether any run + // matches depends on the account, so the API accepting the combination is what is asserted. + var filter = + new RunListOptions() + .status(List.of("READY", "RUNNING")) + .deployment(List.of("SINGLE_TENANT", "MULTI_TENANT_FREE_USERS")); + var page = client.runs().list(new ListOptions().limit(5L), filter).join(); + assertTrue(page.getTotal() >= 0); + var actorPage = + client.actor("apify/hello-world").runs().list(new ListOptions().limit(5L), filter).join(); + assertTrue(actorPage.getTotal() >= 0); + } + + @Test + void iterateRunDatasetItemsOfAFinishedRun() { + ApifyClient client = requireClient(); + ActorRun run = + client + .actor("apify/hello-world") + .call(null, new ActorStartOptions(), TEST_ACTOR_WAIT_SECS) + .join(); + assertEquals("SUCCEEDED", run.getStatus()); + List viaRun = + Publishers.collect(client.run(run.getId()).iterateDatasetItems(null)).join(); + List viaDataset = + Publishers.collect( + client + .dataset(run.getDefaultDatasetId()) + .iterateItems(new DatasetListItemsOptions())) + .join(); + assertEquals(viaDataset, viaRun, "the run iterator yields the run's dataset items"); + } + + @Test + void clientWithATimeoutOverrideStillWorks() { + ApifyClient client = requireClient(); + var page = + client + .withTimeout(Timeout.ofSeconds(60)) + .runs() + .list(new ListOptions().limit(1L), new RunListOptions()) + .join(); + assertTrue(page.getTotal() >= 0); + } + @Test void runActorAndReadOutputs() { ApifyClient client = requireClient(); @@ -200,6 +250,8 @@ void runAbortUpdateResurrectDelete() { // Clean up the resurrected run so it doesn't linger on the shared test account. RunClient resurrectedClient = client.run(resurrected.getId()); resurrectedClient.abort(false).join(); + // An aborting run cannot be deleted yet (cannot-remove-running-run), so let it finish first. + resurrectedClient.waitForFinish(60L).join(); resurrectedClient.delete().join(); } diff --git a/src/test/java/com/apify/client/integration/DatasetIntegrationTest.java b/src/test/java/com/apify/client/integration/DatasetIntegrationTest.java index 5a83cf4..054710c 100644 --- a/src/test/java/com/apify/client/integration/DatasetIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/DatasetIntegrationTest.java @@ -39,6 +39,26 @@ void getDataset() { } } + @Test + void pushPreSerializedItemsAndReadNormalizedUrls() { + ApifyClient client = requireClient(); + Dataset ds = client.datasets().getOrCreate(uniqueName("ds-json")).join(); + try { + DatasetClient dataset = client.dataset(ds.getId()); + // A String is the JSON serialization of the items, sent as it is. + dataset.pushItems("[{\"n\": 1}, {\"n\": 2}]").join(); + var items = dataset.listItems(new DatasetListItemsOptions()).join(); + assertEquals(2, items.getItems().size()); + + var fetched = dataset.get().join().orElseThrow(); + assertTrue( + fetched.getConsoleUrl() != null && fetched.getConsoleUrl().startsWith("https://"), + "consoleUrl is typed and a normalized absolute URL: " + fetched.getConsoleUrl()); + } finally { + client.dataset(ds.getId()).delete().join(); + } + } + @Test void datasetCrudFlow() throws java.io.IOException { ApifyClient client = requireClient(); diff --git a/src/test/java/com/apify/client/integration/IntegrationBase.java b/src/test/java/com/apify/client/integration/IntegrationBase.java index f2c50af..ab0d127 100644 --- a/src/test/java/com/apify/client/integration/IntegrationBase.java +++ b/src/test/java/com/apify/client/integration/IntegrationBase.java @@ -55,16 +55,16 @@ public abstract class IntegrationBase { /** * {@link #pollUntil} attempt count bounding how long a CRUD-flow test waits for a just-created - * resource to surface in its own top-level collection {@code list()} (a write and the LIST - * endpoint's index can converge asynchronously - the same class of eventual-consistency race as - * {@link #STREAM_CATCH_UP_ATTEMPTS} and {@code ActorRunIntegrationTest#RUN_LIST_FIND_ATTEMPTS}, - * just with a shorter budget since these are exclusively-owned resources, not entries in a - * heavily-shared public collection). + * resource to surface in its own top-level collection {@code list()}. The list endpoints read + * from database secondaries with up to 90 s of staleness, so under load a fresh resource can be + * missing from the listing for that long. {@link #LIST_FIND_ATTEMPTS} attempts, {@link + * #LIST_FIND_BACKOFF_MILLIS} apart, cover that window; the wait still ends as soon as the + * resource shows up. */ - static final int LIST_FIND_ATTEMPTS = 5; + static final int LIST_FIND_ATTEMPTS = 30; /** Poll interval used while waiting out {@link #LIST_FIND_ATTEMPTS}. */ - static final long LIST_FIND_BACKOFF_MILLIS = 500L; + static final long LIST_FIND_BACKOFF_MILLIS = 3_000L; private static final SecureRandom RANDOM = new SecureRandom(); diff --git a/src/test/java/com/apify/client/integration/StoreIntegrationTest.java b/src/test/java/com/apify/client/integration/StoreIntegrationTest.java index 9b2f7d2..df562b2 100644 --- a/src/test/java/com/apify/client/integration/StoreIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/StoreIntegrationTest.java @@ -19,6 +19,18 @@ void listStore() { assertTrue(page.getItems().size() <= 5); } + @Test + void listStoreWithInputSchema() { + ApifyClient client = requireClient(); + // With includeInputSchema the API defaults limit to (and caps it at) 10. + var page = + client.store().list(new StoreListOptions().includeInputSchema(true).limit(3L)).join(); + assertTrue(page.getItems().size() <= 3); + assertTrue( + page.getItems().stream().allMatch(item -> item.getExtra().containsKey("inputSchema")), + "every listed Actor carries its compact inputSchema"); + } + @Test void iterateStore() { ApifyClient client = requireClient(); diff --git a/src/test/java/com/apify/client/integration/UserIntegrationTest.java b/src/test/java/com/apify/client/integration/UserIntegrationTest.java index 5f2993f..3ebf0e1 100644 --- a/src/test/java/com/apify/client/integration/UserIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/UserIntegrationTest.java @@ -49,6 +49,17 @@ void getLimits() { assertTrue(limits != null && !limits.isEmpty()); } + @Test + void getLimitsAndMonthlyUsageByUserId() { + ApifyClient client = requireClient(); + var me = client.me().get().join().orElseThrow(); + // users/{userId}/limits and users/{userId}/usage/monthly address the own account by its id. + JsonNode limits = client.user(me.getId()).limits().join(); + assertTrue(limits != null && !limits.isEmpty()); + JsonNode usage = client.user(me.getId()).monthlyUsage().join(); + assertTrue(usage != null && !usage.isEmpty()); + } + // Note: `me().updateLimits(...)` is intentionally NOT exercised by a live integration test. It // mutates account-global state that cannot be isolated between the concurrent cross-language test // runs the test-requirements mandate (unlike the per-run resources every other test creates), so