Skip to content

Commit 3c183d2

Browse files
authored
Merge pull request #7 from blendbyte/rate-limit-typed-exceptions
Add typed exceptions: AuthException, RateLimitException::getRetryAfter(), AcmeException::getSubproblems()
2 parents 8f2999a + 73ddc13 commit 3c183d2

7 files changed

Lines changed: 236 additions & 22 deletions

File tree

README.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ The built-in curl client needs no extra dependencies. Need proxy support, custom
4949

5050
Filesystem with file locking, PDO (MySQL, PostgreSQL, SQLite) with dialect-aware upserts, and in-memory for testing. All three share the same interface, so switching backends doesn't touch your issuance code.
5151

52+
### Typed exceptions for every failure mode
53+
54+
`RateLimitException` carries the CA's `Retry-After` seconds so your retry logic is precise. `AuthException` tells you credentials failed — not a transient error. `AcmeException::getSubproblems()` surfaces RFC 8555 §6.7 per-identifier errors so a multi-domain order can report exactly which domains were rejected and why. All exceptions share a common base so a single `catch` still works when you don't need the detail.
55+
5256
### CAA pre-check
5357

5458
Before submitting an order, CoyoteCert queries CAA DNS records for every requested domain. If a record exists and excludes the chosen CA, you get an immediate `CaaException` naming the blocking domain — no wasted rate-limit attempt, no waiting for an ACME order to fail minutes later. The check follows RFC 8659 tree-walking, handles `issuewild` tags for wildcard domains, and respects parameter extensions (e.g. `letsencrypt.org; validationmethods=http-01`). CAA identifiers are built into every provider; `->skipCaaCheck()` opts out when DNS is internal or split-horizon.
@@ -628,6 +632,81 @@ CoyoteCert::with(new LetsEncrypt())
628632

629633
---
630634

635+
## Error handling
636+
637+
All exceptions extend `AcmeException`, so a single `catch (AcmeException $e)` covers everything. Catch the narrower types when you need to react differently to specific failure modes.
638+
639+
### Rate limits — with Retry-After
640+
641+
```php
642+
use CoyoteCert\Exceptions\RateLimitException;
643+
644+
try {
645+
$cert = CoyoteCert::with(new LetsEncrypt())
646+
->identifiers('example.com')
647+
->challenge(new Http01Handler('/var/www/html'))
648+
->issue();
649+
} catch (RateLimitException $e) {
650+
$wait = $e->getRetryAfter(); // int seconds from Retry-After header, or null
651+
echo "Rate limited. Retry" . ($wait ? " in {$wait}s." : " later.");
652+
}
653+
```
654+
655+
`getRetryAfter()` returns the value from the CA's `Retry-After` header when present, or `null` when the header is absent. Use it to schedule a precise back-off rather than guessing.
656+
657+
### Authentication failures
658+
659+
```php
660+
use CoyoteCert\Exceptions\AuthException;
661+
662+
try {
663+
$api->account()->get();
664+
} catch (AuthException $e) {
665+
// 401 / 403 — account key rejected or credentials revoked
666+
echo $e->getMessage();
667+
}
668+
```
669+
670+
`AuthException` is thrown on 401 and 403 responses. Distinct from a rate limit or a transient server error, so you can alert or re-provision credentials rather than retrying.
671+
672+
### Per-identifier subproblems (RFC 8555 §6.7)
673+
674+
When an order covering multiple domains is rejected, the CA may return a `subproblems` array with a separate error for each failing identifier:
675+
676+
```php
677+
use CoyoteCert\Exceptions\AcmeException;
678+
679+
try {
680+
$cert = CoyoteCert::with(new LetsEncrypt())
681+
->identifiers(['example.com', 'bad.example.com'])
682+
->challenge(new Http01Handler('/var/www/html'))
683+
->issue();
684+
} catch (AcmeException $e) {
685+
foreach ($e->getSubproblems() as $sub) {
686+
// ['type' => '...', 'detail' => '...', 'identifier' => ['type' => 'dns', 'value' => '...']]
687+
echo $sub['identifier']['value'] . ': ' . $sub['detail'] . PHP_EOL;
688+
}
689+
}
690+
```
691+
692+
`getSubproblems()` returns an empty array when the server returned a single top-level error with no per-identifier breakdown.
693+
694+
### Exception hierarchy
695+
696+
```
697+
AcmeException — base; always safe to catch
698+
├── AuthException — 401/403 (bad credentials, revoked account)
699+
├── RateLimitException — 429 (too many requests); carries getRetryAfter()
700+
├── CaaException — CAA DNS record blocks issuance
701+
├── ChallengeException — challenge validation failed
702+
├── CryptoException — local key or certificate operation failed
703+
├── DomainValidationException — pre-flight HTTP/DNS self-check failed
704+
├── OrderNotFoundException — order ID not found on the CA
705+
└── StorageException — storage backend error
706+
```
707+
708+
---
709+
631710
## Wildcard and multi-domain certificates
632711

633712
Pass an array of domains to `->identifiers()`. Wildcards require DNS-01 or dns-persist-01.

src/Endpoints/Endpoint.php

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
use CoyoteCert\Api;
66
use CoyoteCert\Exceptions\AcmeException;
7+
use CoyoteCert\Exceptions\AuthException;
8+
use CoyoteCert\Exceptions\RateLimitException;
79
use CoyoteCert\Http\Response;
810
use CoyoteCert\Support\KeyId;
911

@@ -70,12 +72,45 @@ protected function isBadNonce(Response $response): bool
7072
&& ($response->jsonBody()['type'] ?? '') === 'urn:ietf:params:acme:error:badNonce';
7173
}
7274

73-
protected function throwError(Response $response, string $defaultMessage): never
75+
/** @param array<string, mixed> $additionalContext */
76+
protected function throwError(Response $response, string $defaultMessage, array $additionalContext = []): never
77+
{
78+
$this->logResponse('error', $response->jsonBody()['detail'] ?? $defaultMessage, $response, $additionalContext);
79+
80+
throw $this->createException($response, $defaultMessage);
81+
}
82+
83+
/**
84+
* Build a typed exception from the response without logging.
85+
* Dispatches 401/403 → AuthException, 429 → RateLimitException (with Retry-After),
86+
* and attaches RFC 8555 §6.7 subproblems to any exception that carries them.
87+
*/
88+
protected function createException(Response $response, string $defaultMessage): AcmeException
7489
{
75-
$message = $response->jsonBody()['detail'] ?? $defaultMessage;
76-
$this->logResponse('error', $message, $response);
90+
$body = $response->jsonBody();
91+
$message = $body['detail'] ?? $defaultMessage;
92+
93+
/** @var array<int, array<string, mixed>> $subproblems */
94+
$subproblems = $body['subproblems'] ?? [];
95+
96+
return match ($response->getHttpResponseCode()) {
97+
401, 403 => new AuthException($message),
98+
429 => new RateLimitException($message, $this->parseRetryAfterSeconds($response)),
99+
default => new AcmeException($message, $subproblems),
100+
};
101+
}
102+
103+
protected function parseRetryAfterSeconds(Response $response): ?int
104+
{
105+
$value = $response->getHeader('retry-after', '');
106+
107+
if (!is_string($value) || $value === '') {
108+
return null;
109+
}
110+
111+
$seconds = (int) $value;
77112

78-
throw new AcmeException($message);
113+
return $seconds > 0 ? $seconds : null;
79114
}
80115

81116
protected function getAccountPrivateKey(): string

src/Endpoints/Order.php

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
use CoyoteCert\DTO\OrderData;
77
use CoyoteCert\Exceptions\AcmeException;
88
use CoyoteCert\Exceptions\OrderNotFoundException;
9-
use CoyoteCert\Exceptions\RateLimitException;
109
use CoyoteCert\Support\Base64;
1110

1211
class Order extends Endpoint
@@ -62,15 +61,7 @@ public function new(AccountData $accountData, array $domains, string $profile =
6261
}
6362
}
6463

65-
$this->logResponse('error', 'Creating new order failed; bad response code.', $response, ['payload' => $payload]);
66-
67-
$detail = $response->jsonBody()['detail'] ?? $response->rawBody();
68-
69-
throw new AcmeException(sprintf(
70-
'Creating new order failed (HTTP %d): %s',
71-
$response->getHttpResponseCode(),
72-
$detail,
73-
));
64+
$this->throwError($response, 'Creating new order failed.', ['payload' => $payload]);
7465
}
7566

7667
public function get(string $id): OrderData
@@ -92,13 +83,11 @@ public function get(string $id): OrderData
9283
return OrderData::fromResponse($response, $account->url);
9384
}
9485

95-
// Always log the error.
9686
$this->logResponse('error', 'Getting order failed; bad response code.', $response);
9787

98-
match ($response->getHttpResponseCode()) {
99-
404 => throw new OrderNotFoundException($response->jsonBody()['detail'] ?? 'Order cannot be found.'),
100-
429 => throw new RateLimitException($response->jsonBody()['detail'] ?? 'Too many requests.'),
101-
default => throw new AcmeException($response->jsonBody()['detail'] ?? 'Unknown error.'),
88+
throw match ($response->getHttpResponseCode()) {
89+
404 => new OrderNotFoundException($response->jsonBody()['detail'] ?? 'Order cannot be found.'),
90+
default => $this->createException($response, 'Getting order failed.'),
10291
};
10392
}
10493

src/Exceptions/AcmeException.php

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,23 @@
44

55
use RuntimeException;
66

7-
class AcmeException extends RuntimeException {}
7+
class AcmeException extends RuntimeException
8+
{
9+
/** @var array<int, array<string, mixed>> */
10+
private readonly array $subproblems;
11+
12+
/**
13+
* @param array<int, array<string, mixed>> $subproblems RFC 8555 §6.7 per-identifier problems.
14+
*/
15+
public function __construct(string $message = '', array $subproblems = [], int $code = 0, ?\Throwable $previous = null)
16+
{
17+
$this->subproblems = $subproblems;
18+
parent::__construct($message, $code, $previous);
19+
}
20+
21+
/** @return array<int, array<string, mixed>> */
22+
public function getSubproblems(): array
23+
{
24+
return $this->subproblems;
25+
}
26+
}

src/Exceptions/AuthException.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?php
2+
3+
namespace CoyoteCert\Exceptions;
4+
5+
class AuthException extends AcmeException {}

src/Exceptions/RateLimitException.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,18 @@
22

33
namespace CoyoteCert\Exceptions;
44

5-
class RateLimitException extends AcmeException {}
5+
class RateLimitException extends AcmeException
6+
{
7+
public function __construct(string $message, private readonly ?int $retryAfter = null)
8+
{
9+
parent::__construct($message);
10+
}
11+
12+
/**
13+
* Seconds the server asked us to wait, or null when the header was absent.
14+
*/
15+
public function getRetryAfter(): ?int
16+
{
17+
return $this->retryAfter;
18+
}
19+
}

tests/Unit/Endpoints/EndpointsTest.php

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use CoyoteCert\Enums\AuthorizationChallengeEnum;
88
use CoyoteCert\Enums\KeyType;
99
use CoyoteCert\Exceptions\AcmeException;
10+
use CoyoteCert\Exceptions\AuthException;
1011
use CoyoteCert\Exceptions\OrderNotFoundException;
1112
use CoyoteCert\Exceptions\RateLimitException;
1213
use CoyoteCert\Http\Response;
@@ -429,7 +430,7 @@ function makeTestCerts(): array
429430
), withKeyStorage());
430431

431432
expect(fn() => $api->order()->new(makeAccountData(), ['example.com']))
432-
->toThrow(AcmeException::class, 'Creating new order failed');
433+
->toThrow(AcmeException::class, 'Bad Request');
433434
});
434435

435436
it('Order::new() returns OrderData on 201 response', function () {
@@ -583,6 +584,78 @@ function makeTestCerts(): array
583584
->toThrow(AcmeException::class, 'Internal error');
584585
});
585586

587+
it('Order::get() throws RateLimitException with parsed Retry-After seconds', function () {
588+
$storage = withKeyStorage();
589+
590+
$mock = closureMock(
591+
getHandler: fn($url) => new Response([], $url, 200, directoryBody()),
592+
postHandler: fn($url) => str_contains($url, 'new-account')
593+
? new Response(['location' => 'https://acme.example/account/1'], $url, 200, accountBody())
594+
: new Response(['retry-after' => '30'], $url, 429, ['detail' => 'Rate limited']),
595+
);
596+
597+
$caught = null;
598+
try {
599+
makeEndpointApi($mock, $storage)->order()->get('spam');
600+
} catch (RateLimitException $e) {
601+
$caught = $e;
602+
}
603+
604+
expect($caught)->toBeInstanceOf(RateLimitException::class);
605+
expect($caught->getRetryAfter())->toBe(30);
606+
});
607+
608+
it('Order::get() throws AuthException on 401', function () {
609+
$storage = withKeyStorage();
610+
611+
$mock = closureMock(
612+
getHandler: fn($url) => new Response([], $url, 200, directoryBody()),
613+
postHandler: fn($url) => str_contains($url, 'new-account')
614+
? new Response(['location' => 'https://acme.example/account/1'], $url, 200, accountBody())
615+
: new Response([], $url, 401, ['detail' => 'Unauthorized']),
616+
);
617+
618+
expect(fn() => makeEndpointApi($mock, $storage)->order()->get('auth-fail'))
619+
->toThrow(AuthException::class, 'Unauthorized');
620+
});
621+
622+
it('Order::new() throws RateLimitException on 429', function () {
623+
$api = makeEndpointApi(endpointMock(
624+
getBody: directoryBody(),
625+
postBody: ['detail' => 'Too many certificates already issued'],
626+
postCode: 429,
627+
), withKeyStorage());
628+
629+
expect(fn() => $api->order()->new(makeAccountData(), ['example.com']))
630+
->toThrow(RateLimitException::class, 'Too many certificates already issued');
631+
});
632+
633+
it('Order::new() attaches RFC 8555 §6.7 subproblems to the thrown exception', function () {
634+
$subproblems = [
635+
[
636+
'type' => 'urn:ietf:params:acme:error:rejectedIdentifier',
637+
'detail' => 'This CA will not issue for "bad.example.com"',
638+
'identifier' => ['type' => 'dns', 'value' => 'bad.example.com'],
639+
],
640+
];
641+
642+
$api = makeEndpointApi(endpointMock(
643+
getBody: directoryBody(),
644+
postBody: ['detail' => 'Some identifiers were rejected', 'subproblems' => $subproblems],
645+
postCode: 400,
646+
), withKeyStorage());
647+
648+
$caught = null;
649+
try {
650+
$api->order()->new(makeAccountData(), ['example.com', 'bad.example.com']);
651+
} catch (AcmeException $e) {
652+
$caught = $e;
653+
}
654+
655+
expect($caught)->toBeInstanceOf(AcmeException::class);
656+
expect($caught->getSubproblems())->toBe($subproblems);
657+
});
658+
586659
it('Order::get() returns OrderData on success', function () {
587660
$storage = withKeyStorage();
588661

0 commit comments

Comments
 (0)