programgarden-core 2.5.0__tar.gz → 2.5.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.
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/PKG-INFO +1 -1
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/ai.py +9 -5
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/data.py +140 -37
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/trigger.py +35 -4
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/registry/node_registry.py +5 -2
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/retry_executor.py +34 -13
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/pyproject.toml +1 -1
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/README.md +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/__init__.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/bases/__init__.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/bases/client.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/bases/components.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/bases/listener.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/bases/mixins.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/bases/products.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/bases/sql.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/bases/storage.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/code_node.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/exceptions.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/expression/__init__.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/expression/evaluator.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/expression/reference.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/i18n/__init__.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/i18n/locales/en.json +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/i18n/locales/ko.json +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/i18n/translator.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/korea_alias.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/models/__init__.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/models/connection_rule.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/models/credential.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/models/edge.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/models/event.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/models/exchange.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/models/field_binding.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/models/job.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/models/order_diagnostics.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/models/plugin_resource.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/models/resilience.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/models/resource.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/models/validation.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/models/workflow.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/__init__.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/account_futures.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/account_korea_stock.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/account_stock.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/backtest.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/backtest_futures.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/backtest_korea_stock.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/backtest_stock.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/base.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/broker.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/calculation.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/code.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/condition.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/data_futures.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/data_korea_stock.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/data_stock.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/display.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/event.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/fundamental_korea_stock.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/fundamental_stock.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/infra.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/open_orders_futures.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/open_orders_korea_stock.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/open_orders_stock.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/order.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/orderable_quantity_futures.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/portfolio.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/realtime_futures.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/realtime_korea_stock.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/realtime_stock.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/risk.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/session_gate.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/symbol.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/symbol_futures.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/symbol_korea_stock.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/symbol_stock.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/presets/__init__.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/presets/news_analyst.json +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/presets/risk_manager.json +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/presets/strategist.json +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/presets/technical_analyst.json +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/registry/__init__.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/registry/credential_registry.py +0 -0
- {programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/registry/plugin_registry.py +0 -0
|
@@ -43,13 +43,15 @@ class LLMModelNode(BaseNode):
|
|
|
43
43
|
category: NodeCategory = NodeCategory.AI
|
|
44
44
|
|
|
45
45
|
# 연결(자격증명) 선언 — 챗봇/편집기/검증기 공용. AI 모델 키는 실행에 반드시 필요하다.
|
|
46
|
+
# missing="ask" (오너 결정 2026-09-28): 키가 없으면 챗봇이 먼저 등록을 묻고, 사용자가
|
|
47
|
+
# "키 없이 진행" 이라고 명시할 때만 초안으로 저장한다("draft" 의 조용한 자동 초안과 구분).
|
|
46
48
|
_connection: ClassVar[Dict[str, Any]] = {
|
|
47
49
|
"purpose": "ai",
|
|
48
50
|
"need": "run",
|
|
49
51
|
"when": "always",
|
|
50
52
|
"label_key": "connection.LLMModelNode.label",
|
|
51
53
|
"presets": [],
|
|
52
|
-
"missing": "
|
|
54
|
+
"missing": "ask",
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
|
|
@@ -204,7 +206,7 @@ class LLMModelNode(BaseNode):
|
|
|
204
206
|
},
|
|
205
207
|
]
|
|
206
208
|
_node_guide: ClassVar[Dict[str, Any]] = {
|
|
207
|
-
"input_handling": "credential_id is mandatory and fixed (no expressions). It must reference a registered credential of kind llm_openai, llm_anthropic, llm_deepseek or llm_google whose api_key the user entered themselves; until the user registers and selects such a key the workflow can be built and saved but not run. Set model to the exact model ID for that provider. No data input is needed — this node only establishes the API connection.",
|
|
209
|
+
"input_handling": "credential_id is mandatory and fixed (no expressions). It must reference a registered credential of kind llm_openai, llm_anthropic, llm_deepseek or llm_google whose api_key the user entered themselves; until the user registers and selects such a key the workflow can be built and saved but not run. Because the key is required to run, the chatbot asks the user to register the AI-model key before building (connection.missing='ask'); it only saves a key-free draft when the user explicitly says to proceed without it. Set model to the exact model ID for that provider. No data input is needed — this node only establishes the API connection.",
|
|
208
210
|
"output_consumption": "The 'connection' output port uses edge type 'ai_model', NOT 'main'. Connect it to AIAgentNode's ai_model input. The connection object is not a data value — it is an internal LLM client handle passed to the agent.",
|
|
209
211
|
"common_combinations": [
|
|
210
212
|
"LLMModelNode → AIAgentNode (ai_model edge) — always paired",
|
|
@@ -234,9 +236,11 @@ class LLMModelNode(BaseNode):
|
|
|
234
236
|
),
|
|
235
237
|
]
|
|
236
238
|
|
|
237
|
-
_version: ClassVar[str] = "1.
|
|
238
|
-
_updated_at: ClassVar[str] = "2026-
|
|
239
|
-
_change_note: ClassVar[Optional[str]] =
|
|
239
|
+
_version: ClassVar[str] = "1.1.0"
|
|
240
|
+
_updated_at: ClassVar[str] = "2026-09-28"
|
|
241
|
+
_change_note: ClassVar[Optional[str]] = (
|
|
242
|
+
"connection.missing='ask' — chatbot asks the user to register the AI-model key before build"
|
|
243
|
+
)
|
|
240
244
|
|
|
241
245
|
@classmethod
|
|
242
246
|
def is_tool_enabled(cls) -> bool:
|
|
@@ -13,7 +13,7 @@ MarketDataNode는 상품별 분리됨:
|
|
|
13
13
|
계좌 조회는 account_stock/account_futures 참조
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
|
-
from typing import Optional, List, Literal, Dict, Any, ClassVar, TYPE_CHECKING
|
|
16
|
+
from typing import Optional, List, Literal, Dict, Any, ClassVar, Tuple, TYPE_CHECKING
|
|
17
17
|
from pydantic import Field
|
|
18
18
|
|
|
19
19
|
if TYPE_CHECKING:
|
|
@@ -47,8 +47,15 @@ class HTTPServerError(Exception):
|
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
class HTTPRateLimitError(Exception):
|
|
50
|
-
"""HTTP 429 Rate Limit 에러 - 재시도
|
|
51
|
-
|
|
50
|
+
"""HTTP 429 Rate Limit 에러 - 재시도 가능.
|
|
51
|
+
|
|
52
|
+
`Retry-After` 헤더가 있으면 그 값을 `retry_after`(초, float)에 실어 RetryExecutor 가
|
|
53
|
+
지수 백오프 대신(또는 그와 함께) 서버가 요구한 대기 시간을 존중하게 한다.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, message: str, retry_after: Optional[float] = None):
|
|
57
|
+
super().__init__(message)
|
|
58
|
+
self.retry_after = retry_after
|
|
52
59
|
|
|
53
60
|
|
|
54
61
|
class HTTPNetworkError(Exception):
|
|
@@ -61,6 +68,72 @@ class HTTPTimeoutError(Exception):
|
|
|
61
68
|
pass
|
|
62
69
|
|
|
63
70
|
|
|
71
|
+
class HTTPCredentialError(Exception):
|
|
72
|
+
"""An authenticated HTTP node has no injected credential; no request was sent."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ── HTTP 외부 API 보호 (오너 우려 2026-09-28: 클라우드 공유 IP 로 종목마다 요청이 동시에
|
|
76
|
+
# 나가면 외부 API 사가 차단할 수 있다) ─────────────────────────────────────────────
|
|
77
|
+
# 호스트별 동시 요청 상한. auto-iterate 반복은 원래 순차 실행 + rate_limit_interval 간격이라
|
|
78
|
+
# 안전하지만, 실시간/병렬(fan-out) 경로에서 같은 호스트로 요청이 몰리는 것을 막는 프로세스
|
|
79
|
+
# 전역 가드다. 기본 1 = 한 호스트에 한 번에 한 요청. 상수는 여기 한 곳에서만 정의한다.
|
|
80
|
+
HTTP_MAX_CONCURRENCY_PER_HOST: int = 1
|
|
81
|
+
|
|
82
|
+
# (event-loop id, host) → Semaphore. asyncio.Semaphore 는 생성된 이벤트 루프에 묶이므로
|
|
83
|
+
# 루프별로 따로 만든다(워커는 보통 루프 하나지만, 다른 루프에서 재사용 시 RuntimeError 회피).
|
|
84
|
+
_HOST_SEMAPHORES: Dict[Tuple[int, str], "object"] = {}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _host_semaphore(url: str):
|
|
88
|
+
"""이 URL 호스트의 프로세스 전역 동시성 세마포어(현재 이벤트 루프 기준)."""
|
|
89
|
+
import asyncio
|
|
90
|
+
from urllib.parse import urlparse
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
host = (urlparse(url).hostname or "").lower()
|
|
94
|
+
except Exception:
|
|
95
|
+
host = ""
|
|
96
|
+
try:
|
|
97
|
+
loop_id = id(asyncio.get_running_loop())
|
|
98
|
+
except RuntimeError:
|
|
99
|
+
loop_id = 0
|
|
100
|
+
key = (loop_id, host)
|
|
101
|
+
sem = _HOST_SEMAPHORES.get(key)
|
|
102
|
+
if sem is None:
|
|
103
|
+
sem = asyncio.Semaphore(HTTP_MAX_CONCURRENCY_PER_HOST)
|
|
104
|
+
_HOST_SEMAPHORES[key] = sem
|
|
105
|
+
return sem
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _parse_retry_after(value: Optional[str]) -> Optional[float]:
|
|
109
|
+
"""HTTP `Retry-After` 헤더 → 대기 초. delta-seconds(정수) 또는 HTTP-date 지원.
|
|
110
|
+
|
|
111
|
+
음수/파싱 실패는 None (그러면 RetryExecutor 가 순수 지수 백오프로 되돌아간다).
|
|
112
|
+
"""
|
|
113
|
+
if not value:
|
|
114
|
+
return None
|
|
115
|
+
value = value.strip()
|
|
116
|
+
# delta-seconds (가장 흔한 형태)
|
|
117
|
+
try:
|
|
118
|
+
secs = float(value)
|
|
119
|
+
return secs if secs >= 0 else None
|
|
120
|
+
except (TypeError, ValueError):
|
|
121
|
+
pass
|
|
122
|
+
# HTTP-date (RFC 7231)
|
|
123
|
+
try:
|
|
124
|
+
from email.utils import parsedate_to_datetime
|
|
125
|
+
from datetime import datetime, timezone
|
|
126
|
+
|
|
127
|
+
dt = parsedate_to_datetime(value)
|
|
128
|
+
if dt is None:
|
|
129
|
+
return None
|
|
130
|
+
if dt.tzinfo is None:
|
|
131
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
132
|
+
return max(0.0, (dt - datetime.now(timezone.utc)).total_seconds())
|
|
133
|
+
except Exception:
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
64
137
|
class SQLiteNode(BaseNode):
|
|
65
138
|
"""
|
|
66
139
|
로컬 SQLite 데이터베이스 노드 (단순 DB)
|
|
@@ -584,6 +657,10 @@ class HTTPRequestNode(BaseNode):
|
|
|
584
657
|
default=None,
|
|
585
658
|
description="Credential ID (credentials 섹션에서 참조)"
|
|
586
659
|
)
|
|
660
|
+
credential_preset: Optional[Literal["fmp", "finnhub"]] = Field(
|
|
661
|
+
default=None,
|
|
662
|
+
description="Non-secret provider preset for the credential registration form; never selects a stored key.",
|
|
663
|
+
)
|
|
587
664
|
|
|
588
665
|
# === Headers: UI에서 동적 추가 ===
|
|
589
666
|
headers: Optional[Dict[str, str]] = Field(default=None, description="HTTP headers")
|
|
@@ -628,7 +705,8 @@ class HTTPRequestNode(BaseNode):
|
|
|
628
705
|
}
|
|
629
706
|
_features: ClassVar[List[str]] = [
|
|
630
707
|
"Supports all HTTP methods: GET, POST, PUT, PATCH, DELETE with optional query params and request body",
|
|
631
|
-
"Built-in resilience: retry on 5xx and 429 with exponential backoff (enabled by default, max_retries=3)",
|
|
708
|
+
"Built-in resilience: retry on 5xx and 429 with exponential backoff (enabled by default, max_retries=3). Retry-After is a minimum wait. When it exceeds max_delay or the 5-second validation wait budget, retrying stops with the original failure; the server's wait is never shortened. Existing fallback and per-item error handling apply.",
|
|
709
|
+
"Per-host concurrency cap (default 1): the engine lets only one request per host run at a time process-wide, so many parallel/fan-out requests to the same external API (shared cloud egress IP) queue instead of stampeding. Auto-iterate is already sequential and paced by rate_limit_interval.",
|
|
632
710
|
"Credential integration for Bearer token, HTTP Basic, custom header, and query-param auth patterns",
|
|
633
711
|
"Rate-limited: minimum 1-second interval and max 3 concurrent calls; real-time node connections blocked",
|
|
634
712
|
"is_tool_enabled=True — AI Agent can invoke HTTPRequestNode as a tool to fetch live external data",
|
|
@@ -767,11 +845,10 @@ class HTTPRequestNode(BaseNode):
|
|
|
767
845
|
|
|
768
846
|
_field_schema: ClassVar[Dict[str, "FieldSchema"]] = {}
|
|
769
847
|
|
|
770
|
-
_version: ClassVar[str] = "1.
|
|
848
|
+
_version: ClassVar[str] = "1.2.0"
|
|
771
849
|
_updated_at: ClassVar[str] = "2026-09-28"
|
|
772
850
|
_change_note: ClassVar[Optional[str]] = (
|
|
773
|
-
"
|
|
774
|
-
"scalar ports keep the last item's value."
|
|
851
|
+
"429 honours Retry-After + per-host concurrency cap (1) for shared-IP safety"
|
|
775
852
|
)
|
|
776
853
|
|
|
777
854
|
@classmethod
|
|
@@ -832,6 +909,14 @@ class HTTPRequestNode(BaseNode):
|
|
|
832
909
|
credential_types=["http_bearer", "http_header", "http_basic", "http_query"],
|
|
833
910
|
ui_component=UIComponent.CUSTOM_CREDENTIAL_SELECT,
|
|
834
911
|
),
|
|
912
|
+
"credential_preset": FieldSchema(
|
|
913
|
+
name="credential_preset", type=FieldType.ENUM, required=False,
|
|
914
|
+
description="Non-secret provider preset for credential registration",
|
|
915
|
+
category=FieldCategory.PARAMETERS,
|
|
916
|
+
expression_mode=ExpressionMode.FIXED_ONLY,
|
|
917
|
+
enum_values=[p["id"] for p in cls._connection["presets"]],
|
|
918
|
+
group="advanced",
|
|
919
|
+
),
|
|
835
920
|
"headers": FieldSchema(
|
|
836
921
|
name="headers", type=FieldType.KEY_VALUE_PAIRS, required=False,
|
|
837
922
|
description="i18n:fields.HTTPRequestNode.headers",
|
|
@@ -933,6 +1018,16 @@ class HTTPRequestNode(BaseNode):
|
|
|
933
1018
|
- 5xx 서버 에러, 네트워크 에러, 타임아웃 → Exception raise → 재시도
|
|
934
1019
|
- 4xx 클라이언트 에러 → 재시도 불가, 결과 반환
|
|
935
1020
|
"""
|
|
1021
|
+
if self.auth_required is True and not (
|
|
1022
|
+
getattr(self, "token", None)
|
|
1023
|
+
or (getattr(self, "header_name", None) and getattr(self, "header_value", None))
|
|
1024
|
+
or (getattr(self, "username", None) and getattr(self, "password", None))
|
|
1025
|
+
or (getattr(self, "param_name", None) and getattr(self, "param_value", None))
|
|
1026
|
+
):
|
|
1027
|
+
raise HTTPCredentialError(
|
|
1028
|
+
"CREDENTIAL_REQUIRED_TO_RUN: register and bind this HTTP node's API credential"
|
|
1029
|
+
)
|
|
1030
|
+
|
|
936
1031
|
import aiohttp
|
|
937
1032
|
import asyncio
|
|
938
1033
|
import json
|
|
@@ -986,41 +1081,49 @@ class HTTPRequestNode(BaseNode):
|
|
|
986
1081
|
else:
|
|
987
1082
|
request_kwargs["data"] = self.body
|
|
988
1083
|
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1084
|
+
# 호스트별 동시성 상한(기본 1) — 같은 호스트로 요청이 병렬로 몰리는 것을
|
|
1085
|
+
# 프로세스 전역에서 막는다(공유 IP 보호). 반복은 어차피 순차라 무영향.
|
|
1086
|
+
async with _host_semaphore(self.url):
|
|
1087
|
+
async with session.request(**request_kwargs) as resp:
|
|
1088
|
+
status_code = resp.status
|
|
1089
|
+
# Retry-After 는 응답 컨텍스트를 벗어나기 전에 읽어둔다.
|
|
1090
|
+
retry_after = _parse_retry_after(resp.headers.get("Retry-After"))
|
|
1091
|
+
|
|
1092
|
+
# 응답 파싱 (JSON 시도 → 실패하면 text)
|
|
1093
|
+
try:
|
|
1094
|
+
data = await resp.json()
|
|
1095
|
+
except Exception:
|
|
1096
|
+
data = await resp.text()
|
|
1097
|
+
|
|
1098
|
+
# 5xx 서버 에러 → Exception raise (RetryExecutor가 재시도)
|
|
1099
|
+
if status_code >= 500:
|
|
1100
|
+
error_msg = data if isinstance(data, str) else json.dumps(data, ensure_ascii=False)[:200]
|
|
1101
|
+
raise HTTPServerError(f"HTTP {status_code}: {error_msg}")
|
|
1102
|
+
|
|
1103
|
+
# 429 Rate Limit → Exception raise (RetryExecutor가 지수 백오프 재시도).
|
|
1104
|
+
# Retry-After 헤더가 있으면 그 대기 시간을 예외에 실어 존중하게 한다.
|
|
1105
|
+
if status_code == 429:
|
|
1106
|
+
raise HTTPRateLimitError(
|
|
1107
|
+
"HTTP 429: Rate limit exceeded", retry_after=retry_after
|
|
1108
|
+
)
|
|
1109
|
+
|
|
1110
|
+
# 4xx 클라이언트 에러 → 재시도 불가, 결과 반환
|
|
1111
|
+
if status_code >= 400:
|
|
1017
1112
|
return {
|
|
1018
1113
|
"response": data,
|
|
1019
1114
|
"status_code": status_code,
|
|
1020
|
-
"success":
|
|
1021
|
-
"error":
|
|
1115
|
+
"success": False,
|
|
1116
|
+
"error": f"HTTP {status_code}",
|
|
1022
1117
|
}
|
|
1023
1118
|
|
|
1119
|
+
# 성공 (2xx, 3xx)
|
|
1120
|
+
return {
|
|
1121
|
+
"response": data,
|
|
1122
|
+
"status_code": status_code,
|
|
1123
|
+
"success": True,
|
|
1124
|
+
"error": None,
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1024
1127
|
except aiohttp.ClientError as e:
|
|
1025
1128
|
# 네트워크 에러 → RetryExecutor가 재시도
|
|
1026
1129
|
raise HTTPNetworkError(f"Network error: {e}")
|
|
@@ -74,6 +74,22 @@ class ScheduleNode(BaseNode):
|
|
|
74
74
|
"investor named a fixed number of cycles (>= 1)."
|
|
75
75
|
),
|
|
76
76
|
)
|
|
77
|
+
# Owner concern 2026-09-28: many workflows sharing one cron instant (e.g.
|
|
78
|
+
# "0 9 * * *") all fire at the same second and stampede a shared cloud
|
|
79
|
+
# egress IP. jitter_seconds staggers the ACTUAL fire by a random 0..jitter
|
|
80
|
+
# seconds past each cron instant. 0 (default) = fire exactly on the instant
|
|
81
|
+
# (unchanged). Max 300s. dry_run never applies jitter (it emits one tick and
|
|
82
|
+
# exits), so it does not affect the build budget or replay semantics.
|
|
83
|
+
jitter_seconds: int = Field(
|
|
84
|
+
default=0,
|
|
85
|
+
ge=0,
|
|
86
|
+
le=300,
|
|
87
|
+
description=(
|
|
88
|
+
"Random 0..N second delay applied to each fire past the cron instant, "
|
|
89
|
+
"to spread many same-cron workflows across a shared egress IP. "
|
|
90
|
+
"0 (default) fires exactly on the cron instant; max 300."
|
|
91
|
+
),
|
|
92
|
+
)
|
|
77
93
|
|
|
78
94
|
_inputs: List[InputPort] = []
|
|
79
95
|
_outputs: List[OutputPort] = [
|
|
@@ -106,6 +122,7 @@ class ScheduleNode(BaseNode):
|
|
|
106
122
|
"Standard 5-field cron expression — minute / hour / day / month / weekday",
|
|
107
123
|
"Timezone-aware (IANA names) — 'America/New_York', 'Asia/Seoul', 'UTC'",
|
|
108
124
|
"max_duration_hours and count are optional bounds: omit both and the schedule runs until the workflow is stopped; set one and the scheduler exits cleanly at that limit",
|
|
125
|
+
"jitter_seconds (optional, 0..300, default 0) delays each fire by a random 0..N seconds past the cron instant, so many workflows sharing one cron ('0 9 * * *') do not all hit a shared egress IP at the same second; the cron cadence anchor is unchanged and the logical replay instant is not affected. Keep jitter well below the interval (do not set 300s jitter on an every-minute cron).",
|
|
109
126
|
"enabled=False freezes the trigger without removing the node from the DAG",
|
|
110
127
|
"A tick re-executes the whole main flow: the ScheduleNode returns {trigger: true} without re-registering, so every node downstream of it runs again on each tick",
|
|
111
128
|
"Startup account and open-order snapshots are not retained across schedule ticks; they are retained only across realtime events",
|
|
@@ -189,7 +206,7 @@ class ScheduleNode(BaseNode):
|
|
|
189
206
|
},
|
|
190
207
|
]
|
|
191
208
|
_node_guide: ClassVar[Dict[str, Any]] = {
|
|
192
|
-
"input_handling": "No data inputs. All behavior is configured via `cron`, `timezone`, `enabled`,
|
|
209
|
+
"input_handling": "No data inputs. All behavior is configured via `cron`, `timezone`, `enabled`, the optional bounds `max_duration_hours` / `count` (omit both to run until the workflow is stopped), and the optional `jitter_seconds` (0..300, default 0) which staggers each fire a random 0..N seconds past the cron instant to avoid a shared-IP stampede.",
|
|
193
210
|
"output_consumption": "`trigger` output carries `{fired_at, cycle_index}`. Downstream nodes usually just need an incoming edge; explicit binding is optional.",
|
|
194
211
|
"common_combinations": [
|
|
195
212
|
"StartNode → ScheduleNode → trading body (plain cron workflow)",
|
|
@@ -203,9 +220,11 @@ class ScheduleNode(BaseNode):
|
|
|
203
220
|
],
|
|
204
221
|
}
|
|
205
222
|
|
|
206
|
-
_version: ClassVar[str] = "1.
|
|
207
|
-
_updated_at: ClassVar[str] = "2026-
|
|
208
|
-
_change_note: ClassVar[Optional[str]] =
|
|
223
|
+
_version: ClassVar[str] = "1.1.0"
|
|
224
|
+
_updated_at: ClassVar[str] = "2026-09-28"
|
|
225
|
+
_change_note: ClassVar[Optional[str]] = (
|
|
226
|
+
"Added optional jitter_seconds (0..300) to stagger fires past the cron instant"
|
|
227
|
+
)
|
|
209
228
|
|
|
210
229
|
@classmethod
|
|
211
230
|
def get_field_schema(cls) -> Dict[str, "FieldSchema"]:
|
|
@@ -271,6 +290,18 @@ class ScheduleNode(BaseNode):
|
|
|
271
290
|
expected_type="int",
|
|
272
291
|
example=1000,
|
|
273
292
|
),
|
|
293
|
+
"jitter_seconds": FieldSchema(
|
|
294
|
+
name="jitter_seconds",
|
|
295
|
+
type=FieldType.INTEGER,
|
|
296
|
+
description="Random 0..N second delay applied to each fire past the cron instant, to spread many same-cron workflows across a shared egress IP. 0 (default) fires exactly on the cron instant; max 300.",
|
|
297
|
+
default=0,
|
|
298
|
+
min_value=0,
|
|
299
|
+
max_value=300,
|
|
300
|
+
expression_mode=ExpressionMode.FIXED_ONLY,
|
|
301
|
+
category=FieldCategory.SETTINGS,
|
|
302
|
+
expected_type="int",
|
|
303
|
+
example=30,
|
|
304
|
+
),
|
|
274
305
|
}
|
|
275
306
|
|
|
276
307
|
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/registry/node_registry.py
RENAMED
|
@@ -129,7 +129,10 @@ class NodeTypeSchema(BaseModel):
|
|
|
129
129
|
"when the node config marks the API authenticated), label_ko, "
|
|
130
130
|
"label_en (plain user-facing names), presets (HTTPRequestNode only; "
|
|
131
131
|
"[] elsewhere), missing (draft = may be saved without it and the run "
|
|
132
|
-
"gate blocks; block = must exist before build
|
|
132
|
+
"gate blocks; block = must exist before build; ask = the chatbot "
|
|
133
|
+
"must ask the user to register the credential and only drafts "
|
|
134
|
+
"without it when the user explicitly says to proceed without it — "
|
|
135
|
+
"owner decision 2026-09-28 for AI-model keys and the Telegram bot)."
|
|
133
136
|
),
|
|
134
137
|
)
|
|
135
138
|
|
|
@@ -496,7 +499,7 @@ class NodeTypeRegistry:
|
|
|
496
499
|
_CONNECTION_PURPOSES = ("trading", "data", "ai", "notify")
|
|
497
500
|
_CONNECTION_NEEDS = ("run", "validate", "optional")
|
|
498
501
|
_CONNECTION_WHENS = ("always", "auth_required", "never")
|
|
499
|
-
_CONNECTION_MISSINGS = ("draft", "block")
|
|
502
|
+
_CONNECTION_MISSINGS = ("draft", "block", "ask")
|
|
500
503
|
|
|
501
504
|
def _build_connection(
|
|
502
505
|
self, node_class: Type[BaseNode], config_schema: Dict[str, Any],
|
|
@@ -34,6 +34,10 @@ if TYPE_CHECKING:
|
|
|
34
34
|
|
|
35
35
|
logger = logging.getLogger("programgarden.retry_executor")
|
|
36
36
|
|
|
37
|
+
# Validation has a bounded wait budget. If Retry-After exceeds it, stop this
|
|
38
|
+
# retry sequence and preserve the failure instead of contacting the API early.
|
|
39
|
+
DRY_RUN_MAX_RETRY_WAIT_SEC: float = 5.0
|
|
40
|
+
|
|
37
41
|
|
|
38
42
|
class RetryExecutor:
|
|
39
43
|
"""
|
|
@@ -106,12 +110,23 @@ class RetryExecutor:
|
|
|
106
110
|
)
|
|
107
111
|
break
|
|
108
112
|
|
|
109
|
-
#
|
|
110
|
-
|
|
113
|
+
# A server-directed wait is a floor, never a delay to truncate.
|
|
114
|
+
retry_after = getattr(e, "retry_after", None)
|
|
115
|
+
dry_run = bool(getattr(context, "is_dry_run", False))
|
|
116
|
+
delay = self._calculate_delay(
|
|
117
|
+
config.retry, attempt, retry_after=retry_after, dry_run=dry_run
|
|
118
|
+
)
|
|
119
|
+
if delay is None:
|
|
120
|
+
logger.warning(
|
|
121
|
+
"[%s] Retry-After exceeds the retry wait budget; no further attempt",
|
|
122
|
+
node.id,
|
|
123
|
+
)
|
|
124
|
+
break
|
|
111
125
|
|
|
112
126
|
logger.info(
|
|
113
127
|
f"[{node.id}] {error_type.value} 발생, "
|
|
114
128
|
f"재시도 {attempt}/{config.retry.max_retries}... {delay:.1f}초 후"
|
|
129
|
+
+ (f" (Retry-After={retry_after:.0f}s)" if retry_after else "")
|
|
115
130
|
)
|
|
116
131
|
|
|
117
132
|
# 재시도 이벤트 발송 (context.notify_retry 사용)
|
|
@@ -143,13 +158,14 @@ class RetryExecutor:
|
|
|
143
158
|
category=NotificationCategory.RETRY_EXHAUSTED,
|
|
144
159
|
severity=NotificationSeverity.WARNING,
|
|
145
160
|
title=f"Retry exhausted: {node.id}",
|
|
146
|
-
message=f"{node.__class__.__name__} failed after {
|
|
161
|
+
message=f"{node.__class__.__name__} failed after {attempt} attempts: {last_error}",
|
|
147
162
|
node_id=node.id,
|
|
148
163
|
node_type=node.__class__.__name__,
|
|
149
164
|
data={
|
|
150
165
|
"node_id": node.id,
|
|
151
166
|
"node_type": node.__class__.__name__,
|
|
152
167
|
"max_retries": config.retry.max_retries,
|
|
168
|
+
"attempts": attempt,
|
|
153
169
|
"last_error": str(last_error),
|
|
154
170
|
},
|
|
155
171
|
)
|
|
@@ -159,16 +175,19 @@ class RetryExecutor:
|
|
|
159
175
|
# Fallback 처리
|
|
160
176
|
return self._handle_fallback(node, last_error, config.fallback)
|
|
161
177
|
|
|
162
|
-
def _calculate_delay(
|
|
178
|
+
def _calculate_delay(
|
|
179
|
+
self,
|
|
180
|
+
config: RetryConfig,
|
|
181
|
+
attempt: int,
|
|
182
|
+
retry_after: Optional[float] = None,
|
|
183
|
+
dry_run: bool = False,
|
|
184
|
+
) -> Optional[float]:
|
|
163
185
|
"""
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
Args:
|
|
167
|
-
config: RetryConfig
|
|
168
|
-
attempt: 현재 시도 횟수 (1부터 시작)
|
|
186
|
+
Return bounded exponential backoff with the server's minimum wait.
|
|
169
187
|
|
|
170
|
-
|
|
171
|
-
|
|
188
|
+
None means Retry-After cannot fit the configured wait budget: callers
|
|
189
|
+
must end the retry sequence, retaining the original failure. Ordinary
|
|
190
|
+
backoff remains capped when no server-directed floor prevents a retry.
|
|
172
191
|
"""
|
|
173
192
|
if config.exponential_backoff:
|
|
174
193
|
# 2^(attempt-1) * base_delay
|
|
@@ -183,8 +202,10 @@ class RetryExecutor:
|
|
|
183
202
|
jitter = delay * 0.25 * (random.random() * 2 - 1)
|
|
184
203
|
delay = delay + jitter
|
|
185
204
|
|
|
186
|
-
|
|
187
|
-
|
|
205
|
+
limit = min(config.max_delay, DRY_RUN_MAX_RETRY_WAIT_SEC) if dry_run else config.max_delay
|
|
206
|
+
if retry_after is not None and retry_after > limit:
|
|
207
|
+
return None
|
|
208
|
+
return max(min(delay, limit), retry_after or 0.0)
|
|
188
209
|
|
|
189
210
|
def _handle_fallback(
|
|
190
211
|
self,
|
|
@@ -5,7 +5,7 @@ authors = [
|
|
|
5
5
|
homepage = "https://programgarden.com"
|
|
6
6
|
requires-python = ">=3.12"
|
|
7
7
|
name = "programgarden-core"
|
|
8
|
-
version = "2.5.
|
|
8
|
+
version = "2.5.1"
|
|
9
9
|
license = "AGPL-3.0-or-later"
|
|
10
10
|
description = "ProgramGarden Core - 노드 기반 DSL 핵심 타입 정의"
|
|
11
11
|
readme = "README.md"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/bases/components.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/expression/__init__.py
RENAMED
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/expression/evaluator.py
RENAMED
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/expression/reference.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/i18n/locales/en.json
RENAMED
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/i18n/locales/ko.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/models/connection_rule.py
RENAMED
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/models/credential.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/models/field_binding.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/models/order_diagnostics.py
RENAMED
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/models/plugin_resource.py
RENAMED
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/models/resilience.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/models/validation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/account_futures.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/account_stock.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/backtest_futures.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/backtest_stock.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/calculation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/data_futures.py
RENAMED
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/data_korea_stock.py
RENAMED
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/data_stock.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/fundamental_stock.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/open_orders_stock.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/realtime_futures.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/realtime_stock.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/session_gate.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/symbol_futures.py
RENAMED
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/symbol_korea_stock.py
RENAMED
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/nodes/symbol_stock.py
RENAMED
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/presets/__init__.py
RENAMED
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/presets/news_analyst.json
RENAMED
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/presets/risk_manager.json
RENAMED
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/presets/strategist.json
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/registry/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden_core-2.5.0 → programgarden_core-2.5.1}/programgarden_core/registry/plugin_registry.py
RENAMED
|
File without changes
|