From 13094cc50f763c32f45f570c186d83045982610b Mon Sep 17 00:00:00 2001 From: tomas-vaitiekunas Date: Thu, 23 Jul 2026 12:10:39 +0300 Subject: [PATCH] feat: typed exceptions for 402 and the remaining documented status codes; bump 0.1.6 402 previously surfaced as a bare ApiException with no explanation, even though it is the status a paying customer is most likely to hit. It now throws PaymentRequiredException, with a subclass per billing block picked from the response body (quota, credit limit, failed payment). Also adds NoSubscriptionException (401 with 'No valid subscription'), plus NotFoundException (404), BrowserTimeoutException (408), UnsupportedContentException (415) and ValidationException (422), and corrects the per-status messages - 400 also covers a missing key header. Version jumps to 0.1.6 so all four SDKs share one number. All new classes extend ApiException, so existing handlers keep working. --- CHANGELOG.md | 13 +++ README.md | 50 ++++++++-- src/Client.php | 62 ++++++++++++- src/Exception/AuthenticationException.php | 11 ++- src/Exception/BrowserTimeoutException.php | 15 +++ .../CreditLimitExceededException.php | 16 ++++ src/Exception/InvalidRequestException.php | 8 +- src/Exception/NoSubscriptionException.php | 16 ++++ src/Exception/NotFoundException.php | 14 +++ src/Exception/PaymentFailedException.php | 16 ++++ src/Exception/PaymentRequiredException.php | 22 +++++ src/Exception/QuotaExceededException.php | 17 ++++ src/Exception/UnsupportedContentException.php | 15 +++ src/Exception/ValidationException.php | 15 +++ tests/ClientTest.php | 91 +++++++++++++++++++ 15 files changed, 368 insertions(+), 13 deletions(-) create mode 100644 src/Exception/BrowserTimeoutException.php create mode 100644 src/Exception/CreditLimitExceededException.php create mode 100644 src/Exception/NoSubscriptionException.php create mode 100644 src/Exception/NotFoundException.php create mode 100644 src/Exception/PaymentFailedException.php create mode 100644 src/Exception/PaymentRequiredException.php create mode 100644 src/Exception/QuotaExceededException.php create mode 100644 src/Exception/UnsupportedContentException.php create mode 100644 src/Exception/ValidationException.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e4b706b..92a056a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 0.1.6 - 2026-07-23 + +Version jumps from 0.1.2 to 0.1.6 so all four official SDKs (Python, Node.js, Ruby, PHP) share one version number from here on. Nothing was skipped - 0.1.3 to 0.1.5 were never released for PHP. + +- Added `PaymentRequiredException` for HTTP 402, which previously surfaced as a bare `ApiException` with no explanation. The three billing blocks now each get their own subclass, picked from the response body: `QuotaExceededException` (`Quota exceeded`), `CreditLimitExceededException` (`Credit limit exceeded`) and `PaymentFailedException` (`Payment failed - update payment method`). Catch `PaymentRequiredException` to handle all three. +- Added `NoSubscriptionException`, a subclass of `AuthenticationException`, for the 401 that means "the key is fine, the account has no active plan" (`No valid subscription`) as opposed to an unrecognised key. +- Added typed exceptions for the remaining documented status codes: `NotFoundException` (404), `BrowserTimeoutException` (408), `UnsupportedContentException` (415) and `ValidationException` (422). All previously threw a bare `ApiException`. +- Error messages now describe every documented status code accurately - notably 400, which also covers a missing `x-scrapeunblocker-key` header, not just a bad URL. +- Documented the full exception hierarchy in the README, including which errors are retried, which are billed, and how each 402 clears. +- Fixed the README and `oopbuySearch()` docblock claim that Oopbuy brand keywords return HTTP 422. They return a successful `200` with `keywordRejected: true` and an empty `results` array. + +No breaking changes: every new class extends `ApiException`, so existing `catch (ApiException)` / `catch (ScrapeUnblockerException)` handlers keep working unchanged. + ## 0.1.2 - 2026-07-22 - Added `oopbuySearch()` for the Oopbuy goods search plugin (`/goods/oopbuy-search`) - search 1688/Taobao/official channels and get products with USD and CNY prices, images and monthly sales. diff --git a/README.md b/README.md index 59e2d8f..1e134bc 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ foreach ($goods['results'] as $item) { } ``` -Channels: `1688` (default), `taobao`, `official`. Sort: `default`, `price_asc`, `price_desc`, `best_selling`. `page_size` up to 60. Brand keywords are rejected with HTTP 422. +Channels: `1688` (default), `taobao`, `official`. Sort: `default`, `price_asc`, `price_desc`, `best_selling`. `page_size` up to 60. Oopbuy trademark-blocks brand keywords at its own backend: those come back as a successful `200` with `keywordRejected: true` and an empty `results` array, not an error. ## Cookies and the serving proxy @@ -130,10 +130,11 @@ $cars = $su->skyscanner->carhire(['pickup' => 'Madrid', 'pickup_datetime' => '20 ## Error handling -Non-2xx responses throw typed exceptions, all subclasses of `ScrapeUnblockerException`. Transient failures (429, 502, 503, 504 and network errors) are retried automatically with exponential backoff. +Non-2xx responses throw typed exceptions, all subclasses of `ScrapeUnblockerException`. ```php use ScrapeUnblocker\Exception\BlockedException; +use ScrapeUnblocker\Exception\PaymentRequiredException; use ScrapeUnblocker\Exception\RateLimitException; use ScrapeUnblocker\Exception\UpstreamOutageException; @@ -141,6 +142,8 @@ try { $html = $su->getPageSource('https://example.com'); } catch (BlockedException $e) { // 403: the target blocked every bypass path (not billed) +} catch (PaymentRequiredException $e) { + // 402: quota, credit limit, or a failed payment - fix billing } catch (RateLimitException $e) { // 429: slow down } catch (UpstreamOutageException $e) { @@ -150,15 +153,50 @@ try { | Exception | Status | Meaning | |---|---|---| -| `InvalidRequestException` | 400 | Bad URL or unsupported scheme | -| `AuthenticationException` | 401 | Missing or invalid API key | +| `InvalidRequestException` | 400 | Bad URL, unsupported scheme, or the API key header was not sent | +| `AuthenticationException` | 401 | Key not recognised - typo, stray whitespace, or a rotated key | +| `NoSubscriptionException` | 401 | Key is fine, but the account has no active plan | +| `PaymentRequiredException` | 402 | Billing block - base class for the three below | +| `QuotaExceededException` | 402 | The plan's requests for this period are used up | +| `CreditLimitExceededException` | 402 | Unpaid balance is past the account's credit limit | +| `PaymentFailedException` | 402 | A card payment was declined three times | | `BlockedException` | 403 | Blocked by bot protection on every path | +| `NotFoundException` | 404 | Page loaded but held no image (`getImage` only) | +| `BrowserTimeoutException` | 408 | Our browser run timed out before the page was ready | +| `UnsupportedContentException` | 415 | The URL serves something other than HTML | +| `ValidationException` | 422 | Missing or wrong-typed parameter; `$body` holds the `detail` array | | `RateLimitException` | 429 | Too many requests | | `UpstreamOutageException` | 503 | The target origin is down | -| `ServerException` | 5xx | Unexpected server error | -| `TimeoutException` | - | Request exceeded the timeout | +| `ServerException` | 5xx | Unexpected server error, including a 504 upstream timeout | +| `TimeoutException` | - | This client gave up locally before the API answered | | `ConnectionException` | - | Could not reach the API | +Transient failures (429, 502, 503, 504 and network errors) are retried automatically with exponential backoff. A 401 or 402 is never retried - it clears when the key or the billing state changes, not on another attempt. Neither is billed or counted against your quota, because the request is refused before anything is scraped. + +### Billing errors (402) + +The three billing blocks share a status code and differ only in their message, so the client throws a dedicated exception for each: + +```php +use ScrapeUnblocker\Exception\CreditLimitExceededException; +use ScrapeUnblocker\Exception\PaymentFailedException; +use ScrapeUnblocker\Exception\QuotaExceededException; + +try { + $html = $su->getPageSource('https://example.com'); +} catch (QuotaExceededException $e) { + // plan quota (plus any overage allowance) is used up for this period +} catch (CreditLimitExceededException $e) { + // unpaid balance passed the account credit limit +} catch (PaymentFailedException $e) { + // card declined three times - update the payment method +} +``` + +When more than one applies, the most serious wins: failed payment outranks credit limit, which outranks quota. All three lift by themselves once the billing state changes - access returns within about a minute, and the API key stays the same. One catch worth knowing: subscribing to a new plan does **not** clear `PaymentFailedException`, because the old unpaid invoice stays open until it is paid. + +Full details for every status code: [developers.scrapeunblocker.com/errors](https://developers.scrapeunblocker.com/errors). + ## Configuration ```php diff --git a/src/Client.php b/src/Client.php index 18e76ba..82ff0b4 100644 --- a/src/Client.php +++ b/src/Client.php @@ -7,13 +7,22 @@ use ScrapeUnblocker\Exception\ApiException; use ScrapeUnblocker\Exception\AuthenticationException; use ScrapeUnblocker\Exception\BlockedException; +use ScrapeUnblocker\Exception\BrowserTimeoutException; use ScrapeUnblocker\Exception\ConnectionException; +use ScrapeUnblocker\Exception\CreditLimitExceededException; use ScrapeUnblocker\Exception\InvalidRequestException; +use ScrapeUnblocker\Exception\NoSubscriptionException; +use ScrapeUnblocker\Exception\NotFoundException; +use ScrapeUnblocker\Exception\PaymentFailedException; +use ScrapeUnblocker\Exception\PaymentRequiredException; +use ScrapeUnblocker\Exception\QuotaExceededException; use ScrapeUnblocker\Exception\RateLimitException; use ScrapeUnblocker\Exception\ScrapeUnblockerException; use ScrapeUnblocker\Exception\ServerException; use ScrapeUnblocker\Exception\TimeoutException; +use ScrapeUnblocker\Exception\UnsupportedContentException; use ScrapeUnblocker\Exception\UpstreamOutageException; +use ScrapeUnblocker\Exception\ValidationException; /** * Client for the ScrapeUnblocker API. @@ -26,7 +35,7 @@ final class Client { private const DEFAULT_BASE_URL = 'https://api.scrapeunblocker.com'; - private const VERSION = '0.1.2'; + private const VERSION = '0.1.6'; private const API_KEY_HEADER = 'x-scrapeunblocker-key'; private const RETRYABLE = [429, 502, 503, 504]; @@ -137,7 +146,9 @@ public function googleLocal(string $keyword, array $options = []): array * * Searches the "1688" channel by default ("taobao" and "official" are * also supported) and returns products with USD and CNY prices, images - * and monthly sales. Brand keywords are rejected with HTTP 422. + * and monthly sales. Oopbuy trademark-blocks brand keywords at its own + * backend: those return a successful 200 with keywordRejected = true and + * an empty results array, not an error. */ public function oopbuySearch(string $keyword, array $options = []): array { @@ -238,19 +249,30 @@ private function errorForStatus(int $status, string $body): ApiException $snippet = substr($snippet, 0, 200) . '...'; } $base = match ($status) { - 400 => 'Invalid request (bad URL or unsupported scheme)', - 401 => 'Authentication failed - check your API key', + 400 => 'Invalid request (bad URL, unsupported scheme, or missing API key header)', + 401 => 'Authentication failed - key not recognised, or account has no active plan', + 402 => 'Billing block - quota exceeded, credit limit exceeded, or a failed payment', 403 => 'Target blocked by bot protection on every bypass path', + 404 => 'Requested element not found on the page', + 408 => 'Browser run timed out before the page was ready', + 415 => 'URL does not serve HTML', + 422 => 'Validation error - see the detail array in the response body', 429 => 'Rate limited - too many requests', 503 => 'Upstream origin returned a server-side outage page', + 504 => 'Fetch timed out upstream', default => "API returned HTTP {$status}", }; $message = $snippet !== '' ? "{$base}: {$snippet}" : $base; return match (true) { $status === 400 => new InvalidRequestException($message, $status, $body), - $status === 401 => new AuthenticationException($message, $status, $body), + $status === 401 => $this->authError($message, $status, $body), + $status === 402 => $this->billingError($message, $status, $body), $status === 403 => new BlockedException($message, $status, $body), + $status === 404 => new NotFoundException($message, $status, $body), + $status === 408 => new BrowserTimeoutException($message, $status, $body), + $status === 415 => new UnsupportedContentException($message, $status, $body), + $status === 422 => new ValidationException($message, $status, $body), $status === 429 => new RateLimitException($message, $status, $body), $status === 503 => new UpstreamOutageException($message, $status, $body), $status >= 500 => new ServerException($message, $status, $body), @@ -258,6 +280,36 @@ private function errorForStatus(int $status, string $body): ApiException }; } + /** + * A 401 is either an unknown key or a recognised key on an account without a plan, + * and only the body tells them apart. Anything unrecognised stays on the general + * AuthenticationException rather than guessing. + */ + private function authError(string $message, int $status, string $body): AuthenticationException + { + if (str_contains(strtolower($body), 'no valid subscription')) { + return new NoSubscriptionException($message, $status, $body); + } + + return new AuthenticationException($message, $status, $body); + } + + /** + * The three billing blocks share a status code and differ only in their plain-text + * body. An unrecognised body falls back to PaymentRequiredException. + */ + private function billingError(string $message, int $status, string $body): PaymentRequiredException + { + $text = strtolower($body); + + return match (true) { + str_contains($text, 'quota exceeded') => new QuotaExceededException($message, $status, $body), + str_contains($text, 'credit limit exceeded') => new CreditLimitExceededException($message, $status, $body), + str_contains($text, 'payment failed') => new PaymentFailedException($message, $status, $body), + default => new PaymentRequiredException($message, $status, $body), + }; + } + /** * @param list $headers * @return array{status:int,body:string} diff --git a/src/Exception/AuthenticationException.php b/src/Exception/AuthenticationException.php index a7ef184..9d06f52 100644 --- a/src/Exception/AuthenticationException.php +++ b/src/Exception/AuthenticationException.php @@ -4,7 +4,16 @@ namespace ScrapeUnblocker\Exception; -/** The API key is missing, malformed, or not recognised (HTTP 401). */ +/** + * The API key was rejected (HTTP 401). + * + * Two cases produce a 401: an unrecognised key ("Unauthorized" - a typo, trailing + * whitespace, an empty value, or a key rotated in the dashboard), and a valid key on an + * account with no plan, which throws the NoSubscriptionException subclass. + * + * Omitting the API key header entirely is a 400, not a 401. Nothing is scraped for a 401, + * so it is not billed and does not count against your quota. + */ class AuthenticationException extends ApiException { } diff --git a/src/Exception/BrowserTimeoutException.php b/src/Exception/BrowserTimeoutException.php new file mode 100644 index 0000000..dbfe49f --- /dev/null +++ b/src/Exception/BrowserTimeoutException.php @@ -0,0 +1,15 @@ + tag. + */ +class NotFoundException extends ApiException +{ +} diff --git a/src/Exception/PaymentFailedException.php b/src/Exception/PaymentFailedException.php new file mode 100644 index 0000000..23f7129 --- /dev/null +++ b/src/Exception/PaymentFailedException.php @@ -0,0 +1,16 @@ +client([['status' => 402, 'body' => $body]], ['max_retries' => 0]); + + try { + $client->getPageSource('https://example.com'); + $this->fail('Expected a billing exception'); + } catch (PaymentRequiredException $e) { + $this->assertInstanceOf($exceptionClass, $e); + $this->assertSame(402, $e->statusCode); + $this->assertSame($body, $e->body); + } + } + + public static function billingBodyProvider(): array + { + return [ + ["Quota exceeded\n", QuotaExceededException::class], + ["Credit limit exceeded\n", CreditLimitExceededException::class], + ["Payment failed - update payment method\n", PaymentFailedException::class], + ['something new we do not know yet', PaymentRequiredException::class], + ]; + } + + /** + * @dataProvider authBodyProvider + */ + public function testAuthErrorSubclassFromBody(string $body, string $exceptionClass): void + { + $client = $this->client([['status' => 401, 'body' => $body]], ['max_retries' => 0]); + + try { + $client->getPageSource('https://example.com'); + $this->fail('Expected an authentication exception'); + } catch (AuthenticationException $e) { + $this->assertInstanceOf($exceptionClass, $e); + $this->assertSame(401, $e->statusCode); + } + } + + public static function authBodyProvider(): array + { + return [ + ["No valid subscription\n", NoSubscriptionException::class], + ["Unauthorized\n", AuthenticationException::class], + ]; + } + + /** + * These clear when the key or billing state changes, never on a retry. + * + * @dataProvider nonRetryableProvider + */ + public function testAuthAndBillingErrorsAreNotRetried(int $status): void + { + $client = $this->client([['status' => $status, 'body' => 'Quota exceeded']], ['max_retries' => 3]); + + try { + $client->getPageSource('https://example.com'); + $this->fail('Expected an API exception'); + } catch (ApiException) { + $this->assertCount(1, $this->urls); + } + } + + public static function nonRetryableProvider(): array + { + return [[401], [402]]; + } + public function testRetriesThenSucceeds(): void { $client = $this->client([