Skip to content

Commit 8320639

Browse files
authored
Merge pull request #146 from Sammyjo20/feature/v2-extending-failed-response-handler
Feature | V2 Retry Functionality
2 parents 4ad9757 + 22a8c96 commit 8320639

File tree

13 files changed

+536
-6
lines changed

13 files changed

+536
-6
lines changed

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@
3333
"pestphp/pest": "^1.21",
3434
"phpstan/phpstan": "^1.9",
3535
"spatie/ray": "^1.33",
36-
"symfony/dom-crawler": "^6.0"
36+
"symfony/dom-crawler": "^6.0",
37+
"symfony/stopwatch": "^6.2"
3738
},
3839
"suggest": {
3940
"illuminate/collections": "Required for the response collect() method.",

src/Contracts/CanThrowRequestExceptions.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@
88

99
interface CanThrowRequestExceptions
1010
{
11+
/**
12+
* Determine if the request has failed.
13+
*
14+
* @param \Saloon\Contracts\Response $response
15+
* @return bool|null
16+
*/
17+
public function hasRequestFailed(Response $response): ?bool;
18+
1119
/**
1220
* Determine if we should throw an exception if the `$response->throw()` ({@see \Saloon\Contracts\Response::throw()})
1321
* is used, or when AlwaysThrowOnErrors is used.

src/Contracts/Connector.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,19 @@ public function sender(): Sender;
6565
*/
6666
public function send(Request $request, MockClient $mockClient = null): Response;
6767

68+
/**
69+
* Send a synchronous request and retry if it fails
70+
*
71+
* @param \Saloon\Contracts\Request $request
72+
* @param int $maxAttempts
73+
* @param int $interval
74+
* @param callable|null $handleRetry
75+
* @param bool $throw
76+
* @param \Saloon\Contracts\MockClient|null $mockClient
77+
* @return mixed
78+
*/
79+
public function sendAndRetry(Request $request, int $maxAttempts, int $interval = 0, callable $handleRetry = null, bool $throw = false, MockClient $mockClient = null): Response;
80+
6881
/**
6982
* Send a request asynchronously
7083
*

src/Contracts/Response.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,12 +112,20 @@ public function xml(mixed ...$arguments): SimpleXMLElement|bool;
112112
public function collect(string|int|null $key = null): Collection;
113113

114114
/**
115-
* Cast the response to a DTO.
115+
* Convert the response into a DTO
116116
*
117117
* @return mixed
118118
*/
119119
public function dto(): mixed;
120120

121+
/**
122+
* Convert the response into a DTO or throw a LogicException if the response failed
123+
*
124+
* @throws \LogicException
125+
* @return mixed
126+
*/
127+
public function dtoOrFail(): mixed;
128+
121129
/**
122130
* Determine if the request was successful.
123131
*

src/Traits/Connector/SendsRequests.php

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
use Saloon\Http\PendingRequest;
1010
use Saloon\Contracts\MockClient;
1111
use GuzzleHttp\Promise\PromiseInterface;
12+
use Saloon\Exceptions\Request\RequestException;
13+
use Saloon\Exceptions\Request\FatalRequestException;
1214
use Saloon\Contracts\PendingRequest as PendingRequestContract;
1315

1416
trait SendsRequests
@@ -30,6 +32,70 @@ public function send(Request $request, MockClient $mockClient = null): Response
3032
return $this->createPendingRequest($request, $mockClient)->send();
3133
}
3234

35+
/**
36+
* Send a synchronous request and retry if it fails
37+
*
38+
* @param \Saloon\Contracts\Request $request
39+
* @param int $maxAttempts
40+
* @param int $interval
41+
* @param callable|null $handleRetry
42+
* @param bool $throw
43+
* @param \Saloon\Contracts\MockClient|null $mockClient
44+
* @return \Saloon\Contracts\Response
45+
* @throws \ReflectionException
46+
* @throws \Saloon\Exceptions\InvalidResponseClassException
47+
* @throws \Saloon\Exceptions\PendingRequestException
48+
* @throws \Saloon\Exceptions\Request\FatalRequestException
49+
* @throws \Saloon\Exceptions\Request\RequestException
50+
*/
51+
public function sendAndRetry(Request $request, int $maxAttempts, int $interval = 0, callable $handleRetry = null, bool $throw = true, MockClient $mockClient = null): Response
52+
{
53+
$currentAttempt = 0;
54+
$pendingRequest = $this->createPendingRequest($request, $mockClient);
55+
56+
do {
57+
$currentAttempt++;
58+
59+
// When the current attempt is greater than one, we will pause to wait
60+
// for the interval.
61+
62+
if ($currentAttempt > 1) {
63+
usleep($interval * 1000);
64+
}
65+
66+
try {
67+
// We'll attempt to send the PendingRequest. We'll also use the throw
68+
// method which will throw an exception if the request has failed.
69+
70+
return $pendingRequest->send()->throw();
71+
} catch (FatalRequestException|RequestException $exception) {
72+
// We won't create another pending request if our current attempt is
73+
// the max attempts we can make
74+
75+
if ($currentAttempt === $maxAttempts) {
76+
return $exception instanceof RequestException && $throw === false ? $exception->getResponse() : throw $exception;
77+
}
78+
79+
$pendingRequest = $this->createPendingRequest($request, $mockClient);
80+
81+
// When either the FatalRequestException happens or the RequestException
82+
// happens, we should catch it and check if we should retry. If someone
83+
// has provided a callable into $handleRetry, we'll wait for the result
84+
// of the callable to retry.
85+
86+
if (is_null($handleRetry) || $handleRetry($exception, $pendingRequest) === true) {
87+
continue;
88+
}
89+
90+
// If we should not retry, we need to return the last response. If the
91+
// exception was a RequestException, we should return the response,
92+
// otherwise we'll throw the exception.
93+
94+
return $exception instanceof RequestException && $throw === false ? $exception->getResponse() : throw $exception;
95+
}
96+
} while ($currentAttempt < $maxAttempts);
97+
}
98+
3399
/**
34100
* Send a request asynchronously
35101
*

src/Traits/HandlesExceptions.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@
99

1010
trait HandlesExceptions
1111
{
12+
/**
13+
* Determine if the request has failed.
14+
*
15+
* @param \Saloon\Contracts\Response $response
16+
* @return bool|null
17+
*/
18+
public function hasRequestFailed(Response $response): ?bool
19+
{
20+
return null;
21+
}
22+
1223
/**
1324
* Determine if the request has failed
1425
*

src/Traits/Responses/HasResponseHelpers.php

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,10 +131,6 @@ public function collect(string|int|null $key = null): Collection
131131
*/
132132
public function dto(): mixed
133133
{
134-
if ($this->failed()) {
135-
return null;
136-
}
137-
138134
$dataObject = $this->pendingRequest->createDtoFromResponse($this);
139135

140136
if ($dataObject instanceof WithResponse) {
@@ -144,6 +140,21 @@ public function dto(): mixed
144140
return $dataObject;
145141
}
146142

143+
/**
144+
* Convert the response into a DTO or throw a LogicException if the response failed
145+
*
146+
* @throws \LogicException
147+
* @return mixed
148+
*/
149+
public function dtoOrFail(): mixed
150+
{
151+
if ($this->failed()) {
152+
throw new \LogicException('Unable to create data transfer object as the response has failed.', 0, $this->toException());
153+
}
154+
155+
return $this->dto();
156+
}
157+
147158
/**
148159
* Parse the HTML or XML body into a Symfony DomCrawler instance.
149160
*
@@ -194,6 +205,14 @@ public function redirect(): bool
194205
*/
195206
public function failed(): bool
196207
{
208+
$pendingRequest = $this->getPendingRequest();
209+
210+
$hasRequestFailed = $pendingRequest->getRequest()->hasRequestFailed($this) || $pendingRequest->getConnector()->hasRequestFailed($this);
211+
212+
if ($hasRequestFailed === true) {
213+
return true;
214+
}
215+
197216
return $this->serverError() || $this->clientError();
198217
}
199218

tests/Feature/RequestExceptionTest.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@
1515
use Saloon\Tests\Fixtures\Requests\BadResponseRequest;
1616
use Saloon\Tests\Fixtures\Connectors\BadResponseConnector;
1717
use Saloon\Tests\Fixtures\Exceptions\CustomRequestException;
18+
use Saloon\Tests\Fixtures\Requests\CustomFailHandlerRequest;
1819
use Saloon\Tests\Fixtures\Connectors\CustomExceptionConnector;
1920
use Saloon\Tests\Fixtures\Requests\CustomExceptionUserRequest;
2021
use Saloon\Tests\Fixtures\Exceptions\ConnectorRequestException;
22+
use Saloon\Tests\Fixtures\Connectors\CustomFailHandlerConnector;
2123
use Saloon\Exceptions\Request\Statuses\InternalServerErrorException;
2224
use Saloon\Exceptions\Request\ServerException as SaloonServerException;
2325

@@ -212,3 +214,33 @@
212214
expect($exceptionC->getMessage())->toEqual('OK (200) Response: ' . $exceptionC->getResponse()->body());
213215
expect($exceptionC->getPrevious())->toBeNull();
214216
});
217+
218+
test('you can customise if saloon determines if a request has failed on a connector', function () {
219+
$mockClient = new MockClient([
220+
MockResponse::make(['message' => 'Success']),
221+
MockResponse::make(['message' => 'Error: Invalid Cowboy Hat']),
222+
]);
223+
224+
$responseA = CustomFailHandlerConnector::make()->send(new UserRequest, $mockClient);
225+
226+
expect($responseA->failed())->toBeFalse();
227+
228+
$responseB = CustomFailHandlerConnector::make()->send(new UserRequest, $mockClient);
229+
230+
expect($responseB->failed())->toBeTrue();
231+
});
232+
233+
test('you can customise if saloon determines if a request has failed on a request', function () {
234+
$mockClient = new MockClient([
235+
MockResponse::make(['message' => 'Success']),
236+
MockResponse::make(['message' => 'Yee-naw: Horse Not Found']),
237+
]);
238+
239+
$responseA = TestConnector::make()->send(new CustomFailHandlerRequest, $mockClient);
240+
241+
expect($responseA->failed())->toBeFalse();
242+
243+
$responseB = TestConnector::make()->send(new CustomFailHandlerRequest, $mockClient);
244+
245+
expect($responseB->failed())->toBeTrue();
246+
});

0 commit comments

Comments
 (0)