From 5dfc530e38986c6fea7f6b600566be3278dcd246 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 19:08:46 +0000 Subject: [PATCH 1/5] chore: sync PHP client with Apify OpenAPI spec v2-2026-10-01T153946Z Covers the exact spec delta (apify-docs #3037 imageDigest field, #3024 task-publish text fix) plus the master-targeted subset of the referenced apify-client-js PRs (the rest target its unmerged v3 branch and are out of scope per client_requirements.md's master-only reference pin): Build::getImageDigest(), DatasetClient::createItemsPublicUrl() format parameter, a scanned-count pagination fix for DatasetClient::iterateItems() (X-Apify-Pagination-Count), and raw-bytes Actor input support for start()/validateInput()/metamorph(). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01STUGToGEaEFoBGRbWTvF4m --- CHANGELOG.md | 22 ++++ docs/actors.md | 4 + docs/models.md | 1 + docs/options.md | 4 + docs/runs.md | 2 +- docs/storages.md | 4 +- docs/tasks.md | 4 +- src/Internal/ResourceContext.php | 24 +++- src/Model/Build.php | 10 ++ src/Resource/ActorClient.php | 15 ++- src/Resource/DatasetClient.php | 111 +++++++++++++------ src/Resource/RunClient.php | 6 +- src/Resource/TaskClient.php | 6 +- src/Version.php | 4 +- tests/Integration/BuildIntegrationTest.php | 4 + tests/Integration/DatasetIntegrationTest.php | 3 + tests/Unit/IterationTest.php | 30 +++++ tests/Unit/RequestShapeTest.php | 75 +++++++++++++ 18 files changed, 275 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb57138..cd345a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 0.7.0 + +- Bumped `Version::API_SPEC_VERSION` to the Apify OpenAPI spec `v2-2026-10-01T153946Z`. +- Added `Build::getImageDigest()`, exposing the built Docker image's manifest digest (`null` if + unavailable), per the spec's new `imageDigest` field. +- Removed the stale "contact Apify support to raise these limits" sentence from + `TaskClient::publish()`'s doc comment and `docs/tasks.md`, matching the spec's updated + description. Behavior is unchanged. +- `ActorClient::start()`/`call()`/`validateInput()` and `RunClient::metamorph()` now send a `string` + `$input` as raw bytes instead of JSON-encoding it, so it can be paired with a non-default + `$options->contentType` (matching the reference client's raw-bytes Actor input support). A + JSON-serializable array continues to be encoded to JSON as before. +- Added an optional `?DownloadItemsFormat $format` parameter to `DatasetClient::createItemsPublicUrl()`, + selecting the output format served by the generated URL (defaults to `json`), matching the reference + client's `createItemsPublicUrl({ format })`. +- Fixed `DatasetClient::iterateItems()` to advance and terminate by the number of rows the API + scanned (the `X-Apify-Pagination-Count` response header), not by the number of items a page + returns, falling back to the item count when a response omits the header. This matches the + reference client exactly: `skipEmpty`/`clean`/`unwind` can make a page's item count land on + either side of the scanned count, so the previous item-count-based advancement could repeat or + skip items across pages. + ## 0.6.2 - Bumped `Version::API_SPEC_VERSION` to the Apify OpenAPI spec `v2-2026-09-28T115051Z`. No public diff --git a/docs/actors.md b/docs/actors.md index 33136ea..a9d3edb 100644 --- a/docs/actors.md +++ b/docs/actors.md @@ -37,6 +37,10 @@ Addressed by ID or `username~name` (a `username/name` is accepted too). - `start(mixed $input = null, ?ActorStartOptions $options = null): ActorRun` — start and return immediately. - `call(mixed $input = null, ?ActorStartOptions $options = null, ?int $waitSecs = null): ActorRun` — start and wait. - `validateInput(mixed $input = null, ?ValidateInputOptions $options = null): bool` + +`$input` is a JSON-serializable array (the normal case), or a raw `string` of bytes sent exactly as +given — pair a raw string with a non-default `$options->contentType` (e.g. a scraper that takes a +plain-text URL list instead of JSON). - `build(string $versionNumber, ?ActorBuildOptions $options = null): Build` - `defaultBuild(?int $waitForFinish = null): BuildClient` - `lastRun(?LastRunOptions $options = null): RunClient` diff --git a/docs/models.md b/docs/models.md index 016208a..69b35a2 100644 --- a/docs/models.md +++ b/docs/models.md @@ -54,6 +54,7 @@ model is also used as an input object and therefore additionally exposes setters | `getStartedAt(): ?string` | ISO-8601 start timestamp. | | `getFinishedAt(): ?string` | ISO-8601 finish timestamp (`null` while building). | | `getBuildNumber(): ?string` | The resulting build number. | +| `getImageDigest(): ?string` | Digest of the built Docker image manifest (no `sha256:` prefix), or `null` if unavailable. Compare two builds' digests to tell whether their image contents differ. | | `isTerminal(): bool` | Whether the build has reached a terminal status. | ### `ActorVersion` diff --git a/docs/options.md b/docs/options.md index 8e68807..847121d 100644 --- a/docs/options.md +++ b/docs/options.md @@ -189,6 +189,10 @@ For `dataset()->listItems()`, `iterateItems()`, and `createItemsPublicUrl()`. Supports [`withPagination($offset, $limit)`](#manual-offset-paging-with-withpagination). +`createItemsPublicUrl()` also takes a separate `?DownloadItemsFormat $format` parameter (not a +`DatasetListItemsOptions` field), selecting the output format served by the generated URL — defaults +to `json` when omitted. + ### `DatasetDownloadOptions` For `dataset()->downloadItems()` (export formatting on top of the filtering above). | Field | Type | Description | diff --git a/docs/runs.md b/docs/runs.md index cb65bac..6a55834 100644 --- a/docs/runs.md +++ b/docs/runs.md @@ -23,7 +23,7 @@ An Actor's or task's runs are available at `$client->actor($id)->runs()` / `$cli - `update(mixed $newFields): ActorRun` - `delete(): void` - `abort(?bool $gracefully = null): ActorRun` — aborts the run; with `$gracefully` `true` the run is signalled so it can finish its current request before terminating, `false` aborts immediately, and `null` (the default) lets the server apply its default (immediate abort). -- `metamorph(string $targetActorId, mixed $input = null, ?MetamorphOptions $options = null): ActorRun` +- `metamorph(string $targetActorId, mixed $input = null, ?MetamorphOptions $options = null): ActorRun` — `$input` accepts the same shapes as `ActorClient::start()`'s (a JSON-serializable array, or a raw string of bytes with a matching `$options->contentType`). - `reboot(): ActorRun` - `resurrect(?RunResurrectOptions $options = null): ActorRun` - `charge(RunChargeOptions $options): void` — for pay-per-event Actors. diff --git a/docs/storages.md b/docs/storages.md index 7702b68..c0679e0 100644 --- a/docs/storages.md +++ b/docs/storages.md @@ -19,11 +19,11 @@ Single — `$client->dataset($id)`: - `get(): ?Dataset`, `update(mixed $newFields): Dataset`, `delete(): void` - `listItems(?DatasetListItemsOptions $options = null): PaginationList` — one page of items decoded to PHP values. -- `iterateItems(?DatasetListItemsOptions $options = null, ?int $chunkSize = null): iterable` — lazily iterate all items, paging on demand. The options' `limit` caps the total number of items yielded across all pages (unset = all); `$chunkSize` is the per-page size. Note: item-dropping filters (`skipEmpty`, and `clean` which implies it) are applied after `offset`/`limit`, so combining them with multi-page iteration can repeat or skip items (the iterator advances the offset by the post-filter count, matching the reference JS client). Iterate without those filters, or page explicitly with `listItems()` and filter client-side. (`skipHidden` only strips hidden fields from each item, not whole items, so it does not affect paging.) +- `iterateItems(?DatasetListItemsOptions $options = null, ?int $chunkSize = null): iterable` — lazily iterate all items, paging on demand. The options' `limit` caps the total number of items yielded across all pages (unset = all); `$chunkSize` is the per-page size. The iterator advances and terminates by the number of rows the API scanned (the `X-Apify-Pagination-Count` response header), not by the number of items a page returns, matching the reference JS client exactly — see the method's doc comment for why that matters with `skipEmpty`/`clean`/`unwind`. - `downloadItems(DownloadItemsFormat $format, ?DatasetDownloadOptions $options = null): string` — raw export bytes. - `pushItems(mixed $items): void` - `getStatistics(): ?array` -- `createItemsPublicUrl(?DatasetListItemsOptions $options = null, ?int $expiresInSecs = null): string` — builds a shareable URL for downloading this dataset's items (forwarding the given item filters); for a private dataset it appends an access signature, optionally bounded to `$expiresInSecs`. +- `createItemsPublicUrl(?DatasetListItemsOptions $options = null, ?int $expiresInSecs = null, ?DownloadItemsFormat $format = null): string` — builds a shareable URL for downloading this dataset's items (forwarding the given item filters); for a private dataset it appends an access signature, optionally bounded to `$expiresInSecs`. `$format` selects the served output format (defaults to `json`). ```php $dataset = $client->datasets()->getOrCreate('my-dataset'); diff --git a/docs/tasks.md b/docs/tasks.md index ccbbdcd..a6fdef9 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -28,8 +28,8 @@ foreach ($client->tasks()->iterate(new ListOptions(), 50) as $t) { setting `isPublic` through `update()`. Both require write permission to the task's Actor; publishing additionally requires the Actor to be public and the task's `publicConfig.inputSchemaFields`/`publicConfig.datasetView` to be set. An Actor can have up to 10 - published tasks and an account up to 100 (contact Apify support to raise these limits); if the - conditions aren't met, the request fails and nothing is changed. + published tasks and an account up to 100; if the conditions aren't met, the request fails and + nothing is changed. - `start(mixed $input = null, ?TaskStartOptions $options = null): ActorRun` - `call(mixed $input = null, ?TaskStartOptions $options = null, ?int $waitSecs = null): ActorRun` - `getInput(): mixed`, `updateInput(mixed $input): mixed` diff --git a/src/Internal/ResourceContext.php b/src/Internal/ResourceContext.php index 2b9a3dd..cca7a7c 100644 --- a/src/Internal/ResourceContext.php +++ b/src/Internal/ResourceContext.php @@ -235,8 +235,11 @@ public static function paginateOffset(int $startOffset, ?int $limit, ?int $chunk /** * Returns the smaller of two optional positive bounds, treating {@code null} or {@code 0} as * "unbounded" (the API treats {@code limit=0} as unset). Mirrors the reference minForLimitParam. + * + * Public so {@see \Apify\Client\Resource\DatasetClient::iterateItems()} can reuse it for its own + * scanned-count-aware pagination loop. @internal */ - private static function minLimit(?int $a, ?int $b): ?int + public static function minLimit(?int $a, ?int $b): ?int { if ($a === 0) { $a = null; @@ -461,6 +464,25 @@ private static function asArray(mixed $value): array return is_array($value) ? $value : []; } + /** + * Encodes an Actor run input body for {@code ActorClient::start()/validateInput()} and + * {@code RunClient::metamorph()}: {@code null} stays {@code null}, a {@code string} is sent as + * raw bytes exactly as given (pair it with a non-default {@code contentType}), and anything else + * — the normal case, an associative array — is JSON-encoded. + * + * Mirrors the reference client's {@code ActorInput}, which accepts a plain object/array of them + * (serialized to JSON) or raw bytes such as a {@code Buffer} (sent as they are). A PHP string is + * this client's idiomatic stand-in for the reference client's raw-bytes case, since PHP strings + * are already byte sequences and the language has no separate binary-buffer type. + */ + public static function encodeInputBody(mixed $input): ?string + { + if ($input === null) { + return null; + } + return is_string($input) ? $input : Json::encode($input); + } + // ---- URL / id helpers ----------------------------------------------------- /** diff --git a/src/Model/Build.php b/src/Model/Build.php index 6901a92..cf7f0cd 100644 --- a/src/Model/Build.php +++ b/src/Model/Build.php @@ -49,6 +49,16 @@ public function getBuildNumber(): ?string return $this->getString('buildNumber'); } + /** + * Digest of the built Docker image manifest, without the {@code sha256:} prefix, or + * {@code null} if unavailable. Compare the digests of two builds to tell whether their image + * contents differ. + */ + public function getImageDigest(): ?string + { + return $this->getString('imageDigest'); + } + /** Whether the build has reached a terminal (finished) status. */ public function isTerminal(): bool { diff --git a/src/Resource/ActorClient.php b/src/Resource/ActorClient.php index 76805b6..32cfac1 100644 --- a/src/Resource/ActorClient.php +++ b/src/Resource/ActorClient.php @@ -6,7 +6,6 @@ use Apify\Client\ApifyClient; use Apify\Client\Internal\HttpClientCore; -use Apify\Client\Internal\Json; use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; use Apify\Client\Model\Actor; @@ -69,21 +68,24 @@ public function delete(): void /** * Starts the Actor and returns immediately with the created run. * - * @param mixed $input any JSON-serializable value (or {@code null} for no input) + * @param mixed $input a JSON-serializable array (or {@code null} for no input), or a raw string + * of bytes sent exactly as given — pair it with a non-default + * {@code $options->contentType} */ public function start(mixed $input = null, ?ActorStartOptions $options = null): ActorRun { $options ??= new ActorStartOptions(); $params = new QueryParams(); $options->appendTo($params); - $body = $input === null ? null : Json::encode($input); + $body = ResourceContext::encodeInputBody($input); return new ActorRun($this->ctx->postWithBody('runs', $params, $body, $options->contentTypeOrDefault())); } /** * Starts the Actor and waits (client-side polling) for it to finish. * - * @param mixed $input any JSON-serializable value (or {@code null} for no input) + * @param mixed $input a JSON-serializable array (or {@code null} for no input), or a raw + * string of bytes sent exactly as given — see {@see start()} * @param int|null $waitSecs bounds the wait; {@code null} waits indefinitely */ public function call(mixed $input = null, ?ActorStartOptions $options = null, ?int $waitSecs = null): ActorRun @@ -95,14 +97,15 @@ public function call(mixed $input = null, ?ActorStartOptions $options = null, ?i /** * Validates {@code input} against the Actor's input schema and returns whether it is valid. * - * @param mixed $input any JSON-serializable value (or {@code null}) + * @param mixed $input a JSON-serializable array (or {@code null}), or a raw string of bytes sent + * exactly as given — see {@see start()} */ public function validateInput(mixed $input = null, ?ValidateInputOptions $options = null): bool { $options ??= new ValidateInputOptions(); $params = new QueryParams(); $options->appendTo($params); - $body = $input === null ? null : Json::encode($input); + $body = ResourceContext::encodeInputBody($input); // The validate-input endpoint returns a bare {"valid": } object, not the standard // {"data": ...} envelope, so parse it without unwrapping. $result = $this->ctx->postWithBodyNoEnvelope('validate-input', $params, $body, $options->contentTypeOrDefault()); diff --git a/src/Resource/DatasetClient.php b/src/Resource/DatasetClient.php index 635dd5a..0d2c932 100644 --- a/src/Resource/DatasetClient.php +++ b/src/Resource/DatasetClient.php @@ -83,8 +83,69 @@ public function delete(): void * @return PaginationList */ public function listItems(?DatasetListItemsOptions $options = null): PaginationList + { + return $this->fetchItemsPage($options ?? new DatasetListItemsOptions())[0]; + } + + /** + * Lazily iterates over the dataset's items, fetching pages on demand. Each item is decoded to a + * PHP value (an associative array for objects), like {@see listItems()}. + * + * The options' {@code limit} caps the total number of items yielded across all pages ({@code null} + * = all), {@code offset} is the starting offset, and {@code $chunkSize} is the per-page size + * ({@code null} = the server default). All other {@see DatasetListItemsOptions} fields (field + * selection, filtering, ordering) are applied to every page. + * + * Item-dropping filters ({@code skipEmpty}, and {@code clean} which implies it) and {@code unwind} + * are applied after {@code offset}/{@code limit}, so a page's item count can land on either side of + * the number of rows the API actually scanned to produce it. The iterator therefore advances and + * terminates by the scanned count the API reports in the {@code X-Apify-Pagination-Count} header — + * never by the returned item count — matching the reference JS client exactly (including its + * fallback to the item count on a response that omits the header, e.g. behind a proxy that strips + * it). ({@code skipHidden} only strips hidden fields from each item, not whole items, so it does + * not affect paging.) + * + * @return Generator + */ + public function iterateItems(?DatasetListItemsOptions $options = null, ?int $chunkSize = null): Generator { $options ??= new DatasetListItemsOptions(); + $startOffset = $options->offset ?? 0; + $limit = $options->limit; + + [$page, $scanned] = $this->fetchItemsPage($options->withPagination($startOffset, ResourceContext::minLimit($limit, $chunkSize))); + foreach ($page->getItems() as $item) { + yield $item; + } + + $total = $page->getTotal(); + $cap = min(($limit !== null && $limit > 0) ? $limit : $total, $total); + $pageScanned = $scanned ?? count($page->getItems()); + $currentOffset = $startOffset + $pageScanned; + $remaining = min($total - $startOffset, $cap) - $pageScanned; + + // Guard on the previous page having scanned something, so an over-reported total terminates + // instead of looping forever. + while ($pageScanned > 0 && $remaining > 0) { + [$page, $scanned] = $this->fetchItemsPage($options->withPagination($currentOffset, ResourceContext::minLimit($remaining, $chunkSize))); + foreach ($page->getItems() as $item) { + yield $item; + } + $pageScanned = $scanned ?? count($page->getItems()); + $currentOffset += $pageScanned; + $remaining -= $pageScanned; + } + } + + /** + * Fetches one page of dataset items and parses the pagination headers. Returns the page together + * with the {@code X-Apify-Pagination-Count} "scanned rows" count ({@code null} if the response + * omits the header), which {@see iterateItems()} uses to advance — see its doc comment. + * + * @return array{0:PaginationList,1:?int} + */ + private function fetchItemsPage(DatasetListItemsOptions $options): array + { $params = new QueryParams(); $options->appendTo($params); $url = $this->ctx->mergedParams($params)->applyToUrl($this->ctx->subUrl('items')); @@ -94,7 +155,7 @@ public function listItems(?DatasetListItemsOptions $options = null): PaginationL $items = is_array($items) ? array_values($items) : []; $count = count($items); - return PaginationList::fromItems( + $page = PaginationList::fromItems( $items, $this->headerInt($response, 'X-Apify-Pagination-Total', $count), $this->headerInt($response, 'X-Apify-Pagination-Offset', 0), @@ -105,38 +166,11 @@ public function listItems(?DatasetListItemsOptions $options = null): PaginationL // is absent, e.g. against an older API version that predates it. $this->headerBool($response, 'X-Apify-Pagination-Desc', $options->desc ?? false), ); - } - /** - * Lazily iterates over the dataset's items, fetching pages on demand. Each item is decoded to a - * PHP value (an associative array for objects), like {@see listItems()}. - * - * The options' {@code limit} caps the total number of items yielded across all pages ({@code null} - * = all), {@code offset} is the starting offset, and {@code $chunkSize} is the per-page size - * ({@code null} = the server default). All other {@see DatasetListItemsOptions} fields (field - * selection, filtering, ordering) are applied to every page. - * - * Note: item-dropping filters ({@code skipEmpty}, and {@code clean} which implies it) are applied - * after {@code offset}/{@code limit}, so a page can return fewer items than requested while - * {@code X-Apify-Pagination-Total} still reflects the raw total. Because the iterator advances - * the offset by the post-filter item count (matching the reference JS client), combining those - * filters with multi-page iteration can repeat items across overlapping windows or, if a whole - * offset window is filtered out, end iteration early and skip the remaining items. Iterate - * without those filters, or page explicitly with {@see listItems()} and filter client-side. - * ({@code skipHidden} only strips hidden fields from each item, not whole items, so it does not - * affect paging.) - * - * @return Generator - */ - public function iterateItems(?DatasetListItemsOptions $options = null, ?int $chunkSize = null): Generator - { - $options ??= new DatasetListItemsOptions(); - return ResourceContext::paginateOffset( - $options->offset ?? 0, - $options->limit, - $chunkSize, - fn (int $offset, ?int $pageLimit) => $this->listItems($options->withPagination($offset, $pageLimit)), - ); + $scannedHeader = $response->getHeaderLine('X-Apify-Pagination-Count'); + $scanned = $scannedHeader === '' ? null : (int) $scannedHeader; + + return [$page, $scanned]; } /** @@ -191,13 +225,20 @@ public function getStatistics(): ?array * It fetches the dataset, and if the dataset exposes a URL-signing secret key (i.e. it is * private), appends an HMAC-SHA256 signature so the URL grants access without an API token. * {@code $expiresInSecs} optionally bounds the validity of a signed URL ({@code null} for - * non-expiring). The URL is built from the configured public base URL. + * non-expiring). {@code $format} selects the output format of the items served by the URL + * (defaults to {@code json} when omitted). The URL is built from the configured public base URL. */ - public function createItemsPublicUrl(?DatasetListItemsOptions $options = null, ?int $expiresInSecs = null): string - { + public function createItemsPublicUrl( + ?DatasetListItemsOptions $options = null, + ?int $expiresInSecs = null, + ?DownloadItemsFormat $format = null, + ): string { $options ??= new DatasetListItemsOptions(); $params = new QueryParams(); $options->appendTo($params); + if ($format !== null) { + $params->addString('format', $format->value); + } // Only compute a signature when the caller did not already supply one, otherwise the URL // would carry two conflicting `signature` query params (mirrors KeyValueStoreClient::createKeysPublicUrl). if ($options->signature === null) { diff --git a/src/Resource/RunClient.php b/src/Resource/RunClient.php index 3088062..ccc3164 100644 --- a/src/Resource/RunClient.php +++ b/src/Resource/RunClient.php @@ -103,7 +103,9 @@ public function abort(?bool $gracefully = null): ActorRun * Transforms the run into a run of another Actor with a new input. * * @param string $targetActorId the Actor to metamorph into - * @param mixed $input the new input ({@code null} for none) + * @param mixed $input the new input: a JSON-serializable array, a raw string of bytes + * sent exactly as given (pair it with a non-default + * {@code $options->contentType}), or {@code null} for none */ public function metamorph(string $targetActorId, mixed $input = null, ?MetamorphOptions $options = null): ActorRun { @@ -115,7 +117,7 @@ public function metamorph(string $targetActorId, mixed $input = null, ?Metamorph if ($options->build !== null && $options->build !== '') { $params->addString('build', $options->build); } - $body = $input === null ? null : Json::encode($input); + $body = ResourceContext::encodeInputBody($input); return new ActorRun($this->ctx->postWithBody('metamorph', $params, $body, $options->contentTypeOrDefault())); } diff --git a/src/Resource/TaskClient.php b/src/Resource/TaskClient.php index f907fc0..4c65f9f 100644 --- a/src/Resource/TaskClient.php +++ b/src/Resource/TaskClient.php @@ -63,9 +63,9 @@ public function delete(): void * * To publish, the task's Actor must be public and its {@code publicConfig.inputSchemaFields} * and {@code publicConfig.datasetView} must be set. An Actor can have up to 10 published - * tasks and an account up to 100; contact Apify support to raise these limits. If any of - * these conditions are not met, the request fails and nothing is changed (including fields - * unrelated to publishing). Publishing an already published task does nothing. + * tasks and an account up to 100. If any of these conditions are not met, the request fails + * and nothing is changed (including fields unrelated to publishing). Publishing an already + * published task does nothing. */ public function publish(): Task { diff --git a/src/Version.php b/src/Version.php index ec92dc5..206e985 100644 --- a/src/Version.php +++ b/src/Version.php @@ -17,13 +17,13 @@ final class Version * The semantic version of this client library (see https://semver.org/). * Changes to the public interface other than additive ones are considered breaking changes. */ - public const CLIENT_VERSION = '0.6.2'; + public const CLIENT_VERSION = '0.7.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 const API_SPEC_VERSION = 'v2-2026-09-28T115051Z'; + public const API_SPEC_VERSION = 'v2-2026-10-01T153946Z'; private function __construct() { diff --git a/tests/Integration/BuildIntegrationTest.php b/tests/Integration/BuildIntegrationTest.php index b9352be..46bed2d 100644 --- a/tests/Integration/BuildIntegrationTest.php +++ b/tests/Integration/BuildIntegrationTest.php @@ -48,6 +48,10 @@ public function testBuildActorFlow(): void $build = $client->actor((string) $created->getId())->build('0.0', new ActorBuildOptions()); $finished = $client->build((string) $build->getId())->waitForFinish(300); self::assertTrue($finished->isTerminal(), 'build did not finish: ' . $finished->getStatus()); + // The field is nullable even for a finished build (e.g. `imageSizeBytes` is also null on + // this test environment's build backend), so only exercise the getter's type, not its + // presence — RequestShapeTest covers the populated case against a scripted response. + self::assertTrue($finished->getImageDigest() === null || is_string($finished->getImageDigest())); self::assertNotNull($client->build((string) $build->getId())->get()); $client->build((string) $build->getId())->log()->get(); diff --git a/tests/Integration/DatasetIntegrationTest.php b/tests/Integration/DatasetIntegrationTest.php index 9af3144..4e9acf3 100644 --- a/tests/Integration/DatasetIntegrationTest.php +++ b/tests/Integration/DatasetIntegrationTest.php @@ -114,6 +114,9 @@ public function testDatasetCrudFlow(): void $url = $dataset->createItemsPublicUrl(new DatasetListItemsOptions()); self::assertNotSame('', $url); + $csvUrl = $dataset->createItemsPublicUrl(new DatasetListItemsOptions(), format: DownloadItemsFormat::CSV); + self::assertStringContainsString('format=csv', $csvUrl); + $dataset->getStatistics(); $updated = $dataset->update(['name' => self::uniqueName('ds-renamed')]); diff --git a/tests/Unit/IterationTest.php b/tests/Unit/IterationTest.php index ada2485..4571bc5 100644 --- a/tests/Unit/IterationTest.php +++ b/tests/Unit/IterationTest.php @@ -139,6 +139,36 @@ public function testDatasetIterateItemsPagesViaHeaders(): void self::assertStringContainsString('offset=2', (string) $transport->received[1]->getUri()); } + public function testDatasetIterateItemsAdvancesByScannedCountNotItemCount(): void + { + // With skipEmpty active, the API can scan more rows than it returns as items. The iterator + // must advance (and terminate) by the scanned count (`X-Apify-Pagination-Count`), not by the + // item count — otherwise it would re-request offset=2 (items returned so far) instead of + // offset=3 (rows actually scanned), re-scanning row 2 and duplicating item 'n'=2. + $transport = (new MockTransport()) + // Page 1 scans rows 0-2 (3 rows) but one is empty and skipped, so only 2 items come back. + ->queueResponse(200, Json::encode([['n' => 0], ['n' => 1]]), [ + 'X-Apify-Pagination-Total' => '6', + 'X-Apify-Pagination-Offset' => '0', + 'X-Apify-Pagination-Limit' => '3', + 'X-Apify-Pagination-Count' => '3', + ]) + // Page 2 scans rows 3-5 (3 rows), none filtered. + ->queueResponse(200, Json::encode([['n' => 3], ['n' => 4], ['n' => 5]]), [ + 'X-Apify-Pagination-Total' => '6', + 'X-Apify-Pagination-Offset' => '3', + 'X-Apify-Pagination-Limit' => '3', + 'X-Apify-Pagination-Count' => '3', + ]); + $values = []; + foreach ($this->client($transport)->dataset('ds')->iterateItems(new DatasetListItemsOptions(skipEmpty: true), 3) as $item) { + $values[] = $item['n']; + } + self::assertSame([0, 1, 3, 4, 5], $values); + self::assertSame(2, $transport->callCount()); + self::assertStringContainsString('offset=3', (string) $transport->received[1]->getUri()); + } + public function testDatasetIterateItemsPreservesFilters(): void { $transport = (new MockTransport()) diff --git a/tests/Unit/RequestShapeTest.php b/tests/Unit/RequestShapeTest.php index b451fd8..d98cfe1 100644 --- a/tests/Unit/RequestShapeTest.php +++ b/tests/Unit/RequestShapeTest.php @@ -6,11 +6,14 @@ use Apify\Client\ApifyClient; use Apify\Client\Internal\Json; +use Apify\Client\Options\ActorStartOptions; use Apify\Client\Options\DatasetListItemsOptions; +use Apify\Client\Options\DownloadItemsFormat; use Apify\Client\Options\MetamorphOptions; use Apify\Client\Options\RequestQueueClientOptions; use Apify\Client\Options\RunChargeOptions; use Apify\Client\Options\RunResurrectOptions; +use Apify\Client\Options\ValidateInputOptions; use PHPUnit\Framework\TestCase; /** @@ -57,6 +60,19 @@ public function testMetamorphSendsTargetActorIdAndInput(): void self::assertSame(['x' => 1], Json::decode((string) $request->getBody())); } + public function testMetamorphSendsRawStringInputAsIs(): void + { + // A string $input is sent exactly as given (raw bytes), not JSON-encoded, so it can be + // paired with a non-default contentType — matching the reference client's raw-bytes Actor + // input support (ActorClient::start() is covered the same way below). + $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['id' => 'r']])); + $this->client($transport)->run('run1')->metamorph('apify/other', 'col_a,col_b\n1,2', new MetamorphOptions(contentType: 'text/csv')); + + $request = $transport->lastRequest(); + self::assertSame('text/csv', $request->getHeaderLine('Content-Type')); + self::assertSame('col_a,col_b\n1,2', (string) $request->getBody()); + } + public function testMetamorphNormalizesSlashFormTargetActorId(): void { $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['id' => 'r']])); @@ -142,6 +158,46 @@ public function testRequestQueueOptionsApplyTimeout(): void self::assertSame(2.0, $transport->timeouts[0]); } + public function testActorStartSendsRawStringInputAsIs(): void + { + $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['id' => 'r']])); + $this->client($transport)->actor('me~a')->start('https://a.com\nhttps://b.com', new ActorStartOptions(contentType: 'text/plain')); + + $request = $transport->lastRequest(); + self::assertSame('text/plain', $request->getHeaderLine('Content-Type')); + self::assertSame('https://a.com\nhttps://b.com', (string) $request->getBody()); + } + + public function testActorStartJsonEncodesArrayInput(): void + { + // The normal case (an associative array) still goes through Json::encode(), unaffected by + // the raw-string passthrough added for raw-bytes input. + $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['id' => 'r']])); + $this->client($transport)->actor('me~a')->start(['url' => 'https://a.com']); + + self::assertSame(['url' => 'https://a.com'], Json::decode((string) $transport->lastRequest()->getBody())); + } + + public function testValidateInputSendsRawStringInputAsIs(): void + { + $transport = (new MockTransport())->queueResponse(200, Json::encode(['valid' => true])); + $this->client($transport)->actor('me~a')->validateInput('raw-body', new ValidateInputOptions(contentType: 'text/plain')); + + $request = $transport->lastRequest(); + self::assertSame('text/plain', $request->getHeaderLine('Content-Type')); + self::assertSame('raw-body', (string) $request->getBody()); + } + + public function testCreateItemsPublicUrlIncludesFormat(): void + { + $transport = new MockTransport(); + $url = $this->client($transport) + ->dataset('ds1') + ->createItemsPublicUrl(new DatasetListItemsOptions(signature: 'caller-sig'), format: DownloadItemsFormat::CSV); + + self::assertStringContainsString('format=csv', $url); + } + public function testCreateItemsPublicUrlDoesNotDuplicateCallerSignature(): void { // When the caller already supplies a signature, the client must not fetch the dataset to @@ -168,6 +224,25 @@ public function testCreateItemsPublicUrlSignsPrivateDatasetWhenNoSignatureGiven( self::assertSame(1, substr_count($url, 'signature=')); } + public function testBuildGetExposesImageDigest(): void + { + $transport = (new MockTransport())->queueResponse( + 200, + Json::encode(['data' => ['id' => 'b1', 'imageDigest' => 'abc123']]) + ); + $build = $this->client($transport)->build('b1')->get(); + + self::assertSame('abc123', $build?->getImageDigest()); + } + + public function testBuildGetImageDigestIsNullWhenAbsent(): void + { + $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['id' => 'b1']])); + $build = $this->client($transport)->build('b1')->get(); + + self::assertNull($build?->getImageDigest()); + } + public function testUpdateLimitsPutsToMeLimits(): void { $transport = (new MockTransport())->queueResponse(200, ''); From 5047695557d28503d8309cd38014fac4b58e04c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 19:21:12 +0000 Subject: [PATCH 2/5] fix: address review nits on PR #15 (dataset iteration cap, docblock tag) Simplifies the redundant min() in DatasetClient::iterateItems()'s page cap and moves ResourceContext::minLimit()'s @internal tag onto its own line so phpDoc tooling recognizes it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01STUGToGEaEFoBGRbWTvF4m --- src/Internal/ResourceContext.php | 4 +++- src/Resource/DatasetClient.php | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Internal/ResourceContext.php b/src/Internal/ResourceContext.php index cca7a7c..d66044c 100644 --- a/src/Internal/ResourceContext.php +++ b/src/Internal/ResourceContext.php @@ -237,7 +237,9 @@ public static function paginateOffset(int $startOffset, ?int $limit, ?int $chunk * "unbounded" (the API treats {@code limit=0} as unset). Mirrors the reference minForLimitParam. * * Public so {@see \Apify\Client\Resource\DatasetClient::iterateItems()} can reuse it for its own - * scanned-count-aware pagination loop. @internal + * scanned-count-aware pagination loop. + * + * @internal */ public static function minLimit(?int $a, ?int $b): ?int { diff --git a/src/Resource/DatasetClient.php b/src/Resource/DatasetClient.php index 0d2c932..37fc46b 100644 --- a/src/Resource/DatasetClient.php +++ b/src/Resource/DatasetClient.php @@ -119,7 +119,7 @@ public function iterateItems(?DatasetListItemsOptions $options = null, ?int $chu } $total = $page->getTotal(); - $cap = min(($limit !== null && $limit > 0) ? $limit : $total, $total); + $cap = ($limit !== null && $limit > 0) ? $limit : $total; $pageScanned = $scanned ?? count($page->getItems()); $currentOffset = $startOffset + $pageScanned; $remaining = min($total - $startOffset, $cap) - $pageScanned; From d94fabd405450d21bf0e742bf80c3110eca16b16 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 19:55:11 +0000 Subject: [PATCH 3/5] fix!: port 27 previously-excluded apify-client-js master PRs A prior sync wrongly excluded 27 of 33 referenced apify-client-js PRs as targeting an unmerged v3 branch; all 33 are merged to its master. This re-evaluates and ports every PHP-relevant behavior change, additively: - Added ApifyApiException subclasses grouped by HTTP status (InvalidRequestException, UnauthorizedException, ForbiddenException, NotFoundException, ConflictException, RateLimitException, ServerException). - A 404 is now swallowed regardless of its `type` (was a narrow allowlist). - get()/delete() on an ID-less chained resource client (run.dataset(), run.keyValueStore(), run.requestQueue(), run.log(), build.log()) and fixed sub-paths of an ID-addressed resource (getStatistics(), getInput(), getLog(), getOpenApiDefinition()) now throw instead of resolving to null/no-op on a 404, since the response can't tell a missing parent apart from a missing sub-resource/sub-path. - ActorClient::version()/build() and ActorVersionClient::envVar() reject an empty-string identifier. - Fixed ResourceContext::toSafeId() to replace every "/" in an id, not just the first. Resource ids and path segments built from caller input are now rejected (InvalidArgumentException) when empty or "."/"..", instead of being percent-encoded and sent as literal dot-segments. - Added timeout tiers (timeoutShortSecs/timeoutMediumSecs/timeoutLongSecs/ timeoutMaxSecs on ApifyClient, each defaulting to the existing timeoutSecs) and an optional per-call $timeoutSecs override on every resource client's get()/update()/delete(). - Added a `compression` option to ApifyClient ('brotli'/'gzip'/a custom HttpCompressorInterface); left unset, behavior is unchanged. - Request-body compression is now skipped for content types that already carry their own compression (images, audio, video, archives, fonts). - ApifyClient's baseUrl/publicBaseUrl now accept a URL that already ends with /v2 without doubling it. - RequestQueueClient::batchAddRequests() now serializes each request once instead of re-encoding it for the size check, the chunk split, and the request body. Every one of the 27 PRs is accounted for in notes.md, including the ones with no PHP change and why (architecture mismatches or changes that would themselves be a disallowed breaking change). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01STUGToGEaEFoBGRbWTvF4m --- CHANGELOG.md | 49 +++++ README.md | 98 +++++++++- docs/README.md | 10 +- docs/builds.md | 6 +- docs/runs.md | 4 +- docs/schedules.md | 3 +- docs/storages.md | 3 +- docs/tasks.md | 3 +- src/ApifyClient.php | 89 +++++++++- src/Exception/ConflictException.php | 10 ++ src/Exception/ForbiddenException.php | 10 ++ src/Exception/InvalidRequestException.php | 10 ++ src/Exception/NotFoundException.php | 10 ++ src/Exception/RateLimitException.php | 13 ++ src/Exception/ServerException.php | 13 ++ src/Exception/UnauthorizedException.php | 10 ++ src/Http/BrotliHttpCompressor.php | 45 +++++ src/Http/GzipHttpCompressor.php | 41 +++++ src/Http/HttpCompressorInterface.php | 28 +++ src/Internal/Compression.php | 89 ++++++++++ src/Internal/HttpClientCore.php | 103 ++++++++--- src/Internal/ResourceContext.php | 117 +++++++++--- src/Internal/RetryConfig.php | 2 + src/Internal/TimeoutTiers.php | 95 ++++++++++ src/Resource/ActorClient.php | 23 ++- src/Resource/ActorEnvVarClient.php | 15 +- src/Resource/ActorVersionClient.php | 13 +- src/Resource/BuildClient.php | 30 ++-- src/Resource/DatasetClient.php | 35 ++-- src/Resource/KeyValueStoreClient.php | 25 ++- src/Resource/LogClient.php | 8 +- src/Resource/RequestQueueClient.php | 137 +++++++------- src/Resource/RunClient.php | 34 +++- src/Resource/ScheduleClient.php | 23 ++- src/Resource/TaskClient.php | 22 ++- src/Resource/UserClient.php | 5 +- src/Resource/WebhookClient.php | 13 +- src/Resource/WebhookDispatchClient.php | 5 +- src/Version.php | 2 +- tests/Unit/AmbiguousNotFoundTest.php | 206 ++++++++++++++++++++++ tests/Unit/CompressionConfigTest.php | 107 +++++++++++ tests/Unit/CompressionTest.php | 29 +++ tests/Unit/ConfigTest.php | 24 +++ tests/Unit/PathSafetyTest.php | 120 +++++++++++++ tests/Unit/TimeoutTiersTest.php | 107 +++++++++++ 45 files changed, 1625 insertions(+), 219 deletions(-) create mode 100644 src/Exception/ConflictException.php create mode 100644 src/Exception/ForbiddenException.php create mode 100644 src/Exception/InvalidRequestException.php create mode 100644 src/Exception/NotFoundException.php create mode 100644 src/Exception/RateLimitException.php create mode 100644 src/Exception/ServerException.php create mode 100644 src/Exception/UnauthorizedException.php create mode 100644 src/Http/BrotliHttpCompressor.php create mode 100644 src/Http/GzipHttpCompressor.php create mode 100644 src/Http/HttpCompressorInterface.php create mode 100644 src/Internal/TimeoutTiers.php create mode 100644 tests/Unit/AmbiguousNotFoundTest.php create mode 100644 tests/Unit/CompressionConfigTest.php create mode 100644 tests/Unit/PathSafetyTest.php create mode 100644 tests/Unit/TimeoutTiersTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index cd345a3..00c82d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,54 @@ # Changelog +## 0.8.0 + +- Added `InvalidRequestException` (400), `UnauthorizedException` (401), `ForbiddenException` (403), + `NotFoundException` (404), `ConflictException` (409) and `RateLimitException` (429), plus + `ServerException` for any 5xx, all extending the existing `ApifyApiException`. The client now + throws the subclass matching the response's HTTP status, so a `catch` can branch on `instanceof` + instead of comparing status codes; any other status still throws a plain `ApifyApiException`, + matching the reference client's `ApifyApiError` subclasses. +- A 404 is now swallowed (resolving to `null`/no-op) regardless of its machine-readable `type`, + matching the reference client. Previously only `record-not-found`, `record-or-token-not-found`, + and every HEAD request were swallowed; a 404 with any other `type` incorrectly propagated as an + error. +- `get()`/`delete()` on a resource client obtained without an ID — `RunClient::dataset()`, + `keyValueStore()`, `requestQueue()`, `log()`, and `BuildClient::log()` — now throw instead of + resolving to `null`/no-op on a 404, since the response cannot tell a missing parent (run/build) + apart from a missing sub-resource. `DatasetClient::getStatistics()`, `TaskClient::getInput()`, + `ScheduleClient::getLog()`, and `BuildClient::getOpenApiDefinition()` likewise now always throw on + a 404 instead of returning `null`, since these fixed sub-paths can only 404 because the parent + resource is gone. `getRecord()`, `getRequest()`, `recordExists()`, and `lastRun()` are unaffected. +- `ActorClient::version()`, `ActorClient::build()`, and `ActorVersionClient::envVar()` now reject an + empty-string identifier with an `InvalidArgumentException` instead of silently addressing the whole + collection. +- Fixed `ResourceContext::toSafeId()` (used to build a resource's URL from its id) to replace every + `/` in the id, not just the first — an id with more than one `/` could previously leave a literal + `/` in the built URL path. +- Resource ids and URL path segments built from caller input (record keys, request ids) are now + rejected up front with an `InvalidArgumentException` when empty or `.`/`..`, instead of being + percent-encoded and sent as-is: a URL parser resolves such dot-segments from the decoded path, so a + literal `.`/`..` segment can still collapse to a different endpoint even without a `/` in the + string. Matches the reference client's path-sanitization fix. +- Added timeout tiers: the `ApifyClient` constructor takes optional `timeoutShortSecs`, + `timeoutMediumSecs`, `timeoutLongSecs`, and `timeoutMaxSecs` options, and the `get()`/`update()`/ + `delete()` methods of every resource client take an optional trailing `$timeoutSecs` (a number of + seconds, a tier name, or `'noTimeout'`) that overrides the default for that call. Every tier + defaults to the existing `timeoutSecs`, so a client built without the new options is unaffected. +- Added a `compression` option to the `ApifyClient` constructor: `'brotli'`, `'gzip'`, or a custom + `HttpCompressorInterface` (new `BrotliHttpCompressor`/`GzipHttpCompressor` implementations let the + compression quality be configured too). Left unset, the client keeps its existing best-effort + choice (brotli when available, falling back to gzip). +- Request-body compression is now skipped for a `Content-Type` that already carries its own + compression (`image/*`, `audio/*`, `video/*`, common archive/office/package formats, web fonts), + while raw formats under those prefixes (e.g. `image/bmp`, `audio/wav`) and `+json`/`+xml` structured + suffixes are still compressed, matching the reference client. +- `ApifyClient`'s `$baseUrl`/`$publicBaseUrl` now accept a URL that already ends with the `/v2` API + version path without doubling it into `.../v2/v2`, matching the reference client. +- `RequestQueueClient::batchAddRequests()` now serializes each request to JSON once up front instead + of re-encoding it for the size check, the chunk split, and the request body, matching the reference + client's performance fix. Behavior and chunk boundaries are unchanged. + ## 0.7.0 - Bumped `Version::API_SPEC_VERSION` to the Apify OpenAPI spec `v2-2026-10-01T153946Z`. diff --git a/README.md b/README.md index a0decb5..8bda068 100644 --- a/README.md +++ b/README.md @@ -62,20 +62,69 @@ $configured = new ApifyClient( | Argument | Default | Meaning | |---|---|---| | `token` | `null` | API token, sent as a Bearer token. | -| `baseUrl` | `https://api.apify.com` | API base URL; the `/v2` suffix is appended automatically. | -| `publicBaseUrl` | `baseUrl` | Base URL used when building public, shareable resource URLs. | +| `baseUrl` | `https://api.apify.com` | API base URL, with or without the trailing `/v2` version path — appended automatically when not already present. | +| `publicBaseUrl` | `baseUrl` | Base URL used when building public, shareable resource URLs. Like `baseUrl`, `/v2` is appended when absent. | | `maxRetries` | `8` | Maximum retries for failed requests. | | `minDelayBetweenRetriesMillis` | `500` | Minimum delay between retries (exponential backoff). | | `maxDelayBetweenRetriesMillis` | `timeoutSecs × 1000` (360000) | Upper bound (milliseconds) on the growing inter-retry delay; defaults to the request timeout expressed in milliseconds. | -| `timeoutSecs` | `360` | Overall per-request timeout. | +| `timeoutSecs` | `360` | Overall per-request timeout, and the default duration of every timeout tier below that is left unset. | +| `timeoutShortSecs` / `timeoutMediumSecs` / `timeoutLongSecs` | `timeoutSecs` | Duration (seconds) of the named timeout tier — see [Timeout tiers](#timeout-tiers). | +| `timeoutMaxSecs` | `timeoutSecs` | Upper bound on any single request's timeout, including a per-call override. | +| `compression` | `null` (brotli, falling back to gzip) | Request-body compression: `'brotli'`, `'gzip'`, or a custom `HttpCompressorInterface` — see [HTTP compression](#http-compression). | | `userAgentSuffix` | `null` | Custom suffix appended to the `User-Agent` header. | | `httpClient` | Guzzle | The replaceable transport (`Apify\Client\Http\HttpClientInterface`). | Requests are retried on network errors, HTTP 429 (rate limit) and 5xx responses, with exponential -backoff and jitter. Other 4xx responses are thrown immediately as `ApifyApiException`, with one -exception: a resource-not-found 404 (the API's `record-not-found` / `record-or-token-not-found` -error type) on a single-resource fetch is not thrown — `get()` returns `null` and `delete()` is -treated as a successful no-op (see [Error handling](#error-handling)). +backoff and jitter. Other 4xx responses are thrown immediately as an `ApifyApiException` subclass +matching the status code, with one exception: a resource-not-found 404 on a single-resource fetch is +not thrown — `get()` returns `null` and `delete()` is treated as a successful no-op (see +[Error handling](#error-handling)). + +### Timeout tiers + +Every method that sends a request is assigned a timeout tier: `short` (metadata reads/writes, e.g. +`get()`/`update()`/`delete()`), `medium` (listing/batch/trigger calls), or `long` +(downloads/uploads/streaming). Each tier's duration defaults to the client's single `timeoutSecs`, so +a client constructed without the tier options behaves exactly as before they existed; set +`timeoutShortSecs`/`timeoutMediumSecs`/`timeoutLongSecs` to give a tier its own duration instead. + +The `get()`/`update()`/`delete()` methods of every resource client additionally take an optional +trailing `$timeoutSecs`, overriding the tier for that one call — a number of seconds, a tier name +(`'short'`/`'medium'`/`'long'`), or `'noTimeout'` for no request timeout at all: + +```php +$client->actor('my-actor')->get(timeoutSecs: 10); // this call only, 10 seconds +$client->actor('my-actor')->get(timeoutSecs: 'long'); // this call only, the 'long' tier's duration +``` + +`timeoutMaxSecs` caps every tier and every per-call override alike (defaulting to `timeoutSecs`), so +raise it whenever a call legitimately needs longer than the default 360 seconds. + +### HTTP compression + +Request bodies above 1 KiB are compressed before being sent, unless their `Content-Type` already +carries its own compression (`image/*`, `audio/*`, `video/*`, common archive/office/font formats) or +the caller already set a `Content-Encoding` header. By default the client picks brotli when the +optional PECL `brotli` extension is loaded, falling back to gzip (PHP's standard `zlib` extension) +otherwise — compression is always best-effort, so a body is sent uncompressed rather than the request +failing when neither codec is available. + +Pass `compression` to pick an algorithm explicitly, or an instance for a custom quality: + +```php +use Apify\Client\Http\BrotliHttpCompressor; +use Apify\Client\Http\GzipHttpCompressor; + +$client = new ApifyClient(token: 'my-api-token', compression: 'gzip'); + +// A custom quality (brotli: 0-11, default 6; gzip: 1-9, default 6). +$client = new ApifyClient(token: 'my-api-token', compression: new BrotliHttpCompressor(quality: 11)); +$client = new ApifyClient(token: 'my-api-token', compression: new GzipHttpCompressor(quality: 1)); +``` + +A custom algorithm is any `Apify\Client\Http\HttpCompressorInterface` implementation (a +`contentEncoding(): string` plus a `compress(string $body): ?string` that returns `null` to leave a +body uncompressed). ### Replaceable HTTP transport @@ -95,17 +144,48 @@ $client = new ApifyClient(token: 'my-api-token', httpClient: new Psr18HttpClient ## Error handling -Methods that fetch a single resource return `null` when the resource does not exist (rather than -throwing). Other API failures are thrown as `Apify\Client\Exception\ApifyApiException`: +Methods that fetch a single resource by ID return `null` when the resource does not exist (rather +than throwing), and `delete()` is a no-op for a resource that is already gone. Other API failures are +thrown as `Apify\Client\Exception\ApifyApiException`, or one of its subclasses matching the response's +HTTP status — `InvalidRequestException` (400), `UnauthorizedException` (401), `ForbiddenException` +(403), `NotFoundException` (404), `ConflictException` (409), `RateLimitException` (429), or +`ServerException` (any 5xx) — so a `catch` block can branch on `instanceof` instead of comparing +status codes or `getType()` strings. Any other status still throws a plain `ApifyApiException`, and +every subclass extends it, so an existing `instanceof ApifyApiException` check keeps matching all of +them: ```php +use Apify\Client\Exception\ApifyApiException; +use Apify\Client\Exception\NotFoundException; + try { $client->actor('does/not-exist')->update(['title' => 'x']); +} catch (NotFoundException $e) { + // The Actor doesn't exist, or the token can't see it. } catch (ApifyApiException $e) { echo $e->getStatusCode() . ' ' . $e->getType() . ': ' . $e->getApiMessage() . PHP_EOL; } ``` +A 404 is swallowed (resolving to `null`/no-op) only when it can be pinned to the resource the call +addresses. A resource client obtained *without* an ID — `RunClient::dataset()`/`keyValueStore()`/ +`requestQueue()`/`log()`, `BuildClient::log()` — throws instead, since the 404 there could mean either +the parent run/build or the sub-resource is gone: + +```php +try { + $client->run('missing-run-id')->dataset()->get(); +} catch (NotFoundException $e) { + // Either the run or its default dataset does not exist — the response cannot tell which. +} +``` + +The same rule applies to a handful of fixed sub-paths whose only way to 404 is their parent being +gone: `DatasetClient::getStatistics()`, `TaskClient::getInput()`, `ScheduleClient::getLog()`, and +`BuildClient::getOpenApiDefinition()` always throw rather than returning `null`. Lookups by key, such +as `KeyValueStoreClient::getRecord()`/`RequestQueueClient::getRequest()`, are unaffected and still +resolve to `null` for a missing record/request. + `ApifyApiException` extends `RuntimeException` and exposes: | Accessor | Returns | diff --git a/docs/README.md b/docs/README.md index d7010b0..eba8456 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,8 +20,8 @@ Every class is under the `Apify\Client\` PSR-4 root. Use these when writing `use | `Apify\Client\` | The entry point and version constants. | `ApifyClient`, `Version` | | `Apify\Client\Model\` | Response models returned by the API. | `RequestQueueRequest`, `ActorEnvVar`, `Dataset`, `ActorRun`, `PaginationList` | | `Apify\Client\Options\` | Option objects (all the `*Options` classes) **and** enums. | e.g. `ActorListOptions`, `ActorStartOptions`, `TaskStartOptions`, `RunListOptions`, `RunResurrectOptions`, `StorageListOptions`, `StoreListOptions`, `DatasetListItemsOptions`, `ListKeysOptions`, `GetRecordOptions`, `ListRequestsOptions`, `BatchAddRequestsOptions`, `PaginateRequestsOptions`, `LogOptions`, `DownloadItemsFormat` — see [options reference](options.md) for the full list | -| `Apify\Client\Http\` | The replaceable transport and its adapters. | `HttpClientInterface`, `GuzzleHttpClient`, `Psr18HttpClient` | -| `Apify\Client\Exception\` | Exceptions thrown by the client. | `ApifyApiException`, `TransportException` | +| `Apify\Client\Http\` | The replaceable transport, its adapters, and compressors. | `HttpClientInterface`, `GuzzleHttpClient`, `Psr18HttpClient`, `HttpCompressorInterface`, `BrotliHttpCompressor`, `GzipHttpCompressor` | +| `Apify\Client\Exception\` | Exceptions thrown by the client. | `ApifyApiException` and its status-specific subclasses (`NotFoundException`, `RateLimitException`, ...), `TransportException` | For example, to add requests to a queue you would import the model and (optionally) the batch options: @@ -35,8 +35,10 @@ The streaming-log accessors (`LogClient::stream()` and `RunClient::getStreamedLo PSR-7 `Psr\Http\Message\StreamInterface` (from the `psr/http-message` package), not an `Apify\Client\` type — import it as `use Psr\Http\Message\StreamInterface;`. -Methods that fetch a single resource return `null` when the resource does not exist, rather than -throwing. API failures are thrown as `ApifyApiException` (see [error handling](../README.md#error-handling)). +Methods that fetch a single resource by ID return `null` when the resource does not exist, rather +than throwing. A resource client obtained without an ID (e.g. `RunClient::dataset()`) throws instead, +since a 404 there cannot be pinned to the parent or the sub-resource. API failures are thrown as +`ApifyApiException` or a status-specific subclass (see [error handling](../README.md#error-handling)). ## ApifyClient methods diff --git a/docs/builds.md b/docs/builds.md index 4f250b2..799d6e9 100644 --- a/docs/builds.md +++ b/docs/builds.md @@ -23,8 +23,10 @@ An Actor's builds are available at `$client->actor($id)->builds()`. - `abort(): Build` - `delete(): void` - `waitForFinish(?int $waitSecs = null): Build` — poll until terminal (`null` waits indefinitely). -- `getOpenApiDefinition(): ?array` — the build's generated OpenAPI document, or `null`. -- `log(): LogClient` +- `getOpenApiDefinition(): ?array` — the build's generated OpenAPI document. Throws if the build + itself does not exist (a 404 here cannot mean anything else). +- `log(): LogClient` — `get()` throws instead of resolving to `null` on a 404, since there is no ID + to pin the 404 to the build or its log. ```php $build = $client->actor('me~my-actor')->build('0.0', new ActorBuildOptions(tag: 'latest')); diff --git a/docs/runs.md b/docs/runs.md index 6a55834..a728649 100644 --- a/docs/runs.md +++ b/docs/runs.md @@ -29,7 +29,9 @@ An Actor's or task's runs are available at `$client->actor($id)->runs()` / `$cli - `charge(RunChargeOptions $options): void` — for pay-per-event Actors. - `waitForFinish(?int $waitSecs = null): ActorRun` - `dataset(): DatasetClient`, `keyValueStore(): KeyValueStoreClient`, `requestQueue(): RequestQueueClient` -- `log(): LogClient`, `getStreamedLog(): StreamInterface` + — `get()`/`delete()` on these throw instead of resolving to `null`/no-op on a 404, since there is no + ID to pin the 404 to the run or the sub-resource. +- `log(): LogClient`, `getStreamedLog(): StreamInterface` — `log()->get()` throws on a 404 for the same reason. ```php $run = $client->run('RUN_ID')->waitForFinish(120); diff --git a/docs/schedules.md b/docs/schedules.md index fb878ff..a14b907 100644 --- a/docs/schedules.md +++ b/docs/schedules.md @@ -25,7 +25,8 @@ foreach ($client->schedules()->iterate(new ListOptions(), 50) as $s) { ## A single schedule — `$client->schedule($id)` - `get(): ?Schedule`, `update(mixed $newFields): Schedule`, `delete(): void` -- `getLog(): ?string` — the schedule's invocation log, or `null` if absent. +- `getLog(): ?string` — the schedule's invocation log. Throws if the schedule itself does not exist + (a 404 here cannot mean anything else). ```php $updated = $client->schedule('SCHEDULE_ID')->update(['cronExpression' => '0 12 * * *']); diff --git a/docs/storages.md b/docs/storages.md index c0679e0..710f4f2 100644 --- a/docs/storages.md +++ b/docs/storages.md @@ -22,7 +22,8 @@ Single — `$client->dataset($id)`: - `iterateItems(?DatasetListItemsOptions $options = null, ?int $chunkSize = null): iterable` — lazily iterate all items, paging on demand. The options' `limit` caps the total number of items yielded across all pages (unset = all); `$chunkSize` is the per-page size. The iterator advances and terminates by the number of rows the API scanned (the `X-Apify-Pagination-Count` response header), not by the number of items a page returns, matching the reference JS client exactly — see the method's doc comment for why that matters with `skipEmpty`/`clean`/`unwind`. - `downloadItems(DownloadItemsFormat $format, ?DatasetDownloadOptions $options = null): string` — raw export bytes. - `pushItems(mixed $items): void` -- `getStatistics(): ?array` +- `getStatistics(): ?array` — throws if the dataset itself does not exist (a 404 here cannot mean + anything else). - `createItemsPublicUrl(?DatasetListItemsOptions $options = null, ?int $expiresInSecs = null, ?DownloadItemsFormat $format = null): string` — builds a shareable URL for downloading this dataset's items (forwarding the given item filters); for a private dataset it appends an access signature, optionally bounded to `$expiresInSecs`. `$format` selects the served output format (defaults to `json`). ```php diff --git a/docs/tasks.md b/docs/tasks.md index a6fdef9..43e640d 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -32,7 +32,8 @@ foreach ($client->tasks()->iterate(new ListOptions(), 50) as $t) { nothing is changed. - `start(mixed $input = null, ?TaskStartOptions $options = null): ActorRun` - `call(mixed $input = null, ?TaskStartOptions $options = null, ?int $waitSecs = null): ActorRun` -- `getInput(): mixed`, `updateInput(mixed $input): mixed` +- `getInput(): mixed` (throws if the task itself does not exist — a 404 here cannot mean anything + else), `updateInput(mixed $input): mixed` - `lastRun(?LastRunOptions $options = null): RunClient` - `runs(): RunCollectionClient` - `webhooks(): NestedWebhookCollectionClient` — read-only. diff --git a/src/ApifyClient.php b/src/ApifyClient.php index d380cb0..08a3ab3 100644 --- a/src/ApifyClient.php +++ b/src/ApifyClient.php @@ -4,11 +4,15 @@ namespace Apify\Client; +use Apify\Client\Http\BrotliHttpCompressor; use Apify\Client\Http\GuzzleHttpClient; +use Apify\Client\Http\GzipHttpCompressor; use Apify\Client\Http\HttpClientInterface; +use Apify\Client\Http\HttpCompressorInterface; use Apify\Client\Internal\HttpClientCore; use Apify\Client\Internal\Platform; use Apify\Client\Internal\RetryConfig; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\ActorRun; use Apify\Client\Options\RequestQueueClientOptions; use Apify\Client\Resource\ActorClient; @@ -60,6 +64,9 @@ final class ApifyClient public const DEFAULT_MIN_DELAY_MILLIS = 500; public const DEFAULT_TIMEOUT_SECS = 360; + /** The API version path this client targets; appended to {@code $baseUrl}/{@code $publicBaseUrl} when absent. */ + private const API_VERSION_PATH = '/v2'; + /** Environment variable that signals the client is running on the Apify platform. */ private const ENV_IS_AT_HOME = 'APIFY_IS_AT_HOME'; @@ -72,14 +79,31 @@ final class ApifyClient /** * @param string|null $token API token, sent as a Bearer token - * @param string $baseUrl API base URL; {@code /v2} is appended automatically + * @param string $baseUrl API base URL, with or without the trailing + * {@code /v2} version path — appended automatically + * when not already present * @param string|null $publicBaseUrl base URL for building public, shareable resource - * URLs (defaults to {@code $baseUrl}); {@code /v2} appended + * URLs (defaults to {@code $baseUrl}); like + * {@code $baseUrl}, {@code /v2} is appended when absent * @param int $maxRetries maximum retries for failed requests (default 8) * @param int $minDelayBetweenRetriesMillis minimum delay between retries in ms (default 500) * @param int|null $maxDelayBetweenRetriesMillis upper bound for the growing inter-retry delay * (defaults to the request timeout) - * @param int $timeoutSecs overall per-request timeout in seconds (default 360) + * @param int $timeoutSecs overall per-request timeout in seconds (default + * 360); also the default duration of every timeout + * tier below that is left unset + * @param int|null $timeoutShortSecs duration (seconds) of the {@code short} timeout + * tier (metadata reads/writes); defaults to + * {@code $timeoutSecs} + * @param int|null $timeoutMediumSecs duration (seconds) of the {@code medium} timeout + * tier (listing/batch/trigger calls); defaults to + * {@code $timeoutSecs} + * @param int|null $timeoutLongSecs duration (seconds) of the {@code long} timeout + * tier (downloads/uploads/streaming); defaults to + * {@code $timeoutSecs} + * @param int|null $timeoutMaxSecs upper bound on any single request's timeout, + * including a per-call override; defaults to + * {@code $timeoutSecs} * @param string|null $userAgentSuffix custom suffix appended to the User-Agent header * @param HttpClientInterface|null $httpClient replaces the default transport (Guzzle) * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (defaults to Guzzle's) @@ -94,31 +118,52 @@ public function __construct( int $minDelayBetweenRetriesMillis = self::DEFAULT_MIN_DELAY_MILLIS, ?int $maxDelayBetweenRetriesMillis = null, int $timeoutSecs = self::DEFAULT_TIMEOUT_SECS, + ?int $timeoutShortSecs = null, + ?int $timeoutMediumSecs = null, + ?int $timeoutLongSecs = null, + ?int $timeoutMaxSecs = null, ?string $userAgentSuffix = null, ?HttpClientInterface $httpClient = null, ?RequestFactoryInterface $requestFactory = null, ?StreamFactoryInterface $streamFactory = null, ?callable $isAtHomeFn = null, + /** + * Overrides request-body compression: {@code 'brotli'}, {@code 'gzip'}, or a custom {@see + * HttpCompressorInterface}. {@code null} (the default) keeps the client's existing best-effort + * choice (brotli when the optional PECL extension is loaded, falling back to gzip), unchanged + * from before this option existed, so a client constructed without it sends the same requests + * as always. Pass an explicit {@see BrotliHttpCompressor}/{@see GzipHttpCompressor} instance to + * also choose the compression quality. + */ + string|HttpCompressorInterface|null $compression = null, ) { $transport = $httpClient ?? new GuzzleHttpClient(); $factory = new HttpFactory(); $requestFactory ??= $factory; $streamFactory ??= $factory; + $compressor = self::resolveCompressor($compression); $maxDelayMillis = $maxDelayBetweenRetriesMillis ?? $timeoutSecs * 1000; + $tiers = TimeoutTiers::create( + (float) $timeoutSecs, + $timeoutShortSecs !== null ? (float) $timeoutShortSecs : null, + $timeoutMediumSecs !== null ? (float) $timeoutMediumSecs : null, + $timeoutLongSecs !== null ? (float) $timeoutLongSecs : null, + $timeoutMaxSecs !== null ? (float) $timeoutMaxSecs : null, + ); $retry = new RetryConfig( $maxRetries, (float) $minDelayBetweenRetriesMillis, (float) $maxDelayMillis, (float) $timeoutSecs, + $tiers, ); $userAgent = self::buildUserAgent($userAgentSuffix, $isAtHomeFn ?? self::defaultIsAtHome(...)); - $this->http = new HttpClientCore($transport, $requestFactory, $streamFactory, $token, $userAgent, $retry); + $this->http = new HttpClientCore($transport, $requestFactory, $streamFactory, $token, $userAgent, $retry, $compressor); - $this->baseUrl = self::trimTrailingSlash($baseUrl) . '/v2'; - $publicSource = $publicBaseUrl ?? $baseUrl; - $this->publicBaseUrl = self::trimTrailingSlash($publicSource) . '/v2'; + $this->baseUrl = self::toApiBaseUrl($baseUrl); + $this->publicBaseUrl = self::toApiBaseUrl($publicBaseUrl ?? $baseUrl); } /** Returns the {@code User-Agent} header value this client sends. */ @@ -326,6 +371,36 @@ private static function trimTrailingSlash(string $value): string return rtrim($value, '/'); } + /** + * Resolves the {@code compression} constructor option to a concrete compressor, or {@code null} to + * keep the client's own default best-effort choice. + */ + private static function resolveCompressor(string|HttpCompressorInterface|null $compression): ?HttpCompressorInterface + { + return match (true) { + $compression === null => null, + $compression instanceof HttpCompressorInterface => $compression, + $compression === 'brotli' => new BrotliHttpCompressor(), + $compression === 'gzip' => new GzipHttpCompressor(), + default => throw new RuntimeException( + sprintf('ApifyClient: unknown compression "%s"; expected "brotli", "gzip", or a HttpCompressorInterface', $compression) + ), + }; + } + + /** + * Appends the {@code /v2} API version path to {@code $url}, unless it already ends with one, so + * passing a URL that already includes it (e.g. {@code https://api.apify.com/v2}) does not produce + * {@code .../v2/v2}. A trailing slash is stripped first, matching the reference client's + * {@code toApiBaseUrl}. Only an exact {@code /v2} suffix counts; another {@code /vN} is left alone, + * since every endpoint this client calls exists only under {@code v2}. + */ + private static function toApiBaseUrl(string $url): string + { + $path = self::trimTrailingSlash($url); + return str_ends_with($path, self::API_VERSION_PATH) ? $path : $path . self::API_VERSION_PATH; + } + /** * Reports whether the client is running on the Apify platform, by reading the * {@code APIFY_IS_AT_HOME} environment variable (set to a non-empty value on the platform). diff --git a/src/Exception/ConflictException.php b/src/Exception/ConflictException.php new file mode 100644 index 0000000..de60373 --- /dev/null +++ b/src/Exception/ConflictException.php @@ -0,0 +1,10 @@ + 11) { + throw new InvalidArgumentException('BrotliHttpCompressor: $quality must be between 0 and 11, got ' . $quality); + } + $this->quality = $quality; + } + + public function contentEncoding(): string + { + return 'br'; + } + + public function compress(string $body): ?string + { + if (!function_exists('brotli_compress')) { + return null; + } + // Called indirectly: brotli_compress only exists when the PECL brotli extension is loaded, so a + // direct call would be an unresolved reference for static analysis on builds without it. + $brotliCompress = 'brotli_compress'; + $compressed = $brotliCompress($body, $this->quality); + return is_string($compressed) ? $compressed : null; + } +} diff --git a/src/Http/GzipHttpCompressor.php b/src/Http/GzipHttpCompressor.php new file mode 100644 index 0000000..7dda1e7 --- /dev/null +++ b/src/Http/GzipHttpCompressor.php @@ -0,0 +1,41 @@ + 9) { + throw new InvalidArgumentException('GzipHttpCompressor: $quality must be between 1 and 9, got ' . $quality); + } + $this->quality = $quality; + } + + public function contentEncoding(): string + { + return 'gzip'; + } + + public function compress(string $body): ?string + { + if (!function_exists('gzencode')) { + return null; + } + $compressed = gzencode($body, $this->quality); + return is_string($compressed) ? $compressed : null; + } +} diff --git a/src/Http/HttpCompressorInterface.php b/src/Http/HttpCompressorInterface.php new file mode 100644 index 0000000..e51ec6c --- /dev/null +++ b/src/Http/HttpCompressorInterface.php @@ -0,0 +1,28 @@ +retry->timeoutSecs; } + /** The configured timeout tiers (short/medium/long/max). See {@see TimeoutTiers}. */ + public function timeoutTiers(): TimeoutTiers + { + return $this->retry->timeoutTiers; + } + /** * Sends a request with auth, User-Agent and the retry policy applied. * @@ -74,7 +94,7 @@ public function call( array $extraHeaders = [] ): ResponseInterface { // Compress the body once, up front, so every retry reuses the already-compressed payload. - [$body, $extraHeaders] = self::maybeCompressBody($body, $extraHeaders); + [$body, $extraHeaders] = $this->maybeCompressBody($body, $contentType, $extraHeaders); $delayMillis = $this->retry->minDelayMillis; $maxAttempts = $this->retry->maxRetries + 1; @@ -120,17 +140,35 @@ public function call( * Compresses the request body when it is large enough to be worth it, returning the possibly * replaced body together with the (possibly extended) header map. A caller that already set a * {@code Content-Encoding} header is left untouched, so an explicitly-encoded body is never - * double-compressed. + * double-compressed. A body whose {@code $contentType} already carries its own compression (media, + * archives, fonts, ...) is left uncompressed too — see {@see Compression::isCompressibleContentType()}. + * + * Uses the configured {@see $compressor} when one was given to the constructor (the {@code + * compression} option of {@see \Apify\Client\ApifyClient}); otherwise falls back to the client's + * default best-effort choice ({@see Compression::maybeCompress()}: brotli, falling back to gzip). * * @param array $extraHeaders * @return array{0: string|null, 1: array} */ - private static function maybeCompressBody(?string $body, array $extraHeaders): array + private function maybeCompressBody(?string $body, string $contentType, array $extraHeaders): array { - if ($body === null || self::hasHeader($extraHeaders, 'Content-Encoding')) { + if ($body === null + || self::hasHeader($extraHeaders, 'Content-Encoding') + || !Compression::isCompressibleContentType($contentType) + || strlen($body) < Compression::MIN_COMPRESS_BYTES + ) { return [$body, $extraHeaders]; } + if ($this->compressor !== null) { + $compressedBody = $this->compressor->compress($body); + if ($compressedBody === null) { + return [$body, $extraHeaders]; + } + $extraHeaders['Content-Encoding'] = $this->compressor->contentEncoding(); + return [$compressedBody, $extraHeaders]; + } + $compressed = Compression::maybeCompress($body); if ($compressed === null) { return [$body, $extraHeaders]; @@ -208,20 +246,24 @@ private function doAttempt( } /** - * Returns {@code min(overall, base * TIMEOUT_BACKOFF_FACTOR^(attempt-1))}: the first attempt uses - * the base timeout; each retry scales it up by {@see TIMEOUT_BACKOFF_FACTOR} (a slow-but-progressing - * connection gets more time) while never exceeding the overall budget. + * Returns {@code min(cap, base * TIMEOUT_BACKOFF_FACTOR^(attempt-1))}: the first attempt uses the + * base timeout; each retry scales it up by {@see TIMEOUT_BACKOFF_FACTOR} (a slow-but-progressing + * connection gets more time) while never exceeding {@code $cap} — the configured {@code + * timeoutMaxSecs} ({@see TimeoutTiers::$maxSecs}), which defaults to the legacy overall {@code + * timeoutSecs} when not explicitly raised, so a tier/per-call timeout above the legacy default + * still grows and is honored rather than being silently clipped back down to it. */ private function attemptTimeout(float $base, int $attempt): float { + $cap = $this->retry->timeoutTiers->maxSecs; $scaled = $base; for ($i = 1; $i < $attempt; $i++) { $scaled *= self::TIMEOUT_BACKOFF_FACTOR; - if ($scaled >= $this->retry->timeoutSecs) { - return $this->retry->timeoutSecs; + if ($scaled >= $cap) { + return $cap; } } - return min($scaled, $this->retry->timeoutSecs); + return min($scaled, $cap); } private static function isStatusRetryable(int $status): bool @@ -246,7 +288,24 @@ private function sleepMillis(float $millis): void } } - /** Builds an {@see ApifyApiException} from an API error response body. */ + /** + * Maps an HTTP status code to the {@see ApifyApiException} subclass that represents it, mirroring + * the reference client's {@code ApifyApiError.fromResponse()}. Any status with no dedicated + * subclass below (and no 5xx) stays a plain {@see ApifyApiException}, so {@code instanceof + * ApifyApiException} keeps matching every error regardless of status. + * + * @var array> + */ + private const EXCEPTION_CLASS_BY_STATUS = [ + 400 => InvalidRequestException::class, + 401 => UnauthorizedException::class, + 403 => ForbiddenException::class, + 404 => NotFoundException::class, + 409 => ConflictException::class, + 429 => RateLimitException::class, + ]; + + /** Builds an {@see ApifyApiException} (or the subclass matching the status) from an API error response body. */ public static function buildApiError(int $status, string $body, int $attempt, string $method, string $path): ApifyApiException { $type = null; @@ -270,7 +329,9 @@ public static function buildApiError(int $status, string $body, int $attempt, st : 'unexpected error: ' . $body; } - return new ApifyApiException($status, $type, $message, $attempt, $method, $path, $data); + $exceptionClass = self::EXCEPTION_CLASS_BY_STATUS[$status] + ?? ($status >= self::MIN_SERVER_ERROR ? ServerException::class : ApifyApiException::class); + return new $exceptionClass($status, $type, $message, $attempt, $method, $path, $data); } /** Returns the path+query portion of a URL, for error reporting. */ @@ -285,15 +346,17 @@ public static function extractPath(string $url): string return $slash !== false ? substr($rest, $slash) : ''; } - /** Reports whether an exception represents a "resource not found" API error. */ + /** + * Reports whether an exception represents a "resource not found" API error — any HTTP 404, + * whatever its machine-readable {@code type}. Matches the reference client's {@code + * catchNotFoundOrThrow()}, which checks only {@code instanceof NotFoundError}: earlier versions of + * both clients swallowed only the {@code record-not-found}/{@code record-or-token-not-found} types + * (plus every HEAD request), which meant a 404 with any other type — for example on an endpoint + * added after that type list was written — incorrectly propagated as an error instead of resolving + * to "not found" like every other 404. + */ public static function isNotFound(Throwable $e): bool { - if (!$e instanceof ApifyApiException || $e->getStatusCode() !== self::NOT_FOUND) { - return false; - } - $type = $e->getType(); - return $type === 'record-not-found' - || $type === 'record-or-token-not-found' - || $e->getHttpMethod() === 'HEAD'; + return $e instanceof ApifyApiException && $e->getStatusCode() === self::NOT_FOUND; } } diff --git a/src/Internal/ResourceContext.php b/src/Internal/ResourceContext.php index d66044c..efc4abc 100644 --- a/src/Internal/ResourceContext.php +++ b/src/Internal/ResourceContext.php @@ -7,6 +7,7 @@ use Apify\Client\Exception\ApifyApiException; use Apify\Client\Model\PaginationList; use Generator; +use InvalidArgumentException; use Psr\Http\Message\ResponseInterface; use RuntimeException; @@ -57,6 +58,16 @@ private function __construct( /** Fully-qualified base URL of the resource, e.g. https://api.apify.com/v2/actors/ID. */ public string $url, string $baseUrl, + /** + * Whether this context addresses a resource by an explicit ID ({@see single()}) rather than a + * fixed, ID-less sub-path ({@see collection()}, used e.g. by {@code run.dataset()}). Gates + * whether {@see getResource()}/{@see getRaw()}/{@see deleteResource()} swallow a 404 on the + * resource itself ({@code $subPath === ''}): an ID-addressed resource that is missing resolves + * to {@code null}/no-op, but a 404 on an ID-less chained resource (e.g. a run's default + * dataset) can mean either the parent or the sub-resource is gone, so it is rethrown instead — + * matching the reference client's {@code catchNotFoundForResourceOrThrow}. + */ + private bool $hasId = true, ) { $this->baseParams = new QueryParams(); $this->apiOrigin = self::originOf($baseUrl); @@ -79,13 +90,14 @@ public function requestTimeoutSecs(): ?float /** Creates a context for a collection endpoint: {@code {base}/{resourcePath}}. */ public static function collection(HttpClientCore $http, string $baseUrl, string $resourcePath): self { - return new self($http, $baseUrl . '/' . $resourcePath, $baseUrl); + return new self($http, $baseUrl . '/' . $resourcePath, $baseUrl, hasId: false); } /** Creates a context for a single resource: {@code {base}/{resourcePath}/{safeId}}. */ public static function single(HttpClientCore $http, string $baseUrl, string $resourcePath, string $id): self { - return new self($http, $baseUrl . '/' . $resourcePath . '/' . self::toSafeId($id), $baseUrl); + $segment = self::encodePathSegment(self::toSafeId($id)); + return new self($http, $baseUrl . '/' . $resourcePath . '/' . $segment, $baseUrl, hasId: true); } /** Overrides the origin used when building public URLs. */ @@ -127,12 +139,12 @@ public function mergedParams(?QueryParams $params): QueryParams * * @return mixed */ - public function getResource(string $subPath, QueryParams $params): mixed + public function getResource(string $subPath, QueryParams $params, ?float $timeoutSecs = null): mixed { try { - return $this->getResourceRequired($subPath, $params); + return $this->getResourceRequired($subPath, $params, $timeoutSecs); } catch (ApifyApiException $e) { - if (HttpClientCore::isNotFound($e)) { + if (HttpClientCore::isNotFound($e) && $this->swallowsNotFound($subPath)) { return null; } throw $e; @@ -144,10 +156,10 @@ public function getResource(string $subPath, QueryParams $params): mixed * * @return mixed */ - public function getResourceRequired(string $subPath, QueryParams $params): mixed + public function getResourceRequired(string $subPath, QueryParams $params, ?float $timeoutSecs = null): mixed { $url = $this->mergedParams($params)->applyToUrl($this->subUrl($subPath)); - $response = $this->http->call('GET', $url, timeoutSecs: $this->requestTimeoutSecs); + $response = $this->http->call('GET', $url, timeoutSecs: $this->effectiveTimeout($timeoutSecs)); return Json::decodeData((string) $response->getBody()); } @@ -156,26 +168,52 @@ public function getResourceRequired(string $subPath, QueryParams $params): mixed * * @return array */ - public function updateResource(string $subPath, mixed $body): array + public function updateResource(string $subPath, mixed $body, ?float $timeoutSecs = null): array { $url = $this->mergedParams(new QueryParams())->applyToUrl($this->subUrl($subPath)); - $response = $this->http->call('PUT', $url, Json::encode($body), self::CONTENT_TYPE_JSON, timeoutSecs: $this->requestTimeoutSecs); + $response = $this->http->call('PUT', $url, Json::encode($body), self::CONTENT_TYPE_JSON, timeoutSecs: $this->effectiveTimeout($timeoutSecs)); return self::asArray(Json::decodeData((string) $response->getBody())); } - /** Performs a DELETE; a not-found is treated as a successful no-op. */ - public function deleteResource(string $subPath): void + /** + * Performs a DELETE; a not-found is treated as a successful no-op when this context addresses its + * resource by ID ({@see $hasId}). A DELETE on an ID-less chained resource (e.g. {@code + * run.dataset()->delete()}) rethrows instead, since the 404 could mean the parent run is gone. + */ + public function deleteResource(string $subPath, ?float $timeoutSecs = null): void { $url = $this->mergedParams(new QueryParams())->applyToUrl($this->subUrl($subPath)); try { - $this->http->call('DELETE', $url, timeoutSecs: $this->requestTimeoutSecs); + $this->http->call('DELETE', $url, timeoutSecs: $this->effectiveTimeout($timeoutSecs)); } catch (ApifyApiException $e) { - if (!HttpClientCore::isNotFound($e)) { + if (!HttpClientCore::isNotFound($e) || !$this->swallowsNotFound($subPath)) { throw $e; } } } + /** + * A per-call timeout override (seconds), falling back to this context's own configured timeout + * ({@see withTimeout()}) when {@code null}, which in turn falls back to the client-wide default. + */ + private function effectiveTimeout(?float $timeoutSecs): ?float + { + return $timeoutSecs ?? $this->requestTimeoutSecs; + } + + /** + * Resolves a per-call {@code $timeoutSecs} option (a number of seconds, a timeout tier name, {@see + * \Apify\Client\Internal\TimeoutTiers::NO_TIMEOUT}, or {@code null} for {@code $defaultTier}'s + * configured duration) against the client's configured {@see TimeoutTiers}, matching the reference + * client's per-method tier assignment. See {@see TimeoutTiers} for the tiers and their defaults. + * + * @param int|float|string|null $timeoutSecs + */ + public function resolveTimeout(int|float|string|null $timeoutSecs, string $defaultTier): ?float + { + return $this->http->timeoutTiers()->resolve($timeoutSecs, $defaultTier); + } + /** * GET a paginated listing and build a {@see PaginationList} with each item hydrated. * @@ -328,20 +366,41 @@ public function deleteWithBody(string $subPath, QueryParams $params, mixed $body return self::asArray(Json::decodeData((string) $response->getBody())); } - /** GET returning the raw response (no data envelope). Returns {@code null} on not-found. */ + /** + * GET returning the raw response (no data envelope). Returns {@code null} on not-found, unless + * this context addresses an ID-less chained resource ({@see $hasId}) and {@code $subPath} is the + * resource itself ({@code ''}), in which case the 404 is rethrown (see {@see deleteResource()}). + */ public function getRaw(string $subPath, QueryParams $params): ?ResponseInterface { - $url = $this->mergedParams($params)->applyToUrl($this->subUrl($subPath)); try { - return $this->http->call('GET', $url, timeoutSecs: $this->requestTimeoutSecs); + return $this->getRawRequired($subPath, $params); } catch (ApifyApiException $e) { - if (HttpClientCore::isNotFound($e)) { + if (HttpClientCore::isNotFound($e) && $this->swallowsNotFound($subPath)) { return null; } throw $e; } } + /** GET returning the raw response (no data envelope). Propagates every error, including a 404. */ + public function getRawRequired(string $subPath, QueryParams $params): ResponseInterface + { + $url = $this->mergedParams($params)->applyToUrl($this->subUrl($subPath)); + return $this->http->call('GET', $url, timeoutSecs: $this->requestTimeoutSecs); + } + + /** + * Whether a 404 on {@code $subPath} should resolve to "not found" ({@code null}/no-op) rather than + * being rethrown. True for any fixed sub-path of an ID-addressed resource, and for the resource + * itself only when it is addressed by ID; an ID-less chained resource's own 404 is ambiguous + * between "parent missing" and "sub-resource missing" and is always rethrown. + */ + private function swallowsNotFound(string $subPath): bool + { + return $subPath !== '' || $this->hasId; + } + /** HEAD request; returns whether the resource exists. */ public function headExists(string $subPath, QueryParams $params): bool { @@ -489,20 +548,34 @@ public static function encodeInputBody(mixed $input): ?string /** * Encodes a resource id so it is safe to embed in a URL path. Apify uses the {@code - * username~resourcename} form, so the first {@code /} of an id is replaced with {@code ~}. + * username~resourcename} form, so every {@code /} in the id is replaced with {@code ~} — not just + * the first — matching the reference client. Replacing only the first occurrence would leave any + * further {@code /} in the id as literal path separators once interpolated into a URL, letting an + * id such as {@code "a/../.."} introduce extra, attacker-controlled path segments. */ public static function toSafeId(string $id): string { - $slash = strpos($id, '/'); - return $slash === false ? $id : substr($id, 0, $slash) . '~' . substr($id, $slash + 1); + return str_replace('/', '~', $id); } /** - * Percent-encodes a single URL path segment, so values interpolated into the path (record keys, - * request IDs) cannot break out of the segment. + * Percent-encodes a single URL path segment, so values interpolated into the path (resource ids, + * record keys, request ids) cannot break out of the segment or restructure the request path. + * + * Rejects an empty segment and the dot-segments {@code "."}/{@code ".."} instead of encoding them: + * a URL parser (client-side proxy, CDN, or the server itself) resolves dot segments ({@code + * RFC 3986 §5.2.4}) from the decoded path, so a literal {@code .}/{@code ..} segment can still + * collapse to the parent resource even though the string itself contains no {@code /}. + * + * @throws InvalidArgumentException if {@code $input} is empty or is {@code "."}/{@code ".."} */ public static function encodePathSegment(string $input): string { + if ($input === '' || $input === '.' || $input === '..') { + throw new InvalidArgumentException( + sprintf('a URL path segment must be non-empty and must not be "." or "..", got %s', Json::encode($input)) + ); + } return rawurlencode($input); } diff --git a/src/Internal/RetryConfig.php b/src/Internal/RetryConfig.php index 372dd92..565d653 100644 --- a/src/Internal/RetryConfig.php +++ b/src/Internal/RetryConfig.php @@ -20,6 +20,8 @@ public function __construct( public readonly float $maxDelayMillis, /** Overall per-request timeout budget, in seconds. Each attempt's timeout grows but is capped here. */ public readonly float $timeoutSecs, + /** The configured timeout tiers (short/medium/long/max), used as the default when a call has no per-call override. */ + public readonly TimeoutTiers $timeoutTiers, ) { } } diff --git a/src/Internal/TimeoutTiers.php b/src/Internal/TimeoutTiers.php new file mode 100644 index 0000000..c6d8468 --- /dev/null +++ b/src/Internal/TimeoutTiers.php @@ -0,0 +1,95 @@ + $this->shortSecs, + self::TIER_MEDIUM => $this->mediumSecs, + self::TIER_LONG => $this->longSecs, + default => throw new InvalidArgumentException('unknown timeout tier: ' . $tier), + }; + return min($raw, $this->maxSecs); + } + + /** + * Resolves a per-call timeout override against this tier configuration: {@code null} uses + * {@code $defaultTier}'s configured duration; a tier name ({@see TIER_SHORT}, {@see TIER_MEDIUM}, + * {@see TIER_LONG}) uses that tier's duration instead; {@see NO_TIMEOUT} returns {@code null} + * (no request timeout — for calls that poll until a job finishes); a number of seconds is used + * as-is, capped at {@see $maxSecs}. + * + * @param int|float|string|null $timeoutSecs a number of seconds, a tier name, {@see NO_TIMEOUT}, or + * {@code null} to use {@code $defaultTier} + */ + public function resolve(int|float|string|null $timeoutSecs, string $defaultTier): ?float + { + if ($timeoutSecs === null) { + return $this->secondsFor($defaultTier); + } + if ($timeoutSecs === self::NO_TIMEOUT) { + return null; + } + if (is_string($timeoutSecs)) { + return $this->secondsFor($timeoutSecs); + } + return min((float) $timeoutSecs, $this->maxSecs); + } +} diff --git a/src/Resource/ActorClient.php b/src/Resource/ActorClient.php index 32cfac1..e227399 100644 --- a/src/Resource/ActorClient.php +++ b/src/Resource/ActorClient.php @@ -8,6 +8,7 @@ use Apify\Client\Internal\HttpClientCore; use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\Actor; use Apify\Client\Model\ActorRun; use Apify\Client\Model\Build; @@ -15,6 +16,7 @@ use Apify\Client\Options\ActorStartOptions; use Apify\Client\Options\LastRunOptions; use Apify\Client\Options\ValidateInputOptions; +use InvalidArgumentException; /** * A client for a specific Actor. @@ -43,9 +45,9 @@ public function getId(): string } /** Fetches the Actor object, or {@code null} if it does not exist. */ - public function get(): ?Actor + public function get(int|float|string|null $timeoutSecs = null): ?Actor { - $data = $this->ctx->getResource('', new QueryParams()); + $data = $this->ctx->getResource('', new QueryParams(), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); return is_array($data) ? new Actor($data) : null; } @@ -54,15 +56,15 @@ public function get(): ?Actor * * @param mixed $newFields any JSON-serializable set of fields to update */ - public function update(mixed $newFields): Actor + public function update(mixed $newFields, int|float|string|null $timeoutSecs = null): Actor { - return new Actor($this->ctx->updateResource('', $newFields)); + return new Actor($this->ctx->updateResource('', $newFields, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT))); } /** Deletes the Actor. */ - public function delete(): void + public function delete(int|float|string|null $timeoutSecs = null): void { - $this->ctx->deleteResource(''); + $this->ctx->deleteResource('', $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); } /** @@ -112,9 +114,16 @@ public function validateInput(mixed $input = null, ?ValidateInputOptions $option return is_array($result) && ($result['valid'] ?? false) === true; } - /** Builds the given version of the Actor and returns the created build. */ + /** + * Builds the given version of the Actor and returns the created build. + * + * @throws InvalidArgumentException if {@code $versionNumber} is empty + */ public function build(string $versionNumber, ?ActorBuildOptions $options = null): Build { + if ($versionNumber === '') { + throw new InvalidArgumentException('ActorClient::build: $versionNumber must not be empty'); + } $params = new QueryParams(); $params->addString('version', $versionNumber); ($options ?? new ActorBuildOptions())->appendTo($params); diff --git a/src/Resource/ActorEnvVarClient.php b/src/Resource/ActorEnvVarClient.php index ba88504..dd5e6e9 100644 --- a/src/Resource/ActorEnvVarClient.php +++ b/src/Resource/ActorEnvVarClient.php @@ -7,6 +7,7 @@ use Apify\Client\Internal\HttpClientCore; use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\ActorEnvVar; /** @@ -24,21 +25,23 @@ public function __construct(HttpClientCore $http, string $versionUrl, string $na } /** Fetches the environment variable, or {@code null} if it does not exist. */ - public function get(): ?ActorEnvVar + public function get(int|float|string|null $timeoutSecs = null): ?ActorEnvVar { - $data = $this->ctx->getResource('', new QueryParams()); + $data = $this->ctx->getResource('', new QueryParams(), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); return is_array($data) ? ActorEnvVar::fromArray($data) : null; } /** Updates the environment variable and returns the updated object. */ - public function update(ActorEnvVar $envVar): ActorEnvVar + public function update(ActorEnvVar $envVar, int|float|string|null $timeoutSecs = null): ActorEnvVar { - return ActorEnvVar::fromArray($this->ctx->updateResource('', $envVar->toArray())); + return ActorEnvVar::fromArray( + $this->ctx->updateResource('', $envVar->toArray(), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)) + ); } /** Deletes the environment variable. */ - public function delete(): void + public function delete(int|float|string|null $timeoutSecs = null): void { - $this->ctx->deleteResource(''); + $this->ctx->deleteResource('', $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); } } diff --git a/src/Resource/ActorVersionClient.php b/src/Resource/ActorVersionClient.php index ca104fe..ec42b0a 100644 --- a/src/Resource/ActorVersionClient.php +++ b/src/Resource/ActorVersionClient.php @@ -7,6 +7,7 @@ use Apify\Client\Internal\HttpClientCore; use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\ActorVersion; /** @@ -26,9 +27,9 @@ public function __construct(private HttpClientCore $http, string $actorUrl, stri } /** Fetches the version, or {@code null} if it does not exist. */ - public function get(): ?ActorVersion + public function get(int|float|string|null $timeoutSecs = null): ?ActorVersion { - $data = $this->ctx->getResource('', new QueryParams()); + $data = $this->ctx->getResource('', new QueryParams(), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); return is_array($data) ? new ActorVersion($data) : null; } @@ -37,15 +38,15 @@ public function get(): ?ActorVersion * * @param mixed $newFields any JSON-serializable set of fields to update */ - public function update(mixed $newFields): ActorVersion + public function update(mixed $newFields, int|float|string|null $timeoutSecs = null): ActorVersion { - return new ActorVersion($this->ctx->updateResource('', $newFields)); + return new ActorVersion($this->ctx->updateResource('', $newFields, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT))); } /** Deletes the version. */ - public function delete(): void + public function delete(int|float|string|null $timeoutSecs = null): void { - $this->ctx->deleteResource(''); + $this->ctx->deleteResource('', $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); } /** A client for a specific environment variable of this version. */ diff --git a/src/Resource/BuildClient.php b/src/Resource/BuildClient.php index cdbedd5..ae82736 100644 --- a/src/Resource/BuildClient.php +++ b/src/Resource/BuildClient.php @@ -8,6 +8,7 @@ use Apify\Client\Internal\Json; use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\Build; /** A client for a specific Actor build ({@code /v2/actor-builds/{buildId}}). */ @@ -28,12 +29,12 @@ public function __construct(private HttpClientCore $http, string $baseUrl, strin * longer than the client will wait; the server additionally caps the wait at 60s. Returns * {@code null} if it does not exist. */ - public function get(?int $waitForFinishSecs = null): ?Build + public function get(?int $waitForFinishSecs = null, int|float|string|null $timeoutSecs = null): ?Build { $params = new QueryParams(); // Clamp to the client's per-request timeout so a short custom timeout doesn't abort the call. $params->addInt('waitForFinish', $this->ctx->clampServerWait($waitForFinishSecs)); - $data = $this->ctx->getResource('', $params); + $data = $this->ctx->getResource('', $params, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); return is_array($data) ? new Build($data) : null; } @@ -44,9 +45,9 @@ public function abort(): Build } /** Deletes the build. */ - public function delete(): void + public function delete(int|float|string|null $timeoutSecs = null): void { - $this->ctx->deleteResource(''); + $this->ctx->deleteResource('', $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); } /** @@ -64,21 +65,28 @@ public function waitForFinish(?int $waitSecs = null): Build } /** - * Returns the OpenAPI definition generated for the build, or {@code null} if unavailable. + * Returns the OpenAPI definition generated for the build. + * + * Like the reference client, a 404 here is not swallowed: it is always rethrown, since the only + * way this fixed sub-path 404s is the build itself being gone (same rule as {@see + * \Apify\Client\Resource\DatasetClient::getStatistics()} and friends). * * @return array|null the raw OpenAPI document */ public function getOpenApiDefinition(): ?array { - $response = $this->ctx->getRaw('openapi.json', new QueryParams()); - if ($response === null) { - return null; - } - $decoded = Json::decode((string) $response->getBody()); + $decoded = Json::decode((string) $this->ctx->getRawRequired('openapi.json', new QueryParams())->getBody()); return is_array($decoded) ? $decoded : null; } - /** A client for accessing this build's log. */ + /** + * A client for accessing this build's log. + * + * A 404 from this client's {@code get()} throws an {@see \Apify\Client\Exception\ApifyApiException} + * instead of resolving to {@code null}, since this build client has no ID of its own at this + * sub-path — the 404 could mean either the build or its log is gone, and the response cannot tell + * the two apart (matches the reference client's {@code catchNotFoundForResourceOrThrow}). + */ public function log(): LogClient { return LogClient::nested($this->http, $this->ctx->subUrl('')); diff --git a/src/Resource/DatasetClient.php b/src/Resource/DatasetClient.php index 37fc46b..3b9ebdd 100644 --- a/src/Resource/DatasetClient.php +++ b/src/Resource/DatasetClient.php @@ -9,6 +9,7 @@ use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; use Apify\Client\Internal\Signatures; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\Dataset; use Apify\Client\Model\PaginationList; use Apify\Client\Options\DatasetDownloadOptions; @@ -51,10 +52,15 @@ public function withPublicBase(string $publicBaseUrl): self return $this; } - /** Fetches the dataset metadata, or {@code null} if it does not exist. */ - public function get(): ?Dataset + /** + * Fetches the dataset metadata, or {@code null} if it does not exist. + * + * When this client was obtained without an ID (e.g. {@see \Apify\Client\Resource\RunClient::dataset()}), + * a 404 is rethrown instead, since it could mean either the parent resource or the dataset is gone. + */ + public function get(int|float|string|null $timeoutSecs = null): ?Dataset { - $data = $this->ctx->getResource('', new QueryParams()); + $data = $this->ctx->getResource('', new QueryParams(), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); return is_array($data) ? new Dataset($data) : null; } @@ -63,15 +69,18 @@ public function get(): ?Dataset * * @param mixed $newFields any JSON-serializable set of fields to update */ - public function update(mixed $newFields): Dataset + public function update(mixed $newFields, int|float|string|null $timeoutSecs = null): Dataset { - return new Dataset($this->ctx->updateResource('', $newFields)); + return new Dataset($this->ctx->updateResource('', $newFields, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT))); } - /** Deletes the dataset. */ - public function delete(): void + /** + * Deletes the dataset. A not-found is a no-op, unless this client was obtained without an ID (e.g. + * {@see \Apify\Client\Resource\RunClient::dataset()}), in which case it is rethrown. + */ + public function delete(int|float|string|null $timeoutSecs = null): void { - $this->ctx->deleteResource(''); + $this->ctx->deleteResource('', $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); } /** @@ -205,16 +214,16 @@ public function pushItems(mixed $items): void } /** - * Returns statistical information about the dataset, or {@code null} if unavailable. + * Returns statistical information about the dataset. + * + * Unlike {@see get()}, a 404 here is not swallowed: it is always rethrown, since the only way this + * fixed sub-path 404s is the dataset itself being gone (matching the reference client). * * @return array|null */ public function getStatistics(): ?array { - $response = $this->ctx->getRaw('statistics', new QueryParams()); - if ($response === null) { - return null; - } + $response = $this->ctx->getRawRequired('statistics', new QueryParams()); $decoded = Json::decodeData((string) $response->getBody()); return is_array($decoded) ? $decoded : null; } diff --git a/src/Resource/KeyValueStoreClient.php b/src/Resource/KeyValueStoreClient.php index b723912..e8a74d9 100644 --- a/src/Resource/KeyValueStoreClient.php +++ b/src/Resource/KeyValueStoreClient.php @@ -9,6 +9,7 @@ use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; use Apify\Client\Internal\Signatures; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\KeyValueStore; use Apify\Client\Model\KeyValueStoreKey; use Apify\Client\Model\KeyValueStoreKeysPage; @@ -52,10 +53,15 @@ public function withPublicBase(string $publicBaseUrl): self return $this; } - /** Fetches the store metadata, or {@code null} if it does not exist. */ - public function get(): ?KeyValueStore + /** + * Fetches the store metadata, or {@code null} if it does not exist. + * + * When this client was obtained without an ID (e.g. {@see \Apify\Client\Resource\RunClient::keyValueStore()}), + * a 404 is rethrown instead, since it could mean either the parent resource or the store is gone. + */ + public function get(int|float|string|null $timeoutSecs = null): ?KeyValueStore { - $data = $this->ctx->getResource('', new QueryParams()); + $data = $this->ctx->getResource('', new QueryParams(), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); return is_array($data) ? new KeyValueStore($data) : null; } @@ -64,15 +70,18 @@ public function get(): ?KeyValueStore * * @param mixed $newFields any JSON-serializable set of fields to update */ - public function update(mixed $newFields): KeyValueStore + public function update(mixed $newFields, int|float|string|null $timeoutSecs = null): KeyValueStore { - return new KeyValueStore($this->ctx->updateResource('', $newFields)); + return new KeyValueStore($this->ctx->updateResource('', $newFields, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT))); } - /** Deletes the store. */ - public function delete(): void + /** + * Deletes the store. A not-found is a no-op, unless this client was obtained without an ID (e.g. + * {@see \Apify\Client\Resource\RunClient::keyValueStore()}), in which case it is rethrown. + */ + public function delete(int|float|string|null $timeoutSecs = null): void { - $this->ctx->deleteResource(''); + $this->ctx->deleteResource('', $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); } /** Lists the keys stored in this key-value store. */ diff --git a/src/Resource/LogClient.php b/src/Resource/LogClient.php index 71e690c..dc159df 100644 --- a/src/Resource/LogClient.php +++ b/src/Resource/LogClient.php @@ -40,7 +40,13 @@ public static function nested(HttpClientCore $http, string $base, ?QueryParams $ return new self($http, $ctx); } - /** Fetches the log as text, or {@code null} if the log does not exist. */ + /** + * Fetches the log as text, or {@code null} if the log does not exist. + * + * When this client was obtained without an ID (e.g. {@see \Apify\Client\Resource\RunClient::log()} or + * {@see \Apify\Client\Resource\BuildClient::log()}), a 404 is rethrown instead, since it could mean + * either the parent resource or the log is gone. + */ public function get(?LogOptions $options = null): ?string { $params = new QueryParams(); diff --git a/src/Resource/RequestQueueClient.php b/src/Resource/RequestQueueClient.php index 094ec57..adba527 100644 --- a/src/Resource/RequestQueueClient.php +++ b/src/Resource/RequestQueueClient.php @@ -9,6 +9,7 @@ use Apify\Client\Internal\Json; use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\BatchAddResult; use Apify\Client\Model\BatchDeleteResult; use Apify\Client\Model\LockedRequestQueueHead; @@ -38,7 +39,7 @@ final class RequestQueueClient /** * The API's maximum accepted request payload size (9 MiB). Batches are additionally split so no - * single batch call exceeds this, matching the reference client's {@code sliceArrayByByteLength}. + * single batch call exceeds this, matching the reference client's {@code splitIntoJsonArrayBatches}. */ private const MAX_PAYLOAD_SIZE_BYTES = 9 * 1024 * 1024; @@ -98,10 +99,15 @@ private function applyClientKey(QueryParams $params): QueryParams return $params; } - /** Fetches the queue metadata, or {@code null} if it does not exist. */ - public function get(): ?RequestQueue + /** + * Fetches the queue metadata, or {@code null} if it does not exist. + * + * When this client was obtained without an ID (e.g. {@see \Apify\Client\Resource\RunClient::requestQueue()}), + * a 404 is rethrown instead, since it could mean either the parent resource or the queue is gone. + */ + public function get(int|float|string|null $timeoutSecs = null): ?RequestQueue { - $data = $this->ctx->getResource('', new QueryParams()); + $data = $this->ctx->getResource('', new QueryParams(), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); return is_array($data) ? new RequestQueue($data) : null; } @@ -110,15 +116,18 @@ public function get(): ?RequestQueue * * @param mixed $newFields any JSON-serializable set of fields to update */ - public function update(mixed $newFields): RequestQueue + public function update(mixed $newFields, int|float|string|null $timeoutSecs = null): RequestQueue { - return new RequestQueue($this->ctx->updateResource('', $newFields)); + return new RequestQueue($this->ctx->updateResource('', $newFields, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT))); } - /** Deletes the queue. */ - public function delete(): void + /** + * Deletes the queue. A not-found is a no-op, unless this client was obtained without an ID (e.g. + * {@see \Apify\Client\Resource\RunClient::requestQueue()}), in which case it is rethrown. + */ + public function delete(int|float|string|null $timeoutSecs = null): void { - $this->ctx->deleteResource(''); + $this->ctx->deleteResource('', $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); } /** @@ -223,10 +232,15 @@ public function batchAddRequests( $payloadSizeLimitBytes = self::MAX_PAYLOAD_SIZE_BYTES - (int) ceil(self::MAX_PAYLOAD_SIZE_BYTES * self::PAYLOAD_SAFETY_BUFFER_PERCENT); - // Validate the whole input up front, before any HTTP call. Both the empty-uniqueKey check and - // the per-request oversized check must run here (not inside the send loop): otherwise an - // oversized request in the middle of a large batch would only be discovered after earlier - // chunks had already been POSTed, leaving the queue partially mutated. + // Serialize every request once, up front: the byte lengths (which include the two extra bytes + // each item costs in the batch body - a comma or the closing bracket after it) decide the chunk + // boundaries below, and the same JSON strings are joined into each chunk's body, so nothing is + // re-encoded when the chunk is sent. Matches the reference client's `splitIntoJsonArrayBatches`. + // + // Both the empty-uniqueKey check and the per-request oversized check must run here (not inside + // the send loop): otherwise an oversized request in the middle of a large batch would only be + // discovered after earlier chunks had already been POSTed, leaving the queue partially mutated. + $serialized = []; foreach ($requests as $i => $request) { $uniqueKey = $request->getUniqueKey(); if ($uniqueKey === null || $uniqueKey === '') { @@ -234,69 +248,60 @@ public function batchAddRequests( sprintf('batchAddRequests: the request at index %d is missing a non-empty uniqueKey', $i) ); } - $itemBytes = strlen(Json::encode($request->toArray())); - if ($itemBytes > $payloadSizeLimitBytes) { + $json = Json::encode($request->toArray()); + // +1 for the comma or closing bracket that follows this item in the batch body. + $bytes = strlen($json) + 1; + if ($bytes > $payloadSizeLimitBytes) { throw new InvalidArgumentException(sprintf( 'batchAddRequests: the request at index %d exceeds the maximum payload size (%d bytes)', $i, $payloadSizeLimitBytes )); } + $serialized[] = ['request' => $request, 'json' => $json, 'bytes' => $bytes]; } $merged = new BatchAddResult(); - $index = 0; - $count = count($requests); - while ($index < $count) { - // Bound each batch first by the count limit (25), then by payload byte size. - $countSlice = array_slice($requests, $index, self::MAX_REQUESTS_PER_BATCH); - $chunk = self::sliceByByteLength($countSlice, $payloadSizeLimitBytes); + foreach (self::splitIntoBatches($serialized, self::MAX_REQUESTS_PER_BATCH, $payloadSizeLimitBytes) as $chunk) { $merged->merge($this->batchAddChunkWithRetries($chunk, $forefront, $options)); - $index += count($chunk); } return $merged; } /** - * Returns the longest leading run of {@code $requests} whose combined JSON payload stays under - * {@code $maxByteLength}, always keeping at least one request so iteration makes progress. Ports - * the reference client's {@code sliceArrayByByteLength}. + * Splits pre-serialized requests into consecutive batches of at most {@code $maxCount} items, each + * fitting a JSON array body (items joined by commas between brackets) of at most + * {@code $maxByteLength} bytes. Ports the reference client's {@code splitIntoJsonArrayBatches}. * - * Callers must have already validated (in {@see batchAddRequests()}) that every individual request - * fits under {@code $maxByteLength}, so the always-keep-one fallback never produces an over-limit - * chunk. That up-front validation is what lets this slicer run inside the send loop without risking - * a partially-mutated queue. + * Every item's {@code bytes} (pre-validated in {@see batchAddRequests()} to fit under + * {@code $maxByteLength} on its own) already includes the one extra byte it costs as either a + * separating comma or the batch's closing bracket, so only the opening bracket's byte is added here. * - * @param list $requests - * @return list + * @param list $items + * @return list> */ - private static function sliceByByteLength(array $requests, int $maxByteLength): array + private static function splitIntoBatches(array $items, int $maxCount, int $maxByteLength): array { - $payloads = array_map(static fn (RequestQueueRequest $r) => $r->toArray(), $requests); - if (strlen(Json::encode($payloads)) < $maxByteLength) { - return $requests; - } - - $sliced = []; - $byteLength = 2; // the two bytes of an empty array "[]" - foreach ($requests as $request) { - $itemBytes = strlen(Json::encode($request->toArray())); - if ($byteLength + $itemBytes >= $maxByteLength) { - break; + $batches = []; + $batch = []; + $byteLength = 1; // the opening bracket "[" + foreach ($items as $item) { + if ($batch !== [] && (count($batch) >= $maxCount || $byteLength + $item['bytes'] > $maxByteLength)) { + $batches[] = $batch; + $batch = []; + $byteLength = 1; } - $byteLength += $itemBytes; - $sliced[] = $request; + $batch[] = $item; + $byteLength += $item['bytes']; } - - // Guarantee forward progress: keep at least the first request (pre-validated to fit under the max). - if ($sliced === []) { - $sliced[] = $requests[0]; + if ($batch !== []) { + $batches[] = $batch; } - return $sliced; + return $batches; } /** - * @param list $chunk + * @param list $chunk */ private function batchAddChunkWithRetries(array $chunk, bool $forefront, BatchAddRequestsOptions $options): BatchAddResult { @@ -318,13 +323,13 @@ private function batchAddChunkWithRetries(array $chunk, bool $forefront, BatchAd // processed in THIS chunk are reported as unprocessed and we stop — keeping the method's // non-throwing contract so a multi-chunk call still returns every earlier chunk's // already-merged results instead of aborting the whole operation. - $unprocessed = self::requestsNotYetProcessed($chunk, $processed); + $unprocessed = array_column(self::itemsNotYetProcessed($chunk, $processed), 'request'); break; } $processed = array_merge($processed, $response->getProcessedRequests()); // Only requests the API reports as unprocessed in this SUCCESSFUL response are retried. $unprocessed = $response->getUnprocessedRequests(); - $remaining = self::requestsNotYetProcessed($chunk, $processed); + $remaining = self::itemsNotYetProcessed($chunk, $processed); if ($remaining === []) { break; } @@ -340,15 +345,19 @@ private function batchAddChunkWithRetries(array $chunk, bool $forefront, BatchAd } /** - * @param list $requests + * Sends one already-serialized chunk. The chunk's {@code json} strings — each already validated + * and encoded once in {@see batchAddRequests()} — are joined directly into the batch body, so + * nothing here is JSON-encoded a second time, including on a retry of the same chunk. + * + * @param list $chunk */ - private function batchAddChunk(array $requests, bool $forefront): BatchAddResult + private function batchAddChunk(array $chunk, bool $forefront): BatchAddResult { $params = new QueryParams(); $params->addBool('forefront', $forefront); $this->applyClientKey($params); - $payload = array_map(static fn (RequestQueueRequest $r) => $r->toArray(), $requests); - $data = $this->ctx->postWithBody('requests/batch', $params, Json::encode($payload), ResourceContext::CONTENT_TYPE_JSON); + $body = '[' . implode(',', array_column($chunk, 'json')) . ']'; + $data = $this->ctx->postWithBody('requests/batch', $params, $body, ResourceContext::CONTENT_TYPE_JSON); $rawProcessed = (isset($data['processedRequests']) && is_array($data['processedRequests'])) ? $data['processedRequests'] : []; $processed = array_map( @@ -364,11 +373,11 @@ private function batchAddChunk(array $requests, bool $forefront): BatchAddResult } /** - * @param list $chunk - * @param list $processed - * @return list + * @param list $chunk + * @param list $processed + * @return list */ - private static function requestsNotYetProcessed(array $chunk, array $processed): array + private static function itemsNotYetProcessed(array $chunk, array $processed): array { $processedKeys = []; foreach ($processed as $info) { @@ -378,9 +387,9 @@ private static function requestsNotYetProcessed(array $chunk, array $processed): } } $remaining = []; - foreach ($chunk as $request) { - if (!isset($processedKeys[(string) $request->getUniqueKey()])) { - $remaining[] = $request; + foreach ($chunk as $item) { + if (!isset($processedKeys[(string) $item['request']->getUniqueKey()])) { + $remaining[] = $item; } } return $remaining; diff --git a/src/Resource/RunClient.php b/src/Resource/RunClient.php index ccc3164..e7409c5 100644 --- a/src/Resource/RunClient.php +++ b/src/Resource/RunClient.php @@ -8,6 +8,7 @@ use Apify\Client\Internal\Json; use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\ActorRun; use Apify\Client\Options\LastRunOptions; use Apify\Client\Options\LogOptions; @@ -63,11 +64,11 @@ public function setLastRunParams(LastRunOptions $options): void * longer than the client will wait; the server additionally caps the wait at 60s. Returns * {@code null} if it does not exist. */ - public function get(?int $waitForFinishSecs = null): ?ActorRun + public function get(?int $waitForFinishSecs = null, int|float|string|null $timeoutSecs = null): ?ActorRun { $params = new QueryParams(); $params->addInt('waitForFinish', $this->ctx->clampServerWait($waitForFinishSecs)); - $data = $this->ctx->getResource('', $params); + $data = $this->ctx->getResource('', $params, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); return is_array($data) ? new ActorRun($data) : null; } @@ -76,15 +77,15 @@ public function get(?int $waitForFinishSecs = null): ?ActorRun * * @param mixed $newFields any JSON-serializable set of fields to update */ - public function update(mixed $newFields): ActorRun + public function update(mixed $newFields, int|float|string|null $timeoutSecs = null): ActorRun { - return new ActorRun($this->ctx->updateResource('', $newFields)); + return new ActorRun($this->ctx->updateResource('', $newFields, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT))); } /** Deletes the run. */ - public function delete(): void + public function delete(int|float|string|null $timeoutSecs = null): void { - $this->ctx->deleteResource(''); + $this->ctx->deleteResource('', $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); } /** @@ -189,6 +190,11 @@ public function waitForFinish(?int $waitSecs = null): ActorRun /** * A client for this run's default dataset. Any {@code status}/{@code origin} filters pinned by a * last-run accessor are inherited so the correct run's dataset is resolved. + * + * This client has no ID of its own, so {@code get()}/{@code delete()} throw an {@see + * \Apify\Client\Exception\ApifyApiException} on a 404 instead of resolving to {@code null}/no-op: + * the response cannot tell a missing run apart from a missing dataset (matches the reference + * client's {@code catchNotFoundForResourceOrThrow}). */ public function dataset(): DatasetClient { @@ -198,6 +204,12 @@ public function dataset(): DatasetClient /** * A client for this run's default key-value store. Any {@code status}/{@code origin} filters * pinned by a last-run accessor are inherited so the correct run's store is resolved. + * + * {@code get()}/{@code delete()} throw an {@see \Apify\Client\Exception\ApifyApiException} on a 404 + * instead of resolving to {@code null}/no-op, since this client has no ID of its own and the 404 + * could mean either the run or its store is gone. Record lookups such as {@see + * \Apify\Client\Resource\KeyValueStoreClient::getRecord()} are unaffected and still resolve to + * {@code null} for a missing record. */ public function keyValueStore(): KeyValueStoreClient { @@ -207,6 +219,12 @@ public function keyValueStore(): KeyValueStoreClient /** * A client for this run's default request queue. Any {@code status}/{@code origin} filters pinned * by a last-run accessor are inherited so the correct run's queue is resolved. + * + * {@code get()}/{@code delete()} throw an {@see \Apify\Client\Exception\ApifyApiException} on a 404 + * instead of resolving to {@code null}/no-op, since this client has no ID of its own and the 404 + * could mean either the run or its queue is gone. {@see + * \Apify\Client\Resource\RequestQueueClient::getRequest()} is unaffected and still resolves to + * {@code null} for a missing request. */ public function requestQueue(): RequestQueueClient { @@ -216,6 +234,10 @@ public function requestQueue(): RequestQueueClient /** * A client for accessing this run's log. Any {@code status}/{@code origin} filters pinned by a * last-run accessor are inherited so the correct run's log is resolved. + * + * {@code get()} throws an {@see \Apify\Client\Exception\ApifyApiException} on a 404 instead of + * resolving to {@code null}, since this client has no ID of its own and the 404 could mean either + * the run or its log is gone (matches the reference client's {@code catchNotFoundForResourceOrThrow}). */ public function log(): LogClient { diff --git a/src/Resource/ScheduleClient.php b/src/Resource/ScheduleClient.php index 4fe2104..f1b810c 100644 --- a/src/Resource/ScheduleClient.php +++ b/src/Resource/ScheduleClient.php @@ -7,6 +7,7 @@ use Apify\Client\Internal\HttpClientCore; use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\Schedule; /** A client for a specific schedule ({@code /v2/schedules/{scheduleId}}). */ @@ -21,9 +22,9 @@ public function __construct(HttpClientCore $http, string $baseUrl, string $id) } /** Fetches the schedule, or {@code null} if it does not exist. */ - public function get(): ?Schedule + public function get(int|float|string|null $timeoutSecs = null): ?Schedule { - $data = $this->ctx->getResource('', new QueryParams()); + $data = $this->ctx->getResource('', new QueryParams(), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); return is_array($data) ? new Schedule($data) : null; } @@ -32,21 +33,25 @@ public function get(): ?Schedule * * @param mixed $newFields any JSON-serializable set of fields to update */ - public function update(mixed $newFields): Schedule + public function update(mixed $newFields, int|float|string|null $timeoutSecs = null): Schedule { - return new Schedule($this->ctx->updateResource('', $newFields)); + return new Schedule($this->ctx->updateResource('', $newFields, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT))); } /** Deletes the schedule. */ - public function delete(): void + public function delete(int|float|string|null $timeoutSecs = null): void { - $this->ctx->deleteResource(''); + $this->ctx->deleteResource('', $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); } - /** Fetches the schedule's invocation log as text, or {@code null} if absent. */ + /** + * Fetches the schedule's invocation log as text. + * + * Unlike {@see get()}, a 404 here is not swallowed: it is always rethrown, since the only way this + * fixed sub-path 404s is the schedule itself being gone (matching the reference client). + */ public function getLog(): ?string { - $response = $this->ctx->getRaw('log', new QueryParams()); - return $response === null ? null : (string) $response->getBody(); + return (string) $this->ctx->getRawRequired('log', new QueryParams())->getBody(); } } diff --git a/src/Resource/TaskClient.php b/src/Resource/TaskClient.php index 4c65f9f..36fbee9 100644 --- a/src/Resource/TaskClient.php +++ b/src/Resource/TaskClient.php @@ -9,6 +9,7 @@ use Apify\Client\Internal\Json; use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\ActorRun; use Apify\Client\Model\Task; use Apify\Client\Options\LastRunOptions; @@ -35,9 +36,9 @@ public function __construct( } /** Fetches the task object, or {@code null} if it does not exist. */ - public function get(): ?Task + public function get(int|float|string|null $timeoutSecs = null): ?Task { - $data = $this->ctx->getResource('', new QueryParams()); + $data = $this->ctx->getResource('', new QueryParams(), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); return is_array($data) ? new Task($data) : null; } @@ -46,15 +47,15 @@ public function get(): ?Task * * @param mixed $newFields any JSON-serializable set of fields to update */ - public function update(mixed $newFields): Task + public function update(mixed $newFields, int|float|string|null $timeoutSecs = null): Task { - return new Task($this->ctx->updateResource('', $newFields)); + return new Task($this->ctx->updateResource('', $newFields, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT))); } /** Deletes the task. */ - public function delete(): void + public function delete(int|float|string|null $timeoutSecs = null): void { - $this->ctx->deleteResource(''); + $this->ctx->deleteResource('', $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); } /** @@ -112,14 +113,17 @@ public function call(mixed $input = null, ?TaskStartOptions $options = null, ?in } /** - * Fetches the task's stored input, or {@code null} if none is set. + * Fetches the task's stored input. + * + * Unlike {@see get()}, a 404 here is not swallowed: it is always rethrown, since the only way this + * fixed sub-path 404s is the task itself being gone (matching the reference client). * * @return mixed */ public function getInput(): mixed { - $response = $this->ctx->getRaw('input', new QueryParams()); - return $response === null ? null : Json::decode((string) $response->getBody()); + $response = $this->ctx->getRawRequired('input', new QueryParams()); + return Json::decode((string) $response->getBody()); } /** diff --git a/src/Resource/UserClient.php b/src/Resource/UserClient.php index b7b139b..da8863d 100644 --- a/src/Resource/UserClient.php +++ b/src/Resource/UserClient.php @@ -8,6 +8,7 @@ use Apify\Client\Internal\Json; use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\User; use LogicException; @@ -35,9 +36,9 @@ public function __construct(private HttpClientCore $http, string $baseUrl, strin * Fetches the user. For {@code me} it returns private account details (via {@see User::toArray()}); * for other users it returns the public profile. Returns {@code null} if the user does not exist. */ - public function get(): ?User + public function get(int|float|string|null $timeoutSecs = null): ?User { - $data = $this->ctx->getResource('', new QueryParams()); + $data = $this->ctx->getResource('', new QueryParams(), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); return is_array($data) ? new User($data) : null; } diff --git a/src/Resource/WebhookClient.php b/src/Resource/WebhookClient.php index 3d90f76..e9a6be3 100644 --- a/src/Resource/WebhookClient.php +++ b/src/Resource/WebhookClient.php @@ -7,6 +7,7 @@ use Apify\Client\Internal\HttpClientCore; use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\Webhook; use Apify\Client\Model\WebhookDispatch; @@ -22,9 +23,9 @@ public function __construct(private HttpClientCore $http, string $baseUrl, strin } /** Fetches the webhook, or {@code null} if it does not exist. */ - public function get(): ?Webhook + public function get(int|float|string|null $timeoutSecs = null): ?Webhook { - $data = $this->ctx->getResource('', new QueryParams()); + $data = $this->ctx->getResource('', new QueryParams(), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); return is_array($data) ? new Webhook($data) : null; } @@ -33,15 +34,15 @@ public function get(): ?Webhook * * @param mixed $newFields any JSON-serializable set of fields to update */ - public function update(mixed $newFields): Webhook + public function update(mixed $newFields, int|float|string|null $timeoutSecs = null): Webhook { - return new Webhook($this->ctx->updateResource('', $newFields)); + return new Webhook($this->ctx->updateResource('', $newFields, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT))); } /** Deletes the webhook. */ - public function delete(): void + public function delete(int|float|string|null $timeoutSecs = null): void { - $this->ctx->deleteResource(''); + $this->ctx->deleteResource('', $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); } /** Dispatches the webhook immediately and returns the resulting dispatch. */ diff --git a/src/Resource/WebhookDispatchClient.php b/src/Resource/WebhookDispatchClient.php index 85d7e4c..651703b 100644 --- a/src/Resource/WebhookDispatchClient.php +++ b/src/Resource/WebhookDispatchClient.php @@ -7,6 +7,7 @@ use Apify\Client\Internal\HttpClientCore; use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\WebhookDispatch; /** A client for a specific webhook dispatch ({@code /v2/webhook-dispatches/{dispatchId}}). */ @@ -21,9 +22,9 @@ public function __construct(HttpClientCore $http, string $baseUrl, string $id) } /** Fetches the dispatch, or {@code null} if it does not exist. */ - public function get(): ?WebhookDispatch + public function get(int|float|string|null $timeoutSecs = null): ?WebhookDispatch { - $data = $this->ctx->getResource('', new QueryParams()); + $data = $this->ctx->getResource('', new QueryParams(), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); return is_array($data) ? new WebhookDispatch($data) : null; } } diff --git a/src/Version.php b/src/Version.php index 206e985..61db3b3 100644 --- a/src/Version.php +++ b/src/Version.php @@ -17,7 +17,7 @@ final class Version * The semantic version of this client library (see https://semver.org/). * Changes to the public interface other than additive ones are considered breaking changes. */ - public const CLIENT_VERSION = '0.7.0'; + public const CLIENT_VERSION = '0.8.0'; /** * The version of the Apify OpenAPI specification this client was generated and verified diff --git a/tests/Unit/AmbiguousNotFoundTest.php b/tests/Unit/AmbiguousNotFoundTest.php new file mode 100644 index 0000000..47a0ef7 --- /dev/null +++ b/tests/Unit/AmbiguousNotFoundTest.php @@ -0,0 +1,206 @@ + ['type' => $type, 'message' => 'not found']]); + } + + // ---- Exception subclasses (#1041) ------------------------------------------------------ + + /** @return array}> */ + public static function statusClassProvider(): array + { + return [ + '400' => [400, InvalidRequestException::class], + '401' => [401, UnauthorizedException::class], + '403' => [403, ForbiddenException::class], + '404' => [404, NotFoundException::class], + '409' => [409, ConflictException::class], + '429' => [429, RateLimitException::class], + '500' => [500, ServerException::class], + '502' => [502, ServerException::class], + ]; + } + + /** @dataProvider statusClassProvider */ + public function testBuildApiErrorReturnsStatusSpecificSubclass(int $status, string $expectedClass): void + { + $error = HttpClientCore::buildApiError($status, $this->notFoundBody('x'), 1, 'GET', '/x'); + self::assertInstanceOf($expectedClass, $error); + self::assertInstanceOf(ApifyApiException::class, $error, 'every subclass must still satisfy instanceof ApifyApiException'); + self::assertSame($status, $error->getStatusCode()); + } + + public function testUnmappedStatusStaysPlainApifyApiException(): void + { + $error = HttpClientCore::buildApiError(418, $this->notFoundBody('x'), 1, 'GET', '/x'); + self::assertSame(ApifyApiException::class, get_class($error)); + } + + // ---- isNotFound() swallows any 404 type, not just the historical allowlist (#1041) ---------- + + public function testGetSwallows404OfAnyType(): void + { + $transport = (new MockTransport())->queueResponse(404, $this->notFoundBody('some-other-type-the-allowlist-never-knew-about')); + $actor = $this->client($transport)->actor('missing')->get(); + self::assertNull($actor); + } + + // ---- Chained (ID-less) resource clients throw on 404 instead of resolving to null (#1042) --- + + public function testRunDatasetGetThrowsOnAmbiguous404(): void + { + $transport = (new MockTransport())->queueResponse(404, $this->notFoundBody()); + $this->expectException(NotFoundException::class); + $this->client($transport)->run('missing-run')->dataset()->get(); + } + + public function testRunDatasetDeleteThrowsOnAmbiguous404(): void + { + $transport = (new MockTransport())->queueResponse(404, $this->notFoundBody()); + $this->expectException(NotFoundException::class); + $this->client($transport)->run('missing-run')->dataset()->delete(); + } + + public function testRunKeyValueStoreGetThrowsOnAmbiguous404(): void + { + $transport = (new MockTransport())->queueResponse(404, $this->notFoundBody()); + $this->expectException(NotFoundException::class); + $this->client($transport)->run('missing-run')->keyValueStore()->get(); + } + + public function testRunRequestQueueGetThrowsOnAmbiguous404(): void + { + $transport = (new MockTransport())->queueResponse(404, $this->notFoundBody()); + $this->expectException(NotFoundException::class); + $this->client($transport)->run('missing-run')->requestQueue()->get(); + } + + public function testRunLogGetThrowsOnAmbiguous404(): void + { + $transport = (new MockTransport())->queueResponse(404, $this->notFoundBody()); + $this->expectException(NotFoundException::class); + $this->client($transport)->run('missing-run')->log()->get(); + } + + public function testBuildLogGetThrowsOnAmbiguous404(): void + { + $transport = (new MockTransport())->queueResponse(404, $this->notFoundBody()); + $this->expectException(NotFoundException::class); + $this->client($transport)->build('missing-build')->log()->get(); + } + + /** Directly addressed (by-ID) clients are unaffected: get() still resolves to null on a 404. */ + public function testTopLevelDatasetGetStillReturnsNullOn404(): void + { + $transport = (new MockTransport())->queueResponse(404, $this->notFoundBody()); + $dataset = $this->client($transport)->dataset('missing')->get(); + self::assertNull($dataset); + } + + /** Directly addressed (by-ID) log client is unaffected: get() still resolves to null on a 404. */ + public function testTopLevelLogGetStillReturnsNullOn404(): void + { + $transport = (new MockTransport())->queueResponse(404, $this->notFoundBody()); + $log = $this->client($transport)->log('missing')->get(); + self::assertNull($log); + } + + // ---- Fixed sub-paths of an ID-addressed resource always throw (#1042) ----------------------- + + public function testDatasetGetStatisticsThrowsOn404(): void + { + $transport = (new MockTransport())->queueResponse(404, $this->notFoundBody()); + $this->expectException(NotFoundException::class); + $this->client($transport)->dataset('missing')->getStatistics(); + } + + public function testTaskGetInputThrowsOn404(): void + { + $transport = (new MockTransport())->queueResponse(404, $this->notFoundBody()); + $this->expectException(NotFoundException::class); + $this->client($transport)->task('missing')->getInput(); + } + + public function testScheduleGetLogThrowsOn404(): void + { + $transport = (new MockTransport())->queueResponse(404, $this->notFoundBody()); + $this->expectException(NotFoundException::class); + $this->client($transport)->schedule('missing')->getLog(); + } + + public function testBuildGetOpenApiDefinitionThrowsOn404(): void + { + $transport = (new MockTransport())->queueResponse(404, $this->notFoundBody()); + $this->expectException(NotFoundException::class); + $this->client($transport)->build('missing')->getOpenApiDefinition(); + } + + /** getRecord() is unaffected by the ambiguous-404 rule: it still resolves to null for a missing key. */ + public function testKeyValueStoreGetRecordStillReturnsNullOn404(): void + { + $transport = (new MockTransport())->queueResponse(404, $this->notFoundBody()); + $record = $this->client($transport)->keyValueStore('store1')->getRecord('missing-key'); + self::assertNull($record); + } + + /** getRequest() is unaffected: it still resolves to null for a missing request. */ + public function testRequestQueueGetRequestStillReturnsNullOn404(): void + { + $transport = (new MockTransport())->queueResponse(404, $this->notFoundBody()); + $request = $this->client($transport)->requestQueue('q1')->getRequest('missing-id'); + self::assertNull($request); + } + + // ---- Empty identifiers rejected up front (#1042) --------------------------------------------- + + public function testActorVersionEmptyStringRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->client(new MockTransport())->actor('a1')->version(''); + } + + public function testActorEnvVarEmptyNameRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->client(new MockTransport())->actor('a1')->version('0.1')->envVar(''); + } + + public function testActorBuildEmptyVersionNumberRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->client(new MockTransport())->actor('a1')->build(''); + } +} diff --git a/tests/Unit/CompressionConfigTest.php b/tests/Unit/CompressionConfigTest.php new file mode 100644 index 0000000..6a3f1cd --- /dev/null +++ b/tests/Unit/CompressionConfigTest.php @@ -0,0 +1,107 @@ + str_repeat('payload-', 500)]; + } + + public function testGzipByNameForcesGzipEvenWhenBrotliAvailable(): void + { + $transport = (new MockTransport())->queueResponse(200, '{"data":{}}'); + $client = new ApifyClient(token: 't', compression: 'gzip', httpClient: $transport); + + $client->keyValueStore('s1')->setRecordJson('k', $this->largeBody()); + + $request = $transport->lastRequest(); + self::assertSame('gzip', $request->getHeaderLine('Content-Encoding')); + } + + public function testBrotliByNameIsUsedWhenExtensionAvailable(): void + { + if (!function_exists('brotli_compress')) { + self::markTestSkipped('PECL brotli extension not loaded'); + } + $transport = (new MockTransport())->queueResponse(200, '{"data":{}}'); + $client = new ApifyClient(token: 't', compression: 'brotli', httpClient: $transport); + + $client->keyValueStore('s1')->setRecordJson('k', $this->largeBody()); + + self::assertSame('br', $transport->lastRequest()->getHeaderLine('Content-Encoding')); + } + + public function testCustomCompressorInstanceIsUsed(): void + { + $transport = (new MockTransport())->queueResponse(200, '{"data":{}}'); + $client = new ApifyClient(token: 't', compression: new GzipHttpCompressor(quality: 1), httpClient: $transport); + + $client->keyValueStore('s1')->setRecordJson('k', $this->largeBody()); + + $request = $transport->lastRequest(); + self::assertSame('gzip', $request->getHeaderLine('Content-Encoding')); + $decoded = gzdecode((string) $request->getBody()); + self::assertIsString($decoded); + self::assertStringContainsString(self::LARGE_BODY_FIELD, $decoded); + } + + public function testDefaultBehaviorUnchangedWhenCompressionOptionOmitted(): void + { + $transport = (new MockTransport())->queueResponse(200, '{"data":{}}'); + $client = new ApifyClient(token: 't', httpClient: $transport); + + $client->keyValueStore('s1')->setRecordJson('k', $this->largeBody()); + + $encoding = $transport->lastRequest()->getHeaderLine('Content-Encoding'); + $expected = function_exists('brotli_compress') ? 'br' : 'gzip'; + self::assertSame($expected, $encoding); + } + + public function testUnknownCompressionNameThrows(): void + { + $this->expectException(RuntimeException::class); + new ApifyClient(token: 't', compression: 'deflate', httpClient: new MockTransport()); + } + + public function testBrotliCompressorRejectsOutOfRangeQuality(): void + { + $this->expectException(InvalidArgumentException::class); + new BrotliHttpCompressor(12); + } + + public function testGzipCompressorRejectsOutOfRangeQuality(): void + { + $this->expectException(InvalidArgumentException::class); + new GzipHttpCompressor(0); + } + + /** A content type that already carries its own compression is still skipped under a configured compressor. */ + public function testAlreadyCompressedContentTypeSkipsConfiguredCompressorToo(): void + { + $transport = (new MockTransport())->queueResponse(200, ''); + $client = new ApifyClient(token: 't', compression: 'gzip', httpClient: $transport); + + $client->keyValueStore('s1')->setRecord('k', str_repeat('x', 5000), 'image/png'); + + self::assertSame('', $transport->lastRequest()->getHeaderLine('Content-Encoding')); + } +} diff --git a/tests/Unit/CompressionTest.php b/tests/Unit/CompressionTest.php index dbd9bf8..c68fd2a 100644 --- a/tests/Unit/CompressionTest.php +++ b/tests/Unit/CompressionTest.php @@ -151,6 +151,35 @@ public function testSmallBodyIsNotCompressedEvenWithCodecs(): void )); } + /** + * @return array + */ + public static function contentTypeProvider(): array + { + return [ + 'null content type is compressible' => ['', true], + 'json is compressible' => ['application/json', true], + 'json with charset param is compressible' => ['application/json; charset=utf-8', true], + 'octet-stream is compressible (unknown binary)' => ['application/octet-stream', true], + 'png is not compressible' => ['image/png', false], + 'mp4 is not compressible' => ['video/mp4', false], + 'mp3 is not compressible' => ['audio/mpeg', false], + 'zip is not compressible' => ['application/zip', false], + 'docx is not compressible' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', false], + 'woff2 font is not compressible' => ['font/woff2', false], + 'bmp raw image is still compressible' => ['image/bmp', true], + 'wav raw audio is still compressible' => ['audio/wav', true], + 'svg+xml structured suffix is compressible' => ['image/svg+xml', true], + 'case-insensitive match' => ['IMAGE/PNG', false], + ]; + } + + /** @dataProvider contentTypeProvider */ + public function testIsCompressibleContentType(string $contentType, bool $expected): void + { + self::assertSame($expected, Compression::isCompressibleContentType($contentType === '' ? null : $contentType)); + } + private static function decode(string $encoding, string $data): string { if ($encoding === 'br') { diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php index 298d471..e0e69d1 100644 --- a/tests/Unit/ConfigTest.php +++ b/tests/Unit/ConfigTest.php @@ -46,6 +46,30 @@ public function testApiBaseUrlAppendsV2(): void self::assertSame('https://api.example.com/v2', $client->getApiBaseUrl()); } + /** + * A $baseUrl that already ends with the /v2 version path is accepted as-is instead of becoming + * .../v2/v2, matching the reference client's toApiBaseUrl. + */ + public function testApiBaseUrlAlreadyEndingInV2IsNotDoubled(): void + { + $client = new ApifyClient(token: 't', baseUrl: 'https://api.example.com/v2', httpClient: new MockTransport()); + self::assertSame('https://api.example.com/v2', $client->getApiBaseUrl()); + } + + /** A trailing slash after an existing /v2 is stripped before the idempotency check, not after. */ + public function testApiBaseUrlAlreadyEndingInV2WithTrailingSlashIsNotDoubled(): void + { + $client = new ApifyClient(token: 't', baseUrl: 'https://api.example.com/v2/', httpClient: new MockTransport()); + self::assertSame('https://api.example.com/v2', $client->getApiBaseUrl()); + } + + /** Another /vN suffix is not mistaken for the version path and still gets /v2 appended. */ + public function testApiBaseUrlOtherVersionSuffixIsNotTreatedAsVersionPath(): void + { + $client = new ApifyClient(token: 't', baseUrl: 'https://api.example.com/v3', httpClient: new MockTransport()); + self::assertSame('https://api.example.com/v3/v2', $client->getApiBaseUrl()); + } + public function testVersionConstants(): void { self::assertMatchesRegularExpression('/^\d+\.\d+\.\d+$/', Version::CLIENT_VERSION); diff --git a/tests/Unit/PathSafetyTest.php b/tests/Unit/PathSafetyTest.php new file mode 100644 index 0000000..3facbb4 --- /dev/null +++ b/tests/Unit/PathSafetyTest.php @@ -0,0 +1,120 @@ + */ + public static function toSafeIdProvider(): array + { + return [ + 'no slash' => ['my-actor', 'my-actor'], + 'single slash (username/actor-name)' => ['jane/my-actor', 'jane~my-actor'], + 'multiple slashes replace every one' => ['a/b/c', 'a~b~c'], + ]; + } + + /** @dataProvider toSafeIdProvider */ + public function testToSafeIdReplacesAllSlashes(string $id, string $expected): void + { + self::assertSame($expected, ResourceContext::toSafeId($id)); + } + + // ---- ResourceContext::encodePathSegment() rejects empty / dot segments ----------------------- + + /** @return array */ + public static function invalidSegmentProvider(): array + { + return ['empty string' => [''], 'single dot' => ['.'], 'double dot' => ['..']]; + } + + /** @dataProvider invalidSegmentProvider */ + public function testEncodePathSegmentRejectsInvalidSegment(string $input): void + { + $this->expectException(InvalidArgumentException::class); + ResourceContext::encodePathSegment($input); + } + + public function testEncodePathSegmentPercentEncodesSpecialCharacters(): void + { + self::assertSame('a%2Fb', ResourceContext::encodePathSegment('a/b')); + self::assertSame('a%20b', ResourceContext::encodePathSegment('a b')); + } + + public function testEncodePathSegmentAllowsUnreservedCharactersUnescaped(): void + { + self::assertSame('abc-123_.~', ResourceContext::encodePathSegment('abc-123_.~')); + } + + // ---- End-to-end: empty/dot values in record keys and request ids are rejected before any call --- + + public function testKeyValueStoreGetRecordRejectsEmptyKey(): void + { + $transport = new MockTransport(); + $this->expectException(InvalidArgumentException::class); + $this->client($transport)->keyValueStore('store1')->getRecord(''); + self::assertSame(0, $transport->callCount()); + } + + public function testKeyValueStoreGetRecordRejectsDotDotKey(): void + { + $transport = new MockTransport(); + $this->expectException(InvalidArgumentException::class); + $this->client($transport)->keyValueStore('store1')->getRecord('..'); + } + + public function testRequestQueueGetRequestRejectsEmptyId(): void + { + $transport = new MockTransport(); + $this->expectException(InvalidArgumentException::class); + $this->client($transport)->requestQueue('q1')->getRequest(''); + } + + /** An id resolving to an empty/dot URL-safe form is rejected when the resource client is built. */ + public function testEmptyResourceIdRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->client(new MockTransport())->dataset(''); + } + + public function testDotDotResourceIdRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->client(new MockTransport())->actor('..'); + } + + /** + * A record key sent as "a/../../secret" cannot escape the records/ sub-path: the "/" is + * percent-encoded into "%2F", which decodes back to a single literal segment rather than being + * resolved by a URL parser into extra path segments. + */ + public function testRecordKeyWithSlashesCannotEscapeItsSegment(): void + { + $transport = (new MockTransport())->queueResponse(200, '', ['Content-Type' => 'text/plain']); + $this->client($transport)->keyValueStore('store1')->getRecord('a/../../secret'); + + $url = (string) $transport->lastRequest()->getUri(); + self::assertStringContainsString('a%2F..%2F..%2Fsecret', $url); + self::assertStringNotContainsString('a/../../secret', $url); + } +} diff --git a/tests/Unit/TimeoutTiersTest.php b/tests/Unit/TimeoutTiersTest.php new file mode 100644 index 0000000..fef4475 --- /dev/null +++ b/tests/Unit/TimeoutTiersTest.php @@ -0,0 +1,107 @@ +queueResponse(200, Json::encode(['data' => ['id' => 'a1']])); + $client = new ApifyClient(token: 't', timeoutSecs: 123, httpClient: $transport); + + $client->actor('a1')->get(); + + self::assertSame([123.0], $transport->timeouts); + } + + public function testConfiguredShortTierAppliesToGet(): void + { + $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['id' => 'a1']])); + $client = new ApifyClient(token: 't', timeoutSecs: 300, timeoutShortSecs: 7, httpClient: $transport); + + $client->actor('a1')->get(); + + self::assertSame([7.0], $transport->timeouts); + } + + public function testPerCallNumericOverrideReplacesTheConfiguredTier(): void + { + $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['id' => 'a1']])); + $client = new ApifyClient(token: 't', timeoutSecs: 300, timeoutShortSecs: 7, httpClient: $transport); + + $client->actor('a1')->get(timeoutSecs: 42); + + self::assertSame([42.0], $transport->timeouts); + } + + public function testPerCallTierNameOverrideSelectsThatTier(): void + { + $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['id' => 'a1']])); + $client = new ApifyClient( + token: 't', + timeoutSecs: 300, + timeoutShortSecs: 5, + timeoutLongSecs: 360, + timeoutMaxSecs: 360, + httpClient: $transport, + ); + + $client->actor('a1')->get(timeoutSecs: 'long'); + + self::assertSame([360.0], $transport->timeouts); + } + + public function testTimeoutMaxSecsCapsAPerCallOverride(): void + { + $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['id' => 'a1']])); + $client = new ApifyClient(token: 't', timeoutSecs: 300, timeoutMaxSecs: 60, httpClient: $transport); + + $client->actor('a1')->get(timeoutSecs: 999); + + self::assertSame([60.0], $transport->timeouts); + } + + public function testTimeoutMaxSecsCapsAConfiguredTier(): void + { + $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['id' => 'a1']])); + $client = new ApifyClient( + token: 't', + timeoutSecs: 300, + timeoutLongSecs: 500, + timeoutMaxSecs: 60, + httpClient: $transport, + ); + + $client->actor('a1')->get(timeoutSecs: 'long'); + + self::assertSame([60.0], $transport->timeouts); + } + + public function testUpdateAndDeleteAcceptThePerCallOverrideToo(): void + { + $transport = (new MockTransport()) + ->queueResponse(200, Json::encode(['data' => ['id' => 'a1']])) + ->queueResponse(200, Json::encode(['data' => []])); + $client = new ApifyClient(token: 't', timeoutSecs: 300, httpClient: $transport); + + $client->actor('a1')->update(['name' => 'x'], timeoutSecs: 11); + $client->actor('a1')->delete(timeoutSecs: 22); + + self::assertSame([11.0, 22.0], $transport->timeouts); + } +} From 57a2475b5fe3d932b7d2a8970ee1b3a2bbcb58be Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 20:30:54 +0000 Subject: [PATCH 4/5] fix: address review findings on timeout tiers, path safety and docs - Fixed the Test examples CI step: the README error-handling snippet duplicated the harness-injected `use ApifyApiException`; also added NotFoundException to the harness's shared imports. - Wired the medium/long timeout tiers to every listing/batch/trigger and download/upload/streaming method across all resource and collection clients (previously only short was used, on get/update/delete), with a per-call $timeoutSecs override threaded through every new call site. - Fixed 'noTimeout' to actually disable the request timeout (TimeoutTiers now resolves it to 0.0, which the transport treats as infinite, instead of null, which every `??` fallback silently discarded back to the default). Fixed the per-queue timeoutSecs cap (requestQueue(id, options)) to keep applying now that tier resolution never returns null. - Fixed two bugs the tier wiring surfaced under the full test suite: RequestQueueCollectionClient::getOrCreate() passed the resolved timeout into the wrong positional parameter, and ActorEnvVarCollectionClient::iterate()'s per-page closure didn't capture $timeoutSecs. - Fixed batchAddRequests()'s per-item size check to account for both brackets of a single-item batch body, matching the reference client's accounting exactly. - ScheduleClient::getLog() is no longer nullable: it always throws instead of returning null now that it never swallows a 404. Tightened related docblocks on getInput()/getStatistics()/getOpenApiDefinition() to describe their remaining (non-404) null source. - Removed a redundant double-min in ResourceContext::paginateOffset() and a redundant size-gate duplication in request-body compression. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01STUGToGEaEFoBGRbWTvF4m --- CHANGELOG.md | 17 ++- README.md | 16 +-- docs/schedules.md | 4 +- src/Internal/HttpClientCore.php | 6 +- src/Internal/ResourceContext.php | 62 +++++---- src/Internal/TimeoutTiers.php | 20 ++- src/Options/SetRecordOptions.php | 8 +- .../AbstractWebhookCollectionClient.php | 9 +- src/Resource/ActorClient.php | 49 +++++-- src/Resource/ActorCollectionClient.php | 13 +- src/Resource/ActorEnvVarCollectionClient.php | 22 ++- src/Resource/ActorVersionCollectionClient.php | 13 +- src/Resource/BuildClient.php | 16 ++- src/Resource/BuildCollectionClient.php | 9 +- src/Resource/DatasetClient.php | 40 +++--- src/Resource/DatasetCollectionClient.php | 13 +- src/Resource/KeyValueStoreClient.php | 35 +++-- .../KeyValueStoreCollectionClient.php | 13 +- src/Resource/LogClient.php | 5 +- src/Resource/RequestQueueClient.php | 129 ++++++++++++------ src/Resource/RequestQueueCollectionClient.php | 13 +- src/Resource/RunClient.php | 36 +++-- src/Resource/RunCollectionClient.php | 9 +- src/Resource/ScheduleClient.php | 11 +- src/Resource/ScheduleCollectionClient.php | 13 +- src/Resource/StoreCollectionClient.php | 9 +- src/Resource/TaskClient.php | 30 ++-- src/Resource/TaskCollectionClient.php | 13 +- src/Resource/UserClient.php | 13 +- src/Resource/WebhookClient.php | 6 +- src/Resource/WebhookCollectionClient.php | 5 +- .../WebhookDispatchCollectionClient.php | 9 +- tests/Examples/DocSnippetsTest.php | 1 + tests/Integration/ScheduleIntegrationTest.php | 4 +- tests/Unit/BatchAddRequestsTest.php | 42 ++++++ tests/Unit/TimeoutTiersTest.php | 34 +++++ 36 files changed, 503 insertions(+), 244 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00c82d2..1d84649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,10 +31,13 @@ literal `.`/`..` segment can still collapse to a different endpoint even without a `/` in the string. Matches the reference client's path-sanitization fix. - Added timeout tiers: the `ApifyClient` constructor takes optional `timeoutShortSecs`, - `timeoutMediumSecs`, `timeoutLongSecs`, and `timeoutMaxSecs` options, and the `get()`/`update()`/ - `delete()` methods of every resource client take an optional trailing `$timeoutSecs` (a number of - seconds, a tier name, or `'noTimeout'`) that overrides the default for that call. Every tier - defaults to the existing `timeoutSecs`, so a client built without the new options is unaffected. + `timeoutMediumSecs`, `timeoutLongSecs`, and `timeoutMaxSecs` options, and every method that sends a + request takes an optional trailing `$timeoutSecs` (a number of seconds, a tier name, or + `'noTimeout'` for no timeout at all) that overrides the tier for that call — metadata reads/writes + use `short`, listing/batch/trigger calls use `medium`, downloads/uploads/streaming use `long`. Every + tier defaults to the existing `timeoutSecs`, so a client built without the new options sends every + request with exactly the timeout it always has. A request-queue client's own `timeoutSecs` + (`requestQueue(id, options)`) continues to cap every tier and per-call override for that client. - Added a `compression` option to the `ApifyClient` constructor: `'brotli'`, `'gzip'`, or a custom `HttpCompressorInterface` (new `BrotliHttpCompressor`/`GzipHttpCompressor` implementations let the compression quality be configured too). Left unset, the client keeps its existing best-effort @@ -47,7 +50,11 @@ version path without doubling it into `.../v2/v2`, matching the reference client. - `RequestQueueClient::batchAddRequests()` now serializes each request to JSON once up front instead of re-encoding it for the size check, the chunk split, and the request body, matching the reference - client's performance fix. Behavior and chunk boundaries are unchanged. + client's performance fix. The per-item size check now accounts for both brackets a single-item + batch body needs (matching the reference's exact accounting), so a request that fits only as the + sole member of its own batch is no longer rejected by one byte. +- `ScheduleClient::getLog()` is no longer nullable (`string`, not `?string`): it always throws instead + of returning `null` when the schedule itself is gone, and otherwise always returns a string. ## 0.7.0 diff --git a/README.md b/README.md index 8bda068..685f40c 100644 --- a/README.md +++ b/README.md @@ -88,17 +88,20 @@ Every method that sends a request is assigned a timeout tier: `short` (metadata a client constructed without the tier options behaves exactly as before they existed; set `timeoutShortSecs`/`timeoutMediumSecs`/`timeoutLongSecs` to give a tier its own duration instead. -The `get()`/`update()`/`delete()` methods of every resource client additionally take an optional -trailing `$timeoutSecs`, overriding the tier for that one call — a number of seconds, a tier name -(`'short'`/`'medium'`/`'long'`), or `'noTimeout'` for no request timeout at all: +Every method that sends a request additionally takes an optional trailing `$timeoutSecs`, overriding +the tier for that one call — a number of seconds, a tier name (`'short'`/`'medium'`/`'long'`), or +`'noTimeout'` for no request timeout at all: ```php $client->actor('my-actor')->get(timeoutSecs: 10); // this call only, 10 seconds -$client->actor('my-actor')->get(timeoutSecs: 'long'); // this call only, the 'long' tier's duration +$client->dataset('my-dataset')->listItems(timeoutSecs: 'long'); // this call only, the 'long' tier +$client->requestQueue('my-queue')->unlockRequests(timeoutSecs: 'noTimeout'); // no request timeout at all ``` `timeoutMaxSecs` caps every tier and every per-call override alike (defaulting to `timeoutSecs`), so -raise it whenever a call legitimately needs longer than the default 360 seconds. +raise it whenever a call legitimately needs longer than the default 360 seconds. A request-queue +client's own `timeoutSecs` (`requestQueue(id, options)`) caps every tier and per-call override of +calls made through that client specifically, on top of `timeoutMaxSecs`. ### HTTP compression @@ -155,9 +158,6 @@ every subclass extends it, so an existing `instanceof ApifyApiException` check k them: ```php -use Apify\Client\Exception\ApifyApiException; -use Apify\Client\Exception\NotFoundException; - try { $client->actor('does/not-exist')->update(['title' => 'x']); } catch (NotFoundException $e) { diff --git a/docs/schedules.md b/docs/schedules.md index a14b907..96824c1 100644 --- a/docs/schedules.md +++ b/docs/schedules.md @@ -25,8 +25,8 @@ foreach ($client->schedules()->iterate(new ListOptions(), 50) as $s) { ## A single schedule — `$client->schedule($id)` - `get(): ?Schedule`, `update(mixed $newFields): Schedule`, `delete(): void` -- `getLog(): ?string` — the schedule's invocation log. Throws if the schedule itself does not exist - (a 404 here cannot mean anything else). +- `getLog(): string` — the schedule's invocation log (an empty string if there is no log yet). Throws + if the schedule itself does not exist (a 404 here cannot mean anything else). ```php $updated = $client->schedule('SCHEDULE_ID')->update(['cronExpression' => '0 12 * * *']); diff --git a/src/Internal/HttpClientCore.php b/src/Internal/HttpClientCore.php index 4fde2e1..b66ddef 100644 --- a/src/Internal/HttpClientCore.php +++ b/src/Internal/HttpClientCore.php @@ -155,12 +155,16 @@ private function maybeCompressBody(?string $body, string $contentType, array $ex if ($body === null || self::hasHeader($extraHeaders, 'Content-Encoding') || !Compression::isCompressibleContentType($contentType) - || strlen($body) < Compression::MIN_COMPRESS_BYTES ) { return [$body, $extraHeaders]; } if ($this->compressor !== null) { + // A configured compressor has no built-in size gate of its own (unlike + // Compression::maybeCompress() below), so it is applied here. + if (strlen($body) < Compression::MIN_COMPRESS_BYTES) { + return [$body, $extraHeaders]; + } $compressedBody = $this->compressor->compress($body); if ($compressedBody === null) { return [$body, $extraHeaders]; diff --git a/src/Internal/ResourceContext.php b/src/Internal/ResourceContext.php index efc4abc..8adbf5a 100644 --- a/src/Internal/ResourceContext.php +++ b/src/Internal/ResourceContext.php @@ -207,11 +207,24 @@ private function effectiveTimeout(?float $timeoutSecs): ?float * configured duration) against the client's configured {@see TimeoutTiers}, matching the reference * client's per-method tier assignment. See {@see TimeoutTiers} for the tiers and their defaults. * + * When this context carries its own timeout ({@see withTimeout()} — e.g. the per-queue {@code + * timeoutSecs} of {@code requestQueue(id, options)}), the tier-resolved value is additionally + * capped at it, matching the reference client ("caps the default tier of every request that queue + * client sends"). {@see TimeoutTiers::INFINITE_SECS} (from an explicit {@code 'noTimeout'}) is + * treated as unbounded for that comparison, so a context-level cap still applies to it, rather than + * "no timeout" winning outright over a cap the caller configured for a reason. + * * @param int|float|string|null $timeoutSecs */ - public function resolveTimeout(int|float|string|null $timeoutSecs, string $defaultTier): ?float + public function resolveTimeout(int|float|string|null $timeoutSecs, string $defaultTier): float { - return $this->http->timeoutTiers()->resolve($timeoutSecs, $defaultTier); + $resolved = $this->http->timeoutTiers()->resolve($timeoutSecs, $defaultTier); + if ($this->requestTimeoutSecs === null) { + return $resolved; + } + $comparable = $resolved === TimeoutTiers::INFINITE_SECS ? INF : $resolved; + $capped = min($comparable, $this->requestTimeoutSecs); + return $capped === INF ? TimeoutTiers::INFINITE_SECS : $capped; } /** @@ -221,9 +234,9 @@ public function resolveTimeout(int|float|string|null $timeoutSecs, string $defau * @param callable(array):T $hydrate * @return PaginationList */ - public function listResource(string $subPath, QueryParams $params, callable $hydrate): PaginationList + public function listResource(string $subPath, QueryParams $params, callable $hydrate, ?float $timeoutSecs = null): PaginationList { - $data = $this->getResourceRequired($subPath, $params); + $data = $this->getResourceRequired($subPath, $params, $timeoutSecs); return PaginationList::fromData($data, $hydrate); } @@ -251,8 +264,10 @@ public static function paginateOffset(int $startOffset, ?int $limit, ?int $chunk } $total = $page->getTotal(); - // Effective total cap: the smaller of the requested limit (0/null => all) and what exists. - $cap = min(($limit !== null && $limit > 0) ? $limit : $total, $total); + // Effective total cap: the requested limit (0/null => all, i.e. capped only by what exists). + // The outer bound by $total is redundant here: the next line's min($total - $startOffset, $cap) + // already bounds the result by $total (since $startOffset >= 0). + $cap = ($limit !== null && $limit > 0) ? $limit : $total; $currentOffset = $startOffset + count($items); // Items still to yield, bounded both by what remains after the start offset and by the cap. $remaining = min($total - $startOffset, $cap) - count($items); @@ -301,10 +316,10 @@ public static function minLimit(?int $a, ?int $b): ?int * * @return array */ - public function createResource(QueryParams $params, mixed $body): array + public function createResource(QueryParams $params, mixed $body, ?float $timeoutSecs = null): array { $url = $this->mergedParams($params)->applyToUrl($this->subUrl('')); - $response = $this->http->call('POST', $url, Json::encode($body), self::CONTENT_TYPE_JSON, timeoutSecs: $this->requestTimeoutSecs); + $response = $this->http->call('POST', $url, Json::encode($body), self::CONTENT_TYPE_JSON, timeoutSecs: $this->effectiveTimeout($timeoutSecs)); return self::asArray(Json::decodeData((string) $response->getBody())); } @@ -316,16 +331,17 @@ public function createResource(QueryParams $params, mixed $body): array * @param array|null $schema * @return array */ - public function getOrCreateNamed(?string $name, ?array $schema = null): array + public function getOrCreateNamed(?string $name, ?array $schema = null, ?float $timeoutSecs = null): array { $params = new QueryParams(); if ($name !== null && $name !== '') { $params->addString('name', $name); } $url = $params->applyToUrl($this->subUrl('')); + $effectiveTimeout = $this->effectiveTimeout($timeoutSecs); $response = $schema !== null - ? $this->http->call('POST', $url, Json::encode(['schema' => $schema]), self::CONTENT_TYPE_JSON, timeoutSecs: $this->requestTimeoutSecs) - : $this->http->call('POST', $url, timeoutSecs: $this->requestTimeoutSecs); + ? $this->http->call('POST', $url, Json::encode(['schema' => $schema]), self::CONTENT_TYPE_JSON, timeoutSecs: $effectiveTimeout) + : $this->http->call('POST', $url, timeoutSecs: $effectiveTimeout); return self::asArray(Json::decodeData((string) $response->getBody())); } @@ -334,10 +350,10 @@ public function getOrCreateNamed(?string $name, ?array $schema = null): array * * @return array */ - public function postWithBody(string $subPath, QueryParams $params, ?string $body, string $contentType): array + public function postWithBody(string $subPath, QueryParams $params, ?string $body, string $contentType, ?float $timeoutSecs = null): array { $url = $this->mergedParams($params)->applyToUrl($this->subUrl($subPath)); - $response = $this->http->call('POST', $url, $body, $contentType, timeoutSecs: $this->requestTimeoutSecs); + $response = $this->http->call('POST', $url, $body, $contentType, timeoutSecs: $this->effectiveTimeout($timeoutSecs)); return self::asArray(Json::decodeData((string) $response->getBody())); } @@ -347,10 +363,10 @@ public function postWithBody(string $subPath, QueryParams $params, ?string $body * * @return mixed */ - public function postWithBodyNoEnvelope(string $subPath, QueryParams $params, ?string $body, string $contentType): mixed + public function postWithBodyNoEnvelope(string $subPath, QueryParams $params, ?string $body, string $contentType, ?float $timeoutSecs = null): mixed { $url = $this->mergedParams($params)->applyToUrl($this->subUrl($subPath)); - $response = $this->http->call('POST', $url, $body, $contentType, timeoutSecs: $this->requestTimeoutSecs); + $response = $this->http->call('POST', $url, $body, $contentType, timeoutSecs: $this->effectiveTimeout($timeoutSecs)); return Json::decode((string) $response->getBody()); } @@ -359,10 +375,10 @@ public function postWithBodyNoEnvelope(string $subPath, QueryParams $params, ?st * * @return array */ - public function deleteWithBody(string $subPath, QueryParams $params, mixed $body): array + public function deleteWithBody(string $subPath, QueryParams $params, mixed $body, ?float $timeoutSecs = null): array { $url = $this->mergedParams($params)->applyToUrl($this->subUrl($subPath)); - $response = $this->http->call('DELETE', $url, Json::encode($body), self::CONTENT_TYPE_JSON, timeoutSecs: $this->requestTimeoutSecs); + $response = $this->http->call('DELETE', $url, Json::encode($body), self::CONTENT_TYPE_JSON, timeoutSecs: $this->effectiveTimeout($timeoutSecs)); return self::asArray(Json::decodeData((string) $response->getBody())); } @@ -371,10 +387,10 @@ public function deleteWithBody(string $subPath, QueryParams $params, mixed $body * this context addresses an ID-less chained resource ({@see $hasId}) and {@code $subPath} is the * resource itself ({@code ''}), in which case the 404 is rethrown (see {@see deleteResource()}). */ - public function getRaw(string $subPath, QueryParams $params): ?ResponseInterface + public function getRaw(string $subPath, QueryParams $params, ?float $timeoutSecs = null): ?ResponseInterface { try { - return $this->getRawRequired($subPath, $params); + return $this->getRawRequired($subPath, $params, $timeoutSecs); } catch (ApifyApiException $e) { if (HttpClientCore::isNotFound($e) && $this->swallowsNotFound($subPath)) { return null; @@ -384,10 +400,10 @@ public function getRaw(string $subPath, QueryParams $params): ?ResponseInterface } /** GET returning the raw response (no data envelope). Propagates every error, including a 404. */ - public function getRawRequired(string $subPath, QueryParams $params): ResponseInterface + public function getRawRequired(string $subPath, QueryParams $params, ?float $timeoutSecs = null): ResponseInterface { $url = $this->mergedParams($params)->applyToUrl($this->subUrl($subPath)); - return $this->http->call('GET', $url, timeoutSecs: $this->requestTimeoutSecs); + return $this->http->call('GET', $url, timeoutSecs: $this->effectiveTimeout($timeoutSecs)); } /** @@ -402,11 +418,11 @@ private function swallowsNotFound(string $subPath): bool } /** HEAD request; returns whether the resource exists. */ - public function headExists(string $subPath, QueryParams $params): bool + public function headExists(string $subPath, QueryParams $params, ?float $timeoutSecs = null): bool { $url = $this->mergedParams($params)->applyToUrl($this->subUrl($subPath)); try { - $this->http->call('HEAD', $url, timeoutSecs: $this->requestTimeoutSecs); + $this->http->call('HEAD', $url, timeoutSecs: $this->effectiveTimeout($timeoutSecs)); return true; } catch (ApifyApiException $e) { if (HttpClientCore::isNotFound($e)) { diff --git a/src/Internal/TimeoutTiers.php b/src/Internal/TimeoutTiers.php index c6d8468..acc5cd0 100644 --- a/src/Internal/TimeoutTiers.php +++ b/src/Internal/TimeoutTiers.php @@ -29,6 +29,16 @@ final class TimeoutTiers public const TIER_LONG = 'long'; public const NO_TIMEOUT = 'noTimeout'; + /** + * The request-timeout value meaning "no timeout", as sent to the transport. Both Guzzle + * ({@code RequestOptions::TIMEOUT}) and the underlying cURL option it sets treat {@code 0.0} as + * "wait indefinitely", so this sentinel reaches {@see \Apify\Client\Http\HttpClientInterface} + * as an ordinary {@code float} with no special-cased {@code null} along the way — {@code 0.0} is + * never confused with "no override" because every {@code ?? } fallback in this client only + * triggers on {@code null}, not on a falsy {@code 0.0}. + */ + public const INFINITE_SECS = 0.0; + private function __construct( public readonly float $shortSecs, public readonly float $mediumSecs, @@ -72,20 +82,20 @@ public function secondsFor(string $tier): float /** * Resolves a per-call timeout override against this tier configuration: {@code null} uses * {@code $defaultTier}'s configured duration; a tier name ({@see TIER_SHORT}, {@see TIER_MEDIUM}, - * {@see TIER_LONG}) uses that tier's duration instead; {@see NO_TIMEOUT} returns {@code null} - * (no request timeout — for calls that poll until a job finishes); a number of seconds is used - * as-is, capped at {@see $maxSecs}. + * {@see TIER_LONG}) uses that tier's duration instead; {@see NO_TIMEOUT} returns {@see + * INFINITE_SECS} (no request timeout at all — for calls that poll until a job finishes); a number + * of seconds is used as-is, capped at {@see $maxSecs}. * * @param int|float|string|null $timeoutSecs a number of seconds, a tier name, {@see NO_TIMEOUT}, or * {@code null} to use {@code $defaultTier} */ - public function resolve(int|float|string|null $timeoutSecs, string $defaultTier): ?float + public function resolve(int|float|string|null $timeoutSecs, string $defaultTier): float { if ($timeoutSecs === null) { return $this->secondsFor($defaultTier); } if ($timeoutSecs === self::NO_TIMEOUT) { - return null; + return self::INFINITE_SECS; } if (is_string($timeoutSecs)) { return $this->secondsFor($timeoutSecs); diff --git a/src/Options/SetRecordOptions.php b/src/Options/SetRecordOptions.php index f1e517d..3f74728 100644 --- a/src/Options/SetRecordOptions.php +++ b/src/Options/SetRecordOptions.php @@ -12,11 +12,11 @@ final class SetRecordOptions { public function __construct( /** - * 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: a number of seconds, a timeout tier name, or {@code + * 'noTimeout'} (see {@see \Apify\Client\Internal\TimeoutTiers}). Defaults to the {@code long} + * tier. A numeric value is capped at the configured {@code timeoutMaxSecs}. */ - public readonly ?int $timeoutSecs = null, + public readonly int|float|string|null $timeoutSecs = null, /** If {@code true}, do not retry the upload when it fails with a request timeout. */ public readonly bool $doNotRetryTimeouts = false, ) { diff --git a/src/Resource/AbstractWebhookCollectionClient.php b/src/Resource/AbstractWebhookCollectionClient.php index c865c23..ab9448c 100644 --- a/src/Resource/AbstractWebhookCollectionClient.php +++ b/src/Resource/AbstractWebhookCollectionClient.php @@ -7,6 +7,7 @@ use Apify\Client\Internal\HttpClientCore; use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\PaginationList; use Apify\Client\Model\Webhook; use Apify\Client\Options\ListOptions; @@ -35,11 +36,11 @@ public function __construct(HttpClientCore $http, string $baseUrl) * * @return PaginationList */ - public function list(?ListOptions $options = null): PaginationList + public function list(?ListOptions $options = null, int|float|string|null $timeoutSecs = null): PaginationList { $params = new QueryParams(); ($options ?? new ListOptions())->appendTo($params); - return $this->ctx->listResource('', $params, static fn (array $d) => new Webhook($d)); + return $this->ctx->listResource('', $params, static fn (array $d) => new Webhook($d), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM)); } /** @@ -49,14 +50,14 @@ public function list(?ListOptions $options = null): PaginationList * * @return Generator */ - public function iterate(?ListOptions $options = null, ?int $chunkSize = null): Generator + public function iterate(?ListOptions $options = null, ?int $chunkSize = null, int|float|string|null $timeoutSecs = null): Generator { $options ??= new ListOptions(); return ResourceContext::paginateOffset( $options->offset ?? 0, $options->limit, $chunkSize, - fn (int $offset, ?int $pageLimit) => $this->list($options->withPagination($offset, $pageLimit)), + fn (int $offset, ?int $pageLimit) => $this->list($options->withPagination($offset, $pageLimit), $timeoutSecs), ); } } diff --git a/src/Resource/ActorClient.php b/src/Resource/ActorClient.php index e227399..045e6a3 100644 --- a/src/Resource/ActorClient.php +++ b/src/Resource/ActorClient.php @@ -74,13 +74,22 @@ public function delete(int|float|string|null $timeoutSecs = null): void * of bytes sent exactly as given — pair it with a non-default * {@code $options->contentType} */ - public function start(mixed $input = null, ?ActorStartOptions $options = null): ActorRun - { + public function start( + mixed $input = null, + ?ActorStartOptions $options = null, + int|float|string|null $timeoutSecs = null, + ): ActorRun { $options ??= new ActorStartOptions(); $params = new QueryParams(); $options->appendTo($params); $body = ResourceContext::encodeInputBody($input); - return new ActorRun($this->ctx->postWithBody('runs', $params, $body, $options->contentTypeOrDefault())); + return new ActorRun($this->ctx->postWithBody( + 'runs', + $params, + $body, + $options->contentTypeOrDefault(), + $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM) + )); } /** @@ -102,15 +111,24 @@ public function call(mixed $input = null, ?ActorStartOptions $options = null, ?i * @param mixed $input a JSON-serializable array (or {@code null}), or a raw string of bytes sent * exactly as given — see {@see start()} */ - public function validateInput(mixed $input = null, ?ValidateInputOptions $options = null): bool - { + public function validateInput( + mixed $input = null, + ?ValidateInputOptions $options = null, + int|float|string|null $timeoutSecs = null, + ): bool { $options ??= new ValidateInputOptions(); $params = new QueryParams(); $options->appendTo($params); $body = ResourceContext::encodeInputBody($input); // The validate-input endpoint returns a bare {"valid": } object, not the standard // {"data": ...} envelope, so parse it without unwrapping. - $result = $this->ctx->postWithBodyNoEnvelope('validate-input', $params, $body, $options->contentTypeOrDefault()); + $result = $this->ctx->postWithBodyNoEnvelope( + 'validate-input', + $params, + $body, + $options->contentTypeOrDefault(), + $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM) + ); return is_array($result) && ($result['valid'] ?? false) === true; } @@ -119,27 +137,36 @@ public function validateInput(mixed $input = null, ?ValidateInputOptions $option * * @throws InvalidArgumentException if {@code $versionNumber} is empty */ - public function build(string $versionNumber, ?ActorBuildOptions $options = null): Build - { + public function build( + string $versionNumber, + ?ActorBuildOptions $options = null, + int|float|string|null $timeoutSecs = null, + ): Build { if ($versionNumber === '') { throw new InvalidArgumentException('ActorClient::build: $versionNumber must not be empty'); } $params = new QueryParams(); $params->addString('version', $versionNumber); ($options ?? new ActorBuildOptions())->appendTo($params); - return new Build($this->ctx->postWithBody('builds', $params, null, ResourceContext::CONTENT_TYPE_JSON)); + return new Build($this->ctx->postWithBody( + 'builds', + $params, + null, + ResourceContext::CONTENT_TYPE_JSON, + $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM) + )); } /** * Resolves the Actor's default build and returns a client for it. {@code $waitForFinish} * optionally bounds how long (seconds) the API waits for the build to finish before responding. */ - public function defaultBuild(?int $waitForFinish = null): BuildClient + public function defaultBuild(?int $waitForFinish = null, int|float|string|null $timeoutSecs = null): BuildClient { $params = new QueryParams(); // Clamp the server-side wait below the per-request timeout, consistent with run/build get(). $params->addInt('waitForFinish', $this->ctx->clampServerWait($waitForFinish)); - $data = $this->ctx->getResourceRequired('builds/default', $params); + $data = $this->ctx->getResourceRequired('builds/default', $params, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); $build = new Build(is_array($data) ? $data : []); return new BuildClient($this->http, $this->baseUrl, (string) $build->getId()); } diff --git a/src/Resource/ActorCollectionClient.php b/src/Resource/ActorCollectionClient.php index af756df..39c26c0 100644 --- a/src/Resource/ActorCollectionClient.php +++ b/src/Resource/ActorCollectionClient.php @@ -7,6 +7,7 @@ use Apify\Client\Internal\HttpClientCore; use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\Actor; use Apify\Client\Model\PaginationList; use Apify\Client\Options\ActorListOptions; @@ -28,11 +29,11 @@ public function __construct(HttpClientCore $http, string $baseUrl) * * @return PaginationList */ - public function list(?ActorListOptions $options = null): PaginationList + public function list(?ActorListOptions $options = null, int|float|string|null $timeoutSecs = null): PaginationList { $params = new QueryParams(); ($options ?? new ActorListOptions())->appendTo($params); - return $this->ctx->listResource('', $params, static fn (array $d) => new Actor($d)); + return $this->ctx->listResource('', $params, static fn (array $d) => new Actor($d), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM)); } /** @@ -42,14 +43,14 @@ public function list(?ActorListOptions $options = null): PaginationList * * @return Generator */ - public function iterate(?ActorListOptions $options = null, ?int $chunkSize = null): Generator + public function iterate(?ActorListOptions $options = null, ?int $chunkSize = null, int|float|string|null $timeoutSecs = null): Generator { $options ??= new ActorListOptions(); return ResourceContext::paginateOffset( $options->offset ?? 0, $options->limit, $chunkSize, - fn (int $offset, ?int $pageLimit) => $this->list($options->withPagination($offset, $pageLimit)), + fn (int $offset, ?int $pageLimit) => $this->list($options->withPagination($offset, $pageLimit), $timeoutSecs), ); } @@ -58,8 +59,8 @@ public function iterate(?ActorListOptions $options = null, ?int $chunkSize = nul * * @param mixed $actor any JSON-serializable Actor definition */ - public function create(mixed $actor): Actor + public function create(mixed $actor, int|float|string|null $timeoutSecs = null): Actor { - return new Actor($this->ctx->createResource(new QueryParams(), $actor)); + return new Actor($this->ctx->createResource(new QueryParams(), $actor, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM))); } } diff --git a/src/Resource/ActorEnvVarCollectionClient.php b/src/Resource/ActorEnvVarCollectionClient.php index b0d57e5..517103d 100644 --- a/src/Resource/ActorEnvVarCollectionClient.php +++ b/src/Resource/ActorEnvVarCollectionClient.php @@ -7,6 +7,7 @@ use Apify\Client\Internal\HttpClientCore; use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\ActorEnvVar; use Apify\Client\Model\PaginationList; use Generator; @@ -30,9 +31,14 @@ public function __construct(HttpClientCore $http, string $versionUrl) * * @return PaginationList */ - public function list(): PaginationList + public function list(int|float|string|null $timeoutSecs = null): PaginationList { - return $this->ctx->listResource('', new QueryParams(), static fn (array $d) => ActorEnvVar::fromArray($d)); + return $this->ctx->listResource( + '', + new QueryParams(), + static fn (array $d) => ActorEnvVar::fromArray($d), + $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM) + ); } /** @@ -42,23 +48,25 @@ public function list(): PaginationList * * @return Generator */ - public function iterate(?int $chunkSize = null): Generator + public function iterate(?int $chunkSize = null, int|float|string|null $timeoutSecs = null): Generator { return ResourceContext::paginateOffset( 0, null, $chunkSize, - function (int $offset, ?int $pageLimit) { + function (int $offset, ?int $pageLimit) use ($timeoutSecs) { $params = new QueryParams(); $params->addInt('offset', $offset)->addInt('limit', $pageLimit); - return $this->ctx->listResource('', $params, static fn (array $d) => ActorEnvVar::fromArray($d)); + return $this->ctx->listResource('', $params, static fn (array $d) => ActorEnvVar::fromArray($d), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM)); }, ); } /** Creates a new environment variable. */ - public function create(ActorEnvVar $envVar): ActorEnvVar + public function create(ActorEnvVar $envVar, int|float|string|null $timeoutSecs = null): ActorEnvVar { - return ActorEnvVar::fromArray($this->ctx->createResource(new QueryParams(), $envVar->toArray())); + return ActorEnvVar::fromArray( + $this->ctx->createResource(new QueryParams(), $envVar->toArray(), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM)) + ); } } diff --git a/src/Resource/ActorVersionCollectionClient.php b/src/Resource/ActorVersionCollectionClient.php index 0614849..5a76e10 100644 --- a/src/Resource/ActorVersionCollectionClient.php +++ b/src/Resource/ActorVersionCollectionClient.php @@ -7,6 +7,7 @@ use Apify\Client\Internal\HttpClientCore; use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\ActorVersion; use Apify\Client\Model\PaginationList; use Apify\Client\Options\ListOptions; @@ -28,11 +29,11 @@ public function __construct(HttpClientCore $http, string $actorUrl) * * @return PaginationList */ - public function list(?ListOptions $options = null): PaginationList + public function list(?ListOptions $options = null, int|float|string|null $timeoutSecs = null): PaginationList { $params = new QueryParams(); ($options ?? new ListOptions())->appendTo($params); - return $this->ctx->listResource('', $params, static fn (array $d) => new ActorVersion($d)); + return $this->ctx->listResource('', $params, static fn (array $d) => new ActorVersion($d), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM)); } /** @@ -42,14 +43,14 @@ public function list(?ListOptions $options = null): PaginationList * * @return Generator */ - public function iterate(?ListOptions $options = null, ?int $chunkSize = null): Generator + public function iterate(?ListOptions $options = null, ?int $chunkSize = null, int|float|string|null $timeoutSecs = null): Generator { $options ??= new ListOptions(); return ResourceContext::paginateOffset( $options->offset ?? 0, $options->limit, $chunkSize, - fn (int $offset, ?int $pageLimit) => $this->list($options->withPagination($offset, $pageLimit)), + fn (int $offset, ?int $pageLimit) => $this->list($options->withPagination($offset, $pageLimit), $timeoutSecs), ); } @@ -58,8 +59,8 @@ public function iterate(?ListOptions $options = null, ?int $chunkSize = null): G * * @param mixed $version any JSON-serializable version definition */ - public function create(mixed $version): ActorVersion + public function create(mixed $version, int|float|string|null $timeoutSecs = null): ActorVersion { - return new ActorVersion($this->ctx->createResource(new QueryParams(), $version)); + return new ActorVersion($this->ctx->createResource(new QueryParams(), $version, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM))); } } diff --git a/src/Resource/BuildClient.php b/src/Resource/BuildClient.php index ae82736..d381382 100644 --- a/src/Resource/BuildClient.php +++ b/src/Resource/BuildClient.php @@ -39,9 +39,11 @@ public function get(?int $waitForFinishSecs = null, int|float|string|null $timeo } /** Aborts the build and returns its updated state. */ - public function abort(): Build + public function abort(int|float|string|null $timeoutSecs = null): Build { - return new Build($this->ctx->postWithBody('abort', new QueryParams(), null, '')); + return new Build( + $this->ctx->postWithBody('abort', new QueryParams(), null, '', $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM)) + ); } /** Deletes the build. */ @@ -67,15 +69,17 @@ public function waitForFinish(?int $waitSecs = null): Build /** * Returns the OpenAPI definition generated for the build. * - * Like the reference client, a 404 here is not swallowed: it is always rethrown, since the only + * Like the reference client, a 404 here is not swallowed: it always throws instead, since the only * way this fixed sub-path 404s is the build itself being gone (same rule as {@see - * \Apify\Client\Resource\DatasetClient::getStatistics()} and friends). + * \Apify\Client\Resource\DatasetClient::getStatistics()} and friends). A {@code null} return means + * the response body did not decode to an object — not a missing build, which throws. * * @return array|null the raw OpenAPI document */ - public function getOpenApiDefinition(): ?array + public function getOpenApiDefinition(int|float|string|null $timeoutSecs = null): ?array { - $decoded = Json::decode((string) $this->ctx->getRawRequired('openapi.json', new QueryParams())->getBody()); + $response = $this->ctx->getRawRequired('openapi.json', new QueryParams(), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM)); + $decoded = Json::decode((string) $response->getBody()); return is_array($decoded) ? $decoded : null; } diff --git a/src/Resource/BuildCollectionClient.php b/src/Resource/BuildCollectionClient.php index a62f9b7..62e745c 100644 --- a/src/Resource/BuildCollectionClient.php +++ b/src/Resource/BuildCollectionClient.php @@ -7,6 +7,7 @@ use Apify\Client\Internal\HttpClientCore; use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\Build; use Apify\Client\Model\PaginationList; use Apify\Client\Options\ListOptions; @@ -31,11 +32,11 @@ public function __construct(HttpClientCore $http, string $baseUrl, string $resou * * @return PaginationList */ - public function list(?ListOptions $options = null): PaginationList + public function list(?ListOptions $options = null, int|float|string|null $timeoutSecs = null): PaginationList { $params = new QueryParams(); ($options ?? new ListOptions())->appendTo($params); - return $this->ctx->listResource('', $params, static fn (array $d) => new Build($d)); + return $this->ctx->listResource('', $params, static fn (array $d) => new Build($d), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM)); } /** @@ -45,14 +46,14 @@ public function list(?ListOptions $options = null): PaginationList * * @return Generator */ - public function iterate(?ListOptions $options = null, ?int $chunkSize = null): Generator + public function iterate(?ListOptions $options = null, ?int $chunkSize = null, int|float|string|null $timeoutSecs = null): Generator { $options ??= new ListOptions(); return ResourceContext::paginateOffset( $options->offset ?? 0, $options->limit, $chunkSize, - fn (int $offset, ?int $pageLimit) => $this->list($options->withPagination($offset, $pageLimit)), + fn (int $offset, ?int $pageLimit) => $this->list($options->withPagination($offset, $pageLimit), $timeoutSecs), ); } } diff --git a/src/Resource/DatasetClient.php b/src/Resource/DatasetClient.php index 3b9ebdd..96668b5 100644 --- a/src/Resource/DatasetClient.php +++ b/src/Resource/DatasetClient.php @@ -91,9 +91,10 @@ public function delete(int|float|string|null $timeoutSecs = null): void * * @return PaginationList */ - public function listItems(?DatasetListItemsOptions $options = null): PaginationList + public function listItems(?DatasetListItemsOptions $options = null, int|float|string|null $timeoutSecs = null): PaginationList { - return $this->fetchItemsPage($options ?? new DatasetListItemsOptions())[0]; + $resolved = $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_LONG); + return $this->fetchItemsPage($options ?? new DatasetListItemsOptions(), $resolved)[0]; } /** @@ -116,13 +117,14 @@ public function listItems(?DatasetListItemsOptions $options = null): PaginationL * * @return Generator */ - public function iterateItems(?DatasetListItemsOptions $options = null, ?int $chunkSize = null): Generator + public function iterateItems(?DatasetListItemsOptions $options = null, ?int $chunkSize = null, int|float|string|null $timeoutSecs = null): Generator { $options ??= new DatasetListItemsOptions(); $startOffset = $options->offset ?? 0; $limit = $options->limit; + $resolved = $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_LONG); - [$page, $scanned] = $this->fetchItemsPage($options->withPagination($startOffset, ResourceContext::minLimit($limit, $chunkSize))); + [$page, $scanned] = $this->fetchItemsPage($options->withPagination($startOffset, ResourceContext::minLimit($limit, $chunkSize)), $resolved); foreach ($page->getItems() as $item) { yield $item; } @@ -136,7 +138,7 @@ public function iterateItems(?DatasetListItemsOptions $options = null, ?int $chu // Guard on the previous page having scanned something, so an over-reported total terminates // instead of looping forever. while ($pageScanned > 0 && $remaining > 0) { - [$page, $scanned] = $this->fetchItemsPage($options->withPagination($currentOffset, ResourceContext::minLimit($remaining, $chunkSize))); + [$page, $scanned] = $this->fetchItemsPage($options->withPagination($currentOffset, ResourceContext::minLimit($remaining, $chunkSize)), $resolved); foreach ($page->getItems() as $item) { yield $item; } @@ -153,12 +155,12 @@ public function iterateItems(?DatasetListItemsOptions $options = null, ?int $chu * * @return array{0:PaginationList,1:?int} */ - private function fetchItemsPage(DatasetListItemsOptions $options): array + private function fetchItemsPage(DatasetListItemsOptions $options, ?float $timeoutSecs = null): array { $params = new QueryParams(); $options->appendTo($params); $url = $this->ctx->mergedParams($params)->applyToUrl($this->ctx->subUrl('items')); - $response = $this->http->call('GET', $url); + $response = $this->http->call('GET', $url, timeoutSecs: $timeoutSecs); $items = Json::decode((string) $response->getBody()); $items = is_array($items) ? array_values($items) : []; @@ -187,13 +189,16 @@ private function fetchItemsPage(DatasetListItemsOptions $options): array * Unlike {@see listItems()} (parsed items), this returns the items already serialized to JSON, * CSV, XLSX, XML, RSS or HTML — useful for exporting. */ - public function downloadItems(DownloadItemsFormat $format, ?DatasetDownloadOptions $options = null): string - { + public function downloadItems( + DownloadItemsFormat $format, + ?DatasetDownloadOptions $options = null, + int|float|string|null $timeoutSecs = null, + ): string { $params = new QueryParams(); $params->addString('format', $format->value); ($options ?? new DatasetDownloadOptions())->appendTo($params); $url = $this->ctx->mergedParams($params)->applyToUrl($this->ctx->subUrl('items')); - $response = $this->http->call('GET', $url); + $response = $this->http->call('GET', $url, timeoutSecs: $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_LONG)); return (string) $response->getBody(); } @@ -202,28 +207,31 @@ public function downloadItems(DownloadItemsFormat $format, ?DatasetDownloadOptio * * @param mixed $items must serialize to a JSON object or an array of objects */ - public function pushItems(mixed $items): void + public function pushItems(mixed $items, int|float|string|null $timeoutSecs = null): void { $url = $this->ctx->mergedParams(new QueryParams())->applyToUrl($this->ctx->subUrl('items')); $this->http->call( 'POST', $url, Json::encode($items), - ResourceContext::CONTENT_TYPE_JSON_CHARSET + ResourceContext::CONTENT_TYPE_JSON_CHARSET, + $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_LONG) ); } /** * Returns statistical information about the dataset. * - * Unlike {@see get()}, a 404 here is not swallowed: it is always rethrown, since the only way this - * fixed sub-path 404s is the dataset itself being gone (matching the reference client). + * Unlike {@see get()}, a 404 here is not swallowed: it always throws instead, since the only way + * this fixed sub-path 404s is the dataset itself being gone (matching the reference client). A + * {@code null} return means the response body did not decode to an object — not a missing + * dataset, which throws. * * @return array|null */ - public function getStatistics(): ?array + public function getStatistics(int|float|string|null $timeoutSecs = null): ?array { - $response = $this->ctx->getRawRequired('statistics', new QueryParams()); + $response = $this->ctx->getRawRequired('statistics', new QueryParams(), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); $decoded = Json::decodeData((string) $response->getBody()); return is_array($decoded) ? $decoded : null; } diff --git a/src/Resource/DatasetCollectionClient.php b/src/Resource/DatasetCollectionClient.php index 166dcad..99f31af 100644 --- a/src/Resource/DatasetCollectionClient.php +++ b/src/Resource/DatasetCollectionClient.php @@ -7,6 +7,7 @@ use Apify\Client\Internal\HttpClientCore; use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\Dataset; use Apify\Client\Model\PaginationList; use Apify\Client\Options\StorageListOptions; @@ -28,11 +29,11 @@ public function __construct(HttpClientCore $http, string $baseUrl) * * @return PaginationList */ - public function list(?StorageListOptions $options = null): PaginationList + public function list(?StorageListOptions $options = null, int|float|string|null $timeoutSecs = null): PaginationList { $params = new QueryParams(); ($options ?? new StorageListOptions())->appendTo($params); - return $this->ctx->listResource('', $params, static fn (array $d) => new Dataset($d)); + return $this->ctx->listResource('', $params, static fn (array $d) => new Dataset($d), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM)); } /** @@ -42,14 +43,14 @@ public function list(?StorageListOptions $options = null): PaginationList * * @return Generator */ - public function iterate(?StorageListOptions $options = null, ?int $chunkSize = null): Generator + public function iterate(?StorageListOptions $options = null, ?int $chunkSize = null, int|float|string|null $timeoutSecs = null): Generator { $options ??= new StorageListOptions(); return ResourceContext::paginateOffset( $options->offset ?? 0, $options->limit, $chunkSize, - fn (int $offset, ?int $pageLimit) => $this->list($options->withPagination($offset, $pageLimit)), + fn (int $offset, ?int $pageLimit) => $this->list($options->withPagination($offset, $pageLimit), $timeoutSecs), ); } @@ -60,8 +61,8 @@ public function iterate(?StorageListOptions $options = null, ?int $chunkSize = n * * @param array|null $schema */ - public function getOrCreate(?string $name = null, ?array $schema = null): Dataset + public function getOrCreate(?string $name = null, ?array $schema = null, int|float|string|null $timeoutSecs = null): Dataset { - return new Dataset($this->ctx->getOrCreateNamed($name, $schema)); + return new Dataset($this->ctx->getOrCreateNamed($name, $schema, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM))); } } diff --git a/src/Resource/KeyValueStoreClient.php b/src/Resource/KeyValueStoreClient.php index e8a74d9..d51f03c 100644 --- a/src/Resource/KeyValueStoreClient.php +++ b/src/Resource/KeyValueStoreClient.php @@ -85,11 +85,13 @@ public function delete(int|float|string|null $timeoutSecs = null): void } /** Lists the keys stored in this key-value store. */ - public function listKeys(?ListKeysOptions $options = null): KeyValueStoreKeysPage + public function listKeys(?ListKeysOptions $options = null, int|float|string|null $timeoutSecs = null): KeyValueStoreKeysPage { $params = new QueryParams(); ($options ?? new ListKeysOptions())->appendTo($params); - return KeyValueStoreKeysPage::fromData($this->ctx->getResourceRequired('keys', $params)); + return KeyValueStoreKeysPage::fromData( + $this->ctx->getResourceRequired('keys', $params, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM)) + ); } /** @@ -105,7 +107,7 @@ public function listKeys(?ListKeysOptions $options = null): KeyValueStoreKeysPag * * @return Generator */ - public function iterateKeys(?ListKeysOptions $options = null): Generator + public function iterateKeys(?ListKeysOptions $options = null, int|float|string|null $timeoutSecs = null): Generator { $options ??= new ListKeysOptions(); // Total cap across all pages. null or 0 means "iterate the whole store" (the API treats @@ -125,7 +127,7 @@ public function iterateKeys(?ListKeysOptions $options = null): Generator prefix: $options->prefix, collection: $options->collection, signature: $options->signature, - )); + ), $timeoutSecs); $items = $page->getItems(); if ($items === []) { @@ -145,23 +147,31 @@ public function iterateKeys(?ListKeysOptions $options = null): Generator } /** Reports whether a record with the given key exists. */ - public function recordExists(string $key): bool + public function recordExists(string $key, int|float|string|null $timeoutSecs = null): bool { - return $this->ctx->headExists('records/' . ResourceContext::encodePathSegment($key), new QueryParams()); + return $this->ctx->headExists( + 'records/' . ResourceContext::encodePathSegment($key), + new QueryParams(), + $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT) + ); } /** * Fetches a record by key, or {@code null} if it does not exist. Like the reference client, it * requests the record as an attachment so the API returns the raw bytes directly. */ - public function getRecord(string $key, ?GetRecordOptions $options = null): ?KeyValueStoreRecord + public function getRecord(string $key, ?GetRecordOptions $options = null, int|float|string|null $timeoutSecs = null): ?KeyValueStoreRecord { // GetRecordOptions defaults attachment=true (matching the reference client), so a caller- // supplied options object requests the record as an attachment unless it opts out explicitly. $options ??= new GetRecordOptions(); $params = new QueryParams(); $options->appendTo($params); - $response = $this->ctx->getRaw('records/' . ResourceContext::encodePathSegment($key), $params); + $response = $this->ctx->getRaw( + 'records/' . ResourceContext::encodePathSegment($key), + $params, + $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_LONG) + ); if ($response === null) { return null; } @@ -176,7 +186,7 @@ public function getRecord(string $key, ?GetRecordOptions $options = null): ?KeyV public function setRecord(string $key, string $value, string $contentType, ?SetRecordOptions $options = null): void { $options ??= new SetRecordOptions(); - $timeoutSecs = $options->timeoutSecs !== null ? (float) $options->timeoutSecs : null; + $timeoutSecs = $this->ctx->resolveTimeout($options->timeoutSecs, TimeoutTiers::TIER_LONG); $this->ctx->putRaw( 'records/' . ResourceContext::encodePathSegment($key), new QueryParams(), @@ -198,9 +208,12 @@ public function setRecordJson(string $key, mixed $value): void } /** Deletes a record by key. */ - public function deleteRecord(string $key): void + public function deleteRecord(string $key, int|float|string|null $timeoutSecs = null): void { - $this->ctx->deleteResource('records/' . ResourceContext::encodePathSegment($key)); + $this->ctx->deleteResource( + 'records/' . ResourceContext::encodePathSegment($key), + $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT) + ); } /** diff --git a/src/Resource/KeyValueStoreCollectionClient.php b/src/Resource/KeyValueStoreCollectionClient.php index ed74380..68e07af 100644 --- a/src/Resource/KeyValueStoreCollectionClient.php +++ b/src/Resource/KeyValueStoreCollectionClient.php @@ -7,6 +7,7 @@ use Apify\Client\Internal\HttpClientCore; use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\KeyValueStore; use Apify\Client\Model\PaginationList; use Apify\Client\Options\StorageListOptions; @@ -28,11 +29,11 @@ public function __construct(HttpClientCore $http, string $baseUrl) * * @return PaginationList */ - public function list(?StorageListOptions $options = null): PaginationList + public function list(?StorageListOptions $options = null, int|float|string|null $timeoutSecs = null): PaginationList { $params = new QueryParams(); ($options ?? new StorageListOptions())->appendTo($params); - return $this->ctx->listResource('', $params, static fn (array $d) => new KeyValueStore($d)); + return $this->ctx->listResource('', $params, static fn (array $d) => new KeyValueStore($d), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM)); } /** @@ -42,14 +43,14 @@ public function list(?StorageListOptions $options = null): PaginationList * * @return Generator */ - public function iterate(?StorageListOptions $options = null, ?int $chunkSize = null): Generator + public function iterate(?StorageListOptions $options = null, ?int $chunkSize = null, int|float|string|null $timeoutSecs = null): Generator { $options ??= new StorageListOptions(); return ResourceContext::paginateOffset( $options->offset ?? 0, $options->limit, $chunkSize, - fn (int $offset, ?int $pageLimit) => $this->list($options->withPagination($offset, $pageLimit)), + fn (int $offset, ?int $pageLimit) => $this->list($options->withPagination($offset, $pageLimit), $timeoutSecs), ); } @@ -60,8 +61,8 @@ public function iterate(?StorageListOptions $options = null, ?int $chunkSize = n * * @param array|null $schema */ - public function getOrCreate(?string $name = null, ?array $schema = null): KeyValueStore + public function getOrCreate(?string $name = null, ?array $schema = null, int|float|string|null $timeoutSecs = null): KeyValueStore { - return new KeyValueStore($this->ctx->getOrCreateNamed($name, $schema)); + return new KeyValueStore($this->ctx->getOrCreateNamed($name, $schema, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM))); } } diff --git a/src/Resource/LogClient.php b/src/Resource/LogClient.php index dc159df..cf7bc65 100644 --- a/src/Resource/LogClient.php +++ b/src/Resource/LogClient.php @@ -7,6 +7,7 @@ use Apify\Client\Internal\HttpClientCore; use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Options\LogOptions; use Psr\Http\Message\StreamInterface; @@ -47,11 +48,11 @@ public static function nested(HttpClientCore $http, string $base, ?QueryParams $ * {@see \Apify\Client\Resource\BuildClient::log()}), a 404 is rethrown instead, since it could mean * either the parent resource or the log is gone. */ - public function get(?LogOptions $options = null): ?string + public function get(?LogOptions $options = null, int|float|string|null $timeoutSecs = null): ?string { $params = new QueryParams(); ($options ?? new LogOptions())->appendTo($params); - $response = $this->ctx->getRaw('', $params); + $response = $this->ctx->getRaw('', $params, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_LONG)); return $response === null ? null : (string) $response->getBody(); } diff --git a/src/Resource/RequestQueueClient.php b/src/Resource/RequestQueueClient.php index adba527..482573f 100644 --- a/src/Resource/RequestQueueClient.php +++ b/src/Resource/RequestQueueClient.php @@ -50,11 +50,16 @@ private function __construct( private HttpClientCore $http, private ResourceContext $ctx, private ?string $clientKey, - private ?float $timeoutSecs = null, ) { } - /** @internal */ + /** + * @internal + * + * The per-queue {@code $options->timeoutSecs}, if given, is recorded on {@code $ctx} ({@see + * ResourceContext::withTimeout()}): every call this client makes resolves its timeout tier through + * {@see ResourceContext::resolveTimeout()}, which additionally caps the resolved value at it. + */ public static function forId( HttpClientCore $http, string $baseUrl, @@ -62,9 +67,8 @@ public static function forId( ?RequestQueueClientOptions $options = null ): self { $ctx = ResourceContext::single($http, $baseUrl, 'request-queues', $id); - $timeoutSecs = $options?->timeoutSecs; - $ctx->withTimeout($timeoutSecs); - return new self($http, $ctx, $options?->clientKey, $timeoutSecs); + $ctx->withTimeout($options?->timeoutSecs); + return new self($http, $ctx, $options?->clientKey); } /** @@ -88,7 +92,7 @@ public static function nested(HttpClientCore $http, string $base, string $subPat */ public function withClientKey(string $clientKey): self { - return new self($this->http, $this->ctx, $clientKey, $this->timeoutSecs); + return new self($this->http, $this->ctx, $clientKey); } private function applyClientKey(QueryParams $params): QueryParams @@ -134,28 +138,40 @@ public function delete(int|float|string|null $timeoutSecs = null): void * Returns the requests at the head (front) of the queue, up to {@code $limit} ({@code null} for * the server default). */ - public function listHead(?int $limit = null): RequestQueueHead + public function listHead(?int $limit = null, int|float|string|null $timeoutSecs = null): RequestQueueHead { $params = new QueryParams(); $params->addInt('limit', $limit); $this->applyClientKey($params); - return RequestQueueHead::fromData($this->ctx->getResourceRequired('head', $params)); + return RequestQueueHead::fromData( + $this->ctx->getResourceRequired('head', $params, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM)) + ); } /** Adds a request to the queue. If {@code $forefront} is true, it is added to the front. */ - public function addRequest(RequestQueueRequest $request, bool $forefront = false): RequestQueueOperationInfo + public function addRequest(RequestQueueRequest $request, bool $forefront = false, int|float|string|null $timeoutSecs = null): RequestQueueOperationInfo { $params = new QueryParams(); $params->addBool('forefront', $forefront); $this->applyClientKey($params); - $data = $this->ctx->postWithBody('requests', $params, Json::encode($request->toArray()), ResourceContext::CONTENT_TYPE_JSON); + $data = $this->ctx->postWithBody( + 'requests', + $params, + Json::encode($request->toArray()), + ResourceContext::CONTENT_TYPE_JSON, + $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM) + ); return new RequestQueueOperationInfo($data); } /** Fetches a request by ID, or {@code null} if it does not exist. */ - public function getRequest(string $id): ?RequestQueueRequest + public function getRequest(string $id, int|float|string|null $timeoutSecs = null): ?RequestQueueRequest { - $data = $this->ctx->getResource('requests/' . ResourceContext::encodePathSegment($id), new QueryParams()); + $data = $this->ctx->getResource( + 'requests/' . ResourceContext::encodePathSegment($id), + new QueryParams(), + $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT) + ); return is_array($data) ? RequestQueueRequest::fromArray($data) : null; } @@ -163,26 +179,32 @@ public function getRequest(string $id): ?RequestQueueRequest * Updates an existing request (identified by its ID field) and returns the operation info. If * {@code $forefront} is true, the request is moved to the front of the queue. */ - public function updateRequest(RequestQueueRequest $request, bool $forefront = false): RequestQueueOperationInfo + public function updateRequest(RequestQueueRequest $request, bool $forefront = false, int|float|string|null $timeoutSecs = null): RequestQueueOperationInfo { $params = new QueryParams(); $params->addBool('forefront', $forefront); $this->applyClientKey($params); $url = $this->ctx->mergedParams($params) ->applyToUrl($this->ctx->subUrl('requests/' . ResourceContext::encodePathSegment((string) $request->getId()))); - $response = $this->http->call('PUT', $url, Json::encode($request->toArray()), ResourceContext::CONTENT_TYPE_JSON, timeoutSecs: $this->timeoutSecs); + $response = $this->http->call( + 'PUT', + $url, + Json::encode($request->toArray()), + ResourceContext::CONTENT_TYPE_JSON, + timeoutSecs: $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM) + ); $data = Json::decodeData((string) $response->getBody()); return new RequestQueueOperationInfo(is_array($data) ? $data : []); } /** Deletes a request by ID. */ - public function deleteRequest(string $id): void + public function deleteRequest(string $id, int|float|string|null $timeoutSecs = null): void { $params = $this->applyClientKey(new QueryParams()); $url = $this->ctx->mergedParams($params) ->applyToUrl($this->ctx->subUrl('requests/' . ResourceContext::encodePathSegment($id))); try { - $this->http->call('DELETE', $url, timeoutSecs: $this->timeoutSecs); + $this->http->call('DELETE', $url, timeoutSecs: $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); } catch (ApifyApiException $e) { if (!HttpClientCore::isNotFound($e)) { throw $e; @@ -194,12 +216,14 @@ public function deleteRequest(string $id): void * Atomically returns and locks up to {@code $limit} requests from the head of the queue for * {@code $lockSecs} seconds. */ - public function listAndLockHead(int $lockSecs, ?int $limit = null): LockedRequestQueueHead + public function listAndLockHead(int $lockSecs, ?int $limit = null, int|float|string|null $timeoutSecs = null): LockedRequestQueueHead { $params = new QueryParams(); $params->addInt('lockSecs', $lockSecs)->addInt('limit', $limit); $this->applyClientKey($params); - return LockedRequestQueueHead::fromData($this->ctx->postWithBody('head/lock', $params, null, '')); + return LockedRequestQueueHead::fromData( + $this->ctx->postWithBody('head/lock', $params, null, '', $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM)) + ); } /** @@ -224,10 +248,12 @@ public function listAndLockHead(int $lockSecs, ?int $limit = null): LockedReques public function batchAddRequests( array $requests, bool $forefront = false, - ?BatchAddRequestsOptions $options = null + ?BatchAddRequestsOptions $options = null, + int|float|string|null $timeoutSecs = null ): BatchAddResult { $options ??= new BatchAddRequestsOptions(); $requests = array_values($requests); + $resolvedTimeoutSecs = $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM); $payloadSizeLimitBytes = self::MAX_PAYLOAD_SIZE_BYTES - (int) ceil(self::MAX_PAYLOAD_SIZE_BYTES * self::PAYLOAD_SAFETY_BUFFER_PERCENT); @@ -249,21 +275,24 @@ public function batchAddRequests( ); } $json = Json::encode($request->toArray()); - // +1 for the comma or closing bracket that follows this item in the batch body. - $bytes = strlen($json) + 1; - if ($bytes > $payloadSizeLimitBytes) { + // +2 for the opening and closing brackets a batch of this one request alone would need + // (`[` + json + `]`), matching the reference client's exact accounting: an item that only + // just fits as the sole member of its own batch must still be accepted. + if (strlen($json) + 2 > $payloadSizeLimitBytes) { throw new InvalidArgumentException(sprintf( 'batchAddRequests: the request at index %d exceeds the maximum payload size (%d bytes)', $i, $payloadSizeLimitBytes )); } - $serialized[] = ['request' => $request, 'json' => $json, 'bytes' => $bytes]; + // +1 for the comma or closing bracket that follows this item once it sits inside a batch + // body alongside others (used by splitIntoBatches() below to size a multi-item batch). + $serialized[] = ['request' => $request, 'json' => $json, 'bytes' => strlen($json) + 1]; } $merged = new BatchAddResult(); foreach (self::splitIntoBatches($serialized, self::MAX_REQUESTS_PER_BATCH, $payloadSizeLimitBytes) as $chunk) { - $merged->merge($this->batchAddChunkWithRetries($chunk, $forefront, $options)); + $merged->merge($this->batchAddChunkWithRetries($chunk, $forefront, $options, $resolvedTimeoutSecs)); } return $merged; } @@ -273,9 +302,11 @@ public function batchAddRequests( * fitting a JSON array body (items joined by commas between brackets) of at most * {@code $maxByteLength} bytes. Ports the reference client's {@code splitIntoJsonArrayBatches}. * - * Every item's {@code bytes} (pre-validated in {@see batchAddRequests()} to fit under - * {@code $maxByteLength} on its own) already includes the one extra byte it costs as either a - * separating comma or the batch's closing bracket, so only the opening bracket's byte is added here. + * Every item's {@code bytes} already includes the one extra byte it costs as either a separating + * comma or the batch's closing bracket, so only the opening bracket's byte is added here. + * {@see batchAddRequests()} pre-validates each item against a two-byte-larger bound (both the + * opening and closing bracket a lone-item batch would need), so an item that fits only as the sole + * member of its own batch is still accepted here, not rejected by this narrower one-byte margin. * * @param list $items * @return list> @@ -303,7 +334,7 @@ private static function splitIntoBatches(array $items, int $maxCount, int $maxBy /** * @param list $chunk */ - private function batchAddChunkWithRetries(array $chunk, bool $forefront, BatchAddRequestsOptions $options): BatchAddResult + private function batchAddChunkWithRetries(array $chunk, bool $forefront, BatchAddRequestsOptions $options, ?float $timeoutSecs): BatchAddResult { $maxRetries = $options->maxUnprocessedRequestsRetries; $minDelayMillis = $options->minDelayBetweenUnprocessedRequestsRetriesMillis; @@ -316,7 +347,7 @@ private function batchAddChunkWithRetries(array $chunk, bool $forefront, BatchAd for ($attempt = 0; $attempt <= $maxRetries; $attempt++) { try { - $response = $this->batchAddChunk($remaining, $forefront); + $response = $this->batchAddChunk($remaining, $forefront, $timeoutSecs); } catch (ApifyApiException) { // Matches the JS reference (which mandates consistent error handling): when the HTTP // call fails and the transport did not (or was told not to) retry, the requests not yet @@ -351,13 +382,13 @@ private function batchAddChunkWithRetries(array $chunk, bool $forefront, BatchAd * * @param list $chunk */ - private function batchAddChunk(array $chunk, bool $forefront): BatchAddResult + private function batchAddChunk(array $chunk, bool $forefront, ?float $timeoutSecs): BatchAddResult { $params = new QueryParams(); $params->addBool('forefront', $forefront); $this->applyClientKey($params); $body = '[' . implode(',', array_column($chunk, 'json')) . ']'; - $data = $this->ctx->postWithBody('requests/batch', $params, $body, ResourceContext::CONTENT_TYPE_JSON); + $data = $this->ctx->postWithBody('requests/batch', $params, $body, ResourceContext::CONTENT_TYPE_JSON, $timeoutSecs); $rawProcessed = (isset($data['processedRequests']) && is_array($data['processedRequests'])) ? $data['processedRequests'] : []; $processed = array_map( @@ -424,7 +455,7 @@ private static function sleepBackoff(int $attempt, int $minDelayMillis): void * @throws InvalidArgumentException if {@code $requests} is empty, exceeds the per-call limit, or * any entry is missing a non-empty id or uniqueKey */ - public function batchDeleteRequests(array $requests): BatchDeleteResult + public function batchDeleteRequests(array $requests, int|float|string|null $timeoutSecs = null): BatchDeleteResult { $requests = array_values($requests); if ($requests === []) { @@ -450,34 +481,42 @@ public function batchDeleteRequests(array $requests): BatchDeleteResult $params = $this->applyClientKey(new QueryParams()); $payload = array_map(static fn (RequestQueueRequest $r) => $r->toArray(), $requests); - return BatchDeleteResult::fromData($this->ctx->deleteWithBody('requests/batch', $params, $payload)); + return BatchDeleteResult::fromData( + $this->ctx->deleteWithBody('requests/batch', $params, $payload, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM)) + ); } /** * Lists the queue's requests with pagination. */ - public function listRequests(?ListRequestsOptions $options = null): RequestQueueRequestsPage + public function listRequests(?ListRequestsOptions $options = null, int|float|string|null $timeoutSecs = null): RequestQueueRequestsPage { $options ??= new ListRequestsOptions(); $options->validate(); $params = new QueryParams(); $options->appendTo($params); $this->applyClientKey($params); - return RequestQueueRequestsPage::fromData($this->ctx->getResourceRequired('requests', $params)); + return RequestQueueRequestsPage::fromData( + $this->ctx->getResourceRequired('requests', $params, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM)) + ); } /** * Extends the lock on a request by {@code $lockSecs} seconds. If {@code $forefront} is true, the * request is moved to the front when its lock expires. */ - public function prolongRequestLock(string $id, int $lockSecs, bool $forefront = false): RequestLockInfo - { + public function prolongRequestLock( + string $id, + int $lockSecs, + bool $forefront = false, + int|float|string|null $timeoutSecs = null + ): RequestLockInfo { $params = new QueryParams(); $params->addInt('lockSecs', $lockSecs)->addBool('forefront', $forefront); $this->applyClientKey($params); $url = $this->ctx->mergedParams($params) ->applyToUrl($this->ctx->subUrl('requests/' . ResourceContext::encodePathSegment($id) . '/lock')); - $response = $this->http->call('PUT', $url, null, '', timeoutSecs: $this->timeoutSecs); + $response = $this->http->call('PUT', $url, null, '', timeoutSecs: $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM)); return RequestLockInfo::fromData(Json::decodeData((string) $response->getBody())); } @@ -485,7 +524,7 @@ public function prolongRequestLock(string $id, int $lockSecs, bool $forefront = * Releases the lock on a request. If {@code $forefront} is true, the request is moved to the * front of the queue. */ - public function deleteRequestLock(string $id, bool $forefront = false): void + public function deleteRequestLock(string $id, bool $forefront = false, int|float|string|null $timeoutSecs = null): void { $params = new QueryParams(); $params->addBool('forefront', $forefront); @@ -493,7 +532,7 @@ public function deleteRequestLock(string $id, bool $forefront = false): void $url = $this->ctx->mergedParams($params) ->applyToUrl($this->ctx->subUrl('requests/' . ResourceContext::encodePathSegment($id) . '/lock')); try { - $this->http->call('DELETE', $url, timeoutSecs: $this->timeoutSecs); + $this->http->call('DELETE', $url, timeoutSecs: $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM)); } catch (ApifyApiException $e) { if (!HttpClientCore::isNotFound($e)) { throw $e; @@ -502,10 +541,12 @@ public function deleteRequestLock(string $id, bool $forefront = false): void } /** Releases all locks the client holds on this queue's requests. */ - public function unlockRequests(): UnlockRequestsResult + public function unlockRequests(int|float|string|null $timeoutSecs = null): UnlockRequestsResult { $params = $this->applyClientKey(new QueryParams()); - return UnlockRequestsResult::fromData($this->ctx->postWithBody('requests/unlock', $params, null, '')); + return UnlockRequestsResult::fromData( + $this->ctx->postWithBody('requests/unlock', $params, null, '', $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_LONG)) + ); } /** @@ -519,7 +560,7 @@ public function unlockRequests(): UnlockRequestsResult * * @return Generator */ - public function paginateRequests(?PaginateRequestsOptions $options = null): Generator + public function paginateRequests(?PaginateRequestsOptions $options = null, int|float|string|null $timeoutSecs = null): Generator { $options ??= new PaginateRequestsOptions(); $options->validate(); @@ -541,7 +582,7 @@ public function paginateRequests(?PaginateRequestsOptions $options = null): Gene exclusiveStartId: $nextExclusiveStartId, cursor: $nextCursor, filter: $options->filter, - )); + ), $timeoutSecs); $items = $page->getItems(); if ($items === []) { diff --git a/src/Resource/RequestQueueCollectionClient.php b/src/Resource/RequestQueueCollectionClient.php index 2216980..3c89a1b 100644 --- a/src/Resource/RequestQueueCollectionClient.php +++ b/src/Resource/RequestQueueCollectionClient.php @@ -7,6 +7,7 @@ use Apify\Client\Internal\HttpClientCore; use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\PaginationList; use Apify\Client\Model\RequestQueue; use Apify\Client\Options\StorageListOptions; @@ -28,11 +29,11 @@ public function __construct(HttpClientCore $http, string $baseUrl) * * @return PaginationList */ - public function list(?StorageListOptions $options = null): PaginationList + public function list(?StorageListOptions $options = null, int|float|string|null $timeoutSecs = null): PaginationList { $params = new QueryParams(); ($options ?? new StorageListOptions())->appendTo($params); - return $this->ctx->listResource('', $params, static fn (array $d) => new RequestQueue($d)); + return $this->ctx->listResource('', $params, static fn (array $d) => new RequestQueue($d), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM)); } /** @@ -42,14 +43,14 @@ public function list(?StorageListOptions $options = null): PaginationList * * @return Generator */ - public function iterate(?StorageListOptions $options = null, ?int $chunkSize = null): Generator + public function iterate(?StorageListOptions $options = null, ?int $chunkSize = null, int|float|string|null $timeoutSecs = null): Generator { $options ??= new StorageListOptions(); return ResourceContext::paginateOffset( $options->offset ?? 0, $options->limit, $chunkSize, - fn (int $offset, ?int $pageLimit) => $this->list($options->withPagination($offset, $pageLimit)), + fn (int $offset, ?int $pageLimit) => $this->list($options->withPagination($offset, $pageLimit), $timeoutSecs), ); } @@ -57,8 +58,8 @@ public function iterate(?StorageListOptions $options = null, ?int $chunkSize = n * Gets the queue with the given name, creating it if it does not exist. An empty/{@code null} * name creates a new unnamed queue. */ - public function getOrCreate(?string $name = null): RequestQueue + public function getOrCreate(?string $name = null, int|float|string|null $timeoutSecs = null): RequestQueue { - return new RequestQueue($this->ctx->getOrCreateNamed($name)); + return new RequestQueue($this->ctx->getOrCreateNamed($name, null, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM))); } } diff --git a/src/Resource/RunClient.php b/src/Resource/RunClient.php index e7409c5..f108305 100644 --- a/src/Resource/RunClient.php +++ b/src/Resource/RunClient.php @@ -93,11 +93,11 @@ public function delete(int|float|string|null $timeoutSecs = null): void * the current request before terminating; {@code false} aborts immediately. {@code null} omits * the parameter and lets the server apply its default (immediate abort). */ - public function abort(?bool $gracefully = null): ActorRun + public function abort(?bool $gracefully = null, int|float|string|null $timeoutSecs = null): ActorRun { $params = new QueryParams(); $params->addBool('gracefully', $gracefully); - return new ActorRun($this->ctx->postWithBody('abort', $params, null, '')); + return new ActorRun($this->ctx->postWithBody('abort', $params, null, '', $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM))); } /** @@ -108,8 +108,12 @@ public function abort(?bool $gracefully = null): ActorRun * sent exactly as given (pair it with a non-default * {@code $options->contentType}), or {@code null} for none */ - public function metamorph(string $targetActorId, mixed $input = null, ?MetamorphOptions $options = null): ActorRun - { + public function metamorph( + string $targetActorId, + mixed $input = null, + ?MetamorphOptions $options = null, + int|float|string|null $timeoutSecs = null, + ): ActorRun { $options ??= new MetamorphOptions(); $params = new QueryParams(); // Normalize the target Actor id to the URL-safe `username~actor-name` form (first `/`→`~`), @@ -119,21 +123,31 @@ public function metamorph(string $targetActorId, mixed $input = null, ?Metamorph $params->addString('build', $options->build); } $body = ResourceContext::encodeInputBody($input); - return new ActorRun($this->ctx->postWithBody('metamorph', $params, $body, $options->contentTypeOrDefault())); + return new ActorRun($this->ctx->postWithBody( + 'metamorph', + $params, + $body, + $options->contentTypeOrDefault(), + $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM) + )); } /** Reboots the run (restarts its container while keeping the same run). */ - public function reboot(): ActorRun + public function reboot(int|float|string|null $timeoutSecs = null): ActorRun { - return new ActorRun($this->ctx->postWithBody('reboot', new QueryParams(), null, '')); + return new ActorRun( + $this->ctx->postWithBody('reboot', new QueryParams(), null, '', $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM)) + ); } /** Resurrects a finished run, starting it again from the beginning. */ - public function resurrect(?RunResurrectOptions $options = null): ActorRun + public function resurrect(?RunResurrectOptions $options = null, int|float|string|null $timeoutSecs = null): ActorRun { $params = new QueryParams(); ($options ?? new RunResurrectOptions())->appendTo($params); - return new ActorRun($this->ctx->postWithBody('resurrect', $params, null, '')); + return new ActorRun( + $this->ctx->postWithBody('resurrect', $params, null, '', $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM)) + ); } /** @@ -143,7 +157,7 @@ public function resurrect(?RunResurrectOptions $options = null): ActorRun * An idempotency key is always sent (auto-generated if not provided), so a charge that is retried * by the transport is applied at most once, matching the reference client. */ - public function charge(RunChargeOptions $options): void + public function charge(RunChargeOptions $options, int|float|string|null $timeoutSecs = null): void { if ($options->eventName === '') { throw new InvalidArgumentException('RunChargeOptions.eventName is required and must not be empty'); @@ -158,7 +172,7 @@ public function charge(RunChargeOptions $options): void $this->ctx->subUrl('charge'), Json::encode($body), ResourceContext::CONTENT_TYPE_JSON, - null, + $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM), false, [self::CHARGE_IDEMPOTENCY_HEADER => $idempotencyKey] ); diff --git a/src/Resource/RunCollectionClient.php b/src/Resource/RunCollectionClient.php index 7ea64a8..aa81602 100644 --- a/src/Resource/RunCollectionClient.php +++ b/src/Resource/RunCollectionClient.php @@ -7,6 +7,7 @@ use Apify\Client\Internal\HttpClientCore; use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\ActorRun; use Apify\Client\Model\PaginationList; use Apify\Client\Options\ListOptions; @@ -33,12 +34,12 @@ public function __construct(HttpClientCore $http, string $baseUrl, string $resou * * @return PaginationList */ - public function list(?ListOptions $options = null, ?RunListOptions $filter = null): PaginationList + public function list(?ListOptions $options = null, ?RunListOptions $filter = null, int|float|string|null $timeoutSecs = null): PaginationList { $params = new QueryParams(); ($options ?? new ListOptions())->appendTo($params); ($filter ?? new RunListOptions())->appendTo($params); - return $this->ctx->listResource('', $params, static fn (array $d) => new ActorRun($d)); + return $this->ctx->listResource('', $params, static fn (array $d) => new ActorRun($d), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM)); } /** @@ -48,14 +49,14 @@ public function list(?ListOptions $options = null, ?RunListOptions $filter = nul * * @return Generator */ - public function iterate(?ListOptions $options = null, ?RunListOptions $filter = null, ?int $chunkSize = null): Generator + public function iterate(?ListOptions $options = null, ?RunListOptions $filter = null, ?int $chunkSize = null, int|float|string|null $timeoutSecs = null): Generator { $options ??= new ListOptions(); return ResourceContext::paginateOffset( $options->offset ?? 0, $options->limit, $chunkSize, - fn (int $offset, ?int $pageLimit) => $this->list($options->withPagination($offset, $pageLimit), $filter), + fn (int $offset, ?int $pageLimit) => $this->list($options->withPagination($offset, $pageLimit), $filter, $timeoutSecs), ); } } diff --git a/src/Resource/ScheduleClient.php b/src/Resource/ScheduleClient.php index f1b810c..577dfcc 100644 --- a/src/Resource/ScheduleClient.php +++ b/src/Resource/ScheduleClient.php @@ -45,13 +45,14 @@ public function delete(int|float|string|null $timeoutSecs = null): void } /** - * Fetches the schedule's invocation log as text. + * Fetches the schedule's invocation log as text (an empty string if there is no log yet). * - * Unlike {@see get()}, a 404 here is not swallowed: it is always rethrown, since the only way this - * fixed sub-path 404s is the schedule itself being gone (matching the reference client). + * Unlike {@see get()}, a 404 here is not swallowed: it always throws instead, since the only way + * this fixed sub-path 404s is the schedule itself being gone (matching the reference client). The + * return type is therefore never {@code null}. */ - public function getLog(): ?string + public function getLog(int|float|string|null $timeoutSecs = null): string { - return (string) $this->ctx->getRawRequired('log', new QueryParams())->getBody(); + return (string) $this->ctx->getRawRequired('log', new QueryParams(), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_LONG))->getBody(); } } diff --git a/src/Resource/ScheduleCollectionClient.php b/src/Resource/ScheduleCollectionClient.php index 637b57e..df90ceb 100644 --- a/src/Resource/ScheduleCollectionClient.php +++ b/src/Resource/ScheduleCollectionClient.php @@ -7,6 +7,7 @@ use Apify\Client\Internal\HttpClientCore; use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\PaginationList; use Apify\Client\Model\Schedule; use Apify\Client\Options\ListOptions; @@ -28,11 +29,11 @@ public function __construct(HttpClientCore $http, string $baseUrl) * * @return PaginationList */ - public function list(?ListOptions $options = null): PaginationList + public function list(?ListOptions $options = null, int|float|string|null $timeoutSecs = null): PaginationList { $params = new QueryParams(); ($options ?? new ListOptions())->appendTo($params); - return $this->ctx->listResource('', $params, static fn (array $d) => new Schedule($d)); + return $this->ctx->listResource('', $params, static fn (array $d) => new Schedule($d), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM)); } /** @@ -42,14 +43,14 @@ public function list(?ListOptions $options = null): PaginationList * * @return Generator */ - public function iterate(?ListOptions $options = null, ?int $chunkSize = null): Generator + public function iterate(?ListOptions $options = null, ?int $chunkSize = null, int|float|string|null $timeoutSecs = null): Generator { $options ??= new ListOptions(); return ResourceContext::paginateOffset( $options->offset ?? 0, $options->limit, $chunkSize, - fn (int $offset, ?int $pageLimit) => $this->list($options->withPagination($offset, $pageLimit)), + fn (int $offset, ?int $pageLimit) => $this->list($options->withPagination($offset, $pageLimit), $timeoutSecs), ); } @@ -58,8 +59,8 @@ public function iterate(?ListOptions $options = null, ?int $chunkSize = null): G * * @param mixed $schedule any JSON-serializable schedule definition */ - public function create(mixed $schedule): Schedule + public function create(mixed $schedule, int|float|string|null $timeoutSecs = null): Schedule { - return new Schedule($this->ctx->createResource(new QueryParams(), $schedule)); + return new Schedule($this->ctx->createResource(new QueryParams(), $schedule, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM))); } } diff --git a/src/Resource/StoreCollectionClient.php b/src/Resource/StoreCollectionClient.php index 4acdd7f..8b55c10 100644 --- a/src/Resource/StoreCollectionClient.php +++ b/src/Resource/StoreCollectionClient.php @@ -7,6 +7,7 @@ use Apify\Client\Internal\HttpClientCore; use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\ActorStoreListItem; use Apify\Client\Model\PaginationList; use Apify\Client\Options\StoreListOptions; @@ -28,11 +29,11 @@ public function __construct(HttpClientCore $http, string $baseUrl) * * @return PaginationList */ - public function list(?StoreListOptions $options = null): PaginationList + public function list(?StoreListOptions $options = null, int|float|string|null $timeoutSecs = null): PaginationList { $params = new QueryParams(); ($options ?? new StoreListOptions())->appendTo($params); - return $this->ctx->listResource('', $params, static fn (array $d) => new ActorStoreListItem($d)); + return $this->ctx->listResource('', $params, static fn (array $d) => new ActorStoreListItem($d), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM)); } /** @@ -43,14 +44,14 @@ public function list(?StoreListOptions $options = null): PaginationList * * @return Generator */ - public function iterate(?StoreListOptions $options = null, ?int $chunkSize = null): Generator + public function iterate(?StoreListOptions $options = null, ?int $chunkSize = null, int|float|string|null $timeoutSecs = null): Generator { $options ??= new StoreListOptions(); return ResourceContext::paginateOffset( $options->offset ?? 0, $options->limit, $chunkSize, - fn (int $offset, ?int $pageLimit) => $this->list($options->withPagination($offset, $pageLimit)), + fn (int $offset, ?int $pageLimit) => $this->list($options->withPagination($offset, $pageLimit), $timeoutSecs), ); } } diff --git a/src/Resource/TaskClient.php b/src/Resource/TaskClient.php index 36fbee9..8a026f4 100644 --- a/src/Resource/TaskClient.php +++ b/src/Resource/TaskClient.php @@ -92,12 +92,21 @@ public function unpublish(): Task * * @param mixed $input optionally overrides the task's stored input ({@code null} to use it) */ - public function start(mixed $input = null, ?TaskStartOptions $options = null): ActorRun - { + public function start( + mixed $input = null, + ?TaskStartOptions $options = null, + int|float|string|null $timeoutSecs = null, + ): ActorRun { $params = new QueryParams(); ($options ?? new TaskStartOptions())->appendTo($params); $body = $input === null ? null : Json::encode($input); - return new ActorRun($this->ctx->postWithBody('runs', $params, $body, ResourceContext::CONTENT_TYPE_JSON)); + return new ActorRun($this->ctx->postWithBody( + 'runs', + $params, + $body, + ResourceContext::CONTENT_TYPE_JSON, + $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM) + )); } /** @@ -115,14 +124,16 @@ public function call(mixed $input = null, ?TaskStartOptions $options = null, ?in /** * Fetches the task's stored input. * - * Unlike {@see get()}, a 404 here is not swallowed: it is always rethrown, since the only way this - * fixed sub-path 404s is the task itself being gone (matching the reference client). + * Unlike {@see get()}, a 404 here is not swallowed: it always throws instead, since the only way + * this fixed sub-path 404s is the task itself being gone (matching the reference client). A + * {@code null} return means the task's stored input JSON itself decoded to {@code null} (or the + * response body was empty) — not a missing task, which throws. * * @return mixed */ - public function getInput(): mixed + public function getInput(int|float|string|null $timeoutSecs = null): mixed { - $response = $this->ctx->getRawRequired('input', new QueryParams()); + $response = $this->ctx->getRawRequired('input', new QueryParams(), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); return Json::decode((string) $response->getBody()); } @@ -132,13 +143,14 @@ public function getInput(): mixed * @param mixed $input any JSON-serializable value * @return mixed */ - public function updateInput(mixed $input): mixed + public function updateInput(mixed $input, int|float|string|null $timeoutSecs = null): mixed { $response = $this->http->call( 'PUT', $this->ctx->subUrl('input'), Json::encode($input), - ResourceContext::CONTENT_TYPE_JSON + ResourceContext::CONTENT_TYPE_JSON, + $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT) ); return Json::decode((string) $response->getBody()); } diff --git a/src/Resource/TaskCollectionClient.php b/src/Resource/TaskCollectionClient.php index c0bd8c3..50daafe 100644 --- a/src/Resource/TaskCollectionClient.php +++ b/src/Resource/TaskCollectionClient.php @@ -7,6 +7,7 @@ use Apify\Client\Internal\HttpClientCore; use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\PaginationList; use Apify\Client\Model\Task; use Apify\Client\Options\ListOptions; @@ -28,11 +29,11 @@ public function __construct(HttpClientCore $http, string $baseUrl) * * @return PaginationList */ - public function list(?ListOptions $options = null): PaginationList + public function list(?ListOptions $options = null, int|float|string|null $timeoutSecs = null): PaginationList { $params = new QueryParams(); ($options ?? new ListOptions())->appendTo($params); - return $this->ctx->listResource('', $params, static fn (array $d) => new Task($d)); + return $this->ctx->listResource('', $params, static fn (array $d) => new Task($d), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM)); } /** @@ -42,14 +43,14 @@ public function list(?ListOptions $options = null): PaginationList * * @return Generator */ - public function iterate(?ListOptions $options = null, ?int $chunkSize = null): Generator + public function iterate(?ListOptions $options = null, ?int $chunkSize = null, int|float|string|null $timeoutSecs = null): Generator { $options ??= new ListOptions(); return ResourceContext::paginateOffset( $options->offset ?? 0, $options->limit, $chunkSize, - fn (int $offset, ?int $pageLimit) => $this->list($options->withPagination($offset, $pageLimit)), + fn (int $offset, ?int $pageLimit) => $this->list($options->withPagination($offset, $pageLimit), $timeoutSecs), ); } @@ -58,8 +59,8 @@ public function iterate(?ListOptions $options = null, ?int $chunkSize = null): G * * @param mixed $task any JSON-serializable task definition */ - public function create(mixed $task): Task + public function create(mixed $task, int|float|string|null $timeoutSecs = null): Task { - return new Task($this->ctx->createResource(new QueryParams(), $task)); + return new Task($this->ctx->createResource(new QueryParams(), $task, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM))); } } diff --git a/src/Resource/UserClient.php b/src/Resource/UserClient.php index da8863d..8eccc9e 100644 --- a/src/Resource/UserClient.php +++ b/src/Resource/UserClient.php @@ -49,14 +49,14 @@ public function get(int|float|string|null $timeoutSecs = null): ?User * * @return array */ - public function monthlyUsage(?string $date = null): array + public function monthlyUsage(?string $date = null, int|float|string|null $timeoutSecs = null): array { $this->requireMe(); $params = new QueryParams(); if ($date !== null && $date !== '') { $params->addString('date', $date); } - $data = $this->ctx->getResourceRequired('usage/monthly', $params); + $data = $this->ctx->getResourceRequired('usage/monthly', $params, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); return is_array($data) ? $data : []; } @@ -65,10 +65,10 @@ public function monthlyUsage(?string $date = null): array * * @return array */ - public function limits(): array + public function limits(int|float|string|null $timeoutSecs = null): array { $this->requireMe(); - $data = $this->ctx->getResourceRequired('limits', new QueryParams()); + $data = $this->ctx->getResourceRequired('limits', new QueryParams(), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); return is_array($data) ? $data : []; } @@ -77,14 +77,15 @@ public function limits(): array * * @param mixed $newLimits any JSON-serializable limits object */ - public function updateLimits(mixed $newLimits): void + public function updateLimits(mixed $newLimits, int|float|string|null $timeoutSecs = null): void { $this->requireMe(); $this->http->call( 'PUT', $this->ctx->subUrl('limits'), Json::encode($newLimits), - ResourceContext::CONTENT_TYPE_JSON + ResourceContext::CONTENT_TYPE_JSON, + $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT) ); } diff --git a/src/Resource/WebhookClient.php b/src/Resource/WebhookClient.php index e9a6be3..d405c07 100644 --- a/src/Resource/WebhookClient.php +++ b/src/Resource/WebhookClient.php @@ -46,9 +46,11 @@ public function delete(int|float|string|null $timeoutSecs = null): void } /** Dispatches the webhook immediately and returns the resulting dispatch. */ - public function test(): WebhookDispatch + public function test(int|float|string|null $timeoutSecs = null): WebhookDispatch { - return new WebhookDispatch($this->ctx->postWithBody('test', new QueryParams(), null, '')); + return new WebhookDispatch( + $this->ctx->postWithBody('test', new QueryParams(), null, '', $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM)) + ); } /** A client for this webhook's dispatch collection. */ diff --git a/src/Resource/WebhookCollectionClient.php b/src/Resource/WebhookCollectionClient.php index a837b9f..5512e76 100644 --- a/src/Resource/WebhookCollectionClient.php +++ b/src/Resource/WebhookCollectionClient.php @@ -5,6 +5,7 @@ namespace Apify\Client\Resource; use Apify\Client\Internal\QueryParams; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\Webhook; /** @@ -19,8 +20,8 @@ final class WebhookCollectionClient extends AbstractWebhookCollectionClient * * @param mixed $webhook any JSON-serializable webhook definition */ - public function create(mixed $webhook): Webhook + public function create(mixed $webhook, int|float|string|null $timeoutSecs = null): Webhook { - return new Webhook($this->ctx->createResource(new QueryParams(), $webhook)); + return new Webhook($this->ctx->createResource(new QueryParams(), $webhook, $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM))); } } diff --git a/src/Resource/WebhookDispatchCollectionClient.php b/src/Resource/WebhookDispatchCollectionClient.php index e4a6e0e..a6524ba 100644 --- a/src/Resource/WebhookDispatchCollectionClient.php +++ b/src/Resource/WebhookDispatchCollectionClient.php @@ -7,6 +7,7 @@ use Apify\Client\Internal\HttpClientCore; use Apify\Client\Internal\QueryParams; use Apify\Client\Internal\ResourceContext; +use Apify\Client\Internal\TimeoutTiers; use Apify\Client\Model\PaginationList; use Apify\Client\Model\WebhookDispatch; use Apify\Client\Options\ListOptions; @@ -31,11 +32,11 @@ public function __construct(HttpClientCore $http, string $baseUrl, string $resou * * @return PaginationList */ - public function list(?ListOptions $options = null): PaginationList + public function list(?ListOptions $options = null, int|float|string|null $timeoutSecs = null): PaginationList { $params = new QueryParams(); ($options ?? new ListOptions())->appendTo($params); - return $this->ctx->listResource('', $params, static fn (array $d) => new WebhookDispatch($d)); + return $this->ctx->listResource('', $params, static fn (array $d) => new WebhookDispatch($d), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM)); } /** @@ -45,14 +46,14 @@ public function list(?ListOptions $options = null): PaginationList * * @return Generator */ - public function iterate(?ListOptions $options = null, ?int $chunkSize = null): Generator + public function iterate(?ListOptions $options = null, ?int $chunkSize = null, int|float|string|null $timeoutSecs = null): Generator { $options ??= new ListOptions(); return ResourceContext::paginateOffset( $options->offset ?? 0, $options->limit, $chunkSize, - fn (int $offset, ?int $pageLimit) => $this->list($options->withPagination($offset, $pageLimit)), + fn (int $offset, ?int $pageLimit) => $this->list($options->withPagination($offset, $pageLimit), $timeoutSecs), ); } } diff --git a/tests/Examples/DocSnippetsTest.php b/tests/Examples/DocSnippetsTest.php index fa7c9d0..01c4190 100644 --- a/tests/Examples/DocSnippetsTest.php +++ b/tests/Examples/DocSnippetsTest.php @@ -75,6 +75,7 @@ private function imports(): string 'Apify\Client\Model\RequestQueueRequest', 'Apify\Client\Model\ActorEnvVar', 'Apify\Client\Exception\ApifyApiException', + 'Apify\Client\Exception\NotFoundException', 'Apify\Client\Http\GuzzleHttpClient', 'Apify\Client\Http\Psr18HttpClient', 'Apify\Client\Options\ActorListOptions', diff --git a/tests/Integration/ScheduleIntegrationTest.php b/tests/Integration/ScheduleIntegrationTest.php index 902397c..ea9b40e 100644 --- a/tests/Integration/ScheduleIntegrationTest.php +++ b/tests/Integration/ScheduleIntegrationTest.php @@ -76,8 +76,8 @@ public function testScheduleCrudFlow(): void self::assertNotNull($schedule->get()); $updated = $schedule->update(['cronExpression' => '0 12 * * *']); self::assertSame('0 12 * * *', $updated->getCronExpression()); - // A fresh schedule may have no log yet (null), which is a valid result — we only assert - // the call itself succeeds. + // A fresh schedule may have no invocations yet (an empty string), which is a valid result + // — we only assert the call itself succeeds. $schedule->getLog(); } finally { $client->schedule((string) $sch->getId())->delete(); diff --git a/tests/Unit/BatchAddRequestsTest.php b/tests/Unit/BatchAddRequestsTest.php index fdba507..60f20ef 100644 --- a/tests/Unit/BatchAddRequestsTest.php +++ b/tests/Unit/BatchAddRequestsTest.php @@ -177,6 +177,48 @@ public function testChunksByPayloadByteSize(): void self::assertCount(2, $firstBody); // byte limit, not the count limit, governed here } + /** + * A request whose JSON is exactly 2 bytes under the payload limit must be accepted: as the sole + * member of its own single-item batch body ("[" + json + "]"), it exactly fills the limit. Matches + * the reference client's exact `byteLength + 2 > limit` accounting (ported from `#1051`); a + * stricter `+1` margin would reject this request even though it fits. + */ + public function testRequestExactlyAtThePerItemSizeBoundaryIsAccepted(): void + { + $limitBytes = 9 * 1024 * 1024 - (int) ceil(9 * 1024 * 1024 * 0.0001); + + $probe = new RequestQueueRequest('https://x/boundary', 'boundary'); + $baseLen = strlen(Json::encode($probe->toArray())); + // Grow `userData.pad` until the request's JSON is exactly 2 bytes under the limit. + $probe->setUserData(['pad' => '']); + $overhead = strlen(Json::encode($probe->toArray())) - $baseLen; + $padLen = $limitBytes - 2 - $baseLen - $overhead; + $probe->setUserData(['pad' => str_repeat('x', $padLen)]); + self::assertSame($limitBytes - 2, strlen(Json::encode($probe->toArray())), 'test setup: probe must land exactly 2 bytes under the limit'); + + $transport = (new MockTransport())->queueResponse(200, $this->batchResponse(['boundary'])); + $result = $this->client($transport)->requestQueue('q1')->batchAddRequests([$probe]); + + self::assertCount(1, $result->getProcessedRequests()); + } + + /** One byte over the boundary above, the same request must be rejected. */ + public function testRequestOneByteOverThePerItemSizeBoundaryThrows(): void + { + $limitBytes = 9 * 1024 * 1024 - (int) ceil(9 * 1024 * 1024 * 0.0001); + + $probe = new RequestQueueRequest('https://x/boundary', 'boundary'); + $baseLen = strlen(Json::encode($probe->toArray())); + $probe->setUserData(['pad' => '']); + $overhead = strlen(Json::encode($probe->toArray())) - $baseLen; + $padLen = $limitBytes - 1 - $baseLen - $overhead; + $probe->setUserData(['pad' => str_repeat('x', $padLen)]); + self::assertSame($limitBytes - 1, strlen(Json::encode($probe->toArray())), 'test setup: probe must land exactly 1 byte under the limit'); + + $this->expectException(InvalidArgumentException::class); + $this->client(new MockTransport())->requestQueue('q1')->batchAddRequests([$probe]); + } + public function testOversizedSingleRequestThrows(): void { $huge = str_repeat('x', 10 * 1024 * 1024); // > 9 MiB on its own diff --git a/tests/Unit/TimeoutTiersTest.php b/tests/Unit/TimeoutTiersTest.php index fef4475..bb9b318 100644 --- a/tests/Unit/TimeoutTiersTest.php +++ b/tests/Unit/TimeoutTiersTest.php @@ -6,6 +6,7 @@ use Apify\Client\ApifyClient; use Apify\Client\Internal\Json; +use Apify\Client\Options\RequestQueueClientOptions; use PHPUnit\Framework\TestCase; /** @@ -92,6 +93,39 @@ public function testTimeoutMaxSecsCapsAConfiguredTier(): void self::assertSame([60.0], $transport->timeouts); } + public function testNoTimeoutSendsZeroAsTheRequestTimeout(): void + { + // Guzzle (and the cURL option it sets) treats a 0.0 request timeout as "wait indefinitely", + // so 'noTimeout' must reach the transport as 0.0, not be silently coalesced back to a default. + $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['id' => 'a1']])); + $client = new ApifyClient(token: 't', timeoutSecs: 300, httpClient: $transport); + + $client->actor('a1')->get(timeoutSecs: 'noTimeout'); + + self::assertSame([0.0], $transport->timeouts); + } + + public function testPerQueueTimeoutCapsEvenAnExplicitNoTimeoutOverride(): void + { + $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['items' => []]])); + $client = new ApifyClient(token: 't', timeoutSecs: 300, httpClient: $transport); + + $client->requestQueue('q1', new RequestQueueClientOptions(timeoutSecs: 5.0))->get(timeoutSecs: 'noTimeout'); + + self::assertSame([5.0], $transport->timeouts); + } + + public function testPerQueueTimeoutCapsTheResolvedTierButDoesNotOverrideASmallerPerCallValue(): void + { + $transport = (new MockTransport())->queueResponse(200, Json::encode(['data' => ['items' => []]])); + $client = new ApifyClient(token: 't', timeoutSecs: 300, httpClient: $transport); + + // The per-queue cap (5s) is above the explicit per-call override (2s), so the smaller value wins. + $client->requestQueue('q1', new RequestQueueClientOptions(timeoutSecs: 5.0))->get(timeoutSecs: 2); + + self::assertSame([2.0], $transport->timeouts); + } + public function testUpdateAndDeleteAcceptThePerCallOverrideToo(): void { $transport = (new MockTransport()) From 53130168a1de0d9b773e64d4eb503756777108e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 20:40:33 +0000 Subject: [PATCH 5/5] fix: satisfy phpstan on new test helpers CI's `composer stan` (phpstan, unavailable in this sandbox via the usual composer install path) caught two real static-analysis errors in the test files added for this PR: - AmbiguousNotFoundTest::statusClassProvider()'s array used numeric-string keys ('400', '401', ...), which PHP auto-casts to int keys, so the inferred type didn't match the declared array docblock. Switched to non-numeric keys ('status 400', ...). - CompressionConfigTest::largeBody() was missing a value type on its array return type. Verified by downloading phpstan.phar directly (matching CI's locked 1.12.33) and running `phpstan analyse`: 0 errors. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01STUGToGEaEFoBGRbWTvF4m --- tests/Unit/AmbiguousNotFoundTest.php | 18 ++++++++++-------- tests/Unit/CompressionConfigTest.php | 1 + 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/Unit/AmbiguousNotFoundTest.php b/tests/Unit/AmbiguousNotFoundTest.php index 47a0ef7..8e9a70a 100644 --- a/tests/Unit/AmbiguousNotFoundTest.php +++ b/tests/Unit/AmbiguousNotFoundTest.php @@ -42,15 +42,17 @@ private function notFoundBody(string $type = 'record-not-found'): string /** @return array}> */ public static function statusClassProvider(): array { + // Non-numeric keys: a plain '400' etc. would be auto-cast to an int array key by PHP, + // which would make this a list<...> rather than the declared array. return [ - '400' => [400, InvalidRequestException::class], - '401' => [401, UnauthorizedException::class], - '403' => [403, ForbiddenException::class], - '404' => [404, NotFoundException::class], - '409' => [409, ConflictException::class], - '429' => [429, RateLimitException::class], - '500' => [500, ServerException::class], - '502' => [502, ServerException::class], + 'status 400' => [400, InvalidRequestException::class], + 'status 401' => [401, UnauthorizedException::class], + 'status 403' => [403, ForbiddenException::class], + 'status 404' => [404, NotFoundException::class], + 'status 409' => [409, ConflictException::class], + 'status 429' => [429, RateLimitException::class], + 'status 500' => [500, ServerException::class], + 'status 502' => [502, ServerException::class], ]; } diff --git a/tests/Unit/CompressionConfigTest.php b/tests/Unit/CompressionConfigTest.php index 6a3f1cd..e0afea7 100644 --- a/tests/Unit/CompressionConfigTest.php +++ b/tests/Unit/CompressionConfigTest.php @@ -21,6 +21,7 @@ final class CompressionConfigTest extends TestCase { private const LARGE_BODY_FIELD = 'blob'; // forces the JSON body above the compression threshold + /** @return array */ private function largeBody(): array { return [self::LARGE_BODY_FIELD => str_repeat('payload-', 500)];