Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enhances how the SDK handles API error messages #338

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
15 changes: 5 additions & 10 deletions alpaca/broker/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,7 @@ def _get_account_activities_iterator(
last_result = result[-1]

if "id" not in last_result:
raise APIError(
raise AttributeError(
"AccountActivity didn't contain an `id` field to use for paginating results"
)

Expand Down Expand Up @@ -658,19 +658,14 @@ def download_trade_document_for_account_by_id(
except HTTPError as http_error:
if response.status_code in self._retry_codes:
continue
if "code" in response.text:
error = response.json()
if "code" in error:
raise APIError(error, http_error)
else:
raise http_error
raise APIError(http_error) from http_error

# if we got here there were no issues', so response is now a value
break

if response is None:
# we got here either by error or someone has mis-configured us, so we didn't even try
raise Exception("Somehow we never made a request for download!")
# we got here either by error or someone has mis-configured us, so we didn't even try
if not isinstance(response, Response):
raise TypeError("Somehow we never made a request for download!")

with open(file_path, "wb") as f:
# we specify chunk_size none which is okay since we set stream to true above, so chunks will be as we
Expand Down
97 changes: 79 additions & 18 deletions alpaca/common/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,98 @@
import json
from typing import Any, Dict, List, Union
from pydantic import BaseModel, TypeAdapter, ValidationError
from requests.exceptions import HTTPError
from requests import Request, Response


class ErrorBody(BaseModel):
"""
Represent the body of an API error response.
"""

code: int
message: str


class PDTErrorBody(ErrorBody):
"""
Represent the body of the API error in case of PDT.
"""

day_trading_buying_power: float
max_dtbp_used: float
max_dtbp_used_so_far: float
open_orders: int
symbol: str


class BuyingPowerErrorBody(ErrorBody):
"""
Represent the body of the API error in case of insufficient buying power.
"""

buying_power: float
cost_basis: float


class APIError(Exception):
"""
Represent API related error.
error.status_code will have http status code.
Represent API related error coming from an HTTP request to one of Alpaca's APIs.

Attributes:
request (Request): will be the HTTP request.
response (Response): will be the HTTP response.
status_code (int): will be the HTTP status_code.
body (Union[ErrorBody, Dict[str, Any]]): will have the body of the response,
it will be a base model or the raw data if the pydantic validation fails.
code (int): will be the Alpaca error code from the response body.
"""

def __init__(self, error, http_error=None):
super().__init__(error)
self._error = error
def __init__(
self,
http_error: HTTPError,
) -> None:
super().__init__(http_error)
self._http_error = http_error

@property
def code(self):
error = json.loads(self._error)
return error["code"]
def request(self) -> Request:
assert isinstance(self._http_error.request, Request)
return self._http_error.request

@property
def response(self) -> Response:
assert isinstance(self._http_error.response, Response)
return self._http_error.response

@property
def status_code(self):
http_error = self._http_error
if http_error is not None and hasattr(http_error, "response"):
return http_error.response.status_code
def status_code(self) -> int:
return self.response.status_code

@property
def request(self):
if self._http_error is not None:
return self._http_error.request
def body(self) -> Union[ErrorBody, Dict[str, Any]]:
_body: Dict[str, Any] = json.loads(self.response.content)
_models: List[ErrorBody] = [
ErrorBody,
BuyingPowerErrorBody,
PDTErrorBody,
]
for base_model in _models:
if set(base_model.model_fields.keys()) == set(_body.keys()):
try:
return TypeAdapter(base_model).validate_python(_body)
except ValidationError:
return _body
return _body

@property
def response(self):
if self._http_error is not None:
return self._http_error.response
def code(self) -> int:
if isinstance(self.body, ErrorBody):
return self.body.code
elif isinstance(self.body, dict):
return int(self.body.get("code"))
else:
return int(json.loads(self.response.content)["code"])


class RetryException(Exception):
Expand Down
6 changes: 2 additions & 4 deletions alpaca/common/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,12 +197,10 @@ def _one_request(self, method: str, url: str, opts: dict, retry: int) -> dict:
except HTTPError as http_error:
# retry if we hit Rate Limit
if response.status_code in self._retry_codes and retry > 0:
raise RetryException()
raise RetryException() from http_error

# raise API error for all other errors
error = response.text

raise APIError(error, http_error)
raise APIError(http_error) from http_error

if response.text != "":
return response.json()
Expand Down
1 change: 0 additions & 1 deletion alpaca/data/models/base.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import itertools
import pprint
from typing import Any, Dict, List

import pandas as pd
Expand Down
88 changes: 84 additions & 4 deletions tests/trading/trading_client/test_order_routes.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from alpaca.common.exceptions import APIError
from alpaca.common.exceptions import APIError, BuyingPowerErrorBody
from alpaca.trading.requests import (
GetOrderByIdRequest,
GetOrdersRequest,
Expand All @@ -15,7 +15,7 @@
import pytest


def test_market_order(reqmock, trading_client):
def test_market_order(reqmock, trading_client: TradingClient):
reqmock.post(
f"{BaseURL.TRADING_PAPER.value}/v2/orders",
text="""
Expand Down Expand Up @@ -292,6 +292,12 @@ def test_cancel_order_throws_uncancelable_error(reqmock, trading_client: Trading
reqmock.delete(
f"{BaseURL.TRADING_PAPER.value}/v2/orders/{order_id}",
status_code=status_code,
text="""
{
"code": 40410000,
"message": "order not found"
}
""",
)

with pytest.raises(APIError):
Expand All @@ -307,12 +313,19 @@ def test_cancel_order_throws_not_found_error(reqmock, trading_client: TradingCli
reqmock.delete(
f"{BaseURL.TRADING_PAPER.value}/v2/orders/{order_id}",
status_code=status_code,
text="""
{
"code": 40410000,
"message": "order not found"
}
""",
)

with pytest.raises(APIError):
with pytest.raises(APIError) as error:
trading_client.cancel_order_by_id(order_id)

assert reqmock.called_once
assert error.value.body.message == "order not found"


def test_cancel_orders(reqmock, trading_client: TradingClient):
Expand All @@ -338,7 +351,7 @@ def test_cancel_orders(reqmock, trading_client: TradingClient):
assert response[0].status == 200


def test_limit_order(reqmock, trading_client):
def test_limit_order(reqmock, trading_client: TradingClient):
reqmock.post(
f"{BaseURL.TRADING_PAPER.value}/v2/orders",
text="""
Expand Down Expand Up @@ -391,3 +404,70 @@ def test_limit_order(reqmock, trading_client):
lo_response = trading_client.submit_order(lo)

assert lo_response.status == OrderStatus.ACCEPTED


def test_insufficient_buying_power(reqmock, trading_client: TradingClient):
status_code = 403
reqmock.post(
f"{BaseURL.TRADING_PAPER.value}/v2/orders",
status_code=status_code,
text="""
{
"buying_power": "0",
"code": 40310000,
"cost_basis": "1",
"message": "insufficient buying power"
}
""",
)

# Market Order
mo = MarketOrderRequest(
symbol="SPY",
side=OrderSide.BUY,
time_in_force=TimeInForce.DAY,
notional=1,
)

with pytest.raises(APIError) as error:
trading_client.submit_order(mo)

api_error = error.value
assert isinstance(api_error, APIError)
assert api_error.code == 40310000
assert api_error.status_code == status_code
assert isinstance(api_error.body, BuyingPowerErrorBody)


def test_insufficient_buying_power_pydantic_error(
reqmock, trading_client: TradingClient
):
status_code = 403
reqmock.post(
f"{BaseURL.TRADING_PAPER.value}/v2/orders",
status_code=status_code,
text="""
{
"buying_power": "0",
"code": 40310000,
"cost_basis": "1"
}
""",
)

# Market Order
mo = MarketOrderRequest(
symbol="SPY",
side=OrderSide.BUY,
time_in_force=TimeInForce.DAY,
notional=1,
)

with pytest.raises(APIError) as error:
trading_client.submit_order(mo)

api_error = error.value
assert isinstance(api_error, APIError)
assert api_error.code == 40310000
assert api_error.status_code == status_code
assert isinstance(api_error.body, dict)