Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion scripts/ci/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,10 @@
"pytest_args": "--suites-only",
"cover": False,
"triggered_by": ["connector-tests-harness"],
"paths": ["src/ingestion/connectors/task-tracking/jira"],
"paths": [
"src/ingestion/connectors/task-tracking/jira",
"src/ingestion/connectors/ai/claude-admin",
],
},
]

Expand Down
2 changes: 1 addition & 1 deletion src/ingestion/connectors/ai/claude-admin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ Silver-level `silver:class_*` tags will be added in a separate PR; this connecto

## Operational Constraints

- **Rate limits**: organization-level, enforced by the Anthropic Admin API. The connector follows `Retry-After` on HTTP 429 and retries transient 5xx with exponential backoff.
- **Rate limits**: organization-level, enforced by the Anthropic Admin API. Every stream's requester carries a `CompositeErrorHandler` that honors `Retry-After` on HTTP 429 and retries transient 5xx (500/502/503/504) with exponential backoff, and the connector sets `concurrency_level: 1` so streams read serially rather than firing in parallel. Without this governor, parallel per-day requests across all streams saturated the org-wide limit and produced a 429 storm that starved `messages_usage`/`cost_report` to 0 rows (#1902).
- **31-day window**: usage/cost endpoints cap date ranges at 31 days per request. The connector steps at `P1D` (one day per request) to avoid boundary-day loss caused by Airbyte's inclusive-inclusive cursor arithmetic.
- **`cursor_granularity: PT1S`**: applied on incremental streams to prevent empty date-boundary windows (`starting_at == ending_at`) that the API rejects with HTTP 400. See the historical `claude-api` ADRs for background.
- **No 3-day reporting lag**: unlike the Enterprise Analytics API (`claude-enterprise`), the Admin API makes day `D` data queryable the same day it is aggregated. This connector's default `start_date` is 90 days ago (not 14).
Expand Down
69 changes: 68 additions & 1 deletion src/ingestion/connectors/ai/claude-admin/connector.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
version: "7.0.4"
version: "6.60.9"
type: DeclarativeSource

check:
Expand All @@ -12,6 +12,47 @@ definitions:
api_token: "{{ config['admin_api_key'] }}"
header: x-api-key

# Top-level (NOT under a `linked` block — the Builder strips linked error
# handlers). $ref'd into every stream's requester so the whole connector
# honors the org-wide Anthropic Admin API rate limit instead of self-
# inflicting a 429 storm. Mirrors the sibling claude-enterprise handler.
retryable_error_handler:
type: CompositeErrorHandler
error_handlers:
- type: DefaultErrorHandler
response_filters:
- type: HttpResponseFilter
action: RATE_LIMITED
http_codes:
- 429
backoff_strategies:
- type: WaitTimeFromHeader
header: Retry-After
- type: DefaultErrorHandler
max_retries: 5
response_filters:
- type: HttpResponseFilter
action: RETRY
http_codes:
- 503
- 500
- 502
- 504
backoff_strategies:
- type: ExponentialBackoffStrategy
factor: 5
- type: DefaultErrorHandler
response_filters:
- type: HttpResponseFilter
action: FAIL
http_codes:
- 401
- 404
error_message: >-
Authentication or scope error: verify the admin_api_key is a valid
Anthropic Admin API key with organization-level read scope (created
at console.anthropic.com by an organization admin).

anthropic_headers:
anthropic-version: "2023-06-01"
Content-Type: application/json
Expand Down Expand Up @@ -122,6 +163,8 @@ streams:
$ref: "#/definitions/anthropic_headers"
authenticator:
$ref: "#/definitions/api_key_authenticator"
error_handler:
$ref: "#/definitions/retryable_error_handler"
request_parameters:
limit: "100"
record_selector:
Expand Down Expand Up @@ -209,6 +252,8 @@ streams:
$ref: "#/definitions/anthropic_headers"
authenticator:
$ref: "#/definitions/api_key_authenticator"
error_handler:
$ref: "#/definitions/retryable_error_handler"
request_parameters:
bucket_width: "1d"
record_selector:
Expand Down Expand Up @@ -333,6 +378,8 @@ streams:
$ref: "#/definitions/anthropic_headers"
authenticator:
$ref: "#/definitions/api_key_authenticator"
error_handler:
$ref: "#/definitions/retryable_error_handler"
request_parameters:
bucket_width: "1d"
record_selector:
Expand Down Expand Up @@ -457,6 +504,8 @@ streams:
$ref: "#/definitions/anthropic_headers"
authenticator:
$ref: "#/definitions/api_key_authenticator"
error_handler:
$ref: "#/definitions/retryable_error_handler"
record_selector:
type: RecordSelector
extractor:
Expand Down Expand Up @@ -571,6 +620,8 @@ streams:
$ref: "#/definitions/anthropic_headers"
authenticator:
$ref: "#/definitions/api_key_authenticator"
error_handler:
$ref: "#/definitions/retryable_error_handler"
record_selector:
type: RecordSelector
extractor:
Expand Down Expand Up @@ -641,6 +692,8 @@ streams:
$ref: "#/definitions/anthropic_headers"
authenticator:
$ref: "#/definitions/api_key_authenticator"
error_handler:
$ref: "#/definitions/retryable_error_handler"
record_selector:
type: RecordSelector
extractor:
Expand Down Expand Up @@ -698,6 +751,8 @@ streams:
$ref: "#/definitions/anthropic_headers"
authenticator:
$ref: "#/definitions/api_key_authenticator"
error_handler:
$ref: "#/definitions/retryable_error_handler"
request_parameters:
limit: "100"
record_selector:
Expand Down Expand Up @@ -738,6 +793,8 @@ streams:
$ref: "#/definitions/anthropic_headers"
authenticator:
$ref: "#/definitions/api_key_authenticator"
error_handler:
$ref: "#/definitions/retryable_error_handler"
record_selector:
type: RecordSelector
extractor:
Expand Down Expand Up @@ -807,6 +864,8 @@ streams:
$ref: "#/definitions/anthropic_headers"
authenticator:
$ref: "#/definitions/api_key_authenticator"
error_handler:
$ref: "#/definitions/retryable_error_handler"
record_selector:
type: RecordSelector
extractor:
Expand All @@ -818,6 +877,14 @@ streams:
transformations:
- $ref: "#/definitions/tenant_id_injection"

# default_concurrency: 1 serializes the streams so the connector reads against
# the org-wide Anthropic Admin API rate limit one stream at a time instead of
# firing all streams in parallel (which produced a 429 storm — see #1902).
# Combined with the Retry-After error handler above; matches claude-enterprise.
concurrency_level:
type: ConcurrencyLevel
default_concurrency: 1

spec:
type: Spec
connection_specification:
Expand Down
2 changes: 1 addition & 1 deletion src/ingestion/connectors/ai/claude-admin/descriptor.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: claude-admin
version: "2026.05.04"
version: "2026.07.25"
schedule: '0 2 * * *'
dbt_select: 'tag:claude-admin+'
workflow: sync
Expand Down
22 changes: 22 additions & 0 deletions src/ingestion/connectors/ai/claude-admin/tests/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Claude Admin connector test config builder."""

from __future__ import annotations

from connector_tests import ConfigBuilder

API_BASE = "https://api.anthropic.com"


class ClaudeAdminConfigBuilder(ConfigBuilder):
def __init__(self) -> None:
super().__init__()
self._config.update(
{
"admin_api_key": "test-admin-key",
# Full ISO 8601 with time — the messages_usage and cost_report
# cursors parse strictly and reject bare YYYY-MM-DD. With the clock
# frozen at 2026-04-27 and step P1D this yields a small, fixed set
# of one-day slices from 2026-04-24.
"start_date": "2026-04-24T00:00:00Z",
}
)
7 changes: 7 additions & 0 deletions src/ingestion/connectors/ai/claude-admin/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import sys
from pathlib import Path

# Local builder modules (config.py) are importable under --import-mode=importlib.
sys.path.insert(0, str(Path(__file__).parent))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Weird claude fix, it usually put the same string if fails to properly run code


from connector_tests.plugin import * # noqa: E402,F401,F403 — http_mocker fixture + hooks
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"id": "user_01TESTAAAAAAAAAAAAAAAAAA",
"type": "user",
"email": "member@example.com",
"name": "Test Member",
"role": "developer",
"status": "active",
"added_at": "2026-01-01T00:00:00Z",
"last_active_at": "2026-04-24T12:00:00Z"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Rate-limit / error-handler tests for the claude-admin connector (#1902).

Every stream's requester references the shared `retryable_error_handler`
(CompositeErrorHandler): RATE_LIMITED on 429 with WaitTimeFromHeader
`Retry-After`, RETRY on 5xx with exponential backoff, FAIL on 401/404. The
connector also sets `concurrency_level: 1` so streams read serially against the
org-wide Admin API limit instead of firing in parallel (the #1902 429 storm).

These tests exercise the error handler on the `claude_admin_users` stream
(simple full-refresh, `data` extractor, after_id pagination). A list of
responses on one matcher is served consecutively, so [429, 200] proves the
429 is retried and the read recovers without record loss.

Coverage matrix rows: error_retry (429 -> recover), error_retry (503 -> recover),
error_fail (401 surfaces as an error, no partial records).
"""

from __future__ import annotations

import json

from config import API_BASE, ClaudeAdminConfigBuilder

from connector_tests import (
ANY_QUERY_PARAMS,
HttpMocker,
HttpRequest,
HttpResponse,
load_fixture,
read_stream,
)

_STREAM = "claude_admin_users"
_CONNECTOR = "ai/claude-admin"
_URL = f"{API_BASE}/v1/organizations/users"


def _page(users: list[dict]) -> HttpResponse:
# has_more=false stops the after_id paginator after one page.
return HttpResponse(
body=json.dumps({"data": users, "has_more": False, "last_id": None}),
status_code=200,
)


def _rate_limited() -> HttpResponse:
# Retry-After: 0 keeps the test fast; WaitTimeFromHeader reads this header.
return HttpResponse(
body=json.dumps({"type": "error", "error": {"type": "rate_limit_error", "message": "Too many requests"}}),
status_code=429,
headers={"Retry-After": "0"},
)


def _server_error() -> HttpResponse:
return HttpResponse(
body=json.dumps({"type": "error", "error": {"type": "api_error", "message": "temporary"}}),
status_code=503,
)


def test_error_retry_429_then_recovers(http_mocker: HttpMocker) -> None:
"""A 429 with Retry-After is retried per the handler; the read succeeds
once the source recovers, with no record loss and no ERROR log."""
config = ClaudeAdminConfigBuilder().build()
http_mocker.get(
HttpRequest(_URL, query_params=ANY_QUERY_PARAMS),
[_rate_limited(), _page([load_fixture(__file__, "user.json")])],
)

output = read_stream(_CONNECTOR, _STREAM, config)

assert len(output.records) == 1
assert not output.errors
assert output.records[0].record.data["email"] == "member@example.com"


def test_error_retry_503_then_recovers(http_mocker: HttpMocker) -> None:
"""Transient 5xx is retried with exponential backoff and the read recovers."""
config = ClaudeAdminConfigBuilder().build()
http_mocker.get(
HttpRequest(_URL, query_params=ANY_QUERY_PARAMS),
[_server_error(), _page([load_fixture(__file__, "user.json")])],
)

output = read_stream(_CONNECTOR, _STREAM, config)

assert len(output.records) == 1
assert not output.errors


def test_error_fail_401_surfaces_error(http_mocker: HttpMocker) -> None:
"""401 hits the FAIL branch: the read surfaces an error and emits no
partial records (rather than silently succeeding with 0 rows)."""
config = ClaudeAdminConfigBuilder().build()
http_mocker.get(
HttpRequest(_URL, query_params=ANY_QUERY_PARAMS),
HttpResponse(
body=json.dumps({"type": "error", "error": {"type": "authentication_error", "message": "invalid key"}}),
status_code=401,
),
)

output = read_stream(_CONNECTOR, _STREAM, config, expecting_exception=True)

assert output.errors, "a 401 must surface as a stream error, not a silent 0-row success"
assert len(output.records) == 0
Comment on lines +92 to +107

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate relevant files"
fd -a 'test_claude_admin_reliability.py|.*claude.*admin.*reliability.*|.*claude.*admin.*\.(py)$' . | sed 's#^\./##' | head -100

echo
echo "Inspect target test file around 60-130"
tgt=$(fd 'test_claude_admin_reliability.py' . | head -1)
echo "target=$tgt"
wc -l "$tgt"
sed -n '1,150p' "$tgt" | cat -n

echo
echo "Search output.errors uses for ClaudeAdmin"
rg -n "read_stream|errors\\[|assert output\.errors|authentication_error|FAILED|FAIL" .

Repository: constructorfabric/insight

Length of output: 34691


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Inspect read_stream implementation and output data shape"
wc -l src/ingestion/tests/connectors/connector_tests/source.py
sed -n '1,180p' src/ingestion/tests/connectors/connector_tests/source.py | cat -n

echo
echo "Search connector error handler / response status / failing error message references"
rg -n "CompositeErrorHandler|RetryableErrorHandler|response_action|authentication_error|status_code|FAIL|externalMessage|failureType|errors" src/ingestion/connectors/ai/claude-admin src/ingestion/connectors/ai/claude-enterprise

Repository: constructorfabric/insight

Length of output: 6934


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
try:
    from airbyte_cdk.models import EntrypointOutput
    import inspect
    print("EntrypointOutput fields:")
    for name in dir(EntrypointOutput):
        if not name.startswith("_"):
            print(f"  {name}")
    print("\n__annotations__ / __fields__:")
    print(getattr(EntrypointOutput, "__annotations__", None))
    print(inspect.signature(EntrypointOutput))
    print(inspect.getfile(EntrypointOutput))
    print("\nDocstring:")
    print(EntrypointOutput.__doc__)

    # Inspect the module without importing/requiring a real Connector, if possible.
    import json
    import pathlib
    import yaml
    mod = __import__(EntrypointOutput.__module__, fromlist=["_airbyte_cdk"])
    file_ = inspect.getfile(mod)
    print(f"\nModule file: {file_}")
    txt = pathlib.Path(file_).read_text(errors="replace")
    for i, line in enumerate(txt.splitlines(), 1):
        if any(s in line for s in ["class EntrypointOutput", "errors", "externalMessage", "failureType"]):
            print(f"{i}: {line}")
    entry = None
    for i, line in enumerate(txt.splitlines(), 1):
        if "class EntrypointOutput" in line:
            entry = i
    if entry:
        print(f"\nFragment around EntrypointOutput:")
        lines = txt.splitlines()
        start = max(1, entry-3)
        end = min(len(lines), entry+120)
        for j in range(start, end+1):
            print(f"{j}: {lines[j-1]}")
except Exception as exc:
    print(type(exc).__name__, ":", exc)
    import traceback
    traceback.print_exc()
PY

Repository: constructorfabric/insight

Length of output: 339


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Search EntrypointOutput definitions in installed tools/env if present"
python3 - <<'PY'
import distutils.sysconfig, site, sysconfig, pathlib, os
for p in site.getsitepackages() + [distutils.sysconfig.get_python_lib(), pathlib.Path(sysconfig.get_path("purelib")), pathlib.Path(sysconfig.get_path("platlib"))]:
    print(p)
PY

echo
echo "Search for Airbyte entrypoint output structure in test logs/fixtures/helper references"
rg -n "EntrypointOutput|output\.errors\[0\]|errors=" src/ingestion tests scripts packages 2>/dev/null | head -200

echo
echo "Inspect claude-admin manifest error handler and auth setup"
sed -n '1,140p' src/ingestion/connectors/ai/claude-admin/connector.yaml | cat -n
sed -n '1,140p' src/ingestion/connectors/ai/claude-admin/tests/test_claude_admin_reliability.py | cat -n

Repository: constructorfabric/insight

Length of output: 1710


Make the 401 assertion specific.

assert output.errors allows any connector-level failure to pass, not necessarily the 401 authentication FAIL path. Assert the stable authentication/status detail from output.errors and keep the zero-record assertion.

🧰 Tools
🪛 ast-grep (0.44.1)

[info] 97-100: use JsonResponse instead of HttpResponse to send JSON data
Context: HttpResponse(
body=json.dumps({"type": "error", "error": {"type": "authentication_error", "message": "invalid key"}}),
status_code=401,
)
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').

(http-response-with-json-dumps)


[info] 98-98: use jsonify instead of json.dumps for JSON output
Context: json.dumps({"type": "error", "error": {"type": "authentication_error", "message": "invalid key"}})
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)


[error] 97-100: Lack of sanitization of user data
Context: HttpResponse(
body=json.dumps({"type": "error", "error": {"type": "authentication_error", "message": "invalid key"}}),
status_code=401,
)
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').

(http-response-from-request)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/ingestion/connectors/ai/claude-admin/tests/test_claude_admin_reliability.py`
around lines 92 - 107, Update test_error_fail_401_surfaces_error to assert that
output.errors contains the expected stable 401 authentication/status detail,
rather than only checking that errors is non-empty. Retain the assertion that
output.records has length zero.

Loading