feat(registry): implement RegistryError hierarchy with typed exceptions #43

Merged
CoreRasurae merged 1 commits from feature/m1-registry-exceptions into master 2026-06-11 20:23:43 +00:00
12 changed files with 1487 additions and 126 deletions
+4
View File
@@ -11,10 +11,14 @@ and this project adheres to [Clever Semantic Versioning](https://www.w3.org/subm
- **Registry Resolution Bugfixes (PR Review rui.hu)**: Fixed `TemplateRegistry._instantiate_from_registry_ref` hardcoding `package_type="template"` instead of threading the template type, which broke all non-template registry resolutions. `_try_parse_registry_ref` now filters to `ReferenceType.REGISTRY` only, preserving the "local templates unaffected" acceptance criterion. `_original_reference` in resolved results now stores the verbatim original reference string, not the cache-key with type suffix. `application.py` template-type mapping extended to cover all 8 types (previously only AGENT/GRAPH/STREAM — the other 5 silently misclassified as STREAM). Duplicated `_instantiate_from_registry_ref`/`_try_parse_registry_ref` logic extracted into shared `_resolve_registry_ref` helper in `base.py`. `RegistryClient` now URL-encodes namespace/name path components to prevent injection. HTTPS enforcement with `allow_insecure` flag per spec §12.2. `ReferenceResolver.close_all()` closes clients under the async lock to prevent concurrent client leak. Async lock lazily initialised to avoid deprecation warning. Added `plural_name` property to `TemplateType`.
- **Registry Error Hierarchy Fixes (PR Review rui.hu Round 5)**: Fixed `details` dropping in non-5xx fallback path of `_request()``details` extracted from structured error bodies are now propagated to `RegistryError` for all unrecognised error types. Added `asyncio.Lock` to `RegistryClient._get_client()` to match the project-standardised check-and-create pattern used by `ReferenceResolver._get_client()`. Fixed a no-op BDD scenario ("404 without error type in body falls back to PackageNotFoundError") that had no `Then` step. Fixed HTTPS logging step definitions that constructed a new `RegistryClient` internally, defeating test isolation. Enhanced the "Client with API key" scenario to verify the `Authorization` header is present. Added end-to-end BDD scenario for `details` propagation from structured error bodies. Added `quote(package_id)` URL-encoding to `get_package()`. Replaced misleading `if exc.request is not None:` with `if getattr(exc, "_request", None) is not None:`. Simplified `RegistryNetworkError.__str__` using parts list + join. Replaced deprecated `asyncio.get_event_loop().run_until_complete()` with `asyncio.run()`. Tightened `_CLASS_MAP` type annotation to `dict[str, type[CleverAgentsException]]`. Added comment to `typing.cast(int, code)` explaining intentional non-int test.
- **Registry Reference Resolution**: Fixed `ReferenceResolver._resolve_registry_async` passing the package name instead of the type code to `RegistryClient.resolve_package`, which would construct invalid API paths and break all registry integrations. Added `package_type` parameter to `resolve()` and `aresolve()`, with automatic mapping from `TemplateType` values to `PackageType` codes. Replaced `ValueError` with `TemplateError` for all registry fetch failure paths per acceptance criteria. Cache and client-pool operations now use `asyncio.Lock` in async coroutines (`aresolve`, `_resolve_registry_async`, `_get_client`, `close_all`) to prevent event-loop blocking, while retaining `threading.Lock` for the synchronous `resolve()` path. Use bounded `OrderedDict` with LRU eviction and consistently return deep copies to prevent cache poisoning. Fixed `_get_client()` race condition by protecting the full check-and-create under a single lock acquisition. Ad-hoc `ReferenceResolver` instances created during `_instantiate_from_registry_ref` are now properly closed in both success and error paths. Registry reference detection now uses proper `PackageReference.from_string()` parsing instead of a fragile `":" in template_name` check, and returns PackageReference for all reference types (LOCAL, ID, REGISTRY) enabling resolution of local and ID references through `instantiate_from_config` and `EnhancedTemplateRegistry.instantiate()`. Template `params` from registry-based configs are now applied to resolved content instead of being silently discarded. Added async `aresolve()` entry point to prevent `RuntimeError` when called from running event loops; `resolve()` now detects running loops and directs callers to `aresolve()`. Bare `except Exception: pass` blocks replaced with debug-level logging.
### Added
- **Registry Error Hierarchy** (`cleveractors.registry.exceptions`): Typed exception hierarchy per Package Registry Standard §13.2. `RegistryError(CleverAgentsException)` base carries `message`, optional `details: dict`, and optional `original_reference: str`; `__str__` includes the reference when present. Nine leaf exceptions: `PackageNotFoundError` (404), `InvalidPackageIdError` (400), `InvalidPackageReferenceError` (400), `VersionNotFoundError` (404), `ValidationError` (400), `AuthenticationRequiredError` (401), `AccessDeniedError` (403), `ConflictError` (409), and `RegistryNetworkError` (5xx / connection / timeout) which additionally carries `status_code` and `url`. `exception_for_status()` maps HTTP codes to typed exceptions; `_ERROR_TYPE_MAP` enables error-type parsing from structured JSON error bodies. 23 Behave BDD scenarios and 12 Robot Framework integration tests; exceptions.py achieves 100% coverage.
- **Structured `ExecutionError` fields + 5-limit enforcement in `PureLangGraph`** (`cleveractors.core.exceptions.ExecutionError`, `cleveractors.langgraph.pure_graph.PureLangGraph`): `ExecutionError` gains `kind` (categorical: `depth`, `model_calls`, `tool_calls`, `timeout`, `cost`) and `reason` (sub-code: `budget_exhausted` or `missing_pricing_entry`) fields, both defaulting to `""` for backward compatibility with existing `raise ExecutionError(msg)` call sites. `PureLangGraph` now accepts `limits` and `pricing` constructor arguments. When `limits["max_depth"]` is supplied, a depth breach raises `ExecutionError(kind="depth")` instead of silently returning the current message. `max_model_calls` is checked before each AGENT-type node; `max_tool_calls` before each TOOL-type node. `execute()` wraps `_execute_from_node()` in `asyncio.wait_for()` when `limits["timeout_ms"]` is set, mapping `asyncio.TimeoutError` to `ExecutionError(kind="timeout")`. After each LLM node, cost is computed from the supplied `pricing` table (rates are USD per million tokens per ADR-2029); breach raises `ExecutionError(kind="cost", reason="budget_exhausted")`; a missing provider or model entry raises `ExecutionError(kind="cost", reason="missing_pricing_entry")`. `runtime_dispatch._execute_graph()` now passes `executor.limits` and `executor.pricing` to `PureLangGraph`. `ExecutionError` is exported from `cleveractors.__init__` and `__all__`. (ADR-2029, issue #15)
- **`create_executor()` router-facing API** (`cleveractors.create_executor`): New module-level factory function that constructs an `Executor` wrapping `PureLangGraph` and `AgentFactory`. Accepts `config_dict` (validated actor configuration), `credentials` (per-provider credential dict for per-request injection), `limits` (execution budget), and `pricing` (per-model cost table). `executor.execute(message)` runs the actor graph and returns an `ActorResult(response, prompt_tokens, completion_tokens, nodes)`. All four execution paths are supported — `llm`, `graph`, `tool`, and `multi_actor`. Credentials are passed to `AgentFactory` and never injected into the stored `config_dict` (ADR-2026 AC8). Exported from `cleveractors.__init__` and `__all__` (ADR-2024, ADR-2026, ADR-2029).
- **`ActorResult` and `NodeUsage` types** (`cleveractors.result`): Canonical dataclasses for the router-facing result API, now defined in `cleveractors.result` (ADR-2027). `ActorResult` carries the response string, aggregated `prompt_tokens`/`completion_tokens`, a non-empty `nodes: list[NodeUsage]` breakdown for per-model billing, and an optional opaque `state` blob for stateless graph resumption (ADR-2026). Re-exported from `cleveractors.runtime` for backward compatibility.
+523
View File
@@ -0,0 +1,523 @@
# The Package Registry Standard
**Version:** 1.0.0
**Status:** Normative
---
## 0. Document Conventions
This document uses the following conventions to specify normative requirements:
- **MUST**, **REQUIRED**, **SHALL** indicate an absolute requirement.
- **MUST NOT**, **SHALL NOT** indicate an absolute prohibition.
- **SHOULD**, **RECOMMENDED** indicate that there may exist valid reasons in particular circumstances to ignore a particular requirement, but the full implications must be understood and carefully weighed.
- **SHOULD NOT**, **NOT RECOMMENDED** indicate that there may exist valid reasons in particular circumstances when the particular behavior is acceptable, but the full implications should be understood and the case carefully weighed.
- **MAY**, **OPTIONAL** indicate that an item is truly optional.
Code and configuration examples are presented in YAML 1.2 syntax. Internal identifiers, types, and values are presented in `monospaced font`.
A "compliant implementation" is any software that provides package registry services in conformance with the requirements of this document.
The terms "package", "registry", "client", and "server" are used throughout this document to refer to packages, package registries, registry clients, and registry servers respectively.
---
## 1. Introduction
### 1.1 Purpose
This document defines the **Package Registry Standard**: a protocol and data model for storing, retrieving, and managing versioned packages of AI components. A Package Registry provides a centralized system for discovering, sharing, and distributing reusable AI building blocks including actors, graphs, agents, templates, skills, and other component types.
### 1.2 Scope
This standard normatively defines:
1. The structural grammar and syntax of package identifiers.
2. The complete set of allowed package types and their content-addressed storage format.
3. The versioning scheme for packages including semantic versioning and alias resolution.
4. The reference formats for declaring dependencies between packages.
5. The canonicalization process for ensuring content-addressable uniqueness.
6. The API endpoints and request/response formats for registry operations.
7. The authentication and authorization mechanisms.
8. The discovery and browsing capabilities.
9. The package metadata schema.
10. The caching and assembly mechanisms for composite packages.
This standard does **NOT** define:
1. Specific programming-language bindings or SDKs.
2. Internal storage implementations or database schemas.
3. User interface designs or web application layouts.
4. Package build tools or publishing workflows beyond the API requirements.
5. Specific security implementations beyond authentication requirements.
### 1.3 Conformance
A compliant implementation MUST:
1. Accept any well-formed package that conforms to this standard and reject any document that does not.
2. Implement all required API endpoints with the specified request/response formats.
3. Support at least all defined package types as defined in this document.
4. Apply the canonicalization rules in §5 and signal errors for invalid packages.
5. Support the version resolution mechanism described in §4.2.
6. Treat all reserved identifiers (§7) appropriately.
A compliant implementation MAY:
1. Provide additional package types beyond those defined here, provided they do not conflict with the names defined in this standard.
2. Implement additional API endpoints for enhanced functionality.
3. Support alternative authentication mechanisms beyond those required.
4. Optimize storage or retrieval where the optimization is not observable through the defined API.
---
## 2. Terminology
| Term | Definition |
|------|------------|
| Package | A content-addressed, versioned unit of AI components conforming to a specific type schema. |
| Registry | A service implementing this standard that stores and serves packages. |
| Client | Software that interacts with a registry to publish or retrieve packages. |
| Server | The registry implementation providing the API endpoints. |
| Package ID | A globally unique identifier for a package in the format `pkg_<type>_<40-hex-sha1>`. |
| Reference | A string that identifies a package, which may be resolved to a concrete Package ID. |
| Version | A semantic version string or alias that identifies a specific package revision. |
| Alias | A mutable reference that resolves to a concrete version (e.g., `latest`, `v1.x`). |
| Canonical Form | The normalized, content-addressed representation of a package used for SHA1 computation. |
| Assembly | The process of resolving and combining dependencies into a complete package document. |
| Namespace | A logical grouping of packages under a shared ownership context. |
---
## 3. Document Structure
### 3.1 Package Format
A package MUST be a valid YAML 1.2 document encoded in UTF-8. The top-level YAML value MUST be a mapping (dictionary). Documents that are not mappings, or that fail to parse as YAML, MUST be rejected with an error.
### 3.2 Package Types
The following table enumerates all permitted package types. Each type defines the structure and semantics of packages of that kind:
| Type | Prefix | Description |
|------|--------|-------------|
| `actor` | `pkg_act_` | Actor configuration documents conforming to the Actor Configuration Standard. |
| `graph` | `pkg_grh_` | Graph route definitions that can be used as components in larger systems. |
| `stream` | `pkg_str_` | Stream route definitions for reactive processing pipelines. |
| `agent` | `pkg_agt_` | Agent definitions that can be referenced in actor configurations. |
| `template` | `pkg_tpl_` | Reusable template definitions for actors, graphs, streams, or agents. |
| `skill` | `pkg_skl_` | Skill packages defining capabilities for AI agents. |
| `mcp` | `pkg_mcp_` | MCP (Model Connector Protocol) server definitions. |
| `lsp` | `pkg_lsp_` | LSP (Language Server Protocol) service definitions. |
### 3.3 Minimum Valid Package
The minimum valid package MUST contain at least:
1. All required fields for its package type as defined by the respective standards.
2. A name field identifying the package within its namespace.
3. A description field explaining the package's purpose.
---
## 4. Versioning Scheme
### 4.1 Version Formats and Semantics
| Format | Type | Behavior | Example |
|--------|------|----------|---------|
| `vX.Y.Z` | Immutable Concrete | Always points to same SHA1 | `v3.2.1` → always pkg_act_a1b2c3... |
| `vX.Y.x` | Mutable Range Alias | Points to latest Z in X.Y.* series | `v3.2.x` → points to latest v3.2.Z |
| `vX.x` | Mutable Major Alias | Points to latest version with major X | `v3.x` → points to latest v3.*.* |
| `vx` | Mutable Global Alias | Synonymous with `latest` | `vx` → points to globally latest version |
| `latest` | Mutable Global Alias | Synonymous with `vx` | `latest` → points to globally latest version |
### 4.2 Version Resolution Logic
When resolving a version reference:
1. If the version is immutable (concrete), resolve directly to the corresponding package ID.
2. If the version is mutable (alias):
- Retrieve all published versions for the package namespace/name.
- Filter to only concrete versions that match the alias pattern.
- Sort matching versions semantically (newest first).
- Return the latest/best matching version.
---
## 5. Package Identifiers
### 5.1 Package ID Format
Every package is identified by a globally unique identifier in the format:
```
pkg_<type>_<40-hex-sha1>
```
Where:
- `pkg_` is a fixed prefix
- `<type>` is one of the defined package type prefixes (§3.2)
- `<40-hex-sha1>` is exactly 40 lowercase hexadecimal characters representing a SHA1 hash
### 5.2 Package ID Generation
A Package ID MUST be generated by:
1. Canonicalizing the package content according to §6.
2. Computing the SHA1 hash of the canonical form.
3. Formatting as `pkg_<type>_<sha1>` where type is determined by the package content.
### 5.3 Reference Formats
Packages are referenced using the following formats:
```yaml
# Registry reference with version - external package server
mcp_server: "registry.cleverthis.com:acme/web_search@latest"
skill_ref: "skills.example.com:code_analysis@2.3.1"
# SHA1 reference - direct ID reference
template_ref: "ID:pkg_tpl_a1b2c3d4e5f67890abcdef1234567890abcdef"
# All references can omit version (defaults to @latest)
agent_no_version: "registry.cleverthis.com:simple/agent" # Same as @latest
```
---
## 6. Canonicalization
### 6.1 Canonical Form Requirements
Every package MUST have a canonical form that:
1. Is deterministically generated from the package content.
2. Uses UTF-8 encoding with NFC normalization for all string values.
3. Sorts dictionary keys lexicographically.
4. Removes lifecycle fields that record when content was authored (e.g., `version`, `release_date`).
5. Resolves all internal references to SHA1 IDs.
### 6.2 Canonicalization Process
The canonicalization process MUST:
1. Parse the package YAML into a structured representation.
2. Normalize string values using Unicode NFC normalization.
3. Sort dictionary keys lexicographically.
4. Remove lifecycle fields.
5. Resolve internal references to concrete SHA1 IDs.
6. Serialize to RFC-8785 canonical JSON format.
### 6.3 Dependency Resolution
During canonicalization, all references within the package MUST be resolved:
1. References to other packages in the same bundle are resolved first.
2. External registry references are fetched and resolved to their concrete Package IDs.
3. All resolved dependencies are recorded in the canonical form's metadata.
---
## 7. Reserved Names
The following names are reserved by this standard and MUST NOT be used for user-defined package types or identifiers:
### 7.1 Type Prefixes
All prefixes defined in §3.2 are reserved for their specified semantics.
### 7.2 Version Aliases
The version aliases `latest`, `vx`, and patterns ending in `.x` are reserved for the standard version resolution mechanism.
### 7.3 Reference Schemes
The reference schemes `ID:`, `registry:`, and `local:` are reserved for their specified semantics.
---
## 8. API Specification
### 8.1 Overview
A compliant registry implementation MUST provide the following HTTP API endpoints:
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/packages/{package_id}` | GET | Retrieve raw package content by ID |
| `/{package_type}/{namespace}/{name}` | GET | Resolve a package by type, namespace, name, and version |
| `/publish` | POST | Publish a new package version |
| `/browse` | GET | Browse published packages with optional filters |
| `/.well-known/cleverthis-packages` | GET | Registry discovery endpoint |
### 8.2 Package Retrieval
#### 8.2.1 Get Package by ID
```
GET /packages/{package_id}
```
Returns the raw package content for the specified package ID.
Response:
```json
{
"content": "<YAML string>"
}
```
#### 8.2.2 Resolve Package
```
GET /{package_type}/{namespace}/{name}?version={version}
```
Resolves a package reference to its concrete Package ID.
Response:
```json
{
"package_id": "pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef",
"type": "actor"
}
```
### 8.3 Publishing
#### 8.3.1 Publish Package
```
POST /publish
```
Request:
```json
{
"package_id": "pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef",
"namespace": "example",
"name": "my-actor",
"version": "v1.0.0"
}
```
Response:
```json
{
"version_id": "pver_...",
"message": "Published successfully"
}
```
### 8.4 Discovery
#### 8.4.1 Browse Packages
```
GET /browse?type={type}&namespace={namespace}
```
Returns a list of published packages matching the filters.
Response:
```json
{
"packages": [
{
"id": "pkg_act_...",
"type": "actor",
"name": "my-actor",
"description": "An example actor package",
"namespace": "example",
"version_count": 3,
"created_at": "2026-05-25T10:00:00Z"
}
],
"count": 1
}
```
#### 8.4.2 Registry Discovery
```
GET /.well-known/cleverthis-packages
```
Returns metadata about the registry implementation.
Response:
```json
{
"name": "CleverThis Package Registry",
"version": "1.0.0",
"supported_types": ["actor", "graph", "stream", "agent", "template", "skill", "mcp", "lsp"],
"authentication": ["anonymous", "api_key"],
"features": ["semantic_versioning", "mutable_aliases"]
}
```
---
## 9. Authentication and Authorization
### 9.1 Anonymous Access
Registries MUST support anonymous read access to public packages.
### 9.2 Authenticated Access
For publishing or accessing private packages, registries MAY require authentication using:
1. API key tokens passed in the `Authorization` header.
2. OAuth2 bearer tokens.
3. Other mutually agreed-upon authentication mechanisms.
### 9.3 Authorization Model
Registries MUST enforce the following authorization rules:
1. Only authenticated users MAY publish packages.
2. Package owners control publishing permissions for their namespaces.
3. Private packages are only accessible to authorized users.
4. Public packages are readable by all users (anonymous or authenticated).
---
## 10. Caching and Assembly
### 10.1 Content-Addressed Storage
Packages MUST be stored using content-addressed storage where:
1. The key is the full Package ID.
2. The value is the canonical YAML representation.
3. Storage is immutable - once written, content cannot change.
### 10.2 Assembly Cache
For packages with dependencies, registries SHOULD maintain an assembly cache that:
1. Stores fully resolved package documents with dependencies inlined.
2. Uses LRU eviction policies to manage storage space.
3. Invalidates cached assemblies when dependencies change.
### 10.3 Client-Side Caching
Clients SHOULD implement caching mechanisms that:
1. Store previously retrieved packages locally.
2. Validate cached content using Package IDs.
3. Refresh cached content based on TTL or version updates.
---
## 11. Validation Rules
The following rules MUST be enforced for package operations:
### 11.1 Package Validation
1. The package document MUST parse as valid YAML 1.2.
2. The top-level document MUST be a mapping.
3. The package MUST conform to the structural requirements of its declared type.
4. All references MUST resolve to valid Package IDs.
5. The canonical form MUST produce a consistent SHA1 hash.
### 11.2 Publishing Validation
1. Only authenticated users MAY publish packages.
2. Package IDs MUST be unique - duplicates SHALL be rejected.
3. Version strings MUST conform to the defined format.
4. Namespace ownership MUST be verified for publishing.
5. Dependencies MUST exist in the registry or be published simultaneously.
### 11.3 Reference Validation
1. All package references MUST resolve to existing packages.
2. Circular dependencies MUST be detected and rejected.
3. Version constraints on dependencies MUST be satisfiable.
---
## 12. Security Model
### 12.1 Package Integrity
All packages MUST be content-addressed to ensure:
1. Tamper evidence - any change to package content changes its ID.
2. Deduplication - identical packages resolve to the same ID.
3. Immutability - published packages cannot be altered.
### 12.2 Transport Security
All communication with registry servers MUST use HTTPS in production environments.
### 12.3 Access Control
Registries MUST implement access control that:
1. Separates public and private packages.
2. Controls publishing permissions by namespace ownership.
3. Supports authentication for protected operations.
4. Logs security-relevant events for audit purposes.
---
## 13. Error Handling
### 13.1 Error Response Format
All error responses MUST follow this format:
```json
{
"error": {
"type": "<error_type>",
"message": "<human_readable_message>",
"details": { /* optional additional details */ }
}
}
```
### 13.2 Defined Error Types
| Error Type | HTTP Status | Description |
|------------|-------------|-------------|
| `PackageNotFound` | 404 | The requested package does not exist |
| `InvalidPackageId` | 400 | The provided Package ID format is invalid |
| `VersionNotFound` | 404 | The requested version does not exist |
| `ValidationError` | 400 | The package failed validation |
| `AuthenticationRequired` | 401 | Authentication is required for this operation |
| `AccessDenied` | 403 | Access to the requested resource is denied |
| `Conflict` | 409 | The request conflicts with existing data |
| `InternalServerError` | 500 | An unexpected error occurred server-side |
---
## 14. Compliance Test Vectors
A compliant implementation SHOULD be tested against (at minimum) the following classes of test vectors:
1. **Package ID Generation**: Valid and invalid Package ID formats.
2. **Canonicalization**: Various package types with different content.
3. **Version Resolution**: Concrete versions and mutable aliases.
4. **Reference Resolution**: Different reference formats and edge cases.
5. **API Operations**: All required endpoints with valid and invalid inputs.
6. **Authentication**: Access control for public/private packages.
7. **Error Conditions**: Proper error responses for all failure modes.
8. **Caching**: Cache behavior for updated and unchanged packages.
---
## 15. Versioning of This Standard
This document is **Version 1.0.0** of the Package Registry Standard. Future revisions:
- A **patch** version (1.0.x) corrects errors and clarifies semantics without changing required behavior.
- A **minor** version (1.x.0) adds new optional features without breaking conformance of existing implementations.
- A **major** version (x.0.0) introduces breaking changes; non-backward-compatible.
Conformance MUST be declared against a specific version of this standard. Implementations MAY support multiple major versions concurrently.
+183
View File
@@ -0,0 +1,183 @@
Feature: Registry Exception Hierarchy
As a developer using the Package Registry Standard v1.0.0
I want a typed exception hierarchy per §13.2
So that I can handle each error type appropriately
Background:
Given I have imported the registry exception module
# ── RegistryError construction ────────────────────────────────────────
Scenario: RegistryError with message only
When I raise a plain RegistryError with text "something went wrong"
Then the error message should be "something went wrong"
And the error should have no details
And the error should have no original_reference
Scenario: RegistryError with message and details
When I raise a RegistryError text "invalid input" and detail {"field": "name"}
Then the error message should be "invalid input"
And the error details should equal {"field": "name"}
And the error should have no original_reference
Scenario: RegistryError with message and original_reference
When I raise a RegistryError text "not found" and reference "pkg_act_unknown"
Then the error message should be "not found"
And the error original_reference should be "pkg_act_unknown"
And str of the error should be "not found [ref: pkg_act_unknown]"
Scenario: RegistryError with message, details, and original_reference
When I raise a RegistryError text "conflict" detail {"dup": true} and reference "pkg_act_abc"
Then the error message should be "conflict"
And the error details should equal {"dup": true}
And the error original_reference should be "pkg_act_abc"
And str of the error should be "conflict [ref: pkg_act_abc]"
# ── __str__ without original_reference ────────────────────────────────
Scenario: RegistryError __str__ is just the message when no reference
When I raise a plain RegistryError with text "plain error"
Then str of the error should be "plain error"
# ── RegistryNetworkError specific fields ──────────────────────────────
Scenario: RegistryNetworkError carries status_code and url
When I raise a simple network error message "server error" status 500 url "https://registry.example.com/packages/pkg_act_test"
Then the error message should be "server error"
And the error status_code should be 500
And the error url should be "https://registry.example.com/packages/pkg_act_test"
And str of the error should be "server error [status: 500] [url: https://registry.example.com/packages/pkg_act_test]"
Scenario: RegistryNetworkError with all fields
When I raise a fully detailed network error message "timeout" detail {"retries": 3} ref "pkg_act_x" status None url "https://bad.example.com"
Then the error message should be "timeout"
And the error details should equal {"retries": 3}
And the error original_reference should be "pkg_act_x"
And the error status_code should be None
And the error url should be "https://bad.example.com"
And str of the error should be "timeout [ref: pkg_act_x] [url: https://bad.example.com]"
# ── Typed subclass construction ──────────────────────────────────────
Scenario: PackageNotFoundError construction
When I raise a PackageNotFoundError with message "pkg not found"
Then the error message should be "pkg not found"
And the error should be an instance of PackageNotFoundError
Scenario: InvalidPackageIdError construction
When I raise an InvalidPackageIdError with message "bad id"
Then the error message should be "bad id"
And the error should be an instance of InvalidPackageIdError
Scenario: InvalidPackageReferenceError construction
When I raise an InvalidPackageReferenceError with message "bad ref"
Then the error message should be "bad ref"
And the error should be an instance of InvalidPackageReferenceError
Scenario: VersionNotFoundError construction
When I raise a VersionNotFoundError with message "version missing"
Then the error message should be "version missing"
And the error should be an instance of VersionNotFoundError
Scenario: ValidationError construction
When I raise a ValidationError with message "invalid"
Then the error message should be "invalid"
And the error should be an instance of ValidationError
Scenario: AuthenticationRequiredError construction
When I raise an AuthenticationRequiredError with message "auth needed"
Then the error message should be "auth needed"
And the error should be an instance of AuthenticationRequiredError
Scenario: AccessDeniedError construction
When I raise an AccessDeniedError with message "no access"
Then the error message should be "no access"
And the error should be an instance of AccessDeniedError
Scenario: ConflictError construction
When I raise a ConflictError with message "duplicate"
Then the error message should be "duplicate"
And the error should be an instance of ConflictError
# ── HTTP status code mapping (§13.2) ─────────────────────────────────
Scenario: 400 maps to InvalidPackageIdError
When I call exception_for_status with code 400 and message "bad request"
Then the result should be an instance of InvalidPackageIdError
And the error message should be "bad request"
Scenario: 401 maps to AuthenticationRequiredError
When I call exception_for_status with code 401 and message "auth required"
Then the result should be an instance of AuthenticationRequiredError
And the error message should be "auth required"
Scenario: 403 maps to AccessDeniedError
When I call exception_for_status with code 403 and message "denied"
Then the result should be an instance of AccessDeniedError
And the error message should be "denied"
Scenario: 404 maps to PackageNotFoundError
When I call exception_for_status with code 404 and message "gone"
Then the result should be an instance of PackageNotFoundError
And the error message should be "gone"
Scenario: 409 maps to ConflictError
When I call exception_for_status with code 409 and message "conflict"
Then the result should be an instance of ConflictError
And the error message should be "conflict"
Scenario: 500 maps to RegistryNetworkError
When I call exception_for_status with code 500 and message "server error"
Then the result should be an instance of RegistryNetworkError
And the error message should be "server error"
And the error status_code should be 500
Scenario: 503 maps to RegistryNetworkError
When I call exception_for_status with code 503 and message "unavailable"
Then the result should be an instance of RegistryNetworkError
And the error message should be "unavailable"
And the error status_code should be 503
Scenario: non-integer status_code falls back to RegistryError
When I call exception_for_status with non-integer code "not-a-number" and message "bad status"
Then the result should be an instance of RegistryError
And the error message should be "bad status"
Scenario: Unknown status falls back to RegistryError
When I call exception_for_status with code 418 and message "teapot"
Then the result should be an instance of RegistryError
And the error message should be "teapot"
# ── Inheritance chain ─────────────────────────────────────────────────
Scenario: All typed errors inherit from RegistryError
When I check that PackageNotFoundError is a subclass of RegistryError
And I check that InvalidPackageIdError is a subclass of RegistryError
And I check that InvalidPackageReferenceError is a subclass of RegistryError
And I check that VersionNotFoundError is a subclass of RegistryError
And I check that ValidationError is a subclass of RegistryError
And I check that AuthenticationRequiredError is a subclass of RegistryError
And I check that AccessDeniedError is a subclass of RegistryError
And I check that ConflictError is a subclass of RegistryError
And I check that RegistryNetworkError is a subclass of RegistryError
Then all 9 checks should pass
Scenario: RegistryError inherits from CleverAgentsException
When I check that RegistryError is a subclass of CleverAgentsException
Then all 1 checks should pass
# ── Error type map completeness ──────────────────────────────────────
Scenario: _ERROR_TYPE_MAP contains all error type keys
When I inspect the _ERROR_TYPE_MAP
Then the map should contain key "PackageNotFound"
And the map should contain key "InvalidPackageId"
And the map should contain key "InvalidPackageReference"
And the map should contain key "VersionNotFound"
And the map should contain key "ValidationError"
And the map should contain key "AuthenticationRequired"
And the map should contain key "AccessDenied"
And the map should contain key "Conflict"
And the map should contain key "InternalServerError"
And the value for key "InternalServerError" should be RegistryNetworkError
And the map should have exactly 9 keys
+33 -11
View File
@@ -104,9 +104,17 @@ Feature: Registry HTTP Client
When the mock server raises a timeout error
When I try to call get_package with package_id "pkg_act_test00000000000000000000000000000000000"
Then a RegistryNetworkError should be raised
And the raised error should have url None
# ── Error mapping (§13.2) ─────────────────────────────────────────────
Scenario: Response with null message preserves default HTTP status message
When I create a RegistryClient pointed at "https://registry.example.com"
When the mock server returns status 500 with body '{"message": null}'
When I try to call get_package with package_id "pkg_act_test00000000000000000000000000000000000"
Then a RegistryNetworkError should be raised
And the error message should be "HTTP 500"
Scenario: 401 response raises AuthenticationRequiredError
When I create a RegistryClient pointed at "https://registry.example.com"
When the mock server returns status 401 with body '{"message": "Authentication required"}'
@@ -125,17 +133,21 @@ Feature: Registry HTTP Client
When I try to call get_package with package_id "pkg_act_test00000000000000000000000000000000000"
Then a ConflictError should be raised
Scenario: 500 response raises InternalServerError
Scenario: 500 response raises RegistryNetworkError
When I create a RegistryClient pointed at "https://registry.example.com"
When the mock server returns status 500 with body '{"message": "Internal error"}'
When I try to call get_package with package_id "pkg_act_test00000000000000000000000000000000000"
Then an InternalServerError should be raised
Then a RegistryNetworkError should be raised
And the raised error should have status_code 500
And the raised error should have url set
Scenario: 500 range response raises InternalServerError
Scenario: 503 response raises RegistryNetworkError
When I create a RegistryClient pointed at "https://registry.example.com"
When the mock server returns status 503 with body '{"message": "Service unavailable"}'
When I try to call get_package with package_id "pkg_act_test00000000000000000000000000000000000"
Then an InternalServerError should be raised
Then a RegistryNetworkError should be raised
And the raised error should have status_code 503
And the raised error should have url set
Scenario: Unknown error status falls back to RegistryError
When I create a RegistryClient pointed at "https://registry.example.com"
@@ -157,6 +169,13 @@ Feature: Registry HTTP Client
When I try to call get_package with package_id "pkg_act_test00000000000000000000000000000000000"
Then a VersionNotFoundError should be raised
Scenario: Error response with details propagates details to exception
When I create a RegistryClient pointed at "https://registry.example.com"
When the mock server returns status 404 with body '{"error": {"type": "PackageNotFound", "message": "not found", "details": {"package_id": "pkg_abc"}}}'
When I try to call get_package with package_id "pkg_act_test00000000000000000000000000000000000"
Then a PackageNotFoundError should be raised
And the error details should equal {"package_id": "pkg_abc"}
Scenario: 400 with ValidationError error type in body raises ValidationError
When I create a RegistryClient pointed at "https://registry.example.com"
When the mock server returns status 400 with body '{"error": {"type": "ValidationError", "message": "Package validation failed"}}'
@@ -166,12 +185,15 @@ Feature: Registry HTTP Client
Scenario: 404 without error type in body falls back to PackageNotFoundError
When I create a RegistryClient pointed at "https://registry.example.com"
When the mock server returns status 404 with body '{"message": "Package not found"}'
When I try to call get_package with package_id "pkg_act_test00000000000000000000000000000000000"
Then a PackageNotFoundError should be raised
Scenario: Client with API key includes authorization header
When I create an authenticated RegistryClient pointed at "https://registry.example.com" with API key "my-api-key"
When the mock server returns status 200 with body '{"content": "ok"}'
When I call get_package with API key client and package_id "pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef"
When I call get_package with API key client and package_id "pkg_act_a1b2c3d4e5f67890abcdef1234567890abcdef" capturing headers
Then the registry result body equals '{"content": "ok"}'
And the Authorization header should be "Bearer my-api-key"
# ── All package type prefixes ────────────────────────────────────────
@@ -202,13 +224,13 @@ Feature: Registry HTTP Client
# ── HTTPS enforcement (spec §12.2) ────────────────────────────────────
Scenario: Client warns when HTTP is used with an API key
When I create a RegistryClient with base URL "http://registry.example.com" and API key "token123"
Then the client should log a warning about cleartext credentials
When I create a RegistryClient with base URL "http://registry.example.com" and API key "token123" and capture logs
Then a cleartext credentials warning was logged
Scenario: Client logs info when HTTP is used without an API key
When I create a RegistryClient pointed at "http://registry.example.com"
Then the client should log info about HTTPS requirement
When I create a RegistryClient pointed at "http://registry.example.com" and capture logs
Then an HTTPS requirement info message was logged
Scenario: Client with allow_insecure suppresses HTTP warnings
When I create an insecure RegistryClient pointed at "http://registry.example.com"
Then the client should not log HTTPS warnings
When I create an insecure RegistryClient pointed at "http://registry.example.com" and capture logs
Then no HTTPS warnings were logged
+300
View File
@@ -0,0 +1,300 @@
"""
Step definitions for Registry Exception Hierarchy BDD tests.
"""
from __future__ import annotations
import json
from typing import cast
from behave import given, then, when
from behave.runner import Context
from cleveractors.core.exceptions import CleverAgentsException
from cleveractors.registry.exceptions import (
_ERROR_TYPE_MAP,
AccessDeniedError,
AuthenticationRequiredError,
ConflictError,
InvalidPackageIdError,
InvalidPackageReferenceError,
PackageNotFoundError,
RegistryError,
RegistryNetworkError,
ValidationError,
VersionNotFoundError,
exception_for_status,
)
_CLASS_MAP: dict[str, type[CleverAgentsException]] = {
"CleverAgentsException": CleverAgentsException,
"RegistryError": RegistryError,
"PackageNotFoundError": PackageNotFoundError,
"InvalidPackageIdError": InvalidPackageIdError,
"InvalidPackageReferenceError": InvalidPackageReferenceError,
"VersionNotFoundError": VersionNotFoundError,
"ValidationError": ValidationError,
"AuthenticationRequiredError": AuthenticationRequiredError,
"AccessDeniedError": AccessDeniedError,
"ConflictError": ConflictError,
"RegistryNetworkError": RegistryNetworkError,
}
@given("I have imported the registry exception module")
def step_import_exception_module(context: Context) -> None:
context._checks_passed = 0
# ── Construction steps ──────────────────────────────────────────────────────
@when('I raise a plain RegistryError with text "{message}"')
def step_raise_registry_error_message(context: Context, message: str) -> None:
context.error = RegistryError(message)
@when('I raise a RegistryError text "{message}" and detail {details_json}')
def step_raise_registry_error_message_details(
context: Context, message: str, details_json: str
) -> None:
context.error = RegistryError(message, details=json.loads(details_json))
@when('I raise a RegistryError text "{message}" and reference "{ref}"')
def step_raise_registry_error_message_ref(
context: Context, message: str, ref: str
) -> None:
context.error = RegistryError(message, original_reference=ref)
@when(
'I raise a RegistryError text "{message}" detail {details_json} and reference "{ref}"'
)
def step_raise_registry_error_full(
context: Context, message: str, details_json: str, ref: str
) -> None:
context.error = RegistryError(
message, details=json.loads(details_json), original_reference=ref
)
@when(
'I raise a simple network error message "{message}" status {status_code} url "{url}"'
)
def step_raise_network_error_basic(
context: Context, message: str, status_code: str, url: str
) -> None:
sc = int(status_code)
context.error = RegistryNetworkError(message, status_code=sc, url=url)
@when(
'I raise a fully detailed network error message "{message}" detail {details_json} ref "{ref}" status {status_code} url "{url}"'
)
def step_raise_network_error_full(
context: Context,
message: str,
details_json: str,
ref: str,
status_code: str,
url: str,
) -> None:
sc = None if status_code == "None" else int(status_code)
context.error = RegistryNetworkError(
message,
details=json.loads(details_json),
original_reference=ref,
status_code=sc,
url=url,
)
# ── Assertion steps ─────────────────────────────────────────────────────────
@then('the error message should be "{expected}"')
def step_error_message(context: Context, expected: str) -> None:
assert context.error.message == expected, (
f"Expected message {expected!r}, got {context.error.message!r}"
)
@then("the error should have no details")
def step_error_no_details(context: Context) -> None:
assert context.error.details is None, (
f"Expected details=None, got {context.error.details!r}"
)
@then("the error should have no original_reference")
def step_error_no_ref(context: Context) -> None:
assert context.error.original_reference is None, (
f"Expected original_reference=None, got {context.error.original_reference!r}"
)
@then("the error details should equal {expected_json}")
def step_error_details(context: Context, expected_json: str) -> None:
expected = json.loads(expected_json)
assert context.error.details == expected, (
f"Expected details {expected!r}, got {context.error.details!r}"
)
@then('the error original_reference should be "{expected}"')
def step_error_ref(context: Context, expected: str) -> None:
assert context.error.original_reference == expected, (
f"Expected original_reference {expected!r}, "
f"got {context.error.original_reference!r}"
)
@then('str of the error should be "{expected}"')
def step_error_str(context: Context, expected: str) -> None:
assert str(context.error) == expected, (
f"Expected str {expected!r}, got {str(context.error)!r}"
)
@then("the error status_code should be {expected}")
def step_error_status_code(context: Context, expected: str) -> None:
exp = None if expected == "None" else int(expected)
assert context.error.status_code == exp, (
f"Expected status_code {exp!r}, got {context.error.status_code!r}"
)
@then('the error url should be "{expected}"')
def step_error_url(context: Context, expected: str) -> None:
assert context.error.url == expected, (
f"Expected url {expected!r}, got {context.error.url!r}"
)
# ── exception_for_status steps ──────────────────────────────────────────────
@when('I call exception_for_status with code {code:d} and message "{message}"')
def step_call_exception_for_status(context: Context, code: int, message: str) -> None:
context.result = exception_for_status(code, message)
@when(
'I call exception_for_status with non-integer code "{code}" and message "{message}"'
)
def step_call_exception_for_status_non_int(
context: Context, code: str, message: str
) -> None:
# Intentionally non-int to exercise the isinstance guard in exception_for_status
context.result = exception_for_status(cast(int, code), message)
@then("the result should be an instance of {cls_name}")
def step_result_instance_of(context: Context, cls_name: str) -> None:
expected_cls = _CLASS_MAP[cls_name]
assert isinstance(context.result, expected_cls), (
f"Expected isinstance of {expected_cls.__name__}, "
f"got {type(context.result).__name__}"
)
context.error = context.result
# ── Inheritance chain steps ─────────────────────────────────────────────────
@when("I check that {child} is a subclass of {parent}")
def step_check_subclass(context: Context, child: str, parent: str) -> None:
child_cls = _CLASS_MAP[child]
parent_cls = _CLASS_MAP[parent]
assert issubclass(child_cls, parent_cls), (
f"{child_cls.__name__} is not a subclass of {parent_cls.__name__}"
)
context._checks_passed += 1
@then("all {count:d} checks should pass")
def step_all_checks_pass(context: Context, count: int) -> None:
assert context._checks_passed == count, (
f"Expected {count} inheritance checks, got {context._checks_passed}"
)
# ── Error type map steps ────────────────────────────────────────────────────
@when("I inspect the _ERROR_TYPE_MAP")
def step_inspect_map(context: Context) -> None:
context._error_map = _ERROR_TYPE_MAP
@then('the map should contain key "{key}"')
def step_map_has_key(context: Context, key: str) -> None:
assert key in context._error_map, f"Expected _ERROR_TYPE_MAP to contain key {key!r}"
@then("the map should have exactly {count:d} keys")
def step_map_cardinality(context: Context, count: int) -> None:
actual = len(context._error_map)
assert actual == count, (
f"Expected _ERROR_TYPE_MAP to have {count} keys, got {actual}"
)
@then('the value for key "{key}" should be {value_cls}')
def step_map_value(context: Context, key: str, value_cls: str) -> None:
expected = _CLASS_MAP[value_cls]
assert context._error_map[key] is expected, (
f"Expected _ERROR_TYPE_MAP[{key!r}] = {expected.__name__}, "
f"got {context._error_map[key].__name__}"
)
# ── Typed subclass construction steps ────────────────────────────────────────
@when('I raise a PackageNotFoundError with message "{message}"')
def step_raise_package_not_found_error(context: Context, message: str) -> None:
context.error = PackageNotFoundError(message)
@when('I raise an InvalidPackageIdError with message "{message}"')
def step_raise_invalid_id_error(context: Context, message: str) -> None:
context.error = InvalidPackageIdError(message)
@when('I raise an InvalidPackageReferenceError with message "{message}"')
def step_raise_invalid_ref_error(context: Context, message: str) -> None:
context.error = InvalidPackageReferenceError(message)
@when('I raise a VersionNotFoundError with message "{message}"')
def step_raise_version_not_found_error(context: Context, message: str) -> None:
context.error = VersionNotFoundError(message)
@when('I raise a ValidationError with message "{message}"')
def step_raise_validation_error(context: Context, message: str) -> None:
context.error = ValidationError(message)
@when('I raise an AuthenticationRequiredError with message "{message}"')
def step_raise_auth_required_error(context: Context, message: str) -> None:
context.error = AuthenticationRequiredError(message)
@when('I raise an AccessDeniedError with message "{message}"')
def step_raise_access_denied_error(context: Context, message: str) -> None:
context.error = AccessDeniedError(message)
@when('I raise a ConflictError with message "{message}"')
def step_raise_conflict_error(context: Context, message: str) -> None:
context.error = ConflictError(message)
@then("the error should be an instance of {cls_name}")
def step_error_is_instance(context: Context, cls_name: str) -> None:
assert isinstance(context.error, _CLASS_MAP[cls_name]), (
f"Expected {cls_name}, got {type(context.error).__name__}"
)
+100 -52
View File
@@ -16,8 +16,8 @@ from cleveractors.registry.exceptions import (
AccessDeniedError,
AuthenticationRequiredError,
ConflictError,
InternalServerError,
InvalidPackageIdError,
InvalidPackageReferenceError,
PackageNotFoundError,
RegistryError,
RegistryNetworkError,
@@ -164,7 +164,7 @@ def step_call_get_package(context: Any, package_id: str) -> None:
context.client._client = context._mock_client
context.result = await context.client.get_package(package_id)
asyncio.get_event_loop().run_until_complete(_call())
asyncio.run(_call())
@when('I try to call get_package with package_id "{package_id}"')
@@ -176,7 +176,7 @@ def step_try_call_get_package(context: Any, package_id: str) -> None:
except Exception as exc:
context.error = exc
asyncio.get_event_loop().run_until_complete(_call())
asyncio.run(_call())
@then("the registry result body equals '{body}'")
@@ -205,6 +205,36 @@ def step_check_network_error(context: Any) -> None:
assert isinstance(context.error, RegistryNetworkError)
@then("the raised error should have status_code {expected:d}")
def step_check_error_status_code(context: Any, expected: int) -> None:
assert context.error is not None
assert context.error.status_code == expected, (
f"Expected status_code {expected}, got {context.error.status_code}"
)
@then('the raised error should have url "{expected}"')
def step_check_error_url(context: Any, expected: str) -> None:
assert context.error is not None
assert context.error.url == expected, (
f"Expected url {expected!r}, got {context.error.url!r}"
)
@then("the raised error should have url set")
def step_check_error_url_set(context: Any) -> None:
assert context.error is not None
assert context.error.url is not None, "Expected url to be set, got None"
@then("the raised error should have url None")
def step_check_error_url_none(context: Any) -> None:
assert context.error is not None
assert context.error.url is None, (
f"Expected url to be None, got {context.error.url!r}"
)
# ── resolve_package (§8.2.2) ────────────────────────────────────────────
@@ -220,7 +250,7 @@ def step_call_resolve_with_version(
pkg_type, ns, name, version
)
asyncio.get_event_loop().run_until_complete(_call())
asyncio.run(_call())
@when(
@@ -235,7 +265,7 @@ def step_call_resolve_and_version(
pkg_type, ns, name, version
)
asyncio.get_event_loop().run_until_complete(_call())
asyncio.run(_call())
@when(
@@ -248,7 +278,7 @@ def step_call_resolve_no_version(
context.client._client = context._mock_client
context.result = await context.client.resolve_package(pkg_type, ns, name)
asyncio.get_event_loop().run_until_complete(_call())
asyncio.run(_call())
@when(
@@ -264,7 +294,7 @@ def step_try_call_resolve(
except Exception as exc:
context.error = exc
asyncio.get_event_loop().run_until_complete(_call())
asyncio.run(_call())
@then('the resolved package_id should be "{expected}"')
@@ -289,7 +319,7 @@ def step_call_browse_no_filters(context: Any) -> None:
context.client._client = context._mock_client
context.result = await context.client.browse()
asyncio.get_event_loop().run_until_complete(_call())
asyncio.run(_call())
@when('I call browse with type "{pkg_type}"')
@@ -298,7 +328,7 @@ def step_call_browse_with_type(context: Any, pkg_type: str) -> None:
context.client._client = context._mock_client
context.result = await context.client.browse(package_type=pkg_type)
asyncio.get_event_loop().run_until_complete(_call())
asyncio.run(_call())
@when('I call browse with namespace "{ns}"')
@@ -307,7 +337,7 @@ def step_call_browse_with_namespace(context: Any, ns: str) -> None:
context.client._client = context._mock_client
context.result = await context.client.browse(namespace=ns)
asyncio.get_event_loop().run_until_complete(_call())
asyncio.run(_call())
@then("the result count should be {expected:d}")
@@ -325,7 +355,7 @@ def step_call_discover(context: Any) -> None:
context.client._client = context._mock_client
context.result = await context.client.discover()
asyncio.get_event_loop().run_until_complete(_call())
asyncio.run(_call())
@then('the result should have key "{key}"')
@@ -345,7 +375,7 @@ def step_use_context_manager(context: Any) -> None:
context.ctx_client = client
context.ctx_was_open = not client._client.is_closed
asyncio.get_event_loop().run_until_complete(_use())
asyncio.run(_use())
@then("the client should be closed after the context block")
@@ -384,12 +414,6 @@ def step_check_conflict(context: Any) -> None:
assert isinstance(context.error, ConflictError)
@then("an InternalServerError should be raised")
def step_check_internal_error(context: Any) -> None:
assert context.error is not None
assert isinstance(context.error, InternalServerError)
@then("a RegistryError should be raised")
def step_check_registry_error(context: Any) -> None:
assert context.error is not None
@@ -400,9 +424,12 @@ def step_check_registry_error(context: Any) -> None:
AuthenticationRequiredError,
AccessDeniedError,
ConflictError,
InternalServerError,
RegistryNetworkError,
InvalidPackageIdError,
PackageNotFoundError,
VersionNotFoundError,
InvalidPackageReferenceError,
ValidationError,
),
)
@@ -419,53 +446,74 @@ def step_check_validation_error(context: Any) -> None:
assert isinstance(context.error, ValidationError)
@when('I call get_package with API key client and package_id "{package_id}"')
def step_call_get_package_with_key(context: Any, package_id: str) -> None:
@when(
'I call get_package with API key client and package_id "{package_id}" capturing headers'
)
def step_call_get_package_with_key_capture_headers(
context: Any, package_id: str
) -> None:
async def _call() -> None:
with patch("cleveractors.registry.client.httpx.AsyncClient") as mock_cls:
mock_cls.return_value = context._mock_client
context.result = await context.client.get_package(package_id)
context._captured_client_kwargs = mock_cls.call_args
asyncio.get_event_loop().run_until_complete(_call())
asyncio.run(_call())
@when('I create an insecure RegistryClient pointed at "{base_url}"')
def step_create_insecure_client(context: Any, base_url: str) -> None:
from cleveractors.registry.client import RegistryClient
context.client = RegistryClient(base_url=base_url, allow_insecure=True)
@then('the Authorization header should be "{expected}"')
def step_check_authorization_header(context: Any, expected: str) -> None:
headers = context._captured_client_kwargs[1].get("headers", {})
actual = headers.get("Authorization", "")
assert actual == expected, (
f"Expected Authorization header {expected!r}, got {actual!r}"
)
@then("the client should log a warning about cleartext credentials")
def step_client_logs_cleartext_warning(context: Any) -> None:
from cleveractors.registry.client import RegistryClient
@when(
'I create a RegistryClient with base URL "{base_url}" and API key "{api_key}" and capture logs'
)
def step_create_client_with_key_and_capture_logs(
context: Any, base_url: str, api_key: str
) -> None:
with patch("cleveractors.registry.client.logger.warning") as mock_warn:
RegistryClient(base_url="http://example.com", api_key="test-key")
mock_warn.assert_called_once()
assert "cleartext" in mock_warn.call_args[0][0].lower()
context.client = RegistryClient(base_url=base_url, api_key=api_key)
context._log_warn_mock = mock_warn
@then("the client should log info about HTTPS requirement")
def step_client_logs_https_info(context: Any) -> None:
from cleveractors.registry.client import RegistryClient
@when('I create a RegistryClient pointed at "{base_url}" and capture logs')
def step_create_client_and_capture_logs(context: Any, base_url: str) -> None:
with patch("cleveractors.registry.client.logger.info") as mock_info:
RegistryClient(base_url="http://example.com")
mock_info.assert_called_once()
assert "https" in mock_info.call_args[0][0].lower()
context.client = RegistryClient(base_url=base_url)
context._log_info_mock = mock_info
@then("the client should not log HTTPS warnings")
def step_client_no_https_warnings(context: Any) -> None:
from cleveractors.registry.client import RegistryClient
@when('I create an insecure RegistryClient pointed at "{base_url}" and capture logs')
def step_create_insecure_client_and_capture_logs(context: Any, base_url: str) -> None:
with patch("cleveractors.registry.client.logger.warning") as mock_warn:
with patch("cleveractors.registry.client.logger.info") as mock_info:
RegistryClient(base_url="http://example.com", allow_insecure=True)
for call_args in mock_warn.call_args_list:
msg = call_args[0][0].lower()
assert "cleartext" not in msg
for call_args in mock_info.call_args_list:
msg = call_args[0][0].lower()
assert "https" not in msg or "production" not in msg
context.client = RegistryClient(base_url=base_url, allow_insecure=True)
context._log_warn_mock = mock_warn
context._log_info_mock = mock_info
@then("a cleartext credentials warning was logged")
def step_assert_cleartext_warning(context: Any) -> None:
context._log_warn_mock.assert_called_once()
assert "cleartext" in context._log_warn_mock.call_args[0][0].lower()
@then("an HTTPS requirement info message was logged")
def step_assert_https_info(context: Any) -> None:
context._log_info_mock.assert_called_once()
assert "https" in context._log_info_mock.call_args[0][0].lower()
@then("no HTTPS warnings were logged")
def step_assert_no_https_warnings(context: Any) -> None:
for call_args in context._log_warn_mock.call_args_list:
msg = call_args[0][0].lower()
assert "cleartext" not in msg
for call_args in context._log_info_mock.call_args_list:
msg = call_args[0][0].lower()
assert "https" not in msg or "production" not in msg
+49 -12
View File
@@ -30,6 +30,19 @@ from cleveractors.core.exceptions import (
ConfigurationError,
)
from cleveractors.core.progress import ProgressBarManager
from cleveractors.registry.exceptions import (
AccessDeniedError,
AuthenticationRequiredError,
ConflictError,
InvalidPackageIdError,
InvalidPackageReferenceError,
PackageNotFoundError,
RegistryError,
RegistryNetworkError,
ValidationError,
VersionNotFoundError,
exception_for_status,
)
from cleveractors.templates.enhanced_registry import EnhancedTemplateRegistry
from cleveractors.templates.registry import TemplateRegistry
from cleveractors.templates.renderer import TemplateEngine, TemplateRenderer
@@ -43,6 +56,23 @@ from cleveractors.registry.reference_resolver import PackageContentResolver
from cleveractors.registry.types import PackageReference
_EXCEPTION_CLASS_MAP: dict[str, type[CleverAgentsException]] = {
"CleverAgentsException": CleverAgentsException,
"ConfigurationError": ConfigurationError,
"AgentCreationError": AgentCreationError,
"RegistryError": RegistryError,
"PackageNotFoundError": PackageNotFoundError,
"InvalidPackageIdError": InvalidPackageIdError,
"InvalidPackageReferenceError": InvalidPackageReferenceError,
"VersionNotFoundError": VersionNotFoundError,
"ValidationError": ValidationError,
"AuthenticationRequiredError": AuthenticationRequiredError,
"AccessDeniedError": AccessDeniedError,
"ConflictError": ConflictError,
"RegistryNetworkError": RegistryNetworkError,
}
class CleverActorsLib: # pragma: no cover - integration test library
"""Keyword library for cleveractors Robot Framework integration tests.
@@ -362,25 +392,32 @@ class CleverActorsLib: # pragma: no cover - integration test library
# ── exceptions ────────────────────────────────────────────────────
def exception_is_subclass_of(self, child: str, parent: str) -> None:
exceptions = {
"CleverAgentsException": CleverAgentsException,
"ConfigurationError": ConfigurationError,
"AgentCreationError": AgentCreationError,
}
child_cls = exceptions[child]
parent_cls = exceptions[parent]
child_cls = _EXCEPTION_CLASS_MAP[child]
parent_cls = _EXCEPTION_CLASS_MAP[parent]
if not issubclass(child_cls, parent_cls):
raise AssertionError(f"{child} is not a subclass of {parent}")
def exception_is_instance_cleveragents_exception(self, exc_name: str) -> None:
mapping = {
"ConfigurationError": ConfigurationError,
"AgentCreationError": AgentCreationError,
}
cls = mapping[exc_name]
cls = _EXCEPTION_CLASS_MAP[exc_name]
if not issubclass(cls, CleverAgentsException):
raise AssertionError(f"{exc_name} is not a CleverAgentsException")
def exception_for_status_returns(self, code: str, expected_class: str) -> None:
result = exception_for_status(int(code), f"status {code}")
expected = _EXCEPTION_CLASS_MAP[expected_class]
if not isinstance(result, expected):
raise AssertionError(
f"Expected {expected_class} for code {code}, "
f"got {type(result).__name__}"
)
if (
getattr(result, "status_code", None) is not None
and str(result.status_code) != code
):
raise AssertionError(
f"Expected status_code {code}, got {result.status_code}"
)
# ── progress bar ──────────────────────────────────────────────────
def create_progress_bar(self) -> None:
+118
View File
@@ -19,3 +19,121 @@ ConfigurationError Is Instance Of CleverAgentsException
AgentCreationError Is Instance Of CleverAgentsException
[Documentation] Verify isinstance check for agent error
Exception Is Instance Cleveragents Exception AgentCreationError
# ── Registry exception hierarchy (§13.2) ─────────────────────────────────
RegistryError Is Subclass Of CleverAgentsException
[Documentation] RegistryError extends CleverAgentsException
Exception Is Subclass Of RegistryError CleverAgentsException
PackageNotFoundError Is Subclass Of RegistryError
[Documentation] Typed 404 extends RegistryError
Exception Is Subclass Of PackageNotFoundError RegistryError
InvalidPackageIdError Is Subclass Of RegistryError
[Documentation] Typed 400 extends RegistryError
Exception Is Subclass Of InvalidPackageIdError RegistryError
InvalidPackageReferenceError Is Subclass Of RegistryError
[Documentation] Custom 400 extends RegistryError
Exception Is Subclass Of InvalidPackageReferenceError RegistryError
VersionNotFoundError Is Subclass Of RegistryError
[Documentation] Typed 404 extends RegistryError
Exception Is Subclass Of VersionNotFoundError RegistryError
ValidationError Is Subclass Of RegistryError
[Documentation] Typed 400 extends RegistryError
Exception Is Subclass Of ValidationError RegistryError
AuthenticationRequiredError Is Subclass Of RegistryError
[Documentation] Typed 401 extends RegistryError
Exception Is Subclass Of AuthenticationRequiredError RegistryError
AccessDeniedError Is Subclass Of RegistryError
[Documentation] Typed 403 extends RegistryError
Exception Is Subclass Of AccessDeniedError RegistryError
ConflictError Is Subclass Of RegistryError
[Documentation] Typed 409 extends RegistryError
Exception Is Subclass Of ConflictError RegistryError
RegistryNetworkError Is Subclass Of RegistryError
[Documentation] 5xx and transport extends RegistryError
Exception Is Subclass Of RegistryNetworkError RegistryError
# ── RegistryError isinstance checks ──────────────────────────────────────
RegistryError Is Instance Of CleverAgentsException
[Documentation] RegistryError is a CleverAgentsException
Exception Is Instance Cleveragents Exception RegistryError
PackageNotFoundError Is Instance Of CleverAgentsException
[Documentation] Typed errors inherit base class
Exception Is Instance Cleveragents Exception PackageNotFoundError
RegistryNetworkError Is Instance Of CleverAgentsException
[Documentation] NetworkError inherits base class
Exception Is Instance Cleveragents Exception RegistryNetworkError
InvalidPackageIdError Is Instance Of CleverAgentsException
[Documentation] Typed errors inherit base class
Exception Is Instance Cleveragents Exception InvalidPackageIdError
InvalidPackageReferenceError Is Instance Of CleverAgentsException
[Documentation] Custom reference error inherits base class
Exception Is Instance Cleveragents Exception InvalidPackageReferenceError
VersionNotFoundError Is Instance Of CleverAgentsException
[Documentation] Typed 404 inherits base class
Exception Is Instance Cleveragents Exception VersionNotFoundError
ValidationError Is Instance Of CleverAgentsException
[Documentation] Typed 400 inherits base class
Exception Is Instance Cleveragents Exception ValidationError
AuthenticationRequiredError Is Instance Of CleverAgentsException
[Documentation] Typed 401 inherits base class
Exception Is Instance Cleveragents Exception AuthenticationRequiredError
AccessDeniedError Is Instance Of CleverAgentsException
[Documentation] Typed 403 inherits base class
Exception Is Instance Cleveragents Exception AccessDeniedError
ConflictError Is Instance Of CleverAgentsException
[Documentation] Typed 409 inherits base class
Exception Is Instance Cleveragents Exception ConflictError
# ── exception_for_status routing (§13.2) ─────────────────────────────────
Exception For Status 400 Returns InvalidPackageIdError
[Documentation] 400 status code maps to InvalidPackageIdError
Exception For Status Returns 400 InvalidPackageIdError
Exception For Status 401 Returns AuthenticationRequiredError
[Documentation] 401 status code maps to AuthenticationRequiredError
Exception For Status Returns 401 AuthenticationRequiredError
Exception For Status 403 Returns AccessDeniedError
[Documentation] 403 status code maps to AccessDeniedError
Exception For Status Returns 403 AccessDeniedError
Exception For Status 404 Returns PackageNotFoundError
[Documentation] 404 status code maps to PackageNotFoundError
Exception For Status Returns 404 PackageNotFoundError
Exception For Status 409 Returns ConflictError
[Documentation] 409 status code maps to ConflictError
Exception For Status Returns 409 ConflictError
Exception For Status 500 Returns RegistryNetworkError
[Documentation] 500 status code maps to RegistryNetworkError
Exception For Status Returns 500 RegistryNetworkError
Exception For Status 503 Returns RegistryNetworkError
[Documentation] 503 status code maps to RegistryNetworkError
Exception For Status Returns 503 RegistryNetworkError
Exception For Status 418 Returns RegistryError
[Documentation] Unknown 4xx status code falls back to RegistryError
Exception For Status Returns 418 RegistryError
+1 -1
View File
@@ -11,7 +11,7 @@ ${EXPECTED_VERSION} 2.1.0
Package Version Is Correct
[Documentation] Verify __version__ matches expected value
${result}= Run Process python -c import cleveractors; print(cleveractors.__version__)
... timeout=30s
... timeout=60s
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} ${EXPECTED_VERSION}
+2 -2
View File
@@ -7,7 +7,6 @@ from cleveractors.registry.exceptions import (
AccessDeniedError,
AuthenticationRequiredError,
ConflictError,
InternalServerError,
InvalidPackageIdError,
InvalidPackageReferenceError,
PackageNotFoundError,
@@ -15,6 +14,7 @@ from cleveractors.registry.exceptions import (
RegistryNetworkError,
ValidationError,
VersionNotFoundError,
exception_for_status,
)
from cleveractors.registry.reference_resolver import PackageContentResolver
from cleveractors.registry.resolver import (
@@ -38,7 +38,6 @@ __all__ = [
"CacheStats",
"Canonicalizer",
"ConflictError",
"InternalServerError",
"InvalidPackageIdError",
"InvalidPackageReferenceError",
"PackageContent",
@@ -55,6 +54,7 @@ __all__ = [
"RegistryNetworkError",
"ValidationError",
"VersionNotFoundError",
"exception_for_status",
"is_concrete_version",
"is_version_alias",
"resolve_version",
+53 -19
View File
@@ -12,6 +12,7 @@ and optional API key authentication (§9.2).
from __future__ import annotations
import asyncio
import logging
from typing import Any, Optional
from urllib.parse import quote
@@ -52,6 +53,7 @@ class RegistryClient:
self.api_key: Optional[str] = api_key
self.timeout: float = timeout
self._client: Optional[httpx.AsyncClient] = None
self._async_lock: Optional[asyncio.Lock] = None
if not allow_insecure and not self.base_url.startswith("https://"):
if api_key is not None:
logger.warning(
@@ -66,17 +68,23 @@ class RegistryClient:
"in production (§12.2)."
)
def _get_async_lock(self) -> asyncio.Lock:
if self._async_lock is None:
self._async_lock = asyncio.Lock()
return self._async_lock
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
headers: dict[str, str] = {}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
self._client = httpx.AsyncClient(
base_url=self.base_url,
headers=headers,
timeout=httpx.Timeout(self.timeout),
)
return self._client
async with self._get_async_lock():
if self._client is None or self._client.is_closed:
headers: dict[str, str] = {}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
self._client = httpx.AsyncClient(
base_url=self.base_url,
headers=headers,
timeout=httpx.Timeout(self.timeout),
)
return self._client
async def _request(self, path: str, params: Optional[dict[str, str]] = None) -> Any:
client = await self._get_client()
@@ -86,25 +94,50 @@ class RegistryClient:
return response.json()
except httpx.HTTPStatusError as exc:
status = exc.response.status_code
url = (
str(exc.request.url)
if getattr(exc, "_request", None) is not None
else None
)
msg = f"HTTP {status}"
error_type: Optional[str] = None
details: Optional[dict[str, Any]] = None
try:
body = exc.response.json()
if isinstance(body, dict):
error_block = body.get("error")
if isinstance(error_block, dict):
error_type = error_block.get("type")
if "message" in error_block:
msg = error_block["message"]
elif "message" in body:
new_msg = error_block.get("message")
if new_msg is not None:
msg = new_msg
raw_details = error_block.get("details")
if isinstance(raw_details, dict):
details = raw_details
elif "message" in body and body["message"] is not None:
msg = body["message"]
except (ValueError, KeyError, TypeError):
except (ValueError, TypeError):
pass
if error_type and error_type in _ERROR_TYPE_MAP:
raise _ERROR_TYPE_MAP[error_type](msg) from exc
raise exception_for_status(status, msg) from exc
if isinstance(error_type, str) and error_type in _ERROR_TYPE_MAP:
exc_cls = _ERROR_TYPE_MAP[error_type]
if exc_cls is RegistryNetworkError:
raise RegistryNetworkError(
msg, details=details, status_code=status, url=url
) from exc
raise exc_cls(msg, details=details) from exc
if 500 <= status < 600:
raise RegistryNetworkError(
msg, details=details, status_code=status, url=url
) from exc
raise exception_for_status(status, msg, details=details) from exc
except httpx.RequestError as exc:
raise RegistryNetworkError(str(exc)) from exc
url = None
try:
if getattr(exc, "_request", None) is not None:
url = str(exc.request.url)
except RuntimeError:
pass
raise RegistryNetworkError(str(exc), url=url) from exc
async def close(self) -> None:
"""Close the underlying HTTP client."""
@@ -137,7 +170,8 @@ class RegistryClient:
InvalidPackageIdError: If the Package ID format is invalid.
RegistryNetworkError: For connection errors and timeouts.
"""
return await self._request(f"/packages/{package_id}")
encoded_id = quote(package_id, safe="")
return await self._request(f"/packages/{encoded_id}")
# ------------------------------------------------------------------
# §8.2.2 GET /{package_type}/{namespace}/{name}?version={version}
+121 -29
View File
@@ -1,14 +1,53 @@
"""Typed exceptions for the Package Registry client.
Maps the 8 error types defined in the Package Registry Standard §13.2
to Python exception classes, plus a network error type for transport failures.
Maps the error types defined in the Package Registry Standard §13.2
to Python exception classes. The spec defines 8 standard error types;
``InvalidPackageReferenceError`` (project-specific) and
``RegistryNetworkError`` (transport/5xx) bring the total to 9 typed
subclasses of ``RegistryError``.
All errors carry a human-readable ``message``, optional ``details``
dict, and optional ``original_reference`` string (e.g. the raw
package ID or URL that triggered the error). ``RegistryNetworkError``
additionally carries ``status_code`` and ``url`` for transport failures.
"""
from __future__ import annotations
from types import MappingProxyType
from typing import Any
from cleveractors.core.exceptions import CleverAgentsException
class RegistryError(CleverAgentsException):
"""Base exception for all registry-related errors."""
"""Base exception for all registry-related errors.
Attributes:
message: Human-readable description of the error.
details: Optional dictionary with supplementary information.
original_reference: Optional string that triggered the error
(e.g. a malformed package ID, a URL, or a version alias).
"""
def __init__(
self,
message: str,
details: dict[str, Any] | None = None,
original_reference: str | None = None,
) -> None:
super().__init__(message)
self.message: str = message
self.details: dict[str, Any] | None = (
dict(details) if details is not None else None # shallow copy
)
self.original_reference: str | None = original_reference
def __str__(self) -> str:
base = self.message
if self.original_reference is not None:
base = f"{base} [ref: {self.original_reference}]"
return base
class PackageNotFoundError(RegistryError):
@@ -19,6 +58,10 @@ class InvalidPackageIdError(RegistryError):
"""The provided Package ID format is invalid (HTTP 400)."""
class InvalidPackageReferenceError(RegistryError):
"""The provided package reference string could not be parsed or resolved (HTTP 400)."""
class VersionNotFoundError(RegistryError):
"""The requested version does not exist (HTTP 404)."""
@@ -39,42 +82,91 @@ class ConflictError(RegistryError):
"""The request conflicts with existing data (HTTP 409)."""
class InternalServerError(RegistryError):
"""An unexpected error occurred server-side (HTTP 500)."""
class RegistryNetworkError(RegistryError):
"""Connection errors, timeouts, or other transport-level failures."""
"""Connection errors, timeouts, or server-side failures (HTTP 5xx).
Attributes:
status_code: The HTTP status code when originating from a
server response. May be ``None`` for pure transport errors
(timeout, connection refused, DNS failure, etc.).
url: The URL that was being accessed when the error occurred.
"""
def __init__(
self,
message: str,
details: dict[str, Any] | None = None,
original_reference: str | None = None,
*,
status_code: int | None = None,
url: str | None = None,
) -> None:
super().__init__(
message, details=details, original_reference=original_reference
)
self.status_code: int | None = status_code
self.url: str | None = url
def __str__(self) -> str:
parts: list[str] = [super().__str__()]
if self.status_code is not None:
parts.append(f"[status: {self.status_code}]")
if self.url is not None:
parts.append(f"[url: {self.url}]")
return " ".join(parts)
class InvalidPackageReferenceError(RegistryError):
"""The provided package reference string could not be parsed or resolved."""
# Maps server-side error-type strings to exception classes per
# the Package Registry Standard §13.2, plus the project-specific
# InvalidPackageReference type which the server may return.
_ERROR_TYPE_MAP: MappingProxyType[str, type[RegistryError]] = MappingProxyType(
{
"PackageNotFound": PackageNotFoundError,
"InvalidPackageId": InvalidPackageIdError,
"InvalidPackageReference": InvalidPackageReferenceError,
"VersionNotFound": VersionNotFoundError,
"ValidationError": ValidationError,
"AuthenticationRequired": AuthenticationRequiredError,
"AccessDenied": AccessDeniedError,
"Conflict": ConflictError,
"InternalServerError": RegistryNetworkError,
}
)
_ERROR_TYPE_MAP: dict[str, type[RegistryError]] = {
"PackageNotFound": PackageNotFoundError,
"InvalidPackageId": InvalidPackageIdError,
"VersionNotFound": VersionNotFoundError,
"ValidationError": ValidationError,
"AuthenticationRequired": AuthenticationRequiredError,
"AccessDenied": AccessDeniedError,
"Conflict": ConflictError,
"InternalServerError": InternalServerError,
}
def exception_for_status(
status_code: int, message: str, details: dict[str, Any] | None = None
) -> RegistryError:
"""Return an appropriate :class:`RegistryError` for an HTTP status code.
Mapping follows the Package Registry Standard §13.2:
* 400 :class:`InvalidPackageIdError`
* 401 :class:`AuthenticationRequiredError`
* 403 :class:`AccessDeniedError`
* 404 :class:`PackageNotFoundError`
* 409 :class:`ConflictError`
* 5xx :class:`RegistryNetworkError`
def exception_for_status(status_code: int, message: str) -> RegistryError:
"""Return an appropriate RegistryError for an HTTP status code."""
All other status codes fall back to a plain :class:`RegistryError`.
Args:
status_code: The HTTP status code.
message: Human-readable error message.
details: Optional dictionary with supplementary error information
from a structured error response body (§13.1).
"""
if not isinstance(status_code, int):
return RegistryError(message)
if status_code == 400:
return InvalidPackageIdError(message)
return InvalidPackageIdError(message, details=details)
if status_code == 401:
return AuthenticationRequiredError(message)
return AuthenticationRequiredError(message, details=details)
if status_code == 403:
return AccessDeniedError(message)
return AccessDeniedError(message, details=details)
if status_code == 404:
return PackageNotFoundError(message)
return PackageNotFoundError(message, details=details)
if status_code == 409:
return ConflictError(message)
return ConflictError(message, details=details)
if 500 <= status_code < 600:
return InternalServerError(message)
return RegistryError(message)
return RegistryNetworkError(message, details=details, status_code=status_code)
return RegistryError(message, details=details)