Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions app/Http/Clients/HelloFresh/HelloFreshClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -166,17 +166,21 @@ protected function request(string $url): Response
* @throws ConnectionException
* @throws RequestException
*/
public function getRecipes(Country $country, string $locale, int $skip = 0): RecipesResponse
public function getRecipes(Country $country, string $locale, int $skip = 0, ?int $take = null): RecipesResponse
{
$countryCode = Str::upper($country->code);

if ($take === null) {
$take = $country->take;
}

$url = sprintf(
'%s/gw/api/recipes?country=%s&locale=%s-%s&take=%d&skip=%d',
$country->domain,
$countryCode,
Str::lower($locale),
$countryCode,
$country->take,
$take,
$skip,
);

Expand Down
56 changes: 39 additions & 17 deletions app/Jobs/Recipe/FetchRecipePageJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,27 @@
use App\Contracts\LauncherJobInterface;
use App\Enums\QueueEnum;
use App\Http\Clients\HelloFresh\HelloFreshClient;
use App\Jobs\Concerns\HandlesApiFailuresTrait;
use App\Models\Country;
use Illuminate\Bus\Batchable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Context;
use Illuminate\Support\Facades\Log;

/**
* @method static void dispatch(Country $country, string $locale, int $skip = 0, bool $paginates = true)
* @method static void dispatch(Country $country, string $locale, int $skip = 0, bool $paginates = true, ?int $take = null)
*/
class FetchRecipePageJob implements LauncherJobInterface, ShouldQueue
{
use Batchable;
use HandlesApiFailuresTrait;
use Queueable;

/**
* The number of times the job may be attempted.
*/
public int $tries = 3;
public int $tries = 5;

/**
* Create a new job instance.
Expand All @@ -36,8 +35,13 @@ public function __construct(
public string $locale,
public int $skip = 0,
public bool $paginates = true,
public ?int $take = null
) {
$this->onQueue(QueueEnum::HelloFresh->value);

if ($take === null) {
$this->take = $country->take;
}
}

/**
Expand Down Expand Up @@ -67,18 +71,8 @@ public function handle(HelloFreshClient $client): void
]);

try {
$response = $client->withOutThrow()
->getRecipes($this->country, $this->locale, $this->skip);
} catch (ConnectionException $connectionException) {
$this->handleApiFailure($connectionException);

return;
}

if ($response->failed()) {
$exception = $response->toException();
assert($exception !== null);

$response = $client->getRecipes($this->country, $this->locale, $this->skip, $this->take);
} catch (ConnectionException|RequestException $exception) {
$this->handleApiFailure($exception);

return;
Expand All @@ -91,7 +85,35 @@ public function handle(HelloFreshClient $client): void

// Only add next page fetch to batch if pagination is enabled
if ($this->paginates && $response->hasMorePages()) {
$this->batch()?->add([new self($this->country, $this->locale, $response->nextSkip(), paginates: true)]);
$this->batch()?->add([new self($this->country, $this->locale, $response->nextSkip(), paginates: true, take: $this->take)]);
}
}

/**
* Handle a failed API request with logging and retry logic.
*
* @throws ConnectionException
* @throws RequestException
*/
protected function handleApiFailure(ConnectionException|RequestException $exception): void
{
$isLastAttempt = $this->attempts() >= $this->tries;

if ($isLastAttempt) {
throw $exception;
}

Log::warning(static::class . ' failed, retrying', [
'attempt' => $this->attempts(),
'exception' => $exception->getMessage(),
]);

if ($this->take > 50 && $exception instanceof RequestException && $exception->response->serverError()) {
self::dispatch($this->country, $this->locale, $this->skip, $this->paginates, $this->take - 50);

return;
}

$this->release(30);
}
}
100 changes: 93 additions & 7 deletions tests/Unit/Jobs/FetchRecipePageJobTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
use App\Models\Country;
use GuzzleHttp\Psr7\Response as Psr7Response;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response as HttpResponse;
use Illuminate\Support\Facades\Bus;
use Mockery;
use PHPUnit\Framework\Attributes\Test;
Expand Down Expand Up @@ -47,7 +50,7 @@ public function it_has_correct_tries(): void

$job = new FetchRecipePageJob($country, 'en');

$this->assertSame(3, $job->tries);
$this->assertSame(5, $job->tries);
}

#[Test]
Expand Down Expand Up @@ -81,10 +84,30 @@ public function it_accepts_custom_skip_value(): void
$this->assertSame(100, $job->skip);
}

#[Test]
public function it_defaults_take_from_country(): void
{
$country = Country::factory()->create(['take' => 200]);

$job = new FetchRecipePageJob($country, 'en');

$this->assertSame(200, $job->take);
}

#[Test]
public function it_accepts_custom_take_value(): void
{
$country = Country::factory()->create(['take' => 200]);

$job = new FetchRecipePageJob($country, 'en', take: 150);

$this->assertSame(150, $job->take);
}

#[Test]
public function handle_fetches_recipes(): void
{
$country = Country::factory()->create();
$country = Country::factory()->create(['take' => 50]);

$response = $this->createRecipesResponse([
'items' => [
Expand All @@ -97,10 +120,9 @@ public function handle_fetches_recipes(): void
]);

$client = Mockery::mock(HelloFreshClient::class);
$client->shouldReceive('withOutThrow')->andReturnSelf();
$client->shouldReceive('getRecipes')
->once()
->with($country, 'en', 0)
->with($country, 'en', 0, 50)
->andReturn($response);

Bus::fake();
Expand All @@ -114,7 +136,7 @@ public function handle_fetches_recipes(): void
#[Test]
public function handle_uses_skip_value(): void
{
$country = Country::factory()->create();
$country = Country::factory()->create(['take' => 50]);

$response = $this->createRecipesResponse([
'items' => [],
Expand All @@ -125,10 +147,9 @@ public function handle_uses_skip_value(): void
]);

$client = Mockery::mock(HelloFreshClient::class);
$client->shouldReceive('withOutThrow')->andReturnSelf();
$client->shouldReceive('getRecipes')
->once()
->with($country, 'en', 100)
->with($country, 'en', 100, 50)
->andReturn($response);

Bus::fake();
Expand All @@ -139,6 +160,71 @@ public function handle_uses_skip_value(): void
$this->assertTrue(true);
}

#[Test]
public function handle_dispatches_new_job_with_reduced_take_on_server_error(): void
{
$country = Country::factory()->create(['take' => 200]);

$httpResponse = new HttpResponse(new Psr7Response(500));
$exception = new RequestException($httpResponse);

$client = Mockery::mock(HelloFreshClient::class);
$client->shouldReceive('getRecipes')
->once()
->andThrow($exception);

Bus::fake();

$job = new FetchRecipePageJob($country, 'en');
$job->handle($client);

Bus::assertDispatched(function (FetchRecipePageJob $dispatched) use ($country): bool {
return $dispatched->take === 150
&& $dispatched->country->is($country)
&& $dispatched->locale === 'en'
&& $dispatched->skip === 0;
});
}

#[Test]
public function handle_does_not_dispatch_reduced_take_on_connection_error(): void
{
$country = Country::factory()->create(['take' => 200]);

$client = Mockery::mock(HelloFreshClient::class);
$client->shouldReceive('getRecipes')
->once()
->andThrow(new ConnectionException('Connection failed'));

Bus::fake();

$job = new FetchRecipePageJob($country, 'en');
$job->handle($client);

Bus::assertNotDispatched(FetchRecipePageJob::class);
}

#[Test]
public function handle_does_not_dispatch_reduced_take_when_take_is_fifty_or_less(): void
{
$country = Country::factory()->create(['take' => 50]);

$httpResponse = new HttpResponse(new Psr7Response(500));
$exception = new RequestException($httpResponse);

$client = Mockery::mock(HelloFreshClient::class);
$client->shouldReceive('getRecipes')
->once()
->andThrow($exception);

Bus::fake();

$job = new FetchRecipePageJob($country, 'en');
$job->handle($client);

Bus::assertNotDispatched(FetchRecipePageJob::class);
}

protected function createRecipesResponse(array $data): RecipesResponse
{
$psr7Response = new Psr7Response(200, [], json_encode($data));
Expand Down