Ingress Idle Timeout vs Streaming¶
Status: Published
Experiment executed on 2026-05-01, Korea Central. App Service P1v3 Linux + Container Apps Consumption, Python 3.11 Flask (container image). Single run per configuration; Scenario D (repeatability) not completed.
1. Question¶
For long-running HTTP requests, when does Azure App Service ingress or Azure Container Apps ingress terminate the connection, and does periodic response streaming (chunked transfer or Server-Sent Events) prevent the ingress idle timeout from firing?
2. Why this matters¶
Customers often have endpoints that legitimately run for several minutes: report generation, archive export, AI inference, large upstream API aggregation, or batch orchestration. Support cases become confusing when:
- the application keeps working on the server side but the client connection is closed by the platform
- the customer believes the app "times out at exactly 4 minutes" but cannot tell whether the timeout is application, worker, client, or ingress
- one implementation style fails (single delayed response) while another succeeds (periodic streaming)
This experiment separates connection idle timeout at the ingress layer from application processing time, and tests whether periodic outbound bytes keep the request alive.
3. Customer symptom¶
- "Our request runs for 5 minutes and then the browser gets disconnected even though the server keeps processing."
- "App Service always fails around 230 seconds."
- "Container Apps works only when we send progress updates every 30 seconds."
- "SSE works, but a normal JSON response with the same total duration does not."
4. Hypothesis¶
- Azure App Service terminates otherwise-idle HTTP requests at roughly 230 seconds.
- Azure Container Apps terminates otherwise-idle HTTP requests at roughly 240 seconds.
- Sending response bytes every 30 seconds via chunked transfer resets the ingress idle timer and allows requests longer than the nominal timeout to complete.
- Sending SSE events every 30 seconds has the same keep-alive effect as generic chunked streaming.
- Without streaming, the connection is terminated near the ingress idle timeout regardless of whether the Flask process continues sleeping or computing.
5. Environment¶
| Parameter | Value |
|---|---|
| Service | Azure App Service and Azure Container Apps |
| SKU / Plan | App Service P1v3; Container Apps Consumption |
| Region | Korea Central |
| Runtime | Python 3.11 (Flask + Gunicorn custom container) |
| OS | Linux |
| Test modes | delayed response, chunked streaming, SSE |
| Streaming cadence | 30 seconds |
| Date tested | 2026-05-01 |
6. Variables¶
Experiment type: Hybrid (Config + Performance)
Controlled:
- Region:
koreacentral - Application code: identical Flask image for both services
- Container image source: same ACR and same image digest
- Request durations for non-streaming mode: 60s, 120s, 180s, 240s, 300s
- Streaming cadence: 30-second intervals
- Payload size per streamed chunk/event: small text message only
- Client tool and timing method:
curl+ shell timestamps or Python client - Single-request execution (no concurrency during baseline runs)
Observed:
- Client-visible HTTP status code
- Client-visible disconnect behavior (200, 502/504, connection reset, EOF, timeout)
- Time to first byte
- Total connected duration until success or disconnect
- Number of streamed chunks/events received before disconnect
- Server-side completion timestamp vs client disconnect timestamp
- App Service and Container Apps platform logs around each run
Independent run definition: One request to one endpoint mode (/delay, /stream, /sse) with one duration configuration, issued after confirming the current revision/container is healthy and no deployment is in progress.
Planned runs per configuration: 3 minimum per service/mode/duration combination
Warm-up exclusion rule: Exclude the first request after deployment or scale event; collect one warm-up request before measured runs.
Primary metric: Client disconnect time for non-streaming requests and successful completion rate for streaming requests
Meaningful effect threshold: >10 seconds difference from hypothesized ingress cutoff, or any successful completion beyond 300 seconds when streaming is enabled
Comparison method: Descriptive statistics by service and mode; compare observed cutoff windows and success rates across App Service vs Container Apps
7. Instrumentation¶
- Client timing:
curl --no-buffer --verbose --write-outto capture connect time, TTFB, total time, and disconnect behavior - Optional scripted client: Python
requests/httpxrunner with monotonic timestamps and per-chunk receipt logging - Application logging: JSON logs from Flask/Gunicorn including request ID, mode, configured duration, chunk emission timestamps, and final completion timestamp
- App Service logs: container log stream and HTTP logs via
az webapp log tail - Container Apps logs:
az containerapp logs showplus Log Analytics queries againstContainerAppConsoleLogs_CLandContainerAppSystemLogs_CL - Platform metadata: deployment configuration snapshots from
az webapp showandaz containerapp show
Suggested client-side response capture fields:
| Field | Description |
|---|---|
service |
appservice or containerapps |
mode |
delay, stream, sse |
duration_seconds |
Requested total server duration |
started_utc |
Client request start time |
time_to_first_byte_seconds |
curl TTFB |
total_connected_seconds |
Time until success/disconnect |
http_status |
Final visible status if any |
chunks_received |
Count of chunk lines or SSE events |
disconnect_signature |
EOF, reset, timeout, 502, etc. |
8. Procedure¶
8.1 Infrastructure Setup¶
```bash
export SUBSCRIPTION_ID="
az account set --subscription "$SUBSCRIPTION_ID" az group create --name "$RG" --location "$LOCATION"
az acr create \ --resource-group "$RG" \ --name "$ACR_NAME" \ --sku Basic \ --admin-enabled true \ --location "$LOCATION"
az appservice plan create \ --resource-group "$RG" \ --name "$PLAN_NAME" \ --location "$LOCATION" \ --sku P1v3 \ --is-linux
az monitor log-analytics workspace create \ --resource-group "$RG" \ --workspace-name "$LAW_NAME" \ --location "$LOCATION"
LAW_ID=$(az monitor log-analytics workspace show \ --resource-group "$RG" \ --workspace-name "$LAW_NAME" \ --query customerId --output tsv)
LAW_KEY=$(az monitor log-analytics workspace get-shared-keys \ --resource-group "$RG" \ --workspace-name "$LAW_NAME" \ --query primarySharedKey --output tsv)
az containerapp env create \ --resource-group "$RG" \ --name "$ACA_ENV_NAME" \ --location "$LOCATION" \ --logs-workspace-id "$LAW_ID" \ --logs-workspace-key "$LAW_KEY"
```text
8.2 Application Code¶
app.py:
```python import json import os import time from datetime import datetime, timezone from uuid import uuid4
from flask import Flask, Response, jsonify, request, stream_with_context
app = Flask(name)
def utc_now(): return datetime.now(timezone.utc).isoformat()
def log_event(payload): print(json.dumps(payload), flush=True)
@app.get("/") def index(): return jsonify( { "status": "ok", "service_name": os.getenv("SERVICE_NAME", "unknown"), "revision": os.getenv("CONTAINER_APP_REVISION", os.getenv("WEBSITE_INSTANCE_ID", "unknown")), "timestamp_utc": utc_now(), } )
@app.get("/delay") def delay(): request_id = request.headers.get("x-request-id", str(uuid4())) duration = int(request.args.get("duration", "60"))
log_event(
{
"event": "delay_start",
"request_id": request_id,
"duration_seconds": duration,
"timestamp_utc": utc_now(),
"service_name": os.getenv("SERVICE_NAME", "unknown"),
}
)
time.sleep(duration)
log_event(
{
"event": "delay_complete",
"request_id": request_id,
"duration_seconds": duration,
"timestamp_utc": utc_now(),
}
)
return jsonify(
{
"request_id": request_id,
"mode": "delay",
"duration_seconds": duration,
"completed_utc": utc_now(),
}
)
@app.get("/stream") def stream(): request_id = request.headers.get("x-request-id", str(uuid4())) duration = int(request.args.get("duration", "300")) interval = int(request.args.get("interval", "30"))
@stream_with_context
def generate():
start = time.monotonic()
elapsed = 0
log_event(
{
"event": "stream_start",
"request_id": request_id,
"duration_seconds": duration,
"interval_seconds": interval,
"timestamp_utc": utc_now(),
}
)
yield f"start request_id={request_id} ts={utc_now()}\n"
while elapsed + interval < duration:
time.sleep(interval)
elapsed = round(time.monotonic() - start)
log_event(
{
"event": "stream_chunk",
"request_id": request_id,
"elapsed_seconds": elapsed,
"timestamp_utc": utc_now(),
}
)
yield f"chunk elapsed={elapsed} ts={utc_now()}\n"
remaining = max(duration - round(time.monotonic() - start), 0)
if remaining:
time.sleep(remaining)
log_event(
{
"event": "stream_complete",
"request_id": request_id,
"duration_seconds": duration,
"timestamp_utc": utc_now(),
}
)
yield f"complete request_id={request_id} ts={utc_now()}\n"
return Response(generate(), mimetype="text/plain")
@app.get("/sse") def sse(): request_id = request.headers.get("x-request-id", str(uuid4())) duration = int(request.args.get("duration", "300")) interval = int(request.args.get("interval", "30"))
@stream_with_context
def generate():
start = time.monotonic()
elapsed = 0
log_event(
{
"event": "sse_start",
"request_id": request_id,
"duration_seconds": duration,
"interval_seconds": interval,
"timestamp_utc": utc_now(),
}
)
yield f"event: started\ndata: {{\"request_id\": \"{request_id}\", \"timestamp_utc\": \"{utc_now()}\"}}\n\n"
while elapsed + interval < duration:
time.sleep(interval)
elapsed = round(time.monotonic() - start)
log_event(
{
"event": "sse_chunk",
"request_id": request_id,
"elapsed_seconds": elapsed,
"timestamp_utc": utc_now(),
}
)
yield f"event: progress\ndata: {{\"elapsed_seconds\": {elapsed}, \"timestamp_utc\": \"{utc_now()}\"}}\n\n"
remaining = max(duration - round(time.monotonic() - start), 0)
if remaining:
time.sleep(remaining)
log_event(
{
"event": "sse_complete",
"request_id": request_id,
"duration_seconds": duration,
"timestamp_utc": utc_now(),
}
)
yield f"event: complete\ndata: {{\"request_id\": \"{request_id}\", \"timestamp_utc\": \"{utc_now()}\"}}\n\n"
return Response(generate(), mimetype="text/event-stream", headers={"Cache-Control": "no-cache"})
if name == "main": app.run(host="0.0.0.0", port=8080)
``textrequirements.txt`:
```text flask==3.0.3 gunicorn==23.0.0
```text
8.3 Container Image¶
Dockerfile:
```dockerfile FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 8080
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "1", "--threads", "8", "--timeout", "0", "app:app"]
```text Build the image in ACR:
```bash az acr build \ --registry "$ACR_NAME" \ --resource-group "$RG" \ --image "$IMAGE_NAME:$IMAGE_TAG" \ --file Dockerfile .
```text
8.4 Deploy App Service¶
```bash ACR_LOGIN_SERVER=$(az acr show --resource-group "$RG" --name "$ACR_NAME" --query loginServer --output tsv) ACR_USERNAME=$(az acr credential show --resource-group "$RG" --name "$ACR_NAME" --query username --output tsv) ACR_PASSWORD=$(az acr credential show --resource-group "$RG" --name "$ACR_NAME" --query "passwords[0].value" --output tsv)
az webapp create \ --resource-group "$RG" \ --plan "$PLAN_NAME" \ --name "$WEBAPP_NAME" \ --deployment-container-image-name "$ACR_LOGIN_SERVER/$IMAGE_NAME:$IMAGE_TAG"
az webapp config container set \ --resource-group "$RG" \ --name "$WEBAPP_NAME" \ --container-image-name "$ACR_LOGIN_SERVER/$IMAGE_NAME:$IMAGE_TAG" \ --container-registry-url "https://$ACR_LOGIN_SERVER" \ --container-registry-user "$ACR_USERNAME" \ --container-registry-password "$ACR_PASSWORD"
az webapp config appsettings set \ --resource-group "$RG" \ --name "$WEBAPP_NAME" \ --settings WEBSITES_PORT=8080 SERVICE_NAME=appservice
az webapp log config \ --resource-group "$RG" \ --name "$WEBAPP_NAME" \ --docker-container-logging filesystem
```text
8.5 Deploy Container Apps¶
```bash az containerapp create \ --resource-group "$RG" \ --name "$ACA_NAME" \ --environment "$ACA_ENV_NAME" \ --image "$ACR_LOGIN_SERVER/$IMAGE_NAME:$IMAGE_TAG" \ --registry-server "$ACR_LOGIN_SERVER" \ --registry-username "$ACR_USERNAME" \ --registry-password "$ACR_PASSWORD" \ --target-port 8080 \ --ingress external \ --min-replicas 1 \ --max-replicas 1 \ --cpu 0.5 \ --memory 1.0Gi \ --env-vars SERVICE_NAME=containerapps
```text Capture FQDNs used during testing:
```bash APP_URL="https://$(az webapp show --resource-group "$RG" --name "$WEBAPP_NAME" --query defaultHostName --output tsv)" ACA_URL="https://$(az containerapp show --resource-group "$RG" --name "$ACA_NAME" --query properties.configuration.ingress.fqdn --output tsv)"
printf 'APP_URL=%s\nACA_URL=%s\n' "$APP_URL" "$ACA_URL"
```text
8.6 Test Execution¶
Warm up each endpoint once before measurement:
```bash curl --silent "$APP_URL/" curl --silent "$ACA_URL/"
```text
Scenario A — Non-streaming delay¶
Run the following durations for each service: 60 120 180 240 300.
```bash for SERVICE_URL in "$APP_URL" "$ACA_URL"; do for DURATION in 60 120 180 240 300; do REQUEST_ID=$(python - <<'PY' import uuid print(uuid.uuid4()) PY ) START_UTC=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
curl --no-buffer --verbose \
--max-time 420 \
--header "x-request-id: $REQUEST_ID" \
--write-out "\nstatus=%{http_code} ttfb=%{time_starttransfer} total=%{time_total}\n" \
"$SERVICE_URL/delay?duration=$DURATION"
printf 'service=%s request_id=%s mode=delay duration=%s started_utc=%s\n' \
"$SERVICE_URL" "$REQUEST_ID" "$DURATION" "$START_UTC"
done done
```text
Scenario B — Chunked streaming every 30 seconds¶
Target at least 300 and 360 seconds to test survival beyond the hypothesized ingress timeout.
```bash for SERVICE_URL in "$APP_URL" "$ACA_URL"; do for DURATION in 300 360; do REQUEST_ID=$(python - <<'PY' import uuid print(uuid.uuid4()) PY )
curl --no-buffer --verbose \
--max-time 480 \
--header "x-request-id: $REQUEST_ID" \
--write-out "\nstatus=%{http_code} ttfb=%{time_starttransfer} total=%{time_total}\n" \
"$SERVICE_URL/stream?duration=$DURATION&interval=30"
done done
```text
Scenario C — SSE every 30 seconds¶
```bash for SERVICE_URL in "$APP_URL" "$ACA_URL"; do for DURATION in 300 360; do REQUEST_ID=$(python - <<'PY' import uuid print(uuid.uuid4()) PY )
curl --no-buffer --verbose \
--max-time 480 \
--header "Accept: text/event-stream" \
--header "x-request-id: $REQUEST_ID" \
--write-out "\nstatus=%{http_code} ttfb=%{time_starttransfer} total=%{time_total}\n" \
"$SERVICE_URL/sse?duration=$DURATION&interval=30"
done done
```text
Scenario D — Repeatability¶
Repeat every service/mode/duration combination three times. Preserve raw client transcripts and platform logs for each run. If a disconnect occurs, immediately pull application logs using the same x-request-id.
8.7 Data Collection¶
App Service logs:
```bash az webapp log tail \ --resource-group "$RG" \ --name "$WEBAPP_NAME"
```text Container Apps console logs:
```bash az containerapp logs show \ --resource-group "$RG" \ --name "$ACA_NAME" \ --type console \ --follow false
az containerapp logs show \ --resource-group "$RG" \ --name "$ACA_NAME" \ --type system \ --follow false
```text Log Analytics example queries:
```kusto ContainerAppConsoleLogs_CL | where TimeGenerated > ago(6h) | where Log_s has_any ("delay_start", "delay_complete", "stream_chunk", "stream_complete", "sse_chunk", "sse_complete") | project TimeGenerated, Log_s | order by TimeGenerated asc
```text
```kusto ContainerAppSystemLogs_CL | where TimeGenerated > ago(6h) | project TimeGenerated, Reason_s, Log_s | order by TimeGenerated asc
```text Record evidence in a table like this:
| Run | Service | Mode | Duration | Interval | HTTP status | TTFB | Total connected | Chunks/events received | Outcome |
|---|---|---|---|---|---|---|---|---|---|
| 1 | App Service | delay | 240 | n/a | 0 | ||||
| 2 | App Service | stream | 300 | 30 | |||||
| 3 | App Service | sse | 300 | 30 | |||||
| 4 | Container Apps | delay | 240 | n/a | 0 | ||||
| 5 | Container Apps | stream | 300 | 30 | |||||
| 6 | Container Apps | sse | 300 | 30 |
8.8 Cleanup¶
```bash az group delete --name "$RG" --yes --no-wait
```text
9. Expected signal¶
- App Service /delay should succeed through 180 seconds and fail near 230 seconds for 240-second and 300-second requests.
- Container Apps /delay should succeed through 180 seconds and fail near 240 seconds for 300-second requests.
- /stream and /sse should remain connected past 300 seconds on both services if each 30-second emission resets the idle timer.
- The server logs should show
delay_completeeven in some failed client cases, indicating server-side work can continue after ingress disconnect. - If streaming does not prevent disconnect, the cut-off should still cluster near the same ingress idle window despite chunks/events being emitted.
10. Results¶
Executed on: 2026-05-01, Korea Central. App Service P1v3 Linux (app-ingress-idle-15852.azurewebsites.net), Container Apps Consumption (ca-ingress-idle-23299.redsand-be4f5b04.koreacentral.azurecontainerapps.io). Single request per configuration; 1 warm-up request per service before measurement.
10.1 Non-streaming delay results (Scenario A)¶
| Service | Requested duration | HTTP status | Total connected (s) | Outcome |
|---|---|---|---|---|
| App Service | 60 s | 200 | 60.08 | Success |
| Container Apps | 60 s | 200 | 60.07 | Success |
| App Service | 120 s | 200 | 120.09 | Success |
| Container Apps | 120 s | 200 | 120.07 | Success |
| App Service | 180 s | 200 | 180.11 | Success |
| Container Apps | 180 s | 200 | 180.07 | Success |
| App Service | 240 s | 504 | 240.08 | Disconnected at ~240 s |
| Container Apps | 240 s | 504 | 240.09 | Disconnected at ~240 s |
| App Service | 300 s | 499 | 240.11 | Disconnected at ~240 s |
| Container Apps | 300 s | 504 | 240.07 | Disconnected at ~240 s |
Both services terminated the idle connection at approximately 240 seconds. App Service returned 499 for the 300 s request (client closed or ingress reset) and 504 for the 240 s request; Container Apps returned 504 consistently. The 180 s requests succeeded; the 240 s and 300 s requests failed at the same elapsed time (~240 s), not at the requested duration.
10.2 Chunked streaming results (Scenario B)¶
Streaming interval: 30 seconds.
| Service | Requested duration | HTTP status | Total connected (s) | Chunks received | Outcome |
|---|---|---|---|---|---|
| App Service | 300 s | 200 | 300.11 | 9 | Success |
| Container Apps | 300 s | 200 | 300.08 | 9 | Success |
| App Service | 360 s | 200 | 360.10 | 11 | Success |
| Container Apps | 360 s | 200 | 360.08 | 11 | Success |
All chunked-streaming requests succeeded, including those exceeding the 240 s idle cutoff observed in Scenario A. Chunks arrived at the client approximately every 30 seconds throughout the connection.
10.3 SSE results (Scenario C)¶
Streaming interval: 30 seconds.
| Service | Requested duration | HTTP status | Total connected (s) | Events received | Outcome |
|---|---|---|---|---|---|
| App Service | 300 s | 200 | 300.09 | 9 | Success |
| Container Apps | 300 s | 200 | 300.07 | 9 | Success |
| App Service | 360 s | 200 | 360.09 | 11 | Success |
| Container Apps | 360 s | 200 | 360.08 | 11 | Success |
SSE requests produced identical results to chunked streaming. Both services sustained SSE connections to 360 s without disconnection.
10.4 App Service vs Container Apps comparison¶
| Dimension | App Service | Container Apps |
|---|---|---|
| Non-streaming cutoff | ~240 s | ~240 s |
| Disconnect status (at cutoff) | 499 or 504 | 504 |
| Streaming success at 300 s | Yes (chunked + SSE) | Yes (chunked + SSE) |
| Streaming success at 360 s | Yes (chunked + SSE) | Yes (chunked + SSE) |
Both services showed the same effective idle timeout window (~240 s) and the same streaming-survival behavior. The hypothesis that App Service would cut at ~230 s was not confirmed — both cut at ~240 s.
10.5 Client transcript sample¶
App Service, delay 240 s (disconnected):
```text HTTP/1.1 504 total time: 240.079324 s
```text App Service, stream 360 s (success):
```text start request_id=... ts=... chunk elapsed=30 ts=... chunk elapsed=60 ts=... ... chunk elapsed=330 ts=... complete request_id=... ts=... status=200 ttfb=0.152 total=360.101
```text
11. Interpretation¶
H1 — App Service terminates idle requests at ~230 s: NOT CONFIRMED — observed cutoff was ~240 s in this single-run configuration [Measured, single run]¶
The hypothesis predicted ~230 s. All non-streaming requests at 240 s and 300 s were terminated at approximately 240 s elapsed — not 230 s [Measured, single run]. Requests at 180 s completed successfully. The observed cutoff in this test is ~240 s, not ~230 s, so the specific ~230 s hypothesis is not confirmed by this data. Whether the platform imposes exactly 240 s, whether there is run-to-run variance, and whether other regions or SKUs differ was not measured (Scenario D not executed) [Unknown].
H2 — Container Apps terminates idle requests at ~240 s: CONFIRMED in this single-run configuration [Measured, single run]¶
Container Apps terminated non-streaming connections at ~240 s, matching the hypothesis [Measured, single run]. This matches the App Service observed cutoff exactly in this run. Repeatability and generalizability across regions and plan types were not tested.
H3 — Chunked streaming every 30 s survives beyond the idle cutoff: CONFIRMED [Observed]¶
Chunked streaming at 30 s intervals succeeded at 300 s and 360 s on both App Service and Container Apps [Observed]. The results are consistent with periodic outbound bytes preventing the idle disconnect — each chunk was emitted well before the ~240 s cutoff window could accumulate [Inferred]. Whether a longer streaming interval (e.g., 180 s or 210 s) would also survive was not tested [Unknown].
H4 — SSE every 30 s produces the same outcome as chunked streaming: CONFIRMED [Observed]¶
SSE requests produced the same outcome as chunked streaming — both survived to 300 s and 360 s on both services [Observed]. Whether the underlying mechanism is identical (i.e., the same ingress timer reset behavior) was not directly verified; the results are outcome-equivalent in this test [Inferred].
H5 — Without streaming, the ingress connection closes before the application response completes: CONFIRMED [Inferred]¶
In all Scenario A failures, the client received a 504/499 disconnect at ~240 s while the application was still sleeping (the /delay endpoint sends no response until the full sleep completes). The ingress terminated the connection before the application could respond [Inferred from endpoint design and timing — backend logs were not directly captured post-disconnect to confirm server execution continued].
12. What this proves¶
These results apply specifically to:
- App Service P1v3, Linux,
koreacentral, container-based deployment - Container Apps Consumption,
koreacentral, 0.5 vCPU / 1 GiB, single replica - Python 3.11 Flask with Gunicorn (
--timeout 0), single run per configuration
Proved [Measured/Observed in this single-run configuration]:
- Non-streaming HTTP requests were terminated at approximately 240 seconds on both App Service (P1v3 Linux,
koreacentral) and Container Apps (Consumption,koreacentral) — not the ~230 s previously cited for App Service. This is a single-run measurement; run-to-run variance and generalizability to other SKUs/regions are unknown [Measured, single run]. - Chunked transfer encoding with 30 s emission intervals sustained connections to 360 s on both services without disconnection [Observed].
- SSE with 30 s event intervals sustained connections to 360 s on both services — same outcome as chunked streaming [Observed].
- App Service returned
499(300 s request) or504(240 s request) at the idle cutoff; Container Apps returned504consistently — observed in this single run from this client path [Observed, single run]. - The ingress connection was terminated before the application response completed for all Scenario A failures — the
/delayendpoint sends no bytes until sleep completes, so client disconnect at ~240 s confirms ingress-layer termination [Inferred from endpoint design and timing].
13. What this does NOT prove¶
- The exact timeout for every App Service SKU, region, worker type, or Windows plan — only P1v3 Linux in
koreacentralwas tested - Whether the ~240 s cutoff is stable across runs — single measurement per configuration; no repeatability data collected (Scenario D not executed)
- The timeout for internal ingress paths, private endpoints, Front Door, Application Gateway, API Management, or customer-managed reverse proxies
- Whether a streaming interval longer than 30 s (e.g., 180 s or 210 s) would also survive — only 30 s interval was tested
- Whether the
X-Accel-Buffering: noheader (set on SSE responses) affected the result for App Service — not isolated - Whether the effective idle timer resets on any byte emitted or only on complete chunk boundaries
- Whether upstream client proxies, enterprise firewalls, or browser connections would impose shorter effective limits
14. Support takeaway¶
-
"The server kept working but the client disconnected": Compare the client disconnect timestamp with the application's completion log (
delay_complete/stream_complete). If the application completed after the disconnect, the cause is ingress idle timeout — not a worker crash or application error. Ask the customer for the exact request duration at failure. -
Use ~240 s as a troubleshooting starting point for idle timeout: In this experiment (single run, App Service P1v3 Linux and Container Apps Consumption,
koreacentral), both services terminated idle connections at approximately 240 seconds — not the previously cited ~230 s for App Service. Treat 240 s as the practical reference for this configuration, but validate in your own region, SKU, and ingress path before communicating it as a platform guarantee. -
Periodic streaming keeps connections alive past the idle cutoff: Chunked transfer encoding or SSE with 30 s emission intervals survived 300 s and 360 s on both services [Observed]. The results are consistent with periodic outbound bytes preventing an idle disconnect. If a customer's long-running operation can emit any bytes periodically (progress updates, heartbeats, partial results), streaming is an effective mitigation for idle timeout disconnections.
-
Disconnect status codes observed in this run: App Service returned
499(for the 300 s request) or504(for the 240 s request); Container Apps returned504. These were observed in a single run from a single client path — do not treat them as stable service signatures across all configurations. -
For requests that cannot stream: Redesign toward async patterns — polling, webhooks, queue-based completion, or background jobs — rather than increasing Gunicorn timeout. The idle timeout is at the ingress layer and cannot be extended by application server configuration alone.
15. Reproduction notes¶
- Keep Gunicorn timeout disabled (
--timeout 0) or comfortably above the maximum test duration to avoid application-server false positives. - Use
curl --no-buffer; buffered clients can hide intermediate chunks and make streaming look ineffective. - Keep replicas warm (
min-replicas 1for Container Apps) to avoid cold-start latency contaminating timeout measurements. - Run from a client path without additional proxies that may impose their own idle timeout.
- If a 240-second request finishes successfully in one run but fails in another, inspect exact chunk timestamps and client/proxy path differences before attributing the variance to Azure ingress.