telemetry-dev 0.2.13__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (21) hide show
  1. {telemetry_dev-0.2.13 → telemetry_dev-0.3.1}/PKG-INFO +13 -8
  2. {telemetry_dev-0.2.13 → telemetry_dev-0.3.1}/README.md +12 -7
  3. {telemetry_dev-0.2.13 → telemetry_dev-0.3.1}/pyproject.toml +1 -1
  4. {telemetry_dev-0.2.13 → telemetry_dev-0.3.1}/pyproject.toml.orig +1 -1
  5. {telemetry_dev-0.2.13 → telemetry_dev-0.3.1}/src/telemetry_dev/_config.py +1 -1
  6. {telemetry_dev-0.2.13 → telemetry_dev-0.3.1}/src/telemetry_dev/_prompts.py +151 -29
  7. {telemetry_dev-0.2.13 → telemetry_dev-0.3.1}/LICENSE +0 -0
  8. {telemetry_dev-0.2.13 → telemetry_dev-0.3.1}/src/telemetry_dev/__init__.py +0 -0
  9. {telemetry_dev-0.2.13 → telemetry_dev-0.3.1}/src/telemetry_dev/_capture.py +0 -0
  10. {telemetry_dev-0.2.13 → telemetry_dev-0.3.1}/src/telemetry_dev/_client.py +0 -0
  11. {telemetry_dev-0.2.13 → telemetry_dev-0.3.1}/src/telemetry_dev/_context.py +0 -0
  12. {telemetry_dev-0.2.13 → telemetry_dev-0.3.1}/src/telemetry_dev/_export.py +0 -0
  13. {telemetry_dev-0.2.13 → telemetry_dev-0.3.1}/src/telemetry_dev/_logs.py +0 -0
  14. {telemetry_dev-0.2.13 → telemetry_dev-0.3.1}/src/telemetry_dev/_metrics.py +0 -0
  15. {telemetry_dev-0.2.13 → telemetry_dev-0.3.1}/src/telemetry_dev/_observe.py +0 -0
  16. {telemetry_dev-0.2.13 → telemetry_dev-0.3.1}/src/telemetry_dev/_processor.py +0 -0
  17. {telemetry_dev-0.2.13 → telemetry_dev-0.3.1}/src/telemetry_dev/_semconv.py +0 -0
  18. {telemetry_dev-0.2.13 → telemetry_dev-0.3.1}/src/telemetry_dev/_serialize.py +0 -0
  19. {telemetry_dev-0.2.13 → telemetry_dev-0.3.1}/src/telemetry_dev/_spans.py +0 -0
  20. {telemetry_dev-0.2.13 → telemetry_dev-0.3.1}/src/telemetry_dev/otel.py +0 -0
  21. {telemetry_dev-0.2.13 → telemetry_dev-0.3.1}/src/telemetry_dev/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: telemetry-dev
3
- Version: 0.2.13
3
+ Version: 0.3.1
4
4
  Summary: telemetry.dev SDK for Python — OpenTelemetry-native GenAI tracing, logs, and metrics
5
5
  Keywords: telemetry,opentelemetry,llm,genai,tracing,observability
6
6
  Author: telemetry.dev
@@ -81,7 +81,7 @@ routed to the `on_error` hook / `telemetry_dev` logger — never raised into you
81
81
  | Variable | Default | Purpose |
82
82
  | --- | --- | --- |
83
83
  | `TELEMETRY_DEV_API_KEY` | — | Ingest key (`td_live_...`). Absent = SDK is a no-op. |
84
- | `TELEMETRY_DEV_BASE_URL` | `https://ingest.telemetry.dev` | Base URL for telemetry export and `get_prompt` (trailing slashes stripped). |
84
+ | `TELEMETRY_DEV_BASE_URL` | `https://api.telemetry.dev` | Base URL for telemetry export and `get_prompt` (trailing slashes stripped). |
85
85
  | `TELEMETRY_DEV_ENVIRONMENT` | `production` | Deployment environment label. |
86
86
  | `OTEL_SERVICE_NAME` | `unknown_service` | Service name on every trace. |
87
87
  | `OTEL_TRACES_SAMPLER` | `parentbased_always_on` | OTel trace sampling policy, which includes session roots. |
@@ -89,6 +89,10 @@ routed to the `on_error` hook / `telemetry_dev` logger — never raised into you
89
89
 
90
90
  Explicit `init()` arguments take precedence over environment variables.
91
91
 
92
+ Earlier versions sent data to `https://ingest.telemetry.dev` by default. That hostname reaches the
93
+ same service and stays supported: if a firewall allows only it, set `TELEMETRY_DEV_BASE_URL` or
94
+ `base_url` to it.
95
+
92
96
  Root spans remain independent by default. `telemetry_dev.init(session_mode="process")` groups
93
97
  otherwise uncorrelated spans and logs using a fresh opaque UUID for each enabled SDK
94
98
  initialization. Explicit and propagated session IDs take precedence, and valid parent trace
@@ -253,7 +257,7 @@ pinned = telemetry_dev.get_prompt("support/reply", version=13)
253
257
  without its prompt.
254
258
  - `get_prompt` uses the API key and base URL of an enabled `init()`, or `TELEMETRY_DEV_API_KEY`
255
259
  and `TELEMETRY_DEV_BASE_URL`, or its own `api_key` and `base_url`. It reads the same host that
256
- telemetry goes to, `https://ingest.telemetry.dev` by default. The key needs the read scope for
260
+ telemetry goes to, `https://api.telemetry.dev` by default. The key needs the read scope for
257
261
  prompts.
258
262
  - When a label sends part of the traffic to a second version, each session keeps one version: the
259
263
  choice uses the session id of `propagate_attributes` (or of `session_mode="process"`). Without a
@@ -272,8 +276,9 @@ ran in it, or before it started in the code that started it; with none, it recor
272
276
  for code that has not compiled in its own task. Model calls are the spans of type `generation`
273
277
  (operations `chat`, `text_completion`, and `generate_content`), including the spans of the provider
274
278
  integrations: OpenAI, Anthropic, Amazon Bedrock, Google GenAI, LiteLLM, and OpenRouter. Spans from
275
- other OpenTelemetry tracers do not get the attributes. Messages from `compile()` that you pass to a
276
- model call outside `with_prompt` are not recorded.
279
+ other OpenTelemetry tracers do not get the attributes. A model call outside `with_prompt` does not
280
+ get the attributes, even when it sends messages from `compile()`; with input capture on, the call
281
+ still records those messages as its input.
277
282
 
278
283
  ## Auto-metrics
279
284
 
@@ -331,7 +336,7 @@ from telemetry_dev import TelemetrySpanProcessor
331
336
  | Option | Default | Purpose |
332
337
  | --- | --- | --- |
333
338
  | `api_key` | `TELEMETRY_DEV_API_KEY` | Ingest key. Absent with no `span_exporter` = inert no-op. |
334
- | `base_url` | `TELEMETRY_DEV_BASE_URL` or `https://ingest.telemetry.dev` | Ingest base URL; trailing slashes are stripped. |
339
+ | `base_url` | `TELEMETRY_DEV_BASE_URL` or `https://api.telemetry.dev` | Base URL; trailing slashes are stripped. |
335
340
  | `export_mode` | `"batched"` | `"batched"` or `"immediate"` span export. |
336
341
  | `max_export_batch_size` | `64` | BatchSpanProcessor export batch size. |
337
342
  | `schedule_delay_millis` | `1000` | BatchSpanProcessor schedule delay. |
@@ -399,7 +404,7 @@ from opentelemetry.sdk.trace import TracerProvider
399
404
  from opentelemetry.sdk.trace.export import BatchSpanProcessor
400
405
 
401
406
  exporter = OTLPSpanExporter(
402
- endpoint="https://ingest.telemetry.dev/v1/traces",
407
+ endpoint="https://api.telemetry.dev/v1/traces",
403
408
  headers={"Authorization": f"Bearer {os.environ['TELEMETRY_DEV_API_KEY']}"},
404
409
  )
405
410
  provider = TracerProvider()
@@ -407,7 +412,7 @@ provider.add_span_processor(BatchSpanProcessor(exporter))
407
412
  trace.set_tracer_provider(provider)
408
413
  ```
409
414
 
410
- The Claude API can also send server-side spans to telemetry.dev through a Console destination. Set the endpoint to `https://ingest.telemetry.dev`. Set the `Authorization` header to `Bearer <telemetry.dev API key>`. For more information, refer to [Claude Platform](https://telemetry.dev/docs/integrations/claude-platform).
415
+ The Claude API can also send server-side spans to telemetry.dev through a Console destination. Set the endpoint to `https://api.telemetry.dev`. Set the `Authorization` header to `Bearer <telemetry.dev API key>`. For more information, refer to [Claude Platform](https://telemetry.dev/docs/integrations/claude-platform).
411
416
 
412
417
  When the SDK and the Claude API send the spans of a request to the same telemetry.dev project, telemetry.dev counts the tokens of the request one time, from the API span. The SDK span stays in the trace with no tokens. telemetry.dev matches the two spans by the request ID or the response ID, so the API span does not have to be a child of the SDK span. If the Console destination uses an API key that is scoped to one environment, its API spans take over only the SDK spans of that environment.
413
418
 
@@ -56,7 +56,7 @@ routed to the `on_error` hook / `telemetry_dev` logger — never raised into you
56
56
  | Variable | Default | Purpose |
57
57
  | --- | --- | --- |
58
58
  | `TELEMETRY_DEV_API_KEY` | — | Ingest key (`td_live_...`). Absent = SDK is a no-op. |
59
- | `TELEMETRY_DEV_BASE_URL` | `https://ingest.telemetry.dev` | Base URL for telemetry export and `get_prompt` (trailing slashes stripped). |
59
+ | `TELEMETRY_DEV_BASE_URL` | `https://api.telemetry.dev` | Base URL for telemetry export and `get_prompt` (trailing slashes stripped). |
60
60
  | `TELEMETRY_DEV_ENVIRONMENT` | `production` | Deployment environment label. |
61
61
  | `OTEL_SERVICE_NAME` | `unknown_service` | Service name on every trace. |
62
62
  | `OTEL_TRACES_SAMPLER` | `parentbased_always_on` | OTel trace sampling policy, which includes session roots. |
@@ -64,6 +64,10 @@ routed to the `on_error` hook / `telemetry_dev` logger — never raised into you
64
64
 
65
65
  Explicit `init()` arguments take precedence over environment variables.
66
66
 
67
+ Earlier versions sent data to `https://ingest.telemetry.dev` by default. That hostname reaches the
68
+ same service and stays supported: if a firewall allows only it, set `TELEMETRY_DEV_BASE_URL` or
69
+ `base_url` to it.
70
+
67
71
  Root spans remain independent by default. `telemetry_dev.init(session_mode="process")` groups
68
72
  otherwise uncorrelated spans and logs using a fresh opaque UUID for each enabled SDK
69
73
  initialization. Explicit and propagated session IDs take precedence, and valid parent trace
@@ -228,7 +232,7 @@ pinned = telemetry_dev.get_prompt("support/reply", version=13)
228
232
  without its prompt.
229
233
  - `get_prompt` uses the API key and base URL of an enabled `init()`, or `TELEMETRY_DEV_API_KEY`
230
234
  and `TELEMETRY_DEV_BASE_URL`, or its own `api_key` and `base_url`. It reads the same host that
231
- telemetry goes to, `https://ingest.telemetry.dev` by default. The key needs the read scope for
235
+ telemetry goes to, `https://api.telemetry.dev` by default. The key needs the read scope for
232
236
  prompts.
233
237
  - When a label sends part of the traffic to a second version, each session keeps one version: the
234
238
  choice uses the session id of `propagate_attributes` (or of `session_mode="process"`). Without a
@@ -247,8 +251,9 @@ ran in it, or before it started in the code that started it; with none, it recor
247
251
  for code that has not compiled in its own task. Model calls are the spans of type `generation`
248
252
  (operations `chat`, `text_completion`, and `generate_content`), including the spans of the provider
249
253
  integrations: OpenAI, Anthropic, Amazon Bedrock, Google GenAI, LiteLLM, and OpenRouter. Spans from
250
- other OpenTelemetry tracers do not get the attributes. Messages from `compile()` that you pass to a
251
- model call outside `with_prompt` are not recorded.
254
+ other OpenTelemetry tracers do not get the attributes. A model call outside `with_prompt` does not
255
+ get the attributes, even when it sends messages from `compile()`; with input capture on, the call
256
+ still records those messages as its input.
252
257
 
253
258
  ## Auto-metrics
254
259
 
@@ -306,7 +311,7 @@ from telemetry_dev import TelemetrySpanProcessor
306
311
  | Option | Default | Purpose |
307
312
  | --- | --- | --- |
308
313
  | `api_key` | `TELEMETRY_DEV_API_KEY` | Ingest key. Absent with no `span_exporter` = inert no-op. |
309
- | `base_url` | `TELEMETRY_DEV_BASE_URL` or `https://ingest.telemetry.dev` | Ingest base URL; trailing slashes are stripped. |
314
+ | `base_url` | `TELEMETRY_DEV_BASE_URL` or `https://api.telemetry.dev` | Base URL; trailing slashes are stripped. |
310
315
  | `export_mode` | `"batched"` | `"batched"` or `"immediate"` span export. |
311
316
  | `max_export_batch_size` | `64` | BatchSpanProcessor export batch size. |
312
317
  | `schedule_delay_millis` | `1000` | BatchSpanProcessor schedule delay. |
@@ -374,7 +379,7 @@ from opentelemetry.sdk.trace import TracerProvider
374
379
  from opentelemetry.sdk.trace.export import BatchSpanProcessor
375
380
 
376
381
  exporter = OTLPSpanExporter(
377
- endpoint="https://ingest.telemetry.dev/v1/traces",
382
+ endpoint="https://api.telemetry.dev/v1/traces",
378
383
  headers={"Authorization": f"Bearer {os.environ['TELEMETRY_DEV_API_KEY']}"},
379
384
  )
380
385
  provider = TracerProvider()
@@ -382,7 +387,7 @@ provider.add_span_processor(BatchSpanProcessor(exporter))
382
387
  trace.set_tracer_provider(provider)
383
388
  ```
384
389
 
385
- The Claude API can also send server-side spans to telemetry.dev through a Console destination. Set the endpoint to `https://ingest.telemetry.dev`. Set the `Authorization` header to `Bearer <telemetry.dev API key>`. For more information, refer to [Claude Platform](https://telemetry.dev/docs/integrations/claude-platform).
390
+ The Claude API can also send server-side spans to telemetry.dev through a Console destination. Set the endpoint to `https://api.telemetry.dev`. Set the `Authorization` header to `Bearer <telemetry.dev API key>`. For more information, refer to [Claude Platform](https://telemetry.dev/docs/integrations/claude-platform).
386
391
 
387
392
  When the SDK and the Claude API send the spans of a request to the same telemetry.dev project, telemetry.dev counts the tokens of the request one time, from the API span. The SDK span stays in the trace with no tokens. telemetry.dev matches the two spans by the request ID or the response ID, so the API span does not have to be a child of the SDK span. If the Console destination uses an API key that is scoped to one environment, its API spans take over only the SDK spans of that environment.
388
393
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "telemetry-dev"
3
- version = "0.2.13"
3
+ version = "0.3.1"
4
4
  description = "telemetry.dev SDK for Python — OpenTelemetry-native GenAI tracing, logs, and metrics"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "telemetry-dev"
3
- version = "0.2.13"
3
+ version = "0.3.1"
4
4
  description = "telemetry.dev SDK for Python — OpenTelemetry-native GenAI tracing, logs, and metrics"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -8,7 +8,7 @@ from collections.abc import Callable
8
8
  from dataclasses import dataclass
9
9
  from typing import Literal
10
10
 
11
- DEFAULT_BASE_URL = "https://ingest.telemetry.dev"
11
+ DEFAULT_BASE_URL = "https://api.telemetry.dev"
12
12
 
13
13
 
14
14
  def _resolve_version() -> str:
@@ -1,11 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import contextlib
4
5
  import copy
5
6
  import hashlib
6
7
  import json
7
8
  import random
8
9
  import re
10
+ import socket
9
11
  import threading
10
12
  import urllib.error
11
13
  import urllib.parse
@@ -26,7 +28,7 @@ from opentelemetry import context as otel_context
26
28
  from opentelemetry.context import Context
27
29
 
28
30
  from ._client import Client, get_client
29
- from ._config import report_error, resolve_config
31
+ from ._config import SDK_VERSION, report_error, resolve_config
30
32
  from ._context import propagated_attributes
31
33
  from ._semconv import (
32
34
  ATTR_PROMPT_LABEL,
@@ -274,7 +276,8 @@ def prompt_attributes(
274
276
  client: Client, context: Context | None, operation: object, capture_input: bool
275
277
  ) -> dict[str, AttributeValue]:
276
278
  """The td.prompt.* attributes for a span with this operation that starts in ``context``."""
277
- if operation not in _MODEL_CALL_OPERATIONS:
279
+ # An attribute can be a list, which a frozenset cannot hold.
280
+ if not isinstance(operation, str) or operation not in _MODEL_CALL_OPERATIONS:
278
281
  return {}
279
282
  scope = otel_context.get_value(_PROMPT_KEY, context) or otel_context.get_value(_PROMPT_KEY)
280
283
  if not isinstance(scope, _PromptScope):
@@ -333,7 +336,13 @@ def _get_prompt(
333
336
  cache_ttl_seconds,
334
337
  lambda: _fetch(
335
338
  f"{url}/v1/prompts?{urllib.parse.urlencode(query)}",
336
- {"Authorization": f"Bearer {key}", "X-Telemetry-Dev-SDK": "telemetry-dev"},
339
+ {
340
+ "Authorization": f"Bearer {key}",
341
+ "X-Telemetry-Dev-SDK": "telemetry-dev",
342
+ # urllib's default User-Agent, Python-urllib/3.x, is refused by bot filters
343
+ # such as Cloudflare's Browser Integrity Check.
344
+ "User-Agent": f"telemetry-dev-python/{SDK_VERSION}",
345
+ },
337
346
  ),
338
347
  on_error,
339
348
  )
@@ -461,10 +470,9 @@ def _load(
461
470
  on_error: Callable[[BaseException], None] | None,
462
471
  ) -> dict[str, Any]:
463
472
  """Return the kept copy of a prompt, and fetch a new copy in a background thread when it is
464
- older than the TTL. Only a call with no copy waits for a fetch, until the fetch's deadline: the
465
- timeout of urllib applies to each socket operation, not to the whole fetch. A failed fetch with
466
- no copy fails the calls of the next ``_ERROR_TTL_S`` (or the TTL, if it is shorter) without a
467
- fetch."""
473
+ older than the TTL. Only a call with no copy waits for a fetch, until the fetch's deadline. A
474
+ failed fetch with no copy fails the calls of the next ``_ERROR_TTL_S`` (or the TTL, if it is
475
+ shorter) without a fetch."""
468
476
  with _cache_lock:
469
477
  entry = _cache.get(key)
470
478
  if entry is None:
@@ -483,7 +491,7 @@ def _load(
483
491
  return entry.response
484
492
  pending, deadline = entry.pending, entry.deadline
485
493
  if pending is not None and not pending.wait(deadline - monotonic()):
486
- raise PromptError("timeout", f"Fetching the prompt took more than {_FETCH_TIMEOUT_S:g} s")
494
+ raise _timeout()
487
495
  with _cache_lock:
488
496
  response, error = entry.response, entry.error
489
497
  if response is not None:
@@ -542,31 +550,141 @@ class _NoRedirect(urllib.request.HTTPRedirectHandler):
542
550
  return None
543
551
 
544
552
 
545
- _opener = urllib.request.build_opener(_NoRedirect)
553
+ class _Deadline:
554
+ """Ends one prompt fetch after ``_FETCH_TIMEOUT_S``. The timeout of urllib applies to each
555
+ socket operation, so a server that sends a byte now and then could keep a fetch reading forever,
556
+ and each address of a host gets the full timeout to connect. A connection attempt gets only the
557
+ time that is left, and at the deadline this shuts the connection down, which ends a blocked
558
+ read. It cannot stop an address lookup; the system resolver bounds that."""
559
+
560
+ def __init__(self) -> None:
561
+ self.expired = False
562
+ self._end = monotonic() + _FETCH_TIMEOUT_S
563
+ self._lock = threading.Lock()
564
+ self._sockets: list[socket.socket] = []
565
+ self._timer = threading.Timer(_FETCH_TIMEOUT_S, self._expire)
566
+ self._timer.daemon = True
567
+ self._timer.start()
568
+
569
+ def connect(
570
+ self,
571
+ address: tuple[str, int],
572
+ timeout: float | None,
573
+ source_address: tuple[str, int] | None = None,
574
+ ) -> socket.socket:
575
+ """``socket.create_connection``, with each attempt bounded by the deadline."""
576
+ host, port = address
577
+ error: OSError = OSError(f"no address for {host}")
578
+ for family, kind, proto, _, target in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM):
579
+ left = self._end - monotonic()
580
+ if left <= 0:
581
+ break
582
+ sock = socket.socket(family, kind, proto)
583
+ try:
584
+ sock.settimeout(left if timeout is None else min(left, timeout))
585
+ if source_address is not None:
586
+ sock.bind(source_address)
587
+ sock.connect(target)
588
+ sock.settimeout(timeout)
589
+ except OSError as exc:
590
+ sock.close()
591
+ error = exc
592
+ continue
593
+ with self._lock:
594
+ if not self.expired:
595
+ try:
596
+ # A TLS wrap takes the file descriptor of sock, so keep a copy to shut down.
597
+ self._sockets.append(sock.dup())
598
+ except OSError:
599
+ sock.close()
600
+ raise
601
+ return sock
602
+ sock.close()
603
+ break
604
+ if self.expired or self._end <= monotonic():
605
+ raise TimeoutError("the prompt fetch passed its deadline")
606
+ raise error
607
+
608
+ def _expire(self) -> None:
609
+ with self._lock:
610
+ self.expired = True
611
+ for sock in self._sockets:
612
+ with contextlib.suppress(OSError):
613
+ sock.shutdown(socket.SHUT_RDWR)
614
+
615
+ def close(self) -> None:
616
+ self._timer.cancel()
617
+ with self._lock:
618
+ for sock in self._sockets:
619
+ sock.close()
620
+ self._sockets.clear()
621
+
622
+
623
+ class _Request(urllib.request.Request):
624
+ def __init__(self, url: str, headers: dict[str, str], deadline: _Deadline) -> None:
625
+ super().__init__(url, headers=headers, method="GET")
626
+ self.deadline = deadline
627
+
628
+
629
+ def _on_deadline(http_class: Any, request: urllib.request.Request) -> Any:
630
+ """``http_class``, with the sockets of a ``_Request`` under its deadline."""
631
+
632
+ def connection(host: str, **kwargs: Any) -> Any:
633
+ conn = http_class(host, **kwargs)
634
+ if isinstance(request, _Request):
635
+ conn._create_connection = request.deadline.connect
636
+ return conn
637
+
638
+ return connection
639
+
640
+
641
+ class _HTTPHandler(urllib.request.HTTPHandler):
642
+ def do_open(self, http_class: Any, req: urllib.request.Request, **http_conn_args: Any) -> Any:
643
+ return super().do_open(_on_deadline(http_class, req), req, **http_conn_args)
644
+
645
+
646
+ class _HTTPSHandler(urllib.request.HTTPSHandler):
647
+ def do_open(self, http_class: Any, req: urllib.request.Request, **http_conn_args: Any) -> Any:
648
+ return super().do_open(_on_deadline(http_class, req), req, **http_conn_args)
649
+
650
+
651
+ _opener = urllib.request.build_opener(_NoRedirect, _HTTPHandler, _HTTPSHandler)
546
652
 
547
653
 
548
654
  def _fetch(url: str, headers: dict[str, str]) -> dict[str, Any]:
549
- request = urllib.request.Request(url, headers=headers, method="GET")
655
+ deadline = _Deadline()
656
+ retry_after: str | None = None
550
657
  try:
551
- with _opener.open(request, timeout=_FETCH_TIMEOUT_S) as response:
552
- body: bytes = response.read()
553
- except urllib.error.HTTPError as exc:
554
- with exc:
555
- error = _api_error(exc.code, exc.read())
556
- if exc.code in (429, 503):
557
- retry_after = _retry_after(exc.headers.get("Retry-After"))
558
- error._retry_after = retry_after # pyright: ignore[reportPrivateUsage]
559
- raise error from None
560
- except TimeoutError as exc:
561
- raise PromptError(
562
- "timeout", f"Fetching the prompt took more than {_FETCH_TIMEOUT_S:g} s"
563
- ) from exc
564
- except (urllib.error.URLError, OSError) as exc:
565
- if isinstance(getattr(exc, "reason", None), TimeoutError):
566
- raise PromptError(
567
- "timeout", f"Fetching the prompt took more than {_FETCH_TIMEOUT_S:g} s"
568
- ) from exc
569
- raise PromptError("network_error", "Could not reach telemetry.dev") from exc
658
+ try:
659
+ with _opener.open(
660
+ _Request(url, headers, deadline), timeout=_FETCH_TIMEOUT_S
661
+ ) as response:
662
+ status: int = response.status
663
+ body: bytes = response.read()
664
+ except urllib.error.HTTPError as exc:
665
+ with exc:
666
+ status, body = exc.code, exc.read()
667
+ retry_after = exc.headers.get("Retry-After")
668
+ except Exception as exc:
669
+ if (
670
+ deadline.expired
671
+ or isinstance(exc, TimeoutError)
672
+ or isinstance(getattr(exc, "reason", None), TimeoutError)
673
+ ):
674
+ raise _timeout() from exc
675
+ if isinstance(exc, (urllib.error.URLError, OSError)):
676
+ raise PromptError("network_error", "Could not reach telemetry.dev") from exc
677
+ raise
678
+ finally:
679
+ deadline.close()
680
+ # A read that the deadline cut short can end without an error, with part of the body.
681
+ if deadline.expired:
682
+ raise _timeout()
683
+ if not 200 <= status < 300:
684
+ error = _api_error(status, body)
685
+ if status in (429, 503):
686
+ error._retry_after = _retry_after(retry_after) # pyright: ignore[reportPrivateUsage]
687
+ raise error
570
688
  try:
571
689
  parsed = _parse_response(json.loads(body))
572
690
  except ValueError:
@@ -576,6 +694,10 @@ def _fetch(url: str, headers: dict[str, str]) -> dict[str, Any]:
576
694
  return parsed
577
695
 
578
696
 
697
+ def _timeout() -> PromptError:
698
+ return PromptError("timeout", f"Fetching the prompt took more than {_FETCH_TIMEOUT_S:g} s")
699
+
700
+
579
701
  def _api_error(status: int, body: bytes) -> PromptError:
580
702
  try:
581
703
  error = json.loads(body).get("error")
File without changes