From 22a94b2c5d009878131700328cea1900e2b97217 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Tue, 6 Oct 2026 17:40:29 -0700 Subject: [PATCH 01/16] feat(api): sync durable approval operations Regenerates the internal client from the Hosting spec with the durable approval request, list, get, stats, approve, and deny operations, and requires the operations the SDK calls. Co-authored-by: Cursor --- openapi/openapi.yaml | 686 ++++++++++++++++++ scripts/generate_openapi.py | 5 + .../approve_durable_approval.py | 273 +++++++ .../deny_durable_approval.py | 277 +++++++ .../durable_functions/get_durable_approval.py | 193 +++++ .../get_durable_approval_stats.py | 267 +++++++ .../list_durable_approvals.py | 352 +++++++++ .../request_durable_approval.py | 280 +++++++ src/volcano_sdk/_generated/models/__init__.py | 28 + .../_generated/models/durable_approval.py | 239 ++++++ .../models/durable_approval_counts.py | 117 +++ .../models/durable_approval_daily_counts.py | 96 +++ .../models/durable_approval_decider_type_0.py | 85 +++ .../durable_approval_decision_request.py | 60 ++ .../durable_approval_decision_type_0.py | 122 ++++ .../models/durable_approval_execution.py | 138 ++++ .../models/durable_approval_function.py | 108 +++ .../durable_approval_function_counts.py | 99 +++ .../models/durable_approval_registration.py | 128 ++++ .../models/durable_approval_stats.py | 217 ++++++ .../models/durable_approval_status.py | 10 + .../models/paginated_durable_approvals.py | 126 ++++ .../request_durable_approval_request.py | 102 +++ 23 files changed, 4008 insertions(+) create mode 100644 src/volcano_sdk/_generated/api/durable_functions/approve_durable_approval.py create mode 100644 src/volcano_sdk/_generated/api/durable_functions/deny_durable_approval.py create mode 100644 src/volcano_sdk/_generated/api/durable_functions/get_durable_approval.py create mode 100644 src/volcano_sdk/_generated/api/durable_functions/get_durable_approval_stats.py create mode 100644 src/volcano_sdk/_generated/api/durable_functions/list_durable_approvals.py create mode 100644 src/volcano_sdk/_generated/api/durable_functions/request_durable_approval.py create mode 100644 src/volcano_sdk/_generated/models/durable_approval.py create mode 100644 src/volcano_sdk/_generated/models/durable_approval_counts.py create mode 100644 src/volcano_sdk/_generated/models/durable_approval_daily_counts.py create mode 100644 src/volcano_sdk/_generated/models/durable_approval_decider_type_0.py create mode 100644 src/volcano_sdk/_generated/models/durable_approval_decision_request.py create mode 100644 src/volcano_sdk/_generated/models/durable_approval_decision_type_0.py create mode 100644 src/volcano_sdk/_generated/models/durable_approval_execution.py create mode 100644 src/volcano_sdk/_generated/models/durable_approval_function.py create mode 100644 src/volcano_sdk/_generated/models/durable_approval_function_counts.py create mode 100644 src/volcano_sdk/_generated/models/durable_approval_registration.py create mode 100644 src/volcano_sdk/_generated/models/durable_approval_stats.py create mode 100644 src/volcano_sdk/_generated/models/durable_approval_status.py create mode 100644 src/volcano_sdk/_generated/models/paginated_durable_approvals.py create mode 100644 src/volcano_sdk/_generated/models/request_durable_approval_request.py diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 86b51d5a..aae1de29 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -4390,6 +4390,343 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /durable-approvals: + post: + tags: + - Durable Functions + summary: Request an approval from inside a workflow + description: | + Registers an approval request for a running durable execution. The + Volcano SDK calls this from `ctx.waitForApproval`; applications do not + call it directly. + + `execution_ref` and `callback_id` are opaque values the SDK reads from + the running workflow. Together they are the request's credential, so + the operation takes no other authentication. The approval belongs to + the project that owns the execution, and expires when the workflow's + own approval timeout does. + + Retrying is safe: a request for an approval that is already registered + returns it with `200`. + operationId: requestDurableApproval + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RequestDurableApprovalRequest' + responses: + '200': + description: The approval was already registered + content: + application/json: + schema: + $ref: '#/components/schemas/DurableApprovalRegistration' + '201': + description: Approval registered + content: + application/json: + schema: + $ref: '#/components/schemas/DurableApprovalRegistration' + '400': + description: The request body is invalid + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: No running durable execution matches `execution_ref` + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: | + The approval cannot be registered now. `code` says why: + + - `approval_not_ready`: the workflow has not finished opening the + approval yet. Retry with a short backoff; the SDK does. + - `approval_closed`: the workflow is no longer waiting on it. + - `execution_ended`: the execution has finished. + - `too_many_pending_approvals`: the execution already has the + maximum number of pending approvals. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '413': + description: The request body is larger than 64 KiB + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + description: Too many approval requests from this execution or address + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '503': + description: | + The approval could not be confirmed with the workflow right now. + Retry with backoff. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /projects/{id}/durable-approvals: + get: + tags: + - Durable Functions + summary: List durable approvals + description: | + Lists the approvals durable workflows in the project have requested, + newest first. Pending approvals are the ones a workflow is waiting on; + decided, expired, and cancelled ones are kept for a year. + + Project access tokens can list approvals, including read-only ones. + operationId: listDurableApprovals + security: + - UserToken: [] + - ProjectAccessToken: [] + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/Limit' + - name: status + in: query + required: false + description: Return only approvals in this status. + schema: + $ref: '#/components/schemas/DurableApprovalStatus' + - $ref: '#/components/parameters/DurableApprovalFunctionFilter' + - name: execution_id + in: query + required: false + description: Return only approvals requested by this durable execution. + schema: + type: string + format: uuid + - name: from + in: query + required: false + description: Return only approvals requested at or after this time. + schema: + type: string + format: date-time + - name: to + in: query + required: false + description: Return only approvals requested before this time. + schema: + type: string + format: date-time + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedDurableApprovals' + '400': + description: A filter is invalid, or `from` is after `to` + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /projects/{id}/durable-approvals/stats: + get: + tags: + - Durable Functions + summary: Get durable approval statistics + description: | + Counts the project's approvals by outcome over a time window, overall, + per workflow, and per day. The window defaults to the last 30 days and + can cover up to a year. An approval is counted on the day it was + requested. + + Project access tokens can read statistics, including read-only ones. + operationId: getDurableApprovalStats + security: + - UserToken: [] + - ProjectAccessToken: [] + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/DurableApprovalFunctionFilter' + - name: from + in: query + required: false + description: Start of the window. Defaults to 30 days before `to`. + schema: + type: string + format: date-time + - name: to + in: query + required: false + description: End of the window. Defaults to now. + schema: + type: string + format: date-time + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/DurableApprovalStats' + '400': + description: The window is invalid or longer than 366 days + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /projects/{id}/durable-approvals/{approvalId}: + get: + tags: + - Durable Functions + summary: Get a durable approval + operationId: getDurableApproval + security: + - UserToken: [] + - ProjectAccessToken: [] + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/DurableApprovalId' + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/DurableApproval' + '404': + description: Approval not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /projects/{id}/durable-approvals/{approvalId}/approve: + post: + tags: + - Durable Functions + summary: Approve a durable approval + description: | + Approves a pending approval. The workflow resumes with the decision. + + Only a person can decide: use the dashboard or a platform token from + `volcano login`. Project access tokens are refused with `403`. + + Approving an approval that is already approved returns it unchanged. + If the workflow cannot be reached right away, the decision still + stands and Volcano keeps delivering it. + operationId: approveDurableApproval + security: + - UserToken: [] + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/DurableApprovalId' + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/DurableApprovalDecisionRequest' + responses: + '200': + description: Approved + content: + application/json: + schema: + $ref: '#/components/schemas/DurableApproval' + '400': + description: The request body is invalid + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: The credential is not a person's + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Approval not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: | + The approval was already denied, or has expired or been cancelled. + `code` is `approval_decided`, `approval_expired`, or + `approval_cancelled`. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /projects/{id}/durable-approvals/{approvalId}/deny: + post: + tags: + - Durable Functions + summary: Deny a durable approval + description: | + Denies a pending approval. The workflow resumes with the decision; a + denial is a value the workflow branches on, not an error. + + Only a person can decide: use the dashboard or a platform token from + `volcano login`. Project access tokens are refused with `403`. + + Denying an approval that is already denied returns it unchanged. + If the workflow cannot be reached right away, the decision still + stands and Volcano keeps delivering it. + operationId: denyDurableApproval + security: + - UserToken: [] + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/DurableApprovalId' + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/DurableApprovalDecisionRequest' + responses: + '200': + description: Denied + content: + application/json: + schema: + $ref: '#/components/schemas/DurableApproval' + '400': + description: The request body is invalid + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: The credential is not a person's + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Approval not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: | + The approval was already approved, or has expired or been + cancelled. `code` is `approval_decided`, `approval_expired`, or + `approval_cancelled`. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /projects/{id}/frontends: get: tags: @@ -12818,6 +13155,21 @@ components: Platform user token from the Management API. Required for project management operations. Obtain via POST /tokens in Management API (port 8001). + ProjectAccessToken: + type: http + scheme: bearer + bearerFormat: opaque + description: | + Project access token, scoped to a single project. Starts with `pt-`. + + Accepted on that project's control-plane routes and refused + everywhere else, including account-wide endpoints and any other + project. A `read_only` token additionally refuses mutations. + + Create one with POST /projects/{id}/access-tokens using a platform + token. The secret is returned once and is not recoverable, so a + project access token cannot create, list, read, or revoke project + access tokens. parameters: BackupName: name: backupName @@ -12924,6 +13276,26 @@ components: type: string format: uuid description: Durable execution ID + DurableApprovalId: + name: approvalId + in: path + required: true + schema: + type: string + format: uuid + description: Durable approval ID + DurableApprovalFunctionFilter: + name: function + in: query + required: false + schema: + type: string + minLength: 1 + maxLength: 255 + description: | + Only approvals requested by this durable function, given by id or by + name. A name also matches approvals from a deleted function of that + name. Limit: name: limit in: query @@ -17880,6 +18252,320 @@ components: - limit - total - has_more + DurableApproval: + type: object + description: An approval a durable workflow requested. + properties: + id: + type: string + format: uuid + status: + $ref: '#/components/schemas/DurableApprovalStatus' + name: + type: string + description: | + The name the workflow gave the approval in `ctx.waitForApproval`. + title: + type: string + description: + type: string + details: + description: | + The JSON value the workflow attached for the person deciding, as + given. + function: + $ref: '#/components/schemas/DurableApprovalFunction' + execution: + $ref: '#/components/schemas/DurableApprovalExecution' + requested_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + nullable: true + description: | + When the approval expires if nobody decides. Null when the + workflow set no timeout; the approval then lasts as long as its + execution. + decision: + $ref: '#/components/schemas/DurableApprovalDecision' + required: + - id + - status + - name + - title + - description + - function + - execution + - requested_at + - expires_at + - decision + DurableApprovalStatus: + type: string + enum: + - pending + - approved + - denied + - expired + - cancelled + description: | + `pending` means the workflow is waiting for a decision. `approved` and + `denied` are decisions a person made. `expired` means the workflow's + approval timeout passed first, and `cancelled` means the execution + ended while the approval was still pending. Every status but `pending` + is final. + DurableApprovalRegistration: + type: object + description: The registered approval, as the requesting workflow sees it. + properties: + id: + type: string + format: uuid + status: + $ref: '#/components/schemas/DurableApprovalStatus' + expires_at: + type: string + format: date-time + nullable: true + required: + - id + - status + - expires_at + DurableApprovalStats: + type: object + properties: + from: + type: string + format: date-time + to: + type: string + format: date-time + counts: + $ref: '#/components/schemas/DurableApprovalCounts' + approval_rate: + type: number + format: double + nullable: true + description: | + `approved / (approved + denied)`, from 0 to 1. Null when nothing in + the window was decided. + median_seconds_to_decision: + type: number + format: double + nullable: true + description: Median time from request to decision. Null when nothing was decided. + p90_seconds_to_decision: + type: number + format: double + nullable: true + description: 90th percentile time from request to decision. Null when nothing was decided. + functions: + type: array + description: | + The ten workflows that requested the most approvals, most first. + The rest are summed in `other_functions`. + items: + $ref: '#/components/schemas/DurableApprovalFunctionCounts' + other_functions: + $ref: '#/components/schemas/DurableApprovalCounts' + daily: + type: array + description: | + One entry per UTC day in the window that has approvals, oldest + first. Days with none are omitted. + items: + $ref: '#/components/schemas/DurableApprovalDailyCounts' + required: + - from + - to + - counts + - approval_rate + - median_seconds_to_decision + - p90_seconds_to_decision + - functions + - other_functions + - daily + DurableApprovalDecisionRequest: + type: object + properties: + comment: + type: string + maxLength: 2000 + description: A note for the workflow and the approval's history. + additionalProperties: false + RequestDurableApprovalRequest: + type: object + properties: + execution_ref: + type: string + minLength: 1 + maxLength: 2048 + description: Opaque reference to the running execution, supplied by the SDK. + callback_id: + type: string + minLength: 1 + maxLength: 1024 + description: Opaque approval reference, supplied by the SDK. + name: + type: string + minLength: 1 + maxLength: 255 + title: + type: string + minLength: 1 + maxLength: 200 + description: + type: string + maxLength: 4000 + details: + description: | + Any JSON value to show the person deciding. The whole request is + limited to 64 KiB. + required: + - execution_ref + - callback_id + - name + - title + additionalProperties: false + PaginatedDurableApprovals: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/DurableApproval' + page: + type: integer + description: Current page number (1-indexed) + limit: + type: integer + description: Number of items per page + total: + type: integer + description: Total number of items across all pages + has_more: + type: boolean + description: Whether there are more pages available + required: + - data + - page + - limit + - total + - has_more + DurableApprovalFunction: + type: object + description: | + The durable function that requested the approval. `id` is null once the + function has been deleted; `name` is kept. + properties: + id: + type: string + format: uuid + nullable: true + name: + type: string + required: + - id + - name + DurableApprovalExecution: + type: object + description: | + The durable execution that requested the approval. `id` and `status` + are null once the execution is no longer retained; `name` is kept. + properties: + id: + type: string + format: uuid + nullable: true + name: + type: string + status: + allOf: + - $ref: '#/components/schemas/DurableExecutionStatus' + nullable: true + required: + - id + - name + - status + DurableApprovalDecider: + type: object + nullable: true + description: The person who decided. Null once their account is deleted. + properties: + id: + type: string + email: + type: string + required: + - id + - email + DurableApprovalDecision: + type: object + nullable: true + description: Who decided and when. Null unless the approval was approved or denied. + properties: + comment: + type: string + decided_by: + $ref: '#/components/schemas/DurableApprovalDecider' + decided_at: + type: string + format: date-time + required: + - comment + - decided_by + - decided_at + DurableApprovalCounts: + type: object + description: Approvals by status. `requested` is every approval, whatever its status. + properties: + requested: + type: integer + format: int64 + pending: + type: integer + format: int64 + approved: + type: integer + format: int64 + denied: + type: integer + format: int64 + expired: + type: integer + format: int64 + cancelled: + type: integer + format: int64 + required: + - requested + - pending + - approved + - denied + - expired + - cancelled + DurableApprovalFunctionCounts: + type: object + properties: + function: + $ref: '#/components/schemas/DurableApprovalFunction' + counts: + $ref: '#/components/schemas/DurableApprovalCounts' + required: + - function + - counts + DurableApprovalDailyCounts: + type: object + properties: + date: + type: string + format: date + counts: + $ref: '#/components/schemas/DurableApprovalCounts' + required: + - date + - counts PaginatedProjectCustomDomains: type: object properties: diff --git a/scripts/generate_openapi.py b/scripts/generate_openapi.py index a5ee186e..f6521597 100755 --- a/scripts/generate_openapi.py +++ b/scripts/generate_openapi.py @@ -17,15 +17,20 @@ DEFAULT_OUTPUT = ROOT / "src" / "volcano_sdk" / "_generated" REQUIRED_OPERATION_MODULES = { "acquire_project_lock.py", + "approve_durable_approval.py", "auth_signin.py", "copy_storage_object.py", "delete_storage_object.py", + "deny_durable_approval.py", "download_storage_object.py", + "get_durable_approval.py", + "get_durable_approval_stats.py", "get_durable_execution.py", "force_release_project_lock.py", "get_project_log_activity.py", "get_project_lock.py", "invoke_function.py", + "list_durable_approvals.py", "list_durable_executions.py", "list_storage_objects.py", "move_storage_object.py", diff --git a/src/volcano_sdk/_generated/api/durable_functions/approve_durable_approval.py b/src/volcano_sdk/_generated/api/durable_functions/approve_durable_approval.py new file mode 100644 index 00000000..1c68f620 --- /dev/null +++ b/src/volcano_sdk/_generated/api/durable_functions/approve_durable_approval.py @@ -0,0 +1,273 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.durable_approval import DurableApproval +from ...models.durable_approval_decision_request import DurableApprovalDecisionRequest +from ...models.error import Error +from ...types import UNSET, Unset +from typing import cast +from uuid import UUID + + + +def request_kwargs( + id: UUID | str, + approval_id: UUID | str, + *, + body: DurableApprovalDecisionRequest | Unset = UNSET, + +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + + + + + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/projects/{id}/durable-approvals/{approval_id}/approve".format(id=quote(str(id), safe=""),approval_id=quote(str(approval_id), safe=""),), + } + + + if not isinstance(body, Unset): + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> DurableApproval | Error | None: + if response.status_code == 200: + response_200 = DurableApproval.from_dict(response.json()) + + + + return response_200 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + + + return response_400 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + + + return response_404 + + if response.status_code == 409: + response_409 = Error.from_dict(response.json()) + + + + return response_409 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[DurableApproval | Error]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: UUID | str, + approval_id: UUID | str, + *, + client: AuthenticatedClient, + body: DurableApprovalDecisionRequest | Unset = UNSET, + +) -> Response[DurableApproval | Error]: + """ Approve a durable approval + + Approves a pending approval. The workflow resumes with the decision. + + Only a person can decide: use the dashboard or a platform token from + `volcano login`. Project access tokens are refused with `403`. + + Approving an approval that is already approved returns it unchanged. + If the workflow cannot be reached right away, the decision still + stands and Volcano keeps delivering it. + + Args: + id (UUID): + approval_id (UUID): + body (DurableApprovalDecisionRequest | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DurableApproval | Error] + """ + + + kwargs = request_kwargs( + id=id, +approval_id=approval_id, +body=body, + + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return build_response(client=client, response=response) + +def sync( + id: UUID | str, + approval_id: UUID | str, + *, + client: AuthenticatedClient, + body: DurableApprovalDecisionRequest | Unset = UNSET, + +) -> DurableApproval | Error | None: + """ Approve a durable approval + + Approves a pending approval. The workflow resumes with the decision. + + Only a person can decide: use the dashboard or a platform token from + `volcano login`. Project access tokens are refused with `403`. + + Approving an approval that is already approved returns it unchanged. + If the workflow cannot be reached right away, the decision still + stands and Volcano keeps delivering it. + + Args: + id (UUID): + approval_id (UUID): + body (DurableApprovalDecisionRequest | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DurableApproval | Error + """ + + + return sync_detailed( + id=id, +approval_id=approval_id, +client=client, +body=body, + + ).parsed + +async def asyncio_detailed( + id: UUID | str, + approval_id: UUID | str, + *, + client: AuthenticatedClient, + body: DurableApprovalDecisionRequest | Unset = UNSET, + +) -> Response[DurableApproval | Error]: + """ Approve a durable approval + + Approves a pending approval. The workflow resumes with the decision. + + Only a person can decide: use the dashboard or a platform token from + `volcano login`. Project access tokens are refused with `403`. + + Approving an approval that is already approved returns it unchanged. + If the workflow cannot be reached right away, the decision still + stands and Volcano keeps delivering it. + + Args: + id (UUID): + approval_id (UUID): + body (DurableApprovalDecisionRequest | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DurableApproval | Error] + """ + + + kwargs = request_kwargs( + id=id, +approval_id=approval_id, +body=body, + + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return build_response(client=client, response=response) + +async def asyncio( + id: UUID | str, + approval_id: UUID | str, + *, + client: AuthenticatedClient, + body: DurableApprovalDecisionRequest | Unset = UNSET, + +) -> DurableApproval | Error | None: + """ Approve a durable approval + + Approves a pending approval. The workflow resumes with the decision. + + Only a person can decide: use the dashboard or a platform token from + `volcano login`. Project access tokens are refused with `403`. + + Approving an approval that is already approved returns it unchanged. + If the workflow cannot be reached right away, the decision still + stands and Volcano keeps delivering it. + + Args: + id (UUID): + approval_id (UUID): + body (DurableApprovalDecisionRequest | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DurableApproval | Error + """ + + + return (await asyncio_detailed( + id=id, +approval_id=approval_id, +client=client, +body=body, + + )).parsed diff --git a/src/volcano_sdk/_generated/api/durable_functions/deny_durable_approval.py b/src/volcano_sdk/_generated/api/durable_functions/deny_durable_approval.py new file mode 100644 index 00000000..4920ee9b --- /dev/null +++ b/src/volcano_sdk/_generated/api/durable_functions/deny_durable_approval.py @@ -0,0 +1,277 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.durable_approval import DurableApproval +from ...models.durable_approval_decision_request import DurableApprovalDecisionRequest +from ...models.error import Error +from ...types import UNSET, Unset +from typing import cast +from uuid import UUID + + + +def request_kwargs( + id: UUID | str, + approval_id: UUID | str, + *, + body: DurableApprovalDecisionRequest | Unset = UNSET, + +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + + + + + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/projects/{id}/durable-approvals/{approval_id}/deny".format(id=quote(str(id), safe=""),approval_id=quote(str(approval_id), safe=""),), + } + + + if not isinstance(body, Unset): + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> DurableApproval | Error | None: + if response.status_code == 200: + response_200 = DurableApproval.from_dict(response.json()) + + + + return response_200 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + + + return response_400 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + + + return response_404 + + if response.status_code == 409: + response_409 = Error.from_dict(response.json()) + + + + return response_409 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[DurableApproval | Error]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: UUID | str, + approval_id: UUID | str, + *, + client: AuthenticatedClient, + body: DurableApprovalDecisionRequest | Unset = UNSET, + +) -> Response[DurableApproval | Error]: + """ Deny a durable approval + + Denies a pending approval. The workflow resumes with the decision; a + denial is a value the workflow branches on, not an error. + + Only a person can decide: use the dashboard or a platform token from + `volcano login`. Project access tokens are refused with `403`. + + Denying an approval that is already denied returns it unchanged. + If the workflow cannot be reached right away, the decision still + stands and Volcano keeps delivering it. + + Args: + id (UUID): + approval_id (UUID): + body (DurableApprovalDecisionRequest | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DurableApproval | Error] + """ + + + kwargs = request_kwargs( + id=id, +approval_id=approval_id, +body=body, + + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return build_response(client=client, response=response) + +def sync( + id: UUID | str, + approval_id: UUID | str, + *, + client: AuthenticatedClient, + body: DurableApprovalDecisionRequest | Unset = UNSET, + +) -> DurableApproval | Error | None: + """ Deny a durable approval + + Denies a pending approval. The workflow resumes with the decision; a + denial is a value the workflow branches on, not an error. + + Only a person can decide: use the dashboard or a platform token from + `volcano login`. Project access tokens are refused with `403`. + + Denying an approval that is already denied returns it unchanged. + If the workflow cannot be reached right away, the decision still + stands and Volcano keeps delivering it. + + Args: + id (UUID): + approval_id (UUID): + body (DurableApprovalDecisionRequest | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DurableApproval | Error + """ + + + return sync_detailed( + id=id, +approval_id=approval_id, +client=client, +body=body, + + ).parsed + +async def asyncio_detailed( + id: UUID | str, + approval_id: UUID | str, + *, + client: AuthenticatedClient, + body: DurableApprovalDecisionRequest | Unset = UNSET, + +) -> Response[DurableApproval | Error]: + """ Deny a durable approval + + Denies a pending approval. The workflow resumes with the decision; a + denial is a value the workflow branches on, not an error. + + Only a person can decide: use the dashboard or a platform token from + `volcano login`. Project access tokens are refused with `403`. + + Denying an approval that is already denied returns it unchanged. + If the workflow cannot be reached right away, the decision still + stands and Volcano keeps delivering it. + + Args: + id (UUID): + approval_id (UUID): + body (DurableApprovalDecisionRequest | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DurableApproval | Error] + """ + + + kwargs = request_kwargs( + id=id, +approval_id=approval_id, +body=body, + + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return build_response(client=client, response=response) + +async def asyncio( + id: UUID | str, + approval_id: UUID | str, + *, + client: AuthenticatedClient, + body: DurableApprovalDecisionRequest | Unset = UNSET, + +) -> DurableApproval | Error | None: + """ Deny a durable approval + + Denies a pending approval. The workflow resumes with the decision; a + denial is a value the workflow branches on, not an error. + + Only a person can decide: use the dashboard or a platform token from + `volcano login`. Project access tokens are refused with `403`. + + Denying an approval that is already denied returns it unchanged. + If the workflow cannot be reached right away, the decision still + stands and Volcano keeps delivering it. + + Args: + id (UUID): + approval_id (UUID): + body (DurableApprovalDecisionRequest | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DurableApproval | Error + """ + + + return (await asyncio_detailed( + id=id, +approval_id=approval_id, +client=client, +body=body, + + )).parsed diff --git a/src/volcano_sdk/_generated/api/durable_functions/get_durable_approval.py b/src/volcano_sdk/_generated/api/durable_functions/get_durable_approval.py new file mode 100644 index 00000000..76b9f4bf --- /dev/null +++ b/src/volcano_sdk/_generated/api/durable_functions/get_durable_approval.py @@ -0,0 +1,193 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.durable_approval import DurableApproval +from ...models.error import Error +from typing import cast +from uuid import UUID + + + +def request_kwargs( + id: UUID | str, + approval_id: UUID | str, + +) -> dict[str, Any]: + + + + + + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/projects/{id}/durable-approvals/{approval_id}".format(id=quote(str(id), safe=""),approval_id=quote(str(approval_id), safe=""),), + } + + + return _kwargs + + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> DurableApproval | Error | None: + if response.status_code == 200: + response_200 = DurableApproval.from_dict(response.json()) + + + + return response_200 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + + + return response_404 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[DurableApproval | Error]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: UUID | str, + approval_id: UUID | str, + *, + client: AuthenticatedClient, + +) -> Response[DurableApproval | Error]: + """ Get a durable approval + + Args: + id (UUID): + approval_id (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DurableApproval | Error] + """ + + + kwargs = request_kwargs( + id=id, +approval_id=approval_id, + + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return build_response(client=client, response=response) + +def sync( + id: UUID | str, + approval_id: UUID | str, + *, + client: AuthenticatedClient, + +) -> DurableApproval | Error | None: + """ Get a durable approval + + Args: + id (UUID): + approval_id (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DurableApproval | Error + """ + + + return sync_detailed( + id=id, +approval_id=approval_id, +client=client, + + ).parsed + +async def asyncio_detailed( + id: UUID | str, + approval_id: UUID | str, + *, + client: AuthenticatedClient, + +) -> Response[DurableApproval | Error]: + """ Get a durable approval + + Args: + id (UUID): + approval_id (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DurableApproval | Error] + """ + + + kwargs = request_kwargs( + id=id, +approval_id=approval_id, + + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return build_response(client=client, response=response) + +async def asyncio( + id: UUID | str, + approval_id: UUID | str, + *, + client: AuthenticatedClient, + +) -> DurableApproval | Error | None: + """ Get a durable approval + + Args: + id (UUID): + approval_id (UUID): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DurableApproval | Error + """ + + + return (await asyncio_detailed( + id=id, +approval_id=approval_id, +client=client, + + )).parsed diff --git a/src/volcano_sdk/_generated/api/durable_functions/get_durable_approval_stats.py b/src/volcano_sdk/_generated/api/durable_functions/get_durable_approval_stats.py new file mode 100644 index 00000000..a3a229ac --- /dev/null +++ b/src/volcano_sdk/_generated/api/durable_functions/get_durable_approval_stats.py @@ -0,0 +1,267 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.durable_approval_stats import DurableApprovalStats +from ...models.error import Error +from ...types import UNSET, Unset +from typing import cast +from uuid import UUID +import datetime + + + +def request_kwargs( + id: UUID | str, + *, + function: str | Unset = UNSET, + from_: datetime.datetime | Unset = UNSET, + to: datetime.datetime | Unset = UNSET, + +) -> dict[str, Any]: + + + + + params: dict[str, Any] = {} + + params["function"] = function + + json_from_: str | Unset = UNSET + if not isinstance(from_, Unset): + json_from_ = from_.isoformat() + params["from"] = json_from_ + + json_to: str | Unset = UNSET + if not isinstance(to, Unset): + json_to = to.isoformat() + params["to"] = json_to + + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/projects/{id}/durable-approvals/stats".format(id=quote(str(id), safe=""),), + "params": params, + } + + + return _kwargs + + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> DurableApprovalStats | Error | None: + if response.status_code == 200: + response_200 = DurableApprovalStats.from_dict(response.json()) + + + + return response_200 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + + + return response_400 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[DurableApprovalStats | Error]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: UUID | str, + *, + client: AuthenticatedClient, + function: str | Unset = UNSET, + from_: datetime.datetime | Unset = UNSET, + to: datetime.datetime | Unset = UNSET, + +) -> Response[DurableApprovalStats | Error]: + """ Get durable approval statistics + + Counts the project's approvals by outcome over a time window, overall, + per workflow, and per day. The window defaults to the last 30 days and + can cover up to a year. An approval is counted on the day it was + requested. + + Project access tokens can read statistics, including read-only ones. + + Args: + id (UUID): + function (str | Unset): + from_ (datetime.datetime | Unset): + to (datetime.datetime | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DurableApprovalStats | Error] + """ + + + kwargs = request_kwargs( + id=id, +function=function, +from_=from_, +to=to, + + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return build_response(client=client, response=response) + +def sync( + id: UUID | str, + *, + client: AuthenticatedClient, + function: str | Unset = UNSET, + from_: datetime.datetime | Unset = UNSET, + to: datetime.datetime | Unset = UNSET, + +) -> DurableApprovalStats | Error | None: + """ Get durable approval statistics + + Counts the project's approvals by outcome over a time window, overall, + per workflow, and per day. The window defaults to the last 30 days and + can cover up to a year. An approval is counted on the day it was + requested. + + Project access tokens can read statistics, including read-only ones. + + Args: + id (UUID): + function (str | Unset): + from_ (datetime.datetime | Unset): + to (datetime.datetime | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DurableApprovalStats | Error + """ + + + return sync_detailed( + id=id, +client=client, +function=function, +from_=from_, +to=to, + + ).parsed + +async def asyncio_detailed( + id: UUID | str, + *, + client: AuthenticatedClient, + function: str | Unset = UNSET, + from_: datetime.datetime | Unset = UNSET, + to: datetime.datetime | Unset = UNSET, + +) -> Response[DurableApprovalStats | Error]: + """ Get durable approval statistics + + Counts the project's approvals by outcome over a time window, overall, + per workflow, and per day. The window defaults to the last 30 days and + can cover up to a year. An approval is counted on the day it was + requested. + + Project access tokens can read statistics, including read-only ones. + + Args: + id (UUID): + function (str | Unset): + from_ (datetime.datetime | Unset): + to (datetime.datetime | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DurableApprovalStats | Error] + """ + + + kwargs = request_kwargs( + id=id, +function=function, +from_=from_, +to=to, + + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return build_response(client=client, response=response) + +async def asyncio( + id: UUID | str, + *, + client: AuthenticatedClient, + function: str | Unset = UNSET, + from_: datetime.datetime | Unset = UNSET, + to: datetime.datetime | Unset = UNSET, + +) -> DurableApprovalStats | Error | None: + """ Get durable approval statistics + + Counts the project's approvals by outcome over a time window, overall, + per workflow, and per day. The window defaults to the last 30 days and + can cover up to a year. An approval is counted on the day it was + requested. + + Project access tokens can read statistics, including read-only ones. + + Args: + id (UUID): + function (str | Unset): + from_ (datetime.datetime | Unset): + to (datetime.datetime | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DurableApprovalStats | Error + """ + + + return (await asyncio_detailed( + id=id, +client=client, +function=function, +from_=from_, +to=to, + + )).parsed diff --git a/src/volcano_sdk/_generated/api/durable_functions/list_durable_approvals.py b/src/volcano_sdk/_generated/api/durable_functions/list_durable_approvals.py new file mode 100644 index 00000000..2501866b --- /dev/null +++ b/src/volcano_sdk/_generated/api/durable_functions/list_durable_approvals.py @@ -0,0 +1,352 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.durable_approval_status import check_durable_approval_status +from ...models.durable_approval_status import DurableApprovalStatus +from ...models.error import Error +from ...models.paginated_durable_approvals import PaginatedDurableApprovals +from ...types import UNSET, Unset +from typing import cast +from uuid import UUID +import datetime + + + +def request_kwargs( + id: UUID | str, + *, + page: int | Unset = UNSET, + limit: int | Unset = 10, + status: DurableApprovalStatus | Unset = UNSET, + function: str | Unset = UNSET, + execution_id: UUID | str | Unset = UNSET, + from_: datetime.datetime | Unset = UNSET, + to: datetime.datetime | Unset = UNSET, + +) -> dict[str, Any]: + + + + + params: dict[str, Any] = {} + + params["page"] = page + + params["limit"] = limit + + json_status: str | Unset = UNSET + if not isinstance(status, Unset): + json_status = status + + params["status"] = json_status + + params["function"] = function + + json_execution_id: str | Unset = UNSET + if not isinstance(execution_id, Unset): + json_execution_id = str(execution_id) + params["execution_id"] = json_execution_id + + json_from_: str | Unset = UNSET + if not isinstance(from_, Unset): + json_from_ = from_.isoformat() + params["from"] = json_from_ + + json_to: str | Unset = UNSET + if not isinstance(to, Unset): + json_to = to.isoformat() + params["to"] = json_to + + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/projects/{id}/durable-approvals".format(id=quote(str(id), safe=""),), + "params": params, + } + + + return _kwargs + + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Error | PaginatedDurableApprovals | None: + if response.status_code == 200: + response_200 = PaginatedDurableApprovals.from_dict(response.json()) + + + + return response_200 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + + + return response_400 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Error | PaginatedDurableApprovals]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: UUID | str, + *, + client: AuthenticatedClient, + page: int | Unset = UNSET, + limit: int | Unset = 10, + status: DurableApprovalStatus | Unset = UNSET, + function: str | Unset = UNSET, + execution_id: UUID | str | Unset = UNSET, + from_: datetime.datetime | Unset = UNSET, + to: datetime.datetime | Unset = UNSET, + +) -> Response[Error | PaginatedDurableApprovals]: + """ List durable approvals + + Lists the approvals durable workflows in the project have requested, + newest first. Pending approvals are the ones a workflow is waiting on; + decided, expired, and cancelled ones are kept for a year. + + Project access tokens can list approvals, including read-only ones. + + Args: + id (UUID): + page (int | Unset): + limit (int | Unset): Default: 10. + status (DurableApprovalStatus | Unset): `pending` means the workflow is waiting for a + decision. `approved` and + `denied` are decisions a person made. `expired` means the workflow's + approval timeout passed first, and `cancelled` means the execution + ended while the approval was still pending. Every status but `pending` + is final. + function (str | Unset): + execution_id (UUID | Unset): + from_ (datetime.datetime | Unset): + to (datetime.datetime | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | PaginatedDurableApprovals] + """ + + + kwargs = request_kwargs( + id=id, +page=page, +limit=limit, +status=status, +function=function, +execution_id=execution_id, +from_=from_, +to=to, + + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return build_response(client=client, response=response) + +def sync( + id: UUID | str, + *, + client: AuthenticatedClient, + page: int | Unset = UNSET, + limit: int | Unset = 10, + status: DurableApprovalStatus | Unset = UNSET, + function: str | Unset = UNSET, + execution_id: UUID | str | Unset = UNSET, + from_: datetime.datetime | Unset = UNSET, + to: datetime.datetime | Unset = UNSET, + +) -> Error | PaginatedDurableApprovals | None: + """ List durable approvals + + Lists the approvals durable workflows in the project have requested, + newest first. Pending approvals are the ones a workflow is waiting on; + decided, expired, and cancelled ones are kept for a year. + + Project access tokens can list approvals, including read-only ones. + + Args: + id (UUID): + page (int | Unset): + limit (int | Unset): Default: 10. + status (DurableApprovalStatus | Unset): `pending` means the workflow is waiting for a + decision. `approved` and + `denied` are decisions a person made. `expired` means the workflow's + approval timeout passed first, and `cancelled` means the execution + ended while the approval was still pending. Every status but `pending` + is final. + function (str | Unset): + execution_id (UUID | Unset): + from_ (datetime.datetime | Unset): + to (datetime.datetime | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | PaginatedDurableApprovals + """ + + + return sync_detailed( + id=id, +client=client, +page=page, +limit=limit, +status=status, +function=function, +execution_id=execution_id, +from_=from_, +to=to, + + ).parsed + +async def asyncio_detailed( + id: UUID | str, + *, + client: AuthenticatedClient, + page: int | Unset = UNSET, + limit: int | Unset = 10, + status: DurableApprovalStatus | Unset = UNSET, + function: str | Unset = UNSET, + execution_id: UUID | str | Unset = UNSET, + from_: datetime.datetime | Unset = UNSET, + to: datetime.datetime | Unset = UNSET, + +) -> Response[Error | PaginatedDurableApprovals]: + """ List durable approvals + + Lists the approvals durable workflows in the project have requested, + newest first. Pending approvals are the ones a workflow is waiting on; + decided, expired, and cancelled ones are kept for a year. + + Project access tokens can list approvals, including read-only ones. + + Args: + id (UUID): + page (int | Unset): + limit (int | Unset): Default: 10. + status (DurableApprovalStatus | Unset): `pending` means the workflow is waiting for a + decision. `approved` and + `denied` are decisions a person made. `expired` means the workflow's + approval timeout passed first, and `cancelled` means the execution + ended while the approval was still pending. Every status but `pending` + is final. + function (str | Unset): + execution_id (UUID | Unset): + from_ (datetime.datetime | Unset): + to (datetime.datetime | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | PaginatedDurableApprovals] + """ + + + kwargs = request_kwargs( + id=id, +page=page, +limit=limit, +status=status, +function=function, +execution_id=execution_id, +from_=from_, +to=to, + + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return build_response(client=client, response=response) + +async def asyncio( + id: UUID | str, + *, + client: AuthenticatedClient, + page: int | Unset = UNSET, + limit: int | Unset = 10, + status: DurableApprovalStatus | Unset = UNSET, + function: str | Unset = UNSET, + execution_id: UUID | str | Unset = UNSET, + from_: datetime.datetime | Unset = UNSET, + to: datetime.datetime | Unset = UNSET, + +) -> Error | PaginatedDurableApprovals | None: + """ List durable approvals + + Lists the approvals durable workflows in the project have requested, + newest first. Pending approvals are the ones a workflow is waiting on; + decided, expired, and cancelled ones are kept for a year. + + Project access tokens can list approvals, including read-only ones. + + Args: + id (UUID): + page (int | Unset): + limit (int | Unset): Default: 10. + status (DurableApprovalStatus | Unset): `pending` means the workflow is waiting for a + decision. `approved` and + `denied` are decisions a person made. `expired` means the workflow's + approval timeout passed first, and `cancelled` means the execution + ended while the approval was still pending. Every status but `pending` + is final. + function (str | Unset): + execution_id (UUID | Unset): + from_ (datetime.datetime | Unset): + to (datetime.datetime | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | PaginatedDurableApprovals + """ + + + return (await asyncio_detailed( + id=id, +client=client, +page=page, +limit=limit, +status=status, +function=function, +execution_id=execution_id, +from_=from_, +to=to, + + )).parsed diff --git a/src/volcano_sdk/_generated/api/durable_functions/request_durable_approval.py b/src/volcano_sdk/_generated/api/durable_functions/request_durable_approval.py new file mode 100644 index 00000000..ba8dbc9c --- /dev/null +++ b/src/volcano_sdk/_generated/api/durable_functions/request_durable_approval.py @@ -0,0 +1,280 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.durable_approval_registration import DurableApprovalRegistration +from ...models.error import Error +from ...models.request_durable_approval_request import RequestDurableApprovalRequest +from typing import cast + + + +def request_kwargs( + *, + body: RequestDurableApprovalRequest, + +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + + + + + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/durable-approvals", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> DurableApprovalRegistration | Error | None: + if response.status_code == 200: + response_200 = DurableApprovalRegistration.from_dict(response.json()) + + + + return response_200 + + if response.status_code == 201: + response_201 = DurableApprovalRegistration.from_dict(response.json()) + + + + return response_201 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + + + return response_400 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + + + return response_404 + + if response.status_code == 409: + response_409 = Error.from_dict(response.json()) + + + + return response_409 + + if response.status_code == 413: + response_413 = Error.from_dict(response.json()) + + + + return response_413 + + if response.status_code == 429: + response_429 = Error.from_dict(response.json()) + + + + return response_429 + + if response.status_code == 503: + response_503 = Error.from_dict(response.json()) + + + + return response_503 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[DurableApprovalRegistration | Error]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + body: RequestDurableApprovalRequest, + +) -> Response[DurableApprovalRegistration | Error]: + """ Request an approval from inside a workflow + + Registers an approval request for a running durable execution. The + Volcano SDK calls this from `ctx.waitForApproval`; applications do not + call it directly. + + `execution_ref` and `callback_id` are opaque values the SDK reads from + the running workflow. Together they are the request's credential, so + the operation takes no other authentication. The approval belongs to + the project that owns the execution, and expires when the workflow's + own approval timeout does. + + Retrying is safe: a request for an approval that is already registered + returns it with `200`. + + Args: + body (RequestDurableApprovalRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DurableApprovalRegistration | Error] + """ + + + kwargs = request_kwargs( + body=body, + + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return build_response(client=client, response=response) + +def sync( + *, + client: AuthenticatedClient | Client, + body: RequestDurableApprovalRequest, + +) -> DurableApprovalRegistration | Error | None: + """ Request an approval from inside a workflow + + Registers an approval request for a running durable execution. The + Volcano SDK calls this from `ctx.waitForApproval`; applications do not + call it directly. + + `execution_ref` and `callback_id` are opaque values the SDK reads from + the running workflow. Together they are the request's credential, so + the operation takes no other authentication. The approval belongs to + the project that owns the execution, and expires when the workflow's + own approval timeout does. + + Retrying is safe: a request for an approval that is already registered + returns it with `200`. + + Args: + body (RequestDurableApprovalRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DurableApprovalRegistration | Error + """ + + + return sync_detailed( + client=client, +body=body, + + ).parsed + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + body: RequestDurableApprovalRequest, + +) -> Response[DurableApprovalRegistration | Error]: + """ Request an approval from inside a workflow + + Registers an approval request for a running durable execution. The + Volcano SDK calls this from `ctx.waitForApproval`; applications do not + call it directly. + + `execution_ref` and `callback_id` are opaque values the SDK reads from + the running workflow. Together they are the request's credential, so + the operation takes no other authentication. The approval belongs to + the project that owns the execution, and expires when the workflow's + own approval timeout does. + + Retrying is safe: a request for an approval that is already registered + returns it with `200`. + + Args: + body (RequestDurableApprovalRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DurableApprovalRegistration | Error] + """ + + + kwargs = request_kwargs( + body=body, + + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return build_response(client=client, response=response) + +async def asyncio( + *, + client: AuthenticatedClient | Client, + body: RequestDurableApprovalRequest, + +) -> DurableApprovalRegistration | Error | None: + """ Request an approval from inside a workflow + + Registers an approval request for a running durable execution. The + Volcano SDK calls this from `ctx.waitForApproval`; applications do not + call it directly. + + `execution_ref` and `callback_id` are opaque values the SDK reads from + the running workflow. Together they are the request's credential, so + the operation takes no other authentication. The approval belongs to + the project that owns the execution, and expires when the workflow's + own approval timeout does. + + Retrying is safe: a request for an approval that is already registered + returns it with `200`. + + Args: + body (RequestDurableApprovalRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DurableApprovalRegistration | Error + """ + + + return (await asyncio_detailed( + client=client, +body=body, + + )).parsed diff --git a/src/volcano_sdk/_generated/models/__init__.py b/src/volcano_sdk/_generated/models/__init__.py index ea2acf53..b497f51e 100644 --- a/src/volcano_sdk/_generated/models/__init__.py +++ b/src/volcano_sdk/_generated/models/__init__.py @@ -188,6 +188,18 @@ from .deployment_progress_current_phase import DeploymentProgressCurrentPhase from .deployment_reference import DeploymentReference from .device_authorization_response import DeviceAuthorizationResponse +from .durable_approval import DurableApproval +from .durable_approval_counts import DurableApprovalCounts +from .durable_approval_daily_counts import DurableApprovalDailyCounts +from .durable_approval_decider_type_0 import DurableApprovalDeciderType0 +from .durable_approval_decision_request import DurableApprovalDecisionRequest +from .durable_approval_decision_type_0 import DurableApprovalDecisionType0 +from .durable_approval_execution import DurableApprovalExecution +from .durable_approval_function import DurableApprovalFunction +from .durable_approval_function_counts import DurableApprovalFunctionCounts +from .durable_approval_registration import DurableApprovalRegistration +from .durable_approval_stats import DurableApprovalStats +from .durable_approval_status import DurableApprovalStatus from .durable_execution import DurableExecution from .durable_execution_error import DurableExecutionError from .durable_execution_status import DurableExecutionStatus @@ -322,6 +334,7 @@ from .o_auth_error_response import OAuthErrorResponse from .paginated_auth_users import PaginatedAuthUsers from .paginated_databases import PaginatedDatabases +from .paginated_durable_approvals import PaginatedDurableApprovals from .paginated_durable_executions import PaginatedDurableExecutions from .paginated_durable_functions import PaginatedDurableFunctions from .paginated_frontend_deployments import PaginatedFrontendDeployments @@ -466,6 +479,7 @@ from .refresh_o_auth_provider_token_response_200 import RefreshOAuthProviderTokenResponse200 from .render_default_managed_auth_page_action import RenderDefaultManagedAuthPageAction from .replace_shared_variables_body import ReplaceSharedVariablesBody +from .request_durable_approval_request import RequestDurableApprovalRequest from .reset_database_password_response_200 import ResetDatabasePasswordResponse200 from .resolve_function_response import ResolveFunctionResponse from .resource_reference import ResourceReference @@ -739,6 +753,18 @@ "DeploymentProgressCurrentPhase", "DeploymentReference", "DeviceAuthorizationResponse", + "DurableApproval", + "DurableApprovalCounts", + "DurableApprovalDailyCounts", + "DurableApprovalDeciderType0", + "DurableApprovalDecisionRequest", + "DurableApprovalDecisionType0", + "DurableApprovalExecution", + "DurableApprovalFunction", + "DurableApprovalFunctionCounts", + "DurableApprovalRegistration", + "DurableApprovalStats", + "DurableApprovalStatus", "DurableExecution", "DurableExecutionError", "DurableExecutionStatus", @@ -873,6 +899,7 @@ "OAuthErrorResponse", "PaginatedAuthUsers", "PaginatedDatabases", + "PaginatedDurableApprovals", "PaginatedDurableExecutions", "PaginatedDurableFunctions", "PaginatedFrontendDeployments", @@ -1017,6 +1044,7 @@ "RefreshOAuthProviderTokenResponse200", "RenderDefaultManagedAuthPageAction", "ReplaceSharedVariablesBody", + "RequestDurableApprovalRequest", "ResetDatabasePasswordResponse200", "ResolveFunctionResponse", "ResourceReference", diff --git a/src/volcano_sdk/_generated/models/durable_approval.py b/src/volcano_sdk/_generated/models/durable_approval.py new file mode 100644 index 00000000..a270dd7a --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_approval.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from ..models.durable_approval_status import check_durable_approval_status +from ..models.durable_approval_status import DurableApprovalStatus +from ..types import UNSET, Unset +from typing import cast +from uuid import UUID +import datetime + +if TYPE_CHECKING: + from ..models.durable_approval_decision_type_0 import DurableApprovalDecisionType0 + from ..models.durable_approval_execution import DurableApprovalExecution + from ..models.durable_approval_function import DurableApprovalFunction + + + + + +T = TypeVar("T", bound="DurableApproval") + + + +@_attrs_define +class DurableApproval: + """ An approval a durable workflow requested. + + Attributes: + id (UUID): + status (DurableApprovalStatus): `pending` means the workflow is waiting for a decision. `approved` and + `denied` are decisions a person made. `expired` means the workflow's + approval timeout passed first, and `cancelled` means the execution + ended while the approval was still pending. Every status but `pending` + is final. + name (str): The name the workflow gave the approval in `ctx.waitForApproval`. + title (str): + description (str): + function (DurableApprovalFunction): The durable function that requested the approval. `id` is null once the + function has been deleted; `name` is kept. + execution (DurableApprovalExecution): The durable execution that requested the approval. `id` and `status` + are null once the execution is no longer retained; `name` is kept. + requested_at (datetime.datetime): + expires_at (datetime.datetime | None): When the approval expires if nobody decides. Null when the + workflow set no timeout; the approval then lasts as long as its + execution. + decision (DurableApprovalDecisionType0 | None): Who decided and when. Null unless the approval was approved or + denied. + details (Any | Unset): The JSON value the workflow attached for the person deciding, as + given. + """ + + id: UUID + status: DurableApprovalStatus + name: str + title: str + description: str + function: DurableApprovalFunction + execution: DurableApprovalExecution + requested_at: datetime.datetime + expires_at: datetime.datetime | None + decision: DurableApprovalDecisionType0 | None + details: Any | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + from ..models.durable_approval_decision_type_0 import DurableApprovalDecisionType0 # noqa: PLC0415 + from ..models.durable_approval_execution import DurableApprovalExecution # noqa: PLC0415 + from ..models.durable_approval_function import DurableApprovalFunction # noqa: PLC0415 + id = str(self.id) + + status: str = self.status + + name = self.name + + title = self.title + + description = self.description + + function = self.function.to_dict() + + execution = self.execution.to_dict() + + requested_at = self.requested_at.isoformat() + + expires_at: None | str + if isinstance(self.expires_at, datetime.datetime): + expires_at = self.expires_at.isoformat() + else: + expires_at = self.expires_at + + decision: dict[str, Any] | None + if isinstance(self.decision, DurableApprovalDecisionType0): + decision = self.decision.to_dict() + else: + decision = self.decision + + details = self.details + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "id": id, + "status": status, + "name": name, + "title": title, + "description": description, + "function": function, + "execution": execution, + "requested_at": requested_at, + "expires_at": expires_at, + "decision": decision, + }) + if details is not UNSET: + field_dict["details"] = details + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.durable_approval_decision_type_0 import DurableApprovalDecisionType0 # noqa: PLC0415 + from ..models.durable_approval_execution import DurableApprovalExecution # noqa: PLC0415 + from ..models.durable_approval_function import DurableApprovalFunction # noqa: PLC0415 + d = dict(src_dict) + id = UUID(d.pop("id")) + + + + + status = check_durable_approval_status(d.pop("status")) + + + + + name = d.pop("name") + + title = d.pop("title") + + description = d.pop("description") + + function = DurableApprovalFunction.from_dict(d.pop("function")) + + + + + execution = DurableApprovalExecution.from_dict(d.pop("execution")) + + + + + requested_at = datetime.datetime.fromisoformat(d.pop("requested_at")) + + + + + def _parse_expires_at(data: object) -> datetime.datetime | None: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + expires_at_type_0 = datetime.datetime.fromisoformat(data) + + + + return expires_at_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.datetime | None, data) + + expires_at = _parse_expires_at(d.pop("expires_at")) + + + def _parse_decision(data: object) -> DurableApprovalDecisionType0 | None: + if data is None: + return data + try: + if not isinstance(data, dict): + raise TypeError() + componentsschemas_durable_approval_decision_type_0 = DurableApprovalDecisionType0.from_dict(data) + + + + return componentsschemas_durable_approval_decision_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(DurableApprovalDecisionType0 | None, data) + + decision = _parse_decision(d.pop("decision")) + + + details = d.pop("details", UNSET) + + durable_approval = cls( + id=id, + status=status, + name=name, + title=title, + description=description, + function=function, + execution=execution, + requested_at=requested_at, + expires_at=expires_at, + decision=decision, + details=details, + ) + + + durable_approval.additional_properties = d + return durable_approval + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/volcano_sdk/_generated/models/durable_approval_counts.py b/src/volcano_sdk/_generated/models/durable_approval_counts.py new file mode 100644 index 00000000..b1a19455 --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_approval_counts.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + + + + + + + +T = TypeVar("T", bound="DurableApprovalCounts") + + + +@_attrs_define +class DurableApprovalCounts: + """ Approvals by status. `requested` is every approval, whatever its status. + + Attributes: + requested (int): + pending (int): + approved (int): + denied (int): + expired (int): + cancelled (int): + """ + + requested: int + pending: int + approved: int + denied: int + expired: int + cancelled: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + requested = self.requested + + pending = self.pending + + approved = self.approved + + denied = self.denied + + expired = self.expired + + cancelled = self.cancelled + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "requested": requested, + "pending": pending, + "approved": approved, + "denied": denied, + "expired": expired, + "cancelled": cancelled, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + requested = d.pop("requested") + + pending = d.pop("pending") + + approved = d.pop("approved") + + denied = d.pop("denied") + + expired = d.pop("expired") + + cancelled = d.pop("cancelled") + + durable_approval_counts = cls( + requested=requested, + pending=pending, + approved=approved, + denied=denied, + expired=expired, + cancelled=cancelled, + ) + + + durable_approval_counts.additional_properties = d + return durable_approval_counts + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/volcano_sdk/_generated/models/durable_approval_daily_counts.py b/src/volcano_sdk/_generated/models/durable_approval_daily_counts.py new file mode 100644 index 00000000..2d76f504 --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_approval_daily_counts.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from typing import cast +import datetime + +if TYPE_CHECKING: + from ..models.durable_approval_counts import DurableApprovalCounts + + + + + +T = TypeVar("T", bound="DurableApprovalDailyCounts") + + + +@_attrs_define +class DurableApprovalDailyCounts: + """ + Attributes: + date (datetime.date): + counts (DurableApprovalCounts): Approvals by status. `requested` is every approval, whatever its status. + """ + + date: datetime.date + counts: DurableApprovalCounts + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + from ..models.durable_approval_counts import DurableApprovalCounts # noqa: PLC0415 + date = self.date.isoformat() + + counts = self.counts.to_dict() + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "date": date, + "counts": counts, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.durable_approval_counts import DurableApprovalCounts # noqa: PLC0415 + d = dict(src_dict) + date = datetime.date.fromisoformat(d.pop("date")) + + + + + counts = DurableApprovalCounts.from_dict(d.pop("counts")) + + + + + durable_approval_daily_counts = cls( + date=date, + counts=counts, + ) + + + durable_approval_daily_counts.additional_properties = d + return durable_approval_daily_counts + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/volcano_sdk/_generated/models/durable_approval_decider_type_0.py b/src/volcano_sdk/_generated/models/durable_approval_decider_type_0.py new file mode 100644 index 00000000..ad216085 --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_approval_decider_type_0.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + + + + + + + +T = TypeVar("T", bound="DurableApprovalDeciderType0") + + + +@_attrs_define +class DurableApprovalDeciderType0: + """ The person who decided. Null once their account is deleted. + + Attributes: + id (str): + email (str): + """ + + id: str + email: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + id = self.id + + email = self.email + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "id": id, + "email": email, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + id = d.pop("id") + + email = d.pop("email") + + durable_approval_decider_type_0 = cls( + id=id, + email=email, + ) + + + durable_approval_decider_type_0.additional_properties = d + return durable_approval_decider_type_0 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/volcano_sdk/_generated/models/durable_approval_decision_request.py b/src/volcano_sdk/_generated/models/durable_approval_decision_request.py new file mode 100644 index 00000000..7f383b9e --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_approval_decision_request.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from ..types import UNSET, Unset + + + + + + +T = TypeVar("T", bound="DurableApprovalDecisionRequest") + + + +@_attrs_define +class DurableApprovalDecisionRequest: + """ + Attributes: + comment (str | Unset): A note for the workflow and the approval's history. + """ + + comment: str | Unset = UNSET + + + + + + def to_dict(self) -> dict[str, Any]: + comment = self.comment + + + field_dict: dict[str, Any] = {} + + field_dict.update({ + }) + if comment is not UNSET: + field_dict["comment"] = comment + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + comment = d.pop("comment", UNSET) + + durable_approval_decision_request = cls( + comment=comment, + ) + + return durable_approval_decision_request + diff --git a/src/volcano_sdk/_generated/models/durable_approval_decision_type_0.py b/src/volcano_sdk/_generated/models/durable_approval_decision_type_0.py new file mode 100644 index 00000000..056b03f4 --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_approval_decision_type_0.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from typing import cast +import datetime + +if TYPE_CHECKING: + from ..models.durable_approval_decider_type_0 import DurableApprovalDeciderType0 + + + + + +T = TypeVar("T", bound="DurableApprovalDecisionType0") + + + +@_attrs_define +class DurableApprovalDecisionType0: + """ Who decided and when. Null unless the approval was approved or denied. + + Attributes: + comment (str): + decided_by (DurableApprovalDeciderType0 | None): The person who decided. Null once their account is deleted. + decided_at (datetime.datetime): + """ + + comment: str + decided_by: DurableApprovalDeciderType0 | None + decided_at: datetime.datetime + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + from ..models.durable_approval_decider_type_0 import DurableApprovalDeciderType0 # noqa: PLC0415 + comment = self.comment + + decided_by: dict[str, Any] | None + if isinstance(self.decided_by, DurableApprovalDeciderType0): + decided_by = self.decided_by.to_dict() + else: + decided_by = self.decided_by + + decided_at = self.decided_at.isoformat() + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "comment": comment, + "decided_by": decided_by, + "decided_at": decided_at, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.durable_approval_decider_type_0 import DurableApprovalDeciderType0 # noqa: PLC0415 + d = dict(src_dict) + comment = d.pop("comment") + + def _parse_decided_by(data: object) -> DurableApprovalDeciderType0 | None: + if data is None: + return data + try: + if not isinstance(data, dict): + raise TypeError() + componentsschemas_durable_approval_decider_type_0 = DurableApprovalDeciderType0.from_dict(data) + + + + return componentsschemas_durable_approval_decider_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(DurableApprovalDeciderType0 | None, data) + + decided_by = _parse_decided_by(d.pop("decided_by")) + + + decided_at = datetime.datetime.fromisoformat(d.pop("decided_at")) + + + + + durable_approval_decision_type_0 = cls( + comment=comment, + decided_by=decided_by, + decided_at=decided_at, + ) + + + durable_approval_decision_type_0.additional_properties = d + return durable_approval_decision_type_0 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/volcano_sdk/_generated/models/durable_approval_execution.py b/src/volcano_sdk/_generated/models/durable_approval_execution.py new file mode 100644 index 00000000..f6faaeee --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_approval_execution.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from ..models.durable_execution_status import check_durable_execution_status +from ..models.durable_execution_status import DurableExecutionStatus +from typing import cast +from uuid import UUID + + + + + + +T = TypeVar("T", bound="DurableApprovalExecution") + + + +@_attrs_define +class DurableApprovalExecution: + """ The durable execution that requested the approval. `id` and `status` + are null once the execution is no longer retained; `name` is kept. + + Attributes: + id (None | UUID): + name (str): + status (DurableExecutionStatus | None): + """ + + id: None | UUID + name: str + status: DurableExecutionStatus | None + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + id: None | str + if isinstance(self.id, UUID): + id = str(self.id) + else: + id = self.id + + name = self.name + + status: None | str + if isinstance(self.status, str): + status = self.status + else: + status = self.status + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "id": id, + "name": name, + "status": status, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + def _parse_id(data: object) -> None | UUID: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + id_type_0 = UUID(data) + + + + return id_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(None | UUID, data) + + id = _parse_id(d.pop("id")) + + + name = d.pop("name") + + def _parse_status(data: object) -> DurableExecutionStatus | None: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + status_type_1 = check_durable_execution_status(data) + + + + return status_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(DurableExecutionStatus | None, data) + + status = _parse_status(d.pop("status")) + + + durable_approval_execution = cls( + id=id, + name=name, + status=status, + ) + + + durable_approval_execution.additional_properties = d + return durable_approval_execution + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/volcano_sdk/_generated/models/durable_approval_function.py b/src/volcano_sdk/_generated/models/durable_approval_function.py new file mode 100644 index 00000000..c9f5fcbf --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_approval_function.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from typing import cast +from uuid import UUID + + + + + + +T = TypeVar("T", bound="DurableApprovalFunction") + + + +@_attrs_define +class DurableApprovalFunction: + """ The durable function that requested the approval. `id` is null once the + function has been deleted; `name` is kept. + + Attributes: + id (None | UUID): + name (str): + """ + + id: None | UUID + name: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + id: None | str + if isinstance(self.id, UUID): + id = str(self.id) + else: + id = self.id + + name = self.name + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "id": id, + "name": name, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + def _parse_id(data: object) -> None | UUID: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + id_type_0 = UUID(data) + + + + return id_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(None | UUID, data) + + id = _parse_id(d.pop("id")) + + + name = d.pop("name") + + durable_approval_function = cls( + id=id, + name=name, + ) + + + durable_approval_function.additional_properties = d + return durable_approval_function + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/volcano_sdk/_generated/models/durable_approval_function_counts.py b/src/volcano_sdk/_generated/models/durable_approval_function_counts.py new file mode 100644 index 00000000..ca5090c8 --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_approval_function_counts.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from typing import cast + +if TYPE_CHECKING: + from ..models.durable_approval_counts import DurableApprovalCounts + from ..models.durable_approval_function import DurableApprovalFunction + + + + + +T = TypeVar("T", bound="DurableApprovalFunctionCounts") + + + +@_attrs_define +class DurableApprovalFunctionCounts: + """ + Attributes: + function (DurableApprovalFunction): The durable function that requested the approval. `id` is null once the + function has been deleted; `name` is kept. + counts (DurableApprovalCounts): Approvals by status. `requested` is every approval, whatever its status. + """ + + function: DurableApprovalFunction + counts: DurableApprovalCounts + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + from ..models.durable_approval_counts import DurableApprovalCounts # noqa: PLC0415 + from ..models.durable_approval_function import DurableApprovalFunction # noqa: PLC0415 + function = self.function.to_dict() + + counts = self.counts.to_dict() + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "function": function, + "counts": counts, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.durable_approval_counts import DurableApprovalCounts # noqa: PLC0415 + from ..models.durable_approval_function import DurableApprovalFunction # noqa: PLC0415 + d = dict(src_dict) + function = DurableApprovalFunction.from_dict(d.pop("function")) + + + + + counts = DurableApprovalCounts.from_dict(d.pop("counts")) + + + + + durable_approval_function_counts = cls( + function=function, + counts=counts, + ) + + + durable_approval_function_counts.additional_properties = d + return durable_approval_function_counts + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/volcano_sdk/_generated/models/durable_approval_registration.py b/src/volcano_sdk/_generated/models/durable_approval_registration.py new file mode 100644 index 00000000..1bc3af7d --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_approval_registration.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from ..models.durable_approval_status import check_durable_approval_status +from ..models.durable_approval_status import DurableApprovalStatus +from typing import cast +from uuid import UUID +import datetime + + + + + + +T = TypeVar("T", bound="DurableApprovalRegistration") + + + +@_attrs_define +class DurableApprovalRegistration: + """ The registered approval, as the requesting workflow sees it. + + Attributes: + id (UUID): + status (DurableApprovalStatus): `pending` means the workflow is waiting for a decision. `approved` and + `denied` are decisions a person made. `expired` means the workflow's + approval timeout passed first, and `cancelled` means the execution + ended while the approval was still pending. Every status but `pending` + is final. + expires_at (datetime.datetime | None): + """ + + id: UUID + status: DurableApprovalStatus + expires_at: datetime.datetime | None + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + id = str(self.id) + + status: str = self.status + + expires_at: None | str + if isinstance(self.expires_at, datetime.datetime): + expires_at = self.expires_at.isoformat() + else: + expires_at = self.expires_at + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "id": id, + "status": status, + "expires_at": expires_at, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + id = UUID(d.pop("id")) + + + + + status = check_durable_approval_status(d.pop("status")) + + + + + def _parse_expires_at(data: object) -> datetime.datetime | None: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + expires_at_type_0 = datetime.datetime.fromisoformat(data) + + + + return expires_at_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.datetime | None, data) + + expires_at = _parse_expires_at(d.pop("expires_at")) + + + durable_approval_registration = cls( + id=id, + status=status, + expires_at=expires_at, + ) + + + durable_approval_registration.additional_properties = d + return durable_approval_registration + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/volcano_sdk/_generated/models/durable_approval_stats.py b/src/volcano_sdk/_generated/models/durable_approval_stats.py new file mode 100644 index 00000000..f6a09115 --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_approval_stats.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from typing import cast +import datetime + +if TYPE_CHECKING: + from ..models.durable_approval_counts import DurableApprovalCounts + from ..models.durable_approval_daily_counts import DurableApprovalDailyCounts + from ..models.durable_approval_function_counts import DurableApprovalFunctionCounts + + + + + +T = TypeVar("T", bound="DurableApprovalStats") + + + +@_attrs_define +class DurableApprovalStats: + """ + Attributes: + from_ (datetime.datetime): + to (datetime.datetime): + counts (DurableApprovalCounts): Approvals by status. `requested` is every approval, whatever its status. + approval_rate (float | None): `approved / (approved + denied)`, from 0 to 1. Null when nothing in + the window was decided. + median_seconds_to_decision (float | None): Median time from request to decision. Null when nothing was decided. + p90_seconds_to_decision (float | None): 90th percentile time from request to decision. Null when nothing was + decided. + functions (list[DurableApprovalFunctionCounts]): The ten workflows that requested the most approvals, most + first. + The rest are summed in `other_functions`. + other_functions (DurableApprovalCounts): Approvals by status. `requested` is every approval, whatever its + status. + daily (list[DurableApprovalDailyCounts]): One entry per UTC day in the window that has approvals, oldest + first. Days with none are omitted. + """ + + from_: datetime.datetime + to: datetime.datetime + counts: DurableApprovalCounts + approval_rate: float | None + median_seconds_to_decision: float | None + p90_seconds_to_decision: float | None + functions: list[DurableApprovalFunctionCounts] + other_functions: DurableApprovalCounts + daily: list[DurableApprovalDailyCounts] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + from ..models.durable_approval_counts import DurableApprovalCounts # noqa: PLC0415 + from ..models.durable_approval_daily_counts import DurableApprovalDailyCounts # noqa: PLC0415 + from ..models.durable_approval_function_counts import DurableApprovalFunctionCounts # noqa: PLC0415 + from_ = self.from_.isoformat() + + to = self.to.isoformat() + + counts = self.counts.to_dict() + + approval_rate: float | None + approval_rate = self.approval_rate + + median_seconds_to_decision: float | None + median_seconds_to_decision = self.median_seconds_to_decision + + p90_seconds_to_decision: float | None + p90_seconds_to_decision = self.p90_seconds_to_decision + + functions = [] + for functions_item_data in self.functions: + functions_item = functions_item_data.to_dict() + functions.append(functions_item) + + + + other_functions = self.other_functions.to_dict() + + daily = [] + for daily_item_data in self.daily: + daily_item = daily_item_data.to_dict() + daily.append(daily_item) + + + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "from": from_, + "to": to, + "counts": counts, + "approval_rate": approval_rate, + "median_seconds_to_decision": median_seconds_to_decision, + "p90_seconds_to_decision": p90_seconds_to_decision, + "functions": functions, + "other_functions": other_functions, + "daily": daily, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.durable_approval_counts import DurableApprovalCounts # noqa: PLC0415 + from ..models.durable_approval_daily_counts import DurableApprovalDailyCounts # noqa: PLC0415 + from ..models.durable_approval_function_counts import DurableApprovalFunctionCounts # noqa: PLC0415 + d = dict(src_dict) + from_ = datetime.datetime.fromisoformat(d.pop("from")) + + + + + to = datetime.datetime.fromisoformat(d.pop("to")) + + + + + counts = DurableApprovalCounts.from_dict(d.pop("counts")) + + + + + def _parse_approval_rate(data: object) -> float | None: + if data is None: + return data + return cast(float | None, data) + + approval_rate = _parse_approval_rate(d.pop("approval_rate")) + + + def _parse_median_seconds_to_decision(data: object) -> float | None: + if data is None: + return data + return cast(float | None, data) + + median_seconds_to_decision = _parse_median_seconds_to_decision(d.pop("median_seconds_to_decision")) + + + def _parse_p90_seconds_to_decision(data: object) -> float | None: + if data is None: + return data + return cast(float | None, data) + + p90_seconds_to_decision = _parse_p90_seconds_to_decision(d.pop("p90_seconds_to_decision")) + + + functions = [] + _functions = d.pop("functions") + for functions_item_data in (_functions): + functions_item = DurableApprovalFunctionCounts.from_dict(functions_item_data) + + + + functions.append(functions_item) + + + other_functions = DurableApprovalCounts.from_dict(d.pop("other_functions")) + + + + + daily = [] + _daily = d.pop("daily") + for daily_item_data in (_daily): + daily_item = DurableApprovalDailyCounts.from_dict(daily_item_data) + + + + daily.append(daily_item) + + + durable_approval_stats = cls( + from_=from_, + to=to, + counts=counts, + approval_rate=approval_rate, + median_seconds_to_decision=median_seconds_to_decision, + p90_seconds_to_decision=p90_seconds_to_decision, + functions=functions, + other_functions=other_functions, + daily=daily, + ) + + + durable_approval_stats.additional_properties = d + return durable_approval_stats + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/volcano_sdk/_generated/models/durable_approval_status.py b/src/volcano_sdk/_generated/models/durable_approval_status.py new file mode 100644 index 00000000..a17f9cab --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_approval_status.py @@ -0,0 +1,10 @@ +from typing import Literal + +DurableApprovalStatus = Literal['approved', 'cancelled', 'denied', 'expired', 'pending'] + +DURABLE_APPROVAL_STATUS_VALUES: set[DurableApprovalStatus] = { 'approved', 'cancelled', 'denied', 'expired', 'pending', } + +def check_durable_approval_status(value: str) -> DurableApprovalStatus: + if value in DURABLE_APPROVAL_STATUS_VALUES: + return value + raise TypeError(f"Unexpected value {value!r}. Expected one of {DURABLE_APPROVAL_STATUS_VALUES!r}") diff --git a/src/volcano_sdk/_generated/models/paginated_durable_approvals.py b/src/volcano_sdk/_generated/models/paginated_durable_approvals.py new file mode 100644 index 00000000..0855c22f --- /dev/null +++ b/src/volcano_sdk/_generated/models/paginated_durable_approvals.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from typing import cast + +if TYPE_CHECKING: + from ..models.durable_approval import DurableApproval + + + + + +T = TypeVar("T", bound="PaginatedDurableApprovals") + + + +@_attrs_define +class PaginatedDurableApprovals: + """ + Attributes: + data (list[DurableApproval]): + page (int): Current page number (1-indexed) + limit (int): Number of items per page + total (int): Total number of items across all pages + has_more (bool): Whether there are more pages available + """ + + data: list[DurableApproval] + page: int + limit: int + total: int + has_more: bool + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + from ..models.durable_approval import DurableApproval # noqa: PLC0415 + data = [] + for data_item_data in self.data: + data_item = data_item_data.to_dict() + data.append(data_item) + + + + page = self.page + + limit = self.limit + + total = self.total + + has_more = self.has_more + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "data": data, + "page": page, + "limit": limit, + "total": total, + "has_more": has_more, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.durable_approval import DurableApproval # noqa: PLC0415 + d = dict(src_dict) + data = [] + _data = d.pop("data") + for data_item_data in (_data): + data_item = DurableApproval.from_dict(data_item_data) + + + + data.append(data_item) + + + page = d.pop("page") + + limit = d.pop("limit") + + total = d.pop("total") + + has_more = d.pop("has_more") + + paginated_durable_approvals = cls( + data=data, + page=page, + limit=limit, + total=total, + has_more=has_more, + ) + + + paginated_durable_approvals.additional_properties = d + return paginated_durable_approvals + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/volcano_sdk/_generated/models/request_durable_approval_request.py b/src/volcano_sdk/_generated/models/request_durable_approval_request.py new file mode 100644 index 00000000..043cff5e --- /dev/null +++ b/src/volcano_sdk/_generated/models/request_durable_approval_request.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from ..types import UNSET, Unset + + + + + + +T = TypeVar("T", bound="RequestDurableApprovalRequest") + + + +@_attrs_define +class RequestDurableApprovalRequest: + """ + Attributes: + execution_ref (str): Opaque reference to the running execution, supplied by the SDK. + callback_id (str): Opaque approval reference, supplied by the SDK. + name (str): + title (str): + description (str | Unset): + details (Any | Unset): Any JSON value to show the person deciding. The whole request is + limited to 64 KiB. + """ + + execution_ref: str + callback_id: str + name: str + title: str + description: str | Unset = UNSET + details: Any | Unset = UNSET + + + + + + def to_dict(self) -> dict[str, Any]: + execution_ref = self.execution_ref + + callback_id = self.callback_id + + name = self.name + + title = self.title + + description = self.description + + details = self.details + + + field_dict: dict[str, Any] = {} + + field_dict.update({ + "execution_ref": execution_ref, + "callback_id": callback_id, + "name": name, + "title": title, + }) + if description is not UNSET: + field_dict["description"] = description + if details is not UNSET: + field_dict["details"] = details + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + execution_ref = d.pop("execution_ref") + + callback_id = d.pop("callback_id") + + name = d.pop("name") + + title = d.pop("title") + + description = d.pop("description", UNSET) + + details = d.pop("details", UNSET) + + request_durable_approval_request = cls( + execution_ref=execution_ref, + callback_id=callback_id, + name=name, + title=title, + description=description, + details=details, + ) + + return request_durable_approval_request + From 1b3381acdb50a48f317fc58199f54d51cfa4dbe9 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Tue, 6 Oct 2026 17:40:29 -0700 Subject: [PATCH 02/16] feat(durable): add durable approvals DurableContext.wait_for_approval registers an approval with Volcano from the runtime's callback submitter and suspends until a person decides. A denial or a timeout returns an ApprovalDecision rather than raising. @durable reads the execution reference from the invocation and passes it to child, map, and parallel contexts. client.durable.approvals lists, reads, summarizes, approves, and denies approvals. HTTP 403 now raises PermissionDeniedError, a subclass of AuthenticationError. Co-authored-by: Cursor --- README.md | 43 +- docs/functions.md | 96 +- src/volcano_sdk/__init__.py | 28 + .../_durable_approval_registration.py | 194 ++++ src/volcano_sdk/_durable_approval_response.py | 296 ++++++ src/volcano_sdk/_durable_engine.py | 30 +- src/volcano_sdk/_durable_modules.py | 30 + src/volcano_sdk/_durable_protocols.py | 11 + src/volcano_sdk/_durable_response.py | 17 +- src/volcano_sdk/_durable_results.py | 21 +- .../_tests/fixtures/durable_context.py | 19 +- .../_tests/fixtures/durable_engine.py | 4 +- .../_tests/fixtures/durable_inspection.py | 3 + .../_tests/fixtures/invalid_arguments.py | 21 + .../test_durable_approval_registration.py | 448 +++++++++ .../_tests/test_durable_approvals.py | 893 ++++++++++++++++++ .../_tests/test_durable_authoring.py | 555 ++++++++++- .../_tests/test_durable_runtime_boundary.py | 8 + src/volcano_sdk/_tests/test_errors.py | 3 +- src/volcano_sdk/_transport.py | 6 + .../_transport_durable_approvals.py | 121 +++ src/volcano_sdk/_transport_types.py | 28 +- src/volcano_sdk/durable.py | 323 ++++++- src/volcano_sdk/durable_authoring.py | 156 ++- src/volcano_sdk/errors.py | 8 + src/volcano_sdk/models.py | 172 +++- 26 files changed, 3487 insertions(+), 47 deletions(-) create mode 100644 src/volcano_sdk/_durable_approval_registration.py create mode 100644 src/volcano_sdk/_durable_approval_response.py create mode 100644 src/volcano_sdk/_tests/test_durable_approval_registration.py create mode 100644 src/volcano_sdk/_tests/test_durable_approvals.py create mode 100644 src/volcano_sdk/_transport_durable_approvals.py diff --git a/README.md b/README.md index 8380be4f..34074bef 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,24 @@ outcome it could not determine — as finished. `stop()` is accepted rather than awaited: what it returns is the execution read back after asking, often still `running`, so poll `get()` to see it reach `stopped`. Repeating a stop is safe. +`durable.approvals` reads and decides the approvals a durable function waits on: + +```python +approvals = owner_client.durable.approvals +page = approvals.list(project_id, status="pending", function="order-pipeline") +approval = approvals.approve(project_id, page.approvals[0].id, comment="Looks right") +stats = approvals.stats(project_id) +``` + +`list()`, `get()` and `stats()` accept the project owner's platform user token or +a project access token. `approve()` and `deny()` are for a person: a project +access token raises `PermissionDeniedError`, a subclass of `AuthenticationError`. +Repeating the same decision returns the approval unchanged; a conflicting one, or +deciding an approval that expired or whose execution ended, raises +`ConflictError` with `code` set to `approval_decided`, `approval_expired`, or +`approval_cancelled`. See the +[functions guide](https://github.com/Kong/volcano-sdk-python/blob/main/docs/functions.md#decide-approvals-from-a-backend). + ## Write a durable function `volcano_sdk.durable_authoring` is what the durable function itself is written @@ -332,6 +350,7 @@ the clock or a random value. | `ctx.step(name, fn, retry=..., at_most_once=...)` | Runs one atomic operation and records its result. `retry=False` fails on the first error; `RetryOptions` sets attempts and backoff. | | `ctx.wait(name, duration)` | Suspends the execution. `"30s"`, `"2h"`, `"1m30s"`, a whole number of seconds, or `{"hours": 2}`. | | `ctx.wait_until(check, options, name=None)` | Polls your own state until `options.until` holds, suspending between checks. `options.initial_state` is required. | +| `ctx.wait_for_approval(name, title=..., description=None, details=None, timeout=None)` | Suspends until a person approves or denies, and returns an `ApprovalDecision`. A timeout returns `status="expired"` rather than raising. | | `ctx.map(items, fn, name=None, options=None)` | Runs the same work over every item, each in its own child context. | | `ctx.parallel(branches, name=None, options=None)` | Runs independent branches at the same time. | | `ctx.child(name, fn)` | Groups operations under one recorded context. | @@ -372,8 +391,28 @@ volcano durable start order-pipeline --input '{"order_id":"order-9"}' Local waits resolve immediately by default while preserving checkpoint and replay behavior. Set `LOCAL_DURABLE_REAL_TIME=true` before `volcano start` when wait timing must match the deployed function. Local executions persist across -`volcano stop` and `volcano start`. Volcano does not expose externally completed -callbacks; use `ctx.wait_until` to poll application state instead. +`volcano stop` and `volcano start`. + +`ctx.wait_for_approval` registers the approval with Volcano and suspends until it +is decided, works the same locally, and replays the recorded decision on resume. +A denial or timeout is a value to branch on: + +```python +decision = ctx.wait_for_approval( + "ship-order", + title=f"Ship order {event['order_id']}?", + details={"total": event["total"]}, + timeout="24h", +) +if not decision.approved: + return {"shipped": False, "status": decision.status} +``` + +`decision` carries `approved`, `status` (`approved`, `denied`, or `expired`), +`comment`, `decided_by` (`id` and `email`, or `None`), and `decided_at`. Volcano +sets `VOLCANO_PLATFORM_API_URL` on durable functions; without it the call raises +`RuntimeError`. See the +[functions guide](https://github.com/Kong/volcano-sdk-python/blob/main/docs/functions.md#wait-for-an-approval). `logs.search()` returns an immutable page of retained runtime or deployment log events. Pass `next_cursor` back as `cursor` to continue a search. `logs.activity()` diff --git a/docs/functions.md b/docs/functions.md index 7dec0bb8..d0ccfa15 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -128,7 +128,99 @@ functions: kind: durable ``` -Volcano records each context operation. Resumed executions replay recorded results instead of repeating completed work. Keep changing decisions inside `ctx.step()`. Use `ctx.wait_until()` to poll application state. Volcano does not expose externally completed callbacks. +Volcano records each context operation. Resumed executions replay recorded results instead of repeating completed work. Keep changing decisions inside `ctx.step()`. Use `ctx.wait_until()` to poll application state. + +## Wait for an approval + +`ctx.wait_for_approval()` pauses the execution until a person approves or denies it from the dashboard, the CLI, or the [owner client](#decide-approvals-from-a-backend): + +```python +from volcano_sdk.durable_authoring import durable + + +@durable +def handler(event, ctx): + order = ctx.step("load-order", lambda scope: load_order(event["order_id"])) + + decision = ctx.wait_for_approval( + "ship-order", + title=f"Ship order {order['id']}?", + description="Orders over $500 need a second look.", + details={"order_id": order["id"], "total": order["total"]}, + timeout="24h", + ) + + if not decision.approved: + ctx.step("release-stock", lambda scope: release_stock(order["id"])) + return {"shipped": False, "status": decision.status} + + ctx.step("ship", lambda scope: ship_order(order["id"])) + return {"shipped": True, "comment": decision.comment} +``` + +| Argument | Description | +|---|---| +| `name` | Operation name, up to 255 characters. Filter and search approvals by it. | +| `title` | Required. What the approver is asked, up to 200 characters. | +| `description` | Optional context, up to 4000 characters. | +| `details` | Optional JSON value shown with the approval. | +| `timeout` | Optional duration in the `ctx.wait()` format, from one second to 366 days. Without it, the approval stays open as long as the execution runs. | + +The call returns an immutable `ApprovalDecision`: + +| Field | Approved | Denied | Timed out | +|---|---|---|---| +| `approved` | `True` | `False` | `False` | +| `status` | `"approved"` | `"denied"` | `"expired"` | +| `comment` | The approver's comment, or `""` | The approver's comment, or `""` | `""` | +| `decided_by` | `DurableApprovalDecider` with `id` and `email`, or `None` | Same as approved | `None` | +| `decided_at` | RFC 3339 timestamp | RFC 3339 timestamp | `None` | + +A denial or a timeout is a value to branch on, not an exception. The execution costs nothing while it waits, and a resumed execution replays the recorded decision without asking again. + +Volcano sets `VOLCANO_PLATFORM_API_URL` on deployed and local durable functions. The function sends the approval there without a credential. Volcano accepts it only from the execution that is waiting. Transient failures are retried for about 30 seconds. If Volcano refuses the approval, the execution fails with the SDK error. Calling `wait_for_approval()` without `VOLCANO_PLATFORM_API_URL`, or from a handler that is not decorated with `@durable`, raises `RuntimeError`. + +## Decide approvals from a backend + +`client.durable.approvals` lists, reads, and decides a project's approvals: + +```python +import os +from datetime import UTC, datetime, timedelta + +from volcano_sdk import ConflictError, VolcanoClient + +owner_client = VolcanoClient( + anon_key=os.environ["VOLCANO_ANON_KEY"], + api_url=os.environ.get("VOLCANO_API_URL", "https://api.volcano.dev"), + access_token=os.environ["VOLCANO_PLATFORM_TOKEN"], +) +approvals = owner_client.durable.approvals + +page = approvals.list(project_id, status="pending", function="charge-order") +for approval in page.approvals: + print(approval.id, approval.title, approval.details, approval.expires_at) + +try: + approval = approvals.approve(project_id, approval_id, comment="Address checked") +except ConflictError as error: + print(error.code) # approval_decided, approval_expired, or approval_cancelled + +stats = approvals.stats(project_id, from_=datetime.now(UTC) - timedelta(days=7)) +print(stats.counts.pending, stats.approval_rate, stats.median_seconds_to_decision) +``` + +| Method | Returns | +|---|---| +| `list(project_id, status=..., function=..., execution_id=..., from_=..., to=..., page=..., limit=...)` | `DurableApprovalPage` with `approvals`, `page`, `limit`, `total`, and `has_more`. | +| `get(project_id, approval_id)` | `DurableApproval`. | +| `stats(project_id, function=..., from_=..., to=...)` | `DurableApprovalStats` with counts by status, decision times, and per-function and daily counts. | +| `approve(project_id, approval_id, comment=None)` | The decided `DurableApproval`. | +| `deny(project_id, approval_id, comment=None)` | The decided `DurableApproval`. | + +`status` is one of `pending`, `approved`, `denied`, `expired`, or `cancelled`. An approval is `cancelled` when its execution ends first. `function` takes a durable function's id or name. `from_` and `to` must be timezone-aware datetimes. Stats default to the last 30 days and cover at most 366 days. A comment is up to 2000 characters. + +Read approvals with the project owner's platform user token or a project access token. Only a person decides: `approve()` and `deny()` need a platform user token, and a project access token raises `PermissionDeniedError`. Repeating the same decision returns the approval unchanged. A conflicting decision, or deciding an expired or cancelled approval, raises `ConflictError`. An unknown approval raises `NotFoundError`. `PermissionDeniedError` subclasses `AuthenticationError`, so existing handlers still catch it. ## Run durable functions locally @@ -142,4 +234,6 @@ volcano durable start charge-order --input '{"order_id":"order-9"}' Local waits resolve immediately by default while preserving checkpoint and replay behavior. Set `LOCAL_DURABLE_REAL_TIME=true` before `volcano start` when wait timing must match the deployed function. Local executions persist across `volcano stop` and `volcano start`. +Approvals work the same locally. Decide one with `volcano durable approvals approve` or `volcano durable approvals deny`, or point the owner client at the local server. Approval timeouts run in real time. + Running a decorated handler directly in a Python process still needs the optional test runtime: `python -m pip install 'volcano-sdk-python[durable]'`. diff --git a/src/volcano_sdk/__init__.py b/src/volcano_sdk/__init__.py index 05186c8c..51193485 100644 --- a/src/volcano_sdk/__init__.py +++ b/src/volcano_sdk/__init__.py @@ -7,6 +7,7 @@ AuthenticationError, ConflictError, NotFoundError, + PermissionDeniedError, RateLimitedError, ServerError, SessionChangedError, @@ -19,6 +20,19 @@ AuthSession, AuthStateCallback, AuthSubscription, + DurableApproval, + DurableApprovalCounts, + DurableApprovalDailyCounts, + DurableApprovalDecider, + DurableApprovalDecision, + DurableApprovalExecution, + DurableApprovalFunction, + DurableApprovalFunctionCounts, + DurableApprovalListOptions, + DurableApprovalPage, + DurableApprovalStats, + DurableApprovalStatsOptions, + DurableApprovalStatus, DurableExecution, DurableExecutionFailure, DurableExecutionPage, @@ -58,6 +72,19 @@ "AuthSubscription", "AuthenticationError", "ConflictError", + "DurableApproval", + "DurableApprovalCounts", + "DurableApprovalDailyCounts", + "DurableApprovalDecider", + "DurableApprovalDecision", + "DurableApprovalExecution", + "DurableApprovalFunction", + "DurableApprovalFunctionCounts", + "DurableApprovalListOptions", + "DurableApprovalPage", + "DurableApprovalStats", + "DurableApprovalStatsOptions", + "DurableApprovalStatus", "DurableExecution", "DurableExecutionFailure", "DurableExecutionPage", @@ -73,6 +100,7 @@ "NotFoundError", "OAuthProviderName", "OAuthProviderTokenStatus", + "PermissionDeniedError", "PostgresChange", "RateLimitedError", "RealtimeConnectContext", diff --git a/src/volcano_sdk/_durable_approval_registration.py b/src/volcano_sdk/_durable_approval_registration.py new file mode 100644 index 00000000..ccd4c3f9 --- /dev/null +++ b/src/volcano_sdk/_durable_approval_registration.py @@ -0,0 +1,194 @@ +"""Register a durable approval with Volcano, and read the decision it returns.""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from time import sleep +from typing import TYPE_CHECKING, TypeGuard + +import httpx + +from ._durable_response import json_value +from ._durable_results import ApprovalDecision +from ._transport_response import ( + invoke, + plain_json, + response_payload, + unparsed_response, +) +from ._transport_types import HTTP_CREATED, HTTP_OK, decode_json +from .errors import ( + ConflictError, + RateLimitedError, + ServerError, + TransportError, + VolcanoError, +) +from .models import DurableApprovalDecider + +if TYPE_CHECKING: + from .models import JSONValue + +# 0.5 s doubling to a 5 s cap, about 30 s in all: long enough to outlast the +# moment the platform has not yet seen the callback open. +_RETRY_DELAYS = (0.5, 1.0, 2.0, 4.0, 5.0, 5.0, 5.0, 5.0) +_REQUEST_TIMEOUT_SECONDS = 10.0 +_NOT_READY = "approval_not_ready" +_INVALID_DECISION = "Expected a complete approval decision" +_API_URL_VARIABLE = "VOLCANO_PLATFORM_API_URL" +_MISSING_API_URL = ( + "wait_for_approval() needs VOLCANO_PLATFORM_API_URL. Volcano sets it on " + "deployed durable functions; set it yourself to run the handler elsewhere." +) +_INVALID_DETAILS = "details must be a JSON value" +# The spec's limits on what the approval shows the person deciding. +_TEXT_LIMITS = {"name": 255, "title": 200, "description": 4000} +EXPIRED = ApprovalDecision(approved=False, status="expired") + + +def platform_api_url() -> str: + """Read the Volcano API address the platform gives durable functions. + + Returns: + The API base URL. + + Raises: + RuntimeError: The variable is unset or empty. + + """ + url = os.environ.get(_API_URL_VARIABLE, "").strip() + if not url: + raise RuntimeError(_MISSING_API_URL) + return url + + +def approval_body( + name: object, title: object, description: object, details: object +) -> dict[str, JSONValue]: + """Validate what the approval shows, before any callback is opened. + + Returns: + The registration body without the execution reference or callback id. + + """ + body: dict[str, JSONValue] = { + "name": _text(name, "name", required=True), + "title": _text(title, "title", required=True), + } + if description is not None: + body["description"] = _text(description, "description", required=False) + if details is not None: + body["details"] = json_value(details, _INVALID_DETAILS) + return body + + +def _text(value: object, field: str, *, required: bool) -> str: + if not isinstance(value, str): + message = f"{field} must be a string" + raise TypeError(message) + if required and not value.strip(): + message = f"{field} must not be empty" + raise ValueError(message) + limit = _TEXT_LIMITS[field] + if len(value) > limit: + message = f"{field} must be at most {limit} characters" + raise ValueError(message) + return value + + +def register_approval(api_url: str, body: Mapping[str, JSONValue]) -> None: + """Ask Volcano to record the approval and hold the callback for a person. + + The route takes no credential: the execution reference and callback id in + the body are what Volcano checks against the running execution. + + Raises: + VolcanoError: Volcano refused the approval, or kept failing until the + retries ran out. + + """ + url = f"{api_url.removesuffix('/')}/durable-approvals" + with httpx.Client(timeout=_REQUEST_TIMEOUT_SECONDS) as client: + for delay in _RETRY_DELAYS: + try: + _post(client, url, body) + except VolcanoError as error: + if not _retryable(error): + raise + sleep(delay) + else: + return + _post(client, url, body) + + +def _post(client: httpx.Client, url: str, body: Mapping[str, JSONValue]) -> None: + response = unparsed_response(invoke(client.post, url, json=plain_json(body))) + if response.status_code != HTTP_CREATED: + _ = response_payload(response, HTTP_OK) + + +def _retryable(error: VolcanoError) -> bool: + if isinstance(error, (TransportError, RateLimitedError, ServerError)): + return True + return isinstance(error, ConflictError) and error.code == _NOT_READY + + +def approval_decision(result: object) -> ApprovalDecision: + """Read the decision Volcano completed the callback with. + + Returns: + The approved or denied decision. + + Raises: + TypeError: The callback result is not a decision. + + """ + values = _decision_fields(result) + status = values.get("status") + approved = status == "approved" + if status not in {"approved", "denied"} or values.get("approved") is not approved: + raise TypeError(_INVALID_DECISION) + comment = values.get("comment", "") + decided_at = values.get("decided_at") + if not isinstance(comment, str) or not isinstance(decided_at, str): + raise TypeError(_INVALID_DECISION) + return ApprovalDecision( + approved=approved, + status="approved" if approved else "denied", + comment=comment, + decided_by=_decider(values.get("decided_by")), + decided_at=decided_at, + ) + + +def _decision_fields(result: object) -> Mapping[str, object]: + if isinstance(result, (str, bytes)): + document = result.encode() if isinstance(result, str) else result + try: + result = decode_json(document) + except ValueError as error: + raise TypeError(_INVALID_DECISION) from error + if not _is_fields(result): + raise TypeError(_INVALID_DECISION) + return result + + +def _is_fields(value: object) -> TypeGuard[Mapping[str, object]]: + return _is_mapping(value) and all(isinstance(key, str) for key in value) + + +def _is_mapping(value: object) -> TypeGuard[Mapping[object, object]]: + return isinstance(value, Mapping) + + +def _decider(value: object) -> DurableApprovalDecider | None: + if value is None: + return None + if not _is_fields(value): + raise TypeError(_INVALID_DECISION) + decider_id = value.get("id") + email = value.get("email") + if not isinstance(decider_id, str) or not isinstance(email, str): + raise TypeError(_INVALID_DECISION) + return DurableApprovalDecider(id=decider_id, email=email) diff --git a/src/volcano_sdk/_durable_approval_response.py b/src/volcano_sdk/_durable_approval_response.py new file mode 100644 index 00000000..b7b1db6d --- /dev/null +++ b/src/volcano_sdk/_durable_approval_response.py @@ -0,0 +1,296 @@ +"""Validate durable approval responses before exposing immutable models.""" + +from __future__ import annotations + +import math +from collections.abc import Mapping +from datetime import date, datetime +from typing import TypeGuard + +from ._durable_response import json_value +from .models import ( + DurableApproval, + DurableApprovalCounts, + DurableApprovalDailyCounts, + DurableApprovalDecider, + DurableApprovalDecision, + DurableApprovalExecution, + DurableApprovalFunction, + DurableApprovalFunctionCounts, + DurableApprovalPage, + DurableApprovalStats, + DurableApprovalStatus, + DurableExecutionStatus, +) + +_INVALID_APPROVAL = "Expected a complete durable approval" +_INVALID_PAGE = "Expected a complete durable approval page" +_INVALID_STATS = "Expected complete durable approval stats" +APPROVAL_STATUSES: tuple[DurableApprovalStatus, ...] = ( + "pending", + "approved", + "denied", + "expired", + "cancelled", +) +_EXECUTION_STATUSES: tuple[DurableExecutionStatus, ...] = ( + "pending", + "running", + "succeeded", + "failed", + "timed_out", + "stopped", + "unknown", +) +_COUNT_FIELDS = ("requested", "pending", "approved", "denied", "expired", "cancelled") + + +def _is_fields(value: object) -> TypeGuard[Mapping[str, object]]: + return _is_mapping(value) and all(isinstance(key, str) for key in value) + + +def _is_mapping(value: object) -> TypeGuard[Mapping[object, object]]: + return isinstance(value, Mapping) + + +def _is_sequence(value: object) -> TypeGuard[list[object] | tuple[object, ...]]: + return isinstance(value, (list, tuple)) + + +def _fields(value: object, message: str) -> Mapping[str, object]: + if not _is_fields(value): + raise TypeError(message) + return value + + +def _text(values: Mapping[str, object], key: str, message: str) -> str: + value = values.get(key) + if not isinstance(value, str): + raise TypeError(message) + return value + + +def _identifier(values: Mapping[str, object], key: str, message: str) -> str: + value = _text(values, key, message) + if not value: + raise TypeError(message) + return value + + +def _optional_identifier(values: Mapping[str, object], key: str) -> str | None: + if values.get(key) is None: + return None + return _text(values, key, _INVALID_APPROVAL) + + +def _timestamp(value: object, message: str) -> datetime: + if not isinstance(value, str) or not value: + raise TypeError(message) + try: + return datetime.fromisoformat(value) + except ValueError as error: + raise TypeError(message) from error + + +def _status(value: object) -> DurableApprovalStatus: + for status in APPROVAL_STATUSES: + if value == status: + return status + raise TypeError(_INVALID_APPROVAL) + + +def _execution_status(value: object) -> DurableExecutionStatus | None: + if value is None: + return None + for status in _EXECUTION_STATUSES: + if value == status: + return status + raise TypeError(_INVALID_APPROVAL) + + +def _function(payload: object, message: str) -> DurableApprovalFunction: + values = _fields(payload, message) + function_id = values.get("id") + if function_id is not None and not isinstance(function_id, str): + raise TypeError(message) + return DurableApprovalFunction( + id=function_id, name=_identifier(values, "name", message) + ) + + +def _execution(payload: object) -> DurableApprovalExecution: + values = _fields(payload, _INVALID_APPROVAL) + return DurableApprovalExecution( + id=_optional_identifier(values, "id"), + name=_identifier(values, "name", _INVALID_APPROVAL), + status=_execution_status(values.get("status")), + ) + + +def _decider(payload: object) -> DurableApprovalDecider | None: + if payload is None: + return None + values = _fields(payload, _INVALID_APPROVAL) + return DurableApprovalDecider( + id=_text(values, "id", _INVALID_APPROVAL), + email=_text(values, "email", _INVALID_APPROVAL), + ) + + +def _decision(payload: object) -> DurableApprovalDecision | None: + if payload is None: + return None + values = _fields(payload, _INVALID_APPROVAL) + return DurableApprovalDecision( + comment=_text(values, "comment", _INVALID_APPROVAL), + decided_by=_decider(values.get("decided_by")), + decided_at=_timestamp(values.get("decided_at"), _INVALID_APPROVAL), + ) + + +def _expires_at(value: object) -> datetime | None: + if value is None: + return None + return _timestamp(value, _INVALID_APPROVAL) + + +def durable_approval(payload: object) -> DurableApproval: + """Validate and snapshot one durable approval. + + Returns: + The complete approval model. + + """ + values = _fields(payload, _INVALID_APPROVAL) + return DurableApproval( + id=_identifier(values, "id", _INVALID_APPROVAL), + status=_status(values.get("status")), + name=_text(values, "name", _INVALID_APPROVAL), + title=_text(values, "title", _INVALID_APPROVAL), + description=_text(values, "description", _INVALID_APPROVAL), + function=_function(values.get("function"), _INVALID_APPROVAL), + execution=_execution(values.get("execution")), + requested_at=_timestamp(values.get("requested_at"), _INVALID_APPROVAL), + expires_at=_expires_at(values.get("expires_at")), + decision=_decision(values.get("decision")), + details=json_value(values.get("details"), _INVALID_APPROVAL), + ) + + +def durable_approval_page(payload: object) -> DurableApprovalPage: + """Validate and snapshot a page of approvals. + + Returns: + Approvals and validated pagination metadata. + + Raises: + TypeError: The page container or pagination fields are invalid. + + """ + values = _fields(payload, _INVALID_PAGE) + data: object = values.get("data") + if data is None: + data = list[object]() + if not _is_sequence(data): + raise TypeError(_INVALID_PAGE) + has_more = values.get("has_more", False) + if not isinstance(has_more, bool): + raise TypeError(_INVALID_PAGE) + return DurableApprovalPage( + approvals=tuple(durable_approval(entry) for entry in data), + page=_page_count(values.get("page")), + limit=_page_count(values.get("limit")), + total=_page_count(values.get("total")), + has_more=has_more, + ) + + +def _page_count(value: object) -> int: + if value is None: + return 0 + if type(value) is not int: + raise TypeError(_INVALID_PAGE) + return value + + +def _count(values: Mapping[str, object], key: str) -> int: + value = values.get(key) + if type(value) is not int: + raise TypeError(_INVALID_STATS) + return value + + +def _counts(payload: object) -> DurableApprovalCounts: + values = _fields(payload, _INVALID_STATS) + requested, pending, approved, denied, expired, cancelled = ( + _count(values, key) for key in _COUNT_FIELDS + ) + return DurableApprovalCounts( + requested=requested, + pending=pending, + approved=approved, + denied=denied, + expired=expired, + cancelled=cancelled, + ) + + +def _rate(value: object) -> float | None: + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(_INVALID_STATS) + if not math.isfinite(value): + raise TypeError(_INVALID_STATS) + return float(value) + + +def _entries(value: object) -> tuple[Mapping[str, object], ...]: + if not _is_sequence(value): + raise TypeError(_INVALID_STATS) + return tuple(_fields(entry, _INVALID_STATS) for entry in value) + + +def _function_counts(values: Mapping[str, object]) -> DurableApprovalFunctionCounts: + return DurableApprovalFunctionCounts( + function=_function(values.get("function"), _INVALID_STATS), + counts=_counts(values.get("counts")), + ) + + +def _day(value: object) -> date: + if not isinstance(value, str): + raise TypeError(_INVALID_STATS) + try: + return date.fromisoformat(value) + except ValueError as error: + raise TypeError(_INVALID_STATS) from error + + +def _daily_counts(values: Mapping[str, object]) -> DurableApprovalDailyCounts: + return DurableApprovalDailyCounts( + day=_day(values.get("date")), counts=_counts(values.get("counts")) + ) + + +def durable_approval_stats(payload: object) -> DurableApprovalStats: + """Validate and snapshot approval stats. + + Returns: + The complete stats model. + + """ + values = _fields(payload, _INVALID_STATS) + return DurableApprovalStats( + from_=_timestamp(values.get("from"), _INVALID_STATS), + to=_timestamp(values.get("to"), _INVALID_STATS), + counts=_counts(values.get("counts")), + approval_rate=_rate(values.get("approval_rate")), + median_seconds_to_decision=_rate(values.get("median_seconds_to_decision")), + p90_seconds_to_decision=_rate(values.get("p90_seconds_to_decision")), + functions=tuple( + _function_counts(entry) for entry in _entries(values.get("functions")) + ), + other_functions=_counts(values.get("other_functions")), + daily=tuple(_daily_counts(entry) for entry in _entries(values.get("daily"))), + ) diff --git a/src/volcano_sdk/_durable_engine.py b/src/volcano_sdk/_durable_engine.py index 1d6e8a1d..ef199481 100644 --- a/src/volcano_sdk/_durable_engine.py +++ b/src/volcano_sdk/_durable_engine.py @@ -9,7 +9,13 @@ from typing_extensions import TypeVar from ._durable_duration import to_seconds -from ._durable_modules import load_config, load_retries, load_root, load_waits +from ._durable_modules import ( + load_config, + load_exceptions, + load_retries, + load_root, + load_waits, +) from ._durable_options import BatchOptions, RetryOptions, Unset if TYPE_CHECKING: @@ -22,6 +28,7 @@ ParallelConfig, StepConfig, StepSemantics, + WaitForCallbackConfig, ) from aws_durable_execution_sdk_python.config import Duration as EngineDuration from aws_durable_execution_sdk_python.retries import ( @@ -89,6 +96,7 @@ def __init__(self) -> None: retries = load_retries() waits = load_waits() root = load_root() + exceptions = load_exceptions() except ImportError as error: raise DurableRuntimeMissingError from error self.durable_execution: DurableExecution = root.durable_execution @@ -109,6 +117,10 @@ def __init__(self) -> None: self.create_wait_strategy: CreateWaitStrategy = waits.create_wait_strategy self.wait_strategy_config: WaitStrategyFactory = waits.WaitStrategyConfig self.wait_for_condition_config: WaitConfigFactory = waits.WaitForConditionConfig + self.wait_for_callback_config: type[WaitForCallbackConfig] = ( + config.WaitForCallbackConfig + ) + self.callback_timeout_error: type[Exception] = exceptions.CallbackTimeoutError def seconds(self, value: int) -> EngineDuration: """Build the runtime's duration from whole seconds. @@ -242,6 +254,22 @@ def _set_wait_timing( if options.backoff_rate is not None: config.backoff_rate = options.backoff_rate + def callback_options(self, timeout: int | None) -> WaitForCallbackConfig: + """Build the runtime's callback options. + + The submitter is not retried by the runtime: it already retries what is + worth retrying, and anything else it raises will not go away. + + Returns: + The runtime wait-for-callback configuration. + + """ + if timeout is None: + return self.wait_for_callback_config(retry_strategy=self._never_retry()) + return self.wait_for_callback_config( + timeout=self.seconds(timeout), retry_strategy=self._never_retry() + ) + def map_options(self, options: BatchOptions | None) -> object: """Build the runtime's map options. diff --git a/src/volcano_sdk/_durable_modules.py b/src/volcano_sdk/_durable_modules.py index 0434cfcb..2cfff4fe 100644 --- a/src/volcano_sdk/_durable_modules.py +++ b/src/volcano_sdk/_durable_modules.py @@ -18,7 +18,9 @@ ParallelConfig, StepConfig, StepSemantics, + WaitForCallbackConfig, ) + from aws_durable_execution_sdk_python.exceptions import CallbackTimeoutError from aws_durable_execution_sdk_python.retries import ( RetryDecision, RetryStrategyConfig, @@ -87,6 +89,14 @@ class ConfigModule(Protocol): ParallelConfig: type[ParallelConfig] CompletionConfig: type[CompletionConfig] ParallelBranch: type[ParallelBranch[object]] + WaitForCallbackConfig: type[WaitForCallbackConfig] + + +@runtime_checkable +class ExceptionsModule(Protocol): + """Runtime errors the authoring adapter turns into values.""" + + CallbackTimeoutError: type[CallbackTimeoutError] @runtime_checkable @@ -168,6 +178,26 @@ def load_waits() -> WaitsModule: return module +def load_exceptions() -> ExceptionsModule: + """Validate the optional runtime's exceptions module. + + Returns: + The validated module interface. + + Raises: + TypeError: The installed runtime lacks a required public export. + + """ + module = _import_module("aws_durable_execution_sdk_python.exceptions") + if not isinstance(module, ExceptionsModule): + message = ( + "aws_durable_execution_sdk_python.exceptions does not provide " + "ExceptionsModule" + ) + raise TypeError(message) + return module + + def load_root() -> RootModule: """Validate the optional runtime's root module. diff --git a/src/volcano_sdk/_durable_protocols.py b/src/volcano_sdk/_durable_protocols.py index 9b260cfe..fcf5b78d 100644 --- a/src/volcano_sdk/_durable_protocols.py +++ b/src/volcano_sdk/_durable_protocols.py @@ -52,8 +52,12 @@ def exception( class DurableEngine(Protocol): """The operations the Volcano facade needs from the optional runtime.""" + callback_timeout_error: type[Exception] + def seconds(self, value: int) -> object: ... + def callback_options(self, timeout: int | None) -> object: ... + def step_options(self, *, retry: Retry, at_most_once: bool) -> object: ... def wait_condition_options(self, options: WaitUntilOptions[T]) -> object: ... @@ -133,4 +137,11 @@ def parallel( config: object, ) -> RuntimeBatch[T]: ... + def wait_for_callback( + self, + submitter: Callable[[str, object], None], + name: str | None, + config: object, + ) -> object: ... + def set_logger(self, logger: object) -> None: ... diff --git a/src/volcano_sdk/_durable_response.py b/src/volcano_sdk/_durable_response.py index 73075fef..50a42d1d 100644 --- a/src/volcano_sdk/_durable_response.py +++ b/src/volcano_sdk/_durable_response.py @@ -58,12 +58,25 @@ def _execution_status(value: object) -> DurableExecutionStatus: def _json_result(value: object) -> JSONValue: + return json_value(value, _INVALID_EXECUTION_PAYLOAD) + + +def json_value(value: object, message: str) -> JSONValue: + """Require a JSON value that can be frozen and re-encoded. + + Returns: + The value, narrowed to JSON. + + Raises: + TypeError: The value is not JSON, or is nested too deeply to check. + + """ try: if _is_json_value(value, set()): return value except RecursionError as error: - raise TypeError(_INVALID_EXECUTION_PAYLOAD) from error - raise TypeError(_INVALID_EXECUTION_PAYLOAD) + raise TypeError(message) from error + raise TypeError(message) def _is_json_value(value: object, active: set[int]) -> TypeGuard[JSONValue]: diff --git a/src/volcano_sdk/_durable_results.py b/src/volcano_sdk/_durable_results.py index e8c808df..63fb6279 100644 --- a/src/volcano_sdk/_durable_results.py +++ b/src/volcano_sdk/_durable_results.py @@ -3,16 +3,35 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Generic +from typing import TYPE_CHECKING, Generic, Literal from typing_extensions import TypeVar if TYPE_CHECKING: from ._durable_protocols import RuntimeBatch, RuntimeBatchItem + from .models import DurableApprovalDecider T = TypeVar("T", default=object) +@dataclass(frozen=True, slots=True) +class ApprovalDecision: + """What `ctx.wait_for_approval` resumes with. + + A denial is a decision like an approval, not an error; branch on + `approved`. An approval nobody decided before its timeout comes back + `expired`, with no comment, decider, or decision time. + """ + + approved: bool + status: Literal["approved", "denied", "expired"] + comment: str = "" + # None when the approval expired, or the deciding account no longer exists. + decided_by: DurableApprovalDecider | None = None + # RFC 3339, as Volcano recorded it; None when the approval expired. + decided_at: str | None = None + + @dataclass(frozen=True, slots=True) class BatchFailure: """Why one item of a batch failed. diff --git a/src/volcano_sdk/_tests/fixtures/durable_context.py b/src/volcano_sdk/_tests/fixtures/durable_context.py index 46b35788..af4d9dd7 100644 --- a/src/volcano_sdk/_tests/fixtures/durable_context.py +++ b/src/volcano_sdk/_tests/fixtures/durable_context.py @@ -1,4 +1,4 @@ -"""A typed runtime context that records batch calls without scheduling them.""" +"""A typed runtime context that records calls without scheduling them.""" from __future__ import annotations @@ -109,6 +109,9 @@ def __init__(self) -> None: self.config: object = None self.map_items: list[object] | None = None self.map_result: object = None + self.submitter: Callable[[str, object], None] | None = None + self.callback_result: object = None + self.callback_error: Exception | None = None @override def step( @@ -173,6 +176,20 @@ def parallel( self.config = config return EmptyBatch[T]() + @override + def wait_for_callback( + self, + submitter: Callable[[str, object], None], + name: str | None, + config: object, + ) -> object: + self.name = name + self.config = config + self.submitter = submitter + if self.callback_error is not None: + raise self.callback_error + return self.callback_result + @override def set_logger(self, logger: object) -> None: _ = logger diff --git a/src/volcano_sdk/_tests/fixtures/durable_engine.py b/src/volcano_sdk/_tests/fixtures/durable_engine.py index bcbeed72..f38c2fa9 100644 --- a/src/volcano_sdk/_tests/fixtures/durable_engine.py +++ b/src/volcano_sdk/_tests/fixtures/durable_engine.py @@ -2,7 +2,7 @@ from __future__ import annotations -from aws_durable_execution_sdk_python import config, retries, waits +from aws_durable_execution_sdk_python import config, exceptions, retries, waits from volcano_sdk._durable_engine import load_engine from volcano_sdk._durable_modules import load_root @@ -27,3 +27,5 @@ def assert_runtime_surface() -> None: assert engine.create_wait_strategy is waits.create_wait_strategy assert engine.wait_strategy_config is waits.WaitStrategyConfig assert engine.wait_for_condition_config is waits.WaitForConditionConfig + assert engine.wait_for_callback_config is config.WaitForCallbackConfig + assert engine.callback_timeout_error is exceptions.CallbackTimeoutError diff --git a/src/volcano_sdk/_tests/fixtures/durable_inspection.py b/src/volcano_sdk/_tests/fixtures/durable_inspection.py index e3474d34..452d78b2 100644 --- a/src/volcano_sdk/_tests/fixtures/durable_inspection.py +++ b/src/volcano_sdk/_tests/fixtures/durable_inspection.py @@ -39,6 +39,9 @@ class InspectedDurableContext(DurableContext): def wait_duration(self, value: object) -> object: return self._wait_duration(value) + def approval_timeout(self, value: object) -> int: + return self._approval_timeout(value) + class ClosingScheduler(Scheduler): """Close the upstream scheduler loop after its worker thread stops.""" diff --git a/src/volcano_sdk/_tests/fixtures/invalid_arguments.py b/src/volcano_sdk/_tests/fixtures/invalid_arguments.py index 464a9bf6..fcbaba4b 100644 --- a/src/volcano_sdk/_tests/fixtures/invalid_arguments.py +++ b/src/volcano_sdk/_tests/fixtures/invalid_arguments.py @@ -7,6 +7,8 @@ from typing import BinaryIO from volcano_sdk.auth import Auth + from volcano_sdk.durable import DurableApprovals + from volcano_sdk.durable_authoring import DurableContext from volcano_sdk.logs import Logs from volcano_sdk.models import ( JSONValue, @@ -181,3 +183,22 @@ def unsupported_hosted_auth_action(auth: Auth) -> None: action="device", # type: ignore[arg-type] # pyright: ignore[reportArgumentType] state="state-value", ) + + +def non_string_approval_comment(approvals: DurableApprovals, approval_id: str) -> None: + project_id = "00000000-0000-4000-8000-000000000001" + _ = approvals.approve(project_id, approval_id, comment=5) # type: ignore[arg-type] # pyright: ignore[reportArgumentType] + + +def unknown_approval_status(approvals: DurableApprovals) -> None: + project_id = "00000000-0000-4000-8000-000000000001" + _ = approvals.list(project_id, status="waiting") # type: ignore[arg-type] # pyright: ignore[reportArgumentType] + + +def non_string_approval_title(context: DurableContext) -> None: + _ = context.wait_for_approval("ship", title=None) # type: ignore[arg-type] # pyright: ignore[reportArgumentType] + + +def unknown_approval_option(approvals: DurableApprovals) -> None: + project_id = "00000000-0000-4000-8000-000000000001" + approvals.stats(project_id, state="pending", sort="asc") # type: ignore[call-arg] # pyright: ignore[reportCallIssue] diff --git a/src/volcano_sdk/_tests/test_durable_approval_registration.py b/src/volcano_sdk/_tests/test_durable_approval_registration.py new file mode 100644 index 00000000..95436d45 --- /dev/null +++ b/src/volcano_sdk/_tests/test_durable_approval_registration.py @@ -0,0 +1,448 @@ +from __future__ import annotations + +import json +from types import MappingProxyType +from typing import TYPE_CHECKING, Protocol + +import httpx +import pytest + +from volcano_sdk import ( + ConflictError, + NotFoundError, + RateLimitedError, + ServerError, + TransportError, + ValidationError, + VolcanoError, +) +from volcano_sdk._durable_approval_registration import ( + approval_body, + approval_decision, + platform_api_url, + register_approval, +) +from volcano_sdk.durable_authoring import ApprovalDecision +from volcano_sdk.models import DurableApprovalDecider + +if TYPE_CHECKING: + from collections.abc import Callable + + from volcano_sdk.models import JSONValue + +API_URL = "https://api.volcano.test" +BODY: dict[str, JSONValue] = { + "execution_ref": "arn:execution/order-1234", + "callback_id": "callback-1", + "name": "ship-order", + "title": "Ship order 1234?", +} +RETRY_DELAYS = [0.5, 1.0, 2.0, 4.0, 5.0, 5.0, 5.0, 5.0] +# The fixture replaces httpx.Client for the module under test, which is the +# same attribute everywhere. +REAL_CLIENT = httpx.Client + + +class Platform: + """Answer registrations in order and record what was sent.""" + + def __init__(self, *answers: httpx.Response | Exception) -> None: + self.answers: list[httpx.Response | Exception] = list(answers) + self.requests: list[httpx.Request] = [] + self.sleeps: list[float] = [] + self.client_timeouts: list[object] = [] + + def handle(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + answer = self.answers[0] if len(self.answers) == 1 else self.answers.pop(0) + if isinstance(answer, Exception): + raise answer + return answer + + def client(self, *, timeout: float) -> httpx.Client: + self.client_timeouts.append(timeout) + return REAL_CLIENT(timeout=timeout, transport=httpx.MockTransport(self.handle)) + + +class InstallPlatform(Protocol): + def __call__(self, *answers: httpx.Response | Exception) -> Platform: ... + + +@pytest.fixture +def platform(monkeypatch: pytest.MonkeyPatch) -> InstallPlatform: + def install(*answers: httpx.Response | Exception) -> Platform: + fake = Platform(*answers) + monkeypatch.setattr( + "volcano_sdk._durable_approval_registration.httpx.Client", fake.client + ) + monkeypatch.setattr( + "volcano_sdk._durable_approval_registration.sleep", fake.sleeps.append + ) + return fake + + return install + + +def registered(status: int = 201) -> httpx.Response: + return httpx.Response( + status, + json={"id": "approval-1", "status": "pending", "expires_at": None}, + ) + + +def refused(status: int, code: str | None = None) -> httpx.Response: + body = {"error": f"refused with {status}"} + if code is not None: + body["code"] = code + return httpx.Response(status, json=body) + + +def test_registration_posts_the_body_without_credentials( + platform: InstallPlatform, +) -> None: + fake = platform(registered()) + + register_approval(f"{API_URL}/", BODY) + + assert len(fake.requests) == 1 + request = fake.requests[0] + assert request.method == "POST" + assert str(request.url) == f"{API_URL}/durable-approvals" + assert "authorization" not in request.headers + assert request.headers["content-type"] == "application/json" + assert json.loads(request.content) == BODY + assert fake.sleeps == [] + assert fake.client_timeouts == [10.0] + + +def test_an_already_registered_approval_is_success( + platform: InstallPlatform, +) -> None: + fake = platform(registered(200)) + + register_approval(API_URL, BODY) + + assert len(fake.requests) == 1 + assert fake.sleeps == [] + + +def test_frozen_details_are_sent_as_json(platform: InstallPlatform) -> None: + fake = platform(registered()) + details: JSONValue = MappingProxyType({"items": (1, 2)}) + + register_approval(API_URL, {**BODY, "details": details}) + + assert json.loads(fake.requests[0].content)["details"] == {"items": [1, 2]} + + +@pytest.mark.parametrize( + "first", + [ + pytest.param(refused(409, "approval_not_ready"), id="not ready"), + pytest.param(refused(429), id="rate limited"), + pytest.param(refused(503), id="unavailable"), + pytest.param(refused(502), id="bad gateway"), + pytest.param(httpx.ConnectError("connection refused"), id="network"), + ], +) +def test_a_transient_failure_is_retried( + platform: InstallPlatform, first: httpx.Response | Exception +) -> None: + fake = platform(first, registered()) + + register_approval(API_URL, BODY) + + assert len(fake.requests) == 2 + assert fake.sleeps == [0.5] + assert json.loads(fake.requests[1].content) == BODY + + +def test_retries_back_off_to_a_cap_and_then_raise( + platform: InstallPlatform, +) -> None: + fake = platform(refused(503)) + + with pytest.raises(ServerError, match="refused with 503") as caught: + register_approval(API_URL, BODY) + + assert caught.value.status == 503 + assert fake.sleeps == RETRY_DELAYS + assert len(fake.requests) == len(RETRY_DELAYS) + 1 + + +def test_the_last_attempt_can_still_succeed(platform: InstallPlatform) -> None: + fake = platform(*([refused(429)] * len(RETRY_DELAYS)), registered()) + + register_approval(API_URL, BODY) + + assert fake.sleeps == RETRY_DELAYS + assert len(fake.requests) == len(RETRY_DELAYS) + 1 + + +def test_a_network_failure_that_persists_raises_a_transport_error( + platform: InstallPlatform, +) -> None: + fake = platform(httpx.ConnectError("connection refused")) + + with pytest.raises(TransportError, match="connection refused"): + register_approval(API_URL, BODY) + + assert len(fake.requests) == len(RETRY_DELAYS) + 1 + + +@pytest.mark.parametrize( + ("answer", "expected"), + [ + pytest.param(refused(400), ValidationError, id="invalid"), + pytest.param(refused(404), NotFoundError, id="no execution"), + pytest.param(refused(409, "approval_closed"), ConflictError, id="closed"), + pytest.param(refused(409, "execution_ended"), ConflictError, id="ended"), + pytest.param(refused(409), ConflictError, id="conflict"), + pytest.param(refused(413), VolcanoError, id="too large"), + ], +) +def test_a_refusal_is_raised_without_retrying( + platform: InstallPlatform, + answer: httpx.Response, + expected: type[VolcanoError], +) -> None: + fake = platform(answer) + + with pytest.raises(expected) as caught: + register_approval(API_URL, BODY) + + assert type(caught.value) is expected + assert caught.value.status == answer.status_code + assert len(fake.requests) == 1 + assert fake.sleeps == [] + + +def test_a_rate_limit_that_persists_raises_rate_limited( + platform: InstallPlatform, +) -> None: + _ = platform(refused(429)) + + with pytest.raises(RateLimitedError): + register_approval(API_URL, BODY) + + +def test_the_platform_api_url_comes_from_the_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("VOLCANO_PLATFORM_API_URL", f" {API_URL} ") + + assert platform_api_url() == API_URL + + +@pytest.mark.parametrize("value", [None, "", " "]) +def test_a_missing_platform_api_url_is_explained( + monkeypatch: pytest.MonkeyPatch, value: str | None +) -> None: + if value is None: + monkeypatch.delenv("VOLCANO_PLATFORM_API_URL", raising=False) + else: + monkeypatch.setenv("VOLCANO_PLATFORM_API_URL", value) + + with pytest.raises(RuntimeError) as caught: + _ = platform_api_url() + + assert str(caught.value) == ( + "wait_for_approval() needs VOLCANO_PLATFORM_API_URL. Volcano sets it on " + "deployed durable functions; set it yourself to run the handler elsewhere." + ) + + +def test_the_body_carries_only_what_was_given() -> None: + assert approval_body("ship", "Ship?", None, None) == { + "name": "ship", + "title": "Ship?", + } + assert approval_body("ship", "Ship?", "", {"a": [1]}) == { + "name": "ship", + "title": "Ship?", + "description": "", + "details": {"a": [1]}, + } + + +def test_text_at_its_limit_is_accepted() -> None: + body = approval_body("n" * 255, "t" * 200, "d" * 4000, details=False) + + assert body == { + "name": "n" * 255, + "title": "t" * 200, + "description": "d" * 4000, + "details": False, + } + + +@pytest.mark.parametrize( + ("arguments", "error", "message"), + [ + pytest.param( + (3, "t", None, None), TypeError, "name must be a string", id="name type" + ), + pytest.param( + (" ", "t", None, None), ValueError, "name must not be empty", id="blank" + ), + pytest.param( + ("n" * 256, "t", None, None), + ValueError, + "name must be at most 255 characters", + id="long name", + ), + pytest.param( + ("n", None, None, None), TypeError, "title must be a string", id="title" + ), + pytest.param( + ("n", "", None, None), ValueError, "title must not be empty", id="empty" + ), + pytest.param( + ("n", "t" * 201, None, None), + ValueError, + "title must be at most 200 characters", + id="long title", + ), + pytest.param( + ("n", "t", 1, None), + TypeError, + "description must be a string", + id="description type", + ), + pytest.param( + ("n", "t", "d" * 4001, None), + ValueError, + "description must be at most 4000 characters", + id="long description", + ), + pytest.param( + ("n", "t", None, {"when": object()}), + TypeError, + "details must be a JSON value", + id="details", + ), + pytest.param( + ("n", "t", None, float("nan")), + TypeError, + "details must be a JSON value", + id="nan details", + ), + ], +) +def test_an_invalid_body_is_refused( + arguments: tuple[object, object, object, object], + error: type[Exception], + message: str, +) -> None: + with pytest.raises(error) as caught: + _ = approval_body(*arguments) + + assert str(caught.value) == message + + +DECIDED_BY = {"id": "user-7", "email": "ops@example.com"} + + +def as_bytes(value: object) -> object: + return json.dumps(value).encode() + + +def as_decoded(value: object) -> object: + return value + + +@pytest.mark.parametrize( + "encode", + [ + pytest.param(json.dumps, id="string"), + pytest.param(as_bytes, id="bytes"), + pytest.param(as_decoded, id="decoded"), + ], +) +def test_an_approval_decision_is_read(encode: Callable[[object], object]) -> None: + result = encode( + { + "status": "approved", + "approved": True, + "comment": "Ship it", + "decided_by": DECIDED_BY, + "decided_at": "2026-10-06T12:05:00Z", + } + ) + + assert approval_decision(result) == ApprovalDecision( + approved=True, + status="approved", + comment="Ship it", + decided_by=DurableApprovalDecider(id="user-7", email="ops@example.com"), + decided_at="2026-10-06T12:05:00Z", + ) + + +def test_a_denial_is_a_decision_and_the_comment_defaults_to_empty() -> None: + result = json.dumps( + { + "status": "denied", + "approved": False, + "decided_by": None, + "decided_at": "2026-10-06T12:05:00Z", + } + ) + + assert approval_decision(result) == ApprovalDecision( + approved=False, + status="denied", + comment="", + decided_by=None, + decided_at="2026-10-06T12:05:00Z", + ) + + +def test_an_expired_decision_has_nothing_but_its_status() -> None: + expired = ApprovalDecision(approved=False, status="expired") + + assert (expired.comment, expired.decided_by, expired.decided_at) == ( + "", + None, + None, + ) + + +def complete_decision(**overrides: object) -> dict[str, object]: + decision: dict[str, object] = { + "status": "approved", + "approved": True, + "comment": "", + "decided_by": DECIDED_BY, + "decided_at": "2026-10-06T12:05:00Z", + } + decision.update(overrides) + return decision + + +@pytest.mark.parametrize( + "result", + [ + pytest.param(None, id="none"), + pytest.param("not json", id="not json"), + pytest.param(b"\xff", id="not utf-8"), + pytest.param("[]", id="list"), + pytest.param({1: "approved"}, id="key"), + pytest.param(complete_decision(status="expired"), id="status"), + pytest.param(complete_decision(status=None), id="no status"), + pytest.param(complete_decision(approved=False), id="disagrees"), + pytest.param(complete_decision(status="denied"), id="denied but approved"), + pytest.param(complete_decision(approved=1), id="approved type"), + pytest.param(complete_decision(comment=None), id="comment"), + pytest.param(complete_decision(decided_at=None), id="decided at"), + pytest.param(complete_decision(decided_by="ops"), id="decider"), + pytest.param(complete_decision(decided_by={1: "ops"}), id="decider key"), + pytest.param(complete_decision(decided_by={"id": "u"}), id="decider email"), + pytest.param( + complete_decision(decided_by={"email": "ops@example.com"}), id="decider id" + ), + ], +) +def test_a_malformed_decision_is_refused(result: object) -> None: + with pytest.raises(TypeError, match="Expected a complete approval decision"): + _ = approval_decision(result) diff --git a/src/volcano_sdk/_tests/test_durable_approvals.py b/src/volcano_sdk/_tests/test_durable_approvals.py new file mode 100644 index 00000000..22d20e28 --- /dev/null +++ b/src/volcano_sdk/_tests/test_durable_approvals.py @@ -0,0 +1,893 @@ +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from datetime import UTC, date, datetime, timedelta, timezone +from types import MappingProxyType +from typing import TYPE_CHECKING + +import httpx +import pytest + +from volcano_sdk import ( + AuthenticationError, + ConflictError, + DurableApproval, + DurableApprovalCounts, + DurableApprovalDecider, + DurableApprovalPage, + DurableApprovalStats, + NotFoundError, + PermissionDeniedError, + Session, + VolcanoClient, +) +from volcano_sdk._durable_approval_response import ( + durable_approval, + durable_approval_page, + durable_approval_stats, +) +from volcano_sdk._transport import ( + DurableApprovalListRequest, + DurableApprovalStatsRequest, + GeneratedTransport, +) + +from .fixtures.invalid_arguments import ( + assign_snapshot_value, + non_string_approval_comment, + unknown_approval_option, + unknown_approval_status, +) +from .transport_fixtures import RejectingTransport + +if TYPE_CHECKING: + from collections.abc import Callable + + from volcano_sdk.durable import DurableApprovals + +NAIVE = datetime(2026, 10, 1, tzinfo=UTC).replace(tzinfo=None) +PROJECT_ID = "00000000-0000-4000-8000-000000000001" +APPROVAL_ID = "00000000-0000-4000-8000-0000000000a1" +EXECUTION_ID = "00000000-0000-4000-8000-0000000000e1" +FUNCTION_ID = "00000000-0000-4000-8000-000000000040" +API_URL = "https://api.volcano.test" + + +@dataclass(frozen=True) +class FakeResponse: + status_code: int + payload: object + headers: dict[str, str] + content: bytes = b"" + + +def pending_approval(**overrides: object) -> dict[str, object]: + approval: dict[str, object] = { + "id": APPROVAL_ID, + "status": "pending", + "name": "ship-order", + "title": "Ship order 1234?", + "description": "Customer asked for express shipping.", + "details": {"order_id": "1234", "items": [1, 2]}, + "function": {"id": FUNCTION_ID, "name": "order-pipeline"}, + "execution": {"id": EXECUTION_ID, "name": "order-1234", "status": "running"}, + "requested_at": "2026-10-06T12:00:00Z", + "expires_at": "2026-10-07T12:00:00Z", + "decision": None, + } + approval.update(overrides) + return approval + + +def approved_approval() -> dict[str, object]: + return pending_approval( + status="approved", + decision={ + "comment": "Looks good", + "decided_by": {"id": "user-7", "email": "ops@example.com"}, + "decided_at": "2026-10-06T12:05:00Z", + }, + ) + + +def counts(**overrides: object) -> dict[str, object]: + values: dict[str, object] = { + "requested": 4, + "pending": 1, + "approved": 2, + "denied": 1, + "expired": 0, + "cancelled": 0, + } + values.update(overrides) + return values + + +def stats_payload() -> dict[str, object]: + return { + "from": "2026-09-06T00:00:00Z", + "to": "2026-10-06T00:00:00Z", + "counts": counts(), + "approval_rate": 0.6667, + "median_seconds_to_decision": 12.5, + "p90_seconds_to_decision": 30, + "functions": [ + { + "function": {"id": FUNCTION_ID, "name": "order-pipeline"}, + "counts": counts(), + }, + {"function": {"id": None, "name": "deleted-fn"}, "counts": counts()}, + ], + "other_functions": counts(requested=0, pending=0, approved=0, denied=0), + "daily": [{"date": "2026-10-05", "counts": counts()}], + } + + +class FakeApprovalTransport(RejectingTransport): + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, object]]] = [] + self.list_response: FakeResponse = FakeResponse( + 200, + { + "data": [pending_approval(), approved_approval()], + "page": 1, + "limit": 10, + "total": 2, + "has_more": False, + }, + {}, + ) + self.get_response: FakeResponse = FakeResponse(200, pending_approval(), {}) + self.stats_response: FakeResponse = FakeResponse(200, stats_payload(), {}) + self.decide_response: FakeResponse = FakeResponse(200, approved_approval(), {}) + + def list_durable_approvals(self, **kwargs: object) -> FakeResponse: + self.calls.append(("listDurableApprovals", kwargs)) + return self.list_response + + def get_durable_approval(self, **kwargs: object) -> FakeResponse: + self.calls.append(("getDurableApproval", kwargs)) + return self.get_response + + def get_durable_approval_stats(self, **kwargs: object) -> FakeResponse: + self.calls.append(("getDurableApprovalStats", kwargs)) + return self.stats_response + + def approve_durable_approval(self, **kwargs: object) -> FakeResponse: + self.calls.append(("approveDurableApproval", kwargs)) + return self.decide_response + + def deny_durable_approval(self, **kwargs: object) -> FakeResponse: + self.calls.append(("denyDurableApproval", kwargs)) + return self.decide_response + + +def approvals_client( + transport: RejectingTransport | GeneratedTransport, *, session: bool = True +) -> VolcanoClient: + client = VolcanoClient( + anon_key="anon-key", service_key="service-key", _transport=transport + ) + if session: + _ = client.auth.set_session( + Session( + access_token="access-token", + refresh_token="refresh-token", + user_id="user-1", + ) + ) + return client + + +def wire_client( + respond: Callable[[httpx.Request], httpx.Response], +) -> tuple[VolcanoClient, list[httpx.Request]]: + requests: list[httpx.Request] = [] + + def handle(request: httpx.Request) -> httpx.Response: + requests.append(request) + return respond(request) + + client = approvals_client( + GeneratedTransport(api_url=API_URL, httpx_transport=httpx.MockTransport(handle)) + ) + return client, requests + + +def test_approvals_require_a_transport_with_approval_methods() -> None: + client = approvals_client(RejectingTransport()) + + with pytest.raises(TypeError, match="Transport does not support durable approvals"): + _ = client.durable.approvals.get(PROJECT_ID, APPROVAL_ID) + + +def test_get_returns_a_pending_approval() -> None: + transport = FakeApprovalTransport() + + approval = approvals_client(transport).durable.approvals.get( + f" {PROJECT_ID} ", APPROVAL_ID + ) + + assert approval == DurableApproval( + id=APPROVAL_ID, + status="pending", + name="ship-order", + title="Ship order 1234?", + description="Customer asked for express shipping.", + function=approval.function, + execution=approval.execution, + requested_at=datetime(2026, 10, 6, 12, tzinfo=UTC), + expires_at=datetime(2026, 10, 7, 12, tzinfo=UTC), + decision=None, + details={"order_id": "1234", "items": (1, 2)}, + ) + assert approval.function.id == FUNCTION_ID + assert approval.function.name == "order-pipeline" + assert approval.execution.id == EXECUTION_ID + assert approval.execution.name == "order-1234" + assert approval.execution.status == "running" + assert approval.is_pending is True + assert transport.calls == [ + ( + "getDurableApproval", + { + "authorization": "access-token", + "project_id": PROJECT_ID, + "approval_id": APPROVAL_ID, + }, + ) + ] + + +def test_get_freezes_the_details_the_workflow_attached() -> None: + approval = approvals_client(FakeApprovalTransport()).durable.approvals.get( + PROJECT_ID, APPROVAL_ID + ) + + assert isinstance(approval.details, MappingProxyType) + with pytest.raises(TypeError): + assign_snapshot_value(approval.details) + + +def test_get_reports_who_decided_and_when() -> None: + transport = FakeApprovalTransport() + transport.get_response = FakeResponse(200, approved_approval(), {}) + + approval = approvals_client(transport).durable.approvals.get( + PROJECT_ID, APPROVAL_ID + ) + + assert approval.status == "approved" + assert approval.is_pending is False + assert approval.decision is not None + assert approval.decision.comment == "Looks good" + assert approval.decision.decided_by == DurableApprovalDecider( + id="user-7", email="ops@example.com" + ) + assert approval.decision.decided_at == datetime(2026, 10, 6, 12, 5, tzinfo=UTC) + + +def test_get_keeps_an_approval_whose_function_and_execution_are_gone() -> None: + transport = FakeApprovalTransport() + payload = pending_approval( + status="cancelled", + function={"id": None, "name": "order-pipeline"}, + execution={"id": None, "name": "order-1234", "status": None}, + expires_at=None, + decision={ + "comment": "", + "decided_by": None, + "decided_at": "2026-10-06T13:00:00Z", + }, + ) + del payload["details"] + transport.get_response = FakeResponse(200, payload, {}) + + approval = approvals_client(transport).durable.approvals.get( + PROJECT_ID, APPROVAL_ID + ) + + assert approval.function.id is None + assert approval.execution.id is None + assert approval.execution.status is None + assert approval.expires_at is None + assert approval.details is None + assert approval.decision is not None + assert approval.decision.decided_by is None + + +def test_list_returns_an_immutable_page_and_omits_unset_filters() -> None: + transport = FakeApprovalTransport() + + page = approvals_client(transport).durable.approvals.list(PROJECT_ID) + + assert isinstance(page, DurableApprovalPage) + assert [approval.status for approval in page.approvals] == ["pending", "approved"] + assert isinstance(page.approvals, tuple) + assert (page.page, page.limit, page.total, page.has_more) == (1, 10, 2, False) + assert transport.calls == [ + ( + "listDurableApprovals", + { + "authorization": "access-token", + "project_id": PROJECT_ID, + "request": DurableApprovalListRequest(), + }, + ) + ] + + +def test_list_forwards_every_filter() -> None: + transport = FakeApprovalTransport() + start = datetime(2026, 10, 1, tzinfo=UTC) + end = datetime(2026, 10, 6, tzinfo=UTC) + + _ = approvals_client(transport).durable.approvals.list( + PROJECT_ID, + status="pending", + function=" order-pipeline ", + execution_id=EXECUTION_ID, + from_=start, + to=end, + page=2, + limit=50, + ) + + assert transport.calls[0][1]["request"] == DurableApprovalListRequest( + status="pending", + function="order-pipeline", + execution_id=EXECUTION_ID, + from_=start, + to=end, + page=2, + limit=50, + ) + + +def test_stats_returns_counts_rates_and_breakdowns() -> None: + transport = FakeApprovalTransport() + + stats = approvals_client(transport).durable.approvals.stats( + PROJECT_ID, function="order-pipeline" + ) + + assert isinstance(stats, DurableApprovalStats) + assert stats.from_ == datetime(2026, 9, 6, tzinfo=UTC) + assert stats.to == datetime(2026, 10, 6, tzinfo=UTC) + assert stats.counts == DurableApprovalCounts( + requested=4, pending=1, approved=2, denied=1, expired=0, cancelled=0 + ) + assert stats.approval_rate == pytest.approx(0.6667) + assert stats.median_seconds_to_decision == pytest.approx(12.5) + assert stats.p90_seconds_to_decision == pytest.approx(30.0) + assert isinstance(stats.p90_seconds_to_decision, float) + assert [entry.function.name for entry in stats.functions] == [ + "order-pipeline", + "deleted-fn", + ] + assert stats.functions[1].function.id is None + assert stats.functions[0].counts.requested == 4 + assert stats.other_functions.requested == 0 + assert stats.daily[0].day == date(2026, 10, 5) + assert stats.daily[0].counts.approved == 2 + assert transport.calls == [ + ( + "getDurableApprovalStats", + { + "authorization": "access-token", + "project_id": PROJECT_ID, + "request": DurableApprovalStatsRequest(function="order-pipeline"), + }, + ) + ] + + +def test_stats_keeps_absent_rates_absent() -> None: + transport = FakeApprovalTransport() + payload = stats_payload() + payload.update( + approval_rate=None, + median_seconds_to_decision=None, + p90_seconds_to_decision=None, + functions=[], + daily=[], + ) + transport.stats_response = FakeResponse(200, payload, {}) + + stats = approvals_client(transport).durable.approvals.stats(PROJECT_ID) + + assert stats.approval_rate is None + assert stats.median_seconds_to_decision is None + assert stats.p90_seconds_to_decision is None + assert stats.functions == () + assert stats.daily == () + assert transport.calls[0][1]["request"] == DurableApprovalStatsRequest() + + +@pytest.mark.parametrize("decide", ["approve", "deny"]) +def test_a_decision_sends_its_comment(decide: str) -> None: + transport = FakeApprovalTransport() + approvals = approvals_client(transport).durable.approvals + method = approvals.approve if decide == "approve" else approvals.deny + + approval = method(PROJECT_ID, APPROVAL_ID, comment="Ship it") + + assert approval.status == "approved" + assert transport.calls == [ + ( + f"{decide}DurableApproval", + { + "authorization": "access-token", + "project_id": PROJECT_ID, + "approval_id": APPROVAL_ID, + "comment": "Ship it", + }, + ) + ] + + +@pytest.mark.parametrize("decide", ["approve", "deny"]) +def test_a_decision_without_a_comment_sends_none(decide: str) -> None: + transport = FakeApprovalTransport() + approvals = approvals_client(transport).durable.approvals + method = approvals.approve if decide == "approve" else approvals.deny + + _ = method(PROJECT_ID, APPROVAL_ID) + + assert transport.calls[0][1]["comment"] is None + + +def test_a_comment_at_the_limit_is_accepted() -> None: + transport = FakeApprovalTransport() + + _ = approvals_client(transport).durable.approvals.approve( + PROJECT_ID, APPROVAL_ID, comment="x" * 2000 + ) + + assert transport.calls[0][1]["comment"] == "x" * 2000 + + +@pytest.mark.parametrize("decide", ["approve", "deny"]) +def test_a_comment_over_the_limit_is_refused_before_transport(decide: str) -> None: + transport = FakeApprovalTransport() + approvals = approvals_client(transport).durable.approvals + method = approvals.approve if decide == "approve" else approvals.deny + + with pytest.raises(ValueError, match="comment must be at most 2000 characters"): + _ = method(PROJECT_ID, APPROVAL_ID, comment="x" * 2001) + assert transport.calls == [] + + +def test_a_comment_that_is_not_a_string_is_refused_before_transport() -> None: + transport = FakeApprovalTransport() + + with pytest.raises(TypeError) as caught: + non_string_approval_comment( + approvals_client(transport).durable.approvals, APPROVAL_ID + ) + assert str(caught.value) == "comment must be a string" + assert transport.calls == [] + + +INVALID_ARGUMENTS: dict[str, tuple[Callable[[DurableApprovals], object], str]] = { + "empty project": ( + lambda a: a.get("", APPROVAL_ID), + "project_id must be a non-empty string", + ), + "get project": ( + lambda a: a.get("project", APPROVAL_ID), + "project_id must be a UUID", + ), + "empty approval": ( + lambda a: a.get(PROJECT_ID, " "), + "approval_id must be a non-empty string", + ), + "get approval": ( + lambda a: a.get(PROJECT_ID, "approval"), + "approval_id must be a UUID", + ), + "approve approval": ( + lambda a: a.approve(PROJECT_ID, "approval"), + "approval_id must be a UUID", + ), + "deny project": ( + lambda a: a.deny("project", APPROVAL_ID), + "project_id must be a UUID", + ), + "list project": (lambda a: a.list("project"), "project_id must be a UUID"), + "stats project": (lambda a: a.stats("project"), "project_id must be a UUID"), + "list execution": ( + lambda a: a.list(PROJECT_ID, execution_id="execution"), + "execution_id must be a UUID", + ), + "blank function": ( + lambda a: a.list(PROJECT_ID, function=" "), + "function must be a non-empty string", + ), + "long function": ( + lambda a: a.stats(PROJECT_ID, function="f" * 256), + "function must be at most 255 characters", + ), + "naive list from": ( + lambda a: a.list(PROJECT_ID, from_=NAIVE), + "from_ must be a timezone-aware datetime", + ), + "naive list to": ( + lambda a: a.list(PROJECT_ID, to=NAIVE), + "to must be a timezone-aware datetime", + ), + "naive stats from": ( + lambda a: a.stats(PROJECT_ID, from_=NAIVE), + "from_ must be a timezone-aware datetime", + ), + "naive stats to": ( + lambda a: a.stats(PROJECT_ID, to=NAIVE), + "to must be a timezone-aware datetime", + ), +} + + +@pytest.mark.parametrize( + ("call", "message"), + [pytest.param(*case, id=name) for name, case in INVALID_ARGUMENTS.items()], +) +def test_invalid_arguments_are_refused_before_transport( + call: Callable[[DurableApprovals], object], message: str +) -> None: + transport = FakeApprovalTransport() + approvals = approvals_client(transport).durable.approvals + + with pytest.raises(ValueError, match=f"^{re.escape(message)}$"): + _ = call(approvals) + assert transport.calls == [] + + +def test_list_refuses_an_unknown_status_before_transport() -> None: + transport = FakeApprovalTransport() + + with pytest.raises( + ValueError, + match=r"^status must be one of pending, approved, denied, expired, cancelled$", + ): + unknown_approval_status(approvals_client(transport).durable.approvals) + assert transport.calls == [] + + +def test_an_unknown_option_is_refused_before_transport() -> None: + transport = FakeApprovalTransport() + + with pytest.raises(TypeError) as caught: + unknown_approval_option(approvals_client(transport).durable.approvals) + assert str(caught.value) == "unexpected option: sort, state" + assert transport.calls == [] + + +def test_a_function_at_the_name_limit_is_accepted() -> None: + transport = FakeApprovalTransport() + + _ = approvals_client(transport).durable.approvals.list( + PROJECT_ID, function="f" * 255 + ) + + assert transport.calls[0][1]["request"] == DurableApprovalListRequest( + function="f" * 255 + ) + + +def test_approvals_refuse_a_service_key_alone() -> None: + transport = FakeApprovalTransport() + client = approvals_client(transport, session=False) + + with pytest.raises(RuntimeError, match="No active session"): + _ = client.durable.approvals.get(PROJECT_ID, APPROVAL_ID) + assert transport.calls == [] + + +@pytest.mark.parametrize( + ("status", "code", "expected"), + [ + (403, None, PermissionDeniedError), + (404, None, NotFoundError), + (409, "approval_decided", ConflictError), + (409, "approval_expired", ConflictError), + (409, "approval_cancelled", ConflictError), + ], +) +def test_decision_failures_raise_typed_errors( + status: int, code: str | None, expected: type[Exception] +) -> None: + transport = FakeApprovalTransport() + body: dict[str, object] = {"error": "cannot decide"} + if code is not None: + body["code"] = code + transport.decide_response = FakeResponse(status, body, {}) + + with pytest.raises(expected, match="cannot decide") as caught: + _ = approvals_client(transport).durable.approvals.deny(PROJECT_ID, APPROVAL_ID) + + assert type(caught.value) is expected + assert getattr(caught.value, "code", None) == code + + +def test_permission_denied_is_an_authentication_error() -> None: + error = PermissionDeniedError("denied", status=403) + + assert isinstance(error, AuthenticationError) + + +def test_get_raises_not_found_for_another_project_s_approval() -> None: + transport = FakeApprovalTransport() + transport.get_response = FakeResponse(404, {"error": "approval not found"}, {}) + + with pytest.raises(NotFoundError, match="approval not found"): + _ = approvals_client(transport).durable.approvals.get(PROJECT_ID, APPROVAL_ID) + + +def test_get_sends_the_owner_request_over_the_wire() -> None: + client, requests = wire_client( + lambda _r: httpx.Response(200, json=pending_approval()) + ) + + approval = client.durable.approvals.get(PROJECT_ID, APPROVAL_ID) + + assert approval.id == APPROVAL_ID + assert len(requests) == 1 + assert requests[0].method == "GET" + assert str(requests[0].url) == ( + f"{API_URL}/projects/{PROJECT_ID}/durable-approvals/{APPROVAL_ID}" + ) + assert requests[0].headers["authorization"] == "Bearer access-token" + + +def test_list_sends_filters_as_query_parameters() -> None: + page = { + "data": [pending_approval()], + "page": 2, + "limit": 5, + "total": 6, + "has_more": True, + } + client, requests = wire_client(lambda _r: httpx.Response(200, json=page)) + plus_two = timezone(timedelta(hours=2)) + + result = client.durable.approvals.list( + PROJECT_ID, + status="approved", + function="order-pipeline", + execution_id=EXECUTION_ID, + from_=datetime(2026, 10, 1, 8, tzinfo=plus_two), + to=datetime(2026, 10, 6, tzinfo=UTC), + page=2, + limit=5, + ) + + assert result.page == 2 + assert result.has_more is True + assert requests[0].method == "GET" + assert requests[0].url.path == f"/projects/{PROJECT_ID}/durable-approvals" + assert dict(requests[0].url.params) == { + "status": "approved", + "function": "order-pipeline", + "execution_id": EXECUTION_ID, + "from": "2026-10-01T08:00:00+02:00", + "to": "2026-10-06T00:00:00+00:00", + "page": "2", + "limit": "5", + } + + +def test_list_sends_no_filters_it_was_not_given() -> None: + page: dict[str, object] = { + "data": [], + "page": 1, + "limit": 10, + "total": 0, + "has_more": False, + } + client, requests = wire_client(lambda _r: httpx.Response(200, json=page)) + + result = client.durable.approvals.list(PROJECT_ID) + + assert result.approvals == () + assert dict(requests[0].url.params) == {} + + +def test_stats_sends_its_window_as_query_parameters() -> None: + client, requests = wire_client(lambda _r: httpx.Response(200, json=stats_payload())) + + stats = client.durable.approvals.stats( + PROJECT_ID, + function=FUNCTION_ID, + from_=datetime(2026, 9, 6, tzinfo=UTC), + to=datetime(2026, 10, 6, tzinfo=UTC), + ) + + assert stats.counts.requested == 4 + assert stats.daily[0].day == date(2026, 10, 5) + assert requests[0].url.path == f"/projects/{PROJECT_ID}/durable-approvals/stats" + assert dict(requests[0].url.params) == { + "function": FUNCTION_ID, + "from": "2026-09-06T00:00:00+00:00", + "to": "2026-10-06T00:00:00+00:00", + } + + +def test_stats_sends_no_window_it_was_not_given() -> None: + client, requests = wire_client(lambda _r: httpx.Response(200, json=stats_payload())) + + _ = client.durable.approvals.stats(PROJECT_ID) + + assert dict(requests[0].url.params) == {} + + +@pytest.mark.parametrize( + ("decide", "comment", "body"), + [ + ("approve", "Ship it", {"comment": "Ship it"}), + ("approve", None, {}), + ("deny", "Out of stock", {"comment": "Out of stock"}), + ("deny", None, {}), + ], +) +def test_a_decision_posts_its_body_over_the_wire( + decide: str, comment: str | None, body: dict[str, str] +) -> None: + client, requests = wire_client( + lambda _r: httpx.Response(200, json=approved_approval()) + ) + approvals = client.durable.approvals + method = approvals.approve if decide == "approve" else approvals.deny + + approval = method(PROJECT_ID, APPROVAL_ID, comment=comment) + + assert approval.decision is not None + assert requests[0].method == "POST" + assert str(requests[0].url) == ( + f"{API_URL}/projects/{PROJECT_ID}/durable-approvals/{APPROVAL_ID}/{decide}" + ) + assert requests[0].headers["authorization"] == "Bearer access-token" + assert json.loads(requests[0].content) == body + + +@pytest.mark.parametrize( + ("status", "expected"), + [(403, PermissionDeniedError), (404, NotFoundError), (409, ConflictError)], +) +def test_wire_failures_map_to_typed_errors( + status: int, expected: type[Exception] +) -> None: + message = "approvals are decided by a person; use a platform token or the dashboard" + client, _requests = wire_client( + lambda _r: httpx.Response(status, json={"error": message, "code": "c"}) + ) + + with pytest.raises(expected) as caught: + _ = client.durable.approvals.approve(PROJECT_ID, APPROVAL_ID) + + assert type(caught.value) is expected + assert str(caught.value) == message + + +@pytest.mark.parametrize("payload", [None, [], "approval", 1]) +def test_an_approval_must_be_an_object(payload: object) -> None: + with pytest.raises(TypeError, match="Expected a complete durable approval"): + _ = durable_approval(payload) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("id", ""), + ("id", None), + ("status", "waiting"), + ("name", 3), + ("title", None), + ("description", None), + ("function", None), + ("function", {"id": FUNCTION_ID}), + ("function", {"id": 3, "name": "f"}), + ("execution", "e"), + ("execution", {"id": None, "name": "e", "status": "waiting"}), + ("execution", {"id": None, "name": None, "status": None}), + ("requested_at", "yesterday"), + ("requested_at", None), + ("expires_at", "tomorrow"), + ("decision", "approved"), + ("decision", {"comment": None, "decided_by": None, "decided_at": "2026"}), + ("decision", {"comment": "", "decided_by": "ops", "decided_at": "2026"}), + ( + "decision", + {"comment": "", "decided_by": {"id": "u"}, "decided_at": "2026-10-06"}, + ), + ("decision", {"comment": "", "decided_by": None, "decided_at": None}), + ("details", {1: "x"}), + ], +) +def test_an_approval_with_a_malformed_field_is_refused( + field: str, value: object +) -> None: + payload = pending_approval() + payload[field] = value + + with pytest.raises(TypeError, match="Expected a complete durable approval"): + _ = durable_approval(payload) + + +@pytest.mark.parametrize("field", ["id", "function", "execution", "requested_at"]) +def test_an_approval_missing_a_field_is_refused(field: str) -> None: + payload = pending_approval() + del payload[field] + + with pytest.raises(TypeError, match="Expected a complete durable approval"): + _ = durable_approval(payload) + + +def test_an_approval_with_non_string_keys_is_refused() -> None: + payload: dict[object, object] = {1: "x"} + payload.update(pending_approval()) + + with pytest.raises(TypeError, match="Expected a complete durable approval"): + _ = durable_approval(payload) + + +@pytest.mark.parametrize( + "payload", + [ + None, + [], + {"data": "x"}, + {"data": [], "has_more": "no"}, + {"data": [], "page": True}, + {"data": [], "total": 1.5}, + ], +) +def test_a_malformed_approval_page_is_refused(payload: object) -> None: + with pytest.raises(TypeError, match="Expected a complete durable approval page"): + _ = durable_approval_page(payload) + + +def test_an_approval_page_defaults_missing_metadata() -> None: + page = durable_approval_page({}) + + assert page == DurableApprovalPage( + approvals=(), page=0, limit=0, total=0, has_more=False + ) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("from", None), + ("to", "later"), + ("counts", None), + ("counts", {"requested": 1}), + ("counts", counts(pending=True)), + ("counts", counts(approved=1.5)), + ("approval_rate", "half"), + ("approval_rate", True), + ("median_seconds_to_decision", "fast"), + ("p90_seconds_to_decision", float("nan")), + ("functions", None), + ("functions", [{"function": None, "counts": counts()}]), + ("functions", [{"function": {"id": None, "name": "f"}, "counts": None}]), + ("other_functions", []), + ("daily", "today"), + ("daily", [{"date": "yesterday", "counts": counts()}]), + ("daily", [{"date": None, "counts": counts()}]), + ("daily", ["2026-10-05"]), + ], +) +def test_malformed_stats_are_refused(field: str, value: object) -> None: + payload = stats_payload() + payload[field] = value + + with pytest.raises(TypeError, match="Expected complete durable approval stats"): + _ = durable_approval_stats(payload) + + +@pytest.mark.parametrize("payload", [None, [], {1: "x"}]) +def test_stats_must_be_an_object(payload: object) -> None: + with pytest.raises(TypeError, match="Expected complete durable approval stats"): + _ = durable_approval_stats(payload) diff --git a/src/volcano_sdk/_tests/test_durable_authoring.py b/src/volcano_sdk/_tests/test_durable_authoring.py index 238c1d5e..e899137a 100644 --- a/src/volcano_sdk/_tests/test_durable_authoring.py +++ b/src/volcano_sdk/_tests/test_durable_authoring.py @@ -10,10 +10,11 @@ import inspect import json import logging +import threading from collections.abc import Mapping from contextlib import contextmanager from types import ModuleType, SimpleNamespace -from typing import TYPE_CHECKING, TypeGuard +from typing import TYPE_CHECKING, TypeGuard, TypeVar, cast import pytest from aws_durable_execution_sdk_python.config import ( @@ -22,11 +23,16 @@ ParallelConfig, StepConfig, StepSemantics, + WaitForCallbackConfig, ) from aws_durable_execution_sdk_python.config import ( ParallelBranch as EngineParallelBranch, ) -from aws_durable_execution_sdk_python.exceptions import WaitForConditionError +from aws_durable_execution_sdk_python.exceptions import ( + CallbackError, + CallbackTimeoutError, + WaitForConditionError, +) from aws_durable_execution_sdk_python.retries import RetryDecision from aws_durable_execution_sdk_python.waits import ( WaitForConditionConfig, @@ -36,10 +42,12 @@ ) from aws_durable_execution_sdk_python_testing import DurableFunctionTestRunner +from volcano_sdk import ValidationError from volcano_sdk._durable_duration import to_seconds from volcano_sdk._durable_engine import Engine, load_engine from volcano_sdk._durable_results import completion_reason from volcano_sdk.durable_authoring import ( + ApprovalDecision, BatchFailure, BatchItem, BatchOptions, @@ -53,6 +61,7 @@ WaitUntilOptions, durable, ) +from volcano_sdk.models import DurableApprovalDecider from .fixtures.durable_context import ( RecordedBatch, @@ -65,6 +74,7 @@ InspectedDurableContext, InspectedEngine, ) +from .fixtures.invalid_arguments import non_string_approval_title from .fixtures.invalid_callbacks import ( decorate_non_callable, register_non_callable_branch, @@ -78,6 +88,11 @@ if TYPE_CHECKING: from collections.abc import Callable, Generator, Iterator + from volcano_sdk._durable_protocols import RuntimeContext + from volcano_sdk.models import JSONValue + +T = TypeVar("T") + # The local runner's scheduler calls asyncio.iscoroutinefunction, which Python # 3.14 deprecates, and the suite turns warnings into errors. It comes from the # runner rather than from the durable runtime a deployed function uses, so it @@ -1275,3 +1290,539 @@ def test_runtime_missing_error_preserves_an_explicit_cause() -> None: cause = ImportError("missing runtime") assert DurableRuntimeMissingError(cause).__cause__ is cause + + +PLATFORM_API_URL = "https://api.volcano.test" +_INVOCATION_TIMEOUT_SECONDS = 30 + + +class Registrations: + """Stand in for Volcano's registration route, from the runtime's thread.""" + + def __init__(self) -> None: + self.bodies: list[dict[str, JSONValue]] = [] + self.api_urls: list[str] = [] + self._lock: threading.Lock = threading.Lock() + + def register(self, api_url: str, body: Mapping[str, JSONValue]) -> None: + with self._lock: + self.api_urls.append(api_url) + self.bodies.append(dict(body)) + + def expect(self, count: int) -> list[dict[str, JSONValue]]: + with self._lock: + if len(self.bodies) != count: + message = f"expected {count} registrations, got {len(self.bodies)}" + raise AssertionError(message) + return list(self.bodies) + + +class Invocations: + """Count the invocations a handler has finished. + + The local runner invalidates an invocation's checkpoints when a callback + completes under it, so a decision is sent only once the invocation that + registered the approval has suspended and returned. On the platform a + person decides long after that. + """ + + def __init__(self, handler: FunctionHandler) -> None: + self._handler: FunctionHandler = handler + self._finished: int = 0 + self._changed: threading.Condition = threading.Condition() + + def __call__(self, event: object, function_context: object) -> object: + try: + return self._handler(event, function_context) + finally: + with self._changed: + self._finished += 1 + self._changed.notify_all() + + def wait_for_one(self) -> None: + with self._changed: + if not self._changed.wait_for( + lambda: self._finished > 0, timeout=_INVOCATION_TIMEOUT_SECONDS + ): + message = "expected the invocation to suspend" + raise AssertionError(message) + + +@contextmanager +def suspended_on_approvals( + handler: FunctionHandler, registrations: Registrations, count: int +) -> Generator[tuple[DurableFunctionTestRunner, str, list[dict[str, JSONValue]]]]: + """Start a handler and wait until it suspends on its approvals. + + The first invocation returns only once every approval it reached has + registered and suspended, or once it failed. + + Yields: + The runner, the execution reference, and the registration bodies. + """ + invocations = Invocations(handler) + with local_runner(invocations) as runner: + execution_ref = runner.run_async(input=json.dumps({})) + invocations.wait_for_one() + yield runner, execution_ref, registrations.expect(count) + + +@pytest.fixture +def registrations(monkeypatch: pytest.MonkeyPatch) -> Registrations: + recorder = Registrations() + monkeypatch.setattr( + "volcano_sdk.durable_authoring.register_approval", recorder.register + ) + monkeypatch.setenv("VOLCANO_PLATFORM_API_URL", PLATFORM_API_URL) + return recorder + + +def decided(*, approved: bool = True, comment: str = "Ship it") -> bytes: + return json.dumps( + { + "status": "approved" if approved else "denied", + "approved": approved, + "comment": comment, + "decided_by": {"id": "user-7", "email": "ops@example.com"}, + "decided_at": "2026-10-06T12:05:00Z", + } + ).encode() + + +def callback_id(body: Mapping[str, JSONValue]) -> str: + value = body["callback_id"] + assert isinstance(value, str) + assert value + return value + + +def decision_summary(decision: ApprovalDecision) -> dict[str, object]: + return { + "approved": decision.approved, + "status": decision.status, + "comment": decision.comment, + "decided_by": None if decision.decided_by is None else decision.decided_by.id, + "decided_at": decision.decided_at, + } + + +def decide_each( + handler: FunctionHandler, + registrations: Registrations, + count: int, + decision: bytes | None = None, +) -> tuple[str, object]: + """Run a handler, decide each approval it registers, and return its result. + + Returns: + The execution reference and the decoded result. + + Raises: + AssertionError: If the handler fails. + """ + with suspended_on_approvals(handler, registrations, count) as ( + runner, + execution_ref, + bodies, + ): + for body in bodies: + runner.send_callback_success(callback_id(body), decision or decided()) + result = runner.wait_for_result(execution_ref, timeout=60) + if result.error is not None: + raise AssertionError(result.error.message) + assert result.result is not None + return execution_ref, json.loads(result.result) + + +def test_an_approval_registers_and_resumes_with_the_decision( + registrations: Registrations, +) -> None: + @durable + def handler(_event: object, ctx: DurableContext) -> object: + decision = ctx.wait_for_approval( + "ship-order", + title="Ship order 1234?", + description="Customer asked for express shipping.", + details={"order_id": "1234", "items": [1, 2]}, + timeout="1h", + ) + return decision_summary(decision) + + execution_ref, result = decide_each(handler, registrations, 1) + + assert result == { + "approved": True, + "status": "approved", + "comment": "Ship it", + "decided_by": "user-7", + "decided_at": "2026-10-06T12:05:00Z", + } + [body] = registrations.bodies + assert body == { + "execution_ref": execution_ref, + "callback_id": callback_id(body), + "name": "ship-order", + "title": "Ship order 1234?", + "description": "Customer asked for express shipping.", + "details": {"order_id": "1234", "items": [1, 2]}, + } + assert registrations.api_urls == [PLATFORM_API_URL] + + +def test_a_denial_is_a_decision_rather_than_an_error( + registrations: Registrations, +) -> None: + @durable + def handler(_event: object, ctx: DurableContext) -> object: + decision = ctx.wait_for_approval("ship-order", title="Ship?") + return {"shipped": decision.approved, **decision_summary(decision)} + + _, result = decide_each( + handler, registrations, 1, decided(approved=False, comment="Out of stock") + ) + + assert result == { + "shipped": False, + "approved": False, + "status": "denied", + "comment": "Out of stock", + "decided_by": "user-7", + "decided_at": "2026-10-06T12:05:00Z", + } + [body] = registrations.bodies + assert set(body) == {"execution_ref", "callback_id", "name", "title"} + + +def test_an_approval_nobody_decides_expires_instead_of_raising( + registrations: Registrations, +) -> None: + @durable + def handler(_event: object, ctx: DurableContext) -> object: + decision = ctx.wait_for_approval("ship-order", title="Ship?", timeout="1s") + return decision_summary(decision) + + # A lost timeout would otherwise leave the runner waiting for 15 minutes. + with local_runner(handler) as runner: + result = runner.run(input=json.dumps({}), timeout=15) + + assert result.error is None + assert result.result is not None + assert json.loads(result.result) == { + "approved": False, + "status": "expired", + "comment": "", + "decided_by": None, + "decided_at": None, + } + assert len(registrations.bodies) == 1 + + +def test_a_replay_does_not_register_the_approval_again( + registrations: Registrations, +) -> None: + @durable + def handler(_event: object, ctx: DurableContext) -> object: + decision = ctx.wait_for_approval("ship-order", title="Ship?") + ctx.wait("after", "1s") + return ctx.step("ship", lambda _scope: decision.approved) + + _, result = decide_each(handler, registrations, 1) + + assert result is True + assert len(registrations.bodies) == 1 + + +def test_a_child_context_registers_against_the_same_execution( + registrations: Registrations, +) -> None: + @durable + def handler(_event: object, ctx: DurableContext) -> object: + return ctx.child( + "review", + lambda child: child.wait_for_approval("in-child", title="Ship?").status, + ) + + execution_ref, result = decide_each(handler, registrations, 1) + + assert result == "approved" + assert [body["execution_ref"] for body in registrations.bodies] == [execution_ref] + + +def test_map_items_register_against_the_same_execution( + registrations: Registrations, +) -> None: + @durable + def handler(_event: object, ctx: DurableContext) -> object: + batch = ctx.map( + ["a", "b"], + lambda item, child, _index: ( + child.wait_for_approval(f"ship-{item}", title=f"Ship {item}?").approved + ), + "approvals", + ) + return {"results": list(batch.results), "completed": batch.completed} + + execution_ref, result = decide_each(handler, registrations, 2) + + assert result == {"results": [True, True], "completed": 2} + assert sorted(str(body["name"]) for body in registrations.bodies) == [ + "ship-a", + "ship-b", + ] + assert {str(body["execution_ref"]) for body in registrations.bodies} == { + execution_ref + } + + +def test_parallel_branches_register_against_the_same_execution( + registrations: Registrations, +) -> None: + @durable + def handler(_event: object, ctx: DurableContext) -> object: + branches: list[Callable[[DurableContext], str] | ParallelBranch[str]] = [ + ParallelBranch( + lambda child: child.wait_for_approval("named", title="A?").status, + name="alpha", + ), + lambda child: child.wait_for_approval("bare", title="B?").status, + ] + batch = ctx.parallel(branches, "fan-out") + return sorted(batch.results) + + execution_ref, result = decide_each(handler, registrations, 2) + + assert result == ["approved", "approved"] + assert sorted(str(body["name"]) for body in registrations.bodies) == [ + "bare", + "named", + ] + assert {str(body["execution_ref"]) for body in registrations.bodies} == { + execution_ref + } + + +def test_a_refused_registration_fails_the_execution_without_retrying( + monkeypatch: pytest.MonkeyPatch, +) -> None: + attempts: list[str] = [] + + def refuse(_api_url: str, body: Mapping[str, JSONValue]) -> None: + attempts.append(str(body["name"])) + message = "title is too long" + raise ValidationError(message, status=400) + + monkeypatch.setattr("volcano_sdk.durable_authoring.register_approval", refuse) + monkeypatch.setenv("VOLCANO_PLATFORM_API_URL", PLATFORM_API_URL) + + @durable + def handler(_event: object, ctx: DurableContext) -> object: + return ctx.wait_for_approval("ship-order", title="Ship?").approved + + assert "title is too long" in failing_handler(handler) + assert attempts == ["ship-order"] + + +def test_a_failed_callback_is_not_mistaken_for_an_expiry( + registrations: Registrations, +) -> None: + @durable + def handler(_event: object, ctx: DurableContext) -> object: + return ctx.wait_for_approval("ship-order", title="Ship?").status + + with suspended_on_approvals(handler, registrations, 1) as ( + runner, + execution_ref, + [body], + ): + runner.send_callback_failure(callback_id(body)) + result = runner.wait_for_result(execution_ref, timeout=60) + + assert result.error is not None + assert result.result is None + + +@pytest.mark.usefixtures("registrations") +def test_a_missing_platform_api_url_fails_before_opening_a_callback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("VOLCANO_PLATFORM_API_URL") + runtime = RecordingContext() + context = DurableContext(runtime, Engine(), "arn:execution") + + with pytest.raises(RuntimeError, match="needs VOLCANO_PLATFORM_API_URL"): + _ = context.wait_for_approval("ship-order", title="Ship?") + assert runtime.submitter is None + + +@pytest.mark.usefixtures("registrations") +def test_an_approval_needs_the_execution_reference() -> None: + runtime = RecordingContext() + context = DurableContext(runtime, Engine()) + + with pytest.raises(RuntimeError) as caught: + _ = context.wait_for_approval("ship-order", title="Ship?") + + assert str(caught.value) == ( + "wait_for_approval() needs the invocation's DurableExecutionArn, which " + "@durable reads from the event; call it from a handler wrapped with @durable" + ) + assert runtime.submitter is None + + +def test_wait_for_approval_forwards_its_name_timeout_and_submitter( + registrations: Registrations, +) -> None: + runtime = RecordingContext() + runtime.callback_result = decided().decode() + context = DurableContext(runtime, Engine(), "arn:execution") + + decision = context.wait_for_approval("ship-order", title="Ship?", timeout="2h") + + assert decision == ApprovalDecision( + approved=True, + status="approved", + comment="Ship it", + decided_by=DurableApprovalDecider(id="user-7", email="ops@example.com"), + decided_at="2026-10-06T12:05:00Z", + ) + assert runtime.name == "ship-order" + assert isinstance(runtime.config, WaitForCallbackConfig) + assert runtime.config.timeout.to_seconds() == 7200 + retry = runtime.config.retry_strategy + assert retry is not None + assert retry(RuntimeError("failed"), 1).should_retry is False + assert runtime.submitter is not None + assert registrations.bodies == [] + runtime.submitter("callback-9", None) + assert registrations.bodies == [ + { + "name": "ship-order", + "title": "Ship?", + "execution_ref": "arn:execution", + "callback_id": "callback-9", + } + ] + + +@pytest.mark.usefixtures("registrations") +def test_an_approval_without_a_timeout_lasts_as_long_as_the_execution() -> None: + runtime = RecordingContext() + runtime.callback_result = decided(approved=False).decode() + context = DurableContext(runtime, Engine(), "arn:execution") + + decision = context.wait_for_approval("ship-order", title="Ship?") + + assert decision.status == "denied" + assert isinstance(runtime.config, WaitForCallbackConfig) + assert runtime.config.timeout.to_seconds() == 0 + assert runtime.config.retry_strategy is not None + + +@pytest.mark.usefixtures("registrations") +def test_only_a_callback_timeout_becomes_an_expired_decision() -> None: + runtime = RecordingContext() + runtime.callback_error = CallbackTimeoutError("timed out") + context = DurableContext(runtime, Engine(), "arn:execution") + + expired = context.wait_for_approval("ship-order", title="Ship?", timeout=60) + + assert expired == ApprovalDecision(approved=False, status="expired") + runtime.callback_error = CallbackError("callback failed") + with pytest.raises(CallbackError, match="callback failed"): + _ = context.wait_for_approval("ship-order", title="Ship?") + + +@pytest.mark.parametrize( + ("timeout", "error", "message"), + [ + pytest.param("0s", ValueError, "timeout must be at least 1 second", id="0"), + pytest.param( + {"days": 367}, + ValueError, + r"timeout must be at most 31622400 seconds \(366 days\)", + id="367d", + ), + pytest.param("soon", ValueError, "^timeout must be a duration", id="text"), + pytest.param(1.5, TypeError, "^timeout must be a whole number", id="float"), + ], +) +def test_an_unusable_approval_timeout_is_refused( + timeout: object, error: type[Exception], message: str +) -> None: + context = InspectedDurableContext(RecordingContext(), Engine(), "arn:execution") + + with pytest.raises(error, match=message): + _ = context.approval_timeout(timeout) + + +def test_an_approval_timeout_accepts_its_bounds() -> None: + context = InspectedDurableContext(RecordingContext(), Engine()) + + assert context.approval_timeout(1) == 1 + assert context.approval_timeout({"days": 366}) == 31_622_400 + + +@pytest.mark.usefixtures("registrations") +def test_an_invalid_approval_is_refused_before_opening_a_callback() -> None: + runtime = RecordingContext() + context = DurableContext(runtime, Engine(), "arn:execution") + + with pytest.raises(TypeError, match="title must be a string"): + non_string_approval_title(context) + with pytest.raises(ValueError, match="timeout must be at least 1 second"): + _ = context.wait_for_approval("ship-order", title="Ship?", timeout=0) + assert runtime.submitter is None + + +def direct_execution( + func: Callable[[T, RuntimeContext], object], / +) -> Callable[[object, object], object]: + """Run a handler straight against a recording context, as the runtime would. + + Returns: + An invocation handler. + """ + + def handle(_event: object, _function_context: object) -> object: + runtime = RecordingContext() + runtime.callback_result = decided().decode() + return func(cast("T", {}), runtime) + + return handle + + +@pytest.mark.parametrize( + ("event", "expected"), + [ + pytest.param({"DurableExecutionArn": "arn:mapped"}, "arn:mapped", id="mapping"), + pytest.param( + SimpleNamespace(durable_execution_arn="arn:object"), + "arn:object", + id="object", + ), + pytest.param({"DurableExecutionArn": ""}, None, id="empty"), + pytest.param({"DurableExecutionArn": 7}, None, id="not a string"), + pytest.param({}, None, id="absent"), + pytest.param(SimpleNamespace(), None, id="no attribute"), + ], +) +def test_the_execution_reference_is_read_from_the_invocation_event( + monkeypatch: pytest.MonkeyPatch, + registrations: Registrations, + event: object, + expected: str | None, +) -> None: + engine = Engine() + engine.durable_execution = direct_execution + monkeypatch.setattr("volcano_sdk.durable_authoring.load_engine", lambda: engine) + + @durable + def handler(_event: object, ctx: DurableContext) -> object: + return ctx.wait_for_approval("ship-order", title="Ship?").status + + if expected is None: + with pytest.raises(RuntimeError, match="DurableExecutionArn"): + _ = handler(event, None) + return + assert handler(event, None) == "approved" + assert registrations.bodies == [] diff --git a/src/volcano_sdk/_tests/test_durable_runtime_boundary.py b/src/volcano_sdk/_tests/test_durable_runtime_boundary.py index 5fed65ca..ea98b8bf 100644 --- a/src/volcano_sdk/_tests/test_durable_runtime_boundary.py +++ b/src/volcano_sdk/_tests/test_durable_runtime_boundary.py @@ -10,6 +10,7 @@ from volcano_sdk._durable_modules import ( load_config, + load_exceptions, load_retries, load_root, load_waits, @@ -26,6 +27,13 @@ load_config, "aws_durable_execution_sdk_python.config does not provide ConfigModule", ), + ( + load_exceptions, + ( + "aws_durable_execution_sdk_python.exceptions does not provide " + "ExceptionsModule" + ), + ), ( load_retries, "aws_durable_execution_sdk_python.retries does not provide RetriesModule", diff --git a/src/volcano_sdk/_tests/test_errors.py b/src/volcano_sdk/_tests/test_errors.py index aa120dc7..cdb1062e 100644 --- a/src/volcano_sdk/_tests/test_errors.py +++ b/src/volcano_sdk/_tests/test_errors.py @@ -13,6 +13,7 @@ AuthenticationError, ConflictError, NotFoundError, + PermissionDeniedError, RateLimitedError, ServerError, SessionChangedError, @@ -40,7 +41,7 @@ def client_for(handler: Callable[[httpx.Request], httpx.Response]) -> VolcanoCli [ (400, ValidationError), (401, AuthenticationError), - (403, AuthenticationError), + (403, PermissionDeniedError), (404, NotFoundError), (409, ConflictError), (418, VolcanoError), diff --git a/src/volcano_sdk/_transport.py b/src/volcano_sdk/_transport.py index a3f346a7..26c41533 100644 --- a/src/volcano_sdk/_transport.py +++ b/src/volcano_sdk/_transport.py @@ -3,6 +3,7 @@ from ._transport_auth_account import AuthAccountTransport from ._transport_auth_identity import AuthIdentityTransport from ._transport_database import DatabaseTransport +from ._transport_durable_approvals import DurableApprovalTransport from ._transport_execution import ExecutionTransport from ._transport_locks import LocksTransport from ._transport_response import invoke, invoke_async, response_payload @@ -44,6 +45,8 @@ AuthSignUpTransport, AuthUnlinkOAuthProviderTransport, AuthUpdateUserTransport, + DurableApprovalListRequest, + DurableApprovalStatsRequest, DurableExecutionListRequest, StorageUploadPartRequest, StorageUploadSessionReference, @@ -88,6 +91,8 @@ "AuthSignUpTransport", "AuthUnlinkOAuthProviderTransport", "AuthUpdateUserTransport", + "DurableApprovalListRequest", + "DurableApprovalStatsRequest", "DurableExecutionListRequest", "GeneratedTransport", "StorageUploadPartRequest", @@ -107,6 +112,7 @@ class GeneratedTransport( DatabaseTransport, StorageTransport, ExecutionTransport, + DurableApprovalTransport, LocksTransport, SandboxHTTPTransport, ): diff --git a/src/volcano_sdk/_transport_durable_approvals.py b/src/volcano_sdk/_transport_durable_approvals.py new file mode 100644 index 00000000..254becdc --- /dev/null +++ b/src/volcano_sdk/_transport_durable_approvals.py @@ -0,0 +1,121 @@ +"""Generated durable approval operation adapters.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +from ._generated.api.durable_functions import ( + approve_durable_approval, + deny_durable_approval, + get_durable_approval, + get_durable_approval_stats, + list_durable_approvals, +) +from ._generated.models.durable_approval_decision_request import ( + DurableApprovalDecisionRequest, +) +from ._generated.types import UNSET +from ._transport_base import TransportBase +from ._transport_response import parsed_response + +if TYPE_CHECKING: + from ._transport_types import ( + DurableApprovalListRequest, + DurableApprovalStatsRequest, + TransportResponse, + ) + + +def _decision_body(comment: str | None) -> DurableApprovalDecisionRequest: + return DurableApprovalDecisionRequest(comment=UNSET if comment is None else comment) + + +class DurableApprovalTransport(TransportBase): + """Adapt generated durable approval operations to the SDK transport.""" + + def list_durable_approvals( + self, + *, + authorization: str, + project_id: str, + request: DurableApprovalListRequest, + ) -> TransportResponse: + execution_id = request.execution_id + with self._client(authorization) as client: + response = list_durable_approvals.sync_detailed( + UUID(project_id), + client=client, + page=UNSET if request.page is None else request.page, + limit=UNSET if request.limit is None else request.limit, + status=UNSET if request.status is None else request.status, + function=UNSET if request.function is None else request.function, + execution_id=UNSET if execution_id is None else UUID(execution_id), + from_=UNSET if request.from_ is None else request.from_, + to=UNSET if request.to is None else request.to, + ) + return parsed_response(response) + + def get_durable_approval( + self, + *, + authorization: str, + project_id: str, + approval_id: str, + ) -> TransportResponse: + with self._client(authorization) as client: + response = get_durable_approval.sync_detailed( + UUID(project_id), UUID(approval_id), client=client + ) + return parsed_response(response) + + def get_durable_approval_stats( + self, + *, + authorization: str, + project_id: str, + request: DurableApprovalStatsRequest, + ) -> TransportResponse: + with self._client(authorization) as client: + response = get_durable_approval_stats.sync_detailed( + UUID(project_id), + client=client, + function=UNSET if request.function is None else request.function, + from_=UNSET if request.from_ is None else request.from_, + to=UNSET if request.to is None else request.to, + ) + return parsed_response(response) + + def approve_durable_approval( + self, + *, + authorization: str, + project_id: str, + approval_id: str, + comment: str | None, + ) -> TransportResponse: + with self._client(authorization) as client: + response = approve_durable_approval.sync_detailed( + UUID(project_id), + UUID(approval_id), + client=client, + body=_decision_body(comment), + ) + return parsed_response(response) + + def deny_durable_approval( + self, + *, + authorization: str, + project_id: str, + approval_id: str, + comment: str | None, + ) -> TransportResponse: + with self._client(authorization) as client: + response = deny_durable_approval.sync_detailed( + UUID(project_id), + UUID(approval_id), + client=client, + body=_decision_body(comment), + ) + return parsed_response(response) diff --git a/src/volcano_sdk/_transport_types.py b/src/volcano_sdk/_transport_types.py index 665230f4..8f1685a1 100644 --- a/src/volcano_sdk/_transport_types.py +++ b/src/volcano_sdk/_transport_types.py @@ -16,6 +16,7 @@ AuthenticationError, ConflictError, NotFoundError, + PermissionDeniedError, RateLimitedError, ValidationError, VolcanoError, @@ -23,6 +24,7 @@ if TYPE_CHECKING: from collections.abc import Mapping + from datetime import datetime from ._generated.models.auth_link_o_auth_provider_provider import ( AuthLinkOAuthProviderProvider, @@ -42,7 +44,7 @@ from ._generated.models.refresh_o_auth_provider_token_provider import ( RefreshOAuthProviderTokenProvider, ) - from .models import DurableExecutionStatus, JSONValue + from .models import DurableApprovalStatus, DurableExecutionStatus, JSONValue HTTP_CREATED = 201 @@ -96,7 +98,7 @@ ERROR_TYPES_BY_STATUS: dict[int, type[VolcanoError]] = { 400: ValidationError, 401: AuthenticationError, - 403: AuthenticationError, + 403: PermissionDeniedError, HTTP_NOT_FOUND: NotFoundError, HTTP_CONFLICT: ConflictError, 422: ValidationError, @@ -159,6 +161,28 @@ class DurableExecutionListRequest: limit: int | None = None +@dataclass(frozen=True, slots=True) +class DurableApprovalListRequest: + """Filters and paging for a durable approval listing.""" + + status: DurableApprovalStatus | None = None + function: str | None = None + execution_id: str | None = None + from_: datetime | None = None + to: datetime | None = None + page: int | None = None + limit: int | None = None + + +@dataclass(frozen=True, slots=True) +class DurableApprovalStatsRequest: + """The function filter and window for durable approval stats.""" + + function: str | None = None + from_: datetime | None = None + to: datetime | None = None + + @dataclass(frozen=True, slots=True) class StorageUploadSessionRequest: """Values needed to create a resumable storage upload session.""" diff --git a/src/volcano_sdk/durable.py b/src/volcano_sdk/durable.py index 628cdbd2..d3973fb4 100644 --- a/src/volcano_sdk/durable.py +++ b/src/volcano_sdk/durable.py @@ -2,12 +2,21 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Protocol, runtime_checkable +from datetime import datetime +from typing import TYPE_CHECKING, Literal, Protocol, Unpack, runtime_checkable from uuid import UUID from ._client_context import ClientContextSource, facade_context +from ._durable_approval_response import ( + APPROVAL_STATUSES, + durable_approval, + durable_approval_page, + durable_approval_stats, +) from ._durable_response import durable_execution, durable_execution_page from ._transport import ( + DurableApprovalListRequest, + DurableApprovalStatsRequest, DurableExecutionListRequest, Transport, TransportResponse, @@ -16,7 +25,15 @@ ) if TYPE_CHECKING: + from collections.abc import Mapping + from .models import ( + DurableApproval, + DurableApprovalListOptions, + DurableApprovalPage, + DurableApprovalStats, + DurableApprovalStatsOptions, + DurableApprovalStatus, DurableExecution, DurableExecutionPage, DurableExecutionStatus, @@ -24,6 +41,14 @@ ) _INVALID_DURABLE_TRANSPORT = "Transport does not support durable executions" +_INVALID_APPROVAL_TRANSPORT = "Transport does not support durable approvals" +# The spec's limits on a function filter and a decision comment. +_MAX_FUNCTION_LENGTH = 255 +_MAX_COMMENT_LENGTH = 2000 +_LIST_OPTIONS = frozenset( + {"status", "function", "execution_id", "from_", "to", "page", "limit"} +) +_STATS_OPTIONS = frozenset({"function", "from_", "to"}) # The spec's maxLength on X-Volcano-Execution-Name. Checked here so an # over-long name is refused before a request is spent on it, the way the # JavaScript SDK refuses it. @@ -34,6 +59,7 @@ "execution_name": "execution_name must be a non-empty string", "project_id": "project_id must be a non-empty string", "execution_id": "execution_id must be a non-empty string", + "approval_id": "approval_id must be a non-empty string", } # The two identifiers the transport converts to a UUID before sending. Checked # here so a malformed one is a facade ValidationError rather than a @@ -42,6 +68,7 @@ _UUID_IDENTIFIERS = { "project_id": "project_id must be a UUID", "execution_id": "execution_id must be a UUID", + "approval_id": "approval_id must be a UUID", } _HTTP_ACCEPTED = 202 _HTTP_OK = 200 @@ -112,12 +139,220 @@ def stop_durable_execution( ... +@runtime_checkable +class DurableApprovalsTransport(Protocol): + """Transport operations required by the durable approvals facade.""" + + def list_durable_approvals( + self, + *, + authorization: str, + project_id: str, + request: DurableApprovalListRequest, + ) -> TransportResponse: + """List a project's durable approvals.""" + ... + + def get_durable_approval( + self, + *, + authorization: str, + project_id: str, + approval_id: str, + ) -> TransportResponse: + """Read one durable approval.""" + ... + + def get_durable_approval_stats( + self, + *, + authorization: str, + project_id: str, + request: DurableApprovalStatsRequest, + ) -> TransportResponse: + """Count a project's durable approvals by outcome.""" + ... + + def approve_durable_approval( + self, + *, + authorization: str, + project_id: str, + approval_id: str, + comment: str | None, + ) -> TransportResponse: + """Approve a pending durable approval.""" + ... + + def deny_durable_approval( + self, + *, + authorization: str, + project_id: str, + approval_id: str, + comment: str | None, + ) -> TransportResponse: + """Deny a pending durable approval.""" + ... + + +class DurableApprovals: + """Review and decide the approvals durable workflows wait on. + + Owner-scoped, like reading an execution: every method takes the project id + and the session's platform token. Only a person decides: a project access + token can read approvals but is refused with `PermissionDeniedError` when it + approves or denies one. + """ + + def __init__(self, client: DurableClientContext) -> None: + """Bind approval operations to a Volcano client.""" + self._client: DurableClientContext = client + + def _transport(self) -> DurableApprovalsTransport: + transport = self._client.transport() + if not isinstance(transport, DurableApprovalsTransport): + raise TypeError(_INVALID_APPROVAL_TRANSPORT) + return transport + + def list( + self, project_id: str, **options: Unpack[DurableApprovalListOptions] + ) -> DurableApprovalPage: + """List a project's approvals, newest first. + + Decided, expired, and cancelled approvals are kept for a year. + + Returns + ------- + DurableApprovalPage + Approvals and pagination metadata. + + """ + project = _identifier(project_id, "project_id") + request = _list_request(options) + transport = self._transport() + response = invoke( + transport.list_durable_approvals, + authorization=self._client.session_token(), + project_id=project, + request=request, + ) + response_body: object = response_payload(response, _HTTP_OK) + return durable_approval_page(response_body) + + def get(self, project_id: str, approval_id: str) -> DurableApproval: + """Read one approval, including its decision once it has one. + + Returns + ------- + DurableApproval + The approval. + + """ + project = _identifier(project_id, "project_id") + approval = _identifier(approval_id, "approval_id") + transport = self._transport() + response = invoke( + transport.get_durable_approval, + authorization=self._client.session_token(), + project_id=project, + approval_id=approval, + ) + response_body: object = response_payload(response, _HTTP_OK) + return durable_approval(response_body) + + def stats( + self, project_id: str, **options: Unpack[DurableApprovalStatsOptions] + ) -> DurableApprovalStats: + """Count approvals by outcome, overall, per function, and per day. + + The window defaults to the last 30 days and may span at most 366. + + Returns + ------- + DurableApprovalStats + Counts, approval rate, and decision times over the window. + + """ + project = _identifier(project_id, "project_id") + request = _stats_request(options) + transport = self._transport() + response = invoke( + transport.get_durable_approval_stats, + authorization=self._client.session_token(), + project_id=project, + request=request, + ) + response_body: object = response_payload(response, _HTTP_OK) + return durable_approval_stats(response_body) + + def approve( + self, project_id: str, approval_id: str, *, comment: str | None = None + ) -> DurableApproval: + """Approve a pending approval, resuming the workflow waiting on it. + + Repeating the same decision returns the approval unchanged. Deciding + the other way, or deciding one that expired or was cancelled, raises + `ConflictError`. + + Returns + ------- + DurableApproval + The approval with its decision. + + """ + return self._decide("approve", project_id, approval_id, comment) + + def deny( + self, project_id: str, approval_id: str, *, comment: str | None = None + ) -> DurableApproval: + """Deny a pending approval; the workflow resumes with a denied decision. + + Repeats and conflicts behave as they do for `approve`. + + Returns + ------- + DurableApproval + The approval with its decision. + + """ + return self._decide("deny", project_id, approval_id, comment) + + def _decide( + self, + decision: Literal["approve", "deny"], + project_id: str, + approval_id: str, + comment: object, + ) -> DurableApproval: + project = _identifier(project_id, "project_id") + approval = _identifier(approval_id, "approval_id") + note = _comment(comment) + transport = self._transport() + operation = ( + transport.approve_durable_approval + if decision == "approve" + else transport.deny_durable_approval + ) + response = invoke( + operation, + authorization=self._client.session_token(), + project_id=project, + approval_id=approval, + comment=note, + ) + response_body: object = response_payload(response, _HTTP_OK) + return durable_approval(response_body) + + class Durable: """Start and follow executions of deployed durable functions.""" def __init__(self, client: DurableClientContext | ClientContextSource) -> None: """Bind durable operations to a Volcano client.""" self._client: DurableClientContext = facade_context(client) + # Approvals that workflows wait on with `ctx.wait_for_approval`. + self.approvals: DurableApprovals = DurableApprovals(self._client) def _durable_transport(self) -> DurableTransport: transport = self._client.transport() @@ -320,3 +555,89 @@ def _identifier(value: object, field: str) -> str: except ValueError as exc: raise ValueError(_UUID_IDENTIFIERS[field]) from exc return trimmed + + +def _known_options(options: Mapping[str, object], known: frozenset[str]) -> None: + unknown = sorted(options.keys() - known) + if unknown: + message = f"unexpected option: {', '.join(unknown)}" + raise TypeError(message) + + +def _list_request(options: DurableApprovalListOptions) -> DurableApprovalListRequest: + _known_options(options, _LIST_OPTIONS) + status = options.get("status") + execution_id = options.get("execution_id") + return DurableApprovalListRequest( + status=None if status is None else _approval_status(status), + function=_function_filter(options.get("function")), + execution_id=( + None if execution_id is None else _identifier(execution_id, "execution_id") + ), + from_=_moment(options.get("from_"), "from_"), + to=_moment(options.get("to"), "to"), + page=options.get("page"), + limit=options.get("limit"), + ) + + +def _stats_request( + options: DurableApprovalStatsOptions, +) -> DurableApprovalStatsRequest: + _known_options(options, _STATS_OPTIONS) + return DurableApprovalStatsRequest( + function=_function_filter(options.get("function")), + from_=_moment(options.get("from_"), "from_"), + to=_moment(options.get("to"), "to"), + ) + + +def _approval_status(value: object) -> DurableApprovalStatus: + for status in APPROVAL_STATUSES: + if value == status: + return status + message = f"status must be one of {', '.join(APPROVAL_STATUSES)}" + raise ValueError(message) + + +def _function_filter(value: object) -> str | None: + if value is None: + return None + if not isinstance(value, str) or not value.strip(): + message = "function must be a non-empty string" + raise ValueError(message) + function = value.strip() + if len(function) > _MAX_FUNCTION_LENGTH: + message = f"function must be at most {_MAX_FUNCTION_LENGTH} characters" + raise ValueError(message) + return function + + +def _moment(value: object, field: str) -> datetime | None: + """Require an aware datetime, so the window does not shift with the host's zone. + + Returns: + The datetime, or None when the filter is unset. + + Raises: + ValueError: The value is not a timezone-aware datetime. + + """ + if value is None: + return None + if not isinstance(value, datetime) or value.utcoffset() is None: + message = f"{field} must be a timezone-aware datetime" + raise ValueError(message) + return value + + +def _comment(value: object) -> str | None: + if value is None: + return None + if not isinstance(value, str): + message = "comment must be a string" + raise TypeError(message) + if len(value) > _MAX_COMMENT_LENGTH: + message = f"comment must be at most {_MAX_COMMENT_LENGTH} characters" + raise ValueError(message) + return value diff --git a/src/volcano_sdk/durable_authoring.py b/src/volcano_sdk/durable_authoring.py index fe37b432..e3f78a02 100644 --- a/src/volcano_sdk/durable_authoring.py +++ b/src/volcano_sdk/durable_authoring.py @@ -26,19 +26,27 @@ def handler(event, ctx): from __future__ import annotations import functools -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import ( TYPE_CHECKING, Generic, Never, TypeAlias, + TypeGuard, overload, ) from typing_extensions import TypeVar from ._callbacks import named_operation, operation_callable, require_callable +from ._durable_approval_registration import ( + EXPIRED, + approval_body, + approval_decision, + platform_api_url, + register_approval, +) from ._durable_duration import to_seconds from ._durable_engine import DurableRuntimeMissingError, load_engine from ._durable_options import ( @@ -54,13 +62,16 @@ def handler(event, ctx): OperationScope, RuntimeContext, ) -from ._durable_results import BatchFailure, BatchItem, BatchResult +from ._durable_results import ApprovalDecision, BatchFailure, BatchItem, BatchResult if TYPE_CHECKING: from collections.abc import Sequence + from .models import JSONValue + __all__ = [ + "ApprovalDecision", "BatchFailure", "BatchItem", "BatchOptions", @@ -109,6 +120,10 @@ def handler(event, ctx): _INVALID_BRANCH = "a parallel branch is a callable, or a ParallelBranch" _INVALID_ITEMS = "map() requires a sequence of items" _INVALID_WAIT_ARGS = "wait() takes a name and a duration, or a duration alone" +_MISSING_EXECUTION_REF = ( + "wait_for_approval() needs the invocation's DurableExecutionArn, which " + "@durable reads from the event; call it from a handler wrapped with @durable" +) @dataclass(frozen=True, slots=True) @@ -144,18 +159,24 @@ class DurableContext: the operations Volcano supports, in Volcano's vocabulary, and lets durations be written as `"30s"` rather than `Duration.from_seconds(30)`. - Callbacks are the deliberate omission. The engine can suspend on one, but - completing it is an AWS API call, and nothing in Volcano -- not the - function's own role, not the API -- can make it. Wait on your own state - with `wait_until` instead. + Raw callbacks are the deliberate omission: completing one takes a call + nothing in a project can make. `wait_for_approval` is the callback Volcano + completes, when a person decides. """ - __slots__: tuple[str, ...] = ("_context", "_engine", "log") + __slots__: tuple[str, ...] = ("_context", "_engine", "_execution_ref", "log") - def __init__(self, context: RuntimeContext, engine: DurableEngine) -> None: + def __init__( + self, + context: RuntimeContext, + engine: DurableEngine, + execution_ref: str | None = None, + ) -> None: """Wrap an engine context.""" self._context: RuntimeContext = context self._engine: DurableEngine = engine + # Identifies the execution to Volcano when it registers an approval. + self._execution_ref: str | None = execution_ref # Logs, suppressed while an operation is being replayed. self.log: DurableLogger = context.logger @@ -224,10 +245,9 @@ def child( """ child_name, child_func = named_operation(name, func, "child") - engine = self._engine def run(context: RuntimeContext) -> T: - return child_func(DurableContext(context, engine)) + return child_func(self._derive(context)) return self._context.run_in_child_context(run, child_name) @@ -286,17 +306,16 @@ def map( # work per character rather than refuse. if isinstance(items, str): raise TypeError(_INVALID_ITEMS) - engine = self._engine def run(context: RuntimeContext, item: U, index: int, _all: list[U]) -> T: - return map_func(item, DurableContext(context, engine), index) + return map_func(item, self._derive(context), index) return BatchResult( self._context.map( list(items), run, name, - engine.map_options(options), + self._engine.map_options(options), ) ) @@ -335,22 +354,86 @@ def _branch( A named engine branch or a callable that wraps the engine context. """ - engine = self._engine if isinstance(branch, ParallelBranch): named: Callable[[DurableContext], T] = branch.run def run_named(context: RuntimeContext) -> T: - return named(DurableContext(context, engine)) + return named(self._derive(context)) - return engine.named_branch(run_named, branch.name) + return self._engine.named_branch(run_named, branch.name) require_callable(branch, _INVALID_BRANCH) bare: Callable[[DurableContext], T] = branch def run_bare(context: RuntimeContext) -> object: - return bare(DurableContext(context, engine)) + return bare(self._derive(context)) return run_bare + def _derive(self, context: RuntimeContext) -> DurableContext: + return DurableContext(context, self._engine, self._execution_ref) + + def wait_for_approval( + self, + name: str, + *, + title: str, + description: str | None = None, + details: JSONValue = None, + timeout: Duration | None = None, + ) -> ApprovalDecision: + """Suspend until a person approves or denies, or the timeout passes. + + Volcano records the approval under the execution, where the project's + owners see it in the dashboard, the CLI, or `client.durable.approvals`, + and resumes the execution with their decision. It is not running, and + not billed, while it waits. `title`, `description`, and `details` are + what the person deciding is shown. + + Without a timeout the approval lasts as long as the execution. An + approval nobody decides in time resumes with an `expired` decision + rather than raising, and a denial is a decision too: branch on + `approved`. + + Returns: + The decision, restored from its checkpoint during replay. + + Raises: + RuntimeError: The function is not running where Volcano can record + the approval: `VOLCANO_PLATFORM_API_URL` is unset, or the + handler is not wrapped with `@durable`. + + """ + body = approval_body(name, title, description, details) + seconds = None if timeout is None else self._approval_timeout(timeout) + api_url = platform_api_url() + if self._execution_ref is None: + raise RuntimeError(_MISSING_EXECUTION_REF) + body["execution_ref"] = self._execution_ref + + # Runs inside the runtime's checkpointed submitter step, so a replay + # does not register the approval again. + def submit(callback_id: str, _context: object) -> None: + register_approval(api_url, {**body, "callback_id": callback_id}) + + try: + result = self._context.wait_for_callback( + submit, name, self._engine.callback_options(seconds) + ) + except self._engine.callback_timeout_error: + return EXPIRED + return approval_decision(result) + + @staticmethod + def _approval_timeout(value: object) -> int: + seconds = to_seconds(value, "timeout") + if seconds < _MIN_WAIT_SECONDS: + message = f"timeout must be at least {_MIN_WAIT_SECONDS} second" + raise ValueError(message) + if seconds > _MAX_WAIT_SECONDS: + message = f"timeout must be at most {_MAX_WAIT_SECONDS} seconds (366 days)" + raise ValueError(message) + return seconds + def _wait_duration(self, value: object) -> object: seconds = to_seconds(value, "wait") if seconds < _MIN_WAIT_SECONDS: @@ -410,31 +493,44 @@ def decorate(func: DurableHandler[T, U]) -> FunctionHandler: def _wrap_durable( handler: Callable[[T, DurableContext], object], logger: object ) -> FunctionHandler: - # Wrapped on the first invocation, not here: resolving the engine is what - # fails when the runtime is absent, and a decorator that raises at import - # time would break a module that merely mentions a durable handler. - wrapped: list[FunctionHandler] = [] - + # Wrapped on invocation, not here: resolving the engine is what fails when + # the runtime is absent, and a decorator that raises at import time would + # break a module that merely mentions a durable handler. Wrapped per + # invocation because the handler runs on the runtime's own thread, and the + # execution reference read from this invocation's event has to reach it. # functools.wraps rather than copying two attributes: __qualname__, # __module__, __dict__ and __wrapped__ matter to inspect.unwrap and to a # traceback, and leaving them pointing at this closure makes the SDK's # wrapper the thing a user sees when their handler fails. @functools.wraps(handler) def invoke(event: object, function_context: object) -> object: - if not wrapped: - engine = load_engine() + engine = load_engine() + execution_ref = _execution_ref(event) - def run(input_value: T, context: RuntimeContext) -> object: - if logger is not None: - context.set_logger(logger) - return handler(input_value, DurableContext(context, engine)) + def run(input_value: T, context: RuntimeContext) -> object: + if logger is not None: + context.set_logger(logger) + return handler(input_value, DurableContext(context, engine, execution_ref)) - wrapped.append(engine.durable_execution(run)) - return wrapped[0](event, function_context) + return engine.durable_execution(run)(event, function_context) return invoke +def _execution_ref(event: object) -> str | None: + # A deployed function receives the invocation as a mapping; the local test + # runner hands over its parsed input object instead. + if _is_event_mapping(event): + value = event.get("DurableExecutionArn") + else: + value = getattr(event, "durable_execution_arn", None) + return value if isinstance(value, str) and value else None + + +def _is_event_mapping(value: object) -> TypeGuard[Mapping[object, object]]: + return isinstance(value, Mapping) + + def _validate_wait_options(options: WaitUntilOptions[T]) -> None: if not callable(options.until): raise TypeError(_REQUIRES_UNTIL) diff --git a/src/volcano_sdk/errors.py b/src/volcano_sdk/errors.py index 8ef7863d..3ca6184f 100644 --- a/src/volcano_sdk/errors.py +++ b/src/volcano_sdk/errors.py @@ -25,6 +25,14 @@ class AuthenticationError(VolcanoError): """The supplied credentials are missing or invalid.""" +class PermissionDeniedError(AuthenticationError): + """The credentials are valid but not allowed to perform the request. + + A subclass of `AuthenticationError`, which a 403 raised before this + existed, so existing handlers keep catching it. + """ + + class ValidationError(VolcanoError): """The request failed API validation.""" diff --git a/src/volcano_sdk/models.py b/src/volcano_sdk/models.py index 2a772783..767acbe6 100644 --- a/src/volcano_sdk/models.py +++ b/src/volcano_sdk/models.py @@ -2,9 +2,9 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass, field -from datetime import datetime +from datetime import date, datetime from types import MappingProxyType -from typing import Literal, TypeAlias +from typing import Literal, TypeAlias, TypedDict from ._json_values import JSONValue as _JSONValue from ._json_values import freeze_json @@ -39,6 +39,15 @@ DURABLE_TERMINAL_STATUSES: frozenset[DurableExecutionStatus] = frozenset( {"succeeded", "failed", "timed_out", "stopped", "unknown"} ) +# Every status but "pending" is final. "expired" means the approval's timeout +# passed first; "cancelled" means its execution ended while it was pending. +DurableApprovalStatus: TypeAlias = Literal[ + "pending", + "approved", + "denied", + "expired", + "cancelled", +] def _freeze_metadata( @@ -384,3 +393,162 @@ class DurableExecutionPage: def __post_init__(self) -> None: """Defensively snapshot the executions in this page.""" object.__setattr__(self, "executions", tuple(self.executions)) + + +class DurableApprovalStatsOptions(TypedDict, total=False): + """Narrow approval stats to one function and a window. + + The window defaults to the last 30 days and may span at most 366. + """ + + # A durable function's id or name. + function: str + from_: datetime + to: datetime + + +class DurableApprovalListOptions(TypedDict, total=False): + """Filter and page a project's durable approvals.""" + + status: DurableApprovalStatus + # A durable function's id or name. + function: str + execution_id: str + # Requested at or after `from_`, and before `to`. + from_: datetime + to: datetime + page: int + limit: int + + +@dataclass(frozen=True, slots=True) +class DurableApprovalDecider: + """The person who approved or denied an approval.""" + + id: str + email: str + + +@dataclass(frozen=True, slots=True) +class DurableApprovalDecision: + """Who decided an approval, when, and what they said.""" + + comment: str + # None once the deciding account no longer exists. + decided_by: DurableApprovalDecider | None + decided_at: datetime + + +@dataclass(frozen=True, slots=True) +class DurableApprovalFunction: + """The durable function that requested an approval.""" + + # None once the function has been deleted; the name is kept. + id: str | None + name: str + + +@dataclass(frozen=True, slots=True) +class DurableApprovalExecution: + """The durable execution that requested an approval.""" + + # Both None once the execution is no longer retained; the name is kept. + id: str | None + name: str + status: DurableExecutionStatus | None + + +@dataclass(frozen=True, slots=True) +class DurableApproval: + """An approval a durable workflow requested with `ctx.wait_for_approval`.""" + + id: str + status: DurableApprovalStatus + name: str + title: str + description: str + function: DurableApprovalFunction + execution: DurableApprovalExecution + requested_at: datetime + # None when the workflow set no timeout: the approval then lasts as long as + # its execution. + expires_at: datetime | None + # Set only once the approval is approved or denied. + decision: DurableApprovalDecision | None + details: JSONValue = field(default=None, repr=False, hash=False) + + def __post_init__(self) -> None: + """Defensively freeze the details the workflow attached.""" + object.__setattr__(self, "details", freeze_json(self.details)) + + @property + def is_pending(self) -> bool: + """Report whether the approval is still waiting for a decision.""" + return self.status == "pending" + + +@dataclass(frozen=True, slots=True) +class DurableApprovalPage: + """One page of a project's durable approvals, most recent first.""" + + approvals: tuple[DurableApproval, ...] + page: int + limit: int + total: int + has_more: bool + + def __post_init__(self) -> None: + """Defensively snapshot the approvals in this page.""" + object.__setattr__(self, "approvals", tuple(self.approvals)) + + +@dataclass(frozen=True, slots=True) +class DurableApprovalCounts: + """Approvals counted by outcome; `requested` is all of them.""" + + requested: int + pending: int + approved: int + denied: int + expired: int + cancelled: int + + +@dataclass(frozen=True, slots=True) +class DurableApprovalFunctionCounts: + """Approval counts for one durable function.""" + + function: DurableApprovalFunction + counts: DurableApprovalCounts + + +@dataclass(frozen=True, slots=True) +class DurableApprovalDailyCounts: + """Approval counts for one UTC day, by the day they were requested.""" + + day: date + counts: DurableApprovalCounts + + +@dataclass(frozen=True, slots=True) +class DurableApprovalStats: + """Approval outcomes and decision times over a window.""" + + from_: datetime + to: datetime + counts: DurableApprovalCounts + # Each None while there is nothing to compute it from. + approval_rate: float | None + median_seconds_to_decision: float | None + p90_seconds_to_decision: float | None + # The ten functions with the most requests; the rest are summed in + # other_functions. + functions: tuple[DurableApprovalFunctionCounts, ...] + other_functions: DurableApprovalCounts + # Days with no requests are left out. + daily: tuple[DurableApprovalDailyCounts, ...] + + def __post_init__(self) -> None: + """Defensively snapshot the per-function and per-day breakdowns.""" + object.__setattr__(self, "functions", tuple(self.functions)) + object.__setattr__(self, "daily", tuple(self.daily)) From 16c21c85f1545f1a2f3016629e87035d47788828 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Tue, 6 Oct 2026 22:54:16 -0700 Subject: [PATCH 03/16] fix(durable): bound approval registration retries and validate size Retry registration on network errors, timeouts, 404, 429, any 5xx, and 409 approval_not_ready, on the JS SDK's delay schedule within one 30-second deadline that also caps each attempt's timeout. A 409 approval_closed now counts as registered, so an approval whose timeout passed first resumes with the expired decision instead of failing the execution. Refuse text that cannot be encoded as JSON and approvals over 64 KiB (with room for the longest callback id) before the callback opens, and send the exact bytes that were measured. Out-of-range approval timeouts raise TypeError, as ctx.wait does. Correct the guide: approvals have no name filter, and a refusal reaches the handler as the runtime's CallbackSubmitterError. Co-authored-by: Cursor --- README.md | 4 +- docs/functions.md | 10 +- .../_durable_approval_registration.py | 107 ++++++- src/volcano_sdk/_durable_results.py | 3 +- .../test_durable_approval_registration.py | 288 +++++++++++++++--- .../_tests/test_durable_authoring.py | 144 ++++++++- src/volcano_sdk/durable_authoring.py | 36 ++- 7 files changed, 515 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 34074bef..f2c09a0c 100644 --- a/README.md +++ b/README.md @@ -411,7 +411,9 @@ if not decision.approved: `decision` carries `approved`, `status` (`approved`, `denied`, or `expired`), `comment`, `decided_by` (`id` and `email`, or `None`), and `decided_at`. Volcano sets `VOLCANO_PLATFORM_API_URL` on durable functions; without it the call raises -`RuntimeError`. See the +`RuntimeError`. The function retries registering the approval for up to 30 +seconds. If Volcano refuses it, the call raises the durable runtime's +`CallbackSubmitterError` with the SDK error's message. See the [functions guide](https://github.com/Kong/volcano-sdk-python/blob/main/docs/functions.md#wait-for-an-approval). `logs.search()` returns an immutable page of retained runtime or deployment log diff --git a/docs/functions.md b/docs/functions.md index d0ccfa15..b249b8e4 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -160,12 +160,14 @@ def handler(event, ctx): | Argument | Description | |---|---| -| `name` | Operation name, up to 255 characters. Filter and search approvals by it. | +| `name` | Required. Operation name in the execution's history, up to 255 characters. | | `title` | Required. What the approver is asked, up to 200 characters. | | `description` | Optional context, up to 4000 characters. | -| `details` | Optional JSON value shown with the approval. | +| `details` | Optional JSON value shown with the approval. The whole approval, `details` included, must encode to at most 64 KiB of JSON. | | `timeout` | Optional duration in the `ctx.wait()` format, from one second to 366 days. Without it, the approval stays open as long as the execution runs. | +Limits count Unicode characters. A blank `name` or `title` raises `ValueError`, as does a value over its limit or an approval over 64 KiB. A value that is not a string, or that cannot be encoded as JSON, raises `TypeError`. A timeout out of range raises `TypeError`, as it does for `ctx.wait()`. These checks run before anything is recorded. + The call returns an immutable `ApprovalDecision`: | Field | Approved | Denied | Timed out | @@ -173,12 +175,12 @@ The call returns an immutable `ApprovalDecision`: | `approved` | `True` | `False` | `False` | | `status` | `"approved"` | `"denied"` | `"expired"` | | `comment` | The approver's comment, or `""` | The approver's comment, or `""` | `""` | -| `decided_by` | `DurableApprovalDecider` with `id` and `email`, or `None` | Same as approved | `None` | +| `decided_by` | `DurableApprovalDecider` with `id` and `email`, or `None` if the account was deleted before the decision reached the execution | Same as approved | `None` | | `decided_at` | RFC 3339 timestamp | RFC 3339 timestamp | `None` | A denial or a timeout is a value to branch on, not an exception. The execution costs nothing while it waits, and a resumed execution replays the recorded decision without asking again. -Volcano sets `VOLCANO_PLATFORM_API_URL` on deployed and local durable functions. The function sends the approval there without a credential. Volcano accepts it only from the execution that is waiting. Transient failures are retried for about 30 seconds. If Volcano refuses the approval, the execution fails with the SDK error. Calling `wait_for_approval()` without `VOLCANO_PLATFORM_API_URL`, or from a handler that is not decorated with `@durable`, raises `RuntimeError`. +Volcano sets `VOLCANO_PLATFORM_API_URL` on deployed and local durable functions. The function sends the approval there without a credential. Volcano accepts it only from the execution that is waiting. When Volcano does not answer, has not seen the execution or its approval yet, throttles the request, or fails, the function retries for up to 30 seconds. An approval whose timeout passes before Volcano records it returns the `expired` decision. If Volcano refuses the approval, or keeps failing for 30 seconds, `wait_for_approval()` raises the durable runtime's `CallbackSubmitterError`, carrying the SDK error's message. Unless the handler catches it, the execution fails. Calling `wait_for_approval()` without `VOLCANO_PLATFORM_API_URL`, or from a handler that is not decorated with `@durable`, raises `RuntimeError`. ## Decide approvals from a backend diff --git a/src/volcano_sdk/_durable_approval_registration.py b/src/volcano_sdk/_durable_approval_registration.py index ccd4c3f9..e59097d7 100644 --- a/src/volcano_sdk/_durable_approval_registration.py +++ b/src/volcano_sdk/_durable_approval_registration.py @@ -2,9 +2,10 @@ from __future__ import annotations +import json import os from collections.abc import Mapping -from time import sleep +from time import monotonic, sleep from typing import TYPE_CHECKING, TypeGuard import httpx @@ -20,6 +21,7 @@ from ._transport_types import HTTP_CREATED, HTTP_OK, decode_json from .errors import ( ConflictError, + NotFoundError, RateLimitedError, ServerError, TransportError, @@ -30,18 +32,33 @@ if TYPE_CHECKING: from .models import JSONValue -# 0.5 s doubling to a 5 s cap, about 30 s in all: long enough to outlast the -# moment the platform has not yet seen the callback open. -_RETRY_DELAYS = (0.5, 1.0, 2.0, 4.0, 5.0, 5.0, 5.0, 5.0) +# 0.5 s doubling to a 5 s cap, ending where the deadline runs out: long enough +# to outlast the moment the platform has not yet seen the execution start or +# the callback open. +_RETRY_DELAYS = (0.5, 1.0, 2.0, 4.0, 5.0, 5.0, 5.0, 5.0, 2.5) +_DEADLINE_SECONDS = 30.0 _REQUEST_TIMEOUT_SECONDS = 10.0 _NOT_READY = "approval_not_ready" +_CLOSED = "approval_closed" +_HEADERS = {"Content-Type": "application/json"} +_MAX_BODY_BYTES = 65536 +# The callback id is assigned once the callback opens, after the size check; +# the spec allows it 1024 characters. +_LONGEST_CALLBACK_ID = "x" * 1024 +_TOO_LARGE = ( + "the approval is larger than the 64 KiB Volcano accepts once encoded as " + "JSON; send less in details" +) _INVALID_DECISION = "Expected a complete approval decision" _API_URL_VARIABLE = "VOLCANO_PLATFORM_API_URL" _MISSING_API_URL = ( "wait_for_approval() needs VOLCANO_PLATFORM_API_URL. Volcano sets it on " "deployed durable functions; set it yourself to run the handler elsewhere." ) -_INVALID_DETAILS = "details must be a JSON value" +_INVALID_DETAILS = ( + "details must be JSON-serializable: mappings with string keys, lists, " + "tuples, strings, finite numbers, booleans, and None" +) # The spec's limits on what the approval shows the person deciding. _TEXT_LIMITS = {"name": 255, "title": 200, "description": 4000} EXPIRED = ApprovalDecision(approved=False, status="expired") @@ -87,6 +104,11 @@ def _text(value: object, field: str, *, required: bool) -> str: if not isinstance(value, str): message = f"{field} must be a string" raise TypeError(message) + try: + _ = value.encode() + except UnicodeEncodeError as error: + message = f"{field} must be JSON-serializable: it contains a surrogate" + raise TypeError(message) from error if required and not value.strip(): message = f"{field} must not be empty" raise ValueError(message) @@ -97,43 +119,98 @@ def _text(value: object, field: str, *, required: bool) -> str: return value +def check_size(body: Mapping[str, JSONValue]) -> None: + """Refuse an approval too large for Volcano, before any callback is opened. + + Raises: + ValueError: The registration, with the longest callback id the runtime + can assign, would encode to more than 64 KiB. + + """ + if len(_encode({**body, "callback_id": _LONGEST_CALLBACK_ID})) > _MAX_BODY_BYTES: + raise ValueError(_TOO_LARGE) + + +def _encode(body: Mapping[str, JSONValue]) -> bytes: + return json.dumps( + plain_json(body), ensure_ascii=False, separators=(",", ":"), allow_nan=False + ).encode() + + def register_approval(api_url: str, body: Mapping[str, JSONValue]) -> None: """Ask Volcano to record the approval and hold the callback for a person. The route takes no credential: the execution reference and callback id in the body are what Volcano checks against the running execution. + What can clear on its own is retried until a 30 second deadline: no + answer, an execution or callback Volcano cannot see yet, throttling, and + server failures. An approval that already closed, normally because its + timeout passed first, needs no registration: the callback's own outcome + is what the execution resumes with. + Raises: VolcanoError: Volcano refused the approval, or kept failing until the - retries ran out. + deadline passed or the retries ran out. """ url = f"{api_url.removesuffix('/')}/durable-approvals" - with httpx.Client(timeout=_REQUEST_TIMEOUT_SECONDS) as client: + content = _encode(body) + deadline = monotonic() + _DEADLINE_SECONDS + timeout = _REQUEST_TIMEOUT_SECONDS + with httpx.Client() as client: for delay in _RETRY_DELAYS: try: - _post(client, url, body) + _post(client, url, content, timeout) except VolcanoError as error: - if not _retryable(error): + next_timeout = _next_timeout(error, delay, deadline) + if next_timeout is None: raise - sleep(delay) + timeout = next_timeout else: return - _post(client, url, body) + _post(client, url, content, timeout) -def _post(client: httpx.Client, url: str, body: Mapping[str, JSONValue]) -> None: - response = unparsed_response(invoke(client.post, url, json=plain_json(body))) - if response.status_code != HTTP_CREATED: +def _post(client: httpx.Client, url: str, content: bytes, timeout: float) -> None: + response = unparsed_response( + invoke(client.post, url, content=content, headers=_HEADERS, timeout=timeout) + ) + if response.status_code == HTTP_CREATED: + return + try: _ = response_payload(response, HTTP_OK) + except ConflictError as error: + if error.code != _CLOSED: + raise def _retryable(error: VolcanoError) -> bool: - if isinstance(error, (TransportError, RateLimitedError, ServerError)): + # NotFoundError included: Volcano records an execution only once it has + # started, so an approval that is its first operation can arrive first. + if isinstance( + error, (TransportError, NotFoundError, RateLimitedError, ServerError) + ): return True return isinstance(error, ConflictError) and error.code == _NOT_READY +def _next_timeout(error: VolcanoError, delay: float, deadline: float) -> float | None: + """Wait to retry a failed attempt, if it is worth retrying in the time left. + + Returns: + The next attempt's timeout, or None when the failure is final. + + """ + if not _retryable(error) or monotonic() + delay >= deadline: + return None + sleep(delay) + remaining = deadline - monotonic() + if remaining <= 0: + return None + return min(_REQUEST_TIMEOUT_SECONDS, remaining) + + def approval_decision(result: object) -> ApprovalDecision: """Read the decision Volcano completed the callback with. diff --git a/src/volcano_sdk/_durable_results.py b/src/volcano_sdk/_durable_results.py index 63fb6279..dc628f05 100644 --- a/src/volcano_sdk/_durable_results.py +++ b/src/volcano_sdk/_durable_results.py @@ -26,7 +26,8 @@ class ApprovalDecision: approved: bool status: Literal["approved", "denied", "expired"] comment: str = "" - # None when the approval expired, or the deciding account no longer exists. + # None when the approval expired, or when the deciding account was deleted + # before the decision reached the workflow. decided_by: DurableApprovalDecider | None = None # RFC 3339, as Volcano recorded it; None when the approval expired. decided_at: str | None = None diff --git a/src/volcano_sdk/_tests/test_durable_approval_registration.py b/src/volcano_sdk/_tests/test_durable_approval_registration.py index 95436d45..be6ffb24 100644 --- a/src/volcano_sdk/_tests/test_durable_approval_registration.py +++ b/src/volcano_sdk/_tests/test_durable_approval_registration.py @@ -19,6 +19,7 @@ from volcano_sdk._durable_approval_registration import ( approval_body, approval_decision, + check_size, platform_api_url, register_approval, ) @@ -37,31 +38,62 @@ "name": "ship-order", "title": "Ship order 1234?", } -RETRY_DELAYS = [0.5, 1.0, 2.0, 4.0, 5.0, 5.0, 5.0, 5.0] +RETRY_DELAYS = [0.5, 1.0, 2.0, 4.0, 5.0, 5.0, 5.0, 5.0, 2.5] # The fixture replaces httpx.Client for the module under test, which is the # same attribute everywhere. REAL_CLIENT = httpx.Client +REGISTRATION = "volcano_sdk._durable_approval_registration" +INVALID_DETAILS = ( + "details must be JSON-serializable: mappings with string keys, lists, " + "tuples, strings, finite numbers, booleans, and None" +) class Platform: - """Answer registrations in order and record what was sent.""" + """Answer registrations in order and record what was sent. + + Time is the platform's own: it passes while the registration sleeps and + while a request runs into its timeout, and nowhere else. + """ def __init__(self, *answers: httpx.Response | Exception) -> None: self.answers: list[httpx.Response | Exception] = list(answers) self.requests: list[httpx.Request] = [] + self.timeouts: list[float] = [] self.sleeps: list[float] = [] - self.client_timeouts: list[object] = [] + self.now: float = 0.0 + # How much longer than asked each sleep takes. + self.oversleep: float = 0.0 + self.clock_runs: bool = True def handle(self, request: httpx.Request) -> httpx.Response: self.requests.append(request) + match request.extensions: + case {"timeout": {"read": float(timeout)}}: + self.timeouts.append(timeout) + case _: + message = f"expected a read timeout, got {request.extensions}" + raise AssertionError(message) answer = self.answers[0] if len(self.answers) == 1 else self.answers.pop(0) + if isinstance(answer, httpx.TimeoutException): + self.advance(timeout) if isinstance(answer, Exception): raise answer return answer - def client(self, *, timeout: float) -> httpx.Client: - self.client_timeouts.append(timeout) - return REAL_CLIENT(timeout=timeout, transport=httpx.MockTransport(self.handle)) + def client(self) -> httpx.Client: + return REAL_CLIENT(transport=httpx.MockTransport(self.handle)) + + def monotonic(self) -> float: + return self.now + + def sleep(self, delay: float) -> None: + self.sleeps.append(delay) + self.advance(delay + self.oversleep) + + def advance(self, seconds: float) -> None: + if self.clock_runs: + self.now += seconds class InstallPlatform(Protocol): @@ -72,12 +104,9 @@ def __call__(self, *answers: httpx.Response | Exception) -> Platform: ... def platform(monkeypatch: pytest.MonkeyPatch) -> InstallPlatform: def install(*answers: httpx.Response | Exception) -> Platform: fake = Platform(*answers) - monkeypatch.setattr( - "volcano_sdk._durable_approval_registration.httpx.Client", fake.client - ) - monkeypatch.setattr( - "volcano_sdk._durable_approval_registration.sleep", fake.sleeps.append - ) + monkeypatch.setattr(f"{REGISTRATION}.httpx.Client", fake.client) + monkeypatch.setattr(f"{REGISTRATION}.monotonic", fake.monotonic) + monkeypatch.setattr(f"{REGISTRATION}.sleep", fake.sleep) return fake return install @@ -111,8 +140,10 @@ def test_registration_posts_the_body_without_credentials( assert "authorization" not in request.headers assert request.headers["content-type"] == "application/json" assert json.loads(request.content) == BODY + assert request.extensions["timeout"] == dict.fromkeys( + ("connect", "read", "write", "pool"), 10.0 + ) assert fake.sleeps == [] - assert fake.client_timeouts == [10.0] def test_an_already_registered_approval_is_success( @@ -139,10 +170,13 @@ def test_frozen_details_are_sent_as_json(platform: InstallPlatform) -> None: "first", [ pytest.param(refused(409, "approval_not_ready"), id="not ready"), + pytest.param(refused(404), id="execution not recorded yet"), pytest.param(refused(429), id="rate limited"), + pytest.param(refused(500), id="server error"), pytest.param(refused(503), id="unavailable"), pytest.param(refused(502), id="bad gateway"), pytest.param(httpx.ConnectError("connection refused"), id="network"), + pytest.param(httpx.ReadTimeout("timed out"), id="timeout"), ], ) def test_a_transient_failure_is_retried( @@ -157,7 +191,27 @@ def test_a_transient_failure_is_retried( assert json.loads(fake.requests[1].content) == BODY -def test_retries_back_off_to_a_cap_and_then_raise( +@pytest.mark.parametrize( + ("answers", "sleeps"), + [ + pytest.param([refused(409, "approval_closed")], [], id="at once"), + pytest.param( + [refused(404), refused(409, "approval_closed")], [0.5], id="on a retry" + ), + ], +) +def test_an_approval_that_already_closed_needs_no_registration( + platform: InstallPlatform, answers: list[httpx.Response], sleeps: list[float] +) -> None: + fake = platform(*answers) + + register_approval(API_URL, BODY) + + assert len(fake.requests) == len(answers) + assert fake.sleeps == sleeps + + +def test_retries_back_off_until_the_deadline_and_then_raise( platform: InstallPlatform, ) -> None: fake = platform(refused(503)) @@ -166,37 +220,105 @@ def test_retries_back_off_to_a_cap_and_then_raise( register_approval(API_URL, BODY) assert caught.value.status == 503 - assert fake.sleeps == RETRY_DELAYS - assert len(fake.requests) == len(RETRY_DELAYS) + 1 + # The last delay would end exactly at the deadline, with no time to retry. + assert fake.sleeps == RETRY_DELAYS[:-1] + assert len(fake.requests) == len(RETRY_DELAYS) -def test_the_last_attempt_can_still_succeed(platform: InstallPlatform) -> None: - fake = platform(*([refused(429)] * len(RETRY_DELAYS)), registered()) +def test_the_last_attempt_before_the_deadline_can_still_succeed( + platform: InstallPlatform, +) -> None: + fake = platform(*([refused(429)] * (len(RETRY_DELAYS) - 1)), registered()) register_approval(API_URL, BODY) + assert fake.sleeps == RETRY_DELAYS[:-1] + assert len(fake.requests) == len(RETRY_DELAYS) + + +def test_the_schedule_bounds_the_attempts_while_time_is_left( + platform: InstallPlatform, +) -> None: + fake = platform(refused(503)) + fake.clock_runs = False + + with pytest.raises(ServerError, match="refused with 503"): + register_approval(API_URL, BODY) + assert fake.sleeps == RETRY_DELAYS assert len(fake.requests) == len(RETRY_DELAYS) + 1 + assert fake.timeouts == [10.0] * (len(RETRY_DELAYS) + 1) -def test_a_network_failure_that_persists_raises_a_transport_error( +def test_each_attempt_is_given_what_is_left_of_the_deadline( platform: InstallPlatform, ) -> None: - fake = platform(httpx.ConnectError("connection refused")) + fake = platform(httpx.ReadTimeout("timed out")) - with pytest.raises(TransportError, match="connection refused"): + with pytest.raises(TransportError, match="timed out"): register_approval(API_URL, BODY) - assert len(fake.requests) == len(RETRY_DELAYS) + 1 + assert fake.timeouts == [10.0, 10.0, 8.5] + assert fake.sleeps == [0.5, 1.0] + + +def test_a_late_wake_up_leaves_the_retry_only_the_time_left( + platform: InstallPlatform, +) -> None: + fake = platform(httpx.ReadTimeout("timed out"), registered()) + fake.oversleep = 19.0 + + register_approval(API_URL, BODY) + + assert fake.sleeps == [0.5] + assert fake.timeouts == [10.0, 0.5] + + +def test_waking_up_at_the_deadline_raises_the_last_failure( + platform: InstallPlatform, +) -> None: + fake = platform(httpx.ReadTimeout("timed out")) + fake.oversleep = 19.5 + + with pytest.raises(TransportError, match="timed out"): + register_approval(API_URL, BODY) + + assert fake.sleeps == [0.5] + assert fake.timeouts == [10.0] @pytest.mark.parametrize( ("answer", "expected"), [ - pytest.param(refused(400), ValidationError, id="invalid"), pytest.param(refused(404), NotFoundError, id="no execution"), - pytest.param(refused(409, "approval_closed"), ConflictError, id="closed"), + pytest.param(refused(429), RateLimitedError, id="rate limited"), + pytest.param( + httpx.ConnectError("connection refused"), TransportError, id="network" + ), + ], +) +def test_a_failure_that_persists_raises_its_own_error( + platform: InstallPlatform, + answer: httpx.Response | Exception, + expected: type[VolcanoError], +) -> None: + fake = platform(answer) + + with pytest.raises(expected) as caught: + register_approval(API_URL, BODY) + + assert type(caught.value) is expected + assert len(fake.requests) == len(RETRY_DELAYS) + + +@pytest.mark.parametrize( + ("answer", "expected"), + [ + pytest.param(refused(400), ValidationError, id="invalid"), pytest.param(refused(409, "execution_ended"), ConflictError, id="ended"), + pytest.param( + refused(409, "too_many_pending_approvals"), ConflictError, id="too many" + ), pytest.param(refused(409), ConflictError, id="conflict"), pytest.param(refused(413), VolcanoError, id="too large"), ], @@ -217,15 +339,6 @@ def test_a_refusal_is_raised_without_retrying( assert fake.sleeps == [] -def test_a_rate_limit_that_persists_raises_rate_limited( - platform: InstallPlatform, -) -> None: - _ = platform(refused(429)) - - with pytest.raises(RateLimitedError): - register_approval(API_URL, BODY) - - def test_the_platform_api_url_comes_from_the_environment( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -315,18 +428,42 @@ def test_text_at_its_limit_is_accepted() -> None: "description must be at most 4000 characters", id="long description", ), + pytest.param( + ("n\ud800", "t", None, None), + TypeError, + "name must be JSON-serializable: it contains a surrogate", + id="surrogate name", + ), + pytest.param( + ("n", "\udfff", None, None), + TypeError, + "title must be JSON-serializable: it contains a surrogate", + id="surrogate title", + ), + pytest.param( + ("n", "t", "\ud83d", None), + TypeError, + "description must be JSON-serializable: it contains a surrogate", + id="surrogate description", + ), pytest.param( ("n", "t", None, {"when": object()}), TypeError, - "details must be a JSON value", + INVALID_DETAILS, id="details", ), pytest.param( ("n", "t", None, float("nan")), TypeError, - "details must be a JSON value", + INVALID_DETAILS, id="nan details", ), + pytest.param( + ("n", "t", None, {"note": "\ud800"}), + TypeError, + INVALID_DETAILS, + id="surrogate details", + ), ], ) def test_an_invalid_body_is_refused( @@ -340,6 +477,87 @@ def test_an_invalid_body_is_refused( assert str(caught.value) == message +def test_whitespace_is_kept_and_only_blank_text_is_refused() -> None: + assert approval_body(" ship ", "\tShip?\n", " ", None) == { + "name": " ship ", + "title": "\tShip?\n", + "description": " ", + } + with pytest.raises(ValueError, match="name must not be empty"): + _ = approval_body("\u00a0\u2003", "t", None, None) + + +LONGEST_CALLBACK_ID = "c" * 1024 + + +def sized_body(size: int) -> dict[str, JSONValue]: + """Pad a body to encode, with the longest callback id, to `size` bytes. + + Returns: + The body, without the callback id. + """ + body: dict[str, JSONValue] = { + "name": "ship-order", + "title": "Ship?", + "execution_ref": "arn:" + "e" * 2044, + "details": "", + } + encoded = json.dumps( + {**body, "callback_id": LONGEST_CALLBACK_ID}, + ensure_ascii=False, + separators=(",", ":"), + ).encode() + # Three bytes each in UTF-8, so the body is measured as sent, not escaped. + euros, rest = divmod(size - len(encoded), 3) + body["details"] = "\u20ac" * euros + "d" * rest + return body + + +def test_an_approval_at_the_size_limit_is_sent_as_measured( + platform: InstallPlatform, +) -> None: + fake = platform(registered()) + body = sized_body(65536) + + check_size(body) + register_approval(API_URL, {**body, "callback_id": LONGEST_CALLBACK_ID}) + + assert len(fake.requests[0].content) == 65536 + assert json.loads(fake.requests[0].content)["details"] == body["details"] + + +def test_a_body_that_is_not_json_is_never_sent(platform: InstallPlatform) -> None: + fake = platform(registered()) + + with pytest.raises(ValueError, match="not JSON compliant"): + register_approval(API_URL, {**BODY, "details": float("nan")}) + + assert fake.requests == [] + + +def test_an_approval_over_the_size_limit_is_refused() -> None: + with pytest.raises(ValueError, match="64 KiB") as caught: + check_size(sized_body(65537)) + + assert str(caught.value) == ( + "the approval is larger than the 64 KiB Volcano accepts once encoded as " + "JSON; send less in details" + ) + + +def test_the_size_limit_counts_encoded_bytes() -> None: + body: dict[str, JSONValue] = { + "name": "ship-order", + "title": "Ship?", + "execution_ref": "arn:execution", + "details": "\u20ac" * 21_500, + } + + with pytest.raises(ValueError, match="64 KiB"): + check_size(body) + check_size({**body, "details": "d" * 21_500}) + + DECIDED_BY = {"id": "user-7", "email": "ops@example.com"} diff --git a/src/volcano_sdk/_tests/test_durable_authoring.py b/src/volcano_sdk/_tests/test_durable_authoring.py index e899137a..612d9f24 100644 --- a/src/volcano_sdk/_tests/test_durable_authoring.py +++ b/src/volcano_sdk/_tests/test_durable_authoring.py @@ -16,6 +16,7 @@ from types import ModuleType, SimpleNamespace from typing import TYPE_CHECKING, TypeGuard, TypeVar, cast +import httpx import pytest from aws_durable_execution_sdk_python.config import ( Duration, @@ -30,6 +31,7 @@ ) from aws_durable_execution_sdk_python.exceptions import ( CallbackError, + CallbackSubmitterError, CallbackTimeoutError, WaitForConditionError, ) @@ -1622,6 +1624,73 @@ def handler(_event: object, ctx: DurableContext) -> object: assert attempts == ["ship-order"] +def test_a_refusal_reaches_the_handler_as_the_runtimes_submitter_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def refuse(_api_url: str, _body: Mapping[str, JSONValue]) -> None: + message = "title is too long" + raise ValidationError(message, status=400) + + monkeypatch.setattr("volcano_sdk.durable_authoring.register_approval", refuse) + monkeypatch.setenv("VOLCANO_PLATFORM_API_URL", PLATFORM_API_URL) + + @durable + def handler(_event: object, ctx: DurableContext) -> object: + try: + _ = ctx.wait_for_approval("ship-order", title="Ship?") + except CallbackSubmitterError as error: + return str(error) + return None + + assert run_handler(handler) == "title is too long" + + +def test_an_approval_closed_before_it_registered_resumes_expired( + monkeypatch: pytest.MonkeyPatch, +) -> None: + requests: list[httpx.Request] = [] + + def closed(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 409, + json={ + "error": "approval request is no longer open", + "code": "approval_closed", + }, + ) + + real_client = httpx.Client + + def client() -> httpx.Client: + return real_client(transport=httpx.MockTransport(closed)) + + monkeypatch.setattr( + "volcano_sdk._durable_approval_registration.httpx.Client", client + ) + monkeypatch.setenv("VOLCANO_PLATFORM_API_URL", PLATFORM_API_URL) + + @durable + def handler(_event: object, ctx: DurableContext) -> object: + decision = ctx.wait_for_approval("ship-order", title="Ship?", timeout="1s") + return decision_summary(decision) + + with local_runner(handler) as runner: + result = runner.run(input=json.dumps({}), timeout=15) + + assert result.error is None + assert result.result is not None + assert json.loads(result.result) == { + "approved": False, + "status": "expired", + "comment": "", + "decided_by": None, + "decided_at": None, + } + assert len(requests) == 1 + assert str(requests[0].url) == f"{PLATFORM_API_URL}/durable-approvals" + + def test_a_failed_callback_is_not_mistaken_for_an_expiry( registrations: Registrations, ) -> None: @@ -1735,10 +1804,10 @@ def test_only_a_callback_timeout_becomes_an_expired_decision() -> None: @pytest.mark.parametrize( ("timeout", "error", "message"), [ - pytest.param("0s", ValueError, "timeout must be at least 1 second", id="0"), + pytest.param("0s", TypeError, "timeout must be at least 1 second", id="0"), pytest.param( {"days": 367}, - ValueError, + TypeError, r"timeout must be at most 31622400 seconds \(366 days\)", id="367d", ), @@ -1762,15 +1831,80 @@ def test_an_approval_timeout_accepts_its_bounds() -> None: assert context.approval_timeout({"days": 366}) == 31_622_400 +@pytest.mark.parametrize("duration", ["0s", 0, {"days": 367}, 31_622_401]) +def test_an_approval_timeout_is_bounded_like_a_wait(duration: object) -> None: + context = InspectedDurableContext(RecordingContext(), Engine(), "arn:execution") + + with pytest.raises(TypeError) as waited: + _ = context.wait_duration(duration) + with pytest.raises(TypeError) as timed: + _ = context.approval_timeout(duration) + + assert str(timed.value) == str(waited.value).replace("wait", "timeout", 1) + + +@pytest.mark.usefixtures("registrations") +@pytest.mark.parametrize( + ("title", "details", "timeout", "error", "message"), + [ + pytest.param( + "Ship?", + None, + 0, + TypeError, + "timeout must be at least 1 second", + id="timeout", + ), + pytest.param( + "Ship?", + {"total": float("nan")}, + None, + TypeError, + "details must be JSON-serializable", + id="details", + ), + pytest.param( + "Ship\ud800?", + None, + None, + TypeError, + "title must be JSON-serializable", + id="surrogate", + ), + pytest.param( + "Ship?", + {"lines": ["x" * 1000] * 65}, + None, + ValueError, + "larger than the 64 KiB Volcano accepts", + id="too large with the callback id", + ), + ], +) +def test_an_invalid_approval_is_refused_before_opening_a_callback( + title: str, + details: JSONValue, + timeout: int | None, + error: type[Exception], + message: str, +) -> None: + runtime = RecordingContext() + context = DurableContext(runtime, Engine(), "arn:execution") + + with pytest.raises(error, match=message): + _ = context.wait_for_approval( + "ship-order", title=title, details=details, timeout=timeout + ) + assert runtime.submitter is None + + @pytest.mark.usefixtures("registrations") -def test_an_invalid_approval_is_refused_before_opening_a_callback() -> None: +def test_a_non_string_title_is_refused_before_opening_a_callback() -> None: runtime = RecordingContext() context = DurableContext(runtime, Engine(), "arn:execution") with pytest.raises(TypeError, match="title must be a string"): non_string_approval_title(context) - with pytest.raises(ValueError, match="timeout must be at least 1 second"): - _ = context.wait_for_approval("ship-order", title="Ship?", timeout=0) assert runtime.submitter is None diff --git a/src/volcano_sdk/durable_authoring.py b/src/volcano_sdk/durable_authoring.py index e3f78a02..d2c7af8c 100644 --- a/src/volcano_sdk/durable_authoring.py +++ b/src/volcano_sdk/durable_authoring.py @@ -44,6 +44,7 @@ def handler(event, ctx): EXPIRED, approval_body, approval_decision, + check_size, platform_api_url, register_approval, ) @@ -394,6 +395,11 @@ def wait_for_approval( rather than raising, and a denial is a decision too: branch on `approved`. + The arguments, and the 64 KiB Volcano accepts for the whole approval, + are checked before the callback opens. Registering it is retried for + up to 30 seconds; a refusal raises the runtime's + `CallbackSubmitterError` with the SDK error's message. + Returns: The decision, restored from its checkpoint during replay. @@ -409,6 +415,7 @@ def wait_for_approval( if self._execution_ref is None: raise RuntimeError(_MISSING_EXECUTION_REF) body["execution_ref"] = self._execution_ref + check_size(body) # Runs inside the runtime's checkpointed submitter step, so a replay # does not register the approval again. @@ -425,24 +432,10 @@ def submit(callback_id: str, _context: object) -> None: @staticmethod def _approval_timeout(value: object) -> int: - seconds = to_seconds(value, "timeout") - if seconds < _MIN_WAIT_SECONDS: - message = f"timeout must be at least {_MIN_WAIT_SECONDS} second" - raise ValueError(message) - if seconds > _MAX_WAIT_SECONDS: - message = f"timeout must be at most {_MAX_WAIT_SECONDS} seconds (366 days)" - raise ValueError(message) - return seconds + return _bounded_seconds(value, "timeout") def _wait_duration(self, value: object) -> object: - seconds = to_seconds(value, "wait") - if seconds < _MIN_WAIT_SECONDS: - message = f"wait must be at least {_MIN_WAIT_SECONDS} second" - raise TypeError(message) - if seconds > _MAX_WAIT_SECONDS: - message = f"wait must be at most {_MAX_WAIT_SECONDS} seconds (366 days)" - raise TypeError(message) - return self._engine.seconds(seconds) + return self._engine.seconds(_bounded_seconds(value, "wait")) @overload @@ -531,6 +524,17 @@ def _is_event_mapping(value: object) -> TypeGuard[Mapping[object, object]]: return isinstance(value, Mapping) +def _bounded_seconds(value: object, field_name: str) -> int: + seconds = to_seconds(value, field_name) + if seconds < _MIN_WAIT_SECONDS: + message = f"{field_name} must be at least {_MIN_WAIT_SECONDS} second" + raise TypeError(message) + if seconds > _MAX_WAIT_SECONDS: + message = f"{field_name} must be at most {_MAX_WAIT_SECONDS} seconds (366 days)" + raise TypeError(message) + return seconds + + def _validate_wait_options(options: WaitUntilOptions[T]) -> None: if not callable(options.until): raise TypeError(_REQUIRES_UNTIL) From 4c983ddecc6afdb3d72f640e15d229b6f6c2d055 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Tue, 6 Oct 2026 23:21:38 -0700 Subject: [PATCH 04/16] fix(durable): drop the approval retry delay the deadline never reaches Co-authored-by: Cursor --- .../_durable_approval_registration.py | 7 +++---- .../test_durable_approval_registration.py | 17 +++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/volcano_sdk/_durable_approval_registration.py b/src/volcano_sdk/_durable_approval_registration.py index e59097d7..f66c3a1d 100644 --- a/src/volcano_sdk/_durable_approval_registration.py +++ b/src/volcano_sdk/_durable_approval_registration.py @@ -32,10 +32,9 @@ if TYPE_CHECKING: from .models import JSONValue -# 0.5 s doubling to a 5 s cap, ending where the deadline runs out: long enough -# to outlast the moment the platform has not yet seen the execution start or -# the callback open. -_RETRY_DELAYS = (0.5, 1.0, 2.0, 4.0, 5.0, 5.0, 5.0, 5.0, 2.5) +# 0.5 s doubling to a 5 s cap, within the deadline: long enough to outlast the +# moment the platform has not yet seen the execution start or the callback open. +_RETRY_DELAYS = (0.5, 1.0, 2.0, 4.0, 5.0, 5.0, 5.0, 5.0) _DEADLINE_SECONDS = 30.0 _REQUEST_TIMEOUT_SECONDS = 10.0 _NOT_READY = "approval_not_ready" diff --git a/src/volcano_sdk/_tests/test_durable_approval_registration.py b/src/volcano_sdk/_tests/test_durable_approval_registration.py index be6ffb24..88c8fae9 100644 --- a/src/volcano_sdk/_tests/test_durable_approval_registration.py +++ b/src/volcano_sdk/_tests/test_durable_approval_registration.py @@ -38,7 +38,7 @@ "name": "ship-order", "title": "Ship order 1234?", } -RETRY_DELAYS = [0.5, 1.0, 2.0, 4.0, 5.0, 5.0, 5.0, 5.0, 2.5] +RETRY_DELAYS = [0.5, 1.0, 2.0, 4.0, 5.0, 5.0, 5.0, 5.0] # The fixture replaces httpx.Client for the module under test, which is the # same attribute everywhere. REAL_CLIENT = httpx.Client @@ -220,20 +220,21 @@ def test_retries_back_off_until_the_deadline_and_then_raise( register_approval(API_URL, BODY) assert caught.value.status == 503 - # The last delay would end exactly at the deadline, with no time to retry. - assert fake.sleeps == RETRY_DELAYS[:-1] - assert len(fake.requests) == len(RETRY_DELAYS) + assert fake.sleeps == RETRY_DELAYS + assert len(fake.requests) == len(RETRY_DELAYS) + 1 + # The last attempt starts 27.5 s in and gets only what is left. + assert fake.timeouts[-1] == pytest.approx(2.5) def test_the_last_attempt_before_the_deadline_can_still_succeed( platform: InstallPlatform, ) -> None: - fake = platform(*([refused(429)] * (len(RETRY_DELAYS) - 1)), registered()) + fake = platform(*([refused(429)] * len(RETRY_DELAYS)), registered()) register_approval(API_URL, BODY) - assert fake.sleeps == RETRY_DELAYS[:-1] - assert len(fake.requests) == len(RETRY_DELAYS) + assert fake.sleeps == RETRY_DELAYS + assert len(fake.requests) == len(RETRY_DELAYS) + 1 def test_the_schedule_bounds_the_attempts_while_time_is_left( @@ -308,7 +309,7 @@ def test_a_failure_that_persists_raises_its_own_error( register_approval(API_URL, BODY) assert type(caught.value) is expected - assert len(fake.requests) == len(RETRY_DELAYS) + assert len(fake.requests) == len(RETRY_DELAYS) + 1 @pytest.mark.parametrize( From ecb6c7ea29135141848ef4a5658c5071a89f313c Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Wed, 7 Oct 2026 10:45:41 -0700 Subject: [PATCH 05/16] chore(api): sync the durable approval description from hosting Co-authored-by: Cursor --- openapi/openapi.yaml | 6 +++++ .../durable_functions/get_durable_approval.py | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index aae1de29..6305cd2a 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -4586,6 +4586,12 @@ paths: tags: - Durable Functions summary: Get a durable approval + description: | + Returns one approval: what the workflow asked, the durable function + and execution that asked it, its deadline, and the decision once one + is made. + + Project access tokens can read approvals, including read-only ones. operationId: getDurableApproval security: - UserToken: [] diff --git a/src/volcano_sdk/_generated/api/durable_functions/get_durable_approval.py b/src/volcano_sdk/_generated/api/durable_functions/get_durable_approval.py index 76b9f4bf..b0637514 100644 --- a/src/volcano_sdk/_generated/api/durable_functions/get_durable_approval.py +++ b/src/volcano_sdk/_generated/api/durable_functions/get_durable_approval.py @@ -75,6 +75,12 @@ def sync_detailed( ) -> Response[DurableApproval | Error]: """ Get a durable approval + Returns one approval: what the workflow asked, the durable function + and execution that asked it, its deadline, and the decision once one + is made. + + Project access tokens can read approvals, including read-only ones. + Args: id (UUID): approval_id (UUID): @@ -109,6 +115,12 @@ def sync( ) -> DurableApproval | Error | None: """ Get a durable approval + Returns one approval: what the workflow asked, the durable function + and execution that asked it, its deadline, and the decision once one + is made. + + Project access tokens can read approvals, including read-only ones. + Args: id (UUID): approval_id (UUID): @@ -138,6 +150,12 @@ async def asyncio_detailed( ) -> Response[DurableApproval | Error]: """ Get a durable approval + Returns one approval: what the workflow asked, the durable function + and execution that asked it, its deadline, and the decision once one + is made. + + Project access tokens can read approvals, including read-only ones. + Args: id (UUID): approval_id (UUID): @@ -172,6 +190,12 @@ async def asyncio( ) -> DurableApproval | Error | None: """ Get a durable approval + Returns one approval: what the workflow asked, the durable function + and execution that asked it, its deadline, and the decision once one + is made. + + Project access tokens can read approvals, including read-only ones. + Args: id (UUID): approval_id (UUID): From ea761ba5bccdcaa91ccf93a44b2ef14cd737649a Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Wed, 7 Oct 2026 10:50:28 -0700 Subject: [PATCH 06/16] fix(durable): decide an approval by its status and read its details leniently Co-authored-by: Cursor --- .../_durable_approval_registration.py | 34 +++-- src/volcano_sdk/_durable_results.py | 8 +- .../test_durable_approval_registration.py | 134 ++++++++++++++++-- .../_tests/test_durable_authoring.py | 30 ++++ 4 files changed, 177 insertions(+), 29 deletions(-) diff --git a/src/volcano_sdk/_durable_approval_registration.py b/src/volcano_sdk/_durable_approval_registration.py index f66c3a1d..bbfe4bc9 100644 --- a/src/volcano_sdk/_durable_approval_registration.py +++ b/src/volcano_sdk/_durable_approval_registration.py @@ -5,6 +5,7 @@ import json import os from collections.abc import Mapping +from datetime import datetime from time import monotonic, sleep from typing import TYPE_CHECKING, TypeGuard @@ -213,6 +214,10 @@ def _next_timeout(error: VolcanoError, delay: float, deadline: float) -> float | def approval_decision(result: object) -> ApprovalDecision: """Read the decision Volcano completed the callback with. + Only the status has to be readable. The decision is already recorded, so + refusing it over a detail would fail the execution again on every replay; + an unreadable comment, decider, or decision time reads as absent instead. + Returns: The approved or denied decision. @@ -222,19 +227,16 @@ def approval_decision(result: object) -> ApprovalDecision: """ values = _decision_fields(result) status = values.get("status") - approved = status == "approved" - if status not in {"approved", "denied"} or values.get("approved") is not approved: - raise TypeError(_INVALID_DECISION) - comment = values.get("comment", "") - decided_at = values.get("decided_at") - if not isinstance(comment, str) or not isinstance(decided_at, str): + if status not in {"approved", "denied"}: raise TypeError(_INVALID_DECISION) + approved = status == "approved" + comment = values.get("comment") return ApprovalDecision( approved=approved, status="approved" if approved else "denied", - comment=comment, + comment=comment if isinstance(comment, str) else "", decided_by=_decider(values.get("decided_by")), - decided_at=decided_at, + decided_at=_decided_at(values.get("decided_at")), ) @@ -259,12 +261,20 @@ def _is_mapping(value: object) -> TypeGuard[Mapping[object, object]]: def _decider(value: object) -> DurableApprovalDecider | None: - if value is None: + if not _is_mapping(value): return None - if not _is_fields(value): - raise TypeError(_INVALID_DECISION) decider_id = value.get("id") email = value.get("email") if not isinstance(decider_id, str) or not isinstance(email, str): - raise TypeError(_INVALID_DECISION) + return None return DurableApprovalDecider(id=decider_id, email=email) + + +def _decided_at(value: object) -> str | None: + if not isinstance(value, str): + return None + try: + _ = datetime.fromisoformat(value) + except ValueError: + return None + return value diff --git a/src/volcano_sdk/_durable_results.py b/src/volcano_sdk/_durable_results.py index dc628f05..74037b04 100644 --- a/src/volcano_sdk/_durable_results.py +++ b/src/volcano_sdk/_durable_results.py @@ -26,10 +26,12 @@ class ApprovalDecision: approved: bool status: Literal["approved", "denied", "expired"] comment: str = "" - # None when the approval expired, or when the deciding account was deleted - # before the decision reached the workflow. + # None when the approval expired, when the deciding account was deleted + # before the decision reached the workflow, or when the decision does not + # name a decider this SDK can read. decided_by: DurableApprovalDecider | None = None - # RFC 3339, as Volcano recorded it; None when the approval expired. + # RFC 3339, as Volcano recorded it; None when the approval expired or the + # decision carries no readable time. decided_at: str | None = None diff --git a/src/volcano_sdk/_tests/test_durable_approval_registration.py b/src/volcano_sdk/_tests/test_durable_approval_registration.py index 88c8fae9..21e28534 100644 --- a/src/volcano_sdk/_tests/test_durable_approval_registration.py +++ b/src/volcano_sdk/_tests/test_durable_approval_registration.py @@ -639,6 +639,12 @@ def complete_decision(**overrides: object) -> dict[str, object]: return decision +def without(field: str) -> dict[str, object]: + decision = complete_decision() + del decision[field] + return decision + + @pytest.mark.parametrize( "result", [ @@ -647,21 +653,121 @@ def complete_decision(**overrides: object) -> dict[str, object]: pytest.param(b"\xff", id="not utf-8"), pytest.param("[]", id="list"), pytest.param({1: "approved"}, id="key"), - pytest.param(complete_decision(status="expired"), id="status"), - pytest.param(complete_decision(status=None), id="no status"), - pytest.param(complete_decision(approved=False), id="disagrees"), - pytest.param(complete_decision(status="denied"), id="denied but approved"), - pytest.param(complete_decision(approved=1), id="approved type"), - pytest.param(complete_decision(comment=None), id="comment"), - pytest.param(complete_decision(decided_at=None), id="decided at"), - pytest.param(complete_decision(decided_by="ops"), id="decider"), - pytest.param(complete_decision(decided_by={1: "ops"}), id="decider key"), - pytest.param(complete_decision(decided_by={"id": "u"}), id="decider email"), - pytest.param( - complete_decision(decided_by={"email": "ops@example.com"}), id="decider id" - ), + pytest.param(complete_decision(status="expired"), id="expired"), + pytest.param(complete_decision(status="Approved"), id="status case"), + pytest.param(complete_decision(status=None), id="null status"), + pytest.param(complete_decision(status=True), id="status type"), + pytest.param(without("status"), id="no status"), ], ) -def test_a_malformed_decision_is_refused(result: object) -> None: +def test_a_decision_without_a_readable_status_is_refused(result: object) -> None: with pytest.raises(TypeError, match="Expected a complete approval decision"): _ = approval_decision(result) + + +@pytest.mark.parametrize( + ("result", "expected"), + [ + pytest.param( + complete_decision(approved=False), + ApprovalDecision( + approved=True, + status="approved", + decided_by=DurableApprovalDecider(id="user-7", email="ops@example.com"), + decided_at="2026-10-06T12:05:00Z", + ), + id="approved disagrees with status", + ), + pytest.param( + complete_decision(status="denied", approved="yes"), + ApprovalDecision( + approved=False, + status="denied", + decided_by=DurableApprovalDecider(id="user-7", email="ops@example.com"), + decided_at="2026-10-06T12:05:00Z", + ), + id="denied whatever approved says", + ), + pytest.param( + {"status": "approved"}, + ApprovalDecision(approved=True, status="approved"), + id="status alone", + ), + ], +) +def test_the_status_alone_decides(result: object, expected: ApprovalDecision) -> None: + assert approval_decision(result) == expected + + +@pytest.mark.parametrize( + ("result", "expected"), + [ + pytest.param(without("comment"), {"comment": ""}, id="no comment"), + pytest.param(complete_decision(comment=None), {"comment": ""}, id="null"), + pytest.param(complete_decision(comment=7), {"comment": ""}, id="comment type"), + pytest.param(without("decided_at"), {"decided_at": None}, id="no time"), + pytest.param( + complete_decision(decided_at=None), {"decided_at": None}, id="null time" + ), + pytest.param( + complete_decision(decided_at=""), {"decided_at": None}, id="empty time" + ), + pytest.param( + complete_decision(decided_at="yesterday"), + {"decided_at": None}, + id="not a time", + ), + pytest.param( + complete_decision(decided_at=1_791_288_300), + {"decided_at": None}, + id="epoch seconds", + ), + pytest.param( + complete_decision(decided_at="2026-10-06T12:05:00.123456+02:00"), + {"decided_at": "2026-10-06T12:05:00.123456+02:00"}, + id="offset time kept as sent", + ), + pytest.param(without("decided_by"), {"decided_by": None}, id="no decider"), + pytest.param( + complete_decision(decided_by="ops"), {"decided_by": None}, id="decider type" + ), + pytest.param( + complete_decision(decided_by=["user-7", "ops@example.com"]), + {"decided_by": None}, + id="decider list", + ), + pytest.param( + complete_decision(decided_by={"id": "user-7"}), + {"decided_by": None}, + id="decider without email", + ), + pytest.param( + complete_decision(decided_by={"email": "ops@example.com"}), + {"decided_by": None}, + id="decider without id", + ), + pytest.param( + complete_decision(decided_by={"id": 7, "email": "ops@example.com"}), + {"decided_by": None}, + id="decider id type", + ), + ], +) +def test_an_unreadable_detail_reads_as_absent( + result: dict[str, object], expected: dict[str, object] +) -> None: + decision = approval_decision(json.dumps(result)) + + assert decision.approved is True + assert decision.status == "approved" + read = { + "comment": decision.comment, + "decided_by": decision.decided_by, + "decided_at": decision.decided_at, + } + assert read == { + "comment": "", + "decided_by": DurableApprovalDecider(id="user-7", email="ops@example.com"), + "decided_at": "2026-10-06T12:05:00Z", + **expected, + } diff --git a/src/volcano_sdk/_tests/test_durable_authoring.py b/src/volcano_sdk/_tests/test_durable_authoring.py index 612d9f24..674ee1fe 100644 --- a/src/volcano_sdk/_tests/test_durable_authoring.py +++ b/src/volcano_sdk/_tests/test_durable_authoring.py @@ -1495,6 +1495,36 @@ def handler(_event: object, ctx: DurableContext) -> object: assert set(body) == {"execution_ref", "callback_id", "name", "title"} +def test_a_decision_with_unreadable_details_still_resumes_the_execution( + registrations: Registrations, +) -> None: + @durable + def handler(_event: object, ctx: DurableContext) -> object: + decision = ctx.wait_for_approval("ship-order", title="Ship?") + ctx.wait("after", "1s") + return decision_summary(decision) + + decision = json.dumps( + { + "status": "denied", + "approved": True, + "comment": None, + "decided_by": {"id": "user-7"}, + "decided_at": "soon", + } + ).encode() + + _, result = decide_each(handler, registrations, 1, decision) + + assert result == { + "approved": False, + "status": "denied", + "comment": "", + "decided_by": None, + "decided_at": None, + } + + def test_an_approval_nobody_decides_expires_instead_of_raising( registrations: Registrations, ) -> None: From 52dac056f3a9f8c4fc89e2d09ecf4d2a58a26bf7 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Wed, 7 Oct 2026 10:55:26 -0700 Subject: [PATCH 07/16] perf(durable): build the runtime wrapper once and read the execution from its context Co-authored-by: Cursor --- docs/functions.md | 2 +- src/volcano_sdk/_durable_protocols.py | 8 ++ .../_tests/fixtures/durable_context.py | 11 +- .../_tests/test_durable_authoring.py | 105 +++++++----------- src/volcano_sdk/durable_authoring.py | 74 ++++-------- 5 files changed, 80 insertions(+), 120 deletions(-) diff --git a/docs/functions.md b/docs/functions.md index b249b8e4..07a3c090 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -180,7 +180,7 @@ The call returns an immutable `ApprovalDecision`: A denial or a timeout is a value to branch on, not an exception. The execution costs nothing while it waits, and a resumed execution replays the recorded decision without asking again. -Volcano sets `VOLCANO_PLATFORM_API_URL` on deployed and local durable functions. The function sends the approval there without a credential. Volcano accepts it only from the execution that is waiting. When Volcano does not answer, has not seen the execution or its approval yet, throttles the request, or fails, the function retries for up to 30 seconds. An approval whose timeout passes before Volcano records it returns the `expired` decision. If Volcano refuses the approval, or keeps failing for 30 seconds, `wait_for_approval()` raises the durable runtime's `CallbackSubmitterError`, carrying the SDK error's message. Unless the handler catches it, the execution fails. Calling `wait_for_approval()` without `VOLCANO_PLATFORM_API_URL`, or from a handler that is not decorated with `@durable`, raises `RuntimeError`. +Volcano sets `VOLCANO_PLATFORM_API_URL` on deployed and local durable functions. The function sends the approval there without a credential. Volcano accepts it only from the execution that is waiting. When Volcano does not answer, has not seen the execution or its approval yet, throttles the request, or fails, the function retries for up to 30 seconds. An approval whose timeout passes before Volcano records it returns the `expired` decision. If Volcano refuses the approval, or keeps failing for 30 seconds, `wait_for_approval()` raises the durable runtime's `CallbackSubmitterError`, carrying the SDK error's message. Unless the handler catches it, the execution fails. Calling `wait_for_approval()` without `VOLCANO_PLATFORM_API_URL` raises `RuntimeError`. ## Decide approvals from a backend diff --git a/src/volcano_sdk/_durable_protocols.py b/src/volcano_sdk/_durable_protocols.py index fcf5b78d..74dbabf5 100644 --- a/src/volcano_sdk/_durable_protocols.py +++ b/src/volcano_sdk/_durable_protocols.py @@ -99,8 +99,16 @@ def get_errors(self) -> list[object]: ... def throw_if_error(self) -> None: ... +class RuntimeExecution(Protocol): + @property + def durable_execution_arn(self) -> str: ... + + class RuntimeContext(Protocol): logger: DurableLogger + # The same for the root context and every child, map, and parallel context + # the runtime derives from it. + execution_context: RuntimeExecution def step( self, diff --git a/src/volcano_sdk/_tests/fixtures/durable_context.py b/src/volcano_sdk/_tests/fixtures/durable_context.py index af4d9dd7..3826b91d 100644 --- a/src/volcano_sdk/_tests/fixtures/durable_context.py +++ b/src/volcano_sdk/_tests/fixtures/durable_context.py @@ -17,6 +17,7 @@ DurableLogger, OperationScope, RuntimeBatchItem, + RuntimeExecution, ) T = TypeVar("T") @@ -98,12 +99,20 @@ def get_errors(self) -> list[object]: return [self._error] +@dataclass(frozen=True) +class RecordedExecution: + """The execution metadata the runtime attaches to every context.""" + + durable_execution_arn: str + + class RecordingContext(RuntimeContext): """Implement the runtime context and capture its batch calls.""" logger: DurableLogger = logging.getLogger(__name__) - def __init__(self) -> None: + def __init__(self, execution_arn: str = "arn:execution") -> None: + self.execution_context: RuntimeExecution = RecordedExecution(execution_arn) self.branches: list[object] | None = None self.name: str | None = None self.config: object = None diff --git a/src/volcano_sdk/_tests/test_durable_authoring.py b/src/volcano_sdk/_tests/test_durable_authoring.py index 674ee1fe..06be43f0 100644 --- a/src/volcano_sdk/_tests/test_durable_authoring.py +++ b/src/volcano_sdk/_tests/test_durable_authoring.py @@ -14,7 +14,7 @@ from collections.abc import Mapping from contextlib import contextmanager from types import ModuleType, SimpleNamespace -from typing import TYPE_CHECKING, TypeGuard, TypeVar, cast +from typing import TYPE_CHECKING, TypeGuard, TypeVar import httpx import pytest @@ -1745,26 +1745,11 @@ def test_a_missing_platform_api_url_fails_before_opening_a_callback( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.delenv("VOLCANO_PLATFORM_API_URL") - runtime = RecordingContext() - context = DurableContext(runtime, Engine(), "arn:execution") - - with pytest.raises(RuntimeError, match="needs VOLCANO_PLATFORM_API_URL"): - _ = context.wait_for_approval("ship-order", title="Ship?") - assert runtime.submitter is None - - -@pytest.mark.usefixtures("registrations") -def test_an_approval_needs_the_execution_reference() -> None: runtime = RecordingContext() context = DurableContext(runtime, Engine()) - with pytest.raises(RuntimeError) as caught: + with pytest.raises(RuntimeError, match="needs VOLCANO_PLATFORM_API_URL"): _ = context.wait_for_approval("ship-order", title="Ship?") - - assert str(caught.value) == ( - "wait_for_approval() needs the invocation's DurableExecutionArn, which " - "@durable reads from the event; call it from a handler wrapped with @durable" - ) assert runtime.submitter is None @@ -1773,7 +1758,7 @@ def test_wait_for_approval_forwards_its_name_timeout_and_submitter( ) -> None: runtime = RecordingContext() runtime.callback_result = decided().decode() - context = DurableContext(runtime, Engine(), "arn:execution") + context = DurableContext(runtime, Engine()) decision = context.wait_for_approval("ship-order", title="Ship?", timeout="2h") @@ -1807,7 +1792,7 @@ def test_wait_for_approval_forwards_its_name_timeout_and_submitter( def test_an_approval_without_a_timeout_lasts_as_long_as_the_execution() -> None: runtime = RecordingContext() runtime.callback_result = decided(approved=False).decode() - context = DurableContext(runtime, Engine(), "arn:execution") + context = DurableContext(runtime, Engine()) decision = context.wait_for_approval("ship-order", title="Ship?") @@ -1821,7 +1806,7 @@ def test_an_approval_without_a_timeout_lasts_as_long_as_the_execution() -> None: def test_only_a_callback_timeout_becomes_an_expired_decision() -> None: runtime = RecordingContext() runtime.callback_error = CallbackTimeoutError("timed out") - context = DurableContext(runtime, Engine(), "arn:execution") + context = DurableContext(runtime, Engine()) expired = context.wait_for_approval("ship-order", title="Ship?", timeout=60) @@ -1848,7 +1833,7 @@ def test_only_a_callback_timeout_becomes_an_expired_decision() -> None: def test_an_unusable_approval_timeout_is_refused( timeout: object, error: type[Exception], message: str ) -> None: - context = InspectedDurableContext(RecordingContext(), Engine(), "arn:execution") + context = InspectedDurableContext(RecordingContext(), Engine()) with pytest.raises(error, match=message): _ = context.approval_timeout(timeout) @@ -1863,7 +1848,7 @@ def test_an_approval_timeout_accepts_its_bounds() -> None: @pytest.mark.parametrize("duration", ["0s", 0, {"days": 367}, 31_622_401]) def test_an_approval_timeout_is_bounded_like_a_wait(duration: object) -> None: - context = InspectedDurableContext(RecordingContext(), Engine(), "arn:execution") + context = InspectedDurableContext(RecordingContext(), Engine()) with pytest.raises(TypeError) as waited: _ = context.wait_duration(duration) @@ -1919,7 +1904,7 @@ def test_an_invalid_approval_is_refused_before_opening_a_callback( message: str, ) -> None: runtime = RecordingContext() - context = DurableContext(runtime, Engine(), "arn:execution") + context = DurableContext(runtime, Engine()) with pytest.raises(error, match=message): _ = context.wait_for_approval( @@ -1931,62 +1916,50 @@ def test_an_invalid_approval_is_refused_before_opening_a_callback( @pytest.mark.usefixtures("registrations") def test_a_non_string_title_is_refused_before_opening_a_callback() -> None: runtime = RecordingContext() - context = DurableContext(runtime, Engine(), "arn:execution") + context = DurableContext(runtime, Engine()) with pytest.raises(TypeError, match="title must be a string"): non_string_approval_title(context) assert runtime.submitter is None -def direct_execution( - func: Callable[[T, RuntimeContext], object], / -) -> Callable[[object, object], object]: - """Run a handler straight against a recording context, as the runtime would. - - Returns: - An invocation handler. - """ +def test_one_runtime_wrapper_serves_every_invocation_with_its_own_execution( + monkeypatch: pytest.MonkeyPatch, registrations: Registrations +) -> None: + engine = Engine() + wrap = engine.durable_execution + built: list[object] = [] + invocations: list[object] = [] - def handle(_event: object, _function_context: object) -> object: - runtime = RecordingContext() - runtime.callback_result = decided().decode() - return func(cast("T", {}), runtime) + def build( + func: Callable[[T, RuntimeContext], object], / + ) -> Callable[[object, object], object]: + built.append(func) + run = wrap(func) - return handle + def invoke(event: object, function_context: object) -> object: + invocations.append(event) + return run(event, function_context) + return invoke -@pytest.mark.parametrize( - ("event", "expected"), - [ - pytest.param({"DurableExecutionArn": "arn:mapped"}, "arn:mapped", id="mapping"), - pytest.param( - SimpleNamespace(durable_execution_arn="arn:object"), - "arn:object", - id="object", - ), - pytest.param({"DurableExecutionArn": ""}, None, id="empty"), - pytest.param({"DurableExecutionArn": 7}, None, id="not a string"), - pytest.param({}, None, id="absent"), - pytest.param(SimpleNamespace(), None, id="no attribute"), - ], -) -def test_the_execution_reference_is_read_from_the_invocation_event( - monkeypatch: pytest.MonkeyPatch, - registrations: Registrations, - event: object, - expected: str | None, -) -> None: - engine = Engine() - engine.durable_execution = direct_execution + engine.durable_execution = build monkeypatch.setattr("volcano_sdk.durable_authoring.load_engine", lambda: engine) @durable def handler(_event: object, ctx: DurableContext) -> object: return ctx.wait_for_approval("ship-order", title="Ship?").status - if expected is None: - with pytest.raises(RuntimeError, match="DurableExecutionArn"): - _ = handler(event, None) - return - assert handler(event, None) == "approved" - assert registrations.bodies == [] + first, _ = decide_each(handler, registrations, 1) + later = Registrations() + monkeypatch.setattr( + "volcano_sdk.durable_authoring.register_approval", later.register + ) + second, _ = decide_each(handler, later, 1) + + # Each execution suspends on its approval and resumes once decided. + assert len(invocations) == 4 + assert len(built) == 1 + assert first != second + assert [body["execution_ref"] for body in registrations.bodies] == [first] + assert [body["execution_ref"] for body in later.bodies] == [second] diff --git a/src/volcano_sdk/durable_authoring.py b/src/volcano_sdk/durable_authoring.py index d2c7af8c..b2b2af05 100644 --- a/src/volcano_sdk/durable_authoring.py +++ b/src/volcano_sdk/durable_authoring.py @@ -26,14 +26,13 @@ def handler(event, ctx): from __future__ import annotations import functools -from collections.abc import Callable, Mapping +from collections.abc import Callable from dataclasses import dataclass from typing import ( TYPE_CHECKING, Generic, Never, TypeAlias, - TypeGuard, overload, ) @@ -121,10 +120,6 @@ def handler(event, ctx): _INVALID_BRANCH = "a parallel branch is a callable, or a ParallelBranch" _INVALID_ITEMS = "map() requires a sequence of items" _INVALID_WAIT_ARGS = "wait() takes a name and a duration, or a duration alone" -_MISSING_EXECUTION_REF = ( - "wait_for_approval() needs the invocation's DurableExecutionArn, which " - "@durable reads from the event; call it from a handler wrapped with @durable" -) @dataclass(frozen=True, slots=True) @@ -165,19 +160,12 @@ class DurableContext: completes, when a person decides. """ - __slots__: tuple[str, ...] = ("_context", "_engine", "_execution_ref", "log") + __slots__: tuple[str, ...] = ("_context", "_engine", "log") - def __init__( - self, - context: RuntimeContext, - engine: DurableEngine, - execution_ref: str | None = None, - ) -> None: + def __init__(self, context: RuntimeContext, engine: DurableEngine) -> None: """Wrap an engine context.""" self._context: RuntimeContext = context self._engine: DurableEngine = engine - # Identifies the execution to Volcano when it registers an approval. - self._execution_ref: str | None = execution_ref # Logs, suppressed while an operation is being replayed. self.log: DurableLogger = context.logger @@ -371,7 +359,7 @@ def run_bare(context: RuntimeContext) -> object: return run_bare def _derive(self, context: RuntimeContext) -> DurableContext: - return DurableContext(context, self._engine, self._execution_ref) + return DurableContext(context, self._engine) def wait_for_approval( self, @@ -396,25 +384,20 @@ def wait_for_approval( `approved`. The arguments, and the 64 KiB Volcano accepts for the whole approval, - are checked before the callback opens. Registering it is retried for - up to 30 seconds; a refusal raises the runtime's + are checked before the callback opens, as is `VOLCANO_PLATFORM_API_URL`: + without it the function is not running where Volcano can record the + approval, and the call raises `RuntimeError`. Registering it is retried + for up to 30 seconds; a refusal raises the runtime's `CallbackSubmitterError` with the SDK error's message. Returns: The decision, restored from its checkpoint during replay. - Raises: - RuntimeError: The function is not running where Volcano can record - the approval: `VOLCANO_PLATFORM_API_URL` is unset, or the - handler is not wrapped with `@durable`. - """ body = approval_body(name, title, description, details) seconds = None if timeout is None else self._approval_timeout(timeout) api_url = platform_api_url() - if self._execution_ref is None: - raise RuntimeError(_MISSING_EXECUTION_REF) - body["execution_ref"] = self._execution_ref + body["execution_ref"] = self._context.execution_context.durable_execution_arn check_size(body) # Runs inside the runtime's checkpointed submitter step, so a replay @@ -486,44 +469,31 @@ def decorate(func: DurableHandler[T, U]) -> FunctionHandler: def _wrap_durable( handler: Callable[[T, DurableContext], object], logger: object ) -> FunctionHandler: - # Wrapped on invocation, not here: resolving the engine is what fails when - # the runtime is absent, and a decorator that raises at import time would - # break a module that merely mentions a durable handler. Wrapped per - # invocation because the handler runs on the runtime's own thread, and the - # execution reference read from this invocation's event has to reach it. + # Wrapped on the first invocation, not here: resolving the engine is what + # fails when the runtime is absent, and a decorator that raises at import + # time would break a module that merely mentions a durable handler. + wrapped: list[FunctionHandler] = [] + # functools.wraps rather than copying two attributes: __qualname__, # __module__, __dict__ and __wrapped__ matter to inspect.unwrap and to a # traceback, and leaving them pointing at this closure makes the SDK's # wrapper the thing a user sees when their handler fails. @functools.wraps(handler) def invoke(event: object, function_context: object) -> object: - engine = load_engine() - execution_ref = _execution_ref(event) + if not wrapped: + engine = load_engine() - def run(input_value: T, context: RuntimeContext) -> object: - if logger is not None: - context.set_logger(logger) - return handler(input_value, DurableContext(context, engine, execution_ref)) + def run(input_value: T, context: RuntimeContext) -> object: + if logger is not None: + context.set_logger(logger) + return handler(input_value, DurableContext(context, engine)) - return engine.durable_execution(run)(event, function_context) + wrapped.append(engine.durable_execution(run)) + return wrapped[0](event, function_context) return invoke -def _execution_ref(event: object) -> str | None: - # A deployed function receives the invocation as a mapping; the local test - # runner hands over its parsed input object instead. - if _is_event_mapping(event): - value = event.get("DurableExecutionArn") - else: - value = getattr(event, "durable_execution_arn", None) - return value if isinstance(value, str) and value else None - - -def _is_event_mapping(value: object) -> TypeGuard[Mapping[object, object]]: - return isinstance(value, Mapping) - - def _bounded_seconds(value: object, field_name: str) -> int: seconds = to_seconds(value, field_name) if seconds < _MIN_WAIT_SECONDS: From 747a158abb432929cf79a8767a62821231125f94 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Wed, 7 Oct 2026 11:05:58 -0700 Subject: [PATCH 08/16] fix(durable): end each approval registration attempt by its own deadline Co-authored-by: Cursor --- .../_durable_approval_registration.py | 87 ++++++++--- .../test_durable_approval_registration.py | 145 ++++++++++++++++-- .../_tests/test_durable_authoring.py | 8 +- 3 files changed, 203 insertions(+), 37 deletions(-) diff --git a/src/volcano_sdk/_durable_approval_registration.py b/src/volcano_sdk/_durable_approval_registration.py index bbfe4bc9..36ef1d24 100644 --- a/src/volcano_sdk/_durable_approval_registration.py +++ b/src/volcano_sdk/_durable_approval_registration.py @@ -2,9 +2,11 @@ from __future__ import annotations +import asyncio import json import os from collections.abc import Mapping +from dataclasses import dataclass from datetime import datetime from time import monotonic, sleep from typing import TYPE_CHECKING, TypeGuard @@ -14,7 +16,7 @@ from ._durable_response import json_value from ._durable_results import ApprovalDecision from ._transport_response import ( - invoke, + invoke_async, plain_json, response_payload, unparsed_response, @@ -31,6 +33,7 @@ from .models import DurableApprovalDecider if TYPE_CHECKING: + from ._transport_types import TransportResponse from .models import JSONValue # 0.5 s doubling to a 5 s cap, within the deadline: long enough to outlast the @@ -42,6 +45,8 @@ _CLOSED = "approval_closed" _HEADERS = {"Content-Type": "application/json"} _MAX_BODY_BYTES = 65536 +# Volcano answers in a few hundred bytes; more is not an answer worth reading. +_MAX_ANSWER_BYTES = 65536 # The callback id is assigned once the callback opens, after the size check; # the spec allows it 1024 characters. _LONGEST_CALLBACK_ID = "x" * 1024 @@ -149,6 +154,9 @@ def register_approval(api_url: str, body: Mapping[str, JSONValue]) -> None: timeout passed first, needs no registration: the callback's own outcome is what the execution resumes with. + Each attempt, from connecting to the last byte of the answer, ends within + 10 seconds or what is left of the deadline, whichever is sooner. + Raises: VolcanoError: Volcano refused the approval, or kept failing until the deadline passed or the retries ran out. @@ -158,24 +166,32 @@ def register_approval(api_url: str, body: Mapping[str, JSONValue]) -> None: content = _encode(body) deadline = monotonic() + _DEADLINE_SECONDS timeout = _REQUEST_TIMEOUT_SECONDS - with httpx.Client() as client: - for delay in _RETRY_DELAYS: - try: - _post(client, url, content, timeout) - except VolcanoError as error: - next_timeout = _next_timeout(error, delay, deadline) - if next_timeout is None: - raise - timeout = next_timeout - else: - return - _post(client, url, content, timeout) - - -def _post(client: httpx.Client, url: str, content: bytes, timeout: float) -> None: - response = unparsed_response( - invoke(client.post, url, content=content, headers=_HEADERS, timeout=timeout) - ) + for delay in _RETRY_DELAYS: + try: + _attempt(url, content, timeout) + except VolcanoError as error: + next_timeout = _next_timeout(error, delay, deadline) + if next_timeout is None: + raise + timeout = next_timeout + else: + return + _attempt(url, content, timeout) + + +def _attempt(url: str, content: bytes, timeout: float) -> None: + # httpx times each network read on its own, restarting with every chunk, + # so only cancelling the request bounds the attempt as a whole; its own + # timeouts still keep any one phase within it. The runtime calls the + # submitter on a thread without an event loop. + client = httpx.AsyncClient(timeout=timeout) + try: + response = asyncio.run( + asyncio.wait_for(invoke_async(_exchange, client, url, content), timeout) + ) + except TimeoutError as error: + message = f"Volcano did not answer within {timeout:g} seconds" + raise TransportError(message) from error if response.status_code == HTTP_CREATED: return try: @@ -185,6 +201,39 @@ def _post(client: httpx.Client, url: str, content: bytes, timeout: float) -> Non raise +async def _exchange( + client: httpx.AsyncClient, url: str, content: bytes +) -> TransportResponse: + async with ( + client, + client.stream("POST", url, content=content, headers=_HEADERS) as response, + ): + answer = await _answer(response) + return unparsed_response(_Answer(response.status_code, answer, response.headers)) + + +async def _answer(response: httpx.Response) -> bytes: + """Read the answer, unless it runs past what Volcano would send. + + Returns: + The body, or nothing when it is too large to be one of Volcano's. + + """ + answer = bytearray() + async for chunk in response.aiter_bytes(): + answer += chunk + if len(answer) > _MAX_ANSWER_BYTES: + return b"" + return bytes(answer) + + +@dataclass(frozen=True, slots=True) +class _Answer: + status_code: int + content: bytes + headers: Mapping[str, str] + + def _retryable(error: VolcanoError) -> bool: # NotFoundError included: Volcano records an execution only once it has # started, so an approval that is its first operation can arrive first. diff --git a/src/volcano_sdk/_tests/test_durable_approval_registration.py b/src/volcano_sdk/_tests/test_durable_approval_registration.py index 21e28534..c156b048 100644 --- a/src/volcano_sdk/_tests/test_durable_approval_registration.py +++ b/src/volcano_sdk/_tests/test_durable_approval_registration.py @@ -1,11 +1,14 @@ from __future__ import annotations +import asyncio import json +import time from types import MappingProxyType -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING, NoReturn, Protocol, TypeAlias import httpx import pytest +from typing_extensions import override from volcano_sdk import ( ConflictError, @@ -27,7 +30,7 @@ from volcano_sdk.models import DurableApprovalDecider if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import AsyncIterator, Callable from volcano_sdk.models import JSONValue @@ -39,25 +42,80 @@ "title": "Ship order 1234?", } RETRY_DELAYS = [0.5, 1.0, 2.0, 4.0, 5.0, 5.0, 5.0, 5.0] -# The fixture replaces httpx.Client for the module under test, which is the -# same attribute everywhere. -REAL_CLIENT = httpx.Client +# The fixture replaces httpx.AsyncClient for the module under test, which is +# the same attribute everywhere. +REAL_CLIENT = httpx.AsyncClient REGISTRATION = "volcano_sdk._durable_approval_registration" INVALID_DETAILS = ( "details must be JSON-serializable: mappings with string keys, lists, " "tuples, strings, finite numbers, booleans, and None" ) +# Long past any attempt's deadline: a slow answer still running by then was +# never cut off, and ends rather than hang the suite. +_GIVE_UP_SECONDS = 10.0 +_TRICKLE_INTERVAL = 0.01 +_NEVER_CANCELLED = "the attempt outlived its deadline" + + +class Stall: + """Accept the request and never answer it.""" + + def __init__(self) -> None: + self.released: bool = False + + async def hold(self) -> NoReturn: + try: + await asyncio.sleep(_GIVE_UP_SECONDS) + finally: + self.released = True + raise AssertionError(_NEVER_CANCELLED) + + +class Trickle(httpx.AsyncByteStream): + """Send a body one byte at a time, each well inside any read timeout.""" + + def __init__(self) -> None: + self.sent: int = 0 + self.closed: bool = False + + @override + async def __aiter__(self) -> AsyncIterator[bytes]: + for _ in range(int(_GIVE_UP_SECONDS / _TRICKLE_INTERVAL)): + await asyncio.sleep(_TRICKLE_INTERVAL) + self.sent += 1 + yield b" " + + @override + async def aclose(self) -> None: + self.closed = True + + +class Flood(httpx.AsyncByteStream): + """Send a body as fast as it is read, forever.""" + + def __init__(self) -> None: + self.sent: int = 0 + + @override + async def __aiter__(self) -> AsyncIterator[bytes]: + while True: + self.sent += 4096 + yield b" " * 4096 + + +Answer: TypeAlias = "httpx.Response | Exception | Stall" class Platform: """Answer registrations in order and record what was sent. Time is the platform's own: it passes while the registration sleeps and - while a request runs into its timeout, and nowhere else. + while a request runs into its timeout, and nowhere else. An attempt that + is cancelled at its deadline takes real time, not platform time. """ - def __init__(self, *answers: httpx.Response | Exception) -> None: - self.answers: list[httpx.Response | Exception] = list(answers) + def __init__(self, *answers: Answer) -> None: + self.answers: list[Answer] = list(answers) self.requests: list[httpx.Request] = [] self.timeouts: list[float] = [] self.sleeps: list[float] = [] @@ -66,7 +124,7 @@ def __init__(self, *answers: httpx.Response | Exception) -> None: self.oversleep: float = 0.0 self.clock_runs: bool = True - def handle(self, request: httpx.Request) -> httpx.Response: + async def handle(self, request: httpx.Request) -> httpx.Response: self.requests.append(request) match request.extensions: case {"timeout": {"read": float(timeout)}}: @@ -75,14 +133,16 @@ def handle(self, request: httpx.Request) -> httpx.Response: message = f"expected a read timeout, got {request.extensions}" raise AssertionError(message) answer = self.answers[0] if len(self.answers) == 1 else self.answers.pop(0) + if isinstance(answer, Stall): + await answer.hold() if isinstance(answer, httpx.TimeoutException): self.advance(timeout) if isinstance(answer, Exception): raise answer return answer - def client(self) -> httpx.Client: - return REAL_CLIENT(transport=httpx.MockTransport(self.handle)) + def client(self, *, timeout: float) -> httpx.AsyncClient: + return REAL_CLIENT(transport=httpx.MockTransport(self.handle), timeout=timeout) def monotonic(self) -> float: return self.now @@ -97,14 +157,14 @@ def advance(self, seconds: float) -> None: class InstallPlatform(Protocol): - def __call__(self, *answers: httpx.Response | Exception) -> Platform: ... + def __call__(self, *answers: Answer) -> Platform: ... @pytest.fixture def platform(monkeypatch: pytest.MonkeyPatch) -> InstallPlatform: - def install(*answers: httpx.Response | Exception) -> Platform: + def install(*answers: Answer) -> Platform: fake = Platform(*answers) - monkeypatch.setattr(f"{REGISTRATION}.httpx.Client", fake.client) + monkeypatch.setattr(f"{REGISTRATION}.httpx.AsyncClient", fake.client) monkeypatch.setattr(f"{REGISTRATION}.monotonic", fake.monotonic) monkeypatch.setattr(f"{REGISTRATION}.sleep", fake.sleep) return fake @@ -288,6 +348,63 @@ def test_waking_up_at_the_deadline_raises_the_last_failure( assert fake.timeouts == [10.0] +# Nothing in a mock transport times out, so only the attempt's own deadline can +# end these early. The retry wakes 29.8 s in, which leaves its attempt 0.2 s. +_WAKE_LATE = 29.3 +_ENDED_PROMPTLY = 3.0 + + +def test_an_attempt_ends_by_its_deadline_when_volcano_never_answers( + platform: InstallPlatform, +) -> None: + stall = Stall() + fake = platform(refused(503), stall) + fake.oversleep = _WAKE_LATE + started = time.perf_counter() + + with pytest.raises(TransportError) as caught: + register_approval(API_URL, BODY) + + assert time.perf_counter() - started < _ENDED_PROMPTLY + assert str(caught.value) == "Volcano did not answer within 0.2 seconds" + assert stall.released + assert fake.timeouts == [10.0, pytest.approx(0.2)] + assert fake.sleeps == [0.5] + + +def test_an_attempt_ends_by_its_deadline_while_the_answer_trickles_in( + platform: InstallPlatform, +) -> None: + # Each byte arrives long before any per-read timeout would fire. + trickle = Trickle() + fake = platform(refused(503), httpx.Response(503, stream=trickle)) + fake.oversleep = _WAKE_LATE + started = time.perf_counter() + + with pytest.raises(TransportError) as caught: + register_approval(API_URL, BODY) + + assert time.perf_counter() - started < _ENDED_PROMPTLY + assert str(caught.value) == "Volcano did not answer within 0.2 seconds" + assert trickle.sent > 0 + assert trickle.closed + assert fake.timeouts == [10.0, pytest.approx(0.2)] + + +def test_an_answer_too_large_to_be_volcanos_is_not_read_to_the_end( + platform: InstallPlatform, +) -> None: + flood = Flood() + fake = platform(httpx.Response(409, stream=flood)) + + with pytest.raises(ConflictError) as caught: + register_approval(API_URL, BODY) + + assert caught.value.code is None + assert flood.sent <= 65536 + 4096 + assert len(fake.requests) == 1 + + @pytest.mark.parametrize( ("answer", "expected"), [ diff --git a/src/volcano_sdk/_tests/test_durable_authoring.py b/src/volcano_sdk/_tests/test_durable_authoring.py index 06be43f0..4572ded3 100644 --- a/src/volcano_sdk/_tests/test_durable_authoring.py +++ b/src/volcano_sdk/_tests/test_durable_authoring.py @@ -1690,13 +1690,13 @@ def closed(request: httpx.Request) -> httpx.Response: }, ) - real_client = httpx.Client + real_client = httpx.AsyncClient - def client() -> httpx.Client: - return real_client(transport=httpx.MockTransport(closed)) + def client(*, timeout: float) -> httpx.AsyncClient: + return real_client(transport=httpx.MockTransport(closed), timeout=timeout) monkeypatch.setattr( - "volcano_sdk._durable_approval_registration.httpx.Client", client + "volcano_sdk._durable_approval_registration.httpx.AsyncClient", client ) monkeypatch.setenv("VOLCANO_PLATFORM_API_URL", PLATFORM_API_URL) From 0be2acf1bb86b39283135f54fd55017f445ec290 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Wed, 7 Oct 2026 11:27:18 -0700 Subject: [PATCH 09/16] test(durable): mock the API's own refusal for a project access token Co-authored-by: Cursor --- src/volcano_sdk/_tests/test_durable_approvals.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/volcano_sdk/_tests/test_durable_approvals.py b/src/volcano_sdk/_tests/test_durable_approvals.py index 22d20e28..3f6d56ef 100644 --- a/src/volcano_sdk/_tests/test_durable_approvals.py +++ b/src/volcano_sdk/_tests/test_durable_approvals.py @@ -758,7 +758,10 @@ def test_a_decision_posts_its_body_over_the_wire( def test_wire_failures_map_to_typed_errors( status: int, expected: type[Exception] ) -> None: - message = "approvals are decided by a person; use a platform token or the dashboard" + message = ( + "project access tokens cannot decide durable approvals; " + "a person decides in the dashboard or with a platform token" + ) client, _requests = wire_client( lambda _r: httpx.Response(status, json={"error": message, "code": "c"}) ) From c7c5e93302b6eca4a66f270bac70a67e63c7a8c7 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Wed, 7 Oct 2026 11:47:26 -0700 Subject: [PATCH 10/16] fix(durable): read decided_at only as an RFC 3339 date-time, as the JavaScript SDK does Co-authored-by: Cursor --- .../_durable_approval_registration.py | 15 ++++++++++++- .../test_durable_approval_registration.py | 22 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/volcano_sdk/_durable_approval_registration.py b/src/volcano_sdk/_durable_approval_registration.py index 36ef1d24..22a47c5c 100644 --- a/src/volcano_sdk/_durable_approval_registration.py +++ b/src/volcano_sdk/_durable_approval_registration.py @@ -5,6 +5,7 @@ import asyncio import json import os +import re from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime @@ -319,9 +320,21 @@ def _decider(value: object) -> DurableApprovalDecider | None: return DurableApprovalDecider(id=decider_id, email=email) +# RFC 3339 date-time, the form Volcano writes. fromisoformat alone also takes a +# bare date, a time without an offset, and other ISO 8601 shapes. +_TIMESTAMP = re.compile( + ( + r"\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3])(?::[0-5]\d){2}(?:\.\d+)?" + r"(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)" + ), + re.ASCII, +) + + def _decided_at(value: object) -> str | None: - if not isinstance(value, str): + if not isinstance(value, str) or not _TIMESTAMP.fullmatch(value): return None + # It also refuses an impossible day, such as February 30. try: _ = datetime.fromisoformat(value) except ValueError: diff --git a/src/volcano_sdk/_tests/test_durable_approval_registration.py b/src/volcano_sdk/_tests/test_durable_approval_registration.py index c156b048..d6e542d7 100644 --- a/src/volcano_sdk/_tests/test_durable_approval_registration.py +++ b/src/volcano_sdk/_tests/test_durable_approval_registration.py @@ -839,11 +839,33 @@ def test_the_status_alone_decides(result: object, expected: ApprovalDecision) -> {"decided_at": None}, id="epoch seconds", ), + *( + pytest.param( + complete_decision(decided_at=value), {"decided_at": None}, id=case + ) + for case, value in [ + ("bare year", "2026"), + ("date without a time", "2026-10-06"), + ("time without an offset", "2026-10-06T12:05:00"), + ("time without seconds", "2026-10-06T12:05Z"), + ("space for the T", "2026-10-06 12:05:00Z"), + ("basic format", "20261006T120500Z"), + ("impossible day", "2026-02-30T12:05:00Z"), + ("impossible hour", "2026-10-06T24:00:00Z"), + ("impossible month", "2026-13-06T12:05:00Z"), + ("leap second", "2026-10-06T23:59:60Z"), + ] + ), pytest.param( complete_decision(decided_at="2026-10-06T12:05:00.123456+02:00"), {"decided_at": "2026-10-06T12:05:00.123456+02:00"}, id="offset time kept as sent", ), + pytest.param( + complete_decision(decided_at="2028-02-29T12:05:00Z"), + {"decided_at": "2028-02-29T12:05:00Z"}, + id="leap day kept", + ), pytest.param(without("decided_by"), {"decided_by": None}, id="no decider"), pytest.param( complete_decision(decided_by="ops"), {"decided_by": None}, id="decider type" From 2979c81451ea18e34b35090ebe5a3a0e91fa88bd Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Wed, 7 Oct 2026 15:07:08 -0700 Subject: [PATCH 11/16] test(durable): pin the approval answer cap and the retry deadline edge Co-authored-by: Cursor --- .../_durable_approval_registration.py | 8 ++- .../test_durable_approval_registration.py | 50 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/volcano_sdk/_durable_approval_registration.py b/src/volcano_sdk/_durable_approval_registration.py index 22a47c5c..733c1c75 100644 --- a/src/volcano_sdk/_durable_approval_registration.py +++ b/src/volcano_sdk/_durable_approval_registration.py @@ -9,6 +9,7 @@ from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime +from http import HTTPMethod from time import monotonic, sleep from typing import TYPE_CHECKING, TypeGuard @@ -207,7 +208,9 @@ async def _exchange( ) -> TransportResponse: async with ( client, - client.stream("POST", url, content=content, headers=_HEADERS) as response, + client.stream( + HTTPMethod.POST, url, content=content, headers=_HEADERS + ) as response, ): answer = await _answer(response) return unparsed_response(_Answer(response.status_code, answer, response.headers)) @@ -224,7 +227,8 @@ async def _answer(response: httpx.Response) -> bytes: async for chunk in response.aiter_bytes(): answer += chunk if len(answer) > _MAX_ANSWER_BYTES: - return b"" + answer.clear() + break return bytes(answer) diff --git a/src/volcano_sdk/_tests/test_durable_approval_registration.py b/src/volcano_sdk/_tests/test_durable_approval_registration.py index d6e542d7..2bd0ea36 100644 --- a/src/volcano_sdk/_tests/test_durable_approval_registration.py +++ b/src/volcano_sdk/_tests/test_durable_approval_registration.py @@ -103,6 +103,18 @@ async def __aiter__(self) -> AsyncIterator[bytes]: yield b" " * 4096 +class Pieces(httpx.AsyncByteStream): + """Send a body split into the given pieces.""" + + def __init__(self, *pieces: bytes) -> None: + self.pieces: tuple[bytes, ...] = pieces + + @override + async def __aiter__(self) -> AsyncIterator[bytes]: + for piece in self.pieces: + yield piece + + Answer: TypeAlias = "httpx.Response | Exception | Stall" @@ -348,6 +360,21 @@ def test_waking_up_at_the_deadline_raises_the_last_failure( assert fake.timeouts == [10.0] +def test_a_retry_that_would_wake_at_the_deadline_is_not_slept_for( + platform: InstallPlatform, +) -> None: + # The retry wakes 29 s in; its attempt fails, and the next delay of 1 s + # would end exactly at the deadline. + fake = platform(refused(503)) + fake.oversleep = 28.5 + + with pytest.raises(ServerError, match="refused with 503"): + register_approval(API_URL, BODY) + + assert fake.sleeps == [0.5] + assert fake.timeouts == [10.0, 1.0] + + # Nothing in a mock transport times out, so only the attempt's own deadline can # end these early. The retry wakes 29.8 s in, which leaves its attempt 0.2 s. _WAKE_LATE = 29.3 @@ -405,6 +432,29 @@ def test_an_answer_too_large_to_be_volcanos_is_not_read_to_the_end( assert len(fake.requests) == 1 +def test_an_answer_at_the_size_limit_is_read(platform: InstallPlatform) -> None: + closed = refused(409, "approval_closed").content + fake = platform(httpx.Response(409, content=closed.ljust(65536))) + + register_approval(API_URL, BODY) + + assert len(fake.requests) == 1 + + +def test_an_answer_that_arrives_in_pieces_is_read_whole( + platform: InstallPlatform, +) -> None: + closed = refused(409, "approval_closed").content + middle = len(closed) // 2 + fake = platform( + httpx.Response(409, stream=Pieces(closed[:middle], closed[middle:])) + ) + + register_approval(API_URL, BODY) + + assert len(fake.requests) == 1 + + @pytest.mark.parametrize( ("answer", "expected"), [ From 424d6f4a734de04ff48356b4b0e2c4559bddb80e Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Wed, 7 Oct 2026 15:21:48 -0700 Subject: [PATCH 12/16] test(durable): cover anchoring and range edges of the decision time Co-authored-by: Cursor --- .../_tests/test_durable_approval_registration.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/volcano_sdk/_tests/test_durable_approval_registration.py b/src/volcano_sdk/_tests/test_durable_approval_registration.py index 2bd0ea36..328a4a92 100644 --- a/src/volcano_sdk/_tests/test_durable_approval_registration.py +++ b/src/volcano_sdk/_tests/test_durable_approval_registration.py @@ -900,12 +900,28 @@ def test_the_status_alone_decides(result: object, expected: ApprovalDecision) -> ("time without seconds", "2026-10-06T12:05Z"), ("space for the T", "2026-10-06 12:05:00Z"), ("basic format", "20261006T120500Z"), + ("text before it", "at 2026-10-06T12:05:00Z"), + ("text after it", "2026-10-06T12:05:00Z UTC"), + ("interval", "2026-10-06T12:05:00Z/2026-10-07T12:05:00Z"), + ("day zero", "2026-10-00T12:05:00Z"), + ("impossible day of any month", "2026-10-32T12:05:00Z"), ("impossible day", "2026-02-30T12:05:00Z"), ("impossible hour", "2026-10-06T24:00:00Z"), + ("three-digit hour", "2026-10-06T012:05:00Z"), ("impossible month", "2026-13-06T12:05:00Z"), + ("impossible offset", "2026-10-06T12:05:00+24:00"), ("leap second", "2026-10-06T23:59:60Z"), ] ), + *( + pytest.param( + complete_decision(decided_at=value), {"decided_at": value}, id=case + ) + for case, value in [ + ("last day of a month kept", "2026-10-31T12:05:00Z"), + ("latest time and widest offset kept", "2026-10-06T23:59:59-23:59"), + ] + ), pytest.param( complete_decision(decided_at="2026-10-06T12:05:00.123456+02:00"), {"decided_at": "2026-10-06T12:05:00.123456+02:00"}, From c8291f6e5e0e90295a56f51e008950be9dc28c32 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Thu, 8 Oct 2026 08:37:05 -0700 Subject: [PATCH 13/16] chore(openapi): declare 413 on approval decisions Co-authored-by: Cursor --- openapi/openapi.yaml | 12 ++++++++++++ .../durable_functions/approve_durable_approval.py | 7 +++++++ .../api/durable_functions/deny_durable_approval.py | 7 +++++++ 3 files changed, 26 insertions(+) diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 6305cd2a..d1c8a157 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -4672,6 +4672,12 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + '413': + description: The request body is larger than 16 KiB + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /projects/{id}/durable-approvals/{approvalId}/deny: post: tags: @@ -4733,6 +4739,12 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + '413': + description: The request body is larger than 16 KiB + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /projects/{id}/frontends: get: tags: diff --git a/src/volcano_sdk/_generated/api/durable_functions/approve_durable_approval.py b/src/volcano_sdk/_generated/api/durable_functions/approve_durable_approval.py index 1c68f620..62b17471 100644 --- a/src/volcano_sdk/_generated/api/durable_functions/approve_durable_approval.py +++ b/src/volcano_sdk/_generated/api/durable_functions/approve_durable_approval.py @@ -83,6 +83,13 @@ def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Res return response_409 + if response.status_code == 413: + response_413 = Error.from_dict(response.json()) + + + + return response_413 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: diff --git a/src/volcano_sdk/_generated/api/durable_functions/deny_durable_approval.py b/src/volcano_sdk/_generated/api/durable_functions/deny_durable_approval.py index 4920ee9b..75e8e3a2 100644 --- a/src/volcano_sdk/_generated/api/durable_functions/deny_durable_approval.py +++ b/src/volcano_sdk/_generated/api/durable_functions/deny_durable_approval.py @@ -83,6 +83,13 @@ def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Res return response_409 + if response.status_code == 413: + response_413 = Error.from_dict(response.json()) + + + + return response_413 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: From 9587b6904ab66ce9ab4238d7430bcfec48dd048f Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Thu, 8 Oct 2026 08:37:05 -0700 Subject: [PATCH 14/16] fix(durable): refuse approval names and text the platform cannot record Names are the runtime's operation name, so they hold to printable ASCII and 237 characters. NUL is refused before the callback opens, details are copied when checked, and a stalled host lookup no longer outlasts its attempt. Co-authored-by: Cursor --- docs/functions.md | 4 +- .../_durable_approval_registration.py | 57 ++++++++- .../test_durable_approval_registration.py | 119 +++++++++++++++++- 3 files changed, 168 insertions(+), 12 deletions(-) diff --git a/docs/functions.md b/docs/functions.md index 07a3c090..aeda7c4a 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -160,13 +160,13 @@ def handler(event, ctx): | Argument | Description | |---|---| -| `name` | Required. Operation name in the execution's history, up to 255 characters. | +| `name` | Required. Operation name in the execution's history, up to 237 printable ASCII characters. Keep it short. | | `title` | Required. What the approver is asked, up to 200 characters. | | `description` | Optional context, up to 4000 characters. | | `details` | Optional JSON value shown with the approval. The whole approval, `details` included, must encode to at most 64 KiB of JSON. | | `timeout` | Optional duration in the `ctx.wait()` format, from one second to 366 days. Without it, the approval stays open as long as the execution runs. | -Limits count Unicode characters. A blank `name` or `title` raises `ValueError`, as does a value over its limit or an approval over 64 KiB. A value that is not a string, or that cannot be encoded as JSON, raises `TypeError`. A timeout out of range raises `TypeError`, as it does for `ctx.wait()`. These checks run before anything is recorded. +Limits count Unicode characters. A blank `name` or `title` raises `ValueError`, as does a value over its limit, a `name` outside printable ASCII, a NUL character anywhere in the text or `details`, or an approval over 64 KiB. A value that is not a string, or that cannot be encoded as JSON, raises `TypeError`. A timeout out of range raises `TypeError`, as it does for `ctx.wait()`. These checks run before anything is recorded. The call returns an immutable `ApprovalDecision`: diff --git a/src/volcano_sdk/_durable_approval_registration.py b/src/volcano_sdk/_durable_approval_registration.py index 733c1c75..1c52b634 100644 --- a/src/volcano_sdk/_durable_approval_registration.py +++ b/src/volcano_sdk/_durable_approval_registration.py @@ -66,8 +66,13 @@ "details must be JSON-serializable: mappings with string keys, lists, " "tuples, strings, finite numbers, booleans, and None" ) -# The spec's limits on what the approval shows the person deciding. -_TEXT_LIMITS = {"name": 255, "title": 200, "description": 4000} +# The limits on what the approval shows the person deciding. The name is also +# the runtime's operation name, which allows 256 printable ASCII characters, +# and the runtimes add suffixes of up to 19 characters to it; every SDK holds +# names to the same limit. +_TEXT_LIMITS = {"name": 237, "title": 200, "description": 4000} +_NOT_PRINTABLE_ASCII = "name must be printable ASCII" +_NUL = "\0" EXPIRED = ApprovalDecision(approved=False, status="expired") @@ -97,16 +102,43 @@ def approval_body( """ body: dict[str, JSONValue] = { - "name": _text(name, "name", required=True), + "name": _name(name), "title": _text(title, "title", required=True), } if description is not None: body["description"] = _text(description, "description", required=False) if details is not None: - body["details"] = json_value(details, _INVALID_DETAILS) + body["details"] = _details(details) return body +def _name(value: object) -> str: + name = _text(value, "name", required=True) + if not (name.isascii() and name.isprintable()): + raise ValueError(_NOT_PRINTABLE_ASCII) + return name + + +def _details(value: object) -> JSONValue: + # A copy, so a change the caller makes while the callback opens cannot + # slip past the checks. + details = plain_json(json_value(value, _INVALID_DETAILS)) + if _has_nul(details): + message = "details must not contain NUL characters" + raise ValueError(message) + return details + + +def _has_nul(value: JSONValue) -> bool: + if isinstance(value, str): + return _NUL in value + if isinstance(value, dict): + return any(_NUL in key or _has_nul(item) for key, item in value.items()) + if isinstance(value, list): + return any(_has_nul(item) for item in value) + return False + + def _text(value: object, field: str, *, required: bool) -> str: if not isinstance(value, str): message = f"{field} must be a string" @@ -119,10 +151,17 @@ def _text(value: object, field: str, *, required: bool) -> str: if required and not value.strip(): message = f"{field} must not be empty" raise ValueError(message) + return _recordable(value, field) + + +def _recordable(value: str, field: str) -> str: limit = _TEXT_LIMITS[field] if len(value) > limit: message = f"{field} must be at most {limit} characters" raise ValueError(message) + if _NUL in value: + message = f"{field} must not contain NUL characters" + raise ValueError(message) return value @@ -185,15 +224,21 @@ def _attempt(url: str, content: bytes, timeout: float) -> None: # httpx times each network read on its own, restarting with every chunk, # so only cancelling the request bounds the attempt as a whole; its own # timeouts still keep any one phase within it. The runtime calls the - # submitter on a thread without an event loop. + # submitter on a thread without an event loop. asyncio.run() would wait on + # its way out for a host lookup that cancelling leaves running in the + # loop's executor; closing the loop leaves it behind instead. client = httpx.AsyncClient(timeout=timeout) + loop = asyncio.new_event_loop() try: - response = asyncio.run( + response = loop.run_until_complete( asyncio.wait_for(invoke_async(_exchange, client, url, content), timeout) ) except TimeoutError as error: message = f"Volcano did not answer within {timeout:g} seconds" raise TransportError(message) from error + finally: + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.close() if response.status_code == HTTP_CREATED: return try: diff --git a/src/volcano_sdk/_tests/test_durable_approval_registration.py b/src/volcano_sdk/_tests/test_durable_approval_registration.py index 328a4a92..ef483e06 100644 --- a/src/volcano_sdk/_tests/test_durable_approval_registration.py +++ b/src/volcano_sdk/_tests/test_durable_approval_registration.py @@ -2,6 +2,8 @@ import asyncio import json +import socket +import threading import time from types import MappingProxyType from typing import TYPE_CHECKING, NoReturn, Protocol, TypeAlias @@ -418,6 +420,38 @@ def test_an_attempt_ends_by_its_deadline_while_the_answer_trickles_in( assert fake.timeouts == [10.0, pytest.approx(0.2)] +def test_an_attempt_ends_by_its_deadline_while_the_host_lookup_stalls( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Only the clock is the platform's: the request goes through a real client, + # whose host lookup runs on a thread that cancelling cannot stop. + fake = Platform() + fake.oversleep = _WAKE_LATE + monkeypatch.setattr(f"{REGISTRATION}.monotonic", fake.monotonic) + monkeypatch.setattr(f"{REGISTRATION}.sleep", fake.sleep) + released = threading.Event() + lookups: list[object] = [] + + def lookup(host: object, *_args: object, **_kwargs: object) -> NoReturn: + lookups.append(host) + if len(lookups) > 1: + _ = released.wait(_GIVE_UP_SECONDS) + raise socket.gaierror(socket.EAI_NONAME, "unknown host") + + monkeypatch.setattr(socket, "getaddrinfo", lookup) + started = time.perf_counter() + + try: + with pytest.raises(TransportError) as caught: + register_approval(API_URL, BODY) + finally: + released.set() + + assert time.perf_counter() - started < _ENDED_PROMPTLY + assert str(caught.value) == "Volcano did not answer within 0.2 seconds" + assert lookups == [b"api.volcano.test", b"api.volcano.test"] + + def test_an_answer_too_large_to_be_volcanos_is_not_read_to_the_end( platform: InstallPlatform, ) -> None: @@ -547,10 +581,10 @@ def test_the_body_carries_only_what_was_given() -> None: def test_text_at_its_limit_is_accepted() -> None: - body = approval_body("n" * 255, "t" * 200, "d" * 4000, details=False) + body = approval_body(" ~" * 118 + "n", "t" * 200, "d" * 4000, details=False) assert body == { - "name": "n" * 255, + "name": " ~" * 118 + "n", "title": "t" * 200, "description": "d" * 4000, "details": False, @@ -567,11 +601,71 @@ def test_text_at_its_limit_is_accepted() -> None: (" ", "t", None, None), ValueError, "name must not be empty", id="blank" ), pytest.param( - ("n" * 256, "t", None, None), + ("n" * 238, "t", None, None), ValueError, - "name must be at most 255 characters", + "name must be at most 237 characters", id="long name", ), + pytest.param( + ("aprobaci\u00f3n", "t", None, None), + ValueError, + "name must be printable ASCII", + id="non-ascii name", + ), + pytest.param( + ("ship\torder", "t", None, None), + ValueError, + "name must be printable ASCII", + id="control character in name", + ), + pytest.param( + ("ship\x7f", "t", None, None), + ValueError, + "name must be printable ASCII", + id="delete in name", + ), + pytest.param( + ("ship\0", "t", None, None), + ValueError, + "name must not contain NUL characters", + id="nul name", + ), + pytest.param( + ("n", "Ship\0?", None, None), + ValueError, + "title must not contain NUL characters", + id="nul title", + ), + pytest.param( + ("n", "t", "Express\0", None), + ValueError, + "description must not contain NUL characters", + id="nul description", + ), + pytest.param( + ("n", "t", None, "\0"), + ValueError, + "details must not contain NUL characters", + id="nul details", + ), + pytest.param( + ("n", "t", None, {"items": ["a", "b\0"]}), + ValueError, + "details must not contain NUL characters", + id="nul in a details list", + ), + pytest.param( + ("n", "t", None, {"order": {"a\0": 1}}), + ValueError, + "details must not contain NUL characters", + id="nul in a details key", + ), + pytest.param( + ("n", "t", None, (1, {"note": "\0"})), + ValueError, + "details must not contain NUL characters", + id="nul in a details tuple", + ), pytest.param( ("n", None, None, None), TypeError, "title must be a string", id="title" ), @@ -645,6 +739,23 @@ def test_an_invalid_body_is_refused( assert str(caught.value) == message +def test_details_are_copied_when_checked() -> None: + items: list[JSONValue] = [1] + details: dict[str, JSONValue] = {"items": items, "frozen": MappingProxyType({})} + + body = approval_body("ship", "Ship?", None, details) + items.append("x" * 70_000) + details["late"] = True + + assert body["details"] == {"items": [1], "frozen": {}} + + +def test_details_that_only_spell_out_an_escaped_nul_are_kept() -> None: + details: JSONValue = {"note": "\\u0000", "items": [1, None, True, 1.5]} + + assert approval_body("ship", "Ship?", None, details)["details"] == details + + def test_whitespace_is_kept_and_only_blank_text_is_refused() -> None: assert approval_body(" ship ", "\tShip?\n", " ", None) == { "name": " ship ", From 9f9bd4249f9f269b73156d8fea8c1ed7135f4bc9 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Thu, 8 Oct 2026 11:38:28 -0700 Subject: [PATCH 15/16] fix(durable): report the attempt's deadline when the client's timeout wins Co-authored-by: Cursor --- .../_durable_approval_registration.py | 33 ++++++--- .../test_durable_approval_registration.py | 67 ++++++++++++++++++- 2 files changed, 88 insertions(+), 12 deletions(-) diff --git a/src/volcano_sdk/_durable_approval_registration.py b/src/volcano_sdk/_durable_approval_registration.py index 1c52b634..fd939c5b 100644 --- a/src/volcano_sdk/_durable_approval_registration.py +++ b/src/volcano_sdk/_durable_approval_registration.py @@ -221,6 +221,17 @@ def register_approval(api_url: str, body: Mapping[str, JSONValue]) -> None: def _attempt(url: str, content: bytes, timeout: float) -> None: + response = _send(url, content, timeout) + if response.status_code == HTTP_CREATED: + return + try: + _ = response_payload(response, HTTP_OK) + except ConflictError as error: + if error.code != _CLOSED: + raise + + +def _send(url: str, content: bytes, timeout: float) -> TransportResponse: # httpx times each network read on its own, restarting with every chunk, # so only cancelling the request bounds the attempt as a whole; its own # timeouts still keep any one phase within it. The runtime calls the @@ -230,22 +241,24 @@ def _attempt(url: str, content: bytes, timeout: float) -> None: client = httpx.AsyncClient(timeout=timeout) loop = asyncio.new_event_loop() try: - response = loop.run_until_complete( + return loop.run_until_complete( asyncio.wait_for(invoke_async(_exchange, client, url, content), timeout) ) except TimeoutError as error: - message = f"Volcano did not answer within {timeout:g} seconds" - raise TransportError(message) from error + raise _no_answer(timeout) from error + except TransportError as error: + # The client's timeouts spend the same budget. When a stalled loop + # fires both at once, Python 3.11's wait_for reports the client's. + if isinstance(error.__cause__, httpx.TimeoutException): + raise _no_answer(timeout) from error.__cause__ + raise finally: loop.run_until_complete(loop.shutdown_asyncgens()) loop.close() - if response.status_code == HTTP_CREATED: - return - try: - _ = response_payload(response, HTTP_OK) - except ConflictError as error: - if error.code != _CLOSED: - raise + + +def _no_answer(timeout: float) -> TransportError: + return TransportError(f"Volcano did not answer within {timeout:g} seconds") async def _exchange( diff --git a/src/volcano_sdk/_tests/test_durable_approval_registration.py b/src/volcano_sdk/_tests/test_durable_approval_registration.py index ef483e06..f94417aa 100644 --- a/src/volcano_sdk/_tests/test_durable_approval_registration.py +++ b/src/volcano_sdk/_tests/test_durable_approval_registration.py @@ -330,7 +330,7 @@ def test_each_attempt_is_given_what_is_left_of_the_deadline( ) -> None: fake = platform(httpx.ReadTimeout("timed out")) - with pytest.raises(TransportError, match="timed out"): + with pytest.raises(TransportError, match=r"did not answer within 8\.5 seconds"): register_approval(API_URL, BODY) assert fake.timeouts == [10.0, 10.0, 8.5] @@ -355,13 +355,25 @@ def test_waking_up_at_the_deadline_raises_the_last_failure( fake = platform(httpx.ReadTimeout("timed out")) fake.oversleep = 19.5 - with pytest.raises(TransportError, match="timed out"): + with pytest.raises(TransportError, match="did not answer within 10 seconds"): register_approval(API_URL, BODY) assert fake.sleeps == [0.5] assert fake.timeouts == [10.0] +def test_a_network_failure_keeps_its_own_message( + platform: InstallPlatform, +) -> None: + fake = platform(httpx.ConnectError("connection refused")) + fake.oversleep = 19.5 + + with pytest.raises(TransportError) as caught: + register_approval(API_URL, BODY) + + assert str(caught.value) == "connection refused" + + def test_a_retry_that_would_wake_at_the_deadline_is_not_slept_for( platform: InstallPlatform, ) -> None: @@ -452,6 +464,57 @@ def lookup(host: object, *_args: object, **_kwargs: object) -> NoReturn: assert lookups == [b"api.volcano.test", b"api.volcano.test"] +def test_the_clients_own_timeout_reports_the_attempts_deadline( + platform: InstallPlatform, +) -> None: + # What httpx raises when its connect timeout, not the attempt's, fires. + fake = platform(refused(503), httpx.ConnectTimeout("")) + fake.oversleep = _WAKE_LATE + + with pytest.raises(TransportError) as caught: + register_approval(API_URL, BODY) + + assert str(caught.value) == "Volcano did not answer within 0.2 seconds" + assert fake.timeouts == [10.0, pytest.approx(0.2)] + + +def test_a_stalled_loop_still_reports_the_attempts_deadline( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Holding the loop past 0.2 s fires the attempt's deadline and the client's + # connect timeout in one pass, while the host lookup is still running. + fake = Platform() + fake.oversleep = _WAKE_LATE + monkeypatch.setattr(f"{REGISTRATION}.monotonic", fake.monotonic) + monkeypatch.setattr(f"{REGISTRATION}.sleep", fake.sleep) + released = threading.Event() + lookups: list[object] = [] + new_event_loop = asyncio.new_event_loop + + def lookup(host: object, *_args: object, **_kwargs: object) -> NoReturn: + lookups.append(host) + if len(lookups) > 1: + _ = released.wait(_GIVE_UP_SECONDS) + raise socket.gaierror(socket.EAI_NONAME, "unknown host") + + def stalling_loop() -> asyncio.AbstractEventLoop: + loop = new_event_loop() + _ = loop.call_later(0.1, time.sleep, 0.3) + return loop + + monkeypatch.setattr(socket, "getaddrinfo", lookup) + monkeypatch.setattr(f"{REGISTRATION}.asyncio.new_event_loop", stalling_loop) + + try: + with pytest.raises(TransportError) as caught: + register_approval(API_URL, BODY) + finally: + released.set() + + assert str(caught.value) == "Volcano did not answer within 0.2 seconds" + assert lookups == [b"api.volcano.test", b"api.volcano.test"] + + def test_an_answer_too_large_to_be_volcanos_is_not_read_to_the_end( platform: InstallPlatform, ) -> None: From e874397a276f4ec073a97751540c8f4c717bb3b2 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Thu, 8 Oct 2026 16:24:54 -0700 Subject: [PATCH 16/16] test(realtime): give native commands two seconds to arrive A command normally arrives within a millisecond. The 0.2 s wait failed the cancelled-unsubscribe tests whenever a loaded runner stalled the loop, which made mutation stats collection fail on random modules. Co-authored-by: Cursor --- src/volcano_sdk/_tests/test_realtime.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/volcano_sdk/_tests/test_realtime.py b/src/volcano_sdk/_tests/test_realtime.py index 13b72156..272b38ac 100644 --- a/src/volcano_sdk/_tests/test_realtime.py +++ b/src/volcano_sdk/_tests/test_realtime.py @@ -791,7 +791,7 @@ def __call__( return self.client async def command(self) -> dict[str, object]: - return await asyncio.wait_for(self.commands.get(), timeout=0.2) + return await asyncio.wait_for(self.commands.get(), timeout=2) def subscription(self, channel: str) -> SDKNativeSubscription: subscription = self.client.get_subscription(channel)