Skip to content

Commit 8d7440f

Browse files
committed
v1.17: Fix 60-second VNet timeout with polling-based content generation
- Changed from SSE streaming to polling architecture for content generation - Added /api/generate/start endpoint that returns task_id immediately - Added /api/generate/status/<task_id> endpoint for polling results - Frontend now polls every 1 second instead of waiting on SSE stream - Each poll request completes quickly, avoiding VNet integration timeout - Added background task execution with in-memory task storage - Added proper async client cleanup in image generation - Preserved original /api/generate SSE endpoint for backward compatibility - Updated server.js with extended timeouts and keep-alive settings Root cause: Azure VNet integration has a hardcoded 60-second idle timeout that killed SSE connections before gpt-image-1 could complete (40-90s). The polling approach bypasses this by using quick request/response cycles.
1 parent e481ea8 commit 8d7440f

9 files changed

Lines changed: 495 additions & 117 deletions

File tree

content-gen/src/app.py

Lines changed: 297 additions & 34 deletions
Large diffs are not rendered by default.

content-gen/src/backend/agents/image_content_agent.py

Lines changed: 66 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
"""
21
"""Image Content Agent - Generates marketing images via DALL-E 3 or gpt-image-1.
32
43
Provides the generate_image function used by the orchestrator
@@ -196,24 +195,28 @@ async def _generate_dalle_image(
196195
api_version=app_settings.azure_openai.preview_api_version,
197196
)
198197

199-
response = await client.images.generate(
200-
model=app_settings.azure_openai.dalle_model,
201-
prompt=full_prompt,
202-
size=size,
203-
quality=quality,
204-
n=1,
205-
response_format="b64_json"
206-
)
207-
208-
image_data = response.data[0]
209-
210-
return {
211-
"success": True,
212-
"image_base64": image_data.b64_json,
213-
"prompt_used": full_prompt,
214-
"revised_prompt": getattr(image_data, 'revised_prompt', None),
215-
"model": "dall-e-3",
216-
}
198+
try:
199+
response = await client.images.generate(
200+
model=app_settings.azure_openai.dalle_model,
201+
prompt=full_prompt,
202+
size=size,
203+
quality=quality,
204+
n=1,
205+
response_format="b64_json"
206+
)
207+
208+
image_data = response.data[0]
209+
210+
return {
211+
"success": True,
212+
"image_base64": image_data.b64_json,
213+
"prompt_used": full_prompt,
214+
"revised_prompt": getattr(image_data, 'revised_prompt', None),
215+
"model": "dall-e-3",
216+
}
217+
finally:
218+
# Properly close the async client to avoid unclosed session warnings
219+
await client.close()
217220

218221
except Exception as e:
219222
logger.exception(f"Error generating DALL-E image: {e}")
@@ -327,25 +330,50 @@ async def _generate_gpt_image(
327330
api_version=app_settings.azure_openai.preview_api_version,
328331
)
329332

330-
# gpt-image-1 API call
331-
response = await client.images.generate(
332-
model="gpt-image-1",
333-
prompt=full_prompt,
334-
size=size,
335-
quality=quality,
336-
n=1,
337-
response_format="b64_json"
338-
)
339-
340-
image_data = response.data[0]
341-
342-
return {
343-
"success": True,
344-
"image_base64": image_data.b64_json,
345-
"prompt_used": full_prompt,
346-
"revised_prompt": getattr(image_data, 'revised_prompt', None),
347-
"model": "gpt-image-1",
348-
}
333+
try:
334+
# gpt-image-1 API call - note: gpt-image-1 doesn't support response_format parameter
335+
# It returns base64 data directly in the response
336+
response = await client.images.generate(
337+
model="gpt-image-1",
338+
prompt=full_prompt,
339+
size=size,
340+
quality=quality,
341+
n=1,
342+
)
343+
344+
image_data = response.data[0]
345+
346+
# gpt-image-1 returns b64_json directly without needing response_format parameter
347+
image_base64 = getattr(image_data, 'b64_json', None)
348+
349+
# If no b64_json, try to get URL and fetch the image
350+
if not image_base64 and hasattr(image_data, 'url') and image_data.url:
351+
import aiohttp
352+
async with aiohttp.ClientSession() as session:
353+
async with session.get(image_data.url) as resp:
354+
if resp.status == 200:
355+
import base64
356+
image_bytes = await resp.read()
357+
image_base64 = base64.b64encode(image_bytes).decode('utf-8')
358+
359+
if not image_base64:
360+
return {
361+
"success": False,
362+
"error": "No image data returned from gpt-image-1",
363+
"prompt_used": full_prompt,
364+
"model": "gpt-image-1",
365+
}
366+
367+
return {
368+
"success": True,
369+
"image_base64": image_base64,
370+
"prompt_used": full_prompt,
371+
"revised_prompt": getattr(image_data, 'revised_prompt', None),
372+
"model": "gpt-image-1",
373+
}
374+
finally:
375+
# Properly close the async client to avoid unclosed session warnings
376+
await client.close()
349377

350378
except Exception as e:
351379
logger.exception(f"Error generating gpt-image-1 image: {e}")

content-gen/src/backend/orchestrator.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -803,9 +803,38 @@ async def generate_content(
803803
)
804804

805805
if image_result.get("success"):
806-
results["image_base64"] = image_result.get("image_base64")
806+
image_base64 = image_result.get("image_base64")
807807
results["image_revised_prompt"] = image_result.get("revised_prompt")
808808
logger.info("DALL-E image generated successfully")
809+
810+
# Save to blob storage immediately to avoid returning huge base64
811+
# This prevents timeout issues with large responses
812+
try:
813+
from backend.services.blob_service import BlobStorageService
814+
import os
815+
from datetime import datetime
816+
817+
blob_service = BlobStorageService()
818+
# Generate a unique conversation-like ID for this generation
819+
gen_id = datetime.utcnow().strftime("%Y%m%d%H%M%S")
820+
logger.info(f"Saving image to blob storage (size: {len(image_base64)} bytes)...")
821+
822+
blob_url = await blob_service.save_generated_image(
823+
conversation_id=f"gen_{gen_id}",
824+
image_base64=image_base64
825+
)
826+
827+
if blob_url:
828+
# Store the blob URL - will be converted to proxy URL by app.py
829+
results["image_blob_url"] = blob_url
830+
logger.info(f"Image saved to blob: {blob_url}")
831+
else:
832+
# Fallback to base64 if blob save fails
833+
results["image_base64"] = image_base64
834+
logger.warning("Blob save returned None, falling back to base64")
835+
except Exception as blob_error:
836+
logger.warning(f"Failed to save to blob, falling back to base64: {blob_error}")
837+
results["image_base64"] = image_base64
809838
else:
810839
logger.warning(f"DALL-E image generation failed: {image_result.get('error')}")
811840
results["image_error"] = image_result.get("error")
@@ -855,6 +884,12 @@ async def generate_content(
855884
logger.exception(f"Error generating content: {e}")
856885
results["error"] = str(e)
857886

887+
# Log results summary before returning
888+
logger.info(f"Orchestrator returning results with keys: {list(results.keys())}")
889+
has_image = bool(results.get("image_base64"))
890+
image_size = len(results.get("image_base64", "")) if has_image else 0
891+
logger.info(f"Orchestrator results: has_image={has_image}, image_size={image_size}, has_error={bool(results.get('error'))}")
892+
858893
return results
859894

860895

169 KB
Binary file not shown.
Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
const express = require('express');
22
const { createProxyMiddleware } = require('http-proxy-middleware');
33
const path = require('path');
4+
const http = require('http');
45

56
const app = express();
67
const PORT = process.env.PORT || 8080;
78

89
// Backend API URL (ACI private IP in VNet)
9-
const BACKEND_URL = process.env.BACKEND_URL || 'http://10.0.4.4:8000';
10+
const BACKEND_URL = process.env.BACKEND_URL || 'http://10.0.4.5:8000';
11+
12+
// Create HTTP agent with extended keep-alive timeout for long-running SSE connections
13+
const httpAgent = new http.Agent({
14+
keepAlive: true,
15+
keepAliveMsecs: 300000, // 5 minutes keep-alive
16+
maxSockets: 100,
17+
timeout: 600000 // 10 minutes socket timeout
18+
});
1019

1120
// Proxy API requests to backend
1221
app.use('/api', createProxyMiddleware({
@@ -15,22 +24,30 @@ app.use('/api', createProxyMiddleware({
1524
pathRewrite: {
1625
'^/api': '/api'
1726
},
18-
// Increase timeout for long-running requests (5 minutes)
19-
proxyTimeout: 300000,
20-
timeout: 300000,
27+
agent: httpAgent,
28+
// Increase timeout for long-running requests (10 minutes)
29+
proxyTimeout: 600000,
30+
timeout: 600000,
2131
// Support streaming responses (SSE)
2232
onProxyRes: (proxyRes, req, res) => {
2333
// Disable buffering for streaming responses
2434
if (proxyRes.headers['content-type']?.includes('text/event-stream')) {
2535
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
2636
res.setHeader('X-Accel-Buffering', 'no');
37+
res.setHeader('Connection', 'keep-alive');
2738
res.flushHeaders();
2839
}
40+
// Log response for debugging
41+
console.log(`Proxy response: ${req.method} ${req.path} -> ${proxyRes.statusCode}`);
42+
},
43+
onProxyReq: (proxyReq, req, res) => {
44+
// Log request for debugging
45+
console.log(`Proxy request: ${req.method} ${req.path}`);
2946
},
3047
onError: (err, req, res) => {
31-
console.error('Proxy error:', err);
48+
console.error('Proxy error:', err.message);
3249
if (!res.headersSent) {
33-
res.status(502).json({ error: 'Backend service unavailable' });
50+
res.status(502).json({ error: 'Backend service unavailable', details: err.message });
3451
}
3552
}
3653
}));
@@ -43,7 +60,15 @@ app.get('*', (req, res) => {
4360
res.sendFile(path.join(__dirname, 'static', 'index.html'));
4461
});
4562

46-
app.listen(PORT, () => {
63+
// Create server with extended timeouts for SSE
64+
const server = app.listen(PORT, () => {
4765
console.log(`Frontend server running on port ${PORT}`);
4866
console.log(`Proxying API requests to ${BACKEND_URL}`);
4967
});
68+
69+
// Extend server timeouts for long-running SSE connections
70+
server.keepAliveTimeout = 620000; // 10 minutes + buffer
71+
server.headersTimeout = 630000; // Slightly higher than keepAliveTimeout
72+
server.timeout = 0; // Disable request timeout (handled by proxy)
73+
74+
console.log('Server timeouts configured for SSE streaming');

content-gen/src/frontend-server/static/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
77
<title>Content Generation Accelerator</title>
8-
<script type="module" crossorigin src="/assets/index-BwzhOPU_.js"></script>
8+
<script type="module" crossorigin src="/assets/index-CQ3D5apY.js"></script>
99
<link rel="stylesheet" crossorigin href="/assets/index-D9ems1Py.css">
1010
</head>
1111
<body>

content-gen/src/frontend/src/api/index.ts

Lines changed: 59 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,8 @@ export async function* streamGenerateContent(
154154
conversationId?: string,
155155
userId?: string
156156
): AsyncGenerator<AgentResponse> {
157-
const response = await fetch(`${API_BASE}/generate`, {
157+
// Use polling-based approach for reliability with long-running tasks
158+
const startResponse = await fetch(`${API_BASE}/generate/start`, {
158159
method: 'POST',
159160
headers: { 'Content-Type': 'application/json' },
160161
body: JSON.stringify({
@@ -166,40 +167,68 @@ export async function* streamGenerateContent(
166167
}),
167168
});
168169

169-
if (!response.ok) {
170-
throw new Error(`Content generation failed: ${response.statusText}`);
171-
}
172-
173-
const reader = response.body?.getReader();
174-
if (!reader) {
175-
throw new Error('No response body');
170+
if (!startResponse.ok) {
171+
throw new Error(`Content generation failed to start: ${startResponse.statusText}`);
176172
}
177173

178-
const decoder = new TextDecoder();
179-
let buffer = '';
180-
181-
while (true) {
182-
const { done, value } = await reader.read();
183-
if (done) break;
184-
185-
buffer += decoder.decode(value, { stream: true });
186-
const lines = buffer.split('\n\n');
187-
buffer = lines.pop() || '';
188-
189-
for (const line of lines) {
190-
if (line.startsWith('data: ')) {
191-
const data = line.slice(6);
192-
if (data === '[DONE]') {
193-
return;
194-
}
195-
try {
196-
yield JSON.parse(data) as AgentResponse;
197-
} catch {
198-
console.error('Failed to parse SSE data:', data);
199-
}
174+
const startData = await startResponse.json();
175+
const taskId = startData.task_id;
176+
177+
console.log(`Generation started with task ID: ${taskId}`);
178+
179+
// Yield initial status
180+
yield {
181+
type: 'status',
182+
content: 'Generation started...',
183+
is_final: false,
184+
} as AgentResponse;
185+
186+
// Poll for completion
187+
let attempts = 0;
188+
const maxAttempts = 120; // 2 minutes max with 1-second polling
189+
const pollInterval = 1000; // 1 second
190+
191+
while (attempts < maxAttempts) {
192+
await new Promise(resolve => setTimeout(resolve, pollInterval));
193+
attempts++;
194+
195+
try {
196+
const statusResponse = await fetch(`${API_BASE}/generate/status/${taskId}`);
197+
if (!statusResponse.ok) {
198+
throw new Error(`Failed to get task status: ${statusResponse.statusText}`);
199+
}
200+
201+
const statusData = await statusResponse.json();
202+
console.log(`Task ${taskId} status: ${statusData.status} (attempt ${attempts})`);
203+
204+
if (statusData.status === 'completed') {
205+
// Yield the final result
206+
yield {
207+
type: 'agent_response',
208+
content: JSON.stringify(statusData.result),
209+
is_final: true,
210+
} as AgentResponse;
211+
return;
212+
} else if (statusData.status === 'failed') {
213+
throw new Error(statusData.error || 'Generation failed');
214+
} else if (statusData.status === 'running' && attempts % 5 === 0) {
215+
// Send heartbeat status every 5 seconds
216+
yield {
217+
type: 'heartbeat',
218+
content: `Generating content... (${attempts}s)`,
219+
is_final: false,
220+
} as AgentResponse;
221+
}
222+
} catch (error) {
223+
console.error(`Error polling task ${taskId}:`, error);
224+
// Continue polling on transient errors
225+
if (attempts >= maxAttempts) {
226+
throw error;
200227
}
201228
}
202229
}
230+
231+
throw new Error('Generation timed out after 2 minutes');
203232
}
204233

205234
/**

content-gen/src/frontend/src/components/ProductReview.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -184,10 +184,9 @@ export function ProductReview({
184184
<>
185185
<strong>Looking good!</strong> You can continue to refine your selection:
186186
<ul style={{ margin: '8px 0 0 0', paddingLeft: '16px' }}>
187-
<li>"Add the Arctic Frost paint to the selection"</li>
187+
<li>"Add the Blue Ash paint to the selection"</li>
188188
<li>"Remove the second product"</li>
189189
<li>"Show me more blue paints"</li>
190-
<li>"Replace with products for outdoor use"</li>
191190
</ul>
192191
<div style={{ marginTop: '8px' }}>
193192
When you're satisfied, click <strong>Generate Content</strong> to create your marketing materials.
@@ -197,10 +196,9 @@ export function ProductReview({
197196
<>
198197
<strong>Let's find the right products!</strong> Try saying:
199198
<ul style={{ margin: '8px 0 0 0', paddingLeft: '16px' }}>
200-
<li>"Show me exterior paints"</li>
201-
<li>"I need paint for a kitchen renovation"</li>
199+
<li>"Show me what paints are available"</li>
202200
<li>"Find products with blue tones"</li>
203-
<li>"Select SnowVeil and Ocean Mist"</li>
201+
<li>"Select Snow Veil and Silver Shore"</li>
204202
</ul>
205203
</>
206204
)}

0 commit comments

Comments
 (0)