Skip to content

Commit c3a57c6

Browse files
committed
Enhance Humanitec integration documentation and add new components
- Updated `humanitec-integration.md` to include new configuration and circuit breaker components. - Introduced `config.py` for integration constants and `circuit_breaker.py` for handling transient failures. - Added `retryable_http_client.py` to manage HTTP requests with retry logic and circuit breaker protection. - Updated `humanitec_deployment_deltas.mdx` schema to include new properties: `archived` and `contributors`. - Enhanced `humanitec_exporter_humanitec_client.mdx` and `humanitec_exporter_port_client.mdx` to utilize the new retryable HTTP client. - Added methods for bulk and batched entity upserts in the Port client.
1 parent cd05cef commit c3a57c6

8 files changed

+576
-93
lines changed

docs/guides/all/humanitec-integration.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@ import HumanitecResourceBlueprint from "/docs/guides/templates/humanitec/_humani
1212
import HumanitecResourceGraphBlueprint from "/docs/guides/templates/humanitec/_humanitec_resource_graph_blueprint.mdx";
1313
import HumanitecExporterCacheScript from "/docs/guides/templates/humanitec/_humanitec_exporter_cache.mdx";
1414
import HumanitecExporterMainScript from "/docs/guides/templates/humanitec/_humanitec_exporter_main.mdx";
15+
import HumanitecExporterConfig from "/docs/guides/templates/humanitec/_humanitec_exporter_config.mdx";
1516
import HumanitecExporterRequirements from "/docs/guides/templates/humanitec/_humanitec_exporter_requirements.mdx";
1617
import HumanitecExporterPortClient from "/docs/guides/templates/humanitec/_humanitec_exporter_port_client.mdx";
1718
import HumanitecExporterHumanitecClient from "/docs/guides/templates/humanitec/_humanitec_exporter_humanitec_client.mdx";
19+
import HumanitecExporterCircuitBreaker from "/docs/guides/templates/humanitec/_humanitec_exporter_circuit_breaker.mdx";
20+
import HumanitecExporterRetryableHttpClient from "/docs/guides/templates/humanitec/_humanitec_exporter_retryable_http_client.mdx";
1821
import HumanitecGroups from "/docs/guides/templates/humanitec/_humanitec_groups.mdx";
1922
import HumanitecUsers from "/docs/guides/templates/humanitec/_humanitec_users.mdx";
2023
import HumanitecPipelines from "/docs/guides/templates/humanitec/_humanitec_pipelines.mdx";
@@ -103,6 +106,7 @@ In your GitHub repository, [go to **Settings > Secrets**](https://docs.github.co
103106

104107
1. Create the following Python files in a folder named `integration` at the base directory of your GitHub repository:
105108
- `main.py` - Orchestrates the synchronization of data from Humanitec to Port, ensuring that resource entities are accurately mirrored and updated on your Port catalog.
109+
- `config.py` - Contains the configuration constants for the integration, including cache TTL, connection pooling, and other settings.
106110
- `requirements.txt` - This file contains the dependencies or necessary external packages need to run the integration
107111

108112

@@ -114,6 +118,13 @@ In your GitHub repository, [go to **Settings > Secrets**](https://docs.github.co
114118

115119
</details>
116120

121+
<details>
122+
<summary><b>Config (Click to expand)</b></summary>
123+
124+
<HumanitecExporterConfig/>
125+
126+
</details>
127+
117128

118129
<details>
119130
<summary><b>Requirements (Click to expand)</b></summary>
@@ -127,6 +138,8 @@ In your GitHub repository, [go to **Settings > Secrets**](https://docs.github.co
127138
- `port_client.py` – Manages authentication and API requests to Port, facilitating the creation and updating of entities within Port's system.
128139
- `humanitec_client.py` – Handles API interactions with Humanitec, including retrieving data with caching mechanisms to optimize performance.
129140
- `cache.py` - Provides an in-memory caching mechanism with thread-safe operations for setting, retrieving, and deleting cache entries asynchronously.
141+
- `circuit_breaker.py` - Implements a circuit breaker pattern to handle transient failures in API calls, preventing cascading failures and improving the reliability of the integration.
142+
- `retryable_http_client.py` - Provides a retryable HTTP client with exponential backoff and jitter to handle failed API calls due to disonnected HTTP connections.
130143

131144
<details>
132145
<summary><b>Port Client (Click to expand)</b></summary>
@@ -150,6 +163,21 @@ In your GitHub repository, [go to **Settings > Secrets**](https://docs.github.co
150163

151164
</details>
152165

166+
<details>
167+
<summary><b>Circuit Breaker (Click to expand)</b></summary>
168+
169+
<HumanitecExporterCircuitBreaker/>
170+
171+
</details>
172+
173+
174+
<details>
175+
176+
<HumanitecExporterRetryableHttpClient/>
177+
178+
</details>
179+
180+
153181
### Create the GitHub workflow
154182

155183
Create the file `.github/workflows/humanitec-exporter.yaml` in the `.github/workflows` folder of your repository.

docs/guides/templates/humanitec/_humanitec_deployment_deltas.mdx

Lines changed: 59 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -3,56 +3,68 @@
33

44
```json showLineNumbers
55
{
6-
"identifier": "humanitecDeploymentDelta",
7-
"title": "Humanitec Deployment Delta",
8-
"icon": "Deployment",
9-
"schema": {
10-
"properties": {
11-
"status": {
12-
"title": "Status",
13-
"description": "The status of the deployment delta",
14-
"type": "string",
15-
"icon": "DefaultProperty"
16-
},
17-
"createdAt": {
18-
"title": "Created At",
19-
"description": "The date and time when the deployment delta was created",
20-
"type": "string",
21-
"format": "date-time",
22-
"icon": "DefaultProperty"
23-
},
24-
"createdBy": {
25-
"title": "Created By",
26-
"description": "The user who created the deployment delta",
27-
"type": "string",
28-
"icon": "DefaultProperty"
29-
},
30-
"comment": {
31-
"title": "Comment",
32-
"description": "Comment for the deployment delta",
33-
"type": "string",
34-
"icon": "DefaultProperty"
35-
},
36-
"environment": {
37-
"title": "Environment",
38-
"description": "The environment for the deployment delta",
39-
"type": "string",
40-
"icon": "DefaultProperty"
41-
}
6+
"identifier": "humanitecDeploymentDelta",
7+
"title": "Humanitec Deployment Delta",
8+
"icon": "Deployment",
9+
"schema": {
10+
"properties": {
11+
"archived": {
12+
"title": "Archived",
13+
"description": "Whether the deployment delta is archived",
14+
"type": "boolean",
15+
"icon": "DefaultProperty"
4216
},
43-
"required": []
44-
},
45-
"mirrorProperties": {},
46-
"calculationProperties": {},
47-
"aggregationProperties": {},
48-
"relations": {
49-
"humanitecApplication": {
50-
"title": "Application",
51-
"target": "humanitecApplication",
52-
"required": false,
53-
"many": false
17+
"contributers": {
18+
"title": "Contributers",
19+
"description": "The contributers of the deployment delta",
20+
"type": "array",
21+
"icon": "DefaultProperty"
22+
},
23+
"createdAt": {
24+
"title": "Created At",
25+
"description": "The date and time when the deployment delta was created",
26+
"type": "string",
27+
"format": "date-time",
28+
"icon": "DefaultProperty"
29+
},
30+
"createdBy": {
31+
"title": "Created By",
32+
"description": "The user who created the deployment delta",
33+
"type": "string",
34+
"icon": "DefaultProperty"
35+
},
36+
"modules": {
37+
"title": "Modules",
38+
"description": "The modules for the deployment delta",
39+
"type": "object",
40+
"icon": "DefaultProperty"
41+
},
42+
"shared": {
43+
"title": "Shared",
44+
"description": "The shared for the deployment delta",
45+
"type": "array",
46+
"icon": "DefaultProperty"
5447
}
48+
},
49+
"required": []
50+
},
51+
"mirrorProperties": {},
52+
"calculationProperties": {},
53+
"aggregationProperties": {},
54+
"relations": {
55+
"humanitecApplication": {
56+
"title": "Application",
57+
"target": "humanitecApplication",
58+
"required": false,
59+
"many": false
60+
},
61+
"humanitecEnvironment": {
62+
"title": "Environment",
63+
"target": "humanitecEnvironment",
64+
"required": false,
65+
"many": false
5566
}
67+
}
5668
}
5769
```
5870

docs/guides/templates/humanitec/_humanitec_exporter_cache.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import asyncio
44
from typing import Dict, Any
55

6+
67
class InMemoryCache:
78
def __init__(self):
89
self.cache = {}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
```python showLineNumbers title="circuit_breaker.py"
2+
3+
import time
4+
import asyncio
5+
from typing import Optional, Callable, Any
6+
from loguru import logger
7+
8+
9+
class CircuitBreaker:
10+
"""
11+
Circuit breaker pattern to prevent cascading failures.
12+
"""
13+
14+
def __init__(
15+
self,
16+
failure_threshold: int = 5,
17+
recovery_timeout: float = 60.0,
18+
expected_exception: type = Exception
19+
):
20+
self.failure_threshold = failure_threshold
21+
self.recovery_timeout = recovery_timeout
22+
self.expected_exception = expected_exception
23+
24+
self.failure_count = 0
25+
self.last_failure_time = None
26+
self.state = "CLOSED" # CLOSED, OPEN, HALF_OPEN
27+
28+
def _can_attempt_reset(self) -> bool:
29+
"""Check if enough time has passed to attempt reset."""
30+
if self.last_failure_time is None:
31+
return True
32+
33+
return time.time() - self.last_failure_time >= self.recovery_timeout
34+
35+
def _record_failure(self):
36+
"""Record a failure and potentially open the circuit."""
37+
self.failure_count += 1
38+
self.last_failure_time = time.time()
39+
40+
if self.failure_count >= self.failure_threshold:
41+
self.state = "OPEN"
42+
logger.warning(f"Circuit breaker opened after {self.failure_count} failures")
43+
44+
def _record_success(self):
45+
"""Record a success and reset the circuit."""
46+
self.failure_count = 0
47+
self.last_failure_time = None
48+
if self.state == "HALF_OPEN":
49+
self.state = "CLOSED"
50+
logger.info("Circuit breaker closed after successful request")
51+
52+
async def call(self, func: Callable, *args, **kwargs) -> Any:
53+
"""
54+
Execute a function with circuit breaker protection.
55+
56+
Args:
57+
func: The function to execute
58+
*args: Arguments for the function
59+
**kwargs: Keyword arguments for the function
60+
61+
Returns:
62+
The result of the function call
63+
64+
Raises:
65+
Exception: If the circuit is open or the function fails
66+
"""
67+
if self.state == "OPEN":
68+
if self._can_attempt_reset():
69+
self.state = "HALF_OPEN"
70+
logger.info("Circuit breaker attempting half-open state")
71+
else:
72+
raise Exception(f"Circuit breaker is OPEN. Last failure: {self.last_failure_time}")
73+
74+
try:
75+
result = await func(*args, **kwargs)
76+
self._record_success()
77+
return result
78+
79+
except self.expected_exception as e:
80+
self._record_failure()
81+
raise e
82+
83+
def get_state(self) -> str:
84+
"""Get the current state of the circuit breaker."""
85+
return self.state
86+
87+
def get_failure_count(self) -> int:
88+
"""Get the current failure count."""
89+
return self.failure_count
90+
91+
```
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
```python showLineNumbers title="config.py"
2+
3+
"""
4+
Configuration constants for the Humanitec integration.
5+
"""
6+
7+
MAX_RETRY_ATTEMPTS = 5
8+
DEFAULT_TIMEOUT_SECONDS = 30
9+
RETRY_DELAY_SECONDS = 1
10+
USE_EXPONENTIAL_BACKOFF = True
11+
MAX_RETRY_DELAY_SECONDS = 10
12+
13+
MAX_CONNECTIONS = 20
14+
MAX_KEEPALIVE_CONNECTIONS = 10
15+
KEEPALIVE_EXPIRY = 30
16+
17+
CACHE_TTL_SECONDS = 300
18+
19+
LOG_LEVEL = "INFO"
20+
21+
```

docs/guides/templates/humanitec/_humanitec_exporter_humanitec_client.mdx

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
```python showLineNumbers title="humanitec_client.py"
22

33
import httpx
4-
import asyncio
5-
from typing import Dict, Any, List
6-
import datetime
7-
import re
4+
from typing import Dict, Any, List, Optional
85
from loguru import logger
96
from .cache import InMemoryCache
7+
from .retryable_http_client import RetryableHTTPClient
108

119

1210
class CACHE_KEYS:
@@ -16,8 +14,13 @@ class CACHE_KEYS:
1614

1715

1816
class HumanitecClient:
19-
def __init__(self, org_id, api_token, **kwargs) -> None:
20-
self.client = kwargs.get("httpx_async_client", httpx.AsyncClient())
17+
def __init__(self, org_id: str, api_token: str, **kwargs) -> None:
18+
# Inject the retryable HTTP client
19+
self.http_client = kwargs.get("http_client")
20+
if not self.http_client:
21+
timeout = kwargs.get("timeout", httpx.Timeout(20))
22+
self.http_client = RetryableHTTPClient(timeout=timeout)
23+
2124
self.base_url = (
2225
f"{kwargs.get('base_url','https://api.humanitec.io')}/orgs/{org_id}/"
2326
)
@@ -36,23 +39,14 @@ class HumanitecClient:
3639
self,
3740
method: str,
3841
endpoint: str,
39-
headers: Dict[str, str] | None = None,
40-
json: Dict[str, Any] | List[Dict[str, Any]] | None = None,
42+
headers: Optional[Dict[str, str]] = None,
43+
json: Optional[Dict[str, Any] | List[Dict[str, Any]]] = None,
4144
) -> Any:
45+
"""Send API request using the injected retryable HTTP client."""
4246
url = self.base_url + endpoint
43-
try:
44-
logger.debug(f"Requesting Humanitec data for endpoint: {endpoint}")
45-
response = await self.client.request(
46-
method, url, headers=headers, json=json
47-
)
48-
response.raise_for_status()
49-
return response.json()
50-
except httpx.HTTPStatusError as e:
51-
logger.error(f"HTTP error occurred: {e.response.text}")
52-
raise
53-
except Exception as e:
54-
logger.error(f"An error occurred: {str(e)}")
55-
raise
47+
logger.debug(f"Requesting Humanitec data for endpoint: {endpoint}")
48+
response = await self.http_client.request(method, url, headers=headers, json=json)
49+
return response.json()
5650

5751
async def get_all_applications(self) -> List[Dict[str, Any]]:
5852
if cached_applications := await self.cache.get(CACHE_KEYS.APPLICATION):
@@ -285,4 +279,10 @@ class HumanitecClient:
285279
)
286280
logger.info(f"Received {len(users)} users in group {group_id}")
287281
return users
282+
283+
async def close(self):
284+
"""Close the HTTP client."""
285+
if self.http_client:
286+
await self.http_client.close()
287+
288288
```

0 commit comments

Comments
 (0)