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([