Skip to content

Commit cc5a245

Browse files
committed
add flat files replace
1 parent b6952cf commit cc5a245

8 files changed

Lines changed: 167 additions & 49 deletions

File tree

FlatFilesExport.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ market trades, market quotes, and market candles.
77
This program was set up to easily access a flat files server provided to Coin Metrics commercial clients. If you are a
88
community API user or a client without access to this server, a 403 error will be returned. If you are a community API
99
user and looking to get historical asset prices, Coin Metrics does offer [historical asset prices for download](https://coinmetrics.io/community-network-data/).
10-
The `CoinMetricsDataExporter` class will return a `CoinMetricsUnauthorizedException` if your API key is not authorized
10+
The `CoinMetricsDataExporter` class will return a `CoinMetricsClientFlatFilesUnauthorizedException` if your API key is not authorized
1111
to access this server. The flat files export is considered a separate product from the Coin Metrics API, if you would
1212
like to gain access or believe you should have access but do not, please contact coinmetrics support.
1313

coinmetrics/_exceptions.py

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,80 @@
44
from functools import reduce
55

66

7-
class CoinMetricsUnauthorizedException(HTTPError):
7+
class CoinMetricsClientFlatFilesUnauthorizedException(HTTPError):
88
"""
99
Raised when a request is made that will return an error due to being unauthorized to flat files server
1010
"""
11+
def __init__(self, response: Response, *args: Any, **kwargs: Any):
12+
if response.status_code != 403:
13+
response.raise_for_status()
14+
self.response = response
15+
self.request = response.request
16+
error_message = """The provided API Key is not authorized to access """
17+
self.msg = error_message
18+
super().__init__(response=response, request=response.request, *args, **kwargs)
19+
20+
def __str__(self) -> str:
21+
return self.msg
22+
23+
24+
class CoinMetricsClientNotFoundError(Exception):
25+
"""Raised when a CoinMetricsClient instance is not found."""
26+
def __init__(self, message="CoinMetricsClient not found"):
27+
self.message = message
28+
super().__init__(self.message)
29+
30+
31+
class CoinMetricsClientBadParameterError(HTTPError):
32+
"""
33+
Raised when a request is made with bad parameters (HTTP 400).
34+
"""
35+
def __init__(self, response: Response, *args: Any, **kwargs: Any):
36+
if response.status_code != 400:
37+
response.raise_for_status()
38+
self.response = response
39+
self.request = response.request
40+
error_message = "Bad Parameter: The request contains invalid parameters."
41+
self.msg = error_message
42+
super().__init__(response=response, request=response.request, *args, **kwargs)
43+
44+
def __str__(self) -> str:
45+
return self.msg
46+
1147

48+
class CoinMetricsClientUnauthorizedError(HTTPError):
49+
"""
50+
Raised when a request is unauthorized due to invalid or missing API key (HTTP 401).
51+
"""
1252
def __init__(self, response: Response, *args: Any, **kwargs: Any):
13-
if response.status_code not in [401, 403]:
53+
if response.status_code != 401:
1454
response.raise_for_status()
1555
self.response = response
1656
self.request = response.request
17-
error_message = """The provided API key is not authorized to access the Coin Metrics Flat Files server. This product is separate from the API. If you'd like access granted or believe this is a mistake please contact Coin Metrics support.
18-
"""
57+
error_message = "Unauthorized: The API key is invalid or missing."
1958
self.msg = error_message
2059
super().__init__(response=response, request=response.request, *args, **kwargs)
2160

2261
def __str__(self) -> str:
2362
return self.msg
2463

2564

65+
class CoinMetricsClientForbiddenError(HTTPError):
66+
"""
67+
Raised when a request is forbidden due to insufficient permissions (HTTP 403).
68+
"""
69+
def __init__(self, response: Response, *args: Any, **kwargs: Any):
70+
if response.status_code != 403:
71+
response.raise_for_status()
72+
self.response = response
73+
self.request = response.request
74+
error_message = "Forbidden: The API key does not have permission to access this resource."
75+
self.msg = error_message
76+
super().__init__(response=response, request=response.request, *args, **kwargs)
77+
78+
def __str__(self) -> str:
79+
return self.msg
80+
2681
class CoinMetricsClientQueryParamsException(HTTPError):
2782
"""
2883
Raised when a request is made that will return an error due to the logic or contents of the request
@@ -52,8 +107,20 @@ def __str__(self) -> str:
52107
return self.msg
53108

54109

55-
class CoinMetricsClientNotFoundError(Exception):
56-
"""Raised when a CoinMetricsClient instance is not found."""
57-
def __init__(self, message="CoinMetricsClient not found"):
58-
self.message = message
59-
super().__init__(self.message)
110+
class CoinMetricsClientRateLimitError(HTTPError):
111+
"""
112+
Raised when the rate limit is exceeded (HTTP 429).
113+
"""
114+
def __init__(self, response: Response, *args: Any, **kwargs: Any):
115+
if response.status_code != 429:
116+
response.raise_for_status()
117+
self.response = response
118+
self.request = response.request
119+
self.rate_limit_remaining = response.headers.get("x-ratelimit-remaining", "0")
120+
self.rate_limit_reset = response.headers.get("x-ratelimit-reset", "0")
121+
error_message = f"Rate Limit Exceeded: Too many requests. Remaining: {self.rate_limit_remaining}, Reset in: {self.rate_limit_reset} seconds."
122+
self.msg = error_message
123+
super().__init__(response=response, request=response.request, *args, **kwargs)
124+
125+
def __str__(self) -> str:
126+
return self.msg

coinmetrics/api_client.py

Lines changed: 83 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@
1313

1414
from coinmetrics._utils import retry, transform_url_params_values_to_str, deprecated, alias
1515
from coinmetrics import __version__ as version
16-
from coinmetrics._exceptions import CoinMetricsClientQueryParamsException
16+
from coinmetrics._exceptions import (
17+
CoinMetricsClientQueryParamsException,
18+
CoinMetricsClientBadParameterError,
19+
CoinMetricsClientForbiddenError,
20+
CoinMetricsClientUnauthorizedError,
21+
CoinMetricsClientRateLimitError,
22+
)
1723
from coinmetrics._typing import (
1824
DataReturnType,
1925
MessageHandlerType
@@ -132,6 +138,8 @@ def __init__(
132138
host: Optional[str] = None,
133139
port: Optional[int] = None,
134140
schema: str = "https",
141+
ignore_unauthorized_errors: bool = False,
142+
ignore_forbidden_errors: bool = False,
135143
):
136144
"""
137145
:param api_key: The API key for the CoinMetrics API.
@@ -152,10 +160,16 @@ def __init__(
152160
:type port: int
153161
:param schema: The schema for accessing the Coin Metrics API. Default is "https".
154162
:type schema: str
163+
:param ignore_unauthorized_errors: Whether to ignore 401 Unauthorized errors. Default is False.
164+
:type ignore_unauthorized_errors: bool
165+
:param ignore_forbidden_errors: Whether to ignore 403 Forbidden errors. Default is False.
166+
:type ignore_forbidden_errors: bool
155167
"""
156168
self._api_key_url_str = "api_key={}".format(api_key) if api_key else ""
157169

158170
self._verify_ssl_certs = verify_ssl_certs
171+
self._ignore_unauthorized_errors = ignore_unauthorized_errors
172+
self._ignore_forbidden_errors = ignore_forbidden_errors
159173

160174
if host is None:
161175
self._host = "api.coinmetrics.io" if api_key else "community-api.coinmetrics.io"
@@ -8268,23 +8282,35 @@ def _get_data(self, url: str, params: Dict[str, Any]) -> DataReturnType:
82688282
else:
82698283
self._log(f"Response status code: {resp.status_code} for url: {resp.url} took: {elapsed}")
82708284

8271-
# Raise early on HTTP errors
8272-
try:
8273-
resp.raise_for_status()
8274-
except HTTPError:
8275-
if resp.status_code == 414:
8276-
# URI is too long
8277-
raise CoinMetricsClientQueryParamsException(response=resp)
8278-
if resp.headers.get("content-type") == "application/json":
8279-
try:
8280-
data = json.loads(resp.content)
8281-
if isinstance(data, dict) and "error" in data:
8282-
error_msg = (f"Error found for the query: \n {actual_url}\nError details: {data.get('error')}")
8283-
logger.error(error_msg)
8284-
resp.raise_for_status()
8285-
except ValueError:
8286-
raise ValueError(f"Failed to parse error response as JSON. Status code: {resp.status_code}, Content: {resp.content}")
8287-
raise
8285+
# Handle HTTP errors with custom exceptions
8286+
if resp.status_code == 400:
8287+
raise CoinMetricsClientBadParameterError(response=resp)
8288+
elif resp.status_code == 401:
8289+
if not self._ignore_unauthorized_errors:
8290+
raise CoinMetricsClientUnauthorizedError(response=resp)
8291+
elif resp.status_code == 403:
8292+
if not self._ignore_forbidden_errors:
8293+
raise CoinMetricsClientForbiddenError(response=resp)
8294+
elif resp.status_code == 414:
8295+
# URI is too long
8296+
raise CoinMetricsClientQueryParamsException(response=resp)
8297+
elif resp.status_code == 429:
8298+
raise CoinMetricsClientRateLimitError(response=resp)
8299+
elif resp.status_code >= 400:
8300+
# Handle other HTTP errors
8301+
try:
8302+
resp.raise_for_status()
8303+
except HTTPError:
8304+
if resp.headers.get("content-type") == "application/json":
8305+
try:
8306+
data = json.loads(resp.content)
8307+
if isinstance(data, dict) and "error" in data:
8308+
error_msg = (f"Error found for the query: \n {actual_url}\nError details: {data.get('error')}")
8309+
logger.error(error_msg)
8310+
resp.raise_for_status()
8311+
except ValueError:
8312+
raise ValueError(f"Failed to parse error response as JSON. Status code: {resp.status_code}, Content: {resp.content}")
8313+
raise
82888314

82898315
if is_json_stream:
82908316
# Return a generator: caller can iterate without loading into memory
@@ -8318,21 +8344,46 @@ def _get_stream_data(self, url: str, params: Dict[str, Any]) -> CmStream:
83188344
)
83198345
return CmStream(ws_url=actual_url)
83208346

8321-
@retry((socket.gaierror, HTTPError), retries=5, wait_time_between_retries=5)
8347+
@retry((socket.gaierror, HTTPError, CoinMetricsClientRateLimitError), retries=5, wait_time_between_retries=5)
83228348
def _send_request(self, actual_url: str, is_json_stream: False) -> Response: # type: ignore
83238349
"""
83248350
Wrapper for requests.get with retry on certain exceptions.
83258351
"""
8326-
response = self._session.get(
8327-
actual_url,
8328-
headers=self._session.headers,
8329-
proxies=self._session.proxies,
8330-
verify=self._session.verify,
8331-
stream=is_json_stream,
8332-
)
8333-
if response.status_code == 429 or response.headers.get("x-ratelimit-remaining", None) == "0":
8334-
logger.info("Sleeping for a rate limit window because 429 (too many requests) error was returned. Please "
8335-
"see Coin Metrics APIV4 documentation for more information: https://docs.coinmetrics.io/api/v4/#tag/Rate-limits")
8336-
time.sleep(int(response.headers["x-ratelimit-reset"]))
8337-
response = self._send_request(actual_url=actual_url)
8338-
return response
8352+
max_retries = 5
8353+
retry_count = 0
8354+
8355+
while retry_count < max_retries:
8356+
response = self._session.get(
8357+
actual_url,
8358+
headers=self._session.headers,
8359+
proxies=self._session.proxies,
8360+
verify=self._session.verify,
8361+
stream=is_json_stream,
8362+
)
8363+
8364+
# Handle rate limiting with proper header-based retry logic
8365+
if response.status_code == 429:
8366+
retry_count += 1
8367+
rate_limit_reset = response.headers.get("x-ratelimit-reset", "60")
8368+
rate_limit_remaining = response.headers.get("x-ratelimit-remaining", "0")
8369+
8370+
logger.info(
8371+
"Rate limit exceeded (429). Remaining requests: %s, Reset in: %s seconds. "
8372+
"Retry attempt: %d/%d. See Coin Metrics API v4 documentation for more information: "
8373+
"https://docs.coinmetrics.io/api/v4/#tag/Rate-limits",
8374+
rate_limit_remaining, rate_limit_reset, retry_count, max_retries
8375+
)
8376+
8377+
if retry_count >= max_retries:
8378+
raise CoinMetricsClientRateLimitError(response=response)
8379+
8380+
# Sleep for the reset time specified in the header
8381+
sleep_time = int(rate_limit_reset)
8382+
time.sleep(sleep_time)
8383+
continue
8384+
8385+
# If we get here, the request was successful or had a different error
8386+
return response
8387+
8388+
# This should never be reached, but just in case
8389+
raise CoinMetricsClientRateLimitError(response=response)

coinmetrics/data_exporter.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from requests import Response, HTTPError
1010
from requests.auth import HTTPBasicAuth
1111

12-
from coinmetrics._exceptions import CoinMetricsUnauthorizedException
12+
from coinmetrics._exceptions import CoinMetricsClientFlatFilesUnauthorizedException
1313
from coinmetrics._utils import retry
1414
from datetime import datetime
1515
from typing import Union, Optional, List, Dict, Any, Iterable
@@ -472,7 +472,7 @@ def _send_request(self, actual_url: str, stream: bool = False) -> Response:
472472
actual_url, verify=self._verify_ssl_certs, auth=self._auth, stream=stream, proxies=self.proxies # type: ignore
473473
)
474474
if response.status_code == 403 or response.status_code == 401:
475-
raise CoinMetricsUnauthorizedException(response=response)
475+
raise CoinMetricsClientFlatFilesUnauthorizedException(response=response)
476476
if response.status_code != 200:
477477
response.raise_for_status()
478478
return response

docs/docs/tools/FlatFilesExport.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ market trades, market quotes, and market candles.
77
This program was set up to easily access a flat files server provided to Coin Metrics commercial clients. If you are a
88
community API user or a client without access to this server, a 403 error will be returned. If you are a community API
99
user and looking to get historical asset prices, Coin Metrics does offer [historical asset prices for download](https://coinmetrics.io/community-network-data/).
10-
The `CoinMetricsDataExporter` class will return a `CoinMetricsUnauthorizedException` if your API key is not authorized
10+
The `CoinMetricsDataExporter` class will return a `CoinMetricsClientFlatFilesUnauthorizedException` if your API key is not authorized
1111
to access this server. The flat files export is considered a separate product from the Coin Metrics API, if you would
1212
like to gain access or believe you should have access but do not, please contact coinmetrics support.
1313

docs/site/search/search_index.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

docs/site/tools/FlatFilesExport.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1046,7 +1046,7 @@ <h2 id="authorization-for-flat-files-download">Authorization for flat files down
10461046
<p>This program was set up to easily access a flat files server provided to Coin Metrics commercial clients. If you are a
10471047
community API user or a client without access to this server, a 403 error will be returned. If you are a community API
10481048
user and looking to get historical asset prices, Coin Metrics does offer <a href="https://coinmetrics.io/community-network-data/">historical asset prices for download</a>.<br />
1049-
The <code>CoinMetricsDataExporter</code> class will return a <code>CoinMetricsUnauthorizedException</code> if your API key is not authorized
1049+
The <code>CoinMetricsDataExporter</code> class will return a <code>CoinMetricsClientFlatFilesUnauthorizedException</code> if your API key is not authorized
10501050
to access this server. The flat files export is considered a separate product from the Coin Metrics API, if you would
10511051
like to gain access or believe you should have access but do not, please contact coinmetrics support. </p>
10521052
<h3 id="installation-and-set-up">Installation and set up<a class="headerlink" href="#installation-and-set-up" title="Permanent link">&para;</a></h3>

test/test_custom_exceptions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from datetime import datetime
44
from coinmetrics._exceptions import (
55
CoinMetricsClientQueryParamsException,
6-
# CoinMetricsUnauthorizedException,
6+
# CoinMetricsClientFlatFilesUnauthorizedException,
77
)
88
from coinmetrics.api_client import CoinMetricsClient
99
from coinmetrics.data_exporter import CoinMetricsDataExporter
@@ -58,7 +58,7 @@ def test_custom_exception_not_raised_for_403() -> None:
5858
# data_exporter.export_market_trades_spot_data(
5959
# start_date=start_date, end_date=end_date, exchanges=exchanges, threaded=True
6060
# )
61-
# except CoinMetricsUnauthorizedException as e:
61+
# except CoinMetricsClientFlatFilesUnauthorizedException as e:
6262
# assert e.response is not None
6363
# assert e.response.status_code == 403 or e.response.status_code == 401
6464
# print(e)

0 commit comments

Comments
 (0)