From aa3a031ce1c7421c7e04c449712963124fb8747f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 15:02:13 +0000 Subject: [PATCH 1/5] feat: sync PHP client with Apify OpenAPI spec v2-2026-10-20T121847Z and reference client Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01UUMWnU4gH573HweHieMhHi --- CHANGELOG.md | 12 +++++++ README.md | 8 +++-- docs/options.md | 1 + .../InvalidResponseBodyException.php | 9 +++-- src/Internal/HttpClientCore.php | 20 +++++------ src/Options/RunChargeOptions.php | 6 ++++ src/Resource/RunClient.php | 3 ++ src/Version.php | 4 +-- tests/Unit/HttpClientTest.php | 34 +++++++++++-------- tests/Unit/RequestShapeTest.php | 13 +++++++ 10 files changed, 74 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5935808..f9c41c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.11.0 + +- Synced with Apify OpenAPI spec `v2-2026-10-20T121847Z` (nullable build `buildTag`, new error types, + rental pricing marked deprecated; no client code changes required, since those models are read + untyped). +- Added a `requestId` option to `RunChargeOptions`, sent as `requestId` in the `charge()` request body: + the ID of the Server Actor request being charged, required for multi-tenant Server Actor runs. +- **Breaking:** a successful response whose body fails to parse as JSON now throws + `InvalidResponseBodyException` on the first attempt instead of being retried, since the body arrived + in full and a retry would return the same bytes. An error response whose body fails to parse throws + the `ApifyApiException` subclass for its status, as before. + ## 0.10.0 - Synced with Apify OpenAPI spec `v2-2026-10-19T133704Z` (realigned Actor, version, env var and diff --git a/README.md b/README.md index 94a4277..560aaf3 100644 --- a/README.md +++ b/README.md @@ -206,9 +206,11 @@ 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`. +A successful (2xx) response whose body does not parse as JSON, for an endpoint whose body this client +decodes as JSON, is not retried (it arrived in full, so a retry would get the same body back) and throws +`Apify\Client\Exception\InvalidResponseBodyException` on the first attempt. An error response whose body +does not parse (e.g. a gateway's HTML 502 page) throws the `ApifyApiException` subclass for its status, +with the raw body in the message, and is retried by status like any other error response. Every error the client throws for a failed API interaction — `ApifyApiException` and its status subclasses above, plus `InvalidResponseBodyException` — extends the common diff --git a/docs/options.md b/docs/options.md index d34bfc7..a689844 100644 --- a/docs/options.md +++ b/docs/options.md @@ -154,6 +154,7 @@ For `run()->charge()` (pay-per-event Actors). `eventName` is **required**. | `eventName` | `string` | Name of the event to charge for. | | `count` | `?int` | Number of times to charge the event (defaults to 1). | | `idempotencyKey` | `?string` | Deduplicates the charge across retries; auto-generated if unset. Expires 3 minutes after the charge — reusing it later creates a new charge rather than deduplicating. | +| `requestId` | `?string` | ID of the Server Actor request being charged, from the `X-Actor-Request-ID` header of the incoming request. Required for multi-tenant Server Actor runs. | ## Builds diff --git a/src/Exception/InvalidResponseBodyException.php b/src/Exception/InvalidResponseBodyException.php index 0c55756..ec7d3d7 100644 --- a/src/Exception/InvalidResponseBodyException.php +++ b/src/Exception/InvalidResponseBodyException.php @@ -7,11 +7,10 @@ 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. + * Thrown when the body of a successful (2xx) response does not parse as valid JSON, for example a + * non-JSON value served as {@code application/json}. The response arrived in full, so the request is not + * retried. An error status with an unparsable body throws {@see ApifyApiException} for its status code + * instead. Mirrors the reference client's {@code InvalidResponseBodyError}. */ final class InvalidResponseBodyException extends ApifyClientException { diff --git a/src/Internal/HttpClientCore.php b/src/Internal/HttpClientCore.php index 2a65e2e..7cf3a1e 100644 --- a/src/Internal/HttpClientCore.php +++ b/src/Internal/HttpClientCore.php @@ -86,11 +86,12 @@ public function timeoutTiers(): TimeoutTiers * @param array $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. + * run here against the buffered body before this method returns. A body that fails to parse throws + * {@see InvalidResponseBodyException} on the first attempt without a retry: a transfer cut short + * fails in the transport (and is retried as a transport error), so a returned body arrived in full + * and a retry would get the same bytes back. Matches the reference client. 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, @@ -134,13 +135,8 @@ public function call( // 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; - } + $validateBody($raw); + return $response->withBody($this->streamFactory->createStream($raw)); } else { $lastError = self::buildApiError($status, (string) $response->getBody(), $attempt, $method, $path); $retryable = self::isStatusRetryable($status); diff --git a/src/Options/RunChargeOptions.php b/src/Options/RunChargeOptions.php index 0f59d8f..18d265f 100644 --- a/src/Options/RunChargeOptions.php +++ b/src/Options/RunChargeOptions.php @@ -19,6 +19,12 @@ public function __construct( * charge instead of being deduplicated against the original. */ public readonly ?string $idempotencyKey = null, + /** + * The ID of the Server Actor request being charged, taken from the {@code X-Actor-Request-ID} + * header of the incoming request. Required for multi-tenant Server Actor runs, where it maps the + * charge to the caller's account instead of the account that started the run. Ignored elsewhere. + */ + public readonly ?string $requestId = null, ) { } diff --git a/src/Resource/RunClient.php b/src/Resource/RunClient.php index f108305..657550f 100644 --- a/src/Resource/RunClient.php +++ b/src/Resource/RunClient.php @@ -167,6 +167,9 @@ public function charge(RunChargeOptions $options, int|float|string|null $timeout $idempotencyKey = $this->generateIdempotencyKey($options->eventName); } $body = ['eventName' => $options->eventName, 'count' => $options->countValue()]; + if ($options->requestId !== null) { + $body['requestId'] = $options->requestId; + } $this->http->call( 'POST', $this->ctx->subUrl('charge'), diff --git a/src/Version.php b/src/Version.php index b25a77a..b001b2d 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.10.0'; + public const CLIENT_VERSION = '0.11.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-10-19T133704Z'; + public const API_SPEC_VERSION = 'v2-2026-10-20T121847Z'; private function __construct() { diff --git a/tests/Unit/HttpClientTest.php b/tests/Unit/HttpClientTest.php index 6eac724..1bf18fd 100644 --- a/tests/Unit/HttpClientTest.php +++ b/tests/Unit/HttpClientTest.php @@ -7,6 +7,7 @@ use Apify\Client\ApifyClient; use Apify\Client\Exception\ApifyApiException; use Apify\Client\Exception\InvalidResponseBodyException; +use Apify\Client\Exception\ServerException; use Apify\Client\Internal\Json; use Apify\Client\Options\DatasetListItemsOptions; use GuzzleHttp\Psr7\NoSeekStream; @@ -80,32 +81,37 @@ public function testValidationErrorIsNotRetriedAndThrows(): void } } - public function testMalformedJsonBodyIsRetriedThenSucceeds(): void + public function testMalformedJsonBodyOnSuccessThrowsWithoutRetry(): void { - // A successful (2xx) response whose body fails to parse as JSON is retried exactly like a - // retryable status code — matching the reference client, which parses the body inside its own - // retry loop for the same reason (a connection dropped mid-response is usually fixed by a retry). + // A successful (2xx) response arrived in full (a transfer cut short fails in the transport + // instead), so retrying would get the same unparsable body back. Matches the reference client. $transport = (new MockTransport()) ->queueResponse(200, 'not valid JSON {') - ->queueResponse(200, Json::encode(['data' => ['id' => 'recovered']])); + ->queueResponse(200, Json::encode(['data' => ['id' => 'unused']])); - $actor = $this->client($transport)->actor('x')->get(); - self::assertSame('recovered', $actor?->getId()); - self::assertSame(2, $transport->callCount()); + try { + $this->client($transport)->actor('x')->get(); + self::fail('expected InvalidResponseBodyException'); + } catch (InvalidResponseBodyException $e) { + self::assertSame('not valid JSON {', $e->getRawBody()); + self::assertSame(1, $transport->callCount()); + } } - public function testMalformedJsonBodyThrowsOnceRetriesAreExhausted(): void + public function testErrorStatusWithUnparsableBodyIsRetriedAndThrowsItsStatusException(): void { + // A gateway's HTML error page is reported by its status code, with the raw body as the message. $transport = (new MockTransport()) - ->queueResponse(200, 'not valid JSON {') - ->queueResponse(200, 'still not valid JSON {'); + ->queueResponse(502, 'Bad Gateway') + ->queueResponse(502, 'Bad Gateway'); $client = new ApifyClient(token: 'test-token', maxRetries: 1, minDelayBetweenRetriesMillis: 1, timeoutSecs: 5, httpClient: $transport); try { $client->actor('x')->get(); - self::fail('expected InvalidResponseBodyException'); - } catch (InvalidResponseBodyException $e) { - self::assertSame('still not valid JSON {', $e->getRawBody()); + self::fail('expected ServerException'); + } catch (ServerException $e) { + self::assertSame(502, $e->getStatusCode()); + self::assertStringContainsString('Bad Gateway', $e->getApiMessage()); self::assertSame(2, $transport->callCount()); } } diff --git a/tests/Unit/RequestShapeTest.php b/tests/Unit/RequestShapeTest.php index d98cfe1..8919441 100644 --- a/tests/Unit/RequestShapeTest.php +++ b/tests/Unit/RequestShapeTest.php @@ -39,6 +39,19 @@ public function testRunChargeSendsBodyAndIdempotencyKey(): void self::assertSame(['eventName' => 'result', 'count' => 3], Json::decode((string) $request->getBody())); } + public function testRunChargeSendsRequestIdOnlyWhenProvided(): void + { + $transport = (new MockTransport())->queueResponse(200, '')->queueResponse(200, ''); + $this->client($transport)->run('run1')->charge(new RunChargeOptions(eventName: 'e', requestId: 'req-1')); + self::assertSame( + ['eventName' => 'e', 'count' => 1, 'requestId' => 'req-1'], + Json::decode((string) $transport->lastRequest()->getBody()) + ); + + $this->client($transport)->run('run1')->charge(new RunChargeOptions(eventName: 'e')); + self::assertSame(['eventName' => 'e', 'count' => 1], Json::decode((string) $transport->lastRequest()->getBody())); + } + public function testRunChargeUsesProvidedIdempotencyKey(): void { $transport = (new MockTransport())->queueResponse(200, ''); From 3c1e136020b4bd52bc7abed4de153c8b159a732d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 15:18:44 +0000 Subject: [PATCH 2/5] fix: address review of spec sync (webhook tests, drop double body parse, PSR-18 body buffering) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01UUMWnU4gH573HweHieMhHi --- CHANGELOG.md | 16 +- docs/options.md | 2 +- src/Http/HttpClientInterface.php | 5 +- src/Http/Psr18HttpClient.php | 11 +- src/Internal/HttpClientCore.php | 31 +--- src/Internal/Json.php | 3 +- src/Internal/ResourceContext.php | 38 ++--- src/Options/StoreListOptions.php | 4 +- src/Resource/BuildClient.php | 2 +- src/Resource/DatasetClient.php | 4 +- src/Resource/RequestQueueClient.php | 5 +- src/Resource/RunClient.php | 2 +- src/Resource/TaskClient.php | 5 +- tests/Integration/WebhookIntegrationTest.php | 157 +++++++++++-------- tests/Unit/HttpClientTest.php | 42 +++-- tests/Unit/StubPsr18Client.php | 25 +++ 16 files changed, 203 insertions(+), 149 deletions(-) create mode 100644 tests/Unit/StubPsr18Client.php diff --git a/CHANGELOG.md b/CHANGELOG.md index f9c41c9..8aad17b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,17 @@ ## 0.11.0 -- Synced with Apify OpenAPI spec `v2-2026-10-20T121847Z` (nullable build `buildTag`, new error types, - rental pricing marked deprecated; no client code changes required, since those models are read - untyped). +- Synced with Apify OpenAPI spec `v2-2026-10-20T121847Z`; `StoreListOptions::$pricingModel` no longer + lists the removed `FLAT_PRICE_PER_MONTH` value. - Added a `requestId` option to `RunChargeOptions`, sent as `requestId` in the `charge()` request body: the ID of the Server Actor request being charged, required for multi-tenant Server Actor runs. -- **Breaking:** a successful response whose body fails to parse as JSON now throws - `InvalidResponseBodyException` on the first attempt instead of being retried, since the body arrived - in full and a retry would return the same bytes. An error response whose body fails to parse throws - the `ApifyApiException` subclass for its status, as before. +- Fixed a successful response whose body fails to parse as JSON being retried: it now throws + `InvalidResponseBodyException` on the first attempt, since the body arrived in full and a retry would + return the same bytes. +- `Psr18HttpClient` now reads the response body inside `send()`, so a body cut off mid-transfer is + retried as a `TransportException` instead of surfacing as a raw `RuntimeException`. +- `RunClient::charge()` now defaults to the short request timeout tier, like the reference client + (previously medium). ## 0.10.0 diff --git a/docs/options.md b/docs/options.md index a689844..8fa8013 100644 --- a/docs/options.md +++ b/docs/options.md @@ -85,7 +85,7 @@ For `store()->list()` / `store()->iterate()`. | `sortBy` | `?string` | The sort field (e.g. `popularity`, `newest`). | | `category` | `?string` | Filter Actors by category. | | `username` | `?string` | Filter Actors by owner username. | -| `pricingModel` | `?string` | Filter by pricing model (`FREE`, `FLAT_PRICE_PER_MONTH`, `PRICE_PER_DATASET_ITEM`, `PAY_PER_EVENT`). | +| `pricingModel` | `?string` | Filter by pricing model (`FREE`, `PRICE_PER_DATASET_ITEM`, `PAY_PER_EVENT`). | | `includeUnrunnableActors` | `?bool` | Include Actors the current user cannot run. | | `allowsAgenticUsers` | `?bool` | Filter to Actors that allow agentic users. | | `responseFormat` | `?string` | The response format (`full`, `agent`). | diff --git a/src/Http/HttpClientInterface.php b/src/Http/HttpClientInterface.php index 242d38d..3fd0ce3 100644 --- a/src/Http/HttpClientInterface.php +++ b/src/Http/HttpClientInterface.php @@ -16,8 +16,9 @@ * round-trip. * * A non-2xx HTTP status is not an error at this layer — return it as a normal response. - * Only transport-level failures (connection refused, DNS, timeout) should be thrown, as an - * {@see \Apify\Client\Exception\TransportException}. + * Only transport-level failures (connection refused, DNS, timeout, a connection dropped before the + * response body is complete) should be thrown, as an {@see \Apify\Client\Exception\TransportException}. + * The client takes a returned body as complete and does not retry one that fails to parse. * * Swap the default implementation ({@see GuzzleHttpClient}) via the {@code httpClient} constructor * argument of {@see \Apify\Client\ApifyClient} to share a connection pool, customize TLS/proxy diff --git a/src/Http/Psr18HttpClient.php b/src/Http/Psr18HttpClient.php index f575de2..cb53846 100644 --- a/src/Http/Psr18HttpClient.php +++ b/src/Http/Psr18HttpClient.php @@ -5,6 +5,7 @@ namespace Apify\Client\Http; use Apify\Client\Exception\TransportException; +use GuzzleHttp\Psr7\Utils; use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestInterface; @@ -17,6 +18,11 @@ * PSR-18 has no per-request timeout, so the {@code timeoutSecs} argument is ignored here — configure * the timeout on the wrapped PSR-18 client instead. Streaming falls back to a normal request (the * response body is still readable, just buffered by the underlying client). + * + * The body is read in full inside {@see send()}, since a PSR-18 client may return a body it reads lazily: + * a connection dropped mid-body then surfaces as a {@see TransportException} (which is retried) and not + * as a failure later, when the response is already taken as complete. The response is returned with a + * seekable buffered body. */ final class Psr18HttpClient implements HttpClientInterface { @@ -27,8 +33,9 @@ public function __construct(private ClientInterface $client) public function send(RequestInterface $request, float $timeoutSecs): ResponseInterface { try { - return $this->client->sendRequest($request); - } catch (ClientExceptionInterface $e) { + $response = $this->client->sendRequest($request); + return $response->withBody(Utils::streamFor($response->getBody()->getContents())); + } catch (ClientExceptionInterface | \RuntimeException $e) { throw new TransportException($e->getMessage(), $e); } } diff --git a/src/Internal/HttpClientCore.php b/src/Internal/HttpClientCore.php index 7cf3a1e..b66ddef 100644 --- a/src/Internal/HttpClientCore.php +++ b/src/Internal/HttpClientCore.php @@ -8,7 +8,6 @@ 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; @@ -84,14 +83,6 @@ public function timeoutTiers(): TimeoutTiers * Sends a request with auth, User-Agent and the retry policy applied. * * @param array $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. A body that fails to parse throws - * {@see InvalidResponseBodyException} on the first attempt without a retry: a transfer cut short - * fails in the transport (and is retried as a transport error), so a returned body arrived in full - * and a retry would get the same bytes back. Matches the reference client. 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, @@ -100,8 +91,7 @@ public function call( string $contentType = '', ?float $timeoutSecs = null, bool $doNotRetryTimeouts = false, - array $extraHeaders = [], - ?callable $validateBody = null + array $extraHeaders = [] ): ResponseInterface { // Compress the body once, up front, so every retry reuses the already-compressed payload. [$body, $extraHeaders] = $this->maybeCompressBody($body, $contentType, $extraHeaders); @@ -124,23 +114,10 @@ public function call( ); $status = $response->getStatusCode(); if ($status < self::MAX_SUCCESS_STATUS) { - 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(); - $validateBody($raw); - return $response->withBody($this->streamFactory->createStream($raw)); - } else { - $lastError = self::buildApiError($status, (string) $response->getBody(), $attempt, $method, $path); - $retryable = self::isStatusRetryable($status); + return $response; } + $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. diff --git a/src/Internal/Json.php b/src/Internal/Json.php index 236196f..f6cf88b 100644 --- a/src/Internal/Json.php +++ b/src/Internal/Json.php @@ -33,8 +33,7 @@ public static function encode(mixed $value): string * 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. + * of leaking the raw {@see JsonException}. A response body that arrived in full is not retried. * * @return mixed the decoded value */ diff --git a/src/Internal/ResourceContext.php b/src/Internal/ResourceContext.php index 676a02d..2bcf74f 100644 --- a/src/Internal/ResourceContext.php +++ b/src/Internal/ResourceContext.php @@ -166,7 +166,7 @@ public function getResource(string $subPath, QueryParams $params, ?float $timeou 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->effectiveTimeout($timeoutSecs), validateBody: Json::decode(...)); + $response = $this->http->call('GET', $url, timeoutSecs: $this->effectiveTimeout($timeoutSecs)); return Json::decodeData((string) $response->getBody()); } @@ -178,7 +178,7 @@ public function getResourceRequired(string $subPath, QueryParams $params, ?float 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->effectiveTimeout($timeoutSecs), validateBody: Json::decode(...)); + $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())); } @@ -347,7 +347,7 @@ public static function minLimit(?int $a, ?int $b): ?int 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->effectiveTimeout($timeoutSecs), validateBody: Json::decode(...)); + $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())); } @@ -368,8 +368,8 @@ public function getOrCreateNamed(?string $name, ?array $schema = null, ?float $t $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: $effectiveTimeout, validateBody: Json::decode(...)) - : $this->http->call('POST', $url, timeoutSecs: $effectiveTimeout, validateBody: Json::decode(...)); + ? $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())); } @@ -381,7 +381,7 @@ public function getOrCreateNamed(?string $name, ?array $schema = null, ?float $t 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->effectiveTimeout($timeoutSecs), validateBody: Json::decode(...)); + $response = $this->http->call('POST', $url, $body, $contentType, timeoutSecs: $this->effectiveTimeout($timeoutSecs)); return self::asArray(Json::decodeData((string) $response->getBody())); } @@ -394,7 +394,7 @@ public function postWithBody(string $subPath, QueryParams $params, ?string $body 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->effectiveTimeout($timeoutSecs), validateBody: Json::decode(...)); + $response = $this->http->call('POST', $url, $body, $contentType, timeoutSecs: $this->effectiveTimeout($timeoutSecs)); return Json::decode((string) $response->getBody()); } @@ -406,7 +406,7 @@ public function postWithBodyNoEnvelope(string $subPath, QueryParams $params, ?st 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->effectiveTimeout($timeoutSecs), validateBody: Json::decode(...)); + $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())); } @@ -414,13 +414,11 @@ public function deleteWithBody(string $subPath, QueryParams $params, mixed $body * 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()}). - * - * @param (callable(string): mixed)|null $validateBody See {@see getRawRequired()}. */ - public function getRaw(string $subPath, QueryParams $params, ?float $timeoutSecs = null, ?callable $validateBody = null): ?ResponseInterface + public function getRaw(string $subPath, QueryParams $params, ?float $timeoutSecs = null): ?ResponseInterface { try { - return $this->getRawRequired($subPath, $params, $timeoutSecs, $validateBody); + return $this->getRawRequired($subPath, $params, $timeoutSecs); } catch (ApifyApiException $e) { if (HttpClientCore::isNotFound($e) && $this->swallowsNotFound($subPath)) { return null; @@ -431,22 +429,12 @@ public function getRaw(string $subPath, QueryParams $params, ?float $timeoutSecs /** * GET returning the raw response (no data envelope). Propagates every error, including a 404. - * - * This primitive is shared by endpoints whose body the caller decodes as JSON afterward (e.g. - * {@see \Apify\Client\Resource\TaskClient::getInput()}) and ones that return raw bytes/text it - * never decodes (e.g. {@see \Apify\Client\Resource\ScheduleClient::getLog()}), so — unlike the - * JSON-returning primitives above, which always decode and can validate unconditionally — it - * cannot validate the body itself. A JSON-decoding caller passes its decode step (e.g. {@see - * \Apify\Client\Internal\Json::decode()}) as {@code $validateBody} so a malformed body is retried - * instead of failing on the first occurrence (see {@see HttpClientCore::call()}); a raw caller - * omits it. - * - * @param (callable(string): mixed)|null $validateBody + * The caller decodes the body itself, if it is JSON. */ - public function getRawRequired(string $subPath, QueryParams $params, ?float $timeoutSecs = null, ?callable $validateBody = null): 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->effectiveTimeout($timeoutSecs), validateBody: $validateBody); + return $this->http->call('GET', $url, timeoutSecs: $this->effectiveTimeout($timeoutSecs)); } /** diff --git a/src/Options/StoreListOptions.php b/src/Options/StoreListOptions.php index 10ca75c..aaa58a8 100644 --- a/src/Options/StoreListOptions.php +++ b/src/Options/StoreListOptions.php @@ -26,8 +26,8 @@ public function __construct( /** Filter Actors by owner username. */ public readonly ?string $username = null, /** - * Filter Actors by pricing model ({@code FREE}, {@code FLAT_PRICE_PER_MONTH}, - * {@code PRICE_PER_DATASET_ITEM}, {@code PAY_PER_EVENT}). + * Filter Actors by pricing model ({@code FREE}, {@code PRICE_PER_DATASET_ITEM}, + * {@code PAY_PER_EVENT}). */ public readonly ?string $pricingModel = null, /** Include Actors the current user cannot run. */ diff --git a/src/Resource/BuildClient.php b/src/Resource/BuildClient.php index 49a4765..d381382 100644 --- a/src/Resource/BuildClient.php +++ b/src/Resource/BuildClient.php @@ -78,7 +78,7 @@ public function waitForFinish(?int $waitSecs = null): Build */ public function getOpenApiDefinition(int|float|string|null $timeoutSecs = null): ?array { - $response = $this->ctx->getRawRequired('openapi.json', new QueryParams(), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM), Json::decode(...)); + $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/DatasetClient.php b/src/Resource/DatasetClient.php index 1723a86..e83e869 100644 --- a/src/Resource/DatasetClient.php +++ b/src/Resource/DatasetClient.php @@ -198,7 +198,7 @@ private function fetchItemsPage(DatasetListItemsOptions $options, ?float $timeou $params = new QueryParams(); $options->appendTo($params); $url = $this->ctx->mergedParams($params)->applyToUrl($this->ctx->subUrl('items')); - $response = $this->http->call('GET', $url, timeoutSecs: $timeoutSecs, validateBody: Json::decode(...)); + $response = $this->http->call('GET', $url, timeoutSecs: $timeoutSecs); $items = Json::decode((string) $response->getBody()); $items = is_array($items) ? array_values($items) : []; @@ -293,7 +293,7 @@ public function pushItems(mixed $items, int|float|string|null $timeoutSecs = nul */ public function getStatistics(int|float|string|null $timeoutSecs = null): ?array { - $response = $this->ctx->getRawRequired('statistics', new QueryParams(), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT), Json::decode(...)); + $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/RequestQueueClient.php b/src/Resource/RequestQueueClient.php index a9e69e0..482573f 100644 --- a/src/Resource/RequestQueueClient.php +++ b/src/Resource/RequestQueueClient.php @@ -191,8 +191,7 @@ public function updateRequest(RequestQueueRequest $request, bool $forefront = fa $url, Json::encode($request->toArray()), ResourceContext::CONTENT_TYPE_JSON, - timeoutSecs: $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM), - validateBody: Json::decode(...) + timeoutSecs: $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM) ); $data = Json::decodeData((string) $response->getBody()); return new RequestQueueOperationInfo(is_array($data) ? $data : []); @@ -517,7 +516,7 @@ public function prolongRequestLock( $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->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM), validateBody: Json::decode(...)); + $response = $this->http->call('PUT', $url, null, '', timeoutSecs: $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM)); return RequestLockInfo::fromData(Json::decodeData((string) $response->getBody())); } diff --git a/src/Resource/RunClient.php b/src/Resource/RunClient.php index 657550f..562ca17 100644 --- a/src/Resource/RunClient.php +++ b/src/Resource/RunClient.php @@ -175,7 +175,7 @@ public function charge(RunChargeOptions $options, int|float|string|null $timeout $this->ctx->subUrl('charge'), Json::encode($body), ResourceContext::CONTENT_TYPE_JSON, - $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_MEDIUM), + $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT), false, [self::CHARGE_IDEMPOTENCY_HEADER => $idempotencyKey] ); diff --git a/src/Resource/TaskClient.php b/src/Resource/TaskClient.php index ea09ac0..a9b3b17 100644 --- a/src/Resource/TaskClient.php +++ b/src/Resource/TaskClient.php @@ -139,7 +139,7 @@ public function call(mixed $input = null, ?TaskStartOptions $options = null, ?in */ public function getInput(int|float|string|null $timeoutSecs = null): mixed { - $response = $this->ctx->getRawRequired('input', new QueryParams(), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT), Json::decode(...)); + $response = $this->ctx->getRawRequired('input', new QueryParams(), $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT)); return Json::decode((string) $response->getBody()); } @@ -156,8 +156,7 @@ public function updateInput(mixed $input, int|float|string|null $timeoutSecs = n $this->ctx->subUrl('input'), Json::encode($input), ResourceContext::CONTENT_TYPE_JSON, - $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT), - validateBody: Json::decode(...) + $this->ctx->resolveTimeout($timeoutSecs, TimeoutTiers::TIER_SHORT) ); return Json::decode((string) $response->getBody()); } diff --git a/tests/Integration/WebhookIntegrationTest.php b/tests/Integration/WebhookIntegrationTest.php index 107c0c8..8c92a64 100644 --- a/tests/Integration/WebhookIntegrationTest.php +++ b/tests/Integration/WebhookIntegrationTest.php @@ -4,19 +4,42 @@ namespace Apify\Client\Tests\Integration; +use Apify\Client\ApifyClient; use Apify\Client\Options\ListOptions; final class WebhookIntegrationTest extends IntegrationTestCase { + /** + * Runs {@code $body} with the ID of a task that only this test owns, deleting the task afterwards. + * A webhook conditioned on it can fire only for runs of that task, which nothing else starts. The + * API rejects an ad hoc webhook whose run does not exist, so these tests use persistent webhooks. + * + * @param callable(string): void $body + */ + private function withConditionTask(ApifyClient $client, callable $body): void + { + $task = $client->tasks()->create([ + 'actId' => 'apify/hello-world', + 'name' => self::uniqueName('webhook-task'), + 'options' => ['build' => 'latest', 'memoryMbytes' => 256, 'timeoutSecs' => 60], + 'input' => ['message' => 'hello'], + ]); + $taskId = (string) $task->getId(); + try { + $body($taskId); + } finally { + $client->task($taskId)->delete(); + } + } + /** * @return array */ - private static function webhookDef(string $url): array + private static function webhookDef(string $taskId, string $url): array { return [ - 'isAdHoc' => true, 'eventTypes' => ['ACTOR.RUN.SUCCEEDED'], - 'condition' => ['actorRunId' => 'ZZZZZZZZZZZZZZZZZ'], + 'condition' => ['actorTaskId' => $taskId], 'requestUrl' => $url, ]; } @@ -42,86 +65,96 @@ public function testListWebhookDispatches(): void public function testIterateWebhooks(): void { $client = $this->requireClient(); - $ids = []; - for ($i = 0; $i < 3; $i++) { - $ids[] = (string) $client->webhooks()->create(self::webhookDef('https://example.com/iter-' . $i))->getId(); - } - try { - // Retried with backoff: webhook listing is eventually consistent under concurrent load. - self::assertEventuallyIterated($ids, static function () use ($client): array { - $seen = []; - foreach ($client->webhooks()->iterate(new ListOptions(desc: true), 2) as $webhook) { - $seen[(string) $webhook->getId()] = true; + $this->withConditionTask($client, static function (string $taskId) use ($client): void { + $ids = []; + try { + for ($i = 0; $i < 3; $i++) { + $ids[] = (string) $client->webhooks()->create(self::webhookDef($taskId, 'https://example.com/iter-' . $i))->getId(); + } + // Retried with backoff: webhook listing is eventually consistent under concurrent load. + self::assertEventuallyIterated($ids, static function () use ($client): array { + $seen = []; + foreach ($client->webhooks()->iterate(new ListOptions(desc: true), 2) as $webhook) { + $seen[(string) $webhook->getId()] = true; + } + return $seen; + }, 'webhook'); + } finally { + foreach ($ids as $id) { + $client->webhook($id)->delete(); } - return $seen; - }, 'webhook'); - } finally { - foreach ($ids as $id) { - $client->webhook($id)->delete(); } - } + }); } public function testIterateWebhookDispatches(): void { $client = $this->requireClient(); - $wh = $client->webhooks()->create(self::webhookDef('https://example.com/dispatch-iter')); - try { - // test() synchronously creates an ad-hoc dispatch listed under the webhook. - $dispatch = $client->webhook((string) $wh->getId())->test(); - // Retried with backoff: dispatch listing is eventually consistent under concurrent load. - self::assertEventuallyIterated([(string) $dispatch->getId()], static function () use ($client, $wh): array { - $seen = []; - foreach ($client->webhook((string) $wh->getId())->dispatches()->iterate(new ListOptions(), 2) as $d) { - $seen[(string) $d->getId()] = true; - } - return $seen; - }, 'dispatch'); - } finally { - $client->webhook((string) $wh->getId())->delete(); - } + $this->withConditionTask($client, static function (string $taskId) use ($client): void { + $wh = $client->webhooks()->create(self::webhookDef($taskId, 'https://example.com/dispatch-iter')); + try { + // test() synchronously creates an ad-hoc dispatch listed under the webhook. + $dispatch = $client->webhook((string) $wh->getId())->test(); + // Retried with backoff: dispatch listing is eventually consistent under concurrent load. + self::assertEventuallyIterated([(string) $dispatch->getId()], static function () use ($client, $wh): array { + $seen = []; + foreach ($client->webhook((string) $wh->getId())->dispatches()->iterate(new ListOptions(), 2) as $d) { + $seen[(string) $d->getId()] = true; + } + return $seen; + }, 'dispatch'); + } finally { + $client->webhook((string) $wh->getId())->delete(); + } + }); } public function testGetWebhook(): void { $client = $this->requireClient(); - $wh = $client->webhooks()->create(self::webhookDef('https://example.com/webhook')); - try { - $got = $client->webhook((string) $wh->getId())->get(); - self::assertNotNull($got); - self::assertSame($wh->getId(), $got->getId()); - } finally { - $client->webhook((string) $wh->getId())->delete(); - } + $this->withConditionTask($client, static function (string $taskId) use ($client): void { + $wh = $client->webhooks()->create(self::webhookDef($taskId, 'https://example.com/webhook')); + try { + $got = $client->webhook((string) $wh->getId())->get(); + self::assertNotNull($got); + self::assertSame($wh->getId(), $got->getId()); + } finally { + $client->webhook((string) $wh->getId())->delete(); + } + }); } public function testGetWebhookDispatch(): void { $client = $this->requireClient(); - $wh = $client->webhooks()->create(self::webhookDef('https://example.com/webhook')); - try { - $dispatch = $client->webhook((string) $wh->getId())->test(); - $got = $client->webhookDispatch((string) $dispatch->getId())->get(); - self::assertNotNull($got); - self::assertSame($dispatch->getId(), $got->getId()); - } finally { - $client->webhook((string) $wh->getId())->delete(); - } + $this->withConditionTask($client, static function (string $taskId) use ($client): void { + $wh = $client->webhooks()->create(self::webhookDef($taskId, 'https://example.com/webhook')); + try { + $dispatch = $client->webhook((string) $wh->getId())->test(); + $got = $client->webhookDispatch((string) $dispatch->getId())->get(); + self::assertNotNull($got); + self::assertSame($dispatch->getId(), $got->getId()); + } finally { + $client->webhook((string) $wh->getId())->delete(); + } + }); } public function testWebhookCrudFlow(): void { $client = $this->requireClient(); - $wh = $client->webhooks()->create(self::webhookDef('https://example.com/webhook')); - try { - $webhook = $client->webhook((string) $wh->getId()); - self::assertNotNull($webhook->get()); - $updated = $webhook->update(['requestUrl' => 'https://example.com/updated']); - self::assertSame('https://example.com/updated', $updated->getRequestUrl()); - $webhook->dispatches()->list(new ListOptions()); - $webhook->test(); - } finally { - $client->webhook((string) $wh->getId())->delete(); - } + $this->withConditionTask($client, static function (string $taskId) use ($client): void { + $wh = $client->webhooks()->create(self::webhookDef($taskId, 'https://example.com/webhook')); + try { + $webhook = $client->webhook((string) $wh->getId()); + self::assertNotNull($webhook->get()); + $updated = $webhook->update(['requestUrl' => 'https://example.com/updated']); + self::assertSame('https://example.com/updated', $updated->getRequestUrl()); + $webhook->dispatches()->list(new ListOptions()); + $webhook->test(); + } finally { + $client->webhook((string) $wh->getId())->delete(); + } + }); } } diff --git a/tests/Unit/HttpClientTest.php b/tests/Unit/HttpClientTest.php index 1bf18fd..080a100 100644 --- a/tests/Unit/HttpClientTest.php +++ b/tests/Unit/HttpClientTest.php @@ -8,12 +8,15 @@ use Apify\Client\Exception\ApifyApiException; use Apify\Client\Exception\InvalidResponseBodyException; use Apify\Client\Exception\ServerException; +use Apify\Client\Exception\TransportException; +use Apify\Client\Http\Psr18HttpClient; use Apify\Client\Internal\Json; use Apify\Client\Options\DatasetListItemsOptions; use GuzzleHttp\Psr7\NoSeekStream; use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\Utils; use PHPUnit\Framework\TestCase; +use Psr\Http\Message\StreamInterface; final class HttpClientTest extends TestCase { @@ -116,24 +119,45 @@ public function testErrorStatusWithUnparsableBodyIsRetriedAndThrowsItsStatusExce } } - public function testNonSeekableResponseBodyIsOnlyReadOnce(): void + public function testPsr18ClientBuffersANonSeekableResponseBody(): void { - // A PSR-18 transport's response body is not guaranteed seekable/re-readable (the contract - // HttpClientInterface/Psr18HttpClient exposes); a NoSeekStream reproduces that here. call() - // must validate/return the body from a single buffered read, since a second - // `(string) $response->getBody()` on a stream like this one yields '' once already exhausted - // (GuzzleHttp\Psr7\StreamDecoratorTrait::__toString() skips the rewind-on-seek it does for a - // seekable stream), which would otherwise make the resource silently look empty. + // A PSR-18 client's response body is not guaranteed seekable/re-readable; a NoSeekStream + // reproduces that. Psr18HttpClient reads it in full, so the body can be read again afterward. $body = new NoSeekStream(Utils::streamFor(Json::encode(['data' => ['id' => 'abc', 'name' => 'my-actor']]))); - $transport = (new MockTransport())->queueRawResponse(new Response(200, [], $body)); + $psr18 = new StubPsr18Client(new Response(200, [], $body)); + $client = new ApifyClient(token: 'test-token', httpClient: new Psr18HttpClient($psr18)); - $actor = $this->client($transport)->actor('abc')->get(); + $actor = $client->actor('abc')->get(); self::assertNotNull($actor); self::assertSame('abc', $actor->getId()); self::assertSame('my-actor', $actor->getName()); } + public function testPsr18ClientMapsAFailedBodyReadToARetriedTransportError(): void + { + // A lazily read body that fails mid-transfer (connection dropped) is a transport failure: it is + // retried, and once the retries run out it surfaces as a TransportException. + $failingBody = $this->createStub(StreamInterface::class); + $failingBody->method('getContents')->willThrowException(new \RuntimeException('connection reset while reading the body')); + $psr18 = new StubPsr18Client(new Response(200, [], $failingBody)); + $client = new ApifyClient( + token: 'test-token', + maxRetries: 1, + minDelayBetweenRetriesMillis: 1, + timeoutSecs: 5, + httpClient: new Psr18HttpClient($psr18) + ); + + try { + $client->actor('x')->get(); + self::fail('expected TransportException'); + } catch (TransportException $e) { + self::assertStringContainsString('connection reset', $e->getMessage()); + self::assertSame(2, $psr18->calls); + } + } + public function testTransportErrorsAreRetried(): void { $transport = (new MockTransport()) diff --git a/tests/Unit/StubPsr18Client.php b/tests/Unit/StubPsr18Client.php new file mode 100644 index 0000000..0d83226 --- /dev/null +++ b/tests/Unit/StubPsr18Client.php @@ -0,0 +1,25 @@ +calls++; + return $this->response; + } +} From 0cff566289a3ea8106f1b2dee40342715c813f6a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 15:24:16 +0000 Subject: [PATCH 3/5] fix: keep PSR-18 streaming live, narrow body-read catch, test charge timeout tier Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01UUMWnU4gH573HweHieMhHi --- src/Http/Psr18HttpClient.php | 26 ++++++++++++++++++-------- tests/Unit/HttpClientTest.php | 30 ++++++++++++++++++++++++++++++ tests/Unit/RequestShapeTest.php | 8 ++++++++ 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/Http/Psr18HttpClient.php b/src/Http/Psr18HttpClient.php index cb53846..af8e178 100644 --- a/src/Http/Psr18HttpClient.php +++ b/src/Http/Psr18HttpClient.php @@ -16,13 +16,13 @@ * application can plug in its own configured HTTP client (proxy, TLS, connection pool). * * PSR-18 has no per-request timeout, so the {@code timeoutSecs} argument is ignored here — configure - * the timeout on the wrapped PSR-18 client instead. Streaming falls back to a normal request (the - * response body is still readable, just buffered by the underlying client). + * the timeout on the wrapped PSR-18 client instead. * - * The body is read in full inside {@see send()}, since a PSR-18 client may return a body it reads lazily: + * {@see send()} reads the response body in full, since a PSR-18 client may return a body it reads lazily: * a connection dropped mid-body then surfaces as a {@see TransportException} (which is retried) and not * as a failure later, when the response is already taken as complete. The response is returned with a - * seekable buffered body. + * seekable buffered body. {@see sendStreaming()} returns the body as the wrapped client provides it, so + * it is live only if that client reads it lazily. */ final class Psr18HttpClient implements HttpClientInterface { @@ -32,16 +32,26 @@ public function __construct(private ClientInterface $client) public function send(RequestInterface $request, float $timeoutSecs): ResponseInterface { + $response = $this->sendRequest($request); try { - $response = $this->client->sendRequest($request); - return $response->withBody(Utils::streamFor($response->getBody()->getContents())); - } catch (ClientExceptionInterface | \RuntimeException $e) { + $contents = $response->getBody()->getContents(); + } catch (\RuntimeException $e) { throw new TransportException($e->getMessage(), $e); } + return $response->withBody(Utils::streamFor($contents)); } public function sendStreaming(RequestInterface $request, float $timeoutSecs): ResponseInterface { - return $this->send($request, $timeoutSecs); + return $this->sendRequest($request); + } + + private function sendRequest(RequestInterface $request): ResponseInterface + { + try { + return $this->client->sendRequest($request); + } catch (ClientExceptionInterface $e) { + throw new TransportException($e->getMessage(), $e); + } } } diff --git a/tests/Unit/HttpClientTest.php b/tests/Unit/HttpClientTest.php index 080a100..70f4a33 100644 --- a/tests/Unit/HttpClientTest.php +++ b/tests/Unit/HttpClientTest.php @@ -13,9 +13,13 @@ use Apify\Client\Internal\Json; use Apify\Client\Options\DatasetListItemsOptions; use GuzzleHttp\Psr7\NoSeekStream; +use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\Utils; use PHPUnit\Framework\TestCase; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; final class HttpClientTest extends TestCase @@ -158,6 +162,32 @@ public function testPsr18ClientMapsAFailedBodyReadToARetriedTransportError(): vo } } + public function testPsr18StreamingReturnsTheBodyWithoutReadingIt(): void + { + // Log streaming relies on the body staying live, so sendStreaming() must not read it. + $body = $this->createStub(StreamInterface::class); + $body->method('getContents')->willThrowException(new \RuntimeException('must not be read')); + $transport = new Psr18HttpClient(new StubPsr18Client(new Response(200, [], $body))); + + $response = $transport->sendStreaming(new Request('GET', 'https://example.com/log'), 5.0); + + self::assertSame($body, $response->getBody()); + } + + public function testPsr18ClientDoesNotRewrapUnrelatedRuntimeExceptions(): void + { + // Only a failed body read is a transport failure; an exception of the wrapped client keeps its type. + $client = new class () implements ClientInterface { + public function sendRequest(RequestInterface $request): ResponseInterface + { + throw new \LogicException('misconfigured client'); + } + }; + + $this->expectException(\LogicException::class); + (new Psr18HttpClient($client))->send(new Request('GET', 'https://example.com/x'), 5.0); + } + public function testTransportErrorsAreRetried(): void { $transport = (new MockTransport()) diff --git a/tests/Unit/RequestShapeTest.php b/tests/Unit/RequestShapeTest.php index 8919441..ac4723e 100644 --- a/tests/Unit/RequestShapeTest.php +++ b/tests/Unit/RequestShapeTest.php @@ -52,6 +52,14 @@ public function testRunChargeSendsRequestIdOnlyWhenProvided(): void self::assertSame(['eventName' => 'e', 'count' => 1], Json::decode((string) $transport->lastRequest()->getBody())); } + public function testRunChargeUsesTheShortTimeoutTierByDefault(): void + { + $transport = (new MockTransport())->queueResponse(200, ''); + $client = new ApifyClient(token: 't', timeoutSecs: 100, timeoutShortSecs: 7, timeoutMediumSecs: 30, httpClient: $transport); + $client->run('run1')->charge(new RunChargeOptions(eventName: 'e')); + self::assertSame([7.0], $transport->timeouts); + } + public function testRunChargeUsesProvidedIdempotencyKey(): void { $transport = (new MockTransport())->queueResponse(200, ''); From 0d990896b9414693a19378b6fb2fcb636d8cb9a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 15:29:26 +0000 Subject: [PATCH 4/5] test: make the PSR-18 exception-passthrough test fail on the regression it guards Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01UUMWnU4gH573HweHieMhHi --- tests/Unit/HttpClientTest.php | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/Unit/HttpClientTest.php b/tests/Unit/HttpClientTest.php index 70f4a33..d22f0ae 100644 --- a/tests/Unit/HttpClientTest.php +++ b/tests/Unit/HttpClientTest.php @@ -174,18 +174,24 @@ public function testPsr18StreamingReturnsTheBodyWithoutReadingIt(): void self::assertSame($body, $response->getBody()); } - public function testPsr18ClientDoesNotRewrapUnrelatedRuntimeExceptions(): void + public function testPsr18ClientDoesNotRewrapARuntimeExceptionOfTheWrappedClient(): void { - // Only a failed body read is a transport failure; an exception of the wrapped client keeps its type. + // Only a failed body read and a PSR-18 ClientExceptionInterface are transport failures; any other + // exception of the wrapped client keeps its own type. $client = new class () implements ClientInterface { public function sendRequest(RequestInterface $request): ResponseInterface { - throw new \LogicException('misconfigured client'); + throw new \RuntimeException('misconfigured client'); } }; - $this->expectException(\LogicException::class); - (new Psr18HttpClient($client))->send(new Request('GET', 'https://example.com/x'), 5.0); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('misconfigured client'); + try { + (new Psr18HttpClient($client))->send(new Request('GET', 'https://example.com/x'), 5.0); + } catch (TransportException $e) { + self::fail('a RuntimeException of the wrapped client must not be rewrapped as a TransportException'); + } } public function testTransportErrorsAreRetried(): void From fd9bc1104c15025c9e62999f5de88c2dffd93c0a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 15:34:28 +0000 Subject: [PATCH 5/5] fix: rewind a PSR-18 response body before buffering it Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01UUMWnU4gH573HweHieMhHi --- src/Http/Psr18HttpClient.php | 7 ++++++- tests/Unit/HttpClientTest.php | 12 +++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/Http/Psr18HttpClient.php b/src/Http/Psr18HttpClient.php index af8e178..f8d2865 100644 --- a/src/Http/Psr18HttpClient.php +++ b/src/Http/Psr18HttpClient.php @@ -34,7 +34,12 @@ public function send(RequestInterface $request, float $timeoutSecs): ResponseInt { $response = $this->sendRequest($request); try { - $contents = $response->getBody()->getContents(); + $body = $response->getBody(); + // Some clients hand back a body they wrote without rewinding it, which would read as empty. + if ($body->isSeekable()) { + $body->rewind(); + } + $contents = $body->getContents(); } catch (\RuntimeException $e) { throw new TransportException($e->getMessage(), $e); } diff --git a/tests/Unit/HttpClientTest.php b/tests/Unit/HttpClientTest.php index d22f0ae..edf6bf6 100644 --- a/tests/Unit/HttpClientTest.php +++ b/tests/Unit/HttpClientTest.php @@ -138,6 +138,16 @@ public function testPsr18ClientBuffersANonSeekableResponseBody(): void self::assertSame('my-actor', $actor->getName()); } + public function testPsr18ClientReadsABodyThatWasNotRewound(): void + { + // Some PSR-18 clients return a body they wrote without rewinding it; it must not read as empty. + $body = Utils::streamFor(Json::encode(['data' => ['id' => 'abc']])); + $body->getContents(); + $client = new ApifyClient(token: 'test-token', httpClient: new Psr18HttpClient(new StubPsr18Client(new Response(200, [], $body)))); + + self::assertSame('abc', $client->actor('abc')->get()?->getId()); + } + public function testPsr18ClientMapsAFailedBodyReadToARetriedTransportError(): void { // A lazily read body that fails mid-transfer (connection dropped) is a transport failure: it is @@ -189,7 +199,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface $this->expectExceptionMessage('misconfigured client'); try { (new Psr18HttpClient($client))->send(new Request('GET', 'https://example.com/x'), 5.0); - } catch (TransportException $e) { + } catch (TransportException) { self::fail('a RuntimeException of the wrapped client must not be rewrapped as a TransportException'); } }