This is a technical case study based on a real-world integration scenario with the Google Sheets API, focused on solving quota scarcity and high error-rate problems. In the original scenario, direct load requests quickly blew past the Google ecosystem's limit (300 requests/minute), triggering cascading failures (HTTP 429 - Too Many Requests). To solve the bottleneck, a mock service in Go (sheets-mock-api) was built to faithfully reproduce the real limits, and the Graceful Degradation with Redis pattern was implemented in the main Laravel application (main-service-api).
This is a technical case study based on a real-world integration scenario with the Google Sheets API, focused on solving quota scarcity and high error-rate problems.
In the original scenario, direct load requests quickly blew past the Google ecosystem's limit (300 requests/minute), triggering cascading failures (HTTP 429 - Too Many Requests).
To solve the bottleneck, a mock service in Go (sheets-mock-api) was built to faithfully reproduce the real limits, and the Graceful Degradation with Redis pattern was implemented in the main Laravel application (main-service-api).
sheets-mock-api), Redis, Docker Compose, k6 (Grafana) for load testing.Source Code and Load Reports: Full repository on GitHub containing the services, k6 scripts, and Docker Compose instructions: 🔗 php-graceful-degradation-rate-limit
The starting point (branch: feature/01-direct-api-unstable) of the case study is the scenario with no resilience protection: k6 fires load requests against main-service-api (Laravel), which in turn queries sheets-mock-api (Go) directly for every request it receives.
sheets-mock-api implements a strict Rate Limiting algorithm via Leaky/Token Bucket calibrated for 300 requests per minute (5 req/s on average), mirroring the real read/write quota limits of the Google Sheets API.
When the request rate from main-service-api exceeds this ceiling, the external API immediately starts responding with HTTP 429 Too Many Requests. Without a handling layer or graceful degradation, main-service-api passes these errors straight through to end clients.
During the first 160s, the system operates normally with 100% success, but as soon as traffic peaks at t=180s, the Google Sheets limit of 300 req/min is exhausted, collapsing the integration and generating 100% errors (HTTP 429 / 502) in that window. Even with temporary Token Bucket recoveries as traffic drops, the buildup of requests triggers new bottlenecks (as seen at t=240s), totaling 160 failures (12.7% global error rate).
Initial Situation:
Final Architecture:
main-service-api continuously counts the calls sent to the external API within 1-minute sliding windows. As long as consumption stays at or below 50% of the quota (up to 150 req/min), the application operates in Direct Mode, passing reads and writes through synchronously to sheets-mock-api with no need to go through intermediate layers.
The architecture's intelligence kicks in the moment the quota crosses the 50% trigger. From that point on, the system automatically activates Graceful Degradation mode, changing the execution flow to protect the external quota without compromising the user experience.
For read operations, the service stops making HTTP calls to the Go API and instead queries the snapshot kept in the Redis cache, merging it in real time with pending changes to instantly deliver perfectly up-to-date data. For writes, instead of risking a breach of the 300 req/min limit with synchronous writes, the mutation is recorded in a delta queue (delta queue) in Redis and immediately reflected in the local read cache.
With this separation, as soon as the sliding window resets and quota consumption returns to safe levels (< 50%), a background worker steps in to asynchronously drain the delta queue, persisting all accumulated writes to the Google Sheets API at a controlled pace, without generating new request spikes.
| Branch | What was added / changed |
|---|---|
feature/01-direct-api-unstable | Direct HTTP communication (Laravel $\rightarrow$ Go) with no protection. Quick quota blowout at peak and a global failure rate of 12.7% (HTTP 429 / 502). |
feature/02-redis-quota-counter | Redis Quota Counter: Introduces 1-minute sliding-window monitoring. Detects and fires the trigger when crossing 50% of the quota (150 req/min). |
feature/03-graceful-read-degradation | Degraded Reads with Deltas: Once past 50%, intercepts read calls and serves data via the Redis snapshot merged with local buffer changes in real time. |
feature/04-delta-queue-async-worker | Asynchronous Writes & Worker: Writes above 50% are sent to a delta queue in Redis. Adds the background worker that drains the queue and syncs to the Go API once the quota normalizes (< 50%). |
Detailed information about the main decisions in this case study:
| Decision | Alternative Considered | Why It Was Chosen | Trade-off Accepted |
|---|---|---|---|
| Dynamic Activation Trigger at 50% of the Quota | Keep the cache/degradation layer active 100% of the time | Preserves direct, synchronous query and write behavior to the API while quota is safe ($\le 150\text{ req/min}$), only triggering the intermediate layer's overhead during critical moments. | Higher API quota consumption during normal operation, in exchange for avoiding the continuous CPU, memory, and processing cost of the cache/degradation layer on every request. |
| Delta Buffer in Redis for Writes | Block writes or send synchronous writes during degradation | Avoids blowing past the external API's hard $300\text{ req/min}$ limit during load spikes and guarantees an instant response to the user with no data loss. | Eventual consistency: writes are temporarily held in the queue until the quota normalizes, at which point they're actually persisted to the external API. |
| Asynchronous Draining via Worker (Delta Queue) | Try to sync all pending mutations at once via HTTP | Allows the accumulated requests to be queued and paced in the background as soon as the quota resets ($< 50%$), avoiding new traffic spikes on the external API. | Additional complexity in managing worker state (handling retries, precedence ordering, and connection failures during draining). |
| Combined Read (Snapshot + Local Deltas) | Serve only the old (stale) cache with no recent changes | Guarantees that a user who just wrote data during degradation mode sees that change reflected immediately on read, without querying the external API. | Higher Redis memory usage to store the per-user/resource delta list, plus data-merge logic in the application layer (Laravel). |
Mock Service in Go (sheets-mock-api) | Use the real Google Sheets API for load testing | Enables deterministic simulation of the Token Bucket algorithm ($300\text{ req/min}$) with no costs, test-network throttling, account bans, or dependency on production credentials. | The mock needs to mirror the real Google ecosystem's headers, status codes (HTTP 429), and latencies with surgical precision. |
main-service-api/app/Services/GoogleSheetsQuotaService.phpfeature/02-redis-quota-counterInstead of resetting the count on whole minutes (which can create spikes at window boundaries), GoogleSheetsQuotaService implements a second-by-second, 60-second Sliding Window algorithm. Every second generates an individual key in Redis with a 60s TTL, and total consumption is calculated by summing the keys in the current 60-second window via MGET.
public function registerRequest(): int
{
$now = time();
$key = self::KEY_PREFIX . $now;
$current = Redis::incr($key);
if ($current === 1) {
Redis::expire($key, self::TTL_SECONDS);
}
return $this->getQuotaConsumed();
}
...
public function isDegraded(?int $consumed = null): bool
{
$consumed = $consumed ?? $this->getQuotaConsumed();
return $consumed > $this->getThreshold();
}
main-service-api/app/Http/Middleware/QuotaTrackerMiddleware.phpfeature/02-redis-quota-counterThe middleware intercepts the request, queries GoogleSheetsQuotaService to check whether we've hit the 50% quota threshold, and injects state-control headers into the response sent back to the client/k6.
public function handle(Request $request, Closure $next): Response
{
$consumed = $this->quotaService->registerRequest();
$isDegraded = $this->quotaService->isDegraded($consumed);
$request->attributes->set('quota_consumed', $consumed);
$request->attributes->set('is_degraded', $isDegraded);
$response = $next($request);
$response->headers->set('X-Quota-Consumed', (string) $consumed);
$response->headers->set('X-System-Degraded', $isDegraded ? 'true' : 'false');
return $response;
}
main-service-api/app/Services/GoogleSheetsService.phpfeature/03-graceful-read-degradationGoogleSheetsService queries GoogleSheetsQuotaService to evaluate the quota state in real time and decides whether to perform a direct, synchronous lookup against the Go Google Sheets API or activate the Graceful Degradation flow, combining the last valid copy (snapshot) with pending changes held in the local buffer.
public function getRows(string $sheet = 'orders'): array
{
$isDegraded = $this->quotaService->isDegraded();
if ($isDegraded) {
return $this->getDegradedMergedRows($sheet);
}
return $this->getDirectRows($sheet);
}
Snippet: main-service-api/app/Console/Commands/DrainQuotaBufferCommand.php
Branch: feature/04-delta-queue-async-worker
DrainQuotaBufferCommand acts as a background worker (long-running process) responsible for continuously monitoring quota consumption in Redis. It guarantees a paced, safe drain of the atomic write queue (sheets_write_buffer) to the Google Sheets API, only once the quota settles at a safe level ($\le 150\text{ req/min}$).
public function handle(GoogleSheetsQuotaService $quotaService, GoogleSheetsService $sheetsService): int
{
$sheet = $this->option('sheet');
$loop = $this->option('loop');
$sleep = (int) $this->option('sleep');
$this->info("Iniciando Worker de Drenagem Assíncrona para a folha [{$sheet}]...");
do {
$consumed = $quotaService->getQuotaConsumed();
$bufferLen = $sheetsService->getBufferLength($sheet);
if ($consumed <= 150 && $bufferLen > 0) {
$this->info("Cota Normalizada ({$consumed} req/min). Drenando {$bufferLen} itens pendentes do buffer...");
$drained = $sheetsService->drainBuffer($sheet, 20);
$this->info("Drenados {$drained} itens do buffer com sucesso.");
}
if ($loop) {
sleep($sleep);
}
} while ($loop);
return Command::SUCCESS;
}
| Time t | Service State | Total Req | Normal Req | Degraded Req | CPU Usage (%) | RAM Usage (MiB) | Redis Memory Usage (MB) |
|---|---|---|---|---|---|---|---|
| 10s | NORMAL | 22 | 22 | 0 | 3.00% | 37.50 MiB | 0.85 MB |
| 60s | NORMAL | 30 | 30 | 0 | 5.20% | 42.30 MiB | 1.12 MB |
| 70s | DEGRADED | 32 | 0 | 32 | 6.80% | 44.80 MiB | 1.18 MB |
| 120s | DEGRADED | 40 | 0 | 40 | 10.40% | 52.80 MiB | 1.38 MB |
| 180s | DEGRADED (Peak) | 80 | 0 | 80 | 15.40% | 60.50 MiB | 1.45 MB |
| 200s | DEGRADED | 67 | 0 | 67 | 13.53% | 57.63 MiB | 1.42 MB |
| 240s | DEGRADED | 40 | 0 | 40 | 9.80% | 51.90 MiB | 1.36 MB |
| 270s | NORMAL (Recovered) | 25 | 25 | 0 | 5.10% | 42.00 MiB | 1.15 MB |
| 300s | NORMAL | 10 | 10 | 0 | 2.10% | 37.50 MiB | 0.95 MB |
HTTP 429 and HTTP 502 errors.The complete code, with all four branches, the frozen READMEs for each stage, and the raw reports from each run, is available at: