Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Changelog

## 0.10.0

- Synced with Apify OpenAPI spec `v2-2026-10-19T133704Z` (realigned Actor, version, env var and
schedule request schemas; no client code changes required, since those bodies were already passed
through untyped).
- Added `DatasetClient::streamItems()`, returning a stream over a dataset items export instead of
buffering it fully in memory, matching the reference client's streamed export.
- Added a `$contentEncoding` parameter to `KeyValueStoreClient::setRecord()`, so an already-compressed
value (gzip, br, ...) is uploaded as is instead of being compressed a second time.
- Added `ApifyClientException`, a common base class for every error the client throws for a failed API
interaction. `ApifyApiException` (and its status subclasses) now extend it.
- Added `InvalidResponseBodyException` (extends `ApifyClientException`), thrown instead of a raw
`JsonException` when a successful response body fails to parse as JSON. For an endpoint whose body
is decoded as JSON, this is now retried with the same backoff as a retryable status code, matching
the reference client.

## 0.9.0

- Synced with Apify OpenAPI spec `v2-2026-10-08T073338Z`.
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,25 @@ exhausted does the underlying error surface, as an `Apify\Client\Exception\Trans
`TransportException` means the request never produced a usable response (network failure or timeout)
after all retries. Requests are retried on network errors, HTTP 429 and 5xx.

A successful (2xx) response whose body does not parse as JSON (e.g. a connection dropped
mid-response) is retried the same way, for an endpoint whose body this client decodes as JSON; only
once those retries are exhausted does it surface, as `Apify\Client\Exception\InvalidResponseBodyException`.

Every error the client throws for a failed API interaction — `ApifyApiException` and its status
subclasses above, plus `InvalidResponseBodyException` — extends the common
`Apify\Client\Exception\ApifyClientException`, so one `catch` can handle both:

```php
try {
$client->actor('my-actor')->update(['title' => 'x']);
} catch (ApifyClientException $e) {
// Either the API returned an error response, or its body could not be parsed.
}
```

`ArgumentValidationError`-style invalid-argument errors (built-in `\InvalidArgumentException`s) and
`TransportException` stay outside this hierarchy, matching the reference client.

## Versioning

- `Apify\Client\Version::CLIENT_VERSION` — the semantic version of this library.
Expand Down
4 changes: 3 additions & 1 deletion docs/misc.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ $publicProfile = $client->user('some-username')->get();

## Logs — `$client->log($buildOrRunId)`

- `get(?LogOptions $options = null): ?string` — the full log as text.
- `get(?LogOptions $options = null): ?string` — the full log as text. A PHP string is already a byte
sequence and is never re-decoded, so this already returns the log's exact bytes — there is no
separate "raw bytes" mode to opt into.
- `stream(?LogOptions $options = null): StreamInterface` — a live stream of the log.

```php
Expand Down
5 changes: 5 additions & 0 deletions docs/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,11 @@ For `keyValueStore()->setRecord()`.
| `timeoutSecs` | `?int` | Per-request timeout for the upload (capped at the client's overall request timeout). |
| `doNotRetryTimeouts` | `bool` | If `true`, do not retry the upload on request timeout (default `false`). |

`setRecord()` also takes a trailing `?string $contentEncoding = null` parameter (not a field of
`SetRecordOptions`, which only holds the write-retry options above): set it to the encoding already
applied to `$value` (e.g. `'gzip'`) so the client's own request compression backs off and the bytes
are uploaded exactly as given.

## Request queues

### `RequestQueueClientOptions`
Expand Down
15 changes: 14 additions & 1 deletion docs/storages.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Single — `$client->dataset($id)`:
- `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. 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.
- `streamItems(DownloadItemsFormat $format, ?DatasetDownloadOptions $options = null): StreamInterface` — the same export as `downloadItems()`, as a PSR-7 stream instead of a fully-buffered string, so a large export can be read incrementally (e.g. piped to a file) without holding it all in memory. Bypasses the retrying transport, so a failed request is not retried.
- `pushItems(mixed $items): void`
- `getStatistics(): ?array` — throws if the dataset itself does not exist (a 404 here cannot mean
anything else).
Expand All @@ -36,6 +37,14 @@ $csv = $client->dataset($dataset->getId())->downloadItems(DownloadItemsFormat::C
foreach ($client->dataset($dataset->getId())->iterateItems(new DatasetListItemsOptions(), 1000) as $item) {
echo ($item['url'] ?? '') . PHP_EOL;
}

// Stream a large export straight to a file instead of buffering it in memory.
$stream = $client->dataset($dataset->getId())->streamItems(DownloadItemsFormat::CSV);
$out = fopen('output.csv', 'wb');
while (!$stream->eof()) {
fwrite($out, $stream->read(8192));
}
fclose($out);
```

## Key-value stores
Expand All @@ -53,7 +62,7 @@ Single — `$client->keyValueStore($id)`:
- `iterateKeys(?ListKeysOptions $options = null, ?int $chunkSize = null): iterable` — lazily iterate all keys, following cursor pagination (`exclusiveStartKey`/`nextExclusiveStartKey`). The options' `limit` caps the total number of keys yielded across all pages (unset = all); `$chunkSize` is the per-page size (unset = the remaining total cap, or the server default when unbounded).
- `recordExists(string $key): bool` — reports whether a record with the given key exists, without downloading its value (a `HEAD` request).
- `getRecord(string $key, ?GetRecordOptions $options = null): ?KeyValueStoreRecord`
- `setRecord(string $key, string $value, string $contentType, ?SetRecordOptions $options = null): void`
- `setRecord(string $key, string $value, string $contentType, ?SetRecordOptions $options = null, ?string $contentEncoding = null): void` — `$contentEncoding`, if given, is sent as the `Content-Encoding` header, for a `$value` that is already compressed (gzip, br, ...); the client's own best-effort request compression always backs off when `Content-Encoding` is already set, so the bytes reach the API exactly as given.
- `setRecordJson(string $key, mixed $value): void` — convenience over `setRecord()` that JSON-encodes `$value` and stores it with a JSON content type.
- `deleteRecord(string $key): void` — permanently removes the record with the given key.
- `getRecordPublicUrl(string $key): string` — builds a shareable URL for downloading a single record; for a private store it appends an access signature so the URL works without an API token.
Expand All @@ -71,6 +80,10 @@ echo ($decoded['answer'] ?? '') . PHP_EOL;
foreach ($client->keyValueStore($store->getId())->iterateKeys() as $key) {
echo $key->getKey() . PHP_EOL;
}

// Upload an already-gzipped value without the client compressing it a second time.
$gzipped = gzencode(json_encode(['answer' => 42]));
$client->keyValueStore($store->getId())->setRecord('OUTPUT.json', $gzipped, 'application/json', contentEncoding: 'gzip');
```

## Request queues
Expand Down
7 changes: 3 additions & 4 deletions src/Exception/ApifyApiException.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,16 @@

namespace Apify\Client\Exception;

use RuntimeException;

/**
* Thrown for HTTP requests that reach the Apify API but receive a non-success status code.
*
* It mirrors the {@code ApifyApiError} of the reference JavaScript client and exposes the parsed
* error {@see getType() type}, the human-readable {@see getMessage() message}, the HTTP
* {@see getStatusCode() status code}, the number of the final {@see getAttempt() attempt}, and the
* request {@see getHttpMethod() method}/{@see getPath() path}.
* request {@see getHttpMethod() method}/{@see getPath() path}. Extends {@see ApifyClientException},
* the common base every client error shares.
*/
class ApifyApiException extends RuntimeException
class ApifyApiException extends ApifyClientException
{
private int $statusCode;
private ?string $type;
Expand Down
22 changes: 22 additions & 0 deletions src/Exception/ApifyClientException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Apify\Client\Exception;

use RuntimeException;

/**
* Base class of every error the client throws for a failed API interaction: {@see ApifyApiException}
* and its HTTP-status subclasses, and {@see InvalidResponseBodyException}. A single {@code instanceof
* ApifyClientException} check catches all of them, mirroring the reference JS client's
* {@code ApifyClientError}.
*
* An invalid argument throws a built-in {@code \InvalidArgumentException} before any request is sent,
* and a network/timeout failure surfaces as {@see TransportException}; neither extends this class,
* matching the reference client, where argument validation errors and transport errors also stay
* outside {@code ApifyClientError}.
*/
class ApifyClientException extends RuntimeException
{
}
32 changes: 32 additions & 0 deletions src/Exception/InvalidResponseBodyException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace Apify\Client\Exception;

use JsonException;

/**
* Thrown when a successful (2xx) response body does not parse as valid JSON, typically a document cut
* short by a connection dropped mid-response. Mirrors the reference client's
* {@code InvalidResponseBodyError}. See {@see \Apify\Client\Internal\HttpClientCore::call()}'s
* {@code $validateBody} param for why this is retried like a retryable status code instead of thrown on
* the first occurrence.
*/
final class InvalidResponseBodyException extends ApifyClientException
{
/** The response body exactly as received, for diagnosing the parse failure. */
private string $rawBody;

public function __construct(string $rawBody, JsonException $cause)
{
parent::__construct(sprintf("Response body could not be parsed as JSON.\nCause: %s", $cause->getMessage()), 0, $cause);
$this->rawBody = $rawBody;
}

/** The response body exactly as received, for diagnosing the parse failure. */
public function getRawBody(): string
{
return $this->rawBody;
}
}
35 changes: 31 additions & 4 deletions src/Internal/HttpClientCore.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Apify\Client\Exception\ConflictException;
use Apify\Client\Exception\ForbiddenException;
use Apify\Client\Exception\InvalidRequestException;
use Apify\Client\Exception\InvalidResponseBodyException;
use Apify\Client\Exception\NotFoundException;
use Apify\Client\Exception\RateLimitException;
use Apify\Client\Exception\ServerException;
Expand Down Expand Up @@ -83,6 +84,13 @@ public function timeoutTiers(): TimeoutTiers
* Sends a request with auth, User-Agent and the retry policy applied.
*
* @param array<string,string> $extraHeaders
* @param (callable(string): mixed)|null $validateBody For an endpoint whose successful response body this
* client decodes as JSON, the decoding step itself (e.g. {@see \Apify\Client\Internal\Json::decode()}),
* run here against the buffered body before this method returns, so a body that fails to parse —
* usually a connection dropped mid-response — is retried exactly like a retryable status code, instead
* of surfacing on the first occurrence. Matches the reference client, which parses a (non-streamed)
* response body inside its own retry loop for the same reason. Omit it for an endpoint whose body this
* client does not decode (raw bytes/text, or a response it never reads), which has nothing to validate.
*/
public function call(
string $method,
Expand All @@ -91,7 +99,8 @@ public function call(
string $contentType = '',
?float $timeoutSecs = null,
bool $doNotRetryTimeouts = false,
array $extraHeaders = []
array $extraHeaders = [],
?callable $validateBody = null
): ResponseInterface {
// Compress the body once, up front, so every retry reuses the already-compressed payload.
[$body, $extraHeaders] = $this->maybeCompressBody($body, $contentType, $extraHeaders);
Expand All @@ -114,10 +123,28 @@ public function call(
);
$status = $response->getStatusCode();
if ($status < self::MAX_SUCCESS_STATUS) {
return $response;
if ($validateBody === null) {
return $response;
}
// Buffer the body into a string exactly once: a PSR-18 transport's body is not
// guaranteed seekable/re-readable (@see HttpClientInterface), so a second
// `(string) $response->getBody()` by the caller (every JSON call site does this to
// get the body it already validated here) could otherwise read past EOF and see
// '' instead of the real payload. The response handed back therefore carries a
// fresh, rewound stream built from that buffered string, which every PSR-7 stream
// factory implementation backs with a seekable resource.
$raw = (string) $response->getBody();
try {
$validateBody($raw);
return $response->withBody($this->streamFactory->createStream($raw));
} catch (InvalidResponseBodyException $e) {
$lastError = $e;
$retryable = true;
}
} else {
$lastError = self::buildApiError($status, (string) $response->getBody(), $attempt, $method, $path);
$retryable = self::isStatusRetryable($status);
}
$lastError = self::buildApiError($status, (string) $response->getBody(), $attempt, $method, $path);
$retryable = self::isStatusRetryable($status);
} catch (TransportException $e) {
$lastError = $e;
// Network/timeout failures are retryable, unless the caller opted out of retrying timeouts.
Expand Down
15 changes: 13 additions & 2 deletions src/Internal/Json.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Apify\Client\Internal;

use Apify\Client\Exception\InvalidResponseBodyException;
use JsonException;

/**
Expand All @@ -29,14 +30,24 @@ public static function encode(mixed $value): string
/**
* Decodes a JSON string into PHP values (objects become associative arrays).
*
* Every caller of this method (directly or via {@see decodeData()}) decodes a response body the
* client just received over the wire, never a value the caller constructed itself (that path is
* {@see encode()}), so a malformed body is wrapped as an {@see InvalidResponseBodyException} instead
* of leaking the raw {@see JsonException}. See {@see \Apify\Client\Internal\HttpClientCore::call()}'s
* {@code $validateBody} param for why this is retried rather than thrown on the first occurrence.
*
* @return mixed the decoded value
*/
public static function decode(string $body): mixed
{
if ($body === '') {
return null;
}
return json_decode($body, true, self::MAX_JSON_DEPTH, JSON_THROW_ON_ERROR);
try {
return json_decode($body, true, self::MAX_JSON_DEPTH, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new InvalidResponseBodyException($body, $e);
}
}

/**
Expand All @@ -59,7 +70,7 @@ public static function tryDecode(string $body): mixed
{
try {
return self::decode($body);
} catch (JsonException) {
} catch (InvalidResponseBodyException) {
return null;
}
}
Expand Down
Loading
Loading