binalyze-air-sdk 1.0.1__py3-none-any.whl

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 (82) hide show
  1. binalyze_air/__init__.py +77 -0
  2. binalyze_air/apis/__init__.py +27 -0
  3. binalyze_air/apis/authentication.py +27 -0
  4. binalyze_air/apis/auto_asset_tags.py +75 -0
  5. binalyze_air/apis/endpoints.py +22 -0
  6. binalyze_air/apis/event_subscription.py +97 -0
  7. binalyze_air/apis/evidence.py +53 -0
  8. binalyze_air/apis/evidences.py +216 -0
  9. binalyze_air/apis/interact.py +36 -0
  10. binalyze_air/apis/params.py +40 -0
  11. binalyze_air/apis/settings.py +27 -0
  12. binalyze_air/apis/user_management.py +74 -0
  13. binalyze_air/apis/users.py +68 -0
  14. binalyze_air/apis/webhooks.py +231 -0
  15. binalyze_air/base.py +133 -0
  16. binalyze_air/client.py +1338 -0
  17. binalyze_air/commands/__init__.py +146 -0
  18. binalyze_air/commands/acquisitions.py +387 -0
  19. binalyze_air/commands/assets.py +363 -0
  20. binalyze_air/commands/authentication.py +37 -0
  21. binalyze_air/commands/auto_asset_tags.py +231 -0
  22. binalyze_air/commands/baseline.py +396 -0
  23. binalyze_air/commands/cases.py +603 -0
  24. binalyze_air/commands/event_subscription.py +102 -0
  25. binalyze_air/commands/evidences.py +988 -0
  26. binalyze_air/commands/interact.py +58 -0
  27. binalyze_air/commands/organizations.py +221 -0
  28. binalyze_air/commands/policies.py +203 -0
  29. binalyze_air/commands/settings.py +29 -0
  30. binalyze_air/commands/tasks.py +56 -0
  31. binalyze_air/commands/triage.py +360 -0
  32. binalyze_air/commands/user_management.py +126 -0
  33. binalyze_air/commands/users.py +101 -0
  34. binalyze_air/config.py +245 -0
  35. binalyze_air/exceptions.py +50 -0
  36. binalyze_air/http_client.py +306 -0
  37. binalyze_air/models/__init__.py +285 -0
  38. binalyze_air/models/acquisitions.py +251 -0
  39. binalyze_air/models/assets.py +439 -0
  40. binalyze_air/models/audit.py +273 -0
  41. binalyze_air/models/authentication.py +70 -0
  42. binalyze_air/models/auto_asset_tags.py +117 -0
  43. binalyze_air/models/baseline.py +232 -0
  44. binalyze_air/models/cases.py +276 -0
  45. binalyze_air/models/endpoints.py +76 -0
  46. binalyze_air/models/event_subscription.py +172 -0
  47. binalyze_air/models/evidence.py +66 -0
  48. binalyze_air/models/evidences.py +349 -0
  49. binalyze_air/models/interact.py +136 -0
  50. binalyze_air/models/organizations.py +294 -0
  51. binalyze_air/models/params.py +128 -0
  52. binalyze_air/models/policies.py +250 -0
  53. binalyze_air/models/settings.py +84 -0
  54. binalyze_air/models/tasks.py +149 -0
  55. binalyze_air/models/triage.py +143 -0
  56. binalyze_air/models/user_management.py +97 -0
  57. binalyze_air/models/users.py +82 -0
  58. binalyze_air/queries/__init__.py +134 -0
  59. binalyze_air/queries/acquisitions.py +156 -0
  60. binalyze_air/queries/assets.py +105 -0
  61. binalyze_air/queries/audit.py +417 -0
  62. binalyze_air/queries/authentication.py +56 -0
  63. binalyze_air/queries/auto_asset_tags.py +60 -0
  64. binalyze_air/queries/baseline.py +185 -0
  65. binalyze_air/queries/cases.py +293 -0
  66. binalyze_air/queries/endpoints.py +25 -0
  67. binalyze_air/queries/event_subscription.py +55 -0
  68. binalyze_air/queries/evidence.py +140 -0
  69. binalyze_air/queries/evidences.py +280 -0
  70. binalyze_air/queries/interact.py +28 -0
  71. binalyze_air/queries/organizations.py +223 -0
  72. binalyze_air/queries/params.py +115 -0
  73. binalyze_air/queries/policies.py +150 -0
  74. binalyze_air/queries/settings.py +20 -0
  75. binalyze_air/queries/tasks.py +82 -0
  76. binalyze_air/queries/triage.py +231 -0
  77. binalyze_air/queries/user_management.py +83 -0
  78. binalyze_air/queries/users.py +69 -0
  79. binalyze_air_sdk-1.0.1.dist-info/METADATA +635 -0
  80. binalyze_air_sdk-1.0.1.dist-info/RECORD +82 -0
  81. binalyze_air_sdk-1.0.1.dist-info/WHEEL +5 -0
  82. binalyze_air_sdk-1.0.1.dist-info/top_level.txt +1 -0
binalyze_air/config.py ADDED
@@ -0,0 +1,245 @@
1
+ """
2
+ Configuration management for the Binalyze AIR SDK.
3
+ """
4
+
5
+ import os
6
+ import json
7
+ from typing import Optional, Dict, Any, List
8
+ from pydantic import BaseModel, Field, field_validator
9
+
10
+
11
+ class PaginationConfig(BaseModel):
12
+ """Pagination configuration."""
13
+ default_page_size: int = Field(default=10, description="Default page size")
14
+ max_page_size: int = Field(default=100, description="Maximum page size")
15
+ default_sort_by: str = Field(default="createdAt", description="Default sort field")
16
+ default_sort_type: str = Field(default="ASC", description="Default sort type")
17
+
18
+
19
+ class EndpointConfig(BaseModel):
20
+ """API endpoint configuration."""
21
+ acquisitions: Dict[str, str] = Field(default_factory=dict)
22
+ assets: Dict[str, str] = Field(default_factory=dict)
23
+ audit: Dict[str, str] = Field(default_factory=dict)
24
+ auth: Dict[str, str] = Field(default_factory=dict)
25
+ auto_asset_tags: Dict[str, str] = Field(default_factory=dict)
26
+ baseline: Dict[str, str] = Field(default_factory=dict)
27
+ cases: Dict[str, str] = Field(default_factory=dict)
28
+ policies: Dict[str, str] = Field(default_factory=dict)
29
+ tasks: Dict[str, str] = Field(default_factory=dict)
30
+ task_assignments: Dict[str, str] = Field(default_factory=dict)
31
+ triage_rules: Dict[str, str] = Field(default_factory=dict)
32
+ organizations: Dict[str, str] = Field(default_factory=dict)
33
+ users: Dict[str, str] = Field(default_factory=dict)
34
+ repositories: Dict[str, str] = Field(default_factory=dict)
35
+
36
+
37
+ class DefaultFiltersConfig(BaseModel):
38
+ """Default filter configuration."""
39
+ organization_ids: List[int] = Field(default=[0])
40
+ all_organizations: bool = Field(default=True)
41
+ managed_status: List[str] = Field(default=["managed"])
42
+ online_status: List[str] = Field(default=["online"])
43
+ sort_type: str = Field(default="ASC")
44
+
45
+
46
+ class TaskDefaultsConfig(BaseModel):
47
+ """Task default configuration."""
48
+ cpu_limit: int = Field(default=80)
49
+ enable_compression: bool = Field(default=True)
50
+ enable_encryption: bool = Field(default=False)
51
+ bandwidth_limit: int = Field(default=100000)
52
+ chunk_size: int = Field(default=1048576)
53
+ chunk_count: int = Field(default=0)
54
+ start_offset: int = Field(default=0)
55
+
56
+
57
+ class LoggingConfig(BaseModel):
58
+ """Logging configuration."""
59
+ level: str = Field(default="INFO")
60
+ format: str = Field(default="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
61
+ file: str = Field(default="air_sdk.log")
62
+ max_file_size: str = Field(default="10MB")
63
+ backup_count: int = Field(default=5)
64
+
65
+
66
+ class CacheConfig(BaseModel):
67
+ """Cache configuration."""
68
+ enabled: bool = Field(default=True)
69
+ ttl: int = Field(default=300)
70
+ max_size: int = Field(default=1000)
71
+
72
+
73
+ class RateLimitingConfig(BaseModel):
74
+ """Rate limiting configuration."""
75
+ enabled: bool = Field(default=True)
76
+ requests_per_minute: int = Field(default=100)
77
+ burst_size: int = Field(default=10)
78
+
79
+
80
+ class AIRConfig(BaseModel):
81
+ """Configuration for the AIR SDK."""
82
+
83
+ host: str = Field(..., description="AIR instance host URL")
84
+ api_token: str = Field(..., description="API token for authentication")
85
+ api_prefix: str = Field(default="api/public", description="API prefix path")
86
+ organization_id: int = Field(default=0, description="Default organization ID")
87
+ timeout: int = Field(default=30, description="Request timeout in seconds")
88
+ verify_ssl: bool = Field(default=True, description="Whether to verify SSL certificates")
89
+ retry_attempts: int = Field(default=3, description="Number of retry attempts for failed requests")
90
+ retry_delay: float = Field(default=1.0, description="Delay between retry attempts")
91
+
92
+ # Enhanced configuration sections
93
+ pagination: PaginationConfig = Field(default_factory=PaginationConfig)
94
+ endpoints: EndpointConfig = Field(default_factory=EndpointConfig)
95
+ default_filters: DefaultFiltersConfig = Field(default_factory=DefaultFiltersConfig)
96
+ task_defaults: TaskDefaultsConfig = Field(default_factory=TaskDefaultsConfig)
97
+ logging: LoggingConfig = Field(default_factory=LoggingConfig)
98
+ cache: CacheConfig = Field(default_factory=CacheConfig)
99
+ rate_limiting: RateLimitingConfig = Field(default_factory=RateLimitingConfig)
100
+
101
+ @field_validator("host")
102
+ @classmethod
103
+ def validate_host(cls, v):
104
+ """Ensure host URL is properly formatted."""
105
+ if not v.startswith(("http://", "https://")):
106
+ raise ValueError("Host must start with http:// or https://")
107
+ return v.rstrip("/")
108
+
109
+ @field_validator("api_token")
110
+ @classmethod
111
+ def validate_api_token(cls, v):
112
+ """Ensure API token is not empty."""
113
+ if not v or not v.strip():
114
+ raise ValueError("API token cannot be empty")
115
+ return v.strip()
116
+
117
+ @property
118
+ def base_url(self) -> str:
119
+ """Get the full base URL for API requests."""
120
+ return f"{self.host}/{self.api_prefix}"
121
+
122
+ def get_endpoint(self, category: str, endpoint: str) -> str:
123
+ """Get a specific endpoint URL."""
124
+ category_endpoints = getattr(self.endpoints, category, {})
125
+ if isinstance(category_endpoints, dict):
126
+ return category_endpoints.get(endpoint, "")
127
+ return ""
128
+
129
+ def get_full_endpoint_url(self, category: str, endpoint: str, **kwargs) -> str:
130
+ """Get the full URL for an endpoint with substitutions."""
131
+ endpoint_path = self.get_endpoint(category, endpoint)
132
+ if not endpoint_path:
133
+ raise ValueError(f"Endpoint not found: {category}.{endpoint}")
134
+
135
+ # Substitute path parameters
136
+ full_path = endpoint_path.format(**kwargs)
137
+ return f"{self.base_url}{full_path}"
138
+
139
+ @classmethod
140
+ def from_environment(cls) -> "AIRConfig":
141
+ """Create configuration from environment variables."""
142
+ config_data = {
143
+ "host": os.getenv("AIR_HOST", ""),
144
+ "api_token": os.getenv("AIR_API_TOKEN", ""),
145
+ "api_prefix": os.getenv("AIR_API_PREFIX", "api/public"),
146
+ "organization_id": int(os.getenv("AIR_ORGANIZATION_ID", "0")),
147
+ "timeout": int(os.getenv("AIR_TIMEOUT", "30")),
148
+ "verify_ssl": os.getenv("AIR_VERIFY_SSL", "true").lower() == "true",
149
+ "retry_attempts": int(os.getenv("AIR_RETRY_ATTEMPTS", "3")),
150
+ "retry_delay": float(os.getenv("AIR_RETRY_DELAY", "1.0")),
151
+ }
152
+
153
+ return cls(**config_data)
154
+
155
+ @classmethod
156
+ def from_file(cls, config_path: str = "config.json") -> "AIRConfig":
157
+ """Create configuration from JSON file."""
158
+ try:
159
+ with open(config_path, "r") as f:
160
+ config_data = json.load(f)
161
+ return cls(**config_data)
162
+ except FileNotFoundError:
163
+ raise FileNotFoundError(f"Configuration file not found: {config_path}")
164
+ except json.JSONDecodeError as e:
165
+ raise ValueError(f"Invalid JSON in configuration file: {e}")
166
+
167
+ @classmethod
168
+ def create(
169
+ cls,
170
+ host: Optional[str] = None,
171
+ api_token: Optional[str] = None,
172
+ organization_id: Optional[int] = None,
173
+ config_file: Optional[str] = None,
174
+ **kwargs
175
+ ) -> "AIRConfig":
176
+ """Create configuration with precedence: params > env vars > config file."""
177
+ config_data = {}
178
+
179
+ # 1. Try config file first (lowest precedence)
180
+ if config_file and os.path.exists(config_file):
181
+ try:
182
+ with open(config_file, "r") as f:
183
+ config_data = json.load(f)
184
+ except (json.JSONDecodeError, IOError):
185
+ pass # Ignore file errors, will try other methods
186
+ elif os.path.exists("config.json"):
187
+ # Try default config.json
188
+ try:
189
+ with open("config.json", "r") as f:
190
+ config_data = json.load(f)
191
+ except (json.JSONDecodeError, IOError):
192
+ pass
193
+
194
+ # 2. Override with environment variables
195
+ env_config = {}
196
+ if os.getenv("AIR_HOST"):
197
+ env_config["host"] = os.getenv("AIR_HOST")
198
+ if os.getenv("AIR_API_TOKEN"):
199
+ env_config["api_token"] = os.getenv("AIR_API_TOKEN")
200
+ if os.getenv("AIR_API_PREFIX"):
201
+ env_config["api_prefix"] = os.getenv("AIR_API_PREFIX")
202
+
203
+ org_id_env = os.getenv("AIR_ORGANIZATION_ID")
204
+ if org_id_env:
205
+ env_config["organization_id"] = int(org_id_env)
206
+
207
+ timeout_env = os.getenv("AIR_TIMEOUT")
208
+ if timeout_env:
209
+ env_config["timeout"] = int(timeout_env)
210
+
211
+ verify_ssl_env = os.getenv("AIR_VERIFY_SSL")
212
+ if verify_ssl_env:
213
+ env_config["verify_ssl"] = verify_ssl_env.lower() == "true"
214
+
215
+ retry_attempts_env = os.getenv("AIR_RETRY_ATTEMPTS")
216
+ if retry_attempts_env:
217
+ env_config["retry_attempts"] = int(retry_attempts_env)
218
+
219
+ retry_delay_env = os.getenv("AIR_RETRY_DELAY")
220
+ if retry_delay_env:
221
+ env_config["retry_delay"] = float(retry_delay_env)
222
+
223
+ config_data.update(env_config)
224
+
225
+ # 3. Override with explicit parameters (highest precedence)
226
+ if host is not None:
227
+ config_data["host"] = host
228
+ if api_token is not None:
229
+ config_data["api_token"] = api_token
230
+ if organization_id is not None:
231
+ config_data["organization_id"] = organization_id
232
+
233
+ # Add any additional kwargs
234
+ config_data.update(kwargs)
235
+
236
+ return cls(**config_data)
237
+
238
+ def to_dict(self) -> Dict[str, Any]:
239
+ """Convert configuration to dictionary."""
240
+ return self.model_dump()
241
+
242
+ def save_to_file(self, config_path: str = "config.json") -> None:
243
+ """Save configuration to JSON file."""
244
+ with open(config_path, "w") as f:
245
+ json.dump(self.to_dict(), f, indent=2)
@@ -0,0 +1,50 @@
1
+ """
2
+ Custom exceptions for the Binalyze AIR SDK.
3
+ """
4
+
5
+ from typing import Optional, Dict, Any
6
+
7
+
8
+ class AIRAPIError(Exception):
9
+ """Base exception for all AIR API errors."""
10
+
11
+ def __init__(self, message: str, status_code: Optional[int] = None, response_data: Optional[Dict[str, Any]] = None):
12
+ self.message = message
13
+ self.status_code = status_code
14
+ self.response_data = response_data or {}
15
+ super().__init__(self.message)
16
+
17
+
18
+ class AuthenticationError(AIRAPIError):
19
+ """Raised when authentication fails."""
20
+ pass
21
+
22
+
23
+ class AuthorizationError(AIRAPIError):
24
+ """Raised when authorization fails."""
25
+ pass
26
+
27
+
28
+ class NotFoundError(AIRAPIError):
29
+ """Raised when a resource is not found."""
30
+ pass
31
+
32
+
33
+ class ValidationError(AIRAPIError):
34
+ """Raised when request validation fails."""
35
+ pass
36
+
37
+
38
+ class RateLimitError(AIRAPIError):
39
+ """Raised when rate limit is exceeded."""
40
+ pass
41
+
42
+
43
+ class ServerError(AIRAPIError):
44
+ """Raised when server returns 5xx status codes."""
45
+ pass
46
+
47
+
48
+ class NetworkError(AIRAPIError):
49
+ """Raised when network-related errors occur."""
50
+ pass
@@ -0,0 +1,306 @@
1
+ """
2
+ HTTP client for Binalyze AIR API communications.
3
+ """
4
+
5
+ import time
6
+ import requests
7
+ import urllib3
8
+ from typing import Any, Dict, Optional, Union
9
+ from urllib.parse import urljoin
10
+
11
+ from .config import AIRConfig
12
+ from .exceptions import (
13
+ AIRAPIError,
14
+ AuthenticationError,
15
+ AuthorizationError,
16
+ NotFoundError,
17
+ ValidationError,
18
+ RateLimitError,
19
+ ServerError,
20
+ NetworkError,
21
+ )
22
+
23
+
24
+ class HTTPClient:
25
+ """HTTP client for AIR API communications."""
26
+
27
+ def __init__(self, config: AIRConfig):
28
+ """Initialize the HTTP client with configuration."""
29
+ self.config = config
30
+ self.session = requests.Session()
31
+ self.session.headers.update({
32
+ "Content-Type": "application/json",
33
+ "Authorization": f"Bearer {config.api_token}",
34
+ "User-Agent": "binalyze-air-sdk/1.0.0",
35
+ })
36
+ self.session.verify = config.verify_ssl
37
+
38
+ # Disable SSL warnings when SSL verification is disabled
39
+ if not config.verify_ssl:
40
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
41
+
42
+ def _build_url(self, endpoint: str) -> str:
43
+ """Build full URL from endpoint."""
44
+ # Remove leading slash if present
45
+ endpoint = endpoint.lstrip("/")
46
+ # Build full URL with API prefix
47
+ return f"{self.config.host}/{self.config.api_prefix}/{endpoint}"
48
+
49
+ def _handle_response(self, response: requests.Response) -> Dict[str, Any]:
50
+ """Handle HTTP response and raise appropriate exceptions."""
51
+ try:
52
+ data = response.json()
53
+ except ValueError:
54
+ # If response is not JSON, create a basic structure
55
+ data = {
56
+ "success": False,
57
+ "result": None,
58
+ "statusCode": response.status_code,
59
+ "errors": [response.text or "Unknown error"]
60
+ }
61
+
62
+ # Handle specific known API bugs with better error messages
63
+ if response.status_code == 500:
64
+ error_message = data.get("errors", [""])[0] if data.get("errors") else ""
65
+
66
+ # API-001: Policies endpoint parameter validation bug
67
+ if "GET: /api/public/policies route has internal server error" in error_message:
68
+ raise ValidationError(
69
+ "Missing required 'organizationIds' filter parameter. "
70
+ "Please provide organization IDs to filter policies. "
71
+ "(Note: This is a known API server bug that returns 500 instead of 400)",
72
+ status_code=400, # What it should be
73
+ response_data=data
74
+ )
75
+
76
+ # API-002: Auto asset tags update endpoint bug
77
+ if "auto-asset-tag" in response.url and response.request.method == "PUT":
78
+ raise ServerError(
79
+ "Auto asset tag update is currently unavailable due to a server bug. "
80
+ "Workaround: Delete the existing tag and create a new one with updated values. "
81
+ "(Note: This is a known API server issue)",
82
+ status_code=response.status_code,
83
+ response_data=data
84
+ )
85
+
86
+ # Check for specific error status codes
87
+ if response.status_code == 401:
88
+ raise AuthenticationError(
89
+ "Authentication failed. Check your API token.",
90
+ status_code=response.status_code,
91
+ response_data=data
92
+ )
93
+ elif response.status_code == 403:
94
+ raise AuthorizationError(
95
+ "Authorization failed. Insufficient permissions.",
96
+ status_code=response.status_code,
97
+ response_data=data
98
+ )
99
+ elif response.status_code == 404:
100
+ # Use specific API error message instead of generic message
101
+ api_errors = data.get("errors", ["Resource not found"])
102
+ error_message = "; ".join(api_errors) if api_errors else "Resource not found"
103
+ raise NotFoundError(
104
+ error_message,
105
+ status_code=response.status_code,
106
+ response_data=data
107
+ )
108
+ elif response.status_code == 422:
109
+ errors = data.get("errors", ["Validation failed"])
110
+ raise ValidationError(
111
+ f"Validation error: {'; '.join(errors)}",
112
+ status_code=response.status_code,
113
+ response_data=data
114
+ )
115
+ elif response.status_code == 429:
116
+ raise RateLimitError(
117
+ "Rate limit exceeded. Please try again later.",
118
+ status_code=response.status_code,
119
+ response_data=data
120
+ )
121
+ elif response.status_code >= 500:
122
+ raise ServerError(
123
+ f"Server error: {response.status_code}",
124
+ status_code=response.status_code,
125
+ response_data=data
126
+ )
127
+ elif not response.ok:
128
+ errors = data.get("errors", [f"HTTP {response.status_code}"])
129
+ raise AIRAPIError(
130
+ f"API error: {'; '.join(errors)}",
131
+ status_code=response.status_code,
132
+ response_data=data
133
+ )
134
+
135
+ return data
136
+
137
+ def _handle_binary_response(self, response: requests.Response) -> requests.Response:
138
+ """Handle binary file response without JSON parsing."""
139
+ # Check for specific error status codes
140
+ if response.status_code == 401:
141
+ raise AuthenticationError(
142
+ "Authentication failed. Check your API token.",
143
+ status_code=response.status_code
144
+ )
145
+ elif response.status_code == 403:
146
+ raise AuthorizationError(
147
+ "Authorization failed. Insufficient permissions.",
148
+ status_code=response.status_code
149
+ )
150
+ elif response.status_code == 404:
151
+ raise NotFoundError(
152
+ "Resource not found.",
153
+ status_code=response.status_code
154
+ )
155
+ elif response.status_code == 422:
156
+ raise ValidationError(
157
+ "Validation error",
158
+ status_code=response.status_code
159
+ )
160
+ elif response.status_code == 429:
161
+ raise RateLimitError(
162
+ "Rate limit exceeded. Please try again later.",
163
+ status_code=response.status_code
164
+ )
165
+ elif response.status_code >= 500:
166
+ raise ServerError(
167
+ f"Server error: {response.status_code}",
168
+ status_code=response.status_code
169
+ )
170
+ elif not response.ok:
171
+ raise AIRAPIError(
172
+ f"API error: HTTP {response.status_code}",
173
+ status_code=response.status_code
174
+ )
175
+
176
+ return response
177
+
178
+ def _make_request(
179
+ self,
180
+ method: str,
181
+ endpoint: str,
182
+ params: Optional[Dict[str, Any]] = None,
183
+ data: Optional[Dict[str, Any]] = None,
184
+ json_data: Optional[Dict[str, Any]] = None
185
+ ) -> Dict[str, Any]:
186
+ """Make HTTP request with retry logic."""
187
+ url = self._build_url(endpoint)
188
+ last_exception = None
189
+
190
+ for attempt in range(self.config.retry_attempts):
191
+ try:
192
+ response = self.session.request(
193
+ method=method,
194
+ url=url,
195
+ params=params,
196
+ data=data,
197
+ json=json_data,
198
+ timeout=self.config.timeout
199
+ )
200
+ return self._handle_response(response)
201
+
202
+ except (requests.ConnectionError, requests.Timeout) as e:
203
+ last_exception = NetworkError(f"Network error: {str(e)}")
204
+ if attempt < self.config.retry_attempts - 1:
205
+ time.sleep(self.config.retry_delay * (2 ** attempt)) # Exponential backoff
206
+ continue
207
+ raise last_exception
208
+
209
+ except (RateLimitError, ServerError) as e:
210
+ last_exception = e
211
+ if attempt < self.config.retry_attempts - 1:
212
+ time.sleep(self.config.retry_delay * (2 ** attempt))
213
+ continue
214
+ raise
215
+
216
+ except (AuthenticationError, AuthorizationError, NotFoundError, ValidationError) as e:
217
+ # Don't retry these errors
218
+ raise
219
+
220
+ # If we get here, all retries failed
221
+ if last_exception:
222
+ raise last_exception
223
+
224
+ raise AIRAPIError("All retry attempts failed")
225
+
226
+ def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
227
+ """Make GET request."""
228
+ return self._make_request("GET", endpoint, params=params)
229
+
230
+ def post(
231
+ self,
232
+ endpoint: str,
233
+ data: Optional[Dict[str, Any]] = None,
234
+ json_data: Optional[Dict[str, Any]] = None,
235
+ params: Optional[Dict[str, Any]] = None
236
+ ) -> Dict[str, Any]:
237
+ """Make POST request."""
238
+ return self._make_request("POST", endpoint, params=params, data=data, json_data=json_data)
239
+
240
+ def put(
241
+ self,
242
+ endpoint: str,
243
+ data: Optional[Dict[str, Any]] = None,
244
+ json_data: Optional[Dict[str, Any]] = None,
245
+ params: Optional[Dict[str, Any]] = None
246
+ ) -> Dict[str, Any]:
247
+ """Make PUT request."""
248
+ return self._make_request("PUT", endpoint, params=params, data=data, json_data=json_data)
249
+
250
+ def patch(
251
+ self,
252
+ endpoint: str,
253
+ data: Optional[Dict[str, Any]] = None,
254
+ json_data: Optional[Dict[str, Any]] = None,
255
+ params: Optional[Dict[str, Any]] = None
256
+ ) -> Dict[str, Any]:
257
+ """Make PATCH request."""
258
+ return self._make_request("PATCH", endpoint, params=params, data=data, json_data=json_data)
259
+
260
+ def delete(
261
+ self,
262
+ endpoint: str,
263
+ params: Optional[Dict[str, Any]] = None,
264
+ json_data: Optional[Dict[str, Any]] = None
265
+ ) -> Dict[str, Any]:
266
+ """Make DELETE request."""
267
+ return self._make_request("DELETE", endpoint, params=params, json_data=json_data)
268
+
269
+ def get_binary(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> requests.Response:
270
+ """Make GET request for binary file downloads."""
271
+ url = self._build_url(endpoint)
272
+ last_exception = None
273
+
274
+ for attempt in range(self.config.retry_attempts):
275
+ try:
276
+ response = self.session.request(
277
+ method="GET",
278
+ url=url,
279
+ params=params,
280
+ timeout=self.config.timeout
281
+ )
282
+ return self._handle_binary_response(response)
283
+
284
+ except (requests.ConnectionError, requests.Timeout) as e:
285
+ last_exception = NetworkError(f"Network error: {str(e)}")
286
+ if attempt < self.config.retry_attempts - 1:
287
+ time.sleep(self.config.retry_delay * (2 ** attempt)) # Exponential backoff
288
+ continue
289
+ raise last_exception
290
+
291
+ except (RateLimitError, ServerError) as e:
292
+ last_exception = e
293
+ if attempt < self.config.retry_attempts - 1:
294
+ time.sleep(self.config.retry_delay * (2 ** attempt))
295
+ continue
296
+ raise
297
+
298
+ except (AuthenticationError, AuthorizationError, NotFoundError, ValidationError) as e:
299
+ # Don't retry these errors
300
+ raise
301
+
302
+ # If we get here, all retries failed
303
+ if last_exception:
304
+ raise last_exception
305
+
306
+ raise AIRAPIError("All retry attempts failed")