feat(agent-proxy): segment streaming responses at ~54s to bypass Netlify Edge's ~60s response cap
Netlify Edge Functions empirically cap a single streaming response at
~60s wall-clock, regardless of activity. Confirmed against Netlify's
own canonical SSE example (edge-functions-examples.netlify.app/sse)
which also cuts at +60.1s. The Anthropic Managed Agent session is
fine for several minutes; the cap is per-HTTP-response.
This commit splits a long brief across multiple HTTP responses, while
keeping the UX of one continuous stream:
netlify/edge-functions/agent-proxy.ts
- Accept either a new-session payload {stationName, stationLocation,
stationWebsite} or a resume payload {sessionId, lastEventId,
startedAt}.
- On resume, skip session creation + user.message send. Just reopen
the live SSE stream and backfill via
GET /v1/sessions/{id}/events?after_id=lastEventId (deduped by
event.id), then keep tailing.
- Single AbortController per segment. A 54s timer aborts the upstream,
the for-await loops exit, and we write one final NDJSON line:
{type:'segment_end', sessionId, lastEventId, startedAt}.
- The 20-min OVERALL_BUDGET_MS is enforced via Date.now() - startedAt
so it spans across all segments.
- Refactor main loop so every iteration is openStream + backfill +
tail. Cleaner than the previous initial-stream + reconnect-only-on-
drop pattern.
src/App.jsx
- readStream() now returns a {sessionId, lastEventId, startedAt}
payload if it saw a segment_end, or null if the stream ended
cleanly.
- handleSubmit() loops, reopening /api/agent-proxy with the resume
payload until readStream returns null. Spinner/status state stays
on across segments so the UI shows one continuous stream.
README.md
- Document the segmented-streaming protocol and why it exists.
Co-Authored-By: alex <alex@semipublic.co>
This commit is contained in:
+72
-35
@@ -48,33 +48,50 @@ export default function App() {
|
||||
return Object.keys(next).length === 0
|
||||
}
|
||||
|
||||
const handleEvent = (msg) => {
|
||||
switch (msg?.type) {
|
||||
case 'text':
|
||||
if (typeof msg.text === 'string' && msg.text) {
|
||||
setResult((prev) => prev + msg.text)
|
||||
}
|
||||
break
|
||||
case 'status':
|
||||
setStatus(msg)
|
||||
break
|
||||
case 'error':
|
||||
case 'session_error':
|
||||
setStreamError(msg.message || 'streaming error')
|
||||
break
|
||||
case 'done':
|
||||
case 'heartbeat':
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the NDJSON stream from /agent-proxy: one JSON object per line.
|
||||
// Parse the NDJSON stream from /api/agent-proxy: one JSON object per
|
||||
// line. Returns a resume payload `{sessionId, lastEventId, startedAt}`
|
||||
// if the server emitted a `segment_end` (Netlify Edge caps a single
|
||||
// streaming response at ~60 s, so longer briefs span multiple
|
||||
// segments). Returns null when the stream ends cleanly (`done`,
|
||||
// `error`, or upstream EOF without a segment_end).
|
||||
const readStream = async (response) => {
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
let buffer = ''
|
||||
let firstByte = true
|
||||
let segmentEnd = null
|
||||
let fatalError = null
|
||||
|
||||
const handleEvent = (msg) => {
|
||||
switch (msg?.type) {
|
||||
case 'text':
|
||||
if (typeof msg.text === 'string' && msg.text) {
|
||||
setResult((prev) => prev + msg.text)
|
||||
}
|
||||
break
|
||||
case 'status':
|
||||
setStatus(msg)
|
||||
break
|
||||
case 'segment_end':
|
||||
if (typeof msg.sessionId === 'string' && msg.sessionId) {
|
||||
segmentEnd = {
|
||||
sessionId: msg.sessionId,
|
||||
lastEventId: msg.lastEventId,
|
||||
startedAt: msg.startedAt,
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'error':
|
||||
case 'session_error':
|
||||
fatalError = msg.message || 'streaming error'
|
||||
setStreamError(fatalError)
|
||||
break
|
||||
case 'done':
|
||||
case 'heartbeat':
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const flushLines = () => {
|
||||
let nl
|
||||
@@ -112,8 +129,10 @@ export default function App() {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
setIsStreaming(false)
|
||||
setStatus(null)
|
||||
|
||||
// Only return a resume payload if we got a clean segment_end AND
|
||||
// no fatal error fired in the same segment.
|
||||
return fatalError ? null : segmentEnd
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
@@ -128,19 +147,37 @@ export default function App() {
|
||||
setIsStreaming(false)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/agent-proxy', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
let resume = null
|
||||
// Loop across segments. Each iteration is one HTTP request;
|
||||
// the server closes it at ~54 s and the next iteration picks
|
||||
// up from `lastEventId` so the user sees one continuous brief.
|
||||
// Hard cap on iterations as a belt-and-braces guard against a
|
||||
// runaway segment loop.
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const body = resume
|
||||
? {
|
||||
sessionId: resume.sessionId,
|
||||
lastEventId: resume.lastEventId,
|
||||
startedAt: resume.startedAt,
|
||||
}
|
||||
: form
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(
|
||||
`Request failed: ${response.status} ${response.statusText}`,
|
||||
)
|
||||
const response = await fetch('/api/agent-proxy', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(
|
||||
`Request failed: ${response.status} ${response.statusText}`,
|
||||
)
|
||||
}
|
||||
|
||||
const next = await readStream(response)
|
||||
if (!next) break
|
||||
resume = next
|
||||
}
|
||||
|
||||
await readStream(response)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setStreamError(err.message || 'Something went wrong while streaming.')
|
||||
|
||||
Reference in New Issue
Block a user