perf(outlook): respect Microsoft Graph throttling with batching & chunked upload #470

Merged
JMR-dev merged 1 commits from feat-364-outlook-graph-throttling into main 2026-07-09 02:19:42 +00:00
JMR-dev commented 2026-07-09 00:27:15 +00:00 (Migrated from github.com)

Implements #364 — respect Microsoft Graph throttling on the Outlook/Graph path.

Context

Outlook is the odd one out of the provider set: mail is read over IMAP (outlook.office.com) but sent over Microsoft Graph (me/sendMail). The only Graph surface today is the send path, and its OAuth token is Mail.Send-scoped. This change builds a throttle-aware Graph HTTP layer and routes the live send path through it, composing with #360's AccountThrottleGate.

What landed (org.libremail.mail.graph)

  • GraphHttpClient — the single Graph HTTP transport seam. Preserves the send path's may-have-sent distinction (GraphTransportException: transmit-fail = safe, lost-response = maybe-sent) and parses Retry-After (delta-seconds + HTTP-date).
  • GraphThrottle — caps Graph concurrency at 4 (Graph 429s the 5th concurrent request), honors a 429/503 Retry-After via the shared per-account backoff gate (retry after the honored wait, bounded), and clears the backoff on a 2xx. Every Graph call goes through it.
  • GraphBatch — multiplexes ops via $batch (≤20/call), collapsing N calls to ceil(N/20); feeds per-op 429s inside the envelope back into the gate.
  • GraphUploadSession — createUploadSession + chunked Content-Range PUTs for content over Graph's ~4 MB one-shot ceiling (320 KiB-multiple chunks).
  • GraphSender.send now honors a Graph 429 once (Retry-After) before falling back to SMTP, and records the throttle so the account's IMAP background work backs off too.

Composition with #360

GraphThrottle drives the existing AccountThrottleGate via ThrottleClassifier.classifyHttpStatus — no duplication. Because the gate is account-keyed and shared with the IMAP sync/backfill paths, a Graph send 429 cross-cools that account's background IMAP work, and a clean Graph response clears it.

Scope boundary (called out honestly)

$batch reads and draft-based chunked attachment upload need Mail.ReadWrite; the mail token is Mail.Send-only, so forcing that scope would re-consent every existing Outlook user — deliberately out of scope for a perf ticket. Both ship as fully-tested Graph-layer capabilities ready for a future read/draft surface. The oversized-attachment send path keeps its existing SMTP fallback (SMTP streams large files, needs no scope change).

Tests

  • JVM unit suites (MockK the HTTP seam, coroutines-test virtual time, no real sleeps): 429+Retry-After honored with backoff + gate composition, $batch reduces call count (25 ops → 2 calls), chunked upload for an over-threshold attachment (contiguous ranges reassemble to the original bytes), plus transport, parser, and the reworked GraphSenderSendTest.
  • Instrumented GraphThrottleInstrumentedTest exercises the toolkit under the Android runtime (compiles + runs on the CI matrix).
  • PII-free AppLog (accountLogRef) throughout; SPDX on every file.

Full 7-task local gate green under JDK 21 (assembleDebug, testDebugUnitTest, jacocoTestCoverageVerification, compileDebugAndroidTestKotlin, lintDebug, ktlintCheck, detekt). Local emulator E2E is flaky here — leaning on the CI matrix as authoritative.

Conflict-awareness (#361/#362/#363)

Fully additive on the Graph path — no shared throttle/config file touched (AccountThrottleGate, ThrottleClassifier, ThrottleSignal, ThrottleBackoff, SendWorker all unchanged). Only GraphSender.kt is modified. Zero overlap with the IMAP sibling tickets.

Closes #364

Implements #364 — respect Microsoft Graph throttling on the Outlook/Graph path. ## Context Outlook is the odd one out of the provider set: mail is **read over IMAP** (`outlook.office.com`) but **sent over Microsoft Graph** (`me/sendMail`). The only Graph surface today is the send path, and its OAuth token is **`Mail.Send`-scoped**. This change builds a throttle-aware Graph HTTP layer and routes the live send path through it, composing with #360's `AccountThrottleGate`. ## What landed (`org.libremail.mail.graph`) - **`GraphHttpClient`** — the single Graph HTTP transport seam. Preserves the send path's may-have-sent distinction (`GraphTransportException`: transmit-fail = safe, lost-response = maybe-sent) and parses `Retry-After` (delta-seconds + HTTP-date). - **`GraphThrottle`** — caps Graph concurrency at **4** (Graph 429s the 5th concurrent request), honors a **429/503 `Retry-After`** via the shared per-account backoff gate (retry after the honored wait, bounded), and clears the backoff on a 2xx. Every Graph call goes through it. - **`GraphBatch`** — multiplexes ops via **`$batch`** (≤20/call), collapsing N calls to `ceil(N/20)`; feeds per-op 429s inside the envelope back into the gate. - **`GraphUploadSession`** — `createUploadSession` + **chunked `Content-Range` PUTs** for content over Graph's ~4 MB one-shot ceiling (320 KiB-multiple chunks). - **`GraphSender.send`** now honors a Graph 429 once (`Retry-After`) before falling back to SMTP, and records the throttle so the account's IMAP background work backs off too. ## Composition with #360 `GraphThrottle` drives the existing `AccountThrottleGate` via `ThrottleClassifier.classifyHttpStatus` — no duplication. Because the gate is account-keyed and shared with the IMAP sync/backfill paths, a Graph send 429 **cross-cools** that account's background IMAP work, and a clean Graph response clears it. ## Scope boundary (called out honestly) `$batch` reads and draft-based chunked attachment upload need `Mail.ReadWrite`; the mail token is `Mail.Send`-only, so forcing that scope would re-consent every existing Outlook user — deliberately **out of scope** for a perf ticket. Both ship as fully-tested Graph-layer capabilities ready for a future read/draft surface. The oversized-attachment send path keeps its existing SMTP fallback (SMTP streams large files, needs no scope change). ## Tests - JVM unit suites (MockK the HTTP seam, coroutines-test **virtual time, no real sleeps**): 429+`Retry-After` honored with backoff + gate composition, `$batch` **reduces call count** (25 ops → 2 calls), **chunked upload** for an over-threshold attachment (contiguous ranges reassemble to the original bytes), plus transport, parser, and the reworked `GraphSenderSendTest`. - Instrumented `GraphThrottleInstrumentedTest` exercises the toolkit under the Android runtime (compiles + runs on the CI matrix). - PII-free `AppLog` (`accountLogRef`) throughout; SPDX on every file. Full 7-task local gate green under JDK 21 (assembleDebug, testDebugUnitTest, jacocoTestCoverageVerification, compileDebugAndroidTestKotlin, lintDebug, ktlintCheck, detekt). Local emulator E2E is flaky here — leaning on the CI matrix as authoritative. ## Conflict-awareness (#361/#362/#363) Fully additive on the **Graph** path — **no shared throttle/config file touched** (`AccountThrottleGate`, `ThrottleClassifier`, `ThrottleSignal`, `ThrottleBackoff`, `SendWorker` all unchanged). Only `GraphSender.kt` is modified. Zero overlap with the IMAP sibling tickets. Closes #364
mergify[bot] commented 2026-07-09 01:30:29 +00:00 (Migrated from github.com)

Merge Queue Status

This pull request spent 49 minutes 16 seconds in the queue, including 24 minutes 46 seconds running CI.

Required conditions to merge
<!--- DO NOT EDIT -*- Mergify Payload -*- {"version": 1, "state": "merged", "queue_rule_name": "default", "queued_at": "2026-07-09T01:30:28.181305+00:00", "estimated_time_of_merge": null, "speculative_check_pr": null, "required_conditions": []} -*- Mergify Payload End -*- --> # Merge Queue Status - ✅ **Entered queue** — `2026-07-09 01:30 UTC` · Rule: `default` · triggered by merge protections - ✅ **Checks passed** · on draft #476 - ✅ **Merged** — `2026-07-09 02:19 UTC` · at `c5144ebe03fbb7af26f00e32168665219f1baa3e` · merge This pull request spent **49 minutes 16 seconds** in the queue, including **24 minutes 46 seconds** running CI. <details> <summary>Required conditions to merge</summary> - `-conflict` - [X] #470 - [X] #474 - `-draft` - [X] #470 - [X] #474 - [X] `base = main` - [X] `check-success = CI passed` - `github-review-approved` [🛡 GitHub repository ruleset rule `main`] - [X] #470 - [X] #474 - `label != broken` - [X] #470 - [X] #474 - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = Debug build` - [ ] `check-neutral = Debug build` - [ ] `check-skipped = Debug build` - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = Unit tests` - [ ] `check-neutral = Unit tests` - [ ] `check-skipped = Unit tests` - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = CI passed` - [ ] `check-neutral = CI passed` - [ ] `check-skipped = CI passed` - [X] any of [🛡 GitHub repository ruleset rule `main`]: - [X] `check-success = @github-actions/CI passed` - [ ] `check-neutral = @github-actions/CI passed` - [ ] `check-skipped = @github-actions/CI passed` </details>
Sign in to join this conversation.