Learn Gmail's quota from Google's refusal instead of assuming 15,000

Google lowered the per-user limit to 6,000 units a minute for projects
created since May 2026 and raised a messages.get from 5 units to 20.
The pacer had 15,000 and 5 written into it, so what it took for 9,000
units a minute was 36,000: most of what an import asked for was
refused, the log filled with retries, and a refused listing ended the
import's chain at CRITICAL (#90).

  the limit      starts at 15,000 and is read out of the first 403,
                 which names the limit and its value; forgotten after
                 a day so a raised one is found again. Nothing to set.
  the cost       20 a message, and fifty messages a batch
  shared         spend, refusal and limit are in a database-backed
                 cache pool, so worker-backlog, worker-ingest and the
                 web container draw on one allowance
  a refusal      is "not now", not an error: a batch, an import page
                 or a sync that meets one is put back until the
                 account's minute is over, and a refused sync still
                 restarts an import that has gone quiet

The import line in the background-work indicator shows the limit,
roughly what is in use of it, how long the rest takes, and says so
when Google has paused the account.

No migration: the pool is a key prefix in cache_items.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5.5 committed 2026-10-13 08:30:17 +02:00
1 parent 5debbdbc01
commit b44283cb1c
23 files changed
+1133 -122

No files matched your search

+12
View File
@@ -8,6 +8,18 @@ The published image tags: `latest` follows the most recent release below,
## Unreleased
**A Gmail import no longer runs into Google's quota and stays there.** Google lowered the
per-user limit to 6,000 units a minute for projects created since May 2026 and raised the cost
of fetching a message from 5 units to 20; plMail paced imports for 15,000 at the old cost, so on
a new project most of what an import asked for was refused, the log filled with rate-limit
warnings and an import could stop without resuming. plMail now reads the limit out of Google's
first refusal and keeps to it — nothing to configure — counts a message at its real cost,
fetches fifty at a time, and shares one allowance between its workers. A refusal is no longer an
error: the batch, import page or sync that met it waits out the minute and carries on. The
import line in the background-work indicator now shows the quota, roughly how much of it is in
use, how long the rest of the import takes, and says so when Google has paused it. No
migration. [#90](https://github.com/karatektus/pl_mail/issues/90), reported by @karamanliev.
**The tab icon is the one you picked, in Firefox too.** Settings → Appearance → Logo changed the
icon in the app but not in the tab, which kept showing the wax seal. Every page also linked a
fixed 192px wax-seal PNG as a second tab icon, and Firefox prefers that to the SVG for a tab. The
+10
View File
@@ -47,6 +47,16 @@ framework:
provider: doctrine.dbal.default_connection
default_lifetime: 3600
# What each Gmail account has spent of its quota this minute, when
# Google last refused it, and the limit a refusal named. Shared for
# the reason the signal above is: worker-backlog, worker-ingest and
# the web container all call Gmail for the same account, and a
# refusal one of them met has to hold the others back. No default
# lifetime: GmailQuotaPacer sets each entry's own.
app.gmail_quota:
adapter: cache.adapter.doctrine_dbal
provider: doctrine.dbal.default_connection
# A Stop pressed on the summary card (see ThreadSummaryStop).
#
# Shared for the same reason again: the Stop is a request of its
+10 -1
View File
@@ -1,4 +1,4 @@
<!-- translated-from: internals/mail-ingest.md sha1:dcb2b781f03c1702974cc382857086d18024e21c -->
<!-- translated-from: internals/mail-ingest.md sha1:7329297e0b7553b6e0fb3e23948afdcff155a609 -->
# Mail-Ingest
Vom Anbieter in die Datenbank: wie aus Bytes eine Zeile `Message` wird, was mit ihr geschieht,
@@ -93,6 +93,15 @@ die Vorgeschichte gehört `App\Service\Mail\MailImporter`, auf der Warteschlange
- **Gmail fragt zuerst namentlich nach dem Posteingang,** dann blättert es die Auflistung des
ganzen Postfachs Anfrage für Anfrage durch; das Seiten-Token liegt zwischen den Jobs auf dem
Konto.
- **Gmails Kontingent wird gelernt, nicht eingestellt.** Google erlaubt einem Nutzer eine Zahl
von Kontingent-Einheiten pro Minute und sagt erst, wie viele, wenn es eine Anfrage ablehnt.
`GmailQuotaPacer` beginnt bei 15.000, liest das Limit aus der ersten Ablehnung (ein seit Mai
2026 angelegtes Projekt hat 6.000) und taktet von da an jedes Konto auf vier Fünftel davon;
ein gelerntes Limit wird nach einem Tag vergessen, damit ein angehobenes wiedergefunden wird.
Was ein Konto verbraucht hat und wann es zuletzt abgelehnt wurde, liegt in einem Cache-Pool
in der Datenbank, sodass alle Worker aus einem Kontingent schöpfen. Eine Ablehnung ist auf
diesem Weg nirgends ein Fehler: Ein Stapel, eine Import-Seite oder ein Sync, der auf eine
trifft, wird zurückgestellt und läuft, wenn die Minute des Kontos vorbei ist.
- **Microsoft liest die erste Delta-Runde jedes Ordners seitenweise.** Ein Ordner ohne
Delta-Link wird auf dem Konto eingeplant (`Account::$graphImport`), statt vom Abgleich
aufgezählt zu werden, und `GraphApiSyncer::importPage()` geht diese Runde in Schritten von
+8
View File
@@ -82,6 +82,14 @@ The two are separate now. A sync arranges for new mail to be seen and stops; the
rule.
- **Gmail asks for the Inbox by name first,** then pages the whole-mailbox listing one request
at a time, with the page token kept on the account between jobs.
- **Gmail's quota is learnt, not configured.** Google allows one user a number of quota units
a minute and does not say how many until it refuses a request. `GmailQuotaPacer` starts from
15,000, reads the limit out of the first refusal (a project created since May 2026 has
6,000) and paces every account to four fifths of it from then on; a learnt limit is forgotten
after a day so a raised one is found again. What an account has spent and when it was last
refused are in a database-backed cache pool, so every worker draws on one allowance. A
refusal is not an error anywhere on this path: a batch, an import page or a sync that meets
one is put back to run when the account's minute is over.
- **Microsoft reads each folder's first delta round a page at a time.** A folder without a
delta link is planned on the account (`Account::$graphImport`) instead of being enumerated
by the sync, and `GraphApiSyncer::importPage()` walks that round fifty messages at a call,
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Domain\DTO\Mail;
/**
* Where a Gmail import stands against Google's quota, for the line that shows
* the import.
*
* An import of a large mailbox is as fast as the quota lets it be and no
* faster, so "why is this taking so long" and "is it stuck" are both
* questions about the quota. Before this the line had one word for all of it
* — "waiting" — whether the import was between two listings, held for a
* minute by Google, or had stopped (#90).
*
* Every figure here is this application's own reckoning. Google reports
* neither what is left of a quota nor, until it refuses a request, what the
* quota is. See GmailQuotaPacer.
*/
final readonly class GmailQuotaStatus
{
/**
* @param int $limit units a minute the import is being paced to
* @param bool $learnt whether Google named that limit, or it is
* the one assumed until it does
* @param int $used roughly what the bulk fetching spent in
* the minute just gone
* @param int $pausedSeconds how long Google's last refusal still
* holds the account; 0 when it does not
* @param int|null $minutesLeft how long the rest of the import takes at
* the paced rate, or null when there is no
* total to reckon it from
*/
public function __construct(
public int $limit,
public bool $learnt,
public int $used,
public int $pausedSeconds,
public ?int $minutesLeft,
) {}
public function isPaused(): bool
{
return $this->pausedSeconds > 0;
}
}
+3
View File
@@ -31,6 +31,8 @@ final readonly class ImportLine
* @param bool $waiting nothing has moved for a while: between two
* listings, or because something is wrong —
* the account's own health says which
* @param GmailQuotaStatus|null $quota what Google's quota allows this
* import, for a Gmail account
*/
public function __construct(
public Account $account,
@@ -39,6 +41,7 @@ final readonly class ImportLine
public ?Mailbox $folder,
public bool $waiting,
public ?Label $label = null,
public ?GmailQuotaStatus $quota = null,
) {}
/** Whole percent, floored and never past 100; 0 until there is a total. */
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Infrastructure\Messaging\Handler;
use App\Domain\Exception\GmailThrottledException;
use App\Domain\Exception\OAuthGrantRevokedException;
use App\Domain\Interface\AccountSyncerInterface;
use App\Entity\Mail\Account;
@@ -17,6 +18,8 @@ use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\DelayStamp;
use Throwable;
#[AsMessageHandler]
@@ -35,6 +38,7 @@ final readonly class SyncAccountMessageHandler
private iterable $syncers,
private SyncOrigin $origin,
private MailImporter $importer,
private MessageBusInterface $bus,
) {}
public function __invoke(SyncAccountMessage $message): void
@@ -120,6 +124,42 @@ final readonly class SyncAccountMessageHandler
'accountId' => $message->accountId,
]);
return;
} catch (GmailThrottledException $throttled) {
// NOT A FAILURE, and so neither recorded on the account nor
// rethrown.
//
// Google has refused this account on quota, which while a mailbox
// is being imported is expected — it is how the import learns
// what the quota is (see GmailQuotaPacer). Rethrown, it was a
// warning with two stack traces on each of five retries a minute
// apart, and then the sync was dropped: twenty of those an hour
// on an install that was working as designed (#90).
//
// The same sync is asked for again once the refusal has cleared,
// carrying the moment it was first asked for, so a sync that got
// through in the meantime makes it redundant like any other.
$seconds = max(1, $throttled->getRetryAfterSeconds() ?? 60);
$this->bus->dispatch(
new SyncAccountMessage($message->accountId, $message->trigger ?? null, $message->requestedAt ?? null),
[new DelayStamp(1000 * $seconds)],
);
$this->logger->info('Sync put back: waiting for Gmail quota', [
'accountId' => $message->accountId,
'delaySeconds' => $seconds,
]);
// And the import is still looked after. This used to be reached
// only by a sync that went through, so an import whose chain had
// stopped stayed stopped for as long as the syncs were refused.
$account = $this->accountRepository->find($message->accountId);
if (null !== $account) {
$this->importer->ensureRunning($account);
}
return;
} catch (Throwable $failure) {
// Recorded and RETHROWN. The rethrow is what keeps Messenger's
@@ -17,6 +17,7 @@ use App\Repository\Mail\MessageRepository;
use App\Service\Gmail\GmailAddressFilter;
use App\Service\Gmail\GmailApiSyncer;
use App\Service\Gmail\GmailMessageBuilder;
use App\Service\Gmail\GmailQuotaPacer;
use App\Service\HarvestContactsService;
use App\Service\Label\ThreadLabelSynchronizer;
use App\Service\Imap\MessageThreader;
@@ -36,7 +37,11 @@ use Symfony\Component\Messenger\Stamp\TransportNamesStamp;
#[AsMessageHandler]
final readonly class SyncGmailMessageBatchHandler
{
/** Delay before re-fetching sub-requests that hit the rate limit. */
/**
* Delay before re-fetching sub-requests that failed for a reason other
* than quota — a 5xx, a part missing from the response. One refused on
* quota waits as long as the pacer says the account is being left alone.
*/
private const int RETRY_DELAY_MS = 30000;
public function __construct(
@@ -57,6 +62,7 @@ final readonly class SyncGmailMessageBatchHandler
private MessageCategorizer $categorizer,
private MessageThreader $messageThreader,
private SyncOrigin $origin,
private GmailQuotaPacer $pacer,
) {}
public function __invoke(SyncGmailMessageBatchMessage $message): void
@@ -104,6 +110,17 @@ final readonly class SyncGmailMessageBatchHandler
return;
}
// Google has just refused this account, here or in another process.
// Asking now is a request that will be refused too and counted
// against the minute all the same, so the batch is put back whole to
// come round when the refusal has cleared. Not an error and not a
// retry: nothing went wrong, and there is no ladder to climb.
if ($this->pacer->waitSeconds($account) > 0) {
$this->putBack($account, $toFetch);
return;
}
$fetch = $this->apiClient->getMessages($account, $toFetch);
$payloads = $fetch['payloads'];
@@ -124,21 +141,7 @@ final readonly class SyncGmailMessageBatchHandler
'count' => count($fetch['retryable']),
]);
// Back onto the queue this kind of work belongs on. Left to the
// routing table a retry lands on the live queue, and while an
// account is importing that is its history jumping in front of
// the mail arriving now — a few ids at a time, on every batch
// Google throttled, which during an import is most of them.
$stamps = [new DelayStamp(self::RETRY_DELAY_MS)];
if (true === $account->needsBackfill()) {
$stamps[] = new TransportNamesStamp([GmailApiSyncer::IMPORT_QUEUE]);
}
$this->bus->dispatch(
new SyncGmailMessageBatchMessage($account->id, $fetch['retryable'], $this->origin->current()),
$stamps,
);
$this->putBack($account, $fetch['retryable']);
}
/** @var list<array{message: Message, account: Account}> $built */
@@ -276,6 +279,45 @@ final readonly class SyncGmailMessageBatchHandler
// ── Private ───────────────────────────────────────────────────────────────
/**
* Queue these ids to be fetched later: after the account's quota refusal
* has cleared, if it is under one, and after a short pause otherwise.
*
* A few seconds are added to a quota wait, and not the same few for every
* batch. An import has dozens of batches queued behind the one that was
* refused, each of which is put back in turn; all given the same delay
* they would all come due in the same second.
*
* Back onto the queue this kind of work belongs on. Left to the routing
* table it lands on the live queue, and while an account is importing
* that is its history jumping in front of the mail arriving now.
*
* @param list<string> $gmailIds
*/
private function putBack(Account $account, array $gmailIds): void
{
$wait = $this->pacer->waitSeconds($account);
$delay = $wait > 0 ? 1000 * ($wait + random_int(1, 15)) : self::RETRY_DELAY_MS;
$stamps = [new DelayStamp($delay)];
if (true === $account->needsBackfill()) {
$stamps[] = new TransportNamesStamp([GmailApiSyncer::IMPORT_QUEUE]);
}
$this->bus->dispatch(
new SyncGmailMessageBatchMessage((int) $account->id, $gmailIds, $this->origin->current()),
$stamps,
);
$this->logger->info('SyncGmailMessageBatch: put back to be fetched later', [
'accountId' => $account->id,
'count' => count($gmailIds),
'delaySeconds' => intdiv($delay, 1000),
'quota' => $wait > 0,
]);
}
/**
* Merge the Gmail copy's knowledge onto an existing row: gmailId (covers it
* under the user-scoped dedup from now on), gmailLabelIds, the inbox
+7 -2
View File
@@ -33,8 +33,13 @@ final class GmailApiSyncer
/** Message-list page size (API max is 500). */
private const int PAGE_SIZE = 500;
/** Gmail message IDs per fan-out batch. */
private const int BATCH_SIZE = 100;
/**
* Gmail message IDs per fan-out batch. Fifty is the most Google asks to
* be sent in one batch request, and at twenty units a message it is a
* thousand units — a sixth of the minute on a project with the lowest
* limit, where a hundred was a third of it in one request.
*/
private const int BATCH_SIZE = 50;
/** How many of the Inbox's newest messages an import asks for by name, first. */
private const int INBOX_SLICE = 200;
+276 -46
View File
@@ -5,77 +5,139 @@ declare(strict_types=1);
namespace App\Service\Gmail;
use App\Entity\Mail\Account;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Spaces out the bulk Gmail calls so an import stays inside the account's
* quota instead of finding its edge by being refused.
* quota, and finds out what that quota is by being told.
*
* Google allows one Gmail user 15,000 quota units a minute, across everything
* done in that user's name. Fetching a message costs 5, so a batch of a
* hundred is 500 and thirty batches are the whole minute. Nothing used to
* count: the worker fetched one batch after another as fast as Google
* answered. While the same worker also did the follow-up work for every
* message, that was slow enough to stay under the limit by accident. Once the
* ingest queue held nothing but fetching, an import ran into it — and
* the call that reported it was not the import. A batch that is partly refused
* re-queues the refused ids at `info` and carries on; what failed out loud was
* the next "see what is new" sync of the same account, on labels.list, a call
* that costs one unit. New mail was held up by old mail being fetched too
* fast.
* Google allows one Gmail user a number of quota units a minute, across
* everything done in that user's name. Nothing used to count: the worker
* fetched one batch after another as fast as Google answered, an import ran
* into the limit — and the call that reported it was not the import but the
* next "see what is new" sync of the same account, on labels.list, a call that
* costs one unit. New mail was held up by old mail being fetched too fast.
*
* So this keeps a rate rather than reacting to a refusal. Each account has a
* steady allowance and a small reserve on top of it for a burst; a caller says
* what it is about to spend, and is made to wait first if that would run ahead
* of the allowance. The wait is a few seconds at most for one batch, which is
* why it is a sleep in the handler and not a delayed redelivery: the queue
* behind it is no worse off than behind any other batch, and nothing has to
* be re-sent.
* So this keeps a rate. Each account has a steady allowance and a small
* reserve on top of it for a burst; a caller says what it is about to spend,
* and is made to wait first if that would run ahead of the allowance. The wait
* is a few seconds at most for one batch, which is why it is a sleep in the
* handler and not a delayed redelivery.
*
* WELL UNDER the limit on purpose — the worst minute here is 12,000 units of
* the 15,000. The rest belongs to what is not counted: the person using the
* mailbox (a send is 100, a label change on a selection 50), push-driven
* syncs, and whatever else is signed in to the same Google account.
* ## The limit is learnt, not configured
*
* IN MEMORY, in this process. That is enough because the bulk fetching is one
* worker's job — worker-backlog, which runs an import's listing and its
* batches; worker-ingest also talks to Gmail, but for the handful of calls a
* "see what is new" sync makes, and those are what the headroom above is left
* for. It survives from one message to the next because a worker only resets
* services that ask to be. A restart forgets what was spent and
* starts with a full reserve, which can overshoot for at most the reserve.
* Shared storage would close that, at the price of a database round trip per
* batch to guard against something GmailThrottledException already handles.
* The first version of this had the limit written into it: 15,000 units a
* minute, with a message costing 5. Both were Google's numbers and both
* stopped being true on 1 May 2026 — a project created since then gets 6,000,
* and a message costs 20 for everybody. The pacer went on allowing what it
* believed was 9,000 units a minute and was really 36,000, and an import on a
* new project was refused for most of what it asked (#90).
*
* The limit cannot be asked for: it belongs to the Google Cloud project, and
* reading it takes a Cloud permission no mailbox grant carries. A setting
* would be a number every self-hoster has to look up in a console and keep in
* step with Google. But a refusal says it — the 403 names the limit it was
* refused under and its value — so the pacer starts from the highest limit
* there is, is refused once on a project that has less, and keeps to what it
* was told from then on. A refusal is therefore not a fault here. It is how
* the number arrives, and everything that calls Gmail in bulk treats it as
* "not now" rather than as an error: see refused() and waitSeconds().
*
* FORGOTTEN AFTER A DAY, on purpose. A limit only ever learnt downwards would
* hold an install to 6,000 for ever after Google or its administrator raised
* it. Starting from the top again costs one refused batch a day on a project
* that has less, and that batch is put back like any other.
*
* WELL UNDER the limit — the worst minute here is four fifths of it. The rest
* belongs to what is not counted: the person using the mailbox (a send is
* 100, a label change on a selection 50), push-driven syncs, and whatever
* else is signed in to the same Google account.
*
* ## Shared between processes
*
* What an account has spent and what the project's limit is are kept in a
* cache pool the database backs, not in this object. It was in memory once,
* on the reasoning that bulk fetching is one worker's job. It is two workers'
* — worker-backlog fetches an import's batches, worker-ingest fetches the
* mail arriving meanwhile — and each kept its own count of the same quota.
* More to the point, a refusal one worker met has to stop the other, and the
* web request that is about to ask as well. One read and one write per batch
* is the price, against a batch that is fifty requests to Google.
*
* Two workers spending in the same instant can each read the other's stale
* figure and both go ahead. That overshoots by one batch, inside the fifth
* left spare, and is not worth a lock.
*/
final class GmailQuotaPacer
{
/** What a messages.get costs, and so what each id in a batch costs. */
public const int UNITS_PER_MESSAGE = 5;
public const int UNITS_PER_MESSAGE = 20;
/** What one page of messages.list costs. */
public const int UNITS_PER_LIST_PAGE = 5;
/** The steady allowance: 9,000 units a minute. */
private const float UNITS_PER_SECOND = 150.0;
/**
* Units a minute for one user, before Google has said otherwise: what a
* project that used the API before May 2026 was left with, and the most
* any project has without asking for more.
*/
public const int HIGHEST_LIMIT = 15_000;
/** How far ahead of the allowance a burst may run: 3,000 units. */
/**
* What a project created since May 2026 has. Fallen back to when a
* refusal names the per-user limit and leaves out its value.
*/
public const int CURRENT_DEFAULT_LIMIT = 6_000;
/** The share of the limit that is the steady allowance. */
private const float STEADY_SHARE = 0.6;
/** How far ahead of the allowance a burst may run: a fifth of the limit. */
private const float RESERVE_SECONDS = 20.0;
/**
* Per account id, the moment its spending so far is paid off at the
* steady rate. In the past for an account that has been idle.
*
* @var array<int, float>
* How long an account is left alone after a refusal that came with no
* Retry-After, which is the norm. The limit is counted over a minute, so
* a minute later the whole of it is back.
*/
private array $paidOffAt = [];
private const int COOLDOWN_SECONDS = 60;
/** The longest a Retry-After is believed for. */
private const int LONGEST_COOLDOWN_SECONDS = 900;
/** How long a learnt limit is kept before the pacer starts from the top again. */
private const int LIMIT_LIFETIME_SECONDS = 86_400;
/**
* The width of one slot of the record of what was spent when. Six of them
* are the minute Google counts over; see usedThisMinute().
*/
private const int SLOT_SECONDS = 10;
private const string LIMIT_KEY = 'gmail_quota.limit';
private readonly CacheItemPoolInterface $state;
public function __construct(
private readonly ClockInterface $clock,
// Null in the tests that build this by hand and have one process to
// share with: an array is all the sharing they need.
#[Autowire(service: 'app.gmail_quota')]
?CacheItemPoolInterface $state = null,
) {
$this->state = $state ?? new ArrayAdapter();
}
/**
* Announce a spend, and wait here until the account can afford it.
*
* Only ever a short wait. An account Google has just refused is not
* waited on here — that is a minute, and a worker asleep for a minute is
* every other account's mail not being fetched. The caller asks
* waitSeconds() first and puts its job back instead.
*/
public function spend(Account $account, int $units): void
{
@@ -83,14 +145,23 @@ final class GmailQuotaPacer
return;
}
$key = (int) $account->id;
$now = (float) $this->clock->now()->format('U.u');
$now = $this->now();
$item = $this->state->getItem($this->accountKey($account));
$state = $this->read($item->get());
$paidOffAt = max($this->paidOffAt[$key] ?? 0.0, $now) + $units / self::UNITS_PER_SECOND;
$paidOffAt = max($state['paidOffAt'], $now) + $units / $this->unitsPerSecond();
// Recorded before the wait, not after: what was spent is spent whether
// or not the sleep is interrupted by the worker being stopped.
$this->paidOffAt[$key] = $paidOffAt;
$state['paidOffAt'] = $paidOffAt;
// Against the moment the call will actually be made, which is after
// the wait when there is one.
$slot = $this->slot(max($now, $paidOffAt - self::RESERVE_SECONDS));
$state['spent'][$slot] = ($state['spent'][$slot] ?? 0) + $units;
$state['spent'] = $this->sinceAMinuteAgo($state['spent'], $now);
$this->state->save($item->set($state)->expiresAfter(self::LIMIT_LIFETIME_SECONDS));
$ahead = $paidOffAt - $now - self::RESERVE_SECONDS;
@@ -98,4 +169,163 @@ final class GmailQuotaPacer
$this->clock->sleep($ahead);
}
}
/**
* Google refused a call for this account on quota.
*
* Two things follow. The account is left alone until the refusal has had
* time to clear, by every process — see waitSeconds(). And if the refusal
* was under the per-user limit, the limit it named becomes the one every
* account is paced to, since it is the project's and not the mailbox's.
*
* @param string $limitName Google's name for the limit, e.g. "…PerMinutePerUser"; '' when the body had none
* @param int|null $limitValue units a minute, when the body said
* @param int|null $retryAfterSeconds the Retry-After header, when there was one
*/
public function refused(Account $account, string $limitName = '', ?int $limitValue = null, ?int $retryAfterSeconds = null): void
{
$now = $this->now();
$cooldown = min(self::LONGEST_COOLDOWN_SECONDS, max(1, $retryAfterSeconds ?? self::COOLDOWN_SECONDS));
$item = $this->state->getItem($this->accountKey($account));
$state = $this->read($item->get());
$state['refusedUntil'] = max($state['refusedUntil'], $now + $cooldown);
// The reserve is gone as well: whatever was thought to be left of this
// minute, Google has just said there is none.
$state['paidOffAt'] = max($state['paidOffAt'], $now + self::RESERVE_SECONDS);
$this->state->save($item->set($state)->expiresAfter(self::LIMIT_LIFETIME_SECONDS));
// The per-project limit is two hundred times this one and says nothing
// about how fast a single mailbox may be read.
if (false === str_contains(strtolower($limitName), 'peruser')) {
return;
}
$current = $this->limit();
$learnt = $limitValue ?? self::CURRENT_DEFAULT_LIMIT;
// Downwards only. A refusal under a limit higher than the one being
// kept to is another client spending the same user's quota, not news
// about the limit.
if ($learnt > 0 && $learnt < $current) {
$limit = $this->state->getItem(self::LIMIT_KEY);
$this->state->save($limit->set($learnt)->expiresAfter(self::LIMIT_LIFETIME_SECONDS));
}
}
/**
* How long to leave this account alone, in whole seconds; 0 when it may
* be asked now.
*
* What a job asks before it calls Gmail in bulk, and what it delays itself
* by when the answer is not 0.
*/
public function waitSeconds(Account $account): int
{
$state = $this->read($this->state->getItem($this->accountKey($account))->get());
return max(0, (int) ceil($state['refusedUntil'] - $this->now()));
}
/**
* Roughly what this account has spent in the minute just gone, in units.
*
* AN ESTIMATE, and shown as one. Google does not report what is left of a
* quota, so this is this application's own count of what it announced to
* spend() — the bulk fetching, which is nearly all of an import and none
* of what the person or another client did in the same mailbox. It is
* also counted in ten-second slots, so it runs up to ten seconds stale.
*/
public function usedThisMinute(Account $account): int
{
$now = $this->now();
$state = $this->read($this->state->getItem($this->accountKey($account))->get());
return array_sum(array_filter(
$this->sinceAMinuteAgo($state['spent'], $now),
// A spend that was made to wait is recorded a little ahead, and
// counts once its moment has come.
static fn (int $slot): bool => $slot <= $now,
ARRAY_FILTER_USE_KEY,
));
}
/** Whether the limit is one Google named, rather than the one assumed until it does. */
public function isLimitLearnt(): bool
{
$limit = $this->state->getItem(self::LIMIT_KEY)->get();
return true === is_int($limit) && $limit > 0;
}
/** How many messages a minute an import fetches at the steady allowance. */
public function messagesPerMinute(): int
{
return max(1, (int) floor($this->unitsPerSecond() * 60.0 / self::UNITS_PER_MESSAGE));
}
/** Units a minute one user is being held to: Google's last word, or the highest there is. */
public function limit(): int
{
$limit = $this->state->getItem(self::LIMIT_KEY)->get();
return true === is_int($limit) && $limit > 0 ? $limit : self::HIGHEST_LIMIT;
}
private function unitsPerSecond(): float
{
return $this->limit() * self::STEADY_SHARE / 60.0;
}
/**
* @return array{paidOffAt: float, refusedUntil: float, spent: array<int, int>}
* the moment the account's spending so far is paid off at the
* steady rate, and the moment a refusal stops applying — both in
* the past for an account that has been idle — and what was spent
* in each recent slot, keyed by the second the slot began
*/
private function read(mixed $stored): array
{
$spent = true === is_array($stored) && true === is_array($stored['spent'] ?? null) ? $stored['spent'] : [];
return [
'paidOffAt' => (float) (true === is_array($stored) ? ($stored['paidOffAt'] ?? 0.0) : 0.0),
'refusedUntil' => (float) (true === is_array($stored) ? ($stored['refusedUntil'] ?? 0.0) : 0.0),
'spent' => array_map(intval(...), $spent),
];
}
private function slot(float $at): int
{
return intdiv((int) $at, self::SLOT_SECONDS) * self::SLOT_SECONDS;
}
/**
* The slots a minute ending now has not yet left behind, those still to
* come included.
*
* @param array<int, int> $spent
*
* @return array<int, int>
*/
private function sinceAMinuteAgo(array $spent, float $now): array
{
return array_filter(
$spent,
static fn (int $slot): bool => $slot > $now - 60.0,
ARRAY_FILTER_USE_KEY,
);
}
private function accountKey(Account $account): string
{
return 'gmail_quota.account.' . (int) $account->id;
}
private function now(): float
{
return (float) $this->clock->now()->format('U.u');
}
}
+125 -25
View File
@@ -124,7 +124,7 @@ final class GmailApiClient
'query' => $params,
]);
$body = $this->decode($response, 'messages.list');
$body = $this->decode($account, $response, 'messages.list');
$next = $body['nextPageToken'] ?? null;
return [
@@ -136,7 +136,7 @@ final class GmailApiClient
/**
* Fetch multiple messages (format=full) using the Gmail Batch API.
*
* Packs up to 100 individual messages.get sub-requests into a single
* Packs up to 50 individual messages.get sub-requests into a single
* multipart/mixed HTTP POST. This avoids hammering the per-user per-second
* quota with individual concurrent requests and dramatically reduces
* round-trips for large initial syncs.
@@ -151,7 +151,12 @@ final class GmailApiClient
* empty would let the Messenger message ack and silently drop every id
* in the batch.
*
* @param list<string> $messageIds Maximum 100 per call (enforced by caller via BATCH_SIZE)
* EXCEPT A QUOTA REFUSAL, which is not a failure. Whether Google refuses
* the whole request or some of its parts, the answer is the same: those
* ids come back as `retryable`, and the pacer has been told, so the
* caller can ask it how long to wait before trying them again.
*
* @param list<string> $messageIds Maximum 50 per call (enforced by caller via BATCH_SIZE)
* @return array{payloads: array<string,array<string,mixed>>, retryable: list<string>, gone: list<string>}
*/
public function getMessages(Account $account, array $messageIds): array
@@ -181,7 +186,17 @@ final class GmailApiClient
]);
try {
// Classified first, so the whole request being refused on quota
// is recognised as that and not as "the batch request failed".
$this->assertSuccess($account, $response, 'messages.get(batch)');
$rawBody = $response->getContent();
} catch (GmailThrottledException) {
return [
'payloads' => [],
'retryable' => array_values($messageIds),
'gone' => [],
];
} catch (HttpException $e) {
throw new \RuntimeException(
'Gmail batch request failed: ' . $e->getMessage(),
@@ -191,6 +206,11 @@ final class GmailApiClient
}
$parsed = $this->parseBatchResponse($rawBody);
if (null !== $parsed['refusal']) {
$this->pacer->refused($account, $parsed['refusal']['limitName'], $parsed['refusal']['limitValue']);
}
$payloads = $parsed['payloads'];
$retryable = [];
$gone = [];
@@ -242,7 +262,7 @@ final class GmailApiClient
],
);
return $this->decode($response, 'messages.get');
return $this->decode($account, $response, 'messages.get');
}
/**
@@ -265,7 +285,7 @@ final class GmailApiClient
],
);
$body = $this->decode($response, 'messages.get(raw)');
$body = $this->decode($account, $response, 'messages.get(raw)');
$raw = (string) ($body['raw'] ?? '');
if ('' === $raw) {
@@ -303,7 +323,7 @@ final class GmailApiClient
'query' => $query,
]);
$body = $this->decode($response, 'history.list');
$body = $this->decode($account, $response, 'history.list');
$page = $body['nextPageToken'] ?? null;
if (true === isset($body['historyId'])) {
@@ -339,7 +359,7 @@ final class GmailApiClient
],
]);
return $this->decode($response, 'watch');
return $this->decode($account, $response, 'watch');
}
/**
@@ -367,7 +387,7 @@ final class GmailApiClient
'auth_bearer' => $token,
]);
return $this->decode($response, 'getProfile');
return $this->decode($account, $response, 'getProfile');
}
/**
@@ -430,7 +450,7 @@ final class GmailApiClient
['auth_bearer' => $token],
);
$body = $this->decode($response, 'attachments.get');
$body = $this->decode($account, $response, 'attachments.get');
$data = (string) ($body['data'] ?? '');
return base64_decode(strtr($data, '-_', '+/'));
@@ -477,7 +497,10 @@ final class GmailApiClient
* by a JSON body. 200 parts are decoded into payloads; every part's
* status is recorded so the caller can classify failures per id.
*
* @return array{payloads: array<string,array<string,mixed>>, statuses: array<string,int>}
* `refusal` is the first part Google refused on quota, read the way a
* refused response of its own would be; null when no part was.
*
* @return array{payloads: array<string,array<string,mixed>>, statuses: array<string,int>, refusal: ?array{limitName: string, limitValue: ?int}}
*/
private function parseBatchResponse(string $rawBody): array
{
@@ -490,12 +513,14 @@ final class GmailApiClient
return [
'payloads' => [],
'statuses' => [],
'refusal' => null,
];
}
$boundary = $m[1];
$payloads = [];
$statuses = [];
$refusal = null;
// Split on the boundary lines, drop the preamble and epilogue.
$parts = preg_split('/\r?\n--' . preg_quote($boundary, '/') . '(?:--)?(?:\r?\n|$)/', $rawBody);
@@ -504,6 +529,7 @@ final class GmailApiClient
return [
'payloads' => [],
'statuses' => [],
'refusal' => null,
];
}
@@ -547,11 +573,20 @@ final class GmailApiClient
if (null !== $contentId && null !== $status) {
$statuses[$contentId] = $status;
}
if (null === $refusal && true === in_array($status, [403, 429], true)) {
$failure = $this->describeErrorBody($json);
if (429 === $status || true === in_array($failure['reason'], self::TRANSIENT_REASONS, true)) {
$refusal = ['limitName' => $failure['limitName'], 'limitValue' => $failure['limitValue']];
}
}
}
return [
'payloads' => $payloads,
'statuses' => $statuses,
'refusal' => $refusal,
];
}
// ── labels ────────────────────────────────────────────────────────────────
@@ -567,7 +602,7 @@ final class GmailApiClient
'auth_bearer' => $token,
]);
$body = $this->decode($response, 'labels.list');
$body = $this->decode($account, $response, 'labels.list');
return $body['labels'] ?? [];
}
@@ -604,7 +639,7 @@ final class GmailApiClient
'json' => $payload,
]);
return $this->decode($response, 'labels.create');
return $this->decode($account, $response, 'labels.create');
}
/**
@@ -622,7 +657,7 @@ final class GmailApiClient
'json' => ['name' => $name],
]);
return $this->decode($response, 'labels.patch');
return $this->decode($account, $response, 'labels.patch');
}
/**
@@ -646,7 +681,7 @@ final class GmailApiClient
return;
}
$this->assertSuccess($response, 'labels.delete');
$this->assertSuccess($account, $response, 'labels.delete');
}
/**
@@ -702,7 +737,7 @@ final class GmailApiClient
'json' => $payload,
]);
$this->assertSuccess($response, 'messages.batchModify');
$this->assertSuccess($account, $response, 'messages.batchModify');
}
}
@@ -743,7 +778,7 @@ final class GmailApiClient
'json' => ['ids' => $chunk],
]);
$this->assertSuccess($response, 'messages.batchDelete');
$this->assertSuccess($account, $response, 'messages.batchDelete');
}
}
@@ -760,9 +795,9 @@ final class GmailApiClient
*
* @return array<string,mixed>
*/
private function decode(ResponseInterface $response, string $operation): array
private function decode(Account $account, ResponseInterface $response, string $operation): array
{
$this->assertSuccess($response, $operation);
$this->assertSuccess($account, $response, $operation);
// throw=false so this stays the only place that decides what a failure
// means. Left at true, a status assertSuccess() deliberately let past
@@ -783,8 +818,12 @@ final class GmailApiClient
* did before. The status stays in the message text as well as on the
* exception because it is the only part of a failure that always survives
* into a log line.
*
* A quota rejection is also reported to the pacer before it is thrown,
* which is what makes it known to every other job for this account and
* not only to the one that met it. See GmailQuotaPacer::refused().
*/
private function assertSuccess(ResponseInterface $response, string $operation): void
private function assertSuccess(Account $account, ResponseInterface $response, string $operation): void
{
$status = $response->getStatusCode();
@@ -806,11 +845,18 @@ final class GmailApiClient
// 429 is in here for completeness only — Gmail answers 403 for quota,
// which is the entire reason the reason has to be read.
if (429 === $status || (403 === $status && true === in_array($reason, self::TRANSIENT_REASONS, true))) {
$retryAfter = $this->retryAfterSeconds($response);
$this->pacer->refused($account, $failure['limitName'], $failure['limitValue'], $retryAfter);
// The pacer's answer rather than the header's: it is the header
// when there was one, and otherwise how long the account is being
// left alone, which is when a retry is worth making.
throw new GmailThrottledException(
$message,
$status,
$reason,
$this->retryAfterSeconds($response),
max(1, $this->pacer->waitSeconds($account)),
);
}
@@ -842,7 +888,7 @@ final class GmailApiClient
* all of which would otherwise turn a clean 403 into a JsonException that
* says nothing about what went wrong.
*
* @return array{reason: string, message: string}
* @return array{reason: string, message: string, limitName: string, limitValue: ?int}
*/
private function describeFailure(ResponseInterface $response): array
{
@@ -851,9 +897,35 @@ final class GmailApiClient
} catch (HttpException) {
// The body never arrived — a truncated or reset stream. The status
// is still worth reporting on its own.
return ['reason' => '', 'message' => ''];
return ['reason' => '', 'message' => '', 'limitName' => '', 'limitValue' => null];
}
return $this->describeErrorBody($raw);
}
/**
* The same reading of an error body, for the two places one turns up: a
* response of its own, and one part of a batch response.
*
* ## The limit, when the refusal names it
*
* A quota rejection carries, beside the reason, an ErrorInfo detail whose
* metadata says which limit was hit and what it is:
*
* "details": [{"@type": "type.googleapis.com/google.rpc.ErrorInfo",
* "reason": "RATE_LIMIT_EXCEEDED",
* "metadata": {"quota_limit": "…PerMinutePerUser",
* "quota_limit_value": "6000", …}}]
*
* That number is the only place this application can learn its own quota
* from — see GmailQuotaPacer. A body without the detail still names the
* limit in its sentence ("… and limit 'Units per minute per user' of
* service …"), which is enough to know WHICH limit and not what it is.
*
* @return array{reason: string, message: string, limitName: string, limitValue: ?int}
*/
private function describeErrorBody(string $raw): array
{
$decoded = json_decode($raw, true);
$error = true === is_array($decoded) ? ($decoded['error'] ?? null) : null;
@@ -861,7 +933,7 @@ final class GmailApiClient
// Not a Google error envelope — an empty body, a truncated one, or
// an HTML page from a proxy in front of the API. A short excerpt
// beats discarding it: the proxy page at least names the proxy.
return ['reason' => '', 'message' => trim(substr($raw, 0, 200))];
return ['reason' => '', 'message' => trim(substr($raw, 0, 200)), 'limitName' => '', 'limitValue' => null];
}
$errors = $error['errors'] ?? null;
@@ -872,10 +944,38 @@ final class GmailApiClient
// "PERMISSION_DENIED") is what newer Google endpoints send instead.
$reason ??= $error['status'] ?? null;
$detail = $error['message'] ?? null;
$message = true === is_scalar($detail) ? (string) $detail : '';
$limitName = '';
$limitValue = null;
foreach (true === is_array($error['details'] ?? null) ? $error['details'] : [] as $entry) {
$metadata = true === is_array($entry) ? ($entry['metadata'] ?? null) : null;
if (false === is_array($metadata)) {
continue;
}
if (true === is_scalar($metadata['quota_limit'] ?? null)) {
$limitName = (string) $metadata['quota_limit'];
}
if (true === is_numeric($metadata['quota_limit_value'] ?? null)) {
$limitValue = (int) $metadata['quota_limit_value'];
}
}
if ('' === $limitName && 1 === preg_match("/limit '([^']+)'/", $message, $named)) {
// "Units per minute per user" → "unitsperminuteperuser", so both
// spellings answer the one question asked of them: per user or not.
$limitName = str_replace(' ', '', $named[1]);
}
return [
'reason' => true === is_scalar($reason) ? (string) $reason : '',
'message' => true === is_scalar($detail) ? (string) $detail : '',
'reason' => true === is_scalar($reason) ? (string) $reason : '',
'message' => $message,
'limitName' => $limitName,
'limitValue' => $limitValue,
];
}
+29 -1
View File
@@ -4,12 +4,14 @@ declare(strict_types=1);
namespace App\Service\Mail;
use App\Domain\DTO\Mail\GmailQuotaStatus;
use App\Domain\DTO\Mail\ImportLine;
use App\Entity\Mail\Account;
use App\Entity\User\User;
use App\Repository\Mail\AccountRepository;
use App\Repository\Mail\MailboxRepository;
use App\Repository\Mail\MessageRepository;
use App\Service\Gmail\GmailQuotaPacer;
use App\Service\Graph\GraphFolderResolver;
use DateTimeImmutable;
@@ -45,6 +47,7 @@ final readonly class ImportProgress
private MailboxRepository $mailboxes,
private MessageRepository $messages,
private GraphFolderResolver $folders,
private GmailQuotaPacer $pacer,
) {}
/** @return list<ImportLine> one per importing account, in the accounts' own order */
@@ -80,12 +83,15 @@ final readonly class ImportProgress
if (true === $account->isGmail()) {
if (true === $account->needsBackfill()) {
$done = $this->messages->countForAccount($account);
$lines[] = new ImportLine(
$account,
$this->messages->countForAccount($account),
$done,
$account->importTotal,
null,
$this->isWaiting($account),
quota: $this->quota($account, $done),
);
}
@@ -110,6 +116,28 @@ final readonly class ImportProgress
return $lines;
}
/**
* What the quota allows this import, and how long the rest takes at that.
*
* The time is the messages still to come over the rate the pacer holds an
* import to. It runs long towards the end: the total is Google's count of
* the mailbox, which includes what is never stored — a draft, a chat —
* so the last of "what is left" is mail that is not coming.
*/
private function quota(Account $account, int $done): GmailQuotaStatus
{
$total = $account->importTotal;
$left = null === $total ? null : max(0, $total - $done);
return new GmailQuotaStatus(
$this->pacer->limit(),
$this->pacer->isLimitLearnt(),
$this->pacer->usedThisMinute($account),
$this->pacer->waitSeconds($account),
null === $left || 0 === $left ? null : (int) ceil($left / $this->pacer->messagesPerMinute()),
);
}
private function isWaiting(Account $account): bool
{
$beatAt = $account->importBeatAt;
+52 -7
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service\Mail;
use App\Domain\Enum\Mail\SyncTrigger;
use App\Domain\Exception\GmailThrottledException;
use App\Domain\Helper\ImapConnectionFactory;
use App\Entity\Mail\Account;
use App\Infrastructure\Messaging\Message\ImportMailMessage;
@@ -12,6 +13,7 @@ use App\Repository\Mail\AccountRepository;
use App\Repository\Mail\MailboxRepository;
use App\Service\Gmail\GmailApiSyncer;
use App\Service\Gmail\GmailBackfillStep;
use App\Service\Gmail\GmailQuotaPacer;
use App\Service\Graph\GraphApiSyncer;
use App\Service\Graph\GraphImportStep;
use App\Service\Imap\ImapImportOutcome;
@@ -101,6 +103,7 @@ final readonly class MailImporter
private MessageBusInterface $bus,
private EntityManagerInterface $em,
private LoggerInterface $logger,
private GmailQuotaPacer $pacer,
) {}
/** Whether this account still has history that this class would fetch. */
@@ -162,17 +165,40 @@ final readonly class MailImporter
$this->beat($account);
$this->heartbeats->beatWhileBusy();
// Google is refusing this account just now — this page was, or a
// batch on another worker. The page is asked for again when that has
// cleared, which keeps the chain: it used to be retried five times a
// minute apart and then given up on at CRITICAL, and an import whose
// chain had gone that way waited for a sync to notice (#90).
if (true === $account->isGmail() && $this->pacer->waitSeconds($account) > 0) {
$this->askAgainAfterQuota($account);
return;
}
// Imported mail is marked as such, like mail a poll brought in: it is
// not something a person was just sent.
$wait = false;
$this->origin->during(SyncTrigger::Poll, function () use ($account, &$wait): void {
$wait = match (true) {
$account->isGmail() => $this->gmailPage($account),
$account->isMicrosoft() => GraphImportStep::Waiting === $this->graph->importPage($account),
default => $this->imapPage($account),
};
});
try {
$this->origin->during(SyncTrigger::Poll, function () use ($account, &$wait): void {
$wait = match (true) {
$account->isGmail() => $this->gmailPage($account),
$account->isMicrosoft() => GraphImportStep::Waiting === $this->graph->importPage($account),
default => $this->imapPage($account),
};
});
} catch (GmailThrottledException) {
// Where the listing has got to is on the account and only moves
// when a page goes through, so the same page is asked for again.
$account = $this->accounts->find($accountId);
if (null !== $account) {
$this->askAgainAfterQuota($account);
}
return;
}
// A page clears the entity manager; this is the account as it is now.
$account = $this->accounts->find($accountId);
@@ -203,6 +229,25 @@ final readonly class MailImporter
);
}
/**
* The next page, delayed until Google will answer for this account again.
*/
private function askAgainAfterQuota(Account $account): void
{
$seconds = max(1, $this->pacer->waitSeconds($account));
$this->bus->dispatch(
new ImportMailMessage((int) $account->id),
[new DelayStamp(1000 * $seconds)],
);
$this->logger->info('Import: waiting for Gmail quota', [
'accountId' => $account->id,
'delaySeconds' => $seconds,
'limit' => $this->pacer->limit(),
]);
}
/** @return bool whether asking again at once would only repeat this */
private function gmailPage(Account $account): bool
{
+35 -1
View File
@@ -139,7 +139,12 @@ the record and a missed nudge costs one stale render, never a wrong answer.
bar that has stopped says nothing by itself;
"waiting" at least says it is known to have. #}
<p class="mt-1 text-[11px] text-ink-faint truncate">
{% if line.waiting %}
{# Held by Google for a minute is not
"waiting": it is known why, and when it
ends. #}
{% if line.quota and line.quota.isPaused() %}
{{ 'jobs.import.quota_paused'|trans({ '%seconds%': line.quota.pausedSeconds }) }}
{% elseif line.waiting %}
{{ 'jobs.import.waiting'|trans }}
{% elseif line.label %}
{{ 'jobs.import.folder'|trans({ '%folder%': line.label|label_name }) }}
@@ -149,6 +154,35 @@ the record and a missed nudge costs one stale render, never a wrong answer.
{{ 'jobs.import.newest_first'|trans }}
{% endif %}
</p>
{# A Gmail import is as fast as Google's quota
lets it be, so the quota is what answers "why
so slow" and "how much longer". All of it is
plMail's own reckoning — Google reports none
of it — hence "about". #}
{% if line.quota %}
{# Not truncated like its neighbours: the note that
the limit is only assumed is the end of the
sentence, and the part that was cut off. #}
<p class="mt-0.5 text-[11px] text-ink-faint" data-import-quota>
{{ (line.quota.learnt ? 'jobs.import.quota' : 'jobs.import.quota_assumed')|trans({
'%used%': line.quota.used|number_format,
'%limit%': line.quota.limit|number_format,
}) }}
</p>
{% if line.quota.minutesLeft is not null and (not line.waiting or line.quota.isPaused()) %}
<p class="mt-0.5 text-[11px] text-ink-faint truncate" data-import-remaining>
{% if line.quota.minutesLeft >= 60 %}
{{ 'jobs.import.remaining_hours'|trans({
'%hours%': line.quota.minutesLeft // 60,
'%minutes%': line.quota.minutesLeft % 60,
}) }}
{% else %}
{{ 'jobs.import.remaining_minutes'|trans({ '%minutes%': line.quota.minutesLeft }) }}
{% endif %}
</p>
{% endif %}
{% endif %}
</li>
{% endfor %}
{% for job in jobs %}
@@ -188,6 +188,7 @@ final class GmailInboundCategoryTest extends KernelTestCase
$container->get('App\Service\Mail\MessageCategorizer'),
$container->get('App\Service\Imap\MessageThreader'),
new \App\Service\Mail\SyncOrigin(),
new \App\Service\Gmail\GmailQuotaPacer(new \Symfony\Component\Clock\MockClock()),
);
$handler(new SyncGmailMessageBatchMessage((int) $this->account->id, [self::GMAIL_ID]));
@@ -270,6 +270,7 @@ final class GmailInboundReadStateTest extends KernelTestCase
$container->get('App\Service\Mail\MessageCategorizer'),
$container->get('App\Service\Imap\MessageThreader'),
new \App\Service\Mail\SyncOrigin(),
new \App\Service\Gmail\GmailQuotaPacer(new \Symfony\Component\Clock\MockClock()),
);
$handler(new SyncGmailMessageBatchMessage((int) $this->account->id, [self::GMAIL_ID]));
+206 -22
View File
@@ -8,20 +8,22 @@ use App\Entity\Mail\Account;
use App\Service\Gmail\GmailQuotaPacer;
use PHPUnit\Framework\TestCase;
use ReflectionProperty;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Clock\MockClock;
/**
* The pacer exists to keep an import under Google's 15,000 units a minute, so
* the test that matters is the one that counts a minute. The rest pin down who
* is made to wait and who is not.
* The pacer exists to keep an import under Google's per-user limit, so the
* tests that matter count a minute — at the limit it starts from, and at the
* one a refusal teaches it (#90). The rest pin down who is made to wait and
* who is not.
*
* MockClock::sleep() moves the clock instead of waiting, which is what makes
* "how long was the caller held" readable as a difference of two times.
*/
final class GmailQuotaPacerTest extends TestCase
{
/** A full batch: a hundred messages.get at five units. */
private const int BATCH = 500;
/** A full batch: fifty messages.get at twenty units. */
private const int BATCH = 1000;
public function testAFewBatchesInARowAreNotHeld(): void
{
@@ -30,7 +32,7 @@ final class GmailQuotaPacerTest extends TestCase
$account = $this->account(1);
$start = $clock->now();
for ($i = 0; $i < 6; $i++) {
for ($i = 0; $i < 3; $i++) {
$pacer->spend($account, self::BATCH);
}
@@ -38,30 +40,188 @@ final class GmailQuotaPacerTest extends TestCase
}
public function testAnImportNeverSpendsMoreThanTheLimitInAnyMinute(): void
{
$clock = new MockClock('2026-10-08 14:30:00');
$pacer = new GmailQuotaPacer($clock);
$worst = $this->worstMinute($pacer, $clock, $this->account(1));
self::assertLessThanOrEqual(13_000, $worst);
// And not so cautious that an import crawls: the steady rate is used.
self::assertGreaterThanOrEqual(9_000, $worst);
}
/**
* The whole of #90: a project with 6,000 units a minute, and a pacer that
* allowed 9,000 because 15,000 was written into it.
*/
public function testARefusalUnderALowerLimitIsWhatTheImportIsPacedToFromThenOn(): void
{
$clock = new MockClock('2026-10-08 14:30:00');
$pacer = new GmailQuotaPacer($clock);
$account = $this->account(1);
/** @var list<float> $spentAt */
$spentAt = [];
self::assertSame(15_000, $pacer->limit());
// Ten minutes of a worker that fetches as fast as it is allowed to.
for ($i = 0; $i < 200; $i++) {
$pacer->refused($account, 'defaultPerMinutePerUser', 6000);
self::assertSame(6000, $pacer->limit());
$clock->sleep(120);
$worst = $this->worstMinute($pacer, $clock, $account);
self::assertLessThanOrEqual(6000, $worst);
self::assertGreaterThanOrEqual(3600, $worst);
}
public function testARefusedAccountIsLeftAloneForAMinuteAndThenIsNot(): void
{
$clock = new MockClock('2026-10-08 14:30:00');
$pacer = new GmailQuotaPacer($clock);
$account = $this->account(1);
self::assertSame(0, $pacer->waitSeconds($account));
$pacer->refused($account, 'defaultPerMinutePerUser', 6000);
self::assertSame(60, $pacer->waitSeconds($account));
self::assertSame(0, $pacer->waitSeconds($this->account(2)), 'a refusal is one mailbox\'s, the limit is everybody\'s');
$clock->sleep(61);
self::assertSame(0, $pacer->waitSeconds($account));
}
public function testGooglesOwnRetryAfterIsBelievedOverTheMinute(): void
{
$pacer = new GmailQuotaPacer(new MockClock('2026-10-08 14:30:00'));
$account = $this->account(1);
$pacer->refused($account, 'defaultPerMinutePerUser', 6000, 200);
self::assertSame(200, $pacer->waitSeconds($account));
}
/**
* The per-project limit is 1,200,000. Taken for the per-user one it would
* teach nothing; a refusal under it still means "not now".
*/
public function testARefusalUnderTheProjectsLimitHoldsTheAccountAndTeachesNothing(): void
{
$pacer = new GmailQuotaPacer(new MockClock('2026-10-08 14:30:00'));
$account = $this->account(1);
$pacer->refused($account, 'defaultPerMinutePerProject', 1_200_000);
self::assertSame(15_000, $pacer->limit());
self::assertSame(60, $pacer->waitSeconds($account));
}
/** The sentence names the limit even when the detail with its value is missing. */
public function testAPerUserRefusalThatGivesNoValueFallsBackToTodaysDefault(): void
{
$pacer = new GmailQuotaPacer(new MockClock('2026-10-08 14:30:00'));
$pacer->refused($this->account(1), 'Unitsperminuteperuser');
self::assertSame(6000, $pacer->limit());
}
/** Another client spending the same user's quota is not news about the limit. */
public function testALearntLimitIsNeverRaisedByALaterRefusal(): void
{
$pacer = new GmailQuotaPacer(new MockClock('2026-10-08 14:30:00'));
$account = $this->account(1);
$pacer->refused($account, 'defaultPerMinutePerUser', 6000);
$pacer->refused($account, 'defaultPerMinutePerUser', 15_000);
$pacer->refused($account, 'Unitsperminuteperuser');
self::assertSame(6000, $pacer->limit());
}
/**
* worker-backlog fetches an import's batches while worker-ingest fetches
* the mail arriving meanwhile, each with a pacer of its own. What one
* spent and what one was refused has to count for the other.
*/
public function testTwoProcessesShareOneAllowanceAndOneRefusal(): void
{
$clock = new MockClock('2026-10-08 14:30:00');
$shared = new ArrayAdapter();
$backlog = new GmailQuotaPacer($clock, $shared);
$ingest = new GmailQuotaPacer($clock, $shared);
$account = $this->account(1);
$start = $clock->now();
for ($i = 0; $i < 3; $i++) {
$backlog->spend($account, self::BATCH);
}
self::assertEquals($start, $clock->now());
$ingest->spend($account, self::BATCH);
self::assertGreaterThan($start, $clock->now(), 'the reserve the other process used is used');
$backlog->refused($account, 'defaultPerMinutePerUser', 6000);
self::assertSame(6000, $ingest->limit());
self::assertSame(60, $ingest->waitSeconds($account));
}
/**
* What the import line shows as "about N of the limit this minute". Its
* own count, since Google reports none — and a minute's worth, so it falls
* back as the minute passes.
*/
public function testWhatWasSpentInTheLastMinuteIsKnownAndThenForgotten(): void
{
$clock = new MockClock('2026-10-08 14:30:00');
$pacer = new GmailQuotaPacer($clock);
$account = $this->account(1);
self::assertSame(0, $pacer->usedThisMinute($account));
$pacer->spend($account, self::BATCH);
$pacer->spend($account, self::BATCH);
self::assertSame(2000, $pacer->usedThisMinute($account));
self::assertSame(0, $pacer->usedThisMinute($this->account(2)));
$clock->sleep(30);
$pacer->spend($account, self::BATCH);
self::assertSame(3000, $pacer->usedThisMinute($account));
$clock->sleep(45);
self::assertSame(1000, $pacer->usedThisMinute($account));
$clock->sleep(60);
self::assertSame(0, $pacer->usedThisMinute($account));
}
/** A worker flat out is shown spending what it is allowed, not more. */
public function testAnImportFlatOutShowsItsSteadyAllowanceAsUsed(): void
{
$clock = new MockClock('2026-10-08 14:30:00');
$pacer = new GmailQuotaPacer($clock);
$account = $this->account(1);
$pacer->refused($account, 'defaultPerMinutePerUser', 6000);
$clock->sleep(120);
for ($i = 0; $i < 40; $i++) {
$pacer->spend($account, self::BATCH);
$spentAt[] = (float) $clock->now()->format('U.u');
}
$worst = 0;
foreach ($spentAt as $from) {
$inWindow = count(array_filter($spentAt, static fn (float $at): bool => $at >= $from && $at < $from + 60.0));
$worst = max($worst, $inWindow * self::BATCH);
}
self::assertLessThanOrEqual(12_500, $worst);
// And not so cautious that an import crawls: the steady rate is used.
self::assertGreaterThanOrEqual(9_000, $worst);
self::assertLessThanOrEqual(6000, $pacer->usedThisMinute($account));
self::assertGreaterThanOrEqual(3000, $pacer->usedThisMinute($account));
self::assertTrue($pacer->isLimitLearnt());
self::assertSame(180, $pacer->messagesPerMinute());
}
public function testOneAccountImportingDoesNotHoldAnother(): void
@@ -92,13 +252,37 @@ final class GmailQuotaPacerTest extends TestCase
$clock->sleep(120);
$before = $clock->now();
for ($i = 0; $i < 6; $i++) {
for ($i = 0; $i < 3; $i++) {
$pacer->spend($account, self::BATCH);
}
self::assertEquals($before, $clock->now());
}
/**
* Ten minutes of a worker that fetches as fast as it is allowed to, and
* the most it spent in any sixty seconds of them.
*/
private function worstMinute(GmailQuotaPacer $pacer, MockClock $clock, Account $account): int
{
/** @var list<float> $spentAt */
$spentAt = [];
for ($i = 0; $i < 100; $i++) {
$pacer->spend($account, self::BATCH);
$spentAt[] = (float) $clock->now()->format('U.u');
}
$worst = 0;
foreach ($spentAt as $from) {
$inWindow = count(array_filter($spentAt, static fn (float $at): bool => $at >= $from && $at < $from + 60.0));
$worst = max($worst, $inWindow * self::BATCH);
}
return $worst;
}
private function account(int $id): Account
{
$account = new Account();
@@ -9,9 +9,11 @@ use App\Domain\Exception\GmailPermanentException;
use App\Domain\Exception\GmailThrottledException;
use App\Entity\Mail\Account;
use App\Entity\User\User;
use App\Service\Gmail\GmailQuotaPacer;
use App\Service\Mail\GmailApiClient;
use App\Service\OAuth\OAuthTokenManager;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\Messenger\Exception\RecoverableExceptionInterface;
@@ -112,7 +114,9 @@ final class GmailApiClientFailureTest extends TestCase
$client->listLabels($this->account());
self::fail('a 403 must not be swallowed');
} catch (GmailThrottledException $e) {
self::assertNull($e->getRetryAfterSeconds());
// The minute the account is left alone for when Google names no
// time of its own — not the date read as a number.
self::assertSame(60, $e->getRetryAfterSeconds());
self::assertSame(60000, $e->getRetryDelay());
}
}
@@ -377,8 +381,122 @@ final class GmailApiClientFailureTest extends TestCase
$this->expectNotToPerformAssertions();
}
// ── What a refusal teaches (#90) ─────────────────────────────────────────
/**
* The limit is not configured anywhere: the refusal names it, and that is
* where the pacer has it from.
*/
public function testARefusalTellsThePacerTheLimitItWasRefusedUnder(): void
{
$pacer = new GmailQuotaPacer(new MockClock());
$client = $this->clientWith($pacer, new MockResponse(json_encode($this->perUserQuotaBody(6000), JSON_THROW_ON_ERROR), ['http_code' => 403]));
self::assertSame(GmailQuotaPacer::HIGHEST_LIMIT, $pacer->limit());
try {
$client->listLabels($this->account());
self::fail('a 403 must not be swallowed');
} catch (GmailThrottledException) {
}
self::assertSame(6000, $pacer->limit());
self::assertSame(60, $pacer->waitSeconds($this->account()));
}
/**
* Google refusing some of a batch is the ordinary way an import meets its
* quota. Those ids are handed back to be asked for again and the pacer is
* told; nothing is thrown.
*/
public function testARefusedPartOfABatchIsHandedBackAndReportedToThePacer(): void
{
$refusal = json_encode($this->perUserQuotaBody(6000), JSON_THROW_ON_ERROR);
$body = implode("\r\n", [
'--batch_x',
'Content-Type: application/http',
'Content-ID: <response-m1>',
'',
'HTTP/1.1 200 OK',
'Content-Type: application/json',
'',
'{"id": "m1", "labelIds": ["INBOX"]}',
'--batch_x',
'Content-Type: application/http',
'Content-ID: <response-m2>',
'',
'HTTP/1.1 403 Forbidden',
'Content-Type: application/json',
'',
$refusal,
'--batch_x--',
]);
$pacer = new GmailQuotaPacer(new MockClock());
$client = $this->clientWith($pacer, new MockResponse($body, ['http_code' => 200]));
$fetched = $client->getMessages($this->account(), ['m1', 'm2']);
self::assertSame(['m1'], array_keys($fetched['payloads']));
self::assertSame(['m2'], $fetched['retryable']);
self::assertSame([], $fetched['gone']);
self::assertSame(6000, $pacer->limit());
self::assertGreaterThan(0, $pacer->waitSeconds($this->account()));
}
/** The whole request refused is the same answer as every part refused. */
public function testAWhollyRefusedBatchIsHandedBackRatherThanThrown(): void
{
$pacer = new GmailQuotaPacer(new MockClock());
$client = $this->clientWith($pacer, new MockResponse(json_encode($this->quotaBody('rateLimitExceeded'), JSON_THROW_ON_ERROR), ['http_code' => 403]));
$fetched = $client->getMessages($this->account(), ['m1', 'm2']);
self::assertSame([], $fetched['payloads']);
self::assertSame(['m1', 'm2'], $fetched['retryable']);
self::assertGreaterThan(0, $pacer->waitSeconds($this->account()));
}
// ── Fixture ──────────────────────────────────────────────────────────────
private function clientWith(GmailQuotaPacer $pacer, MockResponse $response): GmailApiClient
{
$tokenManager = $this->createStub(OAuthTokenManager::class);
$tokenManager->method('getValidAccessToken')->willReturn('test-token');
return new GmailApiClient(new MockHttpClient($response), $tokenManager, $pacer);
}
/**
* A per-user quota refusal as Google sends it, with the ErrorInfo detail
* that names the limit and its value.
*
* @return array<string,mixed>
*/
private function perUserQuotaBody(int $limit): array
{
return [
'error' => [
'code' => 403,
'errors' => [['reason' => 'rateLimitExceeded', 'message' => 'Quota exceeded']],
'message' => "Quota exceeded for quota metric 'Total Query Cost' and limit 'Units per minute per user' of service 'gmail.googleapis.com'.",
'status' => 'RESOURCE_EXHAUSTED',
'details' => [[
'@type' => 'type.googleapis.com/google.rpc.ErrorInfo',
'reason' => 'RATE_LIMIT_EXCEEDED',
'domain' => 'googleapis.com',
'metadata' => [
'quota_limit' => 'defaultPerMinutePerUser',
'quota_limit_value' => (string) $limit,
'quota_metric' => 'gmail.googleapis.com/default',
'service' => 'gmail.googleapis.com',
],
]],
],
];
}
/**
* @param array<string,mixed>|null $json decoded body, or null to send $raw verbatim
* @param array<string,string> $headers
+49
View File
@@ -9,6 +9,7 @@ use App\Domain\Enum\Mail\MailboxSpecialUse;
use App\Entity\Label\Label;
use App\Entity\Label\LabelBinding;
use App\Entity\Mail\Mailbox;
use App\Service\Gmail\GmailQuotaPacer;
use App\Service\Mail\ImportProgress;
use App\Tests\Support\Mail\SeedsMarkerFixtures;
use DateTimeImmutable;
@@ -174,6 +175,54 @@ final class ImportProgressTest extends KernelTestCase
self::assertStringContainsString('INBOX', $html);
}
/**
* A Gmail import is as fast as Google's quota lets it be, so its line says
* what the quota is, roughly what is being used of it, and how long the
* rest takes at that — and "held by Google for a minute" is said as that
* and not as the "waiting" that could mean anything (#90).
*/
public function testAGmailImportShowsItsQuotaAndHowLongIsLeft(): void
{
$this->account->authType = AuthType::OAuth2->value;
$this->account->oauthProvider = 'google';
$this->account->importTotal = 9000;
$this->account->importBeatAt = new DateTimeImmutable();
$this->em->flush();
$pacer = static::getContainer()->get(GmailQuotaPacer::class);
$pacer->spend($this->account, 2000);
$line = $this->progress->forUser($this->user)[0];
self::assertNotNull($line->quota);
self::assertSame(15_000, $line->quota->limit);
self::assertFalse($line->quota->learnt);
self::assertSame(2000, $line->quota->used);
self::assertFalse($line->quota->isPaused());
// 9,000 messages at 450 a minute, which is three fifths of 15,000
// units at twenty a message.
self::assertSame(20, $line->quota->minutesLeft);
$pacer->refused($this->account, 'defaultPerMinutePerUser', 6000);
$line = $this->progress->forUser($this->user)[0];
self::assertSame(6000, $line->quota->limit);
self::assertTrue($line->quota->learnt);
self::assertTrue($line->quota->isPaused());
self::assertSame(50, $line->quota->minutesLeft);
$html = static::getContainer()->get(Environment::class)->render('mail/_jobs_indicator.html.twig', [
'jobs' => [],
'imports' => [$line],
]);
self::assertStringContainsString('data-import-quota', $html);
self::assertStringContainsString('6,000', $html);
self::assertStringContainsString('About 50 min left', $html);
self::assertStringContainsString('Paused by Gmail', $html);
}
/** Nothing running is no button at all, as it was before imports were shown. */
public function testWithNothingRunningTheIndicatorIsEmpty(): void
{
+30
View File
@@ -10,6 +10,7 @@ use App\Entity\Mail\Mailbox;
use App\Infrastructure\Messaging\Message\ImportMailMessage;
use App\Infrastructure\Messaging\Message\SyncAccountMessage;
use App\Repository\Mail\MailboxRepository;
use App\Service\Gmail\GmailQuotaPacer;
use App\Service\Mail\InitialImportState;
use App\Service\Mail\MailImporter;
use App\Tests\Support\Mail\SeedsMarkerFixtures;
@@ -17,6 +18,7 @@ use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Messenger\Stamp\DelayStamp;
use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport;
/**
@@ -74,6 +76,34 @@ final class MailImporterTest extends KernelTestCase
self::assertSame([], $this->queue('ingest')->getSent(), 'history is never put on the queue new mail arrives on');
}
/**
* Google refusing a Gmail account is not the end of its import. The page
* is asked for again when the refusal has cleared, and nothing is thrown:
* it used to be retried, given up on at CRITICAL, and left for a later
* sync to restart (#90).
*/
public function testAnImportGoogleIsRefusingAsksAgainLaterInsteadOfFailing(): void
{
$this->account->authType = AuthType::OAuth2->value;
$this->account->oauthProvider = 'google';
$this->em->flush();
static::getContainer()->get(GmailQuotaPacer::class)->refused($this->account, 'defaultPerMinutePerUser', 6000);
$this->importer->runPage((int) $this->account->id);
$sent = $this->queue('ingest_backlog')->getSent();
self::assertCount(1, $sent);
self::assertInstanceOf(ImportMailMessage::class, $sent[0]->getMessage());
$delay = $sent[0]->last(DelayStamp::class);
self::assertNotNull($delay);
self::assertGreaterThan(0, $delay->getDelay());
self::assertLessThanOrEqual(60_000, $delay->getDelay());
}
/**
* Every sync of the account calls ensureRunning(). While the import is
* moving, that must not put a second chain beside the first.
+5
View File
@@ -2187,6 +2187,11 @@ jobs:
folder: "Gerade: %folder%"
heading: "%account% wird importiert"
newest_first: "Neueste Mail zuerst"
quota: "Gmail-Kontingent: etwa %used% von %limit% Einheiten in dieser Minute"
quota_assumed: "Gmail-Kontingent: etwa %used% von %limit% Einheiten in dieser Minute (Limit angenommen, bis Google es nennt)"
quota_paused: "Pausiert durch Gmails Kontingent · weiter in %seconds% s"
remaining_hours: "Noch etwa %hours% Std. %minutes% Min."
remaining_minutes: "Noch etwa %minutes% Min."
waiting: "Wartet darauf, weiterzumachen"
started: '%action% gestartet — das läuft im Hintergrund.'
failure:
+5
View File
@@ -2186,6 +2186,11 @@ jobs:
folder: "Now: %folder%"
heading: "Importing %account%"
newest_first: "Newest mail first"
quota: "Gmail quota: about %used% of %limit% units this minute"
quota_assumed: "Gmail quota: about %used% of %limit% units this minute (limit assumed until Google names it)"
quota_paused: "Paused by Gmail's quota · continues in %seconds% s"
remaining_hours: "About %hours% h %minutes% min left"
remaining_minutes: "About %minutes% min left"
waiting: "Waiting to continue"
started: 'Started %action% — this runs in the background.'
failure:
+5
View File
@@ -2066,6 +2066,11 @@ jobs:
folder: "Now: %folder%"
heading: "Haulin' %account% aboard"
newest_first: "Freshest post first"
quota: "Gmail's ration: about %used% o' %limit% units this minute"
quota_assumed: "Gmail's ration: about %used% o' %limit% units this minute (a guess, till Google names the limit)"
quota_paused: "Becalmed by Gmail's ration · under way in %seconds% s"
remaining_hours: "About %hours% h %minutes% min o' haulin' left"
remaining_minutes: "About %minutes% min o' haulin' left"
waiting: "Waitin' fer the tide"
started: 'Started %action% — she runs below decks.'
failure: