Skip to content

Commit

Permalink
feat(client): add ._request_id property to object responses (#743)
Browse files Browse the repository at this point in the history
  • Loading branch information
RobertCraigie authored Nov 8, 2024
1 parent 472b7d3 commit 9fb64a6
Show file tree
Hide file tree
Showing 6 changed files with 131 additions and 6 deletions.
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,30 @@ Error codes are as followed:
| >=500 | `InternalServerError` |
| N/A | `APIConnectionError` |

## Request IDs

> For more information on debugging requests, see [these docs](https://docs.anthropic.com/en/api/errors#request-id)
All object responses in the SDK provide a `_request_id` property which is added from the `request-id` response header so that you can quickly log failing requests and report them back to Anthropic.

```python
message = client.messages.create(
max_tokens=1024,
messages=[
{
"role": "user",
"content": "Hello, Claude",
}
],
model="claude-3-opus-20240229",
)
print(message._request_id) # req_018EeWyXxfu5pfWkrYcMdjWG
```

Note that unlike other properties that use an `_` prefix, the `_request_id` property
*is* public. Unless documented otherwise, *all* other `_` prefix properties,
methods and modules are *private*.

### Retries

Certain errors are automatically retried 2 times by default, with a short exponential backoff.
Expand Down
7 changes: 5 additions & 2 deletions src/anthropic/_legacy_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

from ._types import NoneType
from ._utils import is_given, extract_type_arg, is_annotated_type
from ._models import BaseModel, is_basemodel
from ._models import BaseModel, is_basemodel, add_request_id
from ._constants import RAW_RESPONSE_HEADER
from ._streaming import Stream, AsyncStream, is_stream_class_type, extract_stream_chunk_type
from ._exceptions import APIResponseValidationError
Expand Down Expand Up @@ -140,8 +140,11 @@ class MyModel(BaseModel):
if is_given(self._options.post_parser):
parsed = self._options.post_parser(parsed)

if isinstance(parsed, BaseModel):
add_request_id(parsed, self.request_id)

self._parsed_by_type[cache_key] = parsed
return parsed
return cast(R, parsed)

@property
def headers(self) -> httpx.Headers:
Expand Down
33 changes: 32 additions & 1 deletion src/anthropic/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import os
import inspect
from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, cast
from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast
from datetime import date, datetime
from typing_extensions import (
Unpack,
Expand Down Expand Up @@ -95,6 +95,22 @@ def model_fields_set(self) -> set[str]:
class Config(pydantic.BaseConfig): # pyright: ignore[reportDeprecated]
extra: Any = pydantic.Extra.allow # type: ignore

if TYPE_CHECKING:
_request_id: Optional[str] = None
"""The ID of the request, returned via the `request-id` header. Useful for debugging requests and reporting issues to Anthropic.
This will **only** be set for the top-level response object, it will not be defined for nested objects. For example:
```py
message = await client.messages.create(...)
message._request_id # req_xxx
message.usage._request_id # raises `AttributeError`
```
Note: unlike other properties that use an `_` prefix, this property
*is* public. Unless documented otherwise, all other `_` prefix properties,
methods and modules are *private*.
"""

def to_dict(
self,
*,
Expand Down Expand Up @@ -665,6 +681,21 @@ def set_pydantic_config(typ: Any, config: pydantic.ConfigDict) -> None:
setattr(typ, "__pydantic_config__", config) # noqa: B010


def add_request_id(obj: BaseModel, request_id: str | None) -> None:
obj._request_id = request_id

# in Pydantic v1, using setattr like we do above causes the attribute
# to be included when serializing the model which we don't want in this
# case so we need to explicitly exclude it
if not PYDANTIC_V2:
try:
exclude_fields = obj.__exclude_fields__ # type: ignore
except AttributeError:
cast(Any, obj).__exclude_fields__ = {"_request_id", "__exclude_fields__"}
else:
cast(Any, obj).__exclude_fields__ = {*(exclude_fields or {}), "_request_id", "__exclude_fields__"}


# our use of subclasssing here causes weirdness for type checkers,
# so we just pretend that we don't subclass
if TYPE_CHECKING:
Expand Down
12 changes: 9 additions & 3 deletions src/anthropic/_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

from ._types import NoneType
from ._utils import is_given, extract_type_arg, is_annotated_type, extract_type_var_from_base
from ._models import BaseModel, is_basemodel
from ._models import BaseModel, is_basemodel, add_request_id
from ._constants import RAW_RESPONSE_HEADER, OVERRIDE_CAST_TO_HEADER
from ._streaming import Stream, AsyncStream, is_stream_class_type, extract_stream_chunk_type
from ._exceptions import AnthropicError, APIResponseValidationError
Expand Down Expand Up @@ -339,8 +339,11 @@ class MyModel(BaseModel):
if is_given(self._options.post_parser):
parsed = self._options.post_parser(parsed)

if isinstance(parsed, BaseModel):
add_request_id(parsed, self.request_id)

self._parsed_by_type[cache_key] = parsed
return parsed
return cast(R, parsed)

def read(self) -> bytes:
"""Read and return the binary response content."""
Expand Down Expand Up @@ -443,8 +446,11 @@ class MyModel(BaseModel):
if is_given(self._options.post_parser):
parsed = self._options.post_parser(parsed)

if isinstance(parsed, BaseModel):
add_request_id(parsed, self.request_id)

self._parsed_by_type[cache_key] = parsed
return parsed
return cast(R, parsed)

async def read(self) -> bytes:
"""Read and return the binary response content."""
Expand Down
20 changes: 20 additions & 0 deletions tests/test_legacy_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,26 @@ def test_response_parse_custom_model(client: Anthropic) -> None:
assert obj.bar == 2


def test_response_basemodel_request_id(client: Anthropic) -> None:
response = LegacyAPIResponse(
raw=httpx.Response(
200,
headers={"request-id": "my-req-id"},
content=json.dumps({"foo": "hello!", "bar": 2}),
),
client=client,
stream=False,
stream_cls=None,
cast_to=str,
options=FinalRequestOptions.construct(method="get", url="/foo"),
)
obj = response.parse(to=CustomModel)
assert obj._request_id == "my-req-id"
assert obj.foo == "hello!"
assert obj.bar == 2
assert obj.to_dict() == {"foo": "hello!", "bar": 2}


def test_response_parse_annotated_type(client: Anthropic) -> None:
response = LegacyAPIResponse(
raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})),
Expand Down
41 changes: 41 additions & 0 deletions tests/test_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,47 @@ async def test_async_response_parse_custom_model(async_client: AsyncAnthropic) -
assert obj.bar == 2


def test_response_basemodel_request_id(client: Anthropic) -> None:
response = APIResponse(
raw=httpx.Response(
200,
headers={"request-id": "my-req-id"},
content=json.dumps({"foo": "hello!", "bar": 2}),
),
client=client,
stream=False,
stream_cls=None,
cast_to=str,
options=FinalRequestOptions.construct(method="get", url="/foo"),
)
obj = response.parse(to=CustomModel)
assert obj._request_id == "my-req-id"
assert obj.foo == "hello!"
assert obj.bar == 2
assert obj.to_dict() == {"foo": "hello!", "bar": 2}


@pytest.mark.asyncio
async def test_async_response_basemodel_request_id(client: Anthropic) -> None:
response = AsyncAPIResponse(
raw=httpx.Response(
200,
headers={"request-id": "my-req-id"},
content=json.dumps({"foo": "hello!", "bar": 2}),
),
client=client,
stream=False,
stream_cls=None,
cast_to=str,
options=FinalRequestOptions.construct(method="get", url="/foo"),
)
obj = await response.parse(to=CustomModel)
assert obj._request_id == "my-req-id"
assert obj.foo == "hello!"
assert obj.bar == 2
assert obj.to_dict() == {"foo": "hello!", "bar": 2}


def test_response_parse_annotated_type(client: Anthropic) -> None:
response = APIResponse(
raw=httpx.Response(200, content=json.dumps({"foo": "hello!", "bar": 2})),
Expand Down

0 comments on commit 9fb64a6

Please sign in to comment.