github/copilot-sdk · error · IOException

LLM inference response startResponse() called twice

Error message

LLM inference response startResponse() called twice

What it means

LlmInferenceExchange.startResponse() guards the response lifecycle with a lock; if startResponse() is invoked when the 'started' flag is already set it throws IOException('LLM inference response startResponse() called twice'). A response's start (status/headers) may only be emitted once per exchange, mirroring HTTP semantics.

Solutions

  1. Ensure only one code path calls startResponse(): guard error responses with an 'already started' check and use a different error channel (e.g. body-level error) once streaming began.
  2. Coordinate streamResponse and finalizeError so they are mutually exclusive (e.g. compareAndSet-style state before responding).
  3. Catch this IOException in the error path and fall back to aborting the exchange instead of re-starting the response.

Example fix

// before
void onError(int status, String msg) {
    exchange.startResponse(status, msg, Map.of()); // may already be started
}
// after
void onError(int status, String msg) {
    if (!exchange.isStarted()) {
        exchange.startResponse(status, msg, Map.of());
    }
}
Defensive patterns

Strategy: type-guard

Validate before calling

if (responseStarted) {
    LOG.warning("skipping startResponse; headers already sent");
    return;
}

Type guard

boolean canStartResponse(LlmInferenceExchange x) { return !x.isStarted() && !x.isFinished(); }

Try / catch

try {
    exchange.startResponse(status, text, headers);
} catch (IOException e) {
    LOG.warning("response already started/finished: " + e.getMessage());
}

Prevention

When it happens

Trigger: Calling startResponse() twice for the same exchange, typically when both streamResponse() and finalizeError() run for one request (e.g. an error occurs after headers were already sent and the error path tries to start a new response) (LlmInferenceExchange.java:182).

Common situations: Error-handling code that responds with an HTTP error after streaming has already begun; race between a handler's normal response path and a late failure handler; missing 'if started' checks in layered response wrappers.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of github/copilot-sdk@cd8cf15dc3 (2026-09-09). Data as JSON: /api/errors/edcfab04202a4ce2. Report an issue: GitHub.

Appendix: source

Thrown at java/sdk/src/main/java/com/github/copilot/LlmInferenceExchange.java:182

            }
        }
    }

    byte[] drainBody() throws InterruptedException {
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        BodyFrame frame;
        while ((frame = readFrame()) != null) {
            out.writeBytes(frame.data());
        }
        return out.toByteArray();
    }

    // --- Response emit (driven by the handler) ---

    void startResponse(int status, String statusText, Map<String, List<String>> headers) throws IOException {
        synchronized (lock) {
            if (started) {
                throw new IOException("LLM inference response startResponse() called twice");
            }
            if (finished) {
                throw new IOException("LLM inference response already finished");
            }
            started = true;
        }
        var params = new LlmInferenceHttpResponseStartParams(requestId, (long) status, statusText, headers);
        join(api().httpResponseStart(params));
    }

    void writeResponseText(String text) throws IOException {
        writeChunk(text, false);
    }

    void writeResponseBinary(byte[] data) throws IOException {
        writeChunk(Base64.getEncoder().encodeToString(data), true);
    }

View on GitHub (pinned to cd8cf15dc3)