Skip to content

Launch gateway hardening: probes, retries, sidecar CPU, DB pools, rate limiting - #868

Open
lorenzo-norcini-scale wants to merge 13 commits into
mainfrom
lorenzonorcini/launch-gateway-incident-hardening
Open

Launch gateway hardening: probes, retries, sidecar CPU, DB pools, rate limiting#868
lorenzo-norcini-scale wants to merge 13 commits into
mainfrom
lorenzonorcini/launch-gateway-incident-hardening

Conversation

@lorenzo-norcini-scale

@lorenzo-norcini-scale lorenzo-norcini-scale commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

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). healthcheck is now async def, so the /readyz probe no longer queues behind blocked threadpool requests; the probe gets an explicit timeoutSeconds (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 dedicated CapacityLimiter(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 under gateway.retries; perTryTimeout unset by default because the route carries streaming requests.

Sidecar CPU request (chart). sidecar.istio.io/proxyCPU: 250m on 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_ratelimit on the gateway sidecars: per-pod token buckets driven by a rateLimits.routes list (overflow 429s at the proxy without consuming a gateway worker) plus a throttledTenants list 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 via user_rate_limits in 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 to FakeAuthenticationRepository (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 the WORKSPACE env var). Parametrized unit tests for the limiter: enforce vs log-only, unconfigured routes, fail-open on error and on timeout.
  • Chart rendered with default and override values: probe timeout, retry block, proxyCPU annotation, and EnvoyFilter (route buckets, tenant clamp path-scoped, rendered Basic prefix matches the incident-derived value) all emit as expected; EnvoyFilter absent when disabled.
  • black / ruff / isort / mypy pass on all touched files.
  • Reviewed by 4 parallel cleanup agents plus 3 rounds of external review; all findings applied or explicitly deferred (security item above).

🤖 Generated with Claude Code

Greptile Summary

The PR hardens gateway behavior under saturation by adjusting probes and retries, isolating async-task polling, lazily creating database engines, and adding proxy and Redis-backed rate limiting.

  • Adds configurable gateway readiness, sidecar CPU, retry, and local-rate-limit policies.
  • Adds per-user async-task limits and a dedicated thread limiter for result polling.
  • Lazily initializes database session families and introduces configurable forwarding timeouts.
  • Documents bounded async-task polling with exponential backoff.

Confidence Score: 0/5

The PR does not appear safe to merge because proxy rate limiting remains ineffective and forgeable, async database pools are not disposed correctly, and Celery forwarding can still block indefinitely.

The current Envoy filter neither enables its virtual-host descriptor actions nor authenticates tenant header prefixes before consuming shared buckets; database credential refresh calls asynchronous disposal without awaiting it; and the Celery requests path still omits the newly configured timeout.

Files Needing Attention: charts/model-engine/templates/istio-ratelimit-envoyfilter.yaml, model-engine/model_engine_server/db/base.py, model-engine/model_engine_server/inference/forwarding/forwarding.py

Important Files Changed

Filename Overview
charts/model-engine/templates/istio-ratelimit-envoyfilter.yaml Adds optional per-pod route and tenant token buckets to gateway sidecars.
model-engine/model_engine_server/db/base.py Replaces eager construction of five database engine families with synchronized lazy initialization and credential-expiry refresh.
model-engine/model_engine_server/inference/forwarding/forwarding.py Adds a validated configurable timeout to the forwarder configuration and asynchronous HTTP transport.
model-engine/model_engine_server/api/rate_limits.py Adds an authenticated Redis-backed fixed-window limiter with log-only, fail-open, timeout, and circuit-breaker behavior.
model-engine/model_engine_server/api/tasks_v1.py Applies per-user limits to async-task routes and moves blocking result retrieval onto a dedicated capacity-limited thread pool.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Client[Client] --> Envoy[Gateway sidecar]
  Envoy --> API[FastAPI gateway]
  API --> Redis[(Redis rate-limit counter)]
  API --> PollLimiter[Dedicated poll thread limiter]
  PollLimiter --> ResultBackend[(Async result backend)]
  API --> DBManager[Lazy DBManager]
  DBManager --> PostgreSQL[(PostgreSQL)]
  API --> Forwarder[Inference forwarder]
  Forwarder --> Model[User model service]
Loading

Reviews (4): Last reviewed commit: "fix(chart+gateway): Envoy 1.23 compatibi..." | Re-trigger Greptile

Context used (4)

lorenzo-norcini-scale and others added 10 commits August 14, 2026 14:22
…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>
max_tokens: 100000
tokens_per_fill: 100000
fill_interval: 1s
always_consume_default_token_bucket: false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Virtual-host descriptors remain disabled

When proxy rate limiting is enabled, the route and tenant actions are attached to the virtual host, but the local rate-limit filter does not enable vh_rate_limits; requests therefore use the effectively unlimited default bucket instead of the configured descriptors, so async-task shedding and tenant clamps do not return the intended 429 responses.

Suggested change
always_consume_default_token_bucket: false
always_consume_default_token_bucket: false
vh_rate_limits: true

Knowledge Base Used: Model-engine deployment and startup

Prompt To Fix With AI
This is a comment left during a code review.
Path: charts/model-engine/templates/istio-ratelimit-envoyfilter.yaml
Line: 44

Comment:
**Virtual-host descriptors remain disabled**

When proxy rate limiting is enabled, the route and tenant actions are attached to the virtual host, but the local rate-limit filter does not enable `vh_rate_limits`; requests therefore use the effectively unlimited default bucket instead of the configured descriptors, so async-task shedding and tenant clamps do not return the intended 429 responses.

```suggestion
            always_consume_default_token_bucket: false
            vh_rate_limits: true
```

**Knowledge Base Used:** [Model-engine deployment and startup](https://app.greptile.com/scale-ai/-/custom-context/knowledge-base/scaleapi/llm-engine/-/docs/deployment-and-startup.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex

Comment on lines +255 to +256
for old_session in old_sessions:
old_session.engine.dispose()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Async engine disposal is unawaited

When an Azure database credential approaches expiry after an async engine has been cached, this synchronous loop calls the coroutine-based AsyncEngine.dispose() without awaiting it, leaving the old async pool and connections undisposed while replacement pools are created.

Knowledge Base Used: Database persistence for model-engine state

Prompt To Fix With AI
This is a comment left during a code review.
Path: model-engine/model_engine_server/db/base.py
Line: 255-256

Comment:
**Async engine disposal is unawaited**

When an Azure database credential approaches expiry after an async engine has been cached, this synchronous loop calls the coroutine-based `AsyncEngine.dispose()` without awaiting it, leaving the old async pool and connections undisposed while replacement pools are created.

**Knowledge Base Used:** [Database persistence for model-engine state](https://app.greptile.com/scale-ai/-/custom-context/knowledge-base/scaleapi/llm-engine/-/docs/database-persistence.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex

- 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>
Comment thread charts/model-engine/templates/istio-ratelimit-envoyfilter.yaml
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>
# Cap on the full round-trip to the user-defined service. Must be explicit: without
# one, aiohttp applies its default total=300s and long-running non-streaming
# generations get cut off with a 500 while the model server keeps computing.
timeout_seconds: float = DEFAULT_SYNC_TIMEOUT_SECONDS

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Celery forwarding remains unbounded

When the user service accepts a Celery-forwarded request but never returns a response, Forwarder.__call__ ignores the configured timeout_seconds value and waits indefinitely in requests.post, causing workers to remain occupied until available worker capacity is exhausted.

Knowledge Base Used: Inference forwarding across HTTP and Celery

Prompt To Fix With AI
This is a comment left during a code review.
Path: model-engine/model_engine_server/inference/forwarding/forwarding.py
Line: 178

Comment:
**Celery forwarding remains unbounded**

When the user service accepts a Celery-forwarded request but never returns a response, `Forwarder.__call__` ignores the configured `timeout_seconds` value and waits indefinitely in `requests.post`, causing workers to remain occupied until available worker capacity is exhausted.

**Knowledge Base Used:** [Inference forwarding across HTTP and Celery](https://app.greptile.com/scale-ai/-/custom-context/knowledge-base/scaleapi/llm-engine/-/docs/inference-forwarding.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex

… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant