Problem
During peak traffic periods the API gateway that fronts a Haystack‑based search service starts returning 504 Gateway Timeout, 502 Bad Gateway and occasional 429 Too Many Requests errors. The downstream Haystack document store logs show repeated failures when trying to fetch source documents from the cloud storage provider (e.g., AWS S3, GCP Cloud Storage, Azure Blob). The symptoms are:
- Intermittent
ReadTimeoutexceptions from the storage SDK. - HTTP 5xx responses from the storage endpoint.
- Rate‑limit headers (e.g.,
Retry-After) appearing only under load. - Overall search latency spikes from < 100 ms to > 5 s.
These failures cascade: Haystack cannot retrieve the document text, the query processor aborts, and the API gateway propagates the error to the client.
Root Cause
The cloud storage provider enforces per‑client request quotas that are dynamically adjusted based on observed traffic patterns. Under sustained high QPS the provider:
- Detects a burst of GET Object calls from a single IP range.
- Enters a throttling state, returning
503 Service Unavailablewith aRetry-Afterheader. - If the client does not respect the back‑off, the provider escalates to
429 Too Many Requestsand eventually drops connections, causing socket timeouts.
Haystack’s default DocumentStore implementation uses a synchronous HTTP client with a fixed 2 s timeout and no built‑in retry/back‑off logic. When the storage API starts throttling, the client immediately times out, producing the observed errors.
Debug
Below is a typical log excerpt from the Haystack worker when the issue occurs:
2026-10-02 14:12:37,842 - haystack.document_stores.s3 - ERROR - S3 GET failed for key docs/12345.txt: ReadTimeoutError: HTTPSConnectionPool(host=s3.amazonaws.com, port=443): Read timed out. (timeout=2)
2026-10-02 14:12:38,001 - haystack.nodes.retriever - INFO - Retrieval aborted after 1 attempts
2026-10-02 14:12:38,003 - api.gateway - ERROR - 504 Gateway Timeout: upstream service failed
Key observations from the logs:
- Timeout value is hard‑coded to 2 seconds.
- No retry attempts are logged.
- The gateway error correlates with the storage timeout.
Diagnostic steps performed:
- Network trace with
tcpdumpon the worker node showed repeated SYN retransmissions to the storage endpoint during the spike. - Metrics inspection in Prometheus revealed a sharp rise in
http_requests_total{service="s3"}[1m]from 500 RPS to > 3 000 RPS. - SDK instrumentation enabled detailed debug output for the storage client, confirming receipt of
503and429responses.
Sample command to capture the storage responses:
sudo tcpdump -i eth0 -s 0 -w /tmp/s3.pcap host s3.amazonaws.com and port 443
After filtering the capture with tshark the following HTTP status codes were observed:
HTTP/1.1 503 Service Unavailable
Retry-After: 30
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Solution
The fix consists of three coordinated changes:
1. Introduce exponential back‑off with jitter
Replace the raw SDK call with a wrapper that respects Retry-After and performs bounded retries.
# before (simplified)
def fetch_document(key):
return s3_client.get_object(Bucket=BUCKET, Key=key)['Body'].read()
# after
import time
import random
import botocore.exceptions
MAX_RETRIES = 5
BASE_DELAY = 0.5 # seconds
def fetch_document(key):
attempt = 0
while True:
try:
resp = s3_client.get_object(Bucket=BUCKET, Key=key)
return resp['Body'].read()
except botocore.exceptions.ClientError as e:
status = e.response['ResponseMetadata']['HTTPStatusCode']
if status in (429, 503):
retry_after = e.response.get('RetryAfter')
delay = float(retry_after) if retry_after else BASE_DELAY * (2 ** attempt)
jitter = random.uniform(0, delay * 0.1)
time.sleep(delay + jitter)
attempt += 1
if attempt > MAX_RETRIES:
raise
else:
raise
2. Increase the client timeout and enable connection pooling
Configure the underlying HTTP adapter to use a larger timeout (e.g., 10 s) and keep‑alive connections.
# before
s3_client = boto3.client('s3', config=botocore.client.Config(connect_timeout=2, read_timeout=2))
# after
s3_client = boto3.client(
's3',
config=botocore.client.Config(
connect_timeout=5,
read_timeout=10,
retries={'max_attempts': 0}, # disable SDK retries, we handle them ourselves
max_pool_connections=50
)
)
3. Apply request rate limiting at the API gateway
Introduce a token‑bucket limiter that caps the outbound S3 request rate per worker to a safe value (e.g., 1 200 RPS). Below is an example using envoy rate limit filter:
http_filters:
- name: envoy.filters.http.ratelimit
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.ratelimit.v3.RateLimit
domain: s3_rate_limit
request_type: both
timeout: 0.025s
rate_limit_service:
grpc_service:
envoy_grpc:
cluster_name: rate_limit_cluster
Corresponding rate_limit_cluster points to a lightweight in‑process limiter that tracks per‑worker counters.
Verify
After deploying the changes, perform the following validation steps:
- Smoke test a single document fetch with
curlagainst the API gateway; response time should be < 200 ms. - Load test using
heyorwrkat 3 000 RPS for 5 minutes. Monitor:- Haystack logs – no longer see
ReadTimeoutError. - Prometheus metric
http_requests_total{service="s3"}[5m]should stay below the configured limit. - Storage provider metrics – absence of
429/503responses.
- Haystack logs – no longer see
- Health check endpoint
/healthzshould return200with a sub‑check confirming successful S3 connectivity.
Sample successful log entry after the fix:
2026-10-02 14:45:12,311 - haystack.document_stores.s3 - INFO - S3 GET succeeded for key docs/98765.txt after 2 retries (total latency 4.3s)
Prevent
To avoid recurrence, implement the following guardrails:
- Monitoring: Alert on
http_requests_total{service="s3"}[1m] > 1,200and on any5xxor429responses from the storage endpoint. - Circuit breaker: Deploy a per‑worker circuit breaker that temporarily halts S3 calls if error rate exceeds 5 % over a 30‑second window.
- Capacity planning: Coordinate with the cloud provider to obtain the exact request quota for the account and request a higher limit if needed.
- Configuration as code: Store the retry/back‑off parameters and rate‑limit thresholds in a version‑controlled
config.yamland enforce CI linting. - Graceful degradation: Cache frequently accessed documents in an in‑memory store (e.g., Redis) to reduce peak S3 load.
Related Topic Hub: RAG Systems Troubleshooting Hub
FAQ
- Why does increasing the timeout alone not solve the problem? The storage service actively throttles during spikes; a longer timeout only prolongs the waiting period and can still result in 5xx responses. Without back‑off, the client continues hammering the endpoint, worsening the throttle.
- Can I rely on the SDK’s built‑in retry mechanism? The SDK retries are limited to idempotent errors and use a fixed back‑off that does not honor
Retry-After. A custom wrapper gives deterministic control and respects provider‑specified delays. - What if the provider’s quota is insufficient even after back‑off? Request a quota increase or introduce a secondary storage tier (e.g., multi‑region replication) to spread the load.
- How do I differentiate between network latency spikes and provider throttling? Throttling responses include HTTP 429/503 with
Retry-After. Pure latency spikes return 200 but with high response times; correlating with the storage service’s status page can confirm throttling events. - Is it safe to cache documents indefinitely? Cache staleness must be bounded. Use a TTL aligned with the source data update frequency (e.g., 5 minutes) and invalidate on write‑through events.