diff --git a/CHANGELOG.md b/CHANGELOG.md index ed4ed47..c652581 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/actor-registry-standard.md b/docs/actor-registry-standard.md new file mode 100644 index 0000000..eac5994 --- /dev/null +++ b/docs/actor-registry-standard.md @@ -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__<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__<40-hex-sha1> +``` + +Where: +- `pkg_` is a fixed prefix +- `` 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__` 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": "" +} +``` + +#### 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": "", + "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. \ No newline at end of file diff --git a/features/registry_exceptions.feature b/features/registry_exceptions.feature new file mode 100644 index 0000000..47a26bd --- /dev/null +++ b/features/registry_exceptions.feature @@ -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 diff --git a/features/registry_http_client.feature b/features/registry_http_client.feature index 4f8d754..c9d39fa 100644 --- a/features/registry_http_client.feature +++ b/features/registry_http_client.feature @@ -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 \ No newline at end of file + When I create an insecure RegistryClient pointed at "http://registry.example.com" and capture logs + Then no HTTPS warnings were logged \ No newline at end of file diff --git a/features/steps/registry_exceptions_steps.py b/features/steps/registry_exceptions_steps.py new file mode 100644 index 0000000..c29e2c3 --- /dev/null +++ b/features/steps/registry_exceptions_steps.py @@ -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__}" + ) diff --git a/features/steps/registry_http_client_steps.py b/features/steps/registry_http_client_steps.py index 4b12489..07086e4 100644 --- a/features/steps/registry_http_client_steps.py +++ b/features/steps/registry_http_client_steps.py @@ -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 diff --git a/robot/CleverActorsLib.py b/robot/CleverActorsLib.py index e75c45d..af588dd 100644 --- a/robot/CleverActorsLib.py +++ b/robot/CleverActorsLib.py @@ -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: diff --git a/robot/exceptions.robot b/robot/exceptions.robot index 27e4835..4b8d9de 100644 --- a/robot/exceptions.robot +++ b/robot/exceptions.robot @@ -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 diff --git a/robot/version.robot b/robot/version.robot index 1e14a3e..afa4253 100644 --- a/robot/version.robot +++ b/robot/version.robot @@ -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} diff --git a/src/cleveractors/registry/__init__.py b/src/cleveractors/registry/__init__.py index 0044d14..3f415ac 100644 --- a/src/cleveractors/registry/__init__.py +++ b/src/cleveractors/registry/__init__.py @@ -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", diff --git a/src/cleveractors/registry/client.py b/src/cleveractors/registry/client.py index 00679ba..09b3fa3 100644 --- a/src/cleveractors/registry/client.py +++ b/src/cleveractors/registry/client.py @@ -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} diff --git a/src/cleveractors/registry/exceptions.py b/src/cleveractors/registry/exceptions.py index 4480356..789afea 100644 --- a/src/cleveractors/registry/exceptions.py +++ b/src/cleveractors/registry/exceptions.py @@ -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)