simulacrum-sdk 0.1.0__tar.gz → 0.2.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.

Potentially problematic release.


This version of simulacrum-sdk might be problematic. Click here for more details.

Files changed (22) hide show
  1. {simulacrum_sdk-0.1.0/simulacrum_sdk.egg-info → simulacrum_sdk-0.2.1}/PKG-INFO +2 -2
  2. {simulacrum_sdk-0.1.0 → simulacrum_sdk-0.2.1}/pyproject.toml +2 -2
  3. simulacrum_sdk-0.2.1/simulacrum/_errors.py +102 -0
  4. simulacrum_sdk-0.2.1/simulacrum/api.py +45 -0
  5. {simulacrum_sdk-0.1.0 → simulacrum_sdk-0.2.1}/simulacrum/client.py +4 -3
  6. simulacrum_sdk-0.2.1/simulacrum/exceptions.py +126 -0
  7. {simulacrum_sdk-0.1.0 → simulacrum_sdk-0.2.1}/simulacrum/models.py +0 -1
  8. {simulacrum_sdk-0.1.0 → simulacrum_sdk-0.2.1/simulacrum_sdk.egg-info}/PKG-INFO +2 -2
  9. {simulacrum_sdk-0.1.0 → simulacrum_sdk-0.2.1}/simulacrum_sdk.egg-info/SOURCES.txt +3 -1
  10. {simulacrum_sdk-0.1.0 → simulacrum_sdk-0.2.1}/simulacrum_sdk.egg-info/requires.txt +1 -1
  11. {simulacrum_sdk-0.1.0 → simulacrum_sdk-0.2.1}/tests/test_client.py +8 -5
  12. simulacrum_sdk-0.2.1/tests/test_errors.py +112 -0
  13. simulacrum_sdk-0.1.0/simulacrum/api.py +0 -48
  14. simulacrum_sdk-0.1.0/simulacrum/exceptions.py +0 -49
  15. {simulacrum_sdk-0.1.0 → simulacrum_sdk-0.2.1}/LICENSE +0 -0
  16. {simulacrum_sdk-0.1.0 → simulacrum_sdk-0.2.1}/MANIFEST.in +0 -0
  17. {simulacrum_sdk-0.1.0 → simulacrum_sdk-0.2.1}/README.md +0 -0
  18. {simulacrum_sdk-0.1.0 → simulacrum_sdk-0.2.1}/setup.cfg +0 -0
  19. {simulacrum_sdk-0.1.0 → simulacrum_sdk-0.2.1}/simulacrum/__init__.py +0 -0
  20. {simulacrum_sdk-0.1.0 → simulacrum_sdk-0.2.1}/simulacrum/config.py +0 -0
  21. {simulacrum_sdk-0.1.0 → simulacrum_sdk-0.2.1}/simulacrum_sdk.egg-info/dependency_links.txt +0 -0
  22. {simulacrum_sdk-0.1.0 → simulacrum_sdk-0.2.1}/simulacrum_sdk.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simulacrum-sdk
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: Official Python SDK for accessing the Simulacrum API.
5
5
  Author-email: "Simulacrum, Inc." <support@smlcrm.com>
6
6
  License-Expression: MIT
@@ -22,7 +22,7 @@ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
22
  Requires-Python: >=3.8
23
23
  Description-Content-Type: text/markdown
24
24
  License-File: LICENSE
25
- Requires-Dist: requests<3,>=2.28
25
+ Requires-Dist: httpx<1,>=0.27
26
26
  Requires-Dist: pydantic<3,>=2.0
27
27
  Requires-Dist: numpy>=1.24
28
28
  Provides-Extra: dev
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simulacrum-sdk"
7
- version = "0.1.0"
7
+ version = "0.2.1"
8
8
  description = "Official Python SDK for accessing the Simulacrum API."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -27,7 +27,7 @@ classifiers = [
27
27
  "Topic :: Scientific/Engineering :: Artificial Intelligence"
28
28
  ]
29
29
  dependencies = [
30
- "requests>=2.28,<3",
30
+ "httpx>=0.27,<1",
31
31
  "pydantic>=2.0,<3",
32
32
  "numpy>=1.24"
33
33
  ]
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Any, Optional
5
+
6
+ from .exceptions import (
7
+ ConflictError,
8
+ ForbiddenError,
9
+ HTTPClientError,
10
+ NotFoundError,
11
+ RateLimitError,
12
+ ServerError,
13
+ SimulacrumError,
14
+ UnauthorizedError,
15
+ ValidationError,
16
+ )
17
+
18
+
19
+ def _parse_retry_after(value: str) -> Optional[float]:
20
+ try:
21
+ return float(value)
22
+ except Exception:
23
+ try:
24
+ dt = datetime.strptime(value, "%a, %d %b %Y %H:%M:%S %Z")
25
+ return max(
26
+ 0.0,
27
+ (dt.replace(tzinfo=timezone.utc) - datetime.now(timezone.utc)).total_seconds(),
28
+ )
29
+ except Exception:
30
+ return None
31
+
32
+
33
+ def parse_error(response, *, service: str, endpoint: str) -> SimulacrumError:
34
+ status = getattr(response, "status_code")
35
+ headers = getattr(response, "headers", {}) or {}
36
+ req_id = headers.get("X-Request-ID")
37
+
38
+ error_type = "http_error"
39
+ message = f"HTTP {status}"
40
+ details = None
41
+ trace_id = None
42
+
43
+ # Try parse JSON body according to ErrorEnvelope
44
+ try:
45
+ data = response.json()
46
+ err = (data or {}).get("error") or {}
47
+ error_type = str(err.get("type") or error_type)
48
+ message = str(err.get("message") or message)
49
+ details = err.get("details")
50
+ trace_id = (data or {}).get("trace_id")
51
+ except Exception:
52
+ try:
53
+ text = response.text
54
+ except Exception:
55
+ text = None
56
+ if text:
57
+ message = str(text).strip()[:500]
58
+
59
+ common = dict(
60
+ service=service,
61
+ endpoint=endpoint,
62
+ status_code=status,
63
+ error_type=error_type,
64
+ message=message,
65
+ details=details,
66
+ trace_id_body=trace_id,
67
+ request_id_header=req_id,
68
+ response=response,
69
+ )
70
+
71
+ if status == 401:
72
+ return UnauthorizedError(**common)
73
+ if status == 403:
74
+ return ForbiddenError(**common)
75
+ if status == 404:
76
+ return NotFoundError(**common)
77
+ if status == 409:
78
+ return ConflictError(**common)
79
+ if status in (400, 422):
80
+ return ValidationError(**common)
81
+ if status == 429:
82
+ ra = headers.get("Retry-After")
83
+ return RateLimitError(**common, retry_after_seconds=_parse_retry_after(ra) if ra else None)
84
+ if 500 <= status <= 599:
85
+ return ServerError(**common)
86
+ if 400 <= status <= 499:
87
+ return HTTPClientError(**common)
88
+ return HTTPClientError(**common)
89
+
90
+
91
+ def is_retryable(exc: SimulacrumError) -> bool:
92
+ return exc.status_code in {429, 500, 502, 503, 504}
93
+
94
+
95
+ def retry_after(exc: RateLimitError) -> Optional[float]:
96
+ return getattr(exc, "retry_after_seconds", None)
97
+
98
+
99
+ def extract_trace_id(exc: SimulacrumError) -> Optional[str]:
100
+ return exc.trace_id()
101
+
102
+
@@ -0,0 +1,45 @@
1
+ """Low-level HTTP helpers used by the Simulacrum client."""
2
+
3
+ from typing import Any, Dict, Mapping, Optional
4
+
5
+ import uuid
6
+ import httpx
7
+
8
+ from simulacrum._errors import parse_error
9
+
10
+
11
+ def _infer_service_from_url(url: str) -> str:
12
+ lowered = url.lower()
13
+ if "/onsiteiq/" in lowered:
14
+ return "onsiteiq"
15
+ if "/tempo/" in lowered:
16
+ return "tempo"
17
+ return "tempo"
18
+
19
+
20
+ def send_request(method: str, url: str, headers: Mapping[str, str], json: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
21
+ """Execute an HTTP request against the Simulacrum API and raise rich typed errors on failure.
22
+
23
+ Args:
24
+ method (str): HTTP method to invoke ("GET", "POST", ...).
25
+ url (str): Fully-qualified endpoint URL.
26
+ headers (Mapping[str, str]): HTTP headers that include authorization and content type.
27
+ json (Mapping[str, Any] | None): JSON-serialisable payload for the request body.
28
+
29
+ Returns:
30
+ dict[str, Any]: Parsed JSON payload returned by the API.
31
+ """
32
+ # Ensure we always send an X-Request-ID
33
+ merged_headers: Dict[str, str] = dict(headers)
34
+ merged_headers.setdefault("X-Request-ID", str(uuid.uuid4()))
35
+
36
+ with httpx.Client(timeout=30) as client:
37
+ response = client.request(method=method, url=url, headers=merged_headers, json=json)
38
+
39
+ if 200 <= response.status_code < 300:
40
+ # Let JSON decode errors propagate as ValueError to be consistent with prior behavior
41
+ return response.json() # type: ignore[return-value]
42
+
43
+ service = _infer_service_from_url(url)
44
+ endpoint = httpx.URL(url).path
45
+ raise parse_error(response, service=service, endpoint=endpoint)
@@ -88,7 +88,7 @@ class Simulacrum:
88
88
  request_body: Dict[str, Any] = payload.model_dump()
89
89
  response_data: Dict[str, Any] = send_request(
90
90
  method="POST",
91
- url=f"{self.base_url}/v1/forecast",
91
+ url=f"{self.base_url}/{model}/v1/forecast",
92
92
  headers=self.headers,
93
93
  json=request_body,
94
94
  )
@@ -97,7 +97,7 @@ class Simulacrum:
97
97
  )
98
98
  return validated_response.get_forecast()
99
99
 
100
- def validate(self) -> ValidateAPIKeyResponse:
100
+ def validate(self, model: str = "tempo") -> ValidateAPIKeyResponse:
101
101
  """Validate the configured API key and return its metadata.
102
102
 
103
103
  Returns:
@@ -115,8 +115,9 @@ class Simulacrum:
115
115
  """
116
116
  response_data: Dict[str, Any] = send_request(
117
117
  method="GET",
118
- url=f"{self.base_url}/v1/validate",
118
+ url=f"{self.base_url}/{model}/v1/validate",
119
119
  headers=self.headers,
120
120
  json=None,
121
121
  )
122
+
122
123
  return ValidateAPIKeyResponse.model_validate(response_data)
@@ -0,0 +1,126 @@
1
+ """Custom exceptions raised by the Simulacrum SDK.
2
+
3
+ This module defines a rich, typed hierarchy aligned with Simulacrum services' ErrorEnvelope
4
+ and preserves backward compatibility with previously exported exception names.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Dict, Optional, Type
10
+
11
+
12
+ class SimulacrumError(Exception):
13
+ """Base exception for all SDK errors with structured fields."""
14
+
15
+ def __init__(
16
+ self,
17
+ *,
18
+ service: str,
19
+ endpoint: str,
20
+ status_code: int,
21
+ error_type: str,
22
+ message: str,
23
+ details: Optional[Dict[str, Any]] = None,
24
+ trace_id_body: Optional[str] = None,
25
+ request_id_header: Optional[str] = None,
26
+ response: Any = None,
27
+ context: Optional[Dict[str, Any]] = None,
28
+ ) -> None:
29
+ super().__init__(message)
30
+ self.service = service
31
+ self.endpoint = endpoint
32
+ self.status_code = status_code
33
+ self.error_type = error_type
34
+ self.message = message
35
+ self.details = details
36
+ self.trace_id_body = trace_id_body
37
+ self.request_id_header = request_id_header
38
+ self.response = response
39
+ self.context = context or {}
40
+
41
+ def trace_id(self) -> Optional[str]:
42
+ return self.trace_id_body or self.request_id_header
43
+
44
+ def __str__(self) -> str:
45
+ tid = self.trace_id()
46
+ tid_str = f" trace_id={tid}" if tid else ""
47
+ return f"[{self.service}] {self.status_code} {self.error_type}: {self.message}{tid_str}"
48
+
49
+
50
+ class UnauthorizedError(SimulacrumError):
51
+ pass
52
+
53
+
54
+ class ForbiddenError(SimulacrumError):
55
+ pass
56
+
57
+
58
+ class NotFoundError(SimulacrumError):
59
+ pass
60
+
61
+
62
+ class ConflictError(SimulacrumError):
63
+ pass
64
+
65
+
66
+ class ValidationError(SimulacrumError):
67
+ pass
68
+
69
+
70
+ class RateLimitError(SimulacrumError):
71
+ def __init__(
72
+ self, *args, retry_after_seconds: Optional[float] = None, **kwargs: Any
73
+ ) -> None:
74
+ super().__init__(*args, **kwargs)
75
+ self.retry_after_seconds = retry_after_seconds
76
+
77
+
78
+ class ServerError(SimulacrumError):
79
+ pass
80
+
81
+
82
+ class HTTPClientError(SimulacrumError):
83
+ pass
84
+
85
+
86
+ # Backward compatibility aliases/classes
87
+ class AuthError(UnauthorizedError):
88
+ """Raised when authentication with the API fails (back-compat)."""
89
+
90
+
91
+ class ApiKeyExpiredError(ForbiddenError):
92
+ """Raised when the API key has expired (back-compat)."""
93
+
94
+
95
+ class ApiKeyInactiveError(ForbiddenError):
96
+ """Raised when the API key has been deactivated (back-compat)."""
97
+
98
+
99
+ class ApiKeyInvalidError(UnauthorizedError):
100
+ """Raised when the API key is not recognised (back-compat)."""
101
+
102
+
103
+ class ForecastAlreadyRunningError(ConflictError):
104
+ """Raised when a forecast job is already in progress (back-compat)."""
105
+
106
+
107
+ class InvalidRequestError(ValidationError):
108
+ """Raised when the request payload is malformed (back-compat)."""
109
+
110
+
111
+ class QuotaExceededError(RateLimitError):
112
+ """Raised when the API usage quota has been exhausted (back-compat)."""
113
+
114
+
115
+ class ApiError(HTTPClientError):
116
+ """Catch-all for unclassified API errors (back-compat)."""
117
+
118
+
119
+ ERROR_CODE_MAP: Dict[str, Type[SimulacrumError]] = {
120
+ "API_KEY_EXPIRED": ApiKeyExpiredError,
121
+ "API_KEY_INVALID": ApiKeyInvalidError,
122
+ "API_KEY_INACTIVE": ApiKeyInactiveError,
123
+ "API_USAGE_LIMIT": QuotaExceededError,
124
+ "REQUEST_INVALID": InvalidRequestError,
125
+ "FORECAST_ALREADY_RUNNING": ForecastAlreadyRunningError,
126
+ }
@@ -57,7 +57,6 @@ class ForecastResponse(BaseModel):
57
57
  """
58
58
 
59
59
  forecast: List[float]
60
- model_used: str
61
60
 
62
61
  def get_forecast(self) -> np.ndarray:
63
62
  """Return forecast values as a numpy array for downstream processing.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simulacrum-sdk
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: Official Python SDK for accessing the Simulacrum API.
5
5
  Author-email: "Simulacrum, Inc." <support@smlcrm.com>
6
6
  License-Expression: MIT
@@ -22,7 +22,7 @@ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
22
  Requires-Python: >=3.8
23
23
  Description-Content-Type: text/markdown
24
24
  License-File: LICENSE
25
- Requires-Dist: requests<3,>=2.28
25
+ Requires-Dist: httpx<1,>=0.27
26
26
  Requires-Dist: pydantic<3,>=2.0
27
27
  Requires-Dist: numpy>=1.24
28
28
  Provides-Extra: dev
@@ -3,6 +3,7 @@ MANIFEST.in
3
3
  README.md
4
4
  pyproject.toml
5
5
  simulacrum/__init__.py
6
+ simulacrum/_errors.py
6
7
  simulacrum/api.py
7
8
  simulacrum/client.py
8
9
  simulacrum/config.py
@@ -13,4 +14,5 @@ simulacrum_sdk.egg-info/SOURCES.txt
13
14
  simulacrum_sdk.egg-info/dependency_links.txt
14
15
  simulacrum_sdk.egg-info/requires.txt
15
16
  simulacrum_sdk.egg-info/top_level.txt
16
- tests/test_client.py
17
+ tests/test_client.py
18
+ tests/test_errors.py
@@ -1,4 +1,4 @@
1
- requests<3,>=2.28
1
+ httpx<1,>=0.27
2
2
  pydantic<3,>=2.0
3
3
  numpy>=1.24
4
4
 
@@ -1,5 +1,7 @@
1
- import numpy as np
1
+ import os
2
2
  import pytest
3
+ import numpy as np
4
+
3
5
  from pydantic import ValidationError
4
6
 
5
7
  import simulacrum.client as simulacrum_client
@@ -7,11 +9,12 @@ import simulacrum.client as simulacrum_client
7
9
 
8
10
  @pytest.fixture
9
11
  def client():
10
- return simulacrum_client.Simulacrum("super-secret-key", base_url="https://api.test")
12
+ base_url = os.environ.get("TEST_API")
13
+ return simulacrum_client.Simulacrum(os.environ.get("TEST_API_KEY"), base_url=base_url)
11
14
 
12
15
 
13
16
  def test_client_initializes_expected_headers(client):
14
- assert client.headers["Authorization"] == "Bearer super-secret-key"
17
+ assert client.headers["Authorization"] == f"Bearer {os.environ.get('TEST_API_KEY')}"
15
18
  assert client.headers["Content-Type"] == "application/json"
16
19
 
17
20
 
@@ -35,7 +38,7 @@ def test_validate_invokes_send_request_and_parses_response(client, monkeypatch):
35
38
 
36
39
  assert captured == {
37
40
  "method": "GET",
38
- "url": "https://api.test/v1/validate",
41
+ "url": f"{client.base_url}/tempo/v1/validate",
39
42
  "headers": client.headers,
40
43
  "json": None,
41
44
  }
@@ -82,7 +85,7 @@ def test_forecast_builds_payload_and_returns_numpy_array(client, monkeypatch):
82
85
  assert captured_payload["horizon"] == 2
83
86
  assert captured_payload["model"] == "prophet"
84
87
  assert captured_payload["method"] == "POST"
85
- assert captured_payload["url"] == "https://api.test/v1/forecast"
88
+ assert captured_payload["url"] == "https://api.test/prophet/v1/forecast"
86
89
  assert captured_payload["headers"] == client.headers
87
90
  assert captured_payload["json"] == {
88
91
  "series": [1.0, 2.0, 3.0],
@@ -0,0 +1,112 @@
1
+ import json
2
+ from datetime import datetime, timedelta, timezone
3
+
4
+ from simulacrum._errors import parse_error
5
+ from simulacrum.exceptions import (
6
+ ConflictError,
7
+ ForbiddenError,
8
+ NotFoundError,
9
+ RateLimitError,
10
+ ServerError,
11
+ UnauthorizedError,
12
+ ValidationError,
13
+ )
14
+
15
+
16
+ class DummyResponse:
17
+ def __init__(self, *, status_code: int, headers: dict | None = None, body: object | None = None, text: str | None = None):
18
+ self.status_code = status_code
19
+ self.headers = headers or {}
20
+ self._body = body
21
+ self._text = text
22
+
23
+ def json(self):
24
+ if self._body is None:
25
+ raise ValueError("no json body")
26
+ return self._body
27
+
28
+ @property
29
+ def text(self):
30
+ if self._text is not None:
31
+ return self._text
32
+ return json.dumps(self._body) if self._body is not None else ""
33
+
34
+
35
+ def mk_envelope(error_type: str, message: str, details: dict | None = None, trace_id: str | None = "t-123"):
36
+ return {
37
+ "error": {"type": error_type, "message": message, "details": details},
38
+ "trace_id": trace_id,
39
+ }
40
+
41
+
42
+ def test_401_unauthorized_json_and_plain_text():
43
+ r1 = DummyResponse(status_code=401, headers={"X-Request-ID": "abc"}, body=mk_envelope("unauthorized", "bad key"))
44
+ exc = parse_error(r1, service="tempo", endpoint="/v1/validate")
45
+ assert isinstance(exc, UnauthorizedError)
46
+ assert exc.trace_id() in {"t-123", "abc"}
47
+
48
+ r2 = DummyResponse(status_code=401, headers={}, body=None, text="Unauthorized")
49
+ exc2 = parse_error(r2, service="tempo", endpoint="/v1/validate")
50
+ assert isinstance(exc2, UnauthorizedError)
51
+
52
+
53
+ def test_403_forbidden_variations():
54
+ r = DummyResponse(status_code=403, headers={}, body=mk_envelope("forbidden", "revoked"))
55
+ exc = parse_error(r, service="tempo", endpoint="/v1/validate")
56
+ assert isinstance(exc, ForbiddenError)
57
+
58
+
59
+ def test_422_and_400_validation_details_list():
60
+ details = {"problems": [
61
+ {"field": "series[5]", "message": "invalid value"},
62
+ {"field": "horizon", "message": "too large"},
63
+ ]}
64
+ r = DummyResponse(status_code=422, headers={}, body=mk_envelope("validation_error", "schema error", details))
65
+ exc = parse_error(r, service="tempo", endpoint="/v1/forecast")
66
+ assert isinstance(exc, ValidationError)
67
+ s = str(exc)
68
+ assert "validation_error" in s and "schema error" in s
69
+
70
+
71
+ def test_400_domain_validation_details_specific_keys():
72
+ details = {"invalid_index": 7, "invalid_value": -1}
73
+ r = DummyResponse(status_code=400, headers={}, body=mk_envelope("validation_error", "domain error", details))
74
+ exc = parse_error(r, service="tempo", endpoint="/v1/forecast")
75
+ assert isinstance(exc, ValidationError)
76
+
77
+
78
+ def test_404_and_409_mapping():
79
+ r404 = DummyResponse(status_code=404, headers={}, body=mk_envelope("http_error", "not found"))
80
+ r409 = DummyResponse(status_code=409, headers={}, body=mk_envelope("http_error", "conflict"))
81
+ assert isinstance(parse_error(r404, service="tempo", endpoint="/x"), NotFoundError)
82
+ assert isinstance(parse_error(r409, service="tempo", endpoint="/x"), ConflictError)
83
+
84
+
85
+ def test_429_retry_after_seconds_and_date():
86
+ r = DummyResponse(status_code=429, headers={"Retry-After": "30"}, body=mk_envelope("rate_limit", "slow down"))
87
+ exc = parse_error(r, service="tempo", endpoint="/v1/forecast")
88
+ assert isinstance(exc, RateLimitError)
89
+ assert exc.retry_after_seconds == 30.0
90
+
91
+ http_date = (datetime.now(timezone.utc) + timedelta(seconds=45)).strftime("%a, %d %b %Y %H:%M:%S %Z")
92
+ r2 = DummyResponse(status_code=429, headers={"Retry-After": http_date}, body=mk_envelope("rate_limit", "slow down"))
93
+ exc2 = parse_error(r2, service="tempo", endpoint="/v1/forecast")
94
+ assert isinstance(exc2, RateLimitError)
95
+ assert exc2.retry_after_seconds is not None and exc2.retry_after_seconds >= 0.0
96
+
97
+
98
+ def test_500_server_error_json_and_plain():
99
+ r1 = DummyResponse(status_code=500, headers={}, body=mk_envelope("internal_error", "boom"))
100
+ r2 = DummyResponse(status_code=500, headers={}, body=None, text="internal error")
101
+ assert isinstance(parse_error(r1, service="tempo", endpoint="/x"), ServerError)
102
+ assert isinstance(parse_error(r2, service="tempo", endpoint="/x"), ServerError)
103
+
104
+
105
+ def test_trace_id_from_body_or_header_and_str_formatting():
106
+ r = DummyResponse(status_code=401, headers={"X-Request-ID": "rid-1"}, body=mk_envelope("unauthorized", "bad key", trace_id=None))
107
+ exc = parse_error(r, service="onsiteiq", endpoint="/v1/validate")
108
+ assert exc.trace_id() == "rid-1"
109
+ s = str(exc)
110
+ assert "[onsiteiq]" in s and "401 unauthorized".split()[1] in s.lower()
111
+
112
+
@@ -1,48 +0,0 @@
1
- """Low-level HTTP helpers used by the Simulacrum client."""
2
-
3
- from typing import Any, Dict, Mapping, Optional
4
-
5
- import requests
6
-
7
- from simulacrum.exceptions import ApiError, AuthError, ERROR_CODE_MAP
8
-
9
-
10
- def send_request(method: str, url: str, headers: Mapping[str, str], json: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
11
- """Execute an HTTP request against the Simulacrum API and handle common errors.
12
-
13
- Args:
14
- method (str): HTTP method to invoke (``"GET"``, ``"POST"``, ...).
15
- url (str): Fully-qualified endpoint URL.
16
- headers (Mapping[str, str]): HTTP headers that include authorization and content type.
17
- json (Mapping[str, Any] | None): JSON-serialisable payload for the request body.
18
-
19
- Returns:
20
- dict[str, Any]: Parsed JSON payload returned by the API.
21
-
22
- Raises:
23
- AuthError: Raised when the API reports an authentication failure.
24
- ApiError: Raised for all other non-success responses or malformed data.
25
- """
26
- response = requests.request(method=method, url=url, headers=dict(headers), json=json)
27
-
28
- if not response.ok:
29
- try:
30
- data: Dict[str, Any] = response.json()
31
- error_code: Optional[str] = data.get("error_code")
32
- message: str = data.get("message", "Unknown error")
33
-
34
- if error_code in ERROR_CODE_MAP:
35
- raise ERROR_CODE_MAP[error_code](message)
36
-
37
- if response.status_code == 401:
38
- raise AuthError(message)
39
-
40
- raise ApiError(f"API error {response.status_code}: {message}")
41
-
42
- except ValueError as exc:
43
- raise ApiError(f"Unexpected API error: {response.text}") from exc
44
-
45
- try:
46
- return response.json() # type: ignore[return-value]
47
- except ValueError as exc: # requests raises ValueError for JSON decode errors
48
- raise ApiError(f"Failed to parse response JSON: {exc}") from exc
@@ -1,49 +0,0 @@
1
- """Custom exceptions raised by the Simulacrum SDK."""
2
-
3
- from typing import Dict, Type
4
-
5
-
6
- class SimulacrumError(Exception):
7
- """Base exception for all SDK errors."""
8
-
9
-
10
- class AuthError(SimulacrumError):
11
- """Raised when authentication with the API fails."""
12
-
13
-
14
- class ApiKeyExpiredError(SimulacrumError):
15
- """Raised when the API key has expired."""
16
-
17
-
18
- class ApiKeyInactiveError(SimulacrumError):
19
- """Raised when the API key has been deactivated."""
20
-
21
-
22
- class ApiKeyInvalidError(AuthError):
23
- """Raised when the API key is not recognised."""
24
-
25
-
26
- class ForecastAlreadyRunningError(SimulacrumError):
27
- """Raised when a forecast job is already in progress."""
28
-
29
-
30
- class InvalidRequestError(SimulacrumError):
31
- """Raised when the request payload is malformed."""
32
-
33
-
34
- class QuotaExceededError(SimulacrumError):
35
- """Raised when the API usage quota has been exhausted."""
36
-
37
-
38
- class ApiError(SimulacrumError):
39
- """Catch-all for unclassified API errors."""
40
-
41
-
42
- ERROR_CODE_MAP: Dict[str, Type[SimulacrumError]] = {
43
- "API_KEY_EXPIRED": ApiKeyExpiredError,
44
- "API_KEY_INVALID": ApiKeyInvalidError,
45
- "API_KEY_INACTIVE": ApiKeyInactiveError,
46
- "API_USAGE_LIMIT": QuotaExceededError,
47
- "REQUEST_INVALID": InvalidRequestError,
48
- "FORECAST_ALREADY_RUNNING": ForecastAlreadyRunningError,
49
- }
File without changes
File without changes
File without changes