Launch gateway hardening: probes, retries, sidecar CPU, DB pools, rate limiting - #868
Open
lorenzo-norcini-scale wants to merge 27 commits into
Open
Launch gateway hardening: probes, retries, sidecar CPU, DB pools, rate limiting#868lorenzo-norcini-scale wants to merge 27 commits into
lorenzo-norcini-scale wants to merge 27 commits into
Conversation
…meout A sync healthcheck handler runs in the anyio threadpool, so under load the /readyz probe queues behind blocked requests, misses the 1s default probe timeout, and k8s ejects pods that are saturated but healthy. Making the handler async keeps the probe on the event loop, and the probe timeout is now explicit (default 5s) and values-configurable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ry policy configurable gateway-error retried 502/503/504 as a bundle, so during overload every 503 was retried 3 more times, multiplying offered load exactly when the fleet was saturated. The default policy now retries connection failures and 502/504 only, and attempts/retryOn/perTryTimeout are values-configurable. perTryTimeout stays unset by default because the single route also carries streaming and long-lived requests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sidecar had memory annotations only. On nodes at full CPU request capacity an unrequested sidecar is starved during bootstrap, its postStart hook hangs, and new gateway pods never become Ready, which turns scale-out into negative capacity. Request 250m by default (configurable, no limit). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sync http-forwarder posts to the local inference server without an explicit aiohttp timeout, so the client default of total=300s applies. Non-streaming generations that take longer than 5 minutes are cut off with a 500 while the inference server keeps computing the response. Add a timeout_seconds field to Forwarder and LoadForwarder (default 3600s), overridable per deployment via forwarder.sync.timeout_seconds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reject zero, negative, non-finite, and non-numeric values when the forwarder config is loaded, instead of letting them reach aiohttp.ClientTimeout at request time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GET /v1/async-tasks/{task_id} was a sync handler doing a blocking
result-backend read (S3 on AWS) in the shared anyio threadpool. Poll volume
scales with outstanding tasks, so a large backlog fills the pool and every
other sync route queues behind it. The handler is now async and dispatches
the blocking read through its own CapacityLimiter(40), isolating polls from
the rest of the threadpool.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…st use DBManager eagerly built five engines per process (sync/async x RW/RO plus a NullPool engine), so every gateway worker held idle pools for engines it never uses; only the async pair is used on the API path. At 4 workers per pod this multiplied idle Postgres connections across the fleet and made scale-out storm the reader's connection limit. Engines are now created on first use per kind; credential-expiry refresh disposes and rebuilds only the kinds actually in use. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nt clamps
Adds a values-gated (default off) EnvoyFilter installing
envoy.filters.http.local_ratelimit on the gateway sidecars, inbound:
- Per-pod token buckets for GET /v1/async-tasks/{task_id} and
POST /v1/async-tasks. Overflow 429s at the proxy and never consumes a
gateway worker, so overload degrades to fast rejections instead of
queueing collapse. Per-pod semantics scale the fleet ceiling with the HPA.
- A throttledTenants values list clamps named callers on those routes,
matching both accepted Authorization forms (Basic base64 prefix and
Bearer), replacing hand-authored VirtualService fault injection during
incidents.
Traffic matching no descriptor is unaffected (large default bucket,
always_consume_default_token_bucket: false).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-open Adds a Redis-backed per-user, per-route-class rate limiter enforced inside verify_authentication, where identity is resolved, using the existing aioredis pool. Fixed 1-second windows keyed on (route class, user_id); rejections return 429 with Retry-After before the request reaches a handler or the threadpool. Disabled unless user_rate_limits is set in the service config. enforce: false gives a log-only rollout mode that counts and logs would-be throttles without rejecting. The limiter fails open on any Redis error or if the check exceeds 100ms, so enforcement can never add an availability dependency. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tanding set) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Aug 14, 2026
- Rate limiter moves from inside verify_authentication to per-route FastAPI dependency composition (user_rate_limit(route_class)); drops the hardcoded method/path classification table and keeps auth single-purpose. - Cached Redis client per pool instead of a client per request; INCR with conditional EXPIRE instead of a pipeline; fail-open log sampled to once per minute (a Redis outage fires it per request otherwise). - Tenant clamps in the EnvoyFilter are scoped to the async-task routes via a :path match (previously they clamped every gateway route); route buckets and tenant actions are now data-driven from a values route list; workload selector reuses the gateway selector helper. - Chart defaults live only in values.yaml (inline template defaults removed); retry policy values moved under gateway.retries. - Task-poll thread limiter memoized with functools.cache; docs polling example uses the guide's existing tenacity idiom. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
INCR and EXPIRE run in one Lua eval so a partial failure cannot leave a counter key without a TTL; the log-only over-limit warning is sampled per (user, route class) alongside the fail-open log so a noisy tenant cannot generate per-request log volume during rollout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… DB build lock - Drop always_consume_default_token_bucket from the local_ratelimit config: the field does not exist before Envoy 1.24 (prod sidecars run Istio 1.15 / Envoy 1.23), and an unknown field makes istiod skip the whole patch silently, turning the rate limit into a no-op. The default bucket is large enough that always consuming it is harmless. - Template the inbound vhost match from service.port instead of hardcoding 80, and fail rendering when a throttledTenants userId length is not divisible by 3 (the Basic base64-prefix match only works then; anything else would half-apply silently). - Rate limiter gains a circuit breaker (5 consecutive failures opens a 10s cooldown): each timed-out check abandons its pooled connection, so per-request checks against a slow Redis become a reconnect storm on the shared cache pool without one. - DBManager session builds are serialized with a lock (called from both the event loop and threadpool threads; a cold-start race built duplicate engines and leaked the loser), and DBManager gets its first unit tests: lazy per-kind construction, credential-expiry rebuild, concurrent first use builds once. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… 1.15 Reverts the workload-port guess: Envoy config_dump on ml-training-new (istiod 1.15.0) shows inbound|http|80 and no inbound|http|5000; with the wrong name the VIRTUAL_HOST patch skips silently and no route or tenant bucket ever fires (observed live: filter present, rate_limits absent, zero 429s under burst). Same naming verified on Istio 1.30 locally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Envoy 1.23's local_ratelimit only consults route-level rate-limit policies; virtual-host-level support (vh_rate_limits) arrived in 1.24+. On Istio 1.15 sidecars the VIRTUAL_HOST patch merged cleanly into config (verified in config_dump) but the filter never produced descriptors, so no bucket ever fired. Observed live on ml-training-new: filter and vhost rate_limits both present, zero 429s, zero filter stats. Route-level actions work on both 1.23 and current Envoy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…kind matrix, forwarder timeout plumbing Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…running asyncio.run() in the sync getter raises RuntimeError when called from a thread with a running event loop (sync session access inside async code); dispose the underlying sync engine directly in that case. Adds the DB-7 parametrized test covering both call contexts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Envoy header_value_match on 'Bearer <id>'/'Basic <b64>' was case-sensitive on the whole value, but FastAPI accepts the scheme case-insensitively, so a throttled caller sending 'bearer <id>' bypassed the clamp entirely (verified live on training: canonical Bearer 429'd, lowercase bearer passed 12/12). Match the scheme with a case-insensitive regex group and the credential exactly (base64/token stays case-sensitive).
…ve OpenAPI schema Adds the v2 LLM routes (and other drift) missing since the tpl was last generated; per-route istio metrics are how rate limits get sized.
The 15/5 rps-per-pod buckets were anchored to the pre-hardening fleet's degradation points (10.7 healthy / 18.3 knee rps/pod), which were symptoms of the threadpool metastability this branch removes, not capacity. The hardened gateway measured ~577 rps/pod of passing poll traffic at p99 36ms, and sustained slow-poll overload now degrades to fast shedding rather than collapse. Buckets become a runaway-storm ceiling (GET 100/pod burst 200, POST 20/pod burst 40); per-tenant fairness remains the app-level limiter's job. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The postmortem's RC2 established the incident's binding resource was node NIC egress from multi-MB inline poll results (~500MB/s/pod knee). At 100 rps/pod the bucket admits ~530MB/s/pod worst-case, i.e. no byte protection. 50 rps/pod bounds worst-case egress at ~265MB/s/pod while still admitting ~15x baseline (fleet floor 1,500 rps ~= incident peak). Revisit upward once MLI-8311 replaces inline results with presigned URLs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Emits model_engine.user_rate_limit.decision (dogstatsd counter) tagged user_id/route_class/outcome on every limiter decision (allowed, would_throttle, throttled, fail_open, breaker_open). outcome:allowed is the per-tenant volume signal on the rate-limited routes; the throttle outcomes are enforcement telemetry, including detection of the limiter itself being inactive. Replaces the postmortem's proposed raw per-tenant request-volume monitor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Consolidated hardening from the 2026-08-13 gateway incident (per-tenant rate limiting tracked in MLI-8236, sync-forwarder timeout in MLI-8206). Supersedes #864, #865, #866 and #867.
Changes
Readiness (app + chart).
healthcheckis nowasync def, so the /readyz probe no longer queues behind blocked threadpool requests; the probe gets an explicittimeoutSeconds(default 5,gateway.readinessProbeTimeoutSeconds). This removes the mechanism that ejected saturated-but-healthy pods (11/60 Ready during the incident). No livenessProbe is added: restarting a saturated pod discards in-flight work.Async-task polls off the shared threadpool (app).
GET /v1/async-tasks/{task_id}does a blocking result-backend read (S3 on AWS) and previously ran in the shared anyio threadpool, so a growing backlog's poll load starved every other sync route. The handler is now async and dispatches the read through a dedicatedCapacityLimiter(40).Retry policy (chart). VirtualService retries no longer include 503 (
retryOn: connect-failure,unavailable,502,504); retrying the overload signal roughly 4x-ed offered load at saturation. Policy configurable undergateway.retries;perTryTimeoutunset by default because the route carries streaming requests.Sidecar CPU request (chart).
sidecar.istio.io/proxyCPU: 250mon gateway pods. Without it, sidecars starved on CPU-packed nodes during scale-out and new pods never became Ready.Lazy DB engines (app). DBManager built five engines per worker process eagerly (sync/async x RW/RO + NullPool); the gateway uses only the async pair. Engines are now built per kind on first use, cutting idle Postgres pools per pod by more than half and making scale-out stop storming the reader's connection limit.
Per-pod proxy rate limits (chart, default off). Values-gated EnvoyFilter installing
local_ratelimiton the gateway sidecars: per-pod token buckets driven by arateLimits.routeslist (overflow 429s at the proxy without consuming a gateway worker) plus athrottledTenantslist that clamps named callers on the async-task routes across both accepted Authorization forms, replacing hand-authored fault-injection during incidents.Per-user rate limiting (app, default off). Redis-backed fixed-window limiter as a per-route FastAPI dependency (
user_rate_limit(route_class), composed with authentication), using the existing aioredis pool with a cached client; counter increment and TTL are one atomic Lua eval; 429 +Retry-After. Configured viauser_rate_limitsin the service config with a log-only rollout mode. Fails open on any Redis error or a check exceeding 100ms; outage and log-only warnings are sampled.Sync forwarder timeout (from #864). Explicit, configurable timeout on the sync forwarder.
Docs. Async-task polling guidance using the guide's existing tenacity idiom (jittered exponential backoff, bounded outstanding set).
Known limitation
Per-user limits key on the resolved
user_id. While prod authentication falls back toFakeAuthenticationRepository(plugins package unimportable in the internal image), any string authenticates, so a caller could rotate credentials past user-keyed limits; the identity-blind per-pod buckets are the backstop. Fixing the plugins packaging is a separate security work item in the internal repo.Verification
tests/unit: all passing (one pre-existing test requires theWORKSPACEenv var). Parametrized unit tests for the limiter: enforce vs log-only, unconfigured routes, fail-open on error and on timeout.🤖 Generated with Claude Code
Greptile Summary
This PR consolidates gateway incident hardening across readiness, retry behavior, proxy and application rate limiting, async polling isolation, database engine lifecycle, and forwarding deadlines.
Confidence Score: 2/5
The PR does not appear safe to merge while expired database sessions leak async pools and forged credentials can exhaust legitimate tenants' proxy rate-limit buckets.
Credential refresh still invokes asynchronous engine disposal without awaiting it, leaving superseded pools alive, while the tenant clamp still selects and consumes buckets from an unauthenticated raw Authorization header before FastAPI verifies the caller.
Files Needing Attention: model-engine/model_engine_server/db/base.py; charts/model-engine/templates/istio-ratelimit-envoyfilter.yaml
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart LR C[Caller] --> E[Gateway Envoy sidecar] E --> A[FastAPI gateway] A --> R[(Redis rate-limit state)] A --> D[(PostgreSQL)] A --> Q[Celery queue] Q --> W[Forwarder worker] W --> U[User model service]Reviews (18): Last reviewed commit: "revert(db): remove lazy engine changes" | Re-trigger Greptile