Cache integration API reads and refresh them on a schedule
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 28s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Hickmeister
2026-10-11 19:01:32 +01:00
co-authored by Claude Sonnet 5.5
parent 8bee8d42c7
commit 2fe4fbd91e
10 changed files with 253 additions and 24 deletions
+6 -1
View File
@@ -8,7 +8,7 @@ use Throwable;
class Bambuddy
{
public function __construct(private IntegrationSettings $settings) {}
public function __construct(private IntegrationSettings $settings, private IntegrationCache $cache) {}
public function url(): string
{
@@ -26,6 +26,11 @@ class Bambuddy
throw new RuntimeException('BamBuddy API key is not configured.');
}
return $this->cache->remember('bambuddy.printers.'.md5($this->url()), 45, fn (): array => $this->fetchPrinters());
}
private function fetchPrinters(): array
{
$url = rtrim($this->url(), '/').'/api/v1/printers';
$apiKey = $this->settings->for('bambuddy')['api_key'];
+8 -5
View File
@@ -7,7 +7,7 @@ use RuntimeException;
class DryerClient
{
public function __construct(private IntegrationSettings $settings) {}
public function __construct(private IntegrationSettings $settings, private IntegrationCache $cache) {}
public function configured(): bool
{
@@ -45,14 +45,14 @@ class DryerClient
public function status(): array
{
return Http::withHeaders(['X-API-Key' => $this->apiKey()])->acceptJson()
->connectTimeout(3)->timeout(7)->get($this->url('status'))->throw()->json() ?? [];
return $this->cache->remember('dryer.status', 20, fn (): array => Http::withHeaders(['X-API-Key' => $this->apiKey()])->acceptJson()
->connectTimeout(3)->timeout(7)->get($this->url('status'))->throw()->json() ?? []);
}
public function history(): array
{
return Http::withHeaders(['X-API-Key' => $this->apiKey()])->acceptJson()
->connectTimeout(3)->timeout(7)->get($this->url('history'), ['limit' => 60])->throw()->json() ?? [];
return $this->cache->remember('dryer.history', 120, fn (): array => Http::withHeaders(['X-API-Key' => $this->apiKey()])->acceptJson()
->connectTimeout(3)->timeout(7)->get($this->url('history'), ['limit' => 60])->throw()->json() ?? []);
}
public function start(string $profile, int $targetTemperature, int $dryingTime): array
@@ -74,6 +74,9 @@ class DryerClient
private function command(string $path, array $payload = []): array
{
$this->cache->forget('dryer.status');
$this->cache->forget('dryer.history');
return Http::withHeaders(['X-API-Key' => $this->apiKey()])->acceptJson()
->connectTimeout(3)->timeout(7)->post($this->url($path), [
'requestId' => 'dashboard-'.bin2hex(random_bytes(12)), ...$payload,
+11 -7
View File
@@ -11,6 +11,8 @@ class Etsy
{
private const API_URL = 'https://api.etsy.com/v3';
public function __construct(private IntegrationCache $cache) {}
/** @var array{keystring: ?string, shared_secret: ?string, shop_name: string, redirect_uri: ?string}|null */
private ?array $credentials = null;
@@ -62,18 +64,20 @@ class Etsy
]);
}
public function receipts(EtsyConnection $connection): array
public function receipts(EtsyConnection $connection, bool $fresh = false): array
{
return $this->get('application/shops/'.$connection->shop_id.'/receipts', $this->accessToken($connection), [
'was_paid' => 'true', 'was_shipped' => 'false', 'limit' => 20,
]);
return $this->cache->remember('etsy.receipts.'.$connection->shop_id, 300, fn (): array => $this->get(
'application/shops/'.$connection->shop_id.'/receipts', $this->accessToken($connection), [
'was_paid' => 'true', 'was_shipped' => 'false', 'limit' => 20,
]), $fresh);
}
public function listings(EtsyConnection $connection): array
{
return $this->get('application/shops/'.$connection->shop_id.'/listings', $this->accessToken($connection), [
'state' => 'active', 'limit' => 20,
]);
return $this->cache->remember('etsy.listings.'.$connection->shop_id, 600, fn (): array => $this->get(
'application/shops/'.$connection->shop_id.'/listings', $this->accessToken($connection), [
'state' => 'active', 'limit' => 20,
]));
}
private function accessToken(EtsyConnection $connection): string
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Services;
use Closure;
use Illuminate\Support\Facades\Cache;
use RuntimeException;
use Throwable;
/**
* Read-through cache for slow third-party API calls.
*
* Fresh entries are served instantly, expired entries fall back to the last good value if the
* upstream call fails, and a short failure cooldown stops every page load waiting on a dead API.
* The scheduled `integrations:refresh` command runs with $forceRefresh enabled to keep it warm.
*/
class IntegrationCache
{
private const RETAIN_SECONDS = 86400;
private const FAILURE_COOLDOWN_SECONDS = 30;
public bool $forceRefresh = false;
/**
* @template TValue
*
* @param Closure(): TValue $fetch
* @return TValue
*/
public function remember(string $key, int $freshSeconds, Closure $fetch, bool $fresh = false): mixed
{
$cacheKey = 'integration:'.$key;
$entry = Cache::get($cacheKey);
$bypass = $fresh || $this->forceRefresh;
if (! $bypass && is_array($entry) && time() < $entry['fetched_at'] + $freshSeconds) {
return $entry['value'];
}
if (! $bypass && ! is_array($entry) && ($failure = Cache::get($cacheKey.':failed'))) {
throw new RuntimeException((string) $failure);
}
try {
$value = $fetch();
} catch (Throwable $exception) {
if (is_array($entry)) {
report($exception);
return $entry['value'];
}
Cache::put($cacheKey.':failed', $exception->getMessage(), self::FAILURE_COOLDOWN_SECONDS);
throw $exception;
}
Cache::put($cacheKey, ['value' => $value, 'fetched_at' => time()], self::RETAIN_SECONDS);
Cache::forget($cacheKey.':failed');
return $value;
}
public function forget(string $key): void
{
Cache::forget('integration:'.$key);
Cache::forget('integration:'.$key.':failed');
}
}
+17 -6
View File
@@ -5,6 +5,7 @@ namespace App\Services;
use App\Models\IntegrationSetting;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use RuntimeException;
@@ -19,7 +20,7 @@ class Parcel2Go
private ?string $profileToken = null;
public function __construct(private IntegrationSettings $settings) {}
public function __construct(private IntegrationSettings $settings, private IntegrationCache $cache) {}
public function hasScope(string $scope): bool
{
@@ -95,12 +96,13 @@ class Parcel2Go
public function fetch(string $path): array
{
$token = str_starts_with(ltrim($path, '/'), 'me/') && $this->profileConnected()
? $this->profileAccessToken()
: $this->accessToken();
$usesProfile = str_starts_with(ltrim($path, '/'), 'me/') && $this->profileConnected();
$settings = $this->settings->for('parcel2go');
$key = 'parcel2go.'.($usesProfile ? 'profile' : 'client').'.'.md5(implode('|', [$this->url($path), $settings['client_id'] ?? '', $settings['profile_client_id'] ?? '']));
return Http::withToken($token)->acceptJson()->connectTimeout(3)->timeout(15)
->get($this->url($path))->throw()->json() ?? [];
return $this->cache->remember($key, 300, fn (): array => Http::withToken($usesProfile ? $this->profileAccessToken() : $this->accessToken())
->acceptJson()->connectTimeout(3)->timeout(15)
->get($this->url($path))->throw()->json() ?? []);
}
public function quote(array $details): array
@@ -240,6 +242,14 @@ class Parcel2Go
throw new RuntimeException('Parcel2Go credentials are not configured.');
}
$tokenKey = 'parcel2go.token.'.md5(implode('|', [$settings['auth_url'], $settings['client_id'], $settings['client_secret'], $settings['scope'] ?? '']));
$cached = Cache::get($tokenKey);
if (is_array($cached) && filled($cached['token'] ?? null)) {
$this->activeScope = $cached['scope'];
return $this->token = $cached['token'];
}
$scope = (string) ($settings['scope'] ?? 'public-api');
$response = $this->tokenResponse($settings, $scope);
if ($scope !== 'public-api' && $response->status() === 400 && $response->json('error') === 'invalid_scope') {
@@ -254,6 +264,7 @@ class Parcel2Go
}
$this->activeScope = $scope;
Cache::put($tokenKey, ['token' => $token, 'scope' => $scope], max(60, (int) $response->json('expires_in', 3600) - 120));
return $this->token = $token;
}