Skip to content

🐛 Bug Report: openai, anthropic, google-generativeai: a call cancelled while waiting for the response (asyncio timeout/cancel, Ctrl-C) never ends its span #4576

Open

Description

@armanunix

Which component is this bug for?

All Packages

📜 Description

When a call is cancelled while it waits for the server (for example asyncio.wait_for(...) times out, the task is cancelled, or the user presses Ctrl-C), the LLM span is never ended. An unended span is never exported, so the call disappears from the trace, even though the request was sent and may be billed.

The wrappers start the span with tracer.start_span(...) and end it only in except Exception or after a successful response. asyncio.CancelledError and KeyboardInterrupt are BaseException, so they pass through without ending the span:

  • openai chat_wrapper / achat_wrapper: L104, L203 (same pattern in completion_wrappers.py and embeddings_wrappers.py)
  • anthropic _wrap / _awrap: L569, L701
  • google-generativeai _awrap / _wrap: L261, L340

Both streaming and non-streaming calls are affected, because the cancellation happens before the stream object exists. The same fix was made for the MCP session span in #4517.

👟 Reproduction steps

pip install openai anthropic google-genai opentelemetry-sdk opentelemetry-instrumentation-openai opentelemetry-instrumentation-anthropic opentelemetry-instrumentation-google-generativeai
python repro.py openai
python repro.py anthropic
python repro.py google
import asyncio, gc, logging, sys
from importlib.metadata import version

import httpx
from opentelemetry.sdk.trace import SpanProcessor, TracerProvider

logging.disable(logging.WARNING)
SDK = sys.argv[1] if len(sys.argv) > 1 else "openai"


class Spans(SpanProcessor):
    def __init__(self): self.open, self.ended = set(), []
    def on_start(self, span, parent_context=None): self.open.add(span.context.span_id)
    def on_end(self, span):
        self.open.discard(span.context.span_id)
        self.ended.append(f"{span.name} [{span.status.status_code.name}]")


spans = Spans()
provider = TracerProvider()
provider.add_span_processor(spans)


def slow_http(hx=httpx):                           # a server that answers after 5 s
    async def slow_server(request):
        await asyncio.sleep(5)
        return hx.Response(200, json={})
    return hx.AsyncClient(transport=hx.MockTransport(slow_server))


M = [{"role": "user", "content": "hi"}]
if SDK == "openai":
    import openai
    from opentelemetry.instrumentation.openai import OpenAIInstrumentor
    OpenAIInstrumentor().instrument(tracer_provider=provider)
    client = openai.AsyncOpenAI(api_key="x", base_url="http://mock/v1", max_retries=0, http_client=slow_http())
    calls = {"create": lambda: client.chat.completions.create(model="m", messages=M),
             "create(stream=True)": lambda: client.chat.completions.create(model="m", messages=M, stream=True)}
    pkg = ("openai", "opentelemetry-instrumentation-openai")
elif SDK == "anthropic":
    import anthropic, anthropic._base_client as base
    from opentelemetry.instrumentation.anthropic import AnthropicInstrumentor
    AnthropicInstrumentor().instrument(tracer_provider=provider)
    hx = getattr(base, "httpx2", None) or httpx            # newer anthropic releases use httpx2
    client = anthropic.AsyncAnthropic(api_key="x", base_url="http://mock", max_retries=0, http_client=slow_http(hx))
    calls = {"create": lambda: client.messages.create(model="m", max_tokens=50, messages=M),
             "create(stream=True)": lambda: client.messages.create(model="m", max_tokens=50, messages=M, stream=True)}
    pkg = ("anthropic", "opentelemetry-instrumentation-anthropic")
else:
    from google import genai
    from google.genai import types
    from opentelemetry.instrumentation.google_generativeai import GoogleGenerativeAiInstrumentor
    GoogleGenerativeAiInstrumentor().instrument(tracer_provider=provider)
    client = genai.Client(api_key="x", http_options=types.HttpOptions(base_url="http://mock", httpx_async_client=slow_http()))
    calls = {"generate_content": lambda: client.aio.models.generate_content(model="m", contents="hi"),
             "generate_content_stream": lambda: client.aio.models.generate_content_stream(model="m", contents="hi")}
    pkg = ("google-genai", "opentelemetry-instrumentation-google-generativeai")


async def main():
    print(" | ".join(f"{p} {version(p)}" for p in pkg))
    for name, call in calls.items():
        spans.ended.clear()
        try:
            await asyncio.wait_for(call(), timeout=1.0)    # the app gives up after 1 s
        except TimeoutError:
            pass
        gc.collect()
        await asyncio.sleep(0.1)
        print(f"{name:25} timed out -> ended spans: {spans.ended or 'none'}; spans never ended: {len(spans.open)}")
        spans.open.clear()

asyncio.run(main())

The sync clients behave the same way when the call is interrupted with Ctrl-C (KeyboardInterrupt) while waiting for the response.

👍 Expected behavior

The span ends with status ERROR and the exception (CancelledError / KeyboardInterrupt) recorded. The exception still propagates to the caller unchanged.

Suggested fix: catch BaseException instead of Exception around the wrapped call (or end the span in a finally block).

👎 Actual Behavior with Screenshots

The span is never ended, so it is never exported:

openai 3.26.0 | opentelemetry-instrumentation-openai 0.62.4
create                    timed out -> ended spans: none; spans never ended: 1
create(stream=True)       timed out -> ended spans: none; spans never ended: 1
anthropic 1.11.0 | opentelemetry-instrumentation-anthropic 0.62.4
create                    timed out -> ended spans: none; spans never ended: 1
create(stream=True)       timed out -> ended spans: none; spans never ended: 1
google-genai 2.28.0 | opentelemetry-instrumentation-google-generativeai 0.62.4
generate_content          timed out -> ended spans: none; spans never ended: 1
generate_content_stream   timed out -> ended spans: none; spans never ended: 1

🤖 Python Version

3.12

📃 Provide any additional context for the Bug.

The span-ending gap is in the request wrappers. A stream that is cancelled in the middle of reading is also not recorded as an error: the stream wrappers catch only Exception as well.

👀 Have you spent some time to check if this bug has been raised before?

  • I checked and didn't find similar issue

Are you willing to submit PR?

Yes I am willing to submit a PR!

Activity

  1. Rudra-G-23 commented on Oct 7, 2026

    @Rudra-G-23

    Hi, I'd be interested in helping with this. I noticed #4517 handled a similar cancellation case by ending the span in finally, while leaving cancellation status UNSET rather than ERROR.

    For these OpenAI/Anthropic/Google request spans, should cancellation be recorded as ERROR as proposed here, or should it follow the #4517 behavior?

    Also, @armanunix are you already planning to submit the PR for this issue?

  2. armanunix commented on Oct 11, 2026

    @armanunix
    Author

    The same in opentelemetry-instrumentation-ollama 0.62.4 (ollama 0.6.3): _wrap / _awrap handle only except Exception, so a chat / generate call interrupted by KeyboardInterrupt (or asyncio.CancelledError for AsyncClient) while waiting for the server never ends its span. The open PR #4127 also catches only Exception, so it does not cover this.

    import gc, httpx, ollama
    from opentelemetry.sdk.trace import SpanProcessor, TracerProvider
    from opentelemetry.instrumentation.ollama import OllamaInstrumentor
    
    class Spans(SpanProcessor):
        def __init__(self): self.started, self.ended = {}, {}
        def on_start(self, span, parent_context=None): self.started[span.context.span_id] = span.name
        def on_end(self, span): self.ended[span.context.span_id] = span.name
    
    spans = Spans()
    provider = TracerProvider(); provider.add_span_processor(spans)
    OllamaInstrumentor().instrument(tracer_provider=provider)
    
    def respond(request):          # the user presses Ctrl-C while waiting for the server
        raise KeyboardInterrupt
    client = ollama.Client(host="http://mock", transport=httpx.MockTransport(respond))
    try:
        client.chat(model="m", messages=[{"role": "user", "content": "hi"}])
    except KeyboardInterrupt:
        pass
    gc.collect()
    print("spans never ended:", [n for k, n in spans.started.items() if k not in spans.ended])
    spans never ended: ['ollama.chat']
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions