diff --git a/README.md b/README.md index a4f188f46..a862707b5 100644 --- a/README.md +++ b/README.md @@ -513,7 +513,7 @@ These are only needed for **headless / CI extraction** (`graphify extract`). Whe | `AWS_*` / `~/.aws/credentials` | AWS Bedrock — standard credential chain | `--backend bedrock` (no API key, uses IAM) | | `GRAPHIFY_MAX_WORKERS` | AST parallelism thread count | optional — also `--max-workers` flag | | `GRAPHIFY_MAX_OUTPUT_TOKENS` | Raise output cap for dense corpora | optional — e.g. `32768` for large files | -| `GRAPHIFY_API_TIMEOUT` | Per-call timeout in seconds for HTTP, claude-cli, and Anthropic SDK backends (default: 600) | optional — also `--api-timeout` flag | +| `GRAPHIFY_API_TIMEOUT` | Per-call timeout in seconds for HTTP, claude-cli, Anthropic SDK, and Bedrock backends (default: 600) | optional — also `--api-timeout` flag | | `GRAPHIFY_MAX_RETRIES` | How many times to retry a rate-limited (429) request before giving up (default: 6; honors `Retry-After`) | optional — raise for strict per-org limits (e.g. kimi); `0` disables | | `GRAPHIFY_FORCE` | Force graph rebuild even with fewer nodes | optional — also `--force` flag | | `GRAPHIFY_GOOGLE_WORKSPACE` | Auto-enable Google Workspace export | optional — set to `1` | diff --git a/graphify/llm.py b/graphify/llm.py index 8c34cde6b..ba26ebcb1 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -1609,6 +1609,7 @@ def _call_bedrock(model: str, user_message: str, max_tokens: int = 8192, *, deep """Call AWS Bedrock via boto3 Converse API using the standard AWS credential chain.""" try: import boto3 + import botocore.config import botocore.exceptions except ImportError as exc: raise ImportError( @@ -1618,7 +1619,19 @@ def _call_bedrock(model: str, user_message: str, max_tokens: int = 8192, *, deep region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") or "us-east-1" profile = os.environ.get("AWS_PROFILE") session = boto3.Session(profile_name=profile, region_name=region) - client = session.client("bedrock-runtime") + # Wire GRAPHIFY_API_TIMEOUT into the botocore read timeout. Without an + # explicit config, Converse uses botocore's 60s default and a long + # generation dies with "Read timeout on endpoint URL" no matter what the + # env var / --api-timeout is set to — the same gap #1112/#1442 closed for + # the claude-cli and secondary-dispatch paths, on the last cloud backend. + client = session.client( + "bedrock-runtime", + config=botocore.config.Config( + read_timeout=_resolve_api_timeout(), + connect_timeout=10, + retries={"max_attempts": _resolve_max_retries(), "mode": "adaptive"}, + ), + ) try: resp = client.converse( @@ -2565,12 +2578,20 @@ def _rec(inp, out) -> None: if backend == "bedrock": try: import boto3 + import botocore.config except ImportError as exc: raise ImportError(_backend_pkg_hint("boto3", "bedrock")) from exc region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") or "us-east-1" profile = os.environ.get("AWS_PROFILE") session = boto3.Session(profile_name=profile, region_name=region) - client = session.client("bedrock-runtime") + client = session.client( + "bedrock-runtime", + config=botocore.config.Config( + read_timeout=_resolve_api_timeout(), + connect_timeout=10, + retries={"max_attempts": _resolve_max_retries(), "mode": "adaptive"}, + ), + ) resp = client.converse( modelId=mdl, messages=[{"role": "user", "content": [{"text": prompt}]}], diff --git a/tests/test_image_vision.py b/tests/test_image_vision.py index c73b1141e..f9c733a70 100644 --- a/tests/test_image_vision.py +++ b/tests/test_image_vision.py @@ -279,15 +279,37 @@ def converse(self, **kw): "usage": {"inputTokens": 1, "outputTokens": 2}, "stopReason": "end_turn", } + + def _make_client(svc, **kw): + # Record the client-construction kwargs (notably config=) separately so + # they don't collide with the converse() call kwargs captured above. + captured["_client_service"] = svc + captured["_client_config"] = kw.get("config") + return _Client() + boto3 = types.ModuleType("boto3") - boto3.Session = lambda **kw: SimpleNamespace(client=lambda svc: _Client()) + boto3.Session = lambda **kw: SimpleNamespace(client=_make_client) monkeypatch.setitem(sys.modules, "boto3", boto3) + botocore = types.ModuleType("botocore") exc = types.ModuleType("botocore.exceptions") exc.ClientError = type("ClientError", (Exception,), {}) + config_mod = types.ModuleType("botocore.config") + + class _Config: + def __init__(self, **kw): + # Mirror botocore.config.Config: expose the kwargs as attributes so + # tests can assert read_timeout/connect_timeout/retries were wired. + self.read_timeout = kw.get("read_timeout") + self.connect_timeout = kw.get("connect_timeout") + self.retries = kw.get("retries") + + config_mod.Config = _Config botocore.exceptions = exc + botocore.config = config_mod monkeypatch.setitem(sys.modules, "botocore", botocore) monkeypatch.setitem(sys.modules, "botocore.exceptions", exc) + monkeypatch.setitem(sys.modules, "botocore.config", config_mod) # ── backend payload shape (mocked) ──────────────────────────────────────────── @@ -330,6 +352,32 @@ def test_call_bedrock_sends_raw_image_bytes(tmp_path, monkeypatch): assert img_block["image"]["source"]["bytes"] == _PNG_BYTES +def test_call_bedrock_honors_api_timeout(monkeypatch): + # GRAPHIFY_API_TIMEOUT must reach the botocore client's read_timeout; else + # Converse falls back to botocore's 60s default and a long generation dies + # with "Read timeout on endpoint URL" regardless of the env var. + monkeypatch.setenv("GRAPHIFY_API_TIMEOUT", "1800") + monkeypatch.delenv("GRAPHIFY_MAX_RETRIES", raising=False) + captured: dict = {} + _fake_boto3(monkeypatch, captured) + llm._call_bedrock("model", "CORPUS") + cfg = captured["_client_config"] + assert cfg is not None, "bedrock client built without a botocore config" + assert cfg.read_timeout == 1800.0 + assert cfg.connect_timeout == 10 + assert cfg.retries == {"max_attempts": 6, "mode": "adaptive"} + + +def test_call_bedrock_api_timeout_defaults_when_unset(monkeypatch): + # With no override the client still gets an explicit 600s read timeout, + # not botocore's silent 60s default. + monkeypatch.delenv("GRAPHIFY_API_TIMEOUT", raising=False) + captured: dict = {} + _fake_boto3(monkeypatch, captured) + llm._call_bedrock("model", "CORPUS") + assert captured["_client_config"].read_timeout == 600.0 + + # ── CLI backends (mocked subprocess) ──────────────────────────────────────────