From 91236872bd00dc68aa0c0455689380e20326b78d Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Mon, 17 Aug 2026 06:35:28 +0000 Subject: [PATCH 01/79] feat: initial resumable upload implementation --- gapic-common/design/implementation-guide.md | 370 +++++++++++++ .../design/reference-implementation.md | 517 ++++++++++++++++++ gapic-common/lib/gapic/common/error.rb | 50 +- gapic-common/lib/gapic/rest.rb | 1 + .../lib/gapic/rest/resumable_upload.rb | 34 ++ .../lib/gapic/rest/resumable_upload/core.rb | 58 ++ .../gapic/rest/resumable_upload/data_types.rb | 98 ++++ .../lib/gapic/rest/resumable_upload/driver.rb | 285 ++++++++++ .../lib/gapic/rest/resumable_upload/errors.rb | 35 ++ .../lib/gapic/rest/resumable_upload/events.rb | 68 +++ .../rest/resumable_upload/instructions.rb | 116 ++++ .../rest/resumable_upload/retry_policies.rb | 87 +++ .../lib/gapic/rest/resumable_upload/rules.rb | 392 +++++++++++++ .../gapic/rest/resumable_upload/core_test.rb | 68 +++ .../rest/resumable_upload/data_types_test.rb | 94 ++++ .../gapic/rest/resumable_upload/rules_test.rb | 361 ++++++++++++ 16 files changed, 2633 insertions(+), 1 deletion(-) create mode 100644 gapic-common/design/implementation-guide.md create mode 100644 gapic-common/design/reference-implementation.md create mode 100644 gapic-common/lib/gapic/rest/resumable_upload.rb create mode 100644 gapic-common/lib/gapic/rest/resumable_upload/core.rb create mode 100644 gapic-common/lib/gapic/rest/resumable_upload/data_types.rb create mode 100644 gapic-common/lib/gapic/rest/resumable_upload/driver.rb create mode 100644 gapic-common/lib/gapic/rest/resumable_upload/errors.rb create mode 100644 gapic-common/lib/gapic/rest/resumable_upload/events.rb create mode 100644 gapic-common/lib/gapic/rest/resumable_upload/instructions.rb create mode 100644 gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb create mode 100644 gapic-common/lib/gapic/rest/resumable_upload/rules.rb create mode 100644 gapic-common/test/gapic/rest/resumable_upload/core_test.rb create mode 100644 gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb create mode 100644 gapic-common/test/gapic/rest/resumable_upload/rules_test.rb diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md new file mode 100644 index 0000000..550f790 --- /dev/null +++ b/gapic-common/design/implementation-guide.md @@ -0,0 +1,370 @@ +# Scotty Resumable Upload Protocol (RUP) Implementation Guide + +## 1. System Architecture + +The Resumable Upload Protocol (RUP) implementation in `gapic-common` is structured across three distinct tiers to separate network execution, protocol state progression, and state transition decision logic: + +```mermaid +graph TD + Client[Client Code] -->|CompleteUploadConfig| Driver + subgraph Gapic::Rest::ResumableUpload + Driver[Driver
Synchronous I/O Adapter] -->|Events| Core[Core
State Container] + Core -->|Instructions| Driver + Core -->|state, event| Rules[Rules
Pure Decision Function] + Rules -->|next_state, instructions| Core + end + Driver -->|RetryPolicy / Faraday| Server[Scotty / GCS Backend] + Driver -->|IO#read| Stream[Local Stream] +``` + +### 1.1 Driver (Synchronous I/O Adapter) +The `Driver` executes all operations with side-effects. It interacts with HTTP transport via `Gapic::Rest::ClientStub`, reads binary data from local input streams, tracks monotonic execution deadlines, and dispatches progress callbacks. + +Crucially, the Driver delegates all **Category 1 (Transient)** transport retries directly to `Gapic::Common::RetryPolicy`. Transient retries occur entirely within the Driver's network execution wrapper. The `Core` state machine is never exposed to transient noise, receiving only verified successful HTTP responses or terminal transport exceptions. + +### 1.2 Core (State Container) +The `Core` maintains the immutable `State` snapshot. When `Core#dispatch(event)` is invoked by the Driver, Core forwards `@state`, the event, and static configuration to `Rules.step`. Core mutates `@state` to the returned next state and yields instructions back to the Driver. Core contains zero protocol branching logic and zero side effects. + +### 1.3 Rules (Pure Decision Function) +The `Rules` module encapsulates the Resumable Upload Protocol state transitions as a pure functional module. Given a state snapshot, an input event, and configuration, `Rules.step` computes the next protocol state and emitted driver instructions. + +### 1.4 Stream Buffering +Because arbitrary Ruby `IO` objects (network sockets, pipes, `STDIN`) do not support seeking (`#seek`), the Driver buffers the current in-flight chunk in memory (bounded by chunk size, default: 8MB). When `RetryPolicy` executes transport retries, or when `Core` triggers Category 2 recovery realignments within the buffered range, the Driver retransmits directly from memory. The buffer is discarded only after receiving a `200 OK` durably confirming receipt of the chunk. + +--- + +## 2. Component Interfaces & Data Models + +### 2.1 Client Configuration (`CompleteUploadConfig`) +```ruby +module Gapic + module Rest + module ResumableUpload + CompleteUploadConfig = Data.define( + :initial_url, # [String] Initial endpoint URI for session initiation + :initial_body, # [String] Request payload for session initiation + :initial_headers, # [Hash] Additional headers for initiation + :stream, # [IO] Binary input stream to upload + :upload_size, # [Integer, nil] Total upload bytes if known upfront + :chunk_size, # [Integer, nil] Explicit chunk size in bytes + :content_type, # [String] MIME type of uploaded media + :deadline, # [Numeric, nil] Absolute monotonic deadline in seconds (Process.clock_gettime(Process::CLOCK_MONOTONIC)) + :control_plane_retry_policy, # [Gapic::Common::RetryPolicy, nil] Policy for start/query/cancel + :data_plane_retry_policy, # [Gapic::Common::RetryPolicy, nil] Policy for upload/finalize + :user_override_start_retry_policy, # [Gapic::Common::RetryPolicy, nil] Optional user override for start command + :on_progress # [Proc, nil] Callback: ->(bytes_uploaded, total_bytes) + ) + end + end +end +``` + +### 2.2 Protocol State (`State`) +```ruby +module Gapic + module Rest + module ResumableUpload + State = Data.define( + :status, # [Symbol] :initializing, :starting, :transmission_reading, :transmission_sending, + # :finalizing_sending_upload, :finalizing_sending_finalize, + # :recovery, :cancelling, :cancelled, :success, :error, :rejected + :upload_url, # [String, nil] Session upload URL returned by Scotty backend + :offset, # [Integer] Contiguous bytes confirmed by server (protocol_state_offset) + :chunk_size, # [Integer] Resolved effective chunk size + :chunk_granularity, # [Integer, nil] Alignment modulus returned by server + :in_flight_length, # [Integer] Byte length of in-flight chunk currently being transmitted + :last_error # [StandardError, nil] Terminal exception + ) do + # def eql?, def hash etc + end + end + end +end +``` + +### 2.3 Events Vocabulary (Driver -> Core) +* `Event::StartUpload`: Start the upload session. +* `Event::ChunkRead.new(bytes_buffered:, eof:)`: Binary data buffered in Driver memory; reports total bytes ready in buffer and whether the stream hit EOF. +* `Event::HttpResponse.new(status:, headers:, body:)`: Dispatched for any completed HTTP exchange over the wire (including 2xx, 4xx, 5xx, or responses with missing/unexpected headers). `Core` inspects status and headers to determine protocol progression or recovery. +* `Event::RequestFailed.new(kind:, message:, source_error:)`: Dispatched when an HTTP request fails to produce a usable HTTP response (e.g., transport connection errors or `RetryPolicy` exhaustion). + * `kind`: Normalized Symbol enum (`:retries_exhausted`, `:connection_failed`). `Core` branches on `kind` and treats other fields as opaque. + * `message`: Human-readable summary string. + * `source_error`: Original underlying exception, preserved for terminal error propagation and logging. +* `Event::Cancel`: Caller requested session cancellation. +* `Event::GlobalDeadlineExceeded`: Absolute monotonic clock exceeded `config.deadline`. + +### 2.4 Instructions Vocabulary (Core -> Driver) +* `Instruction::SendStart.new(url:, headers:, body:)`: Execute initiation request to establish upload session. +* `Instruction::SendChunk.new(url:, offset:, length:, finalize:)`: Transmit buffered chunk of specified `length` starting at `offset`. If `finalize` is true, sends command `upload, finalize`. +* `Instruction::SendFinalize.new(url:)`: Send standalone `finalize` command when all data bytes were already acknowledged. +* `Instruction::SendQuery.new(url:)`: Query backend for current acknowledged offset (`query` command). +* `Instruction::SendCancel.new(url:)`: Cancel upload session on server (`cancel` command). +* `Instruction::RealignBuffer.new(server_offset:)`: Realign Driver in-memory buffer and stream position to match `server_offset`. +* `Instruction::FillBuffer.new(target_bytesize:)`: Read from stream until in-memory buffer reaches `target_bytesize` bytes or stream encounters EOF. +* `Instruction::NotifyProgress.new(bytes_uploaded:, total_bytes:)`: Invoke `on_progress` callback. +* `Instruction::TerminateSuccess.new(response:)`: Upload finalized cleanly; return response. +* `Instruction::TerminateFailure.new(error:)`: Raise terminal exception. + +### 2.5 Driver Buffer Invariants & Stream Position Model + +The Driver coordinates stream reading and in-memory buffering using four explicit offset markers: +* `server_offset`: Contiguous byte count acknowledged by Scotty (extracted from `X-Goog-Upload-Size-Received`). +* `protocol_state_offset`: Byte offset maintained in `State.offset`. +* `buffer_start_offset`: Absolute stream offset corresponding to the first byte in the Driver's `@buffer`. +* `buffer_end_offset`: `buffer_start_offset + @buffer.bytesize`. + +```text +Stream Offset: 0 -----------------> buffer_start_offset -------------------> buffer_end_offset ----> (Stream EOF) + |----------------- @buffer -------------| + ^ + server_offset +``` + +#### Buffer Alignment Strategy (`Instruction::RealignBuffer`) +When `Core` resolves a recovery query or offset realignment, the Driver executes one of three alignment paths based on `server_offset`: + +1. **Case 1: Within Buffer Range (`buffer_start_offset <= server_offset <= buffer_end_offset`)** + * The required offset is already buffered in memory. + * Driver trims already-persisted bytes: `@buffer = @buffer.byteslice((server_offset - buffer_start_offset)..-1)`. + * Driver updates `buffer_start_offset = server_offset`. + * When subsequently executing `Instruction::FillBuffer(target_bytesize)`, Driver calculates `needed = target_bytesize - @buffer.bytesize` and reads only the missing difference from `stream` to complete the chunk to full `chunk_size` (unless stream reaches EOF). +2. **Case 2: Server Offset Behind Buffer (`server_offset < buffer_start_offset`)** + * Occurs if the server rolls back beyond the retained buffer window. + * If `stream.respond_to?(:seek)`: Driver seeks the stream back to `server_offset`, resets `@buffer = "".b`, and sets `buffer_start_offset = server_offset`. + * If `stream` is unseekable (e.g. Socket, Pipe, STDIN): Driver raises a terminal `UnseekableStreamError` (Category 3 failure). +3. **Case 3: Server Offset Ahead of Buffer (`server_offset > buffer_end_offset`)** + * Occurs when resuming an existing session or when the server processed a previously timed-out request ahead of local state. + * Driver resets `@buffer = "".b`. + * Driver advances the stream to `server_offset`: + * If seekable: `stream.seek(server_offset)`. + * If unseekable: Driver reads and discards `server_offset - current_stream_pos` bytes from `stream`. + * Driver sets `buffer_start_offset = server_offset`. + +--- + +## 3. Component Architecture & Reference Implementation + +The complete reference implementation for `Rules`, `Core`, and `Driver` is located in [reference-implementation.md](reference-implementation.md). + +### 3.1 Rules Module (`Gapic::Rest::ResumableUpload::Rules`) +The `Rules` module is a pure functional transition engine with zero state awareness and zero side effects. It provides two primary entry points: +* `Rules.shape_of(event)`: Classifies raw input events (`Event::StartUpload`, `Event::ChunkRead`, `Event::HttpResponse`, `Event::RequestFailed`, `Event::Cancel`, `Event::GlobalDeadlineExceeded`) into canonical symbols. +* `Rules.step(state, event, config)`: Evaluates `case [state.status, shape]` pattern matching to compute the next state snapshot and emitted driver instructions (`[next_state, instructions]`). + +Full implementation: [reference-implementation.md#1-rules-module](reference-implementation.md#1-rules-module) + +### 3.2 Core Class (`Gapic::Rest::ResumableUpload::Core`) +The `Core` class is the state container holding the immutable `State` snapshot. It exposes: +* `#state`: Reader for the current `State` snapshot. +* `#dispatch(event)`: Invokes `Rules.step(@state, event, @config)`, updates `@state = next_state`, and returns the emitted instructions array to the Driver. + +Full implementation: [reference-implementation.md#2-core-class](reference-implementation.md#2-core-class) + +### 3.3 Driver Class (`Gapic::Rest::ResumableUpload::Driver`) +The `Driver` is the synchronous execution engine for the pure protocol state machine. When `Core#dispatch(event)` is invoked, it returns an ordered list (`Array`) of commands that the Driver executes in sequence. + +#### Instruction Processing Semantics +The Driver categorizes instructions into three execution types: +1. **Synchronous Side-Effects** (`NotifyProgress`, `RealignBuffer`): + * Executed immediately in-process. + * Do not yield a new `Event` and do not break the batch loop. +2. **I/O & Network Operations** (`FillBuffer`, `SendStart`, `SendChunk`, `SendFinalize`, `SendQuery`, `SendCancel`): + * Execute physical stream reads or HTTP requests (wrapped in `Gapic::Common::RetryPolicy` for Category 1 transient errors). + * Yield a single resulting `Event` (`ChunkRead`, `HttpResponse`, or `RequestFailed`) that becomes the input for the next cycle. +3. **Terminal Handlers** (`TerminateSuccess`, `TerminateFailure`): + * Break the event loop and return the final `Faraday::Response` or raise the terminal exception. + +Full implementation: [reference-implementation.md#3-driver-class](reference-implementation.md#3-driver-class) + +--- + +## 4. State Machine Protocol Rules + +### 4.1 Upstream Protocol Contract +1. **Logical Header Prefixing**: In the `start` request, logical headers describing the uploaded object must be prefixed with `X-Goog-Upload-Header-`. Specifically: + * `X-Goog-Upload-Header-Content-Type: config.content_type` + * `X-Goog-Upload-Header-Content-Length: config.upload_size` (if known upfront). +2. **Offset Extraction**: On `query` responses, the acknowledged byte count is extracted from `X-Goog-Upload-Size-Received` as an integer (`server_offset`). +3. **Request Modification on 4xx**: Retrying Category 2 errors requires querying the backend for `server_offset` first. +4. **Standard Retry Configuration & Dual Policies**: The Driver manages two distinct retry policy configurations for Category 1 transient errors: + * **Control Plane Policy (`control_plane_retry_policy`)**: Applies to session control requests (`start`, `query`, `cancel`). Configured with standard retry codes (`["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"]`) and network errors (`[Faraday::ConnectionFailed, Faraday::TimeoutError, SocketError]`). Missing `X-Goog-Upload-Status` header is treated as **retriable** (predicate returns `true`) to smooth over transient gateway/proxy stripped headers. + * **User Override for Start (`user_override_start_retry_policy`)**: If supplied by caller in `CompleteUploadConfig`, this policy overrides `control_plane_retry_policy` exclusively for the `start` command. + * **Data Plane Policy (`data_plane_retry_policy`)**: Applies to data transmission requests (`upload`, `upload,finalize`, and standalone `finalize`). Shares the identical standard retry configuration, but treats a missing `X-Goog-Upload-Status` header as **unretriable** (predicate returns `false`). This prevents blind chunk re-transmission and returns `Event::HttpResponse` immediately to `Core` so it can initiate Category 2 `Recovery`. + +### 4.2 State Transition & Data Mutation Specification + +**State Classification:** +* **Non-Terminal States**: `Initializing`, `Starting`, `Transmission | Reading from stream`, `Transmission | Sending`, `Finalizing | Sending with upload`, `Finalizing | Sending finalize`, `Recovery`, `Cancelling`. +* **Terminal States**: `Success`, `Cancelled`, `Error`, `Rejected`. + +| From State | Event Shape | Event & Input Payload | State Mutations | To State | Emitted Instructions & Parameters | +| :--- | :--- | :--- | :--- | :--- | :--- | +| **`Initializing`** | `:start_upload` | `Event::StartUpload` | `status = :starting` | `Starting` | `Instruction::SendStart.new(url: config.initial_url, headers: config.initial_headers, body: config.initial_body)` | +| **`Starting`** | `:response_active` | `Event::HttpResponse(200, headers, _)` with `Status: active` | `upload_url = headers['X-Goog-Upload-URL']`
`chunk_granularity = headers['...-Granularity']&.to_i`
`chunk_size = resolve(config, chunk_granularity)`
`offset = 0`
`status = :transmission_reading` | `Transmission \| Reading from stream` | `Instruction::FillBuffer.new(target_bytesize: state.chunk_size)` | +| **`Starting`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, _)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | +| **`Starting`** | `:response_cat2` / `:response_fatal_bad_response` | `Event::HttpResponse(4xx/5xx, _)` | `last_error = source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | +| **`Starting`** | `:request_retries_exhausted` / `:request_connection_failed` | `Event::RequestFailed(kind:, message:, source_error:)` | `last_error = source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | +| **`Transmission \| Reading from stream`** | `:chunk_read_full` | `Event::ChunkRead(bytes_buffered, eof: false)` | `in_flight_length = event.bytes_buffered`
`status = :transmission_sending` | `Transmission \| Sending` | `Instruction::SendChunk.new(url: state.upload_url, offset: state.offset, length: event.bytes_buffered, finalize: false)` | +| **`Transmission \| Reading from stream`** | `:chunk_read_eof_with_data` | `Event::ChunkRead(bytes_buffered, eof: true)` where `bytes_buffered > 0` | `in_flight_length = event.bytes_buffered`
`status = :finalizing_sending_upload` | `Finalizing \| Sending with upload` | `Instruction::SendChunk.new(url: state.upload_url, offset: state.offset, length: event.bytes_buffered, finalize: true)` | +| **`Transmission \| Reading from stream`** | `:chunk_read_eof_empty` | `Event::ChunkRead(bytes_buffered: 0, eof: true)` | `in_flight_length = 0`
`status = :finalizing_sending_finalize` | `Finalizing \| Sending finalize` | `Instruction::SendFinalize.new(url: state.upload_url)` | +| **`Transmission \| Sending`** | `:response_active` | `Event::HttpResponse(200, headers, _)` with `Status: active` | `offset = state.offset + state.in_flight_length`
`in_flight_length = 0`
`status = :transmission_reading` | `Transmission \| Reading from stream` | `Instruction::NotifyProgress.new(bytes_uploaded: state.offset, total_bytes: config.upload_size)`
`Instruction::FillBuffer.new(target_bytesize: state.chunk_size)` | +| **`Transmission \| Sending`** | `:response_cat2` | `Event::HttpResponse(status: 409\|416, ...)` or missing status header | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | +| **`Transmission \| Sending`** | `:request_retries_exhausted` / `:request_connection_failed` | `Event::RequestFailed(kind:, ...)` | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | +| **`Transmission \| Sending`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, _)` with `Status: final` | `in_flight_length = 0`
`status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | +| **`Transmission \| Sending`** | `:response_fatal_bad_response` | `Event::HttpResponse(401/403/404, ...)` | `in_flight_length = 0`
`last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | +| **`Finalizing \| Sending with upload`** | `:response_final` | `Event::HttpResponse(200, headers, body)` with `Status: final` | `offset = state.offset + state.in_flight_length`
`in_flight_length = 0`
`status = :success` | `Success` | `Instruction::NotifyProgress.new(bytes_uploaded: state.offset, total_bytes: state.offset)`
`Instruction::TerminateSuccess.new(response: event)` | +| **`Finalizing \| Sending with upload`** | `:response_cat2` | `Event::HttpResponse(status: 409\|416, ...)` or missing status header | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | +| **`Finalizing \| Sending with upload`** | `:request_retries_exhausted` / `:request_connection_failed` | `Event::RequestFailed(kind:, ...)` | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | +| **`Finalizing \| Sending with upload`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, body)` with `Status: final` | `in_flight_length = 0`
`status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | +| **`Finalizing \| Sending with upload`** | `:response_fatal_bad_response` | `Event::HttpResponse(401/403/404, ...)` | `in_flight_length = 0`
`last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | +| **`Finalizing \| Sending finalize`** | `:response_final` | `Event::HttpResponse(200, headers, body)` with `Status: final` | `status = :success` | `Success` | `Instruction::TerminateSuccess.new(response: event)` | +| **`Finalizing \| Sending finalize`** | `:response_cat2` | `Event::HttpResponse(status: 409\|416, ...)` or missing status header | `status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | +| **`Finalizing \| Sending finalize`** | `:request_retries_exhausted` / `:request_connection_failed` | `Event::RequestFailed(kind:, ...)` | `status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | +| **`Finalizing \| Sending finalize`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, body)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | +| **`Finalizing \| Sending finalize`** | `:response_fatal_bad_response` | `Event::HttpResponse(401/403/404, ...)` | `last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | +| **`Recovery`** | `:response_active` | `Event::HttpResponse(200, headers, _)` with `Status: active` | `offset = headers['X-Goog-Upload-Size-Received'].to_i`
`in_flight_length = 0`
`status = :transmission_reading` | `Transmission \| Reading from stream` | `Instruction::RealignBuffer.new(server_offset: state.offset)`
`Instruction::FillBuffer.new(target_bytesize: state.chunk_size)` | +| **`Recovery`** | `:response_final` | `Event::HttpResponse(200, headers, body)` with `Status: final` | `in_flight_length = 0`
`status = :success` | `Success` | `Instruction::TerminateSuccess.new(response: event)` | +| **`Recovery`** | `:response_cat2` | `Event::HttpResponse(409/416/missing header)` | `status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | +| **`Recovery`** | `:request_retries_exhausted` / `:request_connection_failed` | `Event::RequestFailed(kind:, ...)` | `last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | +| **`Recovery`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, body)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | +| **`Recovery`** | `:response_fatal_bad_response` | `Event::HttpResponse(401/403/404, ...)` | `last_error = source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | +| **Any Non-Terminal** | `:user_cancel` | `Event::Cancel` | `status = :cancelling` | `Cancelling` | `Instruction::SendCancel.new(url: state.upload_url)` | +| **`Cancelling`** | `:response_cancelled` | `Event::HttpResponse(200, headers, _)` with `Status: cancelled` | `status = :cancelled` | `Cancelled` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadCancelledError.new)` | +| **`Cancelling`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, _)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(event.body))` | +| **`Cancelling`** | `:request_retries_exhausted` / `:request_connection_failed` / `:response_fatal_bad_response` | `Event::RequestFailed` or HTTP failure | `status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | +| **Any Non-Terminal** | `:global_deadline_exceeded` | `Event::GlobalDeadlineExceeded` | `last_error = Gapic::Common::DeadlineExceededError.new`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | + +### 4.3 State Transition Graph + +```mermaid +stateDiagram-v2 + [*] --> Initializing + Initializing --> Starting : Event::StartUpload + Starting --> Transmission_Reading : Event::HttpResponse(200, active) + + state Transmission { + Transmission_Reading --> Transmission_Sending : Event::ChunkRead(eof: false) + Transmission_Sending --> Transmission_Reading : Event::HttpResponse(200, active) + } + + Transmission_Reading --> Finalizing_Sending_Upload : Event::ChunkRead(eof: true, buffered > 0) + Transmission_Reading --> Finalizing_Sending_Finalize : Event::ChunkRead(eof: true, buffered == 0) + + state Finalizing { + Finalizing_Sending_Upload --> Success : Event::HttpResponse(200, final) + Finalizing_Sending_Finalize --> Success : Event::HttpResponse(200, final) + } + + Transmission_Sending --> Recovery : Event::HttpResponse(recoverable) / Event::RequestFailed + Finalizing_Sending_Upload --> Recovery : Event::HttpResponse(recoverable) / Event::RequestFailed + Finalizing_Sending_Finalize --> Recovery : Event::HttpResponse(recoverable) / Event::RequestFailed + + Recovery --> Transmission_Reading : Event::HttpResponse(200, active, server_offset) + Recovery --> Success : Event::HttpResponse(200, final) + + Starting --> Rejected : Event::HttpResponse(non-200, final) + Transmission_Sending --> Rejected : Event::HttpResponse(non-200, final) + Finalizing_Sending_Upload --> Rejected : Event::HttpResponse(non-200, final) + Finalizing_Sending_Finalize --> Rejected : Event::HttpResponse(non-200, final) + Recovery --> Rejected : Event::HttpResponse(non-200, final) + + Starting --> Error : Event::RequestFailed / 4xx / 5xx + Recovery --> Error : Event::RequestFailed + + Success --> [*] + Rejected --> [*] + Error --> [*] +``` + +--- + +## 5. Chunk Size Adjustment Rules + +Upon receiving `200 OK` from the `start` request, `Core` inspects the response headers for `X-Goog-Upload-Chunk-Granularity`. The effective chunk size (`effective_chunk_size`) stored in `State` is resolved using the following variable definitions and rules: + +### 5.1 Variable Definitions +* `DEFAULT_CHUNK_SIZE`: Default chunk size of `8_388_608` bytes (8 MB). +* `user_chunk_size`: Explicit chunk size specified in `CompleteUploadConfig.chunk_size` (or `nil` if unspecified). +* `chunk_granularity`: Required byte alignment modulus parsed from header `X-Goog-Upload-Chunk-Granularity` as an Integer (or `nil` if header is absent). +* `effective_chunk_size`: Final calculated byte size used by Driver for in-memory buffering and chunk transmission. + +### 5.2 Resolution Rules + +#### Rule 1: No Server Granularity Specified (`chunk_granularity` is nil or 0) +When the server does not specify a granularity requirement: +* If `user_chunk_size` is provided: `effective_chunk_size = user_chunk_size`. +* If `user_chunk_size` is omitted: `effective_chunk_size = DEFAULT_CHUNK_SIZE`. + +#### Rule 2: Default Chunk Size with Server Granularity (`user_chunk_size` is nil, `chunk_granularity > 0`) +When the user does not specify a chunk size, the default 8 MB chunk size is aligned down to the nearest multiple of `chunk_granularity`: +* `effective_chunk_size = DEFAULT_CHUNK_SIZE - (DEFAULT_CHUNK_SIZE % chunk_granularity)`. +* If `DEFAULT_CHUNK_SIZE < chunk_granularity`, `effective_chunk_size` is promoted to `chunk_granularity`. + +#### Rule 3: User Specified Chunk Size with Server Granularity (`user_chunk_size > 0`, `chunk_granularity > 0`) +When an explicit `user_chunk_size` is supplied alongside a server `chunk_granularity`: +* **Case 3A (Standard Alignment: `user_chunk_size >= chunk_granularity`)**: + * The user chunk size is aligned down to the nearest integer multiple of `chunk_granularity`: + * `effective_chunk_size = user_chunk_size - (user_chunk_size % chunk_granularity)`. + * If `user_chunk_size` is already a multiple of `chunk_granularity` (`user_chunk_size % chunk_granularity == 0`), `effective_chunk_size = user_chunk_size`. +* **Case 3B (User Size Below Granularity: `user_chunk_size < chunk_granularity`)**: + * If `user_chunk_size` is strictly less than `chunk_granularity`, downward alignment would produce `0` bytes (an invalid chunk size). + * To satisfy the server's mandatory granularity constraint, `effective_chunk_size` is promoted to `chunk_granularity`. + +### 5.3 Reference Implementation +```ruby +def self.resolve_chunk_size(user_chunk_size, chunk_granularity) + base_size = user_chunk_size || DEFAULT_CHUNK_SIZE + return base_size if chunk_granularity.nil? || chunk_granularity <= 0 + return chunk_granularity if base_size <= chunk_granularity + + base_size - (base_size % chunk_granularity) +end +``` + +--- + +## 6. Error Classification & Recovery Flows + +### 6.1 Error Categories +The implementation distinguishes three categories of network and protocol-level failures: + +* **Category 1: Transient Transport Failures** + * *Definition*: Standard TCP, network connection timeout, DNS, or server load-shedding errors that do not compromise the protocol session. + * *Examples*: `503 Service Unavailable`, `408 Request Timeout`, `429 Too Many Requests`, `Faraday::ConnectionFailed`, `Faraday::TimeoutError`. + * *Resolution*: The `Driver` intercepts these errors inside the physical execution wrapper and delegates directly to `Gapic::Common::RetryPolicy`. If retries succeed, `Core` receives `Event::HttpResponse`. If retries exhaust attempt/timeout limits, Driver emits `Event::RequestFailed(kind: :retries_exhausted, ...)`. +* **Category 2: Recoverable Protocol Failures** + * *Definition*: Responses indicating that the client's current offset is misaligned with the server, missing mandatory protocol headers, or transport failures that exhausted Category 1 retries during data transmission. + * *Examples*: `409 Conflict`, `416 Range Not Satisfiable`, missing `X-Goog-Upload-Status` header on completed response, or `Event::RequestFailed` during `Transmission` / `Finalizing`. + * *Resolution*: Core transitions to `Recovery` and emits `Instruction::SendQuery` to obtain `server_offset`. +* **Category 3: Terminal Failures** + * *Definition*: Irrecoverable errors where either the request is structurally invalid, unauthorized, unseekable rewind is needed, or the server has aborted the session. + * *Examples*: `400 Bad Request` (on `start`), `403 Forbidden`, `404 Not Found`, or any response where `X-Goog-Upload-Status` is `final` but returned a non-2xx status code (Rejection). + * *Resolution*: Core transitions to `:rejected` or `:error` and emits `Instruction::TerminateFailure`. + +### 6.2 Recovery and Buffer Alignment +When `Core` resolves a `query` response in the `Recovery` state, it updates `State.offset` (`protocol_state_offset`) to `server_offset` (extracted from `X-Goog-Upload-Size-Received`) and transitions to `Transmission | Reading from stream`. + +To realign the upload state, the `Driver` processes `Instruction::RealignBuffer(server_offset)` using its in-memory buffer and stream position tracking: +1. **Within-Buffer Alignment (`buffer_start_offset <= server_offset <= buffer_end_offset`)**: + * The Driver trims already-persisted bytes: `@buffer = @buffer.byteslice((server_offset - buffer_start_offset)..-1)`. + * The Driver updates `buffer_start_offset = server_offset`. + * Upon executing the accompanying `Instruction::FillBuffer(target_bytesize)`, the Driver reads `target_bytesize - @buffer.bytesize` bytes from `stream` to restore `@buffer` to full `chunk_size` before transmitting. +2. **Rewind Required (`server_offset < buffer_start_offset`)**: + * If `stream.respond_to?(:seek)`: the Driver seeks to `server_offset`, clears `@buffer = "".b`, and sets `buffer_start_offset = server_offset`. + * If `stream` is unseekable (e.g. Socket, Pipe, STDIN): the Driver raises terminal `UnseekableStreamError` (Category 3). +3. **Fast-Forward Required (`server_offset > buffer_end_offset`)**: + * The Driver clears `@buffer = "".b`. + * If `stream.respond_to?(:seek)`: seeks to `server_offset`. + * If unseekable: reads and discards `server_offset - current_stream_pos` bytes from `stream`. + * The Driver sets `buffer_start_offset = server_offset`. + +--- + +## 7. Observability Standards + +When injecting optional loggers (`logger: nil`), utilities must use block syntax to prevent string formatting overhead when debug levels are disabled: +```ruby +@logger&.debug do + "ResumableUpload::Driver: Transmitting chunk offset #{@core.state.offset} (effective size: #{@core.state.chunk_size})" +end +``` \ No newline at end of file diff --git a/gapic-common/design/reference-implementation.md b/gapic-common/design/reference-implementation.md new file mode 100644 index 0000000..1053730 --- /dev/null +++ b/gapic-common/design/reference-implementation.md @@ -0,0 +1,517 @@ +# Resumable Upload Protocol Reference Implementation + +This document provides the complete reference implementation code for the core components of the Resumable Upload Protocol in `gapic-common`: +- [1. Rules Module (`Gapic::Rest::ResumableUpload::Rules`)](#1-rules-module) +- [2. Core Class (`Gapic::Rest::ResumableUpload::Core`)](#2-core-class) +- [3. Driver Class (`Gapic::Rest::ResumableUpload::Driver`)](#3-driver-class) + +For system architecture, data models, buffer invariants, and state transition specifications, see the [Implementation Guide](implementation-guide.md). + +--- + +## 1. Rules Module + +```ruby +module Gapic + module Rest + module ResumableUpload + module Rules + CAT2_STATUS_CODES = [400, 408, 409, 412, 416, 429, 499].freeze + FATAL_STATUS_CODES = [401, 403, 404, 405, 410, 413, 415].freeze + + # Classifies incoming event into a canonical shape symbol. + # Pure function: takes ONLY event, zero state awareness. + # + # @param event [Object] Input event + # @return [Symbol] Canonical event shape + def self.shape_of(event) + case event + when Event::StartUpload + :start_upload + when Event::ChunkRead + if !event.eof + :chunk_read_full + elsif event.bytes_buffered.positive? + :chunk_read_eof_with_data + else + :chunk_read_eof_empty + end + when Event::Cancel + :user_cancel + when Event::GlobalDeadlineExceeded + :global_deadline_exceeded + when Event::RequestFailed + case event.kind + when :retries_exhausted then :request_retries_exhausted + when :connection_failed then :request_connection_failed + else :request_failed_unknown + end + when Event::HttpResponse + classify_http_response(event) + else + :unknown + end + end + + # Top-level transition router. Matches [state.status, shape]. + # + # @param state [State] Current state + # @param event [Object] Input event + # @param config [CompleteUploadConfig] Static configuration + # @return [Array>] Tuple of [next_state, instructions] + def self.step(state, event, config) + shape = shape_of(event) + + case [state.status, shape] + in [:initializing, :start_upload] + start_session(state, config) + in [:starting, :response_active] + begin_transmission(state, event, config) + in [:transmission_reading, :chunk_read_full] + send_chunk(state, event) + in [:transmission_reading, :chunk_read_eof_with_data] + send_upload_finalize(state, event) + in [:transmission_reading, :chunk_read_eof_empty] + send_finalize(state) + in [:transmission_sending, :response_active] + ack_chunk(state, config) + in [:transmission_sending | :finalizing_sending_upload | :finalizing_sending_finalize, :response_cat2 | :request_retries_exhausted | :request_connection_failed] + enter_recovery(state) + in [:finalizing_sending_upload, :response_final] + complete_upload_with_data(state, event) + in [:finalizing_sending_finalize, :response_final] + complete_upload_finalized(state, event) + in [:recovery, :response_active] + realign_from_recovery(state, event) + in [:recovery, :response_final] + complete_upload_finalized(state, event) + in [:recovery, :response_cat2] + retry_recovery(state) + in [:cancelling, :response_cancelled] + complete_cancellation(state) + in [:cancelling, :user_cancel] + [state, []] + in [_, :global_deadline_exceeded] + fail_with_deadline_exceeded(state) + in [_, :user_cancel] + cancel_session(state) + in [_, :response_rejected] + fail_with_rejected(state, event) + in [_, :response_cat2 | :response_fatal_bad_response] + fail_with_bad_response(state, event) + in [_, :request_retries_exhausted | :request_connection_failed | :request_failed_unknown] + fail_with_request_error(state, event) + else + raise InvalidTransitionError, "Invalid event shape #{shape} for state #{state.status}" + end + end + + def self.start_session(state, config) + next_state = state.with(status: :starting) + instructions = [ + Instruction::SendStart.new( + url: config.initial_url, + headers: config.initial_headers, + body: config.initial_body + ) + ] + [next_state, instructions] + end + + def self.begin_transmission(state, event, config) + granularity = event.headers["x-goog-upload-chunk-granularity"]&.to_i + chunk_size = resolve_chunk_size(config.chunk_size, granularity) + next_state = state.with( + status: :transmission_reading, + upload_url: event.headers["x-goog-upload-url"], + chunk_granularity: granularity, + chunk_size: chunk_size, + offset: 0, + in_flight_length: 0 + ) + [next_state, [Instruction::FillBuffer.new(target_bytesize: chunk_size)]] + end + + def self.send_chunk(state, event) + next_state = state.with( + status: :transmission_sending, + in_flight_length: event.bytes_buffered + ) + instructions = [ + Instruction::SendChunk.new( + url: state.upload_url, + offset: state.offset, + length: event.bytes_buffered, + finalize: false + ) + ] + [next_state, instructions] + end + + def self.send_upload_finalize(state, event) + next_state = state.with( + status: :finalizing_sending_upload, + in_flight_length: event.bytes_buffered + ) + instructions = [ + Instruction::SendChunk.new( + url: state.upload_url, + offset: state.offset, + length: event.bytes_buffered, + finalize: true + ) + ] + [next_state, instructions] + end + + def self.send_finalize(state) + next_state = state.with( + status: :finalizing_sending_finalize, + in_flight_length: 0 + ) + [next_state, [Instruction::SendFinalize.new(url: state.upload_url)]] + end + + def self.ack_chunk(state, config) + new_offset = state.offset + state.in_flight_length + next_state = state.with( + status: :transmission_reading, + offset: new_offset, + in_flight_length: 0 + ) + instructions = [ + Instruction::NotifyProgress.new(bytes_uploaded: new_offset, total_bytes: config.upload_size), + Instruction::FillBuffer.new(target_bytesize: state.chunk_size) + ] + [next_state, instructions] + end + + def self.enter_recovery(state) + next_state = state.with( + status: :recovery, + in_flight_length: 0 + ) + [next_state, [Instruction::SendQuery.new(url: state.upload_url)]] + end + + def self.retry_recovery(state) + next_state = state.with( + status: :recovery, + in_flight_length: 0 + ) + [next_state, [Instruction::SendQuery.new(url: state.upload_url)]] + end + + def self.complete_upload_with_data(state, event) + new_offset = state.offset + state.in_flight_length + next_state = state.with( + status: :success, + offset: new_offset, + in_flight_length: 0 + ) + instructions = [ + Instruction::NotifyProgress.new(bytes_uploaded: new_offset, total_bytes: new_offset), + Instruction::TerminateSuccess.new(response: event) + ] + [next_state, instructions] + end + + def self.complete_upload_finalized(state, event) + next_state = state.with( + status: :success, + in_flight_length: 0 + ) + [next_state, [Instruction::TerminateSuccess.new(response: event)]] + end + + def self.realign_from_recovery(state, event) + server_offset = event.headers["x-goog-upload-size-received"].to_i + next_state = state.with( + status: :transmission_reading, + offset: server_offset, + in_flight_length: 0 + ) + instructions = [ + Instruction::RealignBuffer.new(server_offset: server_offset), + Instruction::FillBuffer.new(target_bytesize: state.chunk_size) + ] + [next_state, instructions] + end + + def self.complete_cancellation(state) + next_state = state.with(status: :cancelled, in_flight_length: 0) + [next_state, [Instruction::TerminateFailure.new(error: Gapic::Common::UploadCancelledError.new)]] + end + + def self.cancel_session(state) + next_state = state.with(status: :cancelling) + [next_state, [Instruction::SendCancel.new(url: state.upload_url)]] + end + + def self.fail_with_deadline_exceeded(state) + next_state = state.with( + status: :error, + in_flight_length: 0, + last_error: Gapic::Common::DeadlineExceededError.new + ) + [next_state, [Instruction::TerminateFailure.new(error: next_state.last_error)]] + end + + def self.fail_with_rejected(state, event) + next_state = state.with( + status: :rejected, + in_flight_length: 0, + last_error: Gapic::Common::UploadRejectedError.new(event.body) + ) + [next_state, [Instruction::TerminateFailure.new(error: next_state.last_error)]] + end + + def self.fail_with_bad_response(state, event) + next_state = state.with( + status: :error, + in_flight_length: 0, + last_error: Gapic::Common::BadResponseError.new(event.status) + ) + [next_state, [Instruction::TerminateFailure.new(error: next_state.last_error)]] + end + + def self.fail_with_request_error(state, event) + next_state = state.with( + status: :error, + in_flight_length: 0, + last_error: event.source_error + ) + [next_state, [Instruction::TerminateFailure.new(error: event.source_error)]] + end + + private + + def self.classify_http_response(response) + status_header = response.headers["x-goog-upload-status"]&.downcase + + case status_header + when "active" + response.status == 200 ? :response_active : :response_cat2 + when "final" + response.status == 200 ? :response_final : :response_rejected + when "cancelled" + response.status == 200 ? :response_cancelled : :response_fatal_bad_response + when nil, "" + if response.status == 200 || CAT2_STATUS_CODES.include?(response.status) + :response_cat2 + else + :response_fatal_bad_response + end + else + :response_fatal_bad_response + end + end + + def self.resolve_chunk_size(user_chunk_size, chunk_granularity) + base_size = user_chunk_size || DEFAULT_CHUNK_SIZE + return base_size if chunk_granularity.nil? || chunk_granularity <= 0 + return chunk_granularity if base_size <= chunk_granularity + + base_size - (base_size % chunk_granularity) + end + end + end + end +end +``` + +--- + +## 2. Core Class + +```ruby +module Gapic + module Rest + module ResumableUpload + class Core + attr_reader :state + + # @param config [CompleteUploadConfig] + def initialize(config) + @config = config + @state = State.new( + status: :initializing, upload_url: nil, offset: 0, + chunk_size: config.chunk_size || 8_388_608, + chunk_granularity: nil, in_flight_length: 0, last_error: nil + ) + end + + # Dispatches event to Rules and updates state. + # + # @param event [Object] Input event + # @return [Array] Driver instructions + def dispatch(event) + next_state, instructions = Rules.step(@state, event, @config) + @state = next_state + instructions + end + end + end + end +end +``` + +--- + +## 3. Driver Class + +```ruby +module Gapic + module Rest + module ResumableUpload + class Driver + include Gapic::LoggingConcerns + + # @param client_stub [Gapic::Rest::ClientStub] + # @param config [CompleteUploadConfig] + # @param logger [Logger, nil] Optional logger + def initialize(client_stub:, config:, logger: nil) + @client_stub = client_stub + @config = config + @logger = logger + @core = Core.new(config) + @buffer = "".b + @buffer_start_offset = 0 + @control_plane_retry_policy = config.control_plane_retry_policy || self.class.default_control_plane_retry_policy + @data_plane_retry_policy = config.data_plane_retry_policy || self.class.default_data_plane_retry_policy + end + + # Default retry policy for control plane requests (start, query, cancel). + # Missing X-Goog-Upload-Status header is retriable (predicate returns true). + # + # @return [Gapic::Common::RetryPolicy] + def self.default_control_plane_retry_policy + Gapic::Common::RetryPolicy.new( + retry_codes: ["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"], + initial_delay: 1.0, + max_delay: 15.0, + multiplier: 1.3, + retry_predicate: lambda do |error_or_response| + if error_or_response.respond_to?(:headers) + status_hdr = error_or_response.headers["x-goog-upload-status"] + return true if status_hdr.nil? || status_hdr.empty? + end + nil + end + ) + end + + # Default retry policy for data plane requests (upload, finalize, upload_finalize). + # Missing X-Goog-Upload-Status header is unretriable (predicate returns false), + # causing Driver to yield Event::HttpResponse so Core initiates Recovery. + # + # @return [Gapic::Common::RetryPolicy] + def self.default_data_plane_retry_policy + Gapic::Common::RetryPolicy.new( + retry_codes: ["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"], + initial_delay: 1.0, + max_delay: 15.0, + multiplier: 1.3, + retry_predicate: lambda do |error_or_response| + if error_or_response.respond_to?(:headers) + status_hdr = error_or_response.headers["x-goog-upload-status"] + return false if status_hdr.nil? || status_hdr.empty? + end + nil + end + ) + end + + # Executes event loop until terminal state. + # + # @return [Faraday::Response] Final response + def run + pending_event = Event::StartUpload + + loop do + instructions = @core.dispatch(pending_event) + pending_event = nil + + if deadline_exceeded? && !terminal_instructions?(instructions) + instructions = @core.dispatch(Event::GlobalDeadlineExceeded) + end + + instructions.each do |instruction| + case instruction + when Instruction::NotifyProgress + execute_notify_progress(instruction) + when Instruction::RealignBuffer + execute_realign_buffer(instruction) + when Instruction::FillBuffer + pending_event = execute_fill_buffer(instruction) + when Instruction::SendStart + pending_event = execute_send_start(instruction) + when Instruction::SendChunk + pending_event = execute_send_chunk(instruction) + when Instruction::SendFinalize + pending_event = execute_send_finalize(instruction) + when Instruction::SendQuery + pending_event = execute_send_query(instruction) + when Instruction::SendCancel + pending_event = execute_send_cancel(instruction) + when Instruction::TerminateSuccess + return instruction.response + when Instruction::TerminateFailure + raise instruction.error + end + end + end + end + + private + + def deadline_exceeded? + return false unless @config.deadline + + Process.clock_gettime(Process::CLOCK_MONOTONIC) > @config.deadline + end + + def terminal_instructions?(instructions) + instructions.any? { |i| i.is_a?(Instruction::TerminateSuccess) || i.is_a?(Instruction::TerminateFailure) } + end + + # Synchronous side-effect: invokes user callback safely + def execute_notify_progress(instruction) + @config.on_progress&.call(instruction.bytes_uploaded, instruction.total_bytes) + rescue StandardError => e + @logger&.warn { "User progress callback raised exception: #{e.message}" } + end + + # Synchronous side-effect: adjusts in-memory buffer window and stream + def execute_realign_buffer(instruction) + # Implements Section 2.5 buffer alignment Cases 1, 2, and 3 + end + + # I/O operation: fills buffer from unseekable/seekable stream + # @return [Event::ChunkRead] + def execute_fill_buffer(instruction) + # Reads from stream until @buffer.bytesize reaches instruction.target_bytesize or stream hits EOF + end + + # Network operation: wraps start HTTP request in user_override_start_retry_policy or control_plane_retry_policy + # @return [Event::HttpResponse, Event::RequestFailed] + def execute_send_start(instruction) + policy = @config.user_override_start_retry_policy || @control_plane_retry_policy + # Executes POST initiation request via @client_stub with policy + # Returns Event::HttpResponse for any completed HTTP response (including 4xx/5xx). + # Returns Event::RequestFailed(kind:, message:, source_error:) on unhandled transport error or retry exhaustion. + end + + # Network operation: wraps HTTP request in data_plane_retry_policy + # @return [Event::HttpResponse, Event::RequestFailed] + def execute_send_chunk(instruction) + # Slices body from @buffer[instruction.offset - @buffer_start_offset, instruction.length] + # Executes POST request via @client_stub with @data_plane_retry_policy + # Returns Event::HttpResponse for any completed HTTP response (including 4xx/5xx). + # Returns Event::RequestFailed(kind:, message:, source_error:) on unhandled transport error or retry exhaustion. + end + end + end + end +end +``` diff --git a/gapic-common/lib/gapic/common/error.rb b/gapic-common/lib/gapic/common/error.rb index 26bd430..dc10dd3 100644 --- a/gapic-common/lib/gapic/common/error.rb +++ b/gapic-common/lib/gapic/common/error.rb @@ -1,4 +1,4 @@ -# Copyright 2022 Google LLC +# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -19,5 +19,53 @@ module Common # Gapic Common exception class class Error < StandardError end + + ## + # Raised when Scotty backend explicitly rejects the upload session + # (returns non-2xx with X-Goog-Upload-Status: final). + # + class UploadRejectedError < Error + # @return [String, nil] Response body from backend + attr_reader :response_body + + # @param response_body [String, nil] + def initialize response_body = nil + @response_body = response_body + super "Upload was rejected by server: #{response_body}" + end + end + + ## + # Raised when the upload session is cancelled. + # + class UploadCancelledError < Error + def initialize message = "Upload session was cancelled" + super message + end + end + + ## + # Raised when an unrecoverable HTTP response is received. + # + class BadResponseError < Error + # @return [Integer, nil] HTTP status code + attr_reader :status_code + + # @param status_code [Integer, nil] + # @param message [String, nil] + def initialize status_code = nil, message = nil + @status_code = status_code + super(message || "Received unexpected response with status code: #{status_code}") + end + end + + ## + # Raised when an upload exceeds its global monotonic deadline. + # + class DeadlineExceededError < Error + def initialize message = "Upload deadline exceeded" + super message + end + end end end diff --git a/gapic-common/lib/gapic/rest.rb b/gapic-common/lib/gapic/rest.rb index ae4ccbb..693aba2 100644 --- a/gapic-common/lib/gapic/rest.rb +++ b/gapic-common/lib/gapic/rest.rb @@ -28,6 +28,7 @@ require "gapic/rest/http_binding_override_configuration" require "gapic/rest/operation" require "gapic/rest/paged_enumerable" +require "gapic/rest/resumable_upload" require "gapic/rest/server_stream" require "gapic/rest/threaded_enumerator" require "gapic/rest/transport_operation" diff --git a/gapic-common/lib/gapic/rest/resumable_upload.rb b/gapic-common/lib/gapic/rest/resumable_upload.rb new file mode 100644 index 0000000..6100614 --- /dev/null +++ b/gapic-common/lib/gapic/rest/resumable_upload.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "gapic/rest/resumable_upload/errors" +require "gapic/rest/resumable_upload/data_types" +require "gapic/rest/resumable_upload/events" +require "gapic/rest/resumable_upload/instructions" +require "gapic/rest/resumable_upload/retry_policies" +require "gapic/rest/resumable_upload/rules" +require "gapic/rest/resumable_upload/core" +require "gapic/rest/resumable_upload/driver" + +module Gapic + module Rest + ## + # Resumable Upload Protocol implementation for REST transport. + # + module ResumableUpload + end + end +end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/core.rb b/gapic-common/lib/gapic/rest/resumable_upload/core.rb new file mode 100644 index 0000000..3c51037 --- /dev/null +++ b/gapic-common/lib/gapic/rest/resumable_upload/core.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "gapic/rest/resumable_upload/data_types" +require "gapic/rest/resumable_upload/rules" + +module Gapic + module Rest + module ResumableUpload + ## + # State machine container holding the immutable State snapshot. + # Contains zero protocol branching logic and zero side-effects. + # + class Core + # @return [State] Current immutable state snapshot + attr_reader :state + + # @param config [CompleteUploadConfig] + def initialize config + @config = config + @state = State.new( + status: :initializing, + upload_url: nil, + offset: 0, + chunk_size: config.chunk_size || Rules::DEFAULT_CHUNK_SIZE, + chunk_granularity: nil, + in_flight_length: 0, + last_error: nil + ) + end + + ## + # Dispatches event to Rules and updates internal state snapshot. + # + # @param event [Object] Input event + # @return [Array] Driver instructions + def dispatch event + next_state, instructions = Rules.step @state, event, @config + @state = next_state + instructions + end + end + end + end +end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb new file mode 100644 index 0000000..31a50e2 --- /dev/null +++ b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module Gapic + module Rest + module ResumableUpload + ## + # Immutable configuration for initiating and executing a resumable upload session. + # + CompleteUploadConfig = Data.define( + :initial_url, + :initial_body, + :initial_headers, + :stream, + :upload_size, + :chunk_size, + :content_type, + :deadline, + :control_plane_retry_policy, + :data_plane_retry_policy, + :user_override_start_retry_policy, + :on_progress + ) do + def initialize initial_url:, + stream:, + initial_body: nil, + initial_headers: {}, + upload_size: nil, + chunk_size: nil, + content_type: nil, + deadline: nil, + control_plane_retry_policy: nil, + data_plane_retry_policy: nil, + user_override_start_retry_policy: nil, + on_progress: nil + super( + initial_url: initial_url, + initial_body: initial_body, + initial_headers: initial_headers || {}, + stream: stream, + upload_size: upload_size, + chunk_size: chunk_size, + content_type: content_type, + deadline: deadline, + control_plane_retry_policy: control_plane_retry_policy, + data_plane_retry_policy: data_plane_retry_policy, + user_override_start_retry_policy: user_override_start_retry_policy, + on_progress: on_progress + ) + end + end + + ## + # Immutable state snapshot representing the current protocol progression. + # + State = Data.define( + :status, + :upload_url, + :offset, + :chunk_size, + :chunk_granularity, + :in_flight_length, + :last_error + ) do + def initialize status: :initializing, + upload_url: nil, + offset: 0, + chunk_size: 8_388_608, + chunk_granularity: nil, + in_flight_length: 0, + last_error: nil + super( + status: status, + upload_url: upload_url, + offset: offset, + chunk_size: chunk_size, + chunk_granularity: chunk_granularity, + in_flight_length: in_flight_length, + last_error: last_error + ) + end + end + end + end +end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb new file mode 100644 index 0000000..a597d63 --- /dev/null +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -0,0 +1,285 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "gapic/logging_concerns" +require "gapic/rest/error" +require "gapic/rest/resumable_upload/core" +require "gapic/rest/resumable_upload/data_types" +require "gapic/rest/resumable_upload/errors" +require "gapic/rest/resumable_upload/events" +require "gapic/rest/resumable_upload/instructions" +require "gapic/rest/resumable_upload/retry_policies" + +module Gapic + module Rest + module ResumableUpload + ## + # Synchronous execution engine for the Resumable Upload Protocol. + # Coordinates HTTP network operations, stream buffering, monotonic deadlines, + # and delegates state transitions to Core. + # + class Driver + include Gapic::LoggingConcerns + + # @return [Core] + attr_reader :core + + # @param client_stub [Gapic::Rest::ClientStub] + # @param config [CompleteUploadConfig] + # @param logger [Logger, nil] Optional logger + def initialize client_stub:, config:, logger: nil + @client_stub = client_stub + @config = config + @logger = logger + @core = Core.new config + @buffer = "".b + @buffer_start_offset = 0 + @control_plane_retry_policy = config.control_plane_retry_policy || + self.class.default_control_plane_retry_policy + @data_plane_retry_policy = config.data_plane_retry_policy || + self.class.default_data_plane_retry_policy + end + + ## + # Default retry policy for control plane requests (start, query, cancel). + # + # @return [Gapic::Common::RetryPolicy] + def self.default_control_plane_retry_policy + RetryPolicies.default_control_plane + end + + ## + # Default retry policy for data plane requests (upload, finalize). + # + # @return [Gapic::Common::RetryPolicy] + def self.default_data_plane_retry_policy + RetryPolicies.default_data_plane + end + + ## + # Executes event loop until terminal state. + # + # @return [Faraday::Response, Object] Final response + def run + pending_event = Event::StartUpload.new + + loop do + instructions = @core.dispatch pending_event + pending_event = nil + + if deadline_exceeded? && !terminal_instructions?(instructions) + instructions = @core.dispatch Event::GlobalDeadlineExceeded.new + end + + instructions.each do |instruction| + result = dispatch_instruction instruction + pending_event = result if pending_event_type? result + return result if instruction.is_a? Instruction::TerminateSuccess + end + end + end + + private + + def pending_event_type? obj + obj.is_a?(Event::ChunkRead) || obj.is_a?(Event::HttpResponse) || obj.is_a?(Event::RequestFailed) + end + + def dispatch_instruction instruction + case instruction + when Instruction::NotifyProgress then execute_notify_progress instruction + when Instruction::RealignBuffer then execute_realign_buffer instruction + when Instruction::FillBuffer then execute_fill_buffer instruction + when Instruction::SendStart then execute_send_start instruction + when Instruction::SendChunk then execute_send_chunk instruction + when Instruction::SendFinalize then execute_send_finalize instruction + when Instruction::SendQuery then execute_send_query instruction + when Instruction::SendCancel then execute_send_cancel instruction + when Instruction::TerminateSuccess then instruction.response + when Instruction::TerminateFailure then raise instruction.error + end + end + + def deadline_exceeded? + return false unless @config.deadline + + Process.clock_gettime(Process::CLOCK_MONOTONIC) > @config.deadline + end + + def terminal_instructions? instructions + instructions.any? do |i| + i.is_a?(Instruction::TerminateSuccess) || i.is_a?(Instruction::TerminateFailure) + end + end + + def execute_notify_progress instruction + @config.on_progress&.call instruction.bytes_uploaded, instruction.total_bytes + rescue StandardError => e + @logger&.warn { "User progress callback raised exception: #{e.message}" } + end + + def execute_realign_buffer instruction + server_offset = instruction.server_offset + buffer_start = @buffer_start_offset + buffer_end = @buffer_start_offset + @buffer.bytesize + + if server_offset >= buffer_start && server_offset <= buffer_end + realign_within_buffer server_offset + elsif server_offset < buffer_start + realign_rewind_stream server_offset + else + realign_fast_forward_stream server_offset, buffer_end + end + end + + def realign_within_buffer server_offset + slice_index = server_offset - @buffer_start_offset + @buffer = @buffer.byteslice(slice_index..-1) || "".b + @buffer_start_offset = server_offset + end + + def realign_rewind_stream server_offset + unless @config.stream.respond_to? :seek + raise UnseekableStreamError, + "Cannot rewind unseekable stream to offset #{server_offset} (buffered from #{@buffer_start_offset})" + end + + @config.stream.seek server_offset + @buffer = "".b + @buffer_start_offset = server_offset + end + + def realign_fast_forward_stream server_offset, buffer_end + @buffer = "".b + if @config.stream.respond_to? :seek + @config.stream.seek server_offset + else + needed_discard = server_offset - buffer_end + while needed_discard.positive? + chunk = @config.stream.read [needed_discard, 65_536].min + break if chunk.nil? || chunk.empty? + + needed_discard -= chunk.bytesize + end + end + @buffer_start_offset = server_offset + end + + def execute_fill_buffer instruction + target = instruction.target_bytesize + eof = false + + while @buffer.bytesize < target + bytes_needed = target - @buffer.bytesize + chunk = @config.stream.read bytes_needed + if chunk.nil? || chunk.empty? + eof = true + break + end + @buffer << chunk.b + end + + Event::ChunkRead.new bytes_buffered: @buffer.bytesize, eof: eof + end + + def execute_send_start instruction + policy = @config.user_override_start_retry_policy || @control_plane_retry_policy + headers = { "X-Goog-Upload-Protocol" => "resumable", "X-Goog-Upload-Command" => "start" } + headers["X-Goog-Upload-Header-Content-Type"] = @config.content_type if @config.content_type + headers["X-Goog-Upload-Header-Content-Length"] = @config.upload_size.to_s if @config.upload_size + headers = headers.merge(instruction.headers || {}) + + make_post_request instruction.url, headers: headers, body: instruction.body, retry_policy: policy + end + + def execute_send_chunk instruction + headers = { + "X-Goog-Upload-Command" => instruction.finalize ? "upload, finalize" : "upload", + "X-Goog-Upload-Offset" => instruction.offset.to_s, + "Content-Type" => @config.content_type || "application/octet-stream", + "Content-Length" => instruction.length.to_s + } + slice_index = instruction.offset - @buffer_start_offset + body = @buffer.byteslice slice_index, instruction.length + + make_post_request instruction.url, headers: headers, body: body, retry_policy: @data_plane_retry_policy + end + + def execute_send_finalize instruction + headers = { + "X-Goog-Upload-Command" => "finalize", + "X-Goog-Upload-Offset" => @core.state.offset.to_s, + "Content-Length" => "0" + } + make_post_request instruction.url, headers: headers, body: "", retry_policy: @data_plane_retry_policy + end + + def execute_send_query instruction + headers = { "X-Goog-Upload-Command" => "query", "Content-Length" => "0" } + make_post_request instruction.url, headers: headers, body: "", retry_policy: @control_plane_retry_policy + end + + def execute_send_cancel instruction + headers = { "X-Goog-Upload-Command" => "cancel", "Content-Length" => "0" } + make_post_request instruction.url, headers: headers, body: "", retry_policy: @control_plane_retry_policy + end + + def make_post_request url, headers:, body:, retry_policy: + options = { metadata: headers, retry_policy: retry_policy.dup.start! } + @logger&.debug do + "ResumableUpload::Driver: POST #{url} (offset: #{@core.state.offset}, " \ + "chunk_size: #{@core.state.chunk_size})" + end + response = @client_stub.make_post_request uri: url, body: body, params: {}, options: options + Event::HttpResponse.new status: response.status, headers: response.headers || {}, body: response.body + rescue StandardError => e + rescue_request_error e + end + + def rescue_request_error err + case err + when Gapic::Rest::DeadlineExceededError + Event::RequestFailed.new kind: :retries_exhausted, message: err.message, source_error: err + when Gapic::Rest::Error + if err.status_code + Event::HttpResponse.new status: err.status_code, headers: err.headers || {}, body: err.message + else + Event::RequestFailed.new kind: :connection_failed, message: err.message, source_error: err + end + when Faraday::Error + rescue_faraday_error err + else + Event::RequestFailed.new kind: :connection_failed, message: err.message, source_error: err + end + end + + def rescue_faraday_error err + if err.response && err.response[:status] + Event::HttpResponse.new( + status: err.response[:status], + headers: err.response[:headers] || {}, + body: err.response[:body] + ) + elsif err.is_a?(Faraday::TimeoutError) || err.is_a?(Faraday::ConnectionFailed) + Event::RequestFailed.new kind: :connection_failed, message: err.message, source_error: err + else + Event::RequestFailed.new kind: :retries_exhausted, message: err.message, source_error: err + end + end + end + end + end +end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb new file mode 100644 index 0000000..7d83181 --- /dev/null +++ b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "gapic/common/error" + +module Gapic + module Rest + module ResumableUpload + ## + # Raised when an invalid event is dispatched for the current protocol state. + # + class InvalidTransitionError < Gapic::Common::Error + end + + ## + # Raised when stream rewinding is required but the stream does not support seeking. + # + class UnseekableStreamError < Gapic::Common::Error + end + end + end +end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/events.rb b/gapic-common/lib/gapic/rest/resumable_upload/events.rb new file mode 100644 index 0000000..a70e4b3 --- /dev/null +++ b/gapic-common/lib/gapic/rest/resumable_upload/events.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module Gapic + module Rest + module ResumableUpload + ## + # Event vocabulary emitted by the Driver and dispatched to Core/Rules. + # + module Event + ## + # Signals the start of the upload session. + # + StartUpload = Data.define + + ## + # Signals that binary data was read from the stream into the Driver's buffer. + # + ChunkRead = Data.define :bytes_buffered, :eof do + def initialize bytes_buffered: 0, eof: false + super bytes_buffered: bytes_buffered, eof: eof + end + end + + ## + # Signals a completed HTTP exchange over the wire (status, headers, body). + # + HttpResponse = Data.define :status, :headers, :body do + def initialize status:, headers: {}, body: nil + super status: status, headers: headers || {}, body: body + end + end + + ## + # Signals an HTTP request failure (e.g. transport connection failure or retries exhausted). + # + RequestFailed = Data.define :kind, :message, :source_error do + def initialize kind:, message: nil, source_error: nil + super kind: kind, message: message, source_error: source_error + end + end + + ## + # Signals a caller-requested session cancellation. + # + Cancel = Data.define + + ## + # Signals that the global monotonic clock exceeded the configured deadline. + # + GlobalDeadlineExceeded = Data.define + end + end + end +end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/instructions.rb b/gapic-common/lib/gapic/rest/resumable_upload/instructions.rb new file mode 100644 index 0000000..5f0723a --- /dev/null +++ b/gapic-common/lib/gapic/rest/resumable_upload/instructions.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module Gapic + module Rest + module ResumableUpload + ## + # Instruction vocabulary emitted by Rules/Core to be executed by Driver. + # + module Instruction + ## + # Execute initiation request to establish upload session. + # + SendStart = Data.define :url, :headers, :body do + def initialize url:, headers: {}, body: nil + super url: url, headers: headers || {}, body: body + end + end + + ## + # Transmit buffered chunk starting at offset for length bytes. + # + SendChunk = Data.define :url, :offset, :length, :finalize do + def initialize url:, offset:, length:, finalize: false + super url: url, offset: offset, length: length, finalize: finalize + end + end + + ## + # Send standalone finalize command when all data bytes were already uploaded. + # + SendFinalize = Data.define :url do + def initialize url: + super url: url + end + end + + ## + # Query backend for current acknowledged offset. + # + SendQuery = Data.define :url do + def initialize url: + super url: url + end + end + + ## + # Cancel upload session on backend. + # + SendCancel = Data.define :url do + def initialize url: + super url: url + end + end + + ## + # Realign Driver in-memory buffer and stream position to match server_offset. + # + RealignBuffer = Data.define :server_offset do + def initialize server_offset: + super server_offset: server_offset + end + end + + ## + # Read from stream until in-memory buffer reaches target_bytesize or stream hits EOF. + # + FillBuffer = Data.define :target_bytesize do + def initialize target_bytesize: + super target_bytesize: target_bytesize + end + end + + ## + # Invoke user progress callback with bytes_uploaded and total_bytes. + # + NotifyProgress = Data.define :bytes_uploaded, :total_bytes do + def initialize bytes_uploaded:, total_bytes: nil + super bytes_uploaded: bytes_uploaded, total_bytes: total_bytes + end + end + + ## + # Upload finalized cleanly; return response. + # + TerminateSuccess = Data.define :response do + def initialize response: + super response: response + end + end + + ## + # Terminate upload with error. + # + TerminateFailure = Data.define :error do + def initialize error: + super error: error + end + end + end + end + end +end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb b/gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb new file mode 100644 index 0000000..f10d2ab --- /dev/null +++ b/gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "gapic/common/retry_policy" + +module Gapic + module Rest + module ResumableUpload + ## + # Default retry policy generators for control plane and data plane requests. + # + module RetryPolicies + ## + # Default retry policy for control plane requests (start, query, cancel). + # Missing X-Goog-Upload-Status header is retriable (predicate returns true). + # + # @return [Gapic::Common::RetryPolicy] + def self.default_control_plane + Gapic::Common::RetryPolicy.new( + retry_codes: ["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"], + initial_delay: 1.0, + max_delay: 15.0, + multiplier: 1.3, + retry_predicate: lambda do |error_or_response| + headers = extract_headers error_or_response + if headers + status_hdr = headers["x-goog-upload-status"] || headers["X-Goog-Upload-Status"] + return true if status_hdr.nil? || status_hdr.empty? + end + nil + end + ) + end + + ## + # Default retry policy for data plane requests (upload, finalize). + # Missing X-Goog-Upload-Status header is unretriable (predicate returns false). + # + # @return [Gapic::Common::RetryPolicy] + def self.default_data_plane + Gapic::Common::RetryPolicy.new( + retry_codes: ["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"], + initial_delay: 1.0, + max_delay: 15.0, + multiplier: 1.3, + retry_predicate: lambda do |error_or_response| + headers = extract_headers error_or_response + if headers + status_hdr = headers["x-goog-upload-status"] || headers["X-Goog-Upload-Status"] + return false if status_hdr.nil? || status_hdr.empty? + end + nil + end + ) + end + + ## + # Extracts headers hash from Faraday response or error object. + # + # @param error_or_response [Object] + # @return [Hash, nil] + def self.extract_headers error_or_response + if error_or_response.respond_to? :headers + error_or_response.headers + elsif error_or_response.respond_to? :response_headers + error_or_response.response_headers + elsif error_or_response.respond_to?(:response) && error_or_response.response.is_a?(Hash) + error_or_response.response[:headers] + end + end + end + end + end +end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb new file mode 100644 index 0000000..6309e96 --- /dev/null +++ b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb @@ -0,0 +1,392 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "gapic/common/error" +require "gapic/rest/resumable_upload/errors" +require "gapic/rest/resumable_upload/data_types" +require "gapic/rest/resumable_upload/events" +require "gapic/rest/resumable_upload/instructions" + +module Gapic + module Rest + module ResumableUpload + ## + # Pure functional transition engine for the Resumable Upload Protocol. + # Contains zero side-effects and zero persistent state. + # + # rubocop:disable Metrics/ModuleLength + module Rules + DEFAULT_CHUNK_SIZE = 8_388_608 # 8 MB + CAT2_STATUS_CODES = [400, 408, 409, 412, 416, 429, 499].freeze + FATAL_STATUS_CODES = [401, 403, 404, 405, 410, 413, 415].freeze + + ## + # Classifies incoming event into a canonical shape symbol. + # + # @param event [Object] Input event + # @return [Symbol] Canonical event shape + def self.shape_of event + case event + when Event::StartUpload, Event::StartUpload.singleton_class + :start_upload + when Event::ChunkRead + classify_chunk_read event + when Event::Cancel, Event::Cancel.singleton_class + :user_cancel + when Event::GlobalDeadlineExceeded, Event::GlobalDeadlineExceeded.singleton_class + :global_deadline_exceeded + when Event::RequestFailed + classify_request_failed event + when Event::HttpResponse + classify_http_response event + when Class + classify_event_class event + else + :unknown + end + end + + ## + # Top-level transition router. Matches [state.status, shape]. + # + # @param state [State] Current state + # @param event [Object] Input event + # @param config [CompleteUploadConfig] Static configuration + # @return [Array>] Tuple of [next_state, instructions] + # + # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength + def self.step state, event, config + shape = shape_of event + + case [state.status, shape] + in [:initializing, :start_upload] + start_session state, config + in [:starting, :response_active] + begin_transmission state, event, config + in [:transmission_reading, :chunk_read_full] + send_chunk state, event + in [:transmission_reading, :chunk_read_eof_with_data] + send_upload_finalize state, event + in [:transmission_reading, :chunk_read_eof_empty] + send_finalize state + in [:transmission_sending, :response_active] + ack_chunk state, config + in [:transmission_sending | :finalizing_sending_upload | :finalizing_sending_finalize, + :response_cat2 | :request_retries_exhausted | :request_connection_failed] + enter_recovery state + in [:finalizing_sending_upload, :response_final] + complete_upload_with_data state, event + in [:finalizing_sending_finalize | :recovery, :response_final] + complete_upload_finalized state, event + in [:recovery, :response_active] + realign_from_recovery state, event + in [:recovery, :response_cat2] + retry_recovery state + in [:cancelling, :response_cancelled] + complete_cancellation state + in [:cancelling, :user_cancel] + [state, []] + in [_, :global_deadline_exceeded] + fail_with_deadline_exceeded state + in [_, :user_cancel] + cancel_session state + in [_, :response_rejected] + fail_with_rejected state, event + in [_, :response_cat2 | :response_fatal_bad_response] + fail_with_bad_response state, event + in [_, :request_retries_exhausted | :request_connection_failed | :request_failed_unknown] + fail_with_request_error state, event + else + raise InvalidTransitionError, "Invalid event shape #{shape} for state #{state.status}" + end + end + # rubocop:enable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength + + def self.start_session state, config + next_state = state.with status: :starting + instructions = [ + Instruction::SendStart.new( + url: config.initial_url, + headers: config.initial_headers, + body: config.initial_body + ) + ] + [next_state, instructions] + end + + def self.begin_transmission state, event, config + granularity_str = header_value event.headers, "x-goog-upload-chunk-granularity" + granularity = granularity_str&.to_i + chunk_size = resolve_chunk_size config.chunk_size, granularity + upload_url = header_value event.headers, "x-goog-upload-url" + next_state = state.with( + status: :transmission_reading, + upload_url: upload_url, + chunk_granularity: granularity, + chunk_size: chunk_size, + offset: 0, + in_flight_length: 0 + ) + [next_state, [Instruction::FillBuffer.new(target_bytesize: chunk_size)]] + end + + def self.send_chunk state, event + next_state = state.with( + status: :transmission_sending, + in_flight_length: event.bytes_buffered + ) + instructions = [ + Instruction::SendChunk.new( + url: state.upload_url, + offset: state.offset, + length: event.bytes_buffered, + finalize: false + ) + ] + [next_state, instructions] + end + + def self.send_upload_finalize state, event + next_state = state.with( + status: :finalizing_sending_upload, + in_flight_length: event.bytes_buffered + ) + instructions = [ + Instruction::SendChunk.new( + url: state.upload_url, + offset: state.offset, + length: event.bytes_buffered, + finalize: true + ) + ] + [next_state, instructions] + end + + def self.send_finalize state + next_state = state.with( + status: :finalizing_sending_finalize, + in_flight_length: 0 + ) + [next_state, [Instruction::SendFinalize.new(url: state.upload_url)]] + end + + def self.ack_chunk state, config + new_offset = state.offset + state.in_flight_length + next_state = state.with( + status: :transmission_reading, + offset: new_offset, + in_flight_length: 0 + ) + instructions = [ + Instruction::NotifyProgress.new(bytes_uploaded: new_offset, total_bytes: config.upload_size), + Instruction::FillBuffer.new(target_bytesize: state.chunk_size) + ] + [next_state, instructions] + end + + def self.enter_recovery state + next_state = state.with( + status: :recovery, + in_flight_length: 0 + ) + [next_state, [Instruction::SendQuery.new(url: state.upload_url)]] + end + + def self.retry_recovery state + next_state = state.with( + status: :recovery, + in_flight_length: 0 + ) + [next_state, [Instruction::SendQuery.new(url: state.upload_url)]] + end + + def self.complete_upload_with_data state, event + new_offset = state.offset + state.in_flight_length + next_state = state.with( + status: :success, + offset: new_offset, + in_flight_length: 0 + ) + instructions = [ + Instruction::NotifyProgress.new(bytes_uploaded: new_offset, total_bytes: new_offset), + Instruction::TerminateSuccess.new(response: event) + ] + [next_state, instructions] + end + + def self.complete_upload_finalized state, event + next_state = state.with( + status: :success, + in_flight_length: 0 + ) + [next_state, [Instruction::TerminateSuccess.new(response: event)]] + end + + def self.realign_from_recovery state, event + server_offset_str = header_value event.headers, "x-goog-upload-size-received" + server_offset = server_offset_str.to_i + next_state = state.with( + status: :transmission_reading, + offset: server_offset, + in_flight_length: 0 + ) + instructions = [ + Instruction::RealignBuffer.new(server_offset: server_offset), + Instruction::FillBuffer.new(target_bytesize: state.chunk_size) + ] + [next_state, instructions] + end + + def self.complete_cancellation state + err = Gapic::Common::UploadCancelledError.new + next_state = state.with status: :cancelled, in_flight_length: 0, last_error: err + [next_state, [Instruction::TerminateFailure.new(error: err)]] + end + + def self.cancel_session state + next_state = state.with status: :cancelling + [next_state, [Instruction::SendCancel.new(url: state.upload_url)]] + end + + def self.fail_with_deadline_exceeded state + err = Gapic::Common::DeadlineExceededError.new + next_state = state.with( + status: :error, + in_flight_length: 0, + last_error: err + ) + [next_state, [Instruction::TerminateFailure.new(error: err)]] + end + + def self.fail_with_rejected state, event + err = Gapic::Common::UploadRejectedError.new event.body + next_state = state.with( + status: :rejected, + in_flight_length: 0, + last_error: err + ) + [next_state, [Instruction::TerminateFailure.new(error: err)]] + end + + def self.fail_with_bad_response state, event + err = Gapic::Common::BadResponseError.new event.status + next_state = state.with( + status: :error, + in_flight_length: 0, + last_error: err + ) + [next_state, [Instruction::TerminateFailure.new(error: err)]] + end + + def self.fail_with_request_error state, event + err = event.source_error || Gapic::Common::Error.new(event.message || "Request failed") + next_state = state.with( + status: :error, + in_flight_length: 0, + last_error: err + ) + [next_state, [Instruction::TerminateFailure.new(error: err)]] + end + + ## + # Resolves effective chunk size given user specification and backend granularity. + # + # @param user_chunk_size [Integer, nil] + # @param chunk_granularity [Integer, nil] + # @return [Integer] Effective chunk size in bytes + def self.resolve_chunk_size user_chunk_size, chunk_granularity + base_size = user_chunk_size || DEFAULT_CHUNK_SIZE + return base_size if chunk_granularity.nil? || chunk_granularity <= 0 + return chunk_granularity if base_size <= chunk_granularity + + base_size - (base_size % chunk_granularity) + end + + ## + # Classifies an HTTP response into a canonical response shape. + # + # @param response [Event::HttpResponse] + # @return [Symbol] + def self.classify_http_response response + status_header = header_value(response.headers, "x-goog-upload-status")&.downcase + + case status_header + when "active" + response.status == 200 ? :response_active : :response_cat2 + when "final" + response.status == 200 ? :response_final : :response_rejected + when "cancelled" + response.status == 200 ? :response_cancelled : :response_fatal_bad_response + when nil, "" + if response.status == 200 || CAT2_STATUS_CODES.include?(response.status) + :response_cat2 + else + :response_fatal_bad_response + end + else + :response_fatal_bad_response + end + end + + ## + # Case-insensitive header lookup helper. + # + # @param headers [Hash, Object] + # @param key [String] + # @return [String, nil] + def self.header_value headers, key + return nil unless headers.is_a? Hash + return headers[key] if headers.key? key + + target = key.downcase + _, val = headers.find { |k, _| k.to_s.downcase == target } + val + end + + def self.classify_chunk_read event + if !event.eof + :chunk_read_full + elsif event.bytes_buffered.positive? + :chunk_read_eof_with_data + else + :chunk_read_eof_empty + end + end + + def self.classify_request_failed event + case event.kind + when :retries_exhausted then :request_retries_exhausted + when :connection_failed then :request_connection_failed + else :request_failed_unknown + end + end + + def self.classify_event_class event_class + if event_class == Event::StartUpload + :start_upload + elsif event_class == Event::Cancel + :user_cancel + elsif event_class == Event::GlobalDeadlineExceeded + :global_deadline_exceeded + else + :unknown + end + end + end + # rubocop:enable Metrics/ModuleLength + end + end +end diff --git a/gapic-common/test/gapic/rest/resumable_upload/core_test.rb b/gapic-common/test/gapic/rest/resumable_upload/core_test.rb new file mode 100644 index 0000000..5c51093 --- /dev/null +++ b/gapic-common/test/gapic/rest/resumable_upload/core_test.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "test_helper" +require "gapic/rest/resumable_upload" +require "stringio" + +class CoreTest < Minitest::Test + include Gapic::Rest::ResumableUpload + + def setup + @config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("test content"), + upload_size: 2048, + chunk_size: 1024 + ) + @core = Core.new @config + end + + def test_initial_state + state = @core.state + assert_equal :initializing, state.status + assert_nil state.upload_url + assert_equal 0, state.offset + assert_equal 1024, state.chunk_size + assert_nil state.chunk_granularity + assert_equal 0, state.in_flight_length + assert_nil state.last_error + end + + def test_dispatch_updates_state_and_returns_instructions + instructions = @core.dispatch Event::StartUpload.new + assert_equal :starting, @core.state.status + assert_equal 1, instructions.size + assert_instance_of Instruction::SendStart, instructions.first + + resp = Event::HttpResponse.new( + status: 200, + headers: { + "X-Goog-Upload-URL" => "https://example.com/session/1", + "X-Goog-Upload-Chunk-Granularity" => "512", + "X-Goog-Upload-Status" => "active" + } + ) + instructions = @core.dispatch resp + assert_equal :transmission_reading, @core.state.status + assert_equal "https://example.com/session/1", @core.state.upload_url + assert_equal 512, @core.state.chunk_granularity + assert_equal 1024, @core.state.chunk_size + assert_equal 1, instructions.size + assert_instance_of Instruction::FillBuffer, instructions.first + assert_equal 1024, instructions.first.target_bytesize + end +end diff --git a/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb new file mode 100644 index 0000000..9634f16 --- /dev/null +++ b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "test_helper" +require "gapic/rest/resumable_upload" +require "stringio" + +class DataTypesTest < Minitest::Test + include Gapic::Rest::ResumableUpload + + def test_complete_upload_config_defaults + stream = StringIO.new "content" + config = CompleteUploadConfig.new( + initial_url: "https://example.com", + stream: stream + ) + + assert_equal "https://example.com", config.initial_url + assert_same stream, config.stream + assert_nil config.initial_body + assert_equal({}, config.initial_headers) + assert_nil config.upload_size + assert_nil config.chunk_size + assert_nil config.content_type + assert_nil config.deadline + assert_nil config.control_plane_retry_policy + assert_nil config.data_plane_retry_policy + assert_nil config.user_override_start_retry_policy + assert_nil config.on_progress + end + + def test_state_defaults_and_with + state = State.new + + assert_equal :initializing, state.status + assert_nil state.upload_url + assert_equal 0, state.offset + assert_equal 8_388_608, state.chunk_size + assert_nil state.chunk_granularity + assert_equal 0, state.in_flight_length + assert_nil state.last_error + + modified = state.with status: :starting, upload_url: "https://example.com/upload" + assert_equal :starting, modified.status + assert_equal "https://example.com/upload", modified.upload_url + assert_equal :initializing, state.status + end + + def test_event_instantiation + start_event = Event::StartUpload.new + assert_instance_of Event::StartUpload, start_event + + chunk = Event::ChunkRead.new bytes_buffered: 100, eof: true + assert_equal 100, chunk.bytes_buffered + assert chunk.eof + + http = Event::HttpResponse.new status: 200, headers: { "a" => "b" }, body: "body" + assert_equal 200, http.status + assert_equal({ "a" => "b" }, http.headers) + assert_equal "body", http.body + + req_fail = Event::RequestFailed.new kind: :connection_failed, message: "err" + assert_equal :connection_failed, req_fail.kind + assert_equal "err", req_fail.message + end + + def test_instruction_instantiation + start = Instruction::SendStart.new url: "https://example.com" + assert_equal "https://example.com", start.url + assert_equal({}, start.headers) + assert_nil start.body + + chunk = Instruction::SendChunk.new url: "https://example.com", offset: 0, length: 100 + assert_equal 0, chunk.offset + assert_equal 100, chunk.length + refute chunk.finalize + + realign = Instruction::RealignBuffer.new server_offset: 500 + assert_equal 500, realign.server_offset + end +end diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb new file mode 100644 index 0000000..8ea171c --- /dev/null +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb @@ -0,0 +1,361 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "test_helper" +require "gapic/rest/resumable_upload" +require "stringio" + +class RulesTest < Minitest::Test + include Gapic::Rest::ResumableUpload + + def setup + @config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("test content"), + upload_size: 1024, + chunk_size: 512 + ) + end + + def test_shape_of_start_upload + assert_equal :start_upload, Rules.shape_of(Event::StartUpload.new) + assert_equal :start_upload, Rules.shape_of(Event::StartUpload) + end + + def test_shape_of_chunk_read + full = Event::ChunkRead.new bytes_buffered: 512, eof: false + assert_equal :chunk_read_full, Rules.shape_of(full) + + eof_data = Event::ChunkRead.new bytes_buffered: 256, eof: true + assert_equal :chunk_read_eof_with_data, Rules.shape_of(eof_data) + + eof_empty = Event::ChunkRead.new bytes_buffered: 0, eof: true + assert_equal :chunk_read_eof_empty, Rules.shape_of(eof_empty) + end + + def test_shape_of_cancel + assert_equal :user_cancel, Rules.shape_of(Event::Cancel.new) + assert_equal :user_cancel, Rules.shape_of(Event::Cancel) + end + + def test_shape_of_global_deadline_exceeded + assert_equal :global_deadline_exceeded, Rules.shape_of(Event::GlobalDeadlineExceeded.new) + assert_equal :global_deadline_exceeded, Rules.shape_of(Event::GlobalDeadlineExceeded) + end + + def test_shape_of_request_failed + retries = Event::RequestFailed.new kind: :retries_exhausted + assert_equal :request_retries_exhausted, Rules.shape_of(retries) + + conn = Event::RequestFailed.new kind: :connection_failed + assert_equal :request_connection_failed, Rules.shape_of(conn) + + unknown = Event::RequestFailed.new kind: :other + assert_equal :request_failed_unknown, Rules.shape_of(unknown) + end + + def test_shape_of_http_response_active + resp_200 = Event::HttpResponse.new status: 200, headers: { "X-Goog-Upload-Status" => "active" } + assert_equal :response_active, Rules.shape_of(resp_200) + + resp_500 = Event::HttpResponse.new status: 500, headers: { "x-goog-upload-status" => "active" } + assert_equal :response_cat2, Rules.shape_of(resp_500) + end + + def test_shape_of_http_response_final + resp_200 = Event::HttpResponse.new status: 200, headers: { "X-Goog-Upload-Status" => "final" } + assert_equal :response_final, Rules.shape_of(resp_200) + + resp_400 = Event::HttpResponse.new status: 400, headers: { "x-goog-upload-status" => "final" } + assert_equal :response_rejected, Rules.shape_of(resp_400) + end + + def test_shape_of_http_response_cancelled + resp_200 = Event::HttpResponse.new status: 200, headers: { "X-Goog-Upload-Status" => "cancelled" } + assert_equal :response_cancelled, Rules.shape_of(resp_200) + + resp_500 = Event::HttpResponse.new status: 500, headers: { "x-goog-upload-status" => "cancelled" } + assert_equal :response_fatal_bad_response, Rules.shape_of(resp_500) + end + + def test_shape_of_http_response_missing_header + [200, 400, 408, 409, 412, 416, 429, 499].each do |code| + resp = Event::HttpResponse.new status: code, headers: {} + assert_equal :response_cat2, Rules.shape_of(resp), "Status #{code} with missing header should be :response_cat2" + end + + [401, 403, 404, 405, 410, 413, 415, 500, 503].each do |code| + resp = Event::HttpResponse.new status: code, headers: {} + assert_equal :response_fatal_bad_response, Rules.shape_of(resp), + "Status #{code} with missing header should be :response_fatal_bad_response" + end + end + + # Section 5: Chunk Size Adjustment Rules + def test_resolve_chunk_size_no_granularity + assert_equal 8_388_608, Rules.resolve_chunk_size(nil, nil) + assert_equal 8_388_608, Rules.resolve_chunk_size(nil, 0) + assert_equal 4_000_000, Rules.resolve_chunk_size(4_000_000, nil) + assert_equal 4_000_000, Rules.resolve_chunk_size(4_000_000, 0) + end + + def test_resolve_chunk_size_default_with_granularity + # 8_388_608 % 256_000 = 196_608 -> 8_388_608 - 196_608 = 8_192_000 + assert_equal 8_192_000, Rules.resolve_chunk_size(nil, 256_000) + # If DEFAULT_CHUNK_SIZE < granularity, promote to granularity + assert_equal 16_000_000, Rules.resolve_chunk_size(nil, 16_000_000) + end + + def test_resolve_chunk_size_user_specified_with_granularity + # Case 3A: user_chunk_size >= granularity + assert_equal 512_000, Rules.resolve_chunk_size(512_000, 256_000) + assert_equal 768_000, Rules.resolve_chunk_size(1_000_000, 256_000) + + # Case 3B: user_chunk_size < granularity (promoted to granularity) + assert_equal 256_000, Rules.resolve_chunk_size(100_000, 256_000) + end + + # Section 4: State Machine Transitions + def test_transition_initializing_to_starting + state = State.new status: :initializing + next_state, instructions = Rules.step state, Event::StartUpload.new, @config + + assert_equal :starting, next_state.status + assert_equal 1, instructions.size + assert_instance_of Instruction::SendStart, instructions.first + assert_equal "https://example.com/upload", instructions.first.url + end + + def test_transition_starting_to_transmission_reading + state = State.new status: :starting + headers = { + "X-Goog-Upload-URL" => "https://example.com/session123", + "X-Goog-Upload-Chunk-Granularity" => "256000", + "X-Goog-Upload-Status" => "active" + } + resp = Event::HttpResponse.new status: 200, headers: headers + next_state, instructions = Rules.step state, resp, @config + + assert_equal :transmission_reading, next_state.status + assert_equal "https://example.com/session123", next_state.upload_url + assert_equal 256_000, next_state.chunk_granularity + assert_equal 0, next_state.offset + assert_equal 1, instructions.size + assert_instance_of Instruction::FillBuffer, instructions.first + assert_equal next_state.chunk_size, instructions.first.target_bytesize + end + + def test_transition_starting_rejected + state = State.new status: :starting + resp = Event::HttpResponse.new status: 400, headers: { "x-goog-upload-status" => "final" }, body: "Invalid metadata" + next_state, instructions = Rules.step state, resp, @config + + assert_equal :rejected, next_state.status + assert_instance_of Gapic::Common::UploadRejectedError, next_state.last_error + assert_equal 1, instructions.size + assert_instance_of Instruction::TerminateFailure, instructions.first + end + + def test_transition_starting_fatal_error + state = State.new status: :starting + resp = Event::HttpResponse.new status: 401, headers: {} + next_state, instructions = Rules.step state, resp, @config + + assert_equal :error, next_state.status + assert_instance_of Gapic::Common::BadResponseError, next_state.last_error + assert_equal 401, next_state.last_error.status_code + assert_equal 1, instructions.size + assert_instance_of Instruction::TerminateFailure, instructions.first + end + + def test_transition_starting_request_failed + state = State.new status: :starting + failed = Event::RequestFailed.new kind: :connection_failed, message: "DNS resolution failed" + next_state, instructions = Rules.step state, failed, @config + + assert_equal :error, next_state.status + assert_equal 1, instructions.size + assert_instance_of Instruction::TerminateFailure, instructions.first + end + + def test_transition_transmission_reading_full_chunk + state = State.new status: :transmission_reading, upload_url: "https://example.com/session", offset: 0 + event = Event::ChunkRead.new bytes_buffered: 512, eof: false + next_state, instructions = Rules.step state, event, @config + + assert_equal :transmission_sending, next_state.status + assert_equal 512, next_state.in_flight_length + assert_equal 1, instructions.size + assert_instance_of Instruction::SendChunk, instructions.first + refute instructions.first.finalize + assert_equal 512, instructions.first.length + assert_equal 0, instructions.first.offset + end + + def test_transition_transmission_reading_eof_with_data + state = State.new status: :transmission_reading, upload_url: "https://example.com/session", offset: 512 + event = Event::ChunkRead.new bytes_buffered: 256, eof: true + next_state, instructions = Rules.step state, event, @config + + assert_equal :finalizing_sending_upload, next_state.status + assert_equal 256, next_state.in_flight_length + assert_equal 1, instructions.size + assert_instance_of Instruction::SendChunk, instructions.first + assert instructions.first.finalize + assert_equal 256, instructions.first.length + assert_equal 512, instructions.first.offset + end + + def test_transition_transmission_reading_eof_empty + state = State.new status: :transmission_reading, upload_url: "https://example.com/session", offset: 1024 + event = Event::ChunkRead.new bytes_buffered: 0, eof: true + next_state, instructions = Rules.step state, event, @config + + assert_equal :finalizing_sending_finalize, next_state.status + assert_equal 0, next_state.in_flight_length + assert_equal 1, instructions.size + assert_instance_of Instruction::SendFinalize, instructions.first + assert_equal "https://example.com/session", instructions.first.url + end + + def test_transition_transmission_sending_ack_chunk + state = State.new status: :transmission_sending, offset: 0, in_flight_length: 512, chunk_size: 512 + resp = Event::HttpResponse.new status: 200, headers: { "x-goog-upload-status" => "active" } + next_state, instructions = Rules.step state, resp, @config + + assert_equal :transmission_reading, next_state.status + assert_equal 512, next_state.offset + assert_equal 0, next_state.in_flight_length + assert_equal 2, instructions.size + assert_instance_of Instruction::NotifyProgress, instructions[0] + assert_equal 512, instructions[0].bytes_uploaded + assert_equal 1024, instructions[0].total_bytes + assert_instance_of Instruction::FillBuffer, instructions[1] + assert_equal 512, instructions[1].target_bytesize + end + + def test_transition_transmission_sending_cat2_triggers_recovery + state = State.new status: :transmission_sending, upload_url: "https://example.com/session", offset: 0, + in_flight_length: 512 + resp = Event::HttpResponse.new status: 409, headers: {} + next_state, instructions = Rules.step state, resp, @config + + assert_equal :recovery, next_state.status + assert_equal 0, next_state.in_flight_length + assert_equal 1, instructions.size + assert_instance_of Instruction::SendQuery, instructions.first + end + + def test_transition_finalizing_sending_upload_success + state = State.new status: :finalizing_sending_upload, offset: 512, in_flight_length: 512 + resp = Event::HttpResponse.new status: 200, headers: { "x-goog-upload-status" => "final" }, body: '{"done":true}' + next_state, instructions = Rules.step state, resp, @config + + assert_equal :success, next_state.status + assert_equal 1024, next_state.offset + assert_equal 0, next_state.in_flight_length + assert_equal 2, instructions.size + assert_instance_of Instruction::NotifyProgress, instructions[0] + assert_equal 1024, instructions[0].bytes_uploaded + assert_instance_of Instruction::TerminateSuccess, instructions[1] + end + + def test_transition_finalizing_sending_finalize_success + state = State.new status: :finalizing_sending_finalize, offset: 1024, in_flight_length: 0 + resp = Event::HttpResponse.new status: 200, headers: { "x-goog-upload-status" => "final" }, body: '{"done":true}' + next_state, instructions = Rules.step state, resp, @config + + assert_equal :success, next_state.status + assert_equal 1, instructions.size + assert_instance_of Instruction::TerminateSuccess, instructions.first + end + + def test_transition_recovery_active_realigns_buffer + state = State.new status: :recovery, upload_url: "https://example.com/session", offset: 0, chunk_size: 512 + headers = { + "x-goog-upload-status" => "active", + "x-goog-upload-size-received" => "768" + } + resp = Event::HttpResponse.new status: 200, headers: headers + next_state, instructions = Rules.step state, resp, @config + + assert_equal :transmission_reading, next_state.status + assert_equal 768, next_state.offset + assert_equal 2, instructions.size + assert_instance_of Instruction::RealignBuffer, instructions[0] + assert_equal 768, instructions[0].server_offset + assert_instance_of Instruction::FillBuffer, instructions[1] + end + + def test_transition_recovery_final_completes_upload + state = State.new status: :recovery, upload_url: "https://example.com/session", offset: 512 + resp = Event::HttpResponse.new status: 200, headers: { "x-goog-upload-status" => "final" } + next_state, instructions = Rules.step state, resp, @config + + assert_equal :success, next_state.status + assert_equal 1, instructions.size + assert_instance_of Instruction::TerminateSuccess, instructions.first + end + + def test_transition_recovery_cat2_retries_query + state = State.new status: :recovery, upload_url: "https://example.com/session" + resp = Event::HttpResponse.new status: 416, headers: {} + next_state, instructions = Rules.step state, resp, @config + + assert_equal :recovery, next_state.status + assert_equal 1, instructions.size + assert_instance_of Instruction::SendQuery, instructions.first + end + + def test_transition_cancellation_flow + state = State.new status: :transmission_sending, upload_url: "https://example.com/session" + next_state, instructions = Rules.step state, Event::Cancel.new, @config + + assert_equal :cancelling, next_state.status + assert_equal 1, instructions.size + assert_instance_of Instruction::SendCancel, instructions.first + + # Duplicate cancel in cancelling state does nothing + dup_state, dup_instructions = Rules.step next_state, Event::Cancel.new, @config + assert_equal :cancelling, dup_state.status + assert_empty dup_instructions + + # Cancellation confirmed + resp = Event::HttpResponse.new status: 200, headers: { "x-goog-upload-status" => "cancelled" } + final_state, final_instructions = Rules.step next_state, resp, @config + assert_equal :cancelled, final_state.status + assert_instance_of Gapic::Common::UploadCancelledError, final_state.last_error + assert_equal 1, final_instructions.size + assert_instance_of Instruction::TerminateFailure, final_instructions.first + end + + def test_transition_global_deadline_exceeded + state = State.new status: :transmission_sending + next_state, instructions = Rules.step state, Event::GlobalDeadlineExceeded.new, @config + + assert_equal :error, next_state.status + assert_instance_of Gapic::Common::DeadlineExceededError, next_state.last_error + assert_equal 1, instructions.size + assert_instance_of Instruction::TerminateFailure, instructions.first + end + + def test_invalid_transition_raises_error + state = State.new status: :initializing + assert_raises InvalidTransitionError do + Rules.step state, Event::ChunkRead.new(bytes_buffered: 512, eof: false), @config + end + end +end From b44af710f52b02c9c2485c89e973ec72246af257 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Wed, 2 Sep 2026 13:14:08 +0000 Subject: [PATCH 02/79] fix: missing fillbuffer instruction --- gapic-common/design/implementation-guide.md | 2 +- .../design/reference-implementation.md | 1 + .../lib/gapic/rest/resumable_upload/rules.rb | 1 + .../rest/resumable_upload/driver_test.rb | 98 +++++++++++++++++++ .../gapic/rest/resumable_upload/rules_test.rb | 8 +- 5 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 gapic-common/test/gapic/rest/resumable_upload/driver_test.rb diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index 550f790..c929885 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -207,7 +207,7 @@ Full implementation: [reference-implementation.md#3-driver-class](reference-impl | **`Transmission \| Reading from stream`** | `:chunk_read_full` | `Event::ChunkRead(bytes_buffered, eof: false)` | `in_flight_length = event.bytes_buffered`
`status = :transmission_sending` | `Transmission \| Sending` | `Instruction::SendChunk.new(url: state.upload_url, offset: state.offset, length: event.bytes_buffered, finalize: false)` | | **`Transmission \| Reading from stream`** | `:chunk_read_eof_with_data` | `Event::ChunkRead(bytes_buffered, eof: true)` where `bytes_buffered > 0` | `in_flight_length = event.bytes_buffered`
`status = :finalizing_sending_upload` | `Finalizing \| Sending with upload` | `Instruction::SendChunk.new(url: state.upload_url, offset: state.offset, length: event.bytes_buffered, finalize: true)` | | **`Transmission \| Reading from stream`** | `:chunk_read_eof_empty` | `Event::ChunkRead(bytes_buffered: 0, eof: true)` | `in_flight_length = 0`
`status = :finalizing_sending_finalize` | `Finalizing \| Sending finalize` | `Instruction::SendFinalize.new(url: state.upload_url)` | -| **`Transmission \| Sending`** | `:response_active` | `Event::HttpResponse(200, headers, _)` with `Status: active` | `offset = state.offset + state.in_flight_length`
`in_flight_length = 0`
`status = :transmission_reading` | `Transmission \| Reading from stream` | `Instruction::NotifyProgress.new(bytes_uploaded: state.offset, total_bytes: config.upload_size)`
`Instruction::FillBuffer.new(target_bytesize: state.chunk_size)` | +| **`Transmission \| Sending`** | `:response_active` | `Event::HttpResponse(200, headers, _)` with `Status: active` | `offset = state.offset + state.in_flight_length`
`in_flight_length = 0`
`status = :transmission_reading` | `Transmission \| Reading from stream` | `Instruction::NotifyProgress.new(bytes_uploaded: state.offset, total_bytes: config.upload_size)`
`Instruction::RealignBuffer.new(server_offset: state.offset)`
`Instruction::FillBuffer.new(target_bytesize: state.chunk_size)` | | **`Transmission \| Sending`** | `:response_cat2` | `Event::HttpResponse(status: 409\|416, ...)` or missing status header | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | | **`Transmission \| Sending`** | `:request_retries_exhausted` / `:request_connection_failed` | `Event::RequestFailed(kind:, ...)` | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | | **`Transmission \| Sending`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, _)` with `Status: final` | `in_flight_length = 0`
`status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | diff --git a/gapic-common/design/reference-implementation.md b/gapic-common/design/reference-implementation.md index 1053730..9c8a708 100644 --- a/gapic-common/design/reference-implementation.md +++ b/gapic-common/design/reference-implementation.md @@ -181,6 +181,7 @@ module Gapic ) instructions = [ Instruction::NotifyProgress.new(bytes_uploaded: new_offset, total_bytes: config.upload_size), + Instruction::RealignBuffer.new(server_offset: new_offset), Instruction::FillBuffer.new(target_bytesize: state.chunk_size) ] [next_state, instructions] diff --git a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb index 6309e96..3c36d0b 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb @@ -192,6 +192,7 @@ def self.ack_chunk state, config ) instructions = [ Instruction::NotifyProgress.new(bytes_uploaded: new_offset, total_bytes: config.upload_size), + Instruction::RealignBuffer.new(server_offset: new_offset), Instruction::FillBuffer.new(target_bytesize: state.chunk_size) ] [next_state, instructions] diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb new file mode 100644 index 0000000..548a7c6 --- /dev/null +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "test_helper" +require "gapic/rest/resumable_upload" +require "stringio" + +## +# Tests for ResumableUpload Driver synchronous execution engine. +# +class DriverTest < Minitest::Test + include Gapic::Rest::ResumableUpload + + FakeResponse = Struct.new :status, :headers, :body, keyword_init: true + + # Fake client stub recording calls and yielding scripted responses. + class FakeClientStub + attr_reader :requests + + def initialize responses + @responses = responses + @requests = [] + end + + def make_post_request uri:, body:, params:, options: + @requests << { uri: uri, body: body, params: params, options: options } + raise "Unexpected request: no scripted response left" if @responses.empty? + + @responses.shift + end + end + + def test_multi_chunk_upload_with_active_responses + stub = FakeClientStub.new build_scripted_responses + config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("0123456789"), + upload_size: 10, + chunk_size: 4 + ) + + driver = Driver.new client_stub: stub, config: config + result = driver.run + + assert_equal '{"done":true}', result.body + assert_equal 4, stub.requests.size + assert_start_request stub.requests[0] + assert_chunk_request stub.requests[1], offset: "0", length: "4", body: "0123", finalize: false + assert_chunk_request stub.requests[2], offset: "4", length: "4", body: "4567", finalize: false + assert_chunk_request stub.requests[3], offset: "8", length: "2", body: "89", finalize: true + end + + private + + def build_scripted_responses + [ + FakeResponse.new( + status: 200, + headers: { + "X-Goog-Upload-URL" => "https://example.com/session/1", + "X-Goog-Upload-Status" => "active" + }, + body: "" + ), + FakeResponse.new(status: 200, headers: { "X-Goog-Upload-Status" => "active" }, body: ""), + FakeResponse.new(status: 200, headers: { "X-Goog-Upload-Status" => "active" }, body: ""), + FakeResponse.new(status: 200, headers: { "X-Goog-Upload-Status" => "final" }, body: '{"done":true}') + ] + end + + def assert_start_request req + assert_equal "https://example.com/upload", req[:uri] + assert_equal "start", req[:options][:metadata]["X-Goog-Upload-Command"] + end + + def assert_chunk_request req, offset:, length:, body:, finalize: + expected_cmd = finalize ? "upload, finalize" : "upload" + metadata = req[:options][:metadata] + assert_equal "https://example.com/session/1", req[:uri] + assert_equal expected_cmd, metadata["X-Goog-Upload-Command"] + assert_equal offset, metadata["X-Goog-Upload-Offset"] + assert_equal length, metadata["Content-Length"] + assert_equal body, req[:body] + end +end diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb index 8ea171c..c1142f7 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb @@ -239,12 +239,14 @@ def test_transition_transmission_sending_ack_chunk assert_equal :transmission_reading, next_state.status assert_equal 512, next_state.offset assert_equal 0, next_state.in_flight_length - assert_equal 2, instructions.size + assert_equal 3, instructions.size assert_instance_of Instruction::NotifyProgress, instructions[0] assert_equal 512, instructions[0].bytes_uploaded assert_equal 1024, instructions[0].total_bytes - assert_instance_of Instruction::FillBuffer, instructions[1] - assert_equal 512, instructions[1].target_bytesize + assert_instance_of Instruction::RealignBuffer, instructions[1] + assert_equal 512, instructions[1].server_offset + assert_instance_of Instruction::FillBuffer, instructions[2] + assert_equal 512, instructions[2].target_bytesize end def test_transition_transmission_sending_cat2_triggers_recovery From cdb42c9a498952c3dfc4dd75436b7ce4b5767fa4 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Wed, 2 Sep 2026 13:34:25 +0000 Subject: [PATCH 03/79] fix: clarified recovery on missing headers --- .../design/reference-implementation.md | 6 +-- .../lib/gapic/rest/resumable_upload/rules.rb | 6 +-- .../rest/resumable_upload/driver_test.rb | 51 +++++++++++++++++++ .../gapic/rest/resumable_upload/rules_test.rb | 4 +- 4 files changed, 59 insertions(+), 8 deletions(-) diff --git a/gapic-common/design/reference-implementation.md b/gapic-common/design/reference-implementation.md index 9c8a708..9f16708 100644 --- a/gapic-common/design/reference-implementation.md +++ b/gapic-common/design/reference-implementation.md @@ -298,10 +298,10 @@ module Gapic when "cancelled" response.status == 200 ? :response_cancelled : :response_fatal_bad_response when nil, "" - if response.status == 200 || CAT2_STATUS_CODES.include?(response.status) - :response_cat2 - else + if FATAL_STATUS_CODES.include?(response.status) :response_fatal_bad_response + else + :response_cat2 end else :response_fatal_bad_response diff --git a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb index 3c36d0b..df20be3 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb @@ -332,10 +332,10 @@ def self.classify_http_response response when "cancelled" response.status == 200 ? :response_cancelled : :response_fatal_bad_response when nil, "" - if response.status == 200 || CAT2_STATUS_CODES.include?(response.status) - :response_cat2 - else + if FATAL_STATUS_CODES.include? response.status :response_fatal_bad_response + else + :response_cat2 end else :response_fatal_bad_response diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb index 548a7c6..90aea77 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb @@ -63,6 +63,28 @@ def test_multi_chunk_upload_with_active_responses assert_chunk_request stub.requests[3], offset: "8", length: "2", body: "89", finalize: true end + def test_upload_recovers_when_chunk_response_lacks_status_header + responses = build_recovery_responses + stub = FakeClientStub.new responses + config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("0123456789"), + upload_size: 10, + chunk_size: 4 + ) + + driver = Driver.new client_stub: stub, config: config + result = driver.run + + assert_equal '{"done":true}', result.body + assert_equal 5, stub.requests.size + assert_start_request stub.requests[0] + assert_chunk_request stub.requests[1], offset: "0", length: "4", body: "0123", finalize: false + assert_query_request stub.requests[2] + assert_chunk_request stub.requests[3], offset: "4", length: "4", body: "4567", finalize: false + assert_chunk_request stub.requests[4], offset: "8", length: "2", body: "89", finalize: true + end + private def build_scripted_responses @@ -81,11 +103,40 @@ def build_scripted_responses ] end + def build_recovery_responses + [ + FakeResponse.new( + status: 200, + headers: { + "X-Goog-Upload-URL" => "https://example.com/session/1", + "X-Goog-Upload-Status" => "active" + }, + body: "" + ), + FakeResponse.new(status: 503, headers: {}, body: "Service Unavailable"), + FakeResponse.new( + status: 200, + headers: { + "X-Goog-Upload-Status" => "active", + "X-Goog-Upload-Size-Received" => "4" + }, + body: "" + ), + FakeResponse.new(status: 200, headers: { "X-Goog-Upload-Status" => "active" }, body: ""), + FakeResponse.new(status: 200, headers: { "X-Goog-Upload-Status" => "final" }, body: '{"done":true}') + ] + end + def assert_start_request req assert_equal "https://example.com/upload", req[:uri] assert_equal "start", req[:options][:metadata]["X-Goog-Upload-Command"] end + def assert_query_request req + assert_equal "https://example.com/session/1", req[:uri] + assert_equal "query", req[:options][:metadata]["X-Goog-Upload-Command"] + end + def assert_chunk_request req, offset:, length:, body:, finalize: expected_cmd = finalize ? "upload, finalize" : "upload" metadata = req[:options][:metadata] diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb index c1142f7..6de9e35 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb @@ -92,12 +92,12 @@ def test_shape_of_http_response_cancelled end def test_shape_of_http_response_missing_header - [200, 400, 408, 409, 412, 416, 429, 499].each do |code| + [200, 400, 408, 409, 412, 416, 429, 499, 500, 502, 503, 504].each do |code| resp = Event::HttpResponse.new status: code, headers: {} assert_equal :response_cat2, Rules.shape_of(resp), "Status #{code} with missing header should be :response_cat2" end - [401, 403, 404, 405, 410, 413, 415, 500, 503].each do |code| + [401, 403, 404, 405, 410, 413, 415].each do |code| resp = Event::HttpResponse.new status: code, headers: {} assert_equal :response_fatal_bad_response, Rules.shape_of(resp), "Status #{code} with missing header should be :response_fatal_bad_response" From eb2f38f9a8f47c664b5b2d1ae2d1cbfed301075e Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Wed, 2 Sep 2026 13:39:33 +0000 Subject: [PATCH 04/79] fixdoc: errors description in the table --- gapic-common/design/implementation-guide.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index c929885..aacee3c 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -202,8 +202,8 @@ Full implementation: [reference-implementation.md#3-driver-class](reference-impl | **`Initializing`** | `:start_upload` | `Event::StartUpload` | `status = :starting` | `Starting` | `Instruction::SendStart.new(url: config.initial_url, headers: config.initial_headers, body: config.initial_body)` | | **`Starting`** | `:response_active` | `Event::HttpResponse(200, headers, _)` with `Status: active` | `upload_url = headers['X-Goog-Upload-URL']`
`chunk_granularity = headers['...-Granularity']&.to_i`
`chunk_size = resolve(config, chunk_granularity)`
`offset = 0`
`status = :transmission_reading` | `Transmission \| Reading from stream` | `Instruction::FillBuffer.new(target_bytesize: state.chunk_size)` | | **`Starting`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, _)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | -| **`Starting`** | `:response_cat2` / `:response_fatal_bad_response` | `Event::HttpResponse(4xx/5xx, _)` | `last_error = source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | -| **`Starting`** | `:request_retries_exhausted` / `:request_connection_failed` | `Event::RequestFailed(kind:, message:, source_error:)` | `last_error = source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | +| **`Starting`** | `:response_cat2` / `:response_fatal_bad_response` | `Event::HttpResponse(4xx/5xx, _)` | `last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | +| **`Starting`** | `:request_retries_exhausted` / `:request_connection_failed` | `Event::RequestFailed(kind:, message:, source_error:)` | `last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | | **`Transmission \| Reading from stream`** | `:chunk_read_full` | `Event::ChunkRead(bytes_buffered, eof: false)` | `in_flight_length = event.bytes_buffered`
`status = :transmission_sending` | `Transmission \| Sending` | `Instruction::SendChunk.new(url: state.upload_url, offset: state.offset, length: event.bytes_buffered, finalize: false)` | | **`Transmission \| Reading from stream`** | `:chunk_read_eof_with_data` | `Event::ChunkRead(bytes_buffered, eof: true)` where `bytes_buffered > 0` | `in_flight_length = event.bytes_buffered`
`status = :finalizing_sending_upload` | `Finalizing \| Sending with upload` | `Instruction::SendChunk.new(url: state.upload_url, offset: state.offset, length: event.bytes_buffered, finalize: true)` | | **`Transmission \| Reading from stream`** | `:chunk_read_eof_empty` | `Event::ChunkRead(bytes_buffered: 0, eof: true)` | `in_flight_length = 0`
`status = :finalizing_sending_finalize` | `Finalizing \| Sending finalize` | `Instruction::SendFinalize.new(url: state.upload_url)` | @@ -211,27 +211,27 @@ Full implementation: [reference-implementation.md#3-driver-class](reference-impl | **`Transmission \| Sending`** | `:response_cat2` | `Event::HttpResponse(status: 409\|416, ...)` or missing status header | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | | **`Transmission \| Sending`** | `:request_retries_exhausted` / `:request_connection_failed` | `Event::RequestFailed(kind:, ...)` | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | | **`Transmission \| Sending`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, _)` with `Status: final` | `in_flight_length = 0`
`status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | -| **`Transmission \| Sending`** | `:response_fatal_bad_response` | `Event::HttpResponse(401/403/404, ...)` | `in_flight_length = 0`
`last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | +| **`Transmission \| Sending`** | `:response_fatal_bad_response` | `Event::HttpResponse(401/403/404, ...)` | `in_flight_length = 0`
`last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | | **`Finalizing \| Sending with upload`** | `:response_final` | `Event::HttpResponse(200, headers, body)` with `Status: final` | `offset = state.offset + state.in_flight_length`
`in_flight_length = 0`
`status = :success` | `Success` | `Instruction::NotifyProgress.new(bytes_uploaded: state.offset, total_bytes: state.offset)`
`Instruction::TerminateSuccess.new(response: event)` | | **`Finalizing \| Sending with upload`** | `:response_cat2` | `Event::HttpResponse(status: 409\|416, ...)` or missing status header | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | | **`Finalizing \| Sending with upload`** | `:request_retries_exhausted` / `:request_connection_failed` | `Event::RequestFailed(kind:, ...)` | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | | **`Finalizing \| Sending with upload`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, body)` with `Status: final` | `in_flight_length = 0`
`status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | -| **`Finalizing \| Sending with upload`** | `:response_fatal_bad_response` | `Event::HttpResponse(401/403/404, ...)` | `in_flight_length = 0`
`last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | +| **`Finalizing \| Sending with upload`** | `:response_fatal_bad_response` | `Event::HttpResponse(401/403/404, ...)` | `in_flight_length = 0`
`last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | | **`Finalizing \| Sending finalize`** | `:response_final` | `Event::HttpResponse(200, headers, body)` with `Status: final` | `status = :success` | `Success` | `Instruction::TerminateSuccess.new(response: event)` | | **`Finalizing \| Sending finalize`** | `:response_cat2` | `Event::HttpResponse(status: 409\|416, ...)` or missing status header | `status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | | **`Finalizing \| Sending finalize`** | `:request_retries_exhausted` / `:request_connection_failed` | `Event::RequestFailed(kind:, ...)` | `status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | | **`Finalizing \| Sending finalize`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, body)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | -| **`Finalizing \| Sending finalize`** | `:response_fatal_bad_response` | `Event::HttpResponse(401/403/404, ...)` | `last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | +| **`Finalizing \| Sending finalize`** | `:response_fatal_bad_response` | `Event::HttpResponse(401/403/404, ...)` | `last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | | **`Recovery`** | `:response_active` | `Event::HttpResponse(200, headers, _)` with `Status: active` | `offset = headers['X-Goog-Upload-Size-Received'].to_i`
`in_flight_length = 0`
`status = :transmission_reading` | `Transmission \| Reading from stream` | `Instruction::RealignBuffer.new(server_offset: state.offset)`
`Instruction::FillBuffer.new(target_bytesize: state.chunk_size)` | | **`Recovery`** | `:response_final` | `Event::HttpResponse(200, headers, body)` with `Status: final` | `in_flight_length = 0`
`status = :success` | `Success` | `Instruction::TerminateSuccess.new(response: event)` | | **`Recovery`** | `:response_cat2` | `Event::HttpResponse(409/416/missing header)` | `status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | | **`Recovery`** | `:request_retries_exhausted` / `:request_connection_failed` | `Event::RequestFailed(kind:, ...)` | `last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | | **`Recovery`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, body)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | -| **`Recovery`** | `:response_fatal_bad_response` | `Event::HttpResponse(401/403/404, ...)` | `last_error = source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | +| **`Recovery`** | `:response_fatal_bad_response` | `Event::HttpResponse(401/403/404, ...)` | `last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | | **Any Non-Terminal** | `:user_cancel` | `Event::Cancel` | `status = :cancelling` | `Cancelling` | `Instruction::SendCancel.new(url: state.upload_url)` | | **`Cancelling`** | `:response_cancelled` | `Event::HttpResponse(200, headers, _)` with `Status: cancelled` | `status = :cancelled` | `Cancelled` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadCancelledError.new)` | | **`Cancelling`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, _)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(event.body))` | -| **`Cancelling`** | `:request_retries_exhausted` / `:request_connection_failed` / `:response_fatal_bad_response` | `Event::RequestFailed` or HTTP failure | `status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | +| **`Cancelling`** | `:request_retries_exhausted` / `:request_connection_failed` / `:response_fatal_bad_response` | `Event::RequestFailed` or HTTP failure | `last_error = error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | | **Any Non-Terminal** | `:global_deadline_exceeded` | `Event::GlobalDeadlineExceeded` | `last_error = Gapic::Common::DeadlineExceededError.new`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | ### 4.3 State Transition Graph From 04f0ccb78547fa7d66d41b676e3e42acb5098e01 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Wed, 2 Sep 2026 13:45:13 +0000 Subject: [PATCH 05/79] fix: nailed down Cat 2 classification --- gapic-common/design/implementation-guide.md | 76 +++++++++++++------ .../design/reference-implementation.md | 2 +- .../lib/gapic/rest/resumable_upload/rules.rb | 2 +- .../gapic/rest/resumable_upload/rules_test.rb | 27 +++++++ 4 files changed, 80 insertions(+), 27 deletions(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index aacee3c..b02d178 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -187,7 +187,7 @@ Full implementation: [reference-implementation.md#3-driver-class](reference-impl 2. **Offset Extraction**: On `query` responses, the acknowledged byte count is extracted from `X-Goog-Upload-Size-Received` as an integer (`server_offset`). 3. **Request Modification on 4xx**: Retrying Category 2 errors requires querying the backend for `server_offset` first. 4. **Standard Retry Configuration & Dual Policies**: The Driver manages two distinct retry policy configurations for Category 1 transient errors: - * **Control Plane Policy (`control_plane_retry_policy`)**: Applies to session control requests (`start`, `query`, `cancel`). Configured with standard retry codes (`["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"]`) and network errors (`[Faraday::ConnectionFailed, Faraday::TimeoutError, SocketError]`). Missing `X-Goog-Upload-Status` header is treated as **retriable** (predicate returns `true`) to smooth over transient gateway/proxy stripped headers. + * **Control Plane Policy (`control_plane_retry_policy`)**: Applies to session control requests (`start`, `query`, `cancel`). Configured with standard retry codes (`["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"]`) and network errors (`[Faraday::ConnectionFailed, Faraday::TimeoutError, SocketError]`). Missing `X-Goog-Upload-Status` header is treated as **retriable** (predicate returns `true`). * **User Override for Start (`user_override_start_retry_policy`)**: If supplied by caller in `CompleteUploadConfig`, this policy overrides `control_plane_retry_policy` exclusively for the `start` command. * **Data Plane Policy (`data_plane_retry_policy`)**: Applies to data transmission requests (`upload`, `upload,finalize`, and standalone `finalize`). Shares the identical standard retry configuration, but treats a missing `X-Goog-Upload-Status` header as **unretriable** (predicate returns `false`). This prevents blind chunk re-transmission and returns `Event::HttpResponse` immediately to `Core` so it can initiate Category 2 `Recovery`. @@ -202,32 +202,35 @@ Full implementation: [reference-implementation.md#3-driver-class](reference-impl | **`Initializing`** | `:start_upload` | `Event::StartUpload` | `status = :starting` | `Starting` | `Instruction::SendStart.new(url: config.initial_url, headers: config.initial_headers, body: config.initial_body)` | | **`Starting`** | `:response_active` | `Event::HttpResponse(200, headers, _)` with `Status: active` | `upload_url = headers['X-Goog-Upload-URL']`
`chunk_granularity = headers['...-Granularity']&.to_i`
`chunk_size = resolve(config, chunk_granularity)`
`offset = 0`
`status = :transmission_reading` | `Transmission \| Reading from stream` | `Instruction::FillBuffer.new(target_bytesize: state.chunk_size)` | | **`Starting`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, _)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | -| **`Starting`** | `:response_cat2` / `:response_fatal_bad_response` | `Event::HttpResponse(4xx/5xx, _)` | `last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | +| **`Starting`** | `:response_cat2` / `:response_fatal_bad_response` | `Event::HttpResponse` (Non-200; see Section 6.1) | `last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | | **`Starting`** | `:request_retries_exhausted` / `:request_connection_failed` | `Event::RequestFailed(kind:, message:, source_error:)` | `last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | | **`Transmission \| Reading from stream`** | `:chunk_read_full` | `Event::ChunkRead(bytes_buffered, eof: false)` | `in_flight_length = event.bytes_buffered`
`status = :transmission_sending` | `Transmission \| Sending` | `Instruction::SendChunk.new(url: state.upload_url, offset: state.offset, length: event.bytes_buffered, finalize: false)` | | **`Transmission \| Reading from stream`** | `:chunk_read_eof_with_data` | `Event::ChunkRead(bytes_buffered, eof: true)` where `bytes_buffered > 0` | `in_flight_length = event.bytes_buffered`
`status = :finalizing_sending_upload` | `Finalizing \| Sending with upload` | `Instruction::SendChunk.new(url: state.upload_url, offset: state.offset, length: event.bytes_buffered, finalize: true)` | | **`Transmission \| Reading from stream`** | `:chunk_read_eof_empty` | `Event::ChunkRead(bytes_buffered: 0, eof: true)` | `in_flight_length = 0`
`status = :finalizing_sending_finalize` | `Finalizing \| Sending finalize` | `Instruction::SendFinalize.new(url: state.upload_url)` | | **`Transmission \| Sending`** | `:response_active` | `Event::HttpResponse(200, headers, _)` with `Status: active` | `offset = state.offset + state.in_flight_length`
`in_flight_length = 0`
`status = :transmission_reading` | `Transmission \| Reading from stream` | `Instruction::NotifyProgress.new(bytes_uploaded: state.offset, total_bytes: config.upload_size)`
`Instruction::RealignBuffer.new(server_offset: state.offset)`
`Instruction::FillBuffer.new(target_bytesize: state.chunk_size)` | -| **`Transmission \| Sending`** | `:response_cat2` | `Event::HttpResponse(status: 409\|416, ...)` or missing status header | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | -| **`Transmission \| Sending`** | `:request_retries_exhausted` / `:request_connection_failed` | `Event::RequestFailed(kind:, ...)` | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | +| **`Transmission \| Sending`** | `:response_cat2` | `Event::HttpResponse` (Category 2; see Section 6.1.2) | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | +| **`Transmission \| Sending`** | `:request_connection_failed` | `Event::RequestFailed(kind: :connection_failed)` | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | +| **`Transmission \| Sending`** | `:request_retries_exhausted` | `Event::RequestFailed(kind: :retries_exhausted)` | `in_flight_length = 0`
`last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | | **`Transmission \| Sending`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, _)` with `Status: final` | `in_flight_length = 0`
`status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | -| **`Transmission \| Sending`** | `:response_fatal_bad_response` | `Event::HttpResponse(401/403/404, ...)` | `in_flight_length = 0`
`last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | +| **`Transmission \| Sending`** | `:response_fatal_bad_response` | `Event::HttpResponse` (Fatal status; see Section 6.1.3) | `in_flight_length = 0`
`last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | | **`Finalizing \| Sending with upload`** | `:response_final` | `Event::HttpResponse(200, headers, body)` with `Status: final` | `offset = state.offset + state.in_flight_length`
`in_flight_length = 0`
`status = :success` | `Success` | `Instruction::NotifyProgress.new(bytes_uploaded: state.offset, total_bytes: state.offset)`
`Instruction::TerminateSuccess.new(response: event)` | -| **`Finalizing \| Sending with upload`** | `:response_cat2` | `Event::HttpResponse(status: 409\|416, ...)` or missing status header | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | -| **`Finalizing \| Sending with upload`** | `:request_retries_exhausted` / `:request_connection_failed` | `Event::RequestFailed(kind:, ...)` | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | +| **`Finalizing \| Sending with upload`** | `:response_cat2` | `Event::HttpResponse` (Category 2; see Section 6.1.2) | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | +| **`Finalizing \| Sending with upload`** | `:request_connection_failed` | `Event::RequestFailed(kind: :connection_failed)` | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | +| **`Finalizing \| Sending with upload`** | `:request_retries_exhausted` | `Event::RequestFailed(kind: :retries_exhausted)` | `in_flight_length = 0`
`last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | | **`Finalizing \| Sending with upload`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, body)` with `Status: final` | `in_flight_length = 0`
`status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | -| **`Finalizing \| Sending with upload`** | `:response_fatal_bad_response` | `Event::HttpResponse(401/403/404, ...)` | `in_flight_length = 0`
`last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | +| **`Finalizing \| Sending with upload`** | `:response_fatal_bad_response` | `Event::HttpResponse` (Fatal status; see Section 6.1.3) | `in_flight_length = 0`
`last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | | **`Finalizing \| Sending finalize`** | `:response_final` | `Event::HttpResponse(200, headers, body)` with `Status: final` | `status = :success` | `Success` | `Instruction::TerminateSuccess.new(response: event)` | -| **`Finalizing \| Sending finalize`** | `:response_cat2` | `Event::HttpResponse(status: 409\|416, ...)` or missing status header | `status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | -| **`Finalizing \| Sending finalize`** | `:request_retries_exhausted` / `:request_connection_failed` | `Event::RequestFailed(kind:, ...)` | `status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | +| **`Finalizing \| Sending finalize`** | `:response_cat2` | `Event::HttpResponse` (Category 2; see Section 6.1.2) | `status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | +| **`Finalizing \| Sending finalize`** | `:request_connection_failed` | `Event::RequestFailed(kind: :connection_failed)` | `status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | +| **`Finalizing \| Sending finalize`** | `:request_retries_exhausted` | `Event::RequestFailed(kind: :retries_exhausted)` | `last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | | **`Finalizing \| Sending finalize`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, body)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | -| **`Finalizing \| Sending finalize`** | `:response_fatal_bad_response` | `Event::HttpResponse(401/403/404, ...)` | `last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | +| **`Finalizing \| Sending finalize`** | `:response_fatal_bad_response` | `Event::HttpResponse` (Fatal status; see Section 6.1.3) | `last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | | **`Recovery`** | `:response_active` | `Event::HttpResponse(200, headers, _)` with `Status: active` | `offset = headers['X-Goog-Upload-Size-Received'].to_i`
`in_flight_length = 0`
`status = :transmission_reading` | `Transmission \| Reading from stream` | `Instruction::RealignBuffer.new(server_offset: state.offset)`
`Instruction::FillBuffer.new(target_bytesize: state.chunk_size)` | | **`Recovery`** | `:response_final` | `Event::HttpResponse(200, headers, body)` with `Status: final` | `in_flight_length = 0`
`status = :success` | `Success` | `Instruction::TerminateSuccess.new(response: event)` | -| **`Recovery`** | `:response_cat2` | `Event::HttpResponse(409/416/missing header)` | `status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | +| **`Recovery`** | `:response_cat2` | `Event::HttpResponse` (Category 2; see Section 6.1.2) | `status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | | **`Recovery`** | `:request_retries_exhausted` / `:request_connection_failed` | `Event::RequestFailed(kind:, ...)` | `last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | | **`Recovery`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, body)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | -| **`Recovery`** | `:response_fatal_bad_response` | `Event::HttpResponse(401/403/404, ...)` | `last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | +| **`Recovery`** | `:response_fatal_bad_response` | `Event::HttpResponse` (Fatal status; see Section 6.1.3) | `last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | | **Any Non-Terminal** | `:user_cancel` | `Event::Cancel` | `status = :cancelling` | `Cancelling` | `Instruction::SendCancel.new(url: state.upload_url)` | | **`Cancelling`** | `:response_cancelled` | `Event::HttpResponse(200, headers, _)` with `Status: cancelled` | `status = :cancelled` | `Cancelled` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadCancelledError.new)` | | **`Cancelling`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, _)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(event.body))` | @@ -328,18 +331,41 @@ end ### 6.1 Error Categories The implementation distinguishes three categories of network and protocol-level failures: -* **Category 1: Transient Transport Failures** - * *Definition*: Standard TCP, network connection timeout, DNS, or server load-shedding errors that do not compromise the protocol session. - * *Examples*: `503 Service Unavailable`, `408 Request Timeout`, `429 Too Many Requests`, `Faraday::ConnectionFailed`, `Faraday::TimeoutError`. - * *Resolution*: The `Driver` intercepts these errors inside the physical execution wrapper and delegates directly to `Gapic::Common::RetryPolicy`. If retries succeed, `Core` receives `Event::HttpResponse`. If retries exhaust attempt/timeout limits, Driver emits `Event::RequestFailed(kind: :retries_exhausted, ...)`. -* **Category 2: Recoverable Protocol Failures** - * *Definition*: Responses indicating that the client's current offset is misaligned with the server, missing mandatory protocol headers, or transport failures that exhausted Category 1 retries during data transmission. - * *Examples*: `409 Conflict`, `416 Range Not Satisfiable`, missing `X-Goog-Upload-Status` header on completed response, or `Event::RequestFailed` during `Transmission` / `Finalizing`. - * *Resolution*: Core transitions to `Recovery` and emits `Instruction::SendQuery` to obtain `server_offset`. -* **Category 3: Terminal Failures** - * *Definition*: Irrecoverable errors where either the request is structurally invalid, unauthorized, unseekable rewind is needed, or the server has aborted the session. - * *Examples*: `400 Bad Request` (on `start`), `403 Forbidden`, `404 Not Found`, or any response where `X-Goog-Upload-Status` is `final` but returned a non-2xx status code (Rejection). - * *Resolution*: Core transitions to `:rejected` or `:error` and emits `Instruction::TerminateFailure`. +#### 6.1.1 Category 1: Transient Transport Failures +* **Definition**: Standard TCP, network connection timeout, DNS, or server load-shedding errors that do not compromise the protocol session. +* **Examples**: `503 Service Unavailable`, `408 Request Timeout`, `429 Too Many Requests`, `Faraday::ConnectionFailed`, `Faraday::TimeoutError`. +* **Resolution**: The `Driver` intercepts these errors inside the physical execution wrapper and delegates directly to `Gapic::Common::RetryPolicy`. If retries succeed, `Core` receives `Event::HttpResponse`. If retries exhaust attempt/timeout limits, Driver emits `Event::RequestFailed(kind: :retries_exhausted, ...)`. + +#### 6.1.2 Category 2: Recoverable Protocol Failures +* **Definition**: Responses indicating that the client's current offset is misaligned with the server, protocol headers are missing/stripped on completed requests, or unretried transport connection failures during data transmission. +* **Conditions Producing `:response_cat2`**: + 1. **Non-200 Active Responses**: Any response with `X-Goog-Upload-Status: active` where HTTP status is non-200. + 2. **Missing or Empty `X-Goog-Upload-Status` Header**: Any response lacking `X-Goog-Upload-Status` (or empty) whose HTTP status is **not** in `FATAL_STATUS_CODES` (Section 6.1.3). This includes HTTP 200, 5xx server/gateway errors (`500`, `502`, `503`, `504`), and recoverable client errors (`400`, `408`, `409`, `412`, `416`, `429`, `499`). + 3. **Unretried Data Plane Connection Drops**: `Event::RequestFailed(kind: :connection_failed)` occurring during `Transmission` or `Finalizing`. +* **Missing Header Handling & Dual Retry Policy Contract**: + * *Why Headers Go Missing*: Intermediate proxies, reverse-proxies, or Google Front End (GFE) edge proxies can strip Scotty response headers or return raw HTML/text error pages on failure. + * *Control Plane (`start`, `query`, `cancel`)*: Missing `X-Goog-Upload-Status` is treated as **retriable** by `control_plane_retry_policy` (retry predicate returns `true`). Driver retries transparently to smooth over transient gateway noise. If retries exhaust, `Starting` transitions to `:error` via `fail_with_request_error` or `fail_with_bad_response` (cannot recover a session before upload URL is obtained). + * *Data Plane (`upload`, `upload, finalize`, standalone `finalize`)*: Missing `X-Goog-Upload-Status` is treated as **unretriable** by `data_plane_retry_policy` (retry predicate returns `false`). The Driver immediately returns `Event::HttpResponse` to `Core` so it classifies as `:response_cat2` and initiates Category 2 `Recovery` via `Instruction::SendQuery` rather than blindly re-transmitting data. +* **Resolution**: Core transitions to `Recovery` and emits `Instruction::SendQuery.new(url: state.upload_url)` to obtain `server_offset`. + +#### 6.1.3 Category 3: Terminal Failures & Fatal Status Codes +* **Definition**: Irrecoverable errors where either the request is structurally invalid, unauthorized, transport retry limits are exhausted, unseekable rewind is needed, or the server has explicitly aborted/rejected the session. +* **Canonical Fatal Status Codes (`FATAL_STATUS_CODES`)**: + The following status codes indicate structural or authentication failures that cannot be resolved by querying the Scotty backend: + * `401 Unauthorized`: Authentication token is expired, invalid, or missing. + * `403 Forbidden`: Caller lacks required IAM permissions for the upload destination. + * `404 Not Found`: Session upload URL does not exist or has expired. + * `405 Method Not Allowed`: HTTP method is rejected by the server. + * `410 Gone`: Upload session has been permanently removed. + * `413 Payload Too Large`: Upload chunk or overall size exceeds server limit. + * `415 Unsupported Media Type`: Object content type is rejected. + Responses with these status codes are classified as `:response_fatal_bad_response` even if the `X-Goog-Upload-Status` header is absent. +* **Other Terminal Conditions**: + * **Retry Exhaustion**: Any `Event::RequestFailed(kind: :retries_exhausted)` occurring at any stage. When Category 1 transport retries are exhausted by the `RetryPolicy`, failure is immediate and terminal; it does not enter Category 2 `Recovery`. + * **Session Rejection**: Any response with `X-Goog-Upload-Status: final` and non-2xx status code (`:response_rejected` -> raises `Gapic::Common::UploadRejectedError`). + * **Initiation Failure**: Any 4xx/5xx or `Event::RequestFailed` during `Starting` (`:error` -> raises `Gapic::Common::BadResponseError` or source error). + * **Unseekable Stream Rewind**: Server offset rolled back behind retained buffer (`server_offset < buffer_start_offset`) on an unseekable stream (raises `Gapic::Rest::ResumableUpload::UnseekableStreamError`). +* **Resolution**: Core transitions to `:rejected` or `:error` and emits `Instruction::TerminateFailure`. ### 6.2 Recovery and Buffer Alignment When `Core` resolves a `query` response in the `Recovery` state, it updates `State.offset` (`protocol_state_offset`) to `server_offset` (extracted from `X-Goog-Upload-Size-Received`) and transitions to `Transmission | Reading from stream`. diff --git a/gapic-common/design/reference-implementation.md b/gapic-common/design/reference-implementation.md index 9f16708..d8c8ca8 100644 --- a/gapic-common/design/reference-implementation.md +++ b/gapic-common/design/reference-implementation.md @@ -75,7 +75,7 @@ module Gapic send_finalize(state) in [:transmission_sending, :response_active] ack_chunk(state, config) - in [:transmission_sending | :finalizing_sending_upload | :finalizing_sending_finalize, :response_cat2 | :request_retries_exhausted | :request_connection_failed] + in [:transmission_sending | :finalizing_sending_upload | :finalizing_sending_finalize, :response_cat2 | :request_connection_failed] enter_recovery(state) in [:finalizing_sending_upload, :response_final] complete_upload_with_data(state, event) diff --git a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb index df20be3..0231a8a 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb @@ -85,7 +85,7 @@ def self.step state, event, config in [:transmission_sending, :response_active] ack_chunk state, config in [:transmission_sending | :finalizing_sending_upload | :finalizing_sending_finalize, - :response_cat2 | :request_retries_exhausted | :request_connection_failed] + :response_cat2 | :request_connection_failed] enter_recovery state in [:finalizing_sending_upload, :response_final] complete_upload_with_data state, event diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb index 6de9e35..2254225 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb @@ -261,6 +261,33 @@ def test_transition_transmission_sending_cat2_triggers_recovery assert_instance_of Instruction::SendQuery, instructions.first end + def test_transition_transmission_sending_connection_failed_triggers_recovery + state = State.new status: :transmission_sending, upload_url: "https://example.com/session", offset: 0, + in_flight_length: 512 + req_failed = Event::RequestFailed.new kind: :connection_failed, message: "Network unreachable" + next_state, instructions = Rules.step state, req_failed, @config + + assert_equal :recovery, next_state.status + assert_equal 0, next_state.in_flight_length + assert_equal 1, instructions.size + assert_instance_of Instruction::SendQuery, instructions.first + end + + def test_transition_transmission_sending_retries_exhausted_terminates_failure + state = State.new status: :transmission_sending, upload_url: "https://example.com/session", offset: 0, + in_flight_length: 512 + err = StandardError.new "Retries exhausted" + req_failed = Event::RequestFailed.new kind: :retries_exhausted, message: "Retries exhausted", source_error: err + next_state, instructions = Rules.step state, req_failed, @config + + assert_equal :error, next_state.status + assert_equal 0, next_state.in_flight_length + assert_equal err, next_state.last_error + assert_equal 1, instructions.size + assert_instance_of Instruction::TerminateFailure, instructions.first + assert_equal err, instructions.first.error + end + def test_transition_finalizing_sending_upload_success state = State.new status: :finalizing_sending_upload, offset: 512, in_flight_length: 512 resp = Event::HttpResponse.new status: 200, headers: { "x-goog-upload-status" => "final" }, body: '{"done":true}' From 72a2846064cddcf2db577d3802841b5ddf72f4d0 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Wed, 2 Sep 2026 13:57:52 +0000 Subject: [PATCH 06/79] fixed: separated start retry policy from control plane policy --- gapic-common/design/implementation-guide.md | 17 ++- .../design/reference-implementation.md | 29 +++- .../gapic/rest/resumable_upload/data_types.rb | 3 + .../lib/gapic/rest/resumable_upload/driver.rb | 58 ++++++-- .../rest/resumable_upload/retry_policies.rb | 21 ++- .../rest/resumable_upload/data_types_test.rb | 4 + .../rest/resumable_upload/driver_test.rb | 129 ++++++++++++++++++ 7 files changed, 233 insertions(+), 28 deletions(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index b02d178..7490627 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -49,7 +49,8 @@ module Gapic :chunk_size, # [Integer, nil] Explicit chunk size in bytes :content_type, # [String] MIME type of uploaded media :deadline, # [Numeric, nil] Absolute monotonic deadline in seconds (Process.clock_gettime(Process::CLOCK_MONOTONIC)) - :control_plane_retry_policy, # [Gapic::Common::RetryPolicy, nil] Policy for start/query/cancel + :start_retry_policy, # [Gapic::Common::RetryPolicy, nil] Default policy for start command + :control_plane_retry_policy, # [Gapic::Common::RetryPolicy, nil] Policy for query/cancel commands :data_plane_retry_policy, # [Gapic::Common::RetryPolicy, nil] Policy for upload/finalize :user_override_start_retry_policy, # [Gapic::Common::RetryPolicy, nil] Optional user override for start command :on_progress # [Proc, nil] Callback: ->(bytes_uploaded, total_bytes) @@ -186,10 +187,11 @@ Full implementation: [reference-implementation.md#3-driver-class](reference-impl * `X-Goog-Upload-Header-Content-Length: config.upload_size` (if known upfront). 2. **Offset Extraction**: On `query` responses, the acknowledged byte count is extracted from `X-Goog-Upload-Size-Received` as an integer (`server_offset`). 3. **Request Modification on 4xx**: Retrying Category 2 errors requires querying the backend for `server_offset` first. -4. **Standard Retry Configuration & Dual Policies**: The Driver manages two distinct retry policy configurations for Category 1 transient errors: - * **Control Plane Policy (`control_plane_retry_policy`)**: Applies to session control requests (`start`, `query`, `cancel`). Configured with standard retry codes (`["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"]`) and network errors (`[Faraday::ConnectionFailed, Faraday::TimeoutError, SocketError]`). Missing `X-Goog-Upload-Status` header is treated as **retriable** (predicate returns `true`). - * **User Override for Start (`user_override_start_retry_policy`)**: If supplied by caller in `CompleteUploadConfig`, this policy overrides `control_plane_retry_policy` exclusively for the `start` command. - * **Data Plane Policy (`data_plane_retry_policy`)**: Applies to data transmission requests (`upload`, `upload,finalize`, and standalone `finalize`). Shares the identical standard retry configuration, but treats a missing `X-Goog-Upload-Status` header as **unretriable** (predicate returns `false`). This prevents blind chunk re-transmission and returns `Event::HttpResponse` immediately to `Core` so it can initiate Category 2 `Recovery`. +4. **Standard Retry Configuration & Distinct Policies**: The Driver manages distinct retry policy configurations for Category 1 transient errors: + * **Start Policy (`start_retry_policy`)**: Applies specifically to session initiation (`start`). Configured with standard retry codes (`["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"]`) and network errors (`[Faraday::ConnectionFailed, Faraday::TimeoutError, SocketError]`). A missing or empty `X-Goog-Upload-Status` header is treated as **retriable** (predicate returns `true`) across **any response code, including 200 OK**. + * **User Override for Start (`user_override_start_retry_policy`)**: If supplied by caller in `CompleteUploadConfig`, this policy overrides `start_retry_policy` exclusively for the `start` command. + * **Control Plane Policy (`control_plane_retry_policy`)**: Applies to session control requests (`query`, `cancel`). Configured with standard retry codes and network errors. It does **not** retry on a missing `X-Goog-Upload-Status` header, allowing `Core` to evaluate responses immediately. + * **Data Plane Policy (`data_plane_retry_policy`)**: Applies to data transmission requests (`upload`, `upload,finalize`, and standalone `finalize`). Shares the standard retry codes and network errors, but treats a missing `X-Goog-Upload-Status` header as **unretriable** (predicate returns `false`). This prevents blind chunk re-transmission and returns `Event::HttpResponse` immediately to `Core` so it can initiate Category 2 `Recovery`. ### 4.2 State Transition & Data Mutation Specification @@ -342,9 +344,10 @@ The implementation distinguishes three categories of network and protocol-level 1. **Non-200 Active Responses**: Any response with `X-Goog-Upload-Status: active` where HTTP status is non-200. 2. **Missing or Empty `X-Goog-Upload-Status` Header**: Any response lacking `X-Goog-Upload-Status` (or empty) whose HTTP status is **not** in `FATAL_STATUS_CODES` (Section 6.1.3). This includes HTTP 200, 5xx server/gateway errors (`500`, `502`, `503`, `504`), and recoverable client errors (`400`, `408`, `409`, `412`, `416`, `429`, `499`). 3. **Unretried Data Plane Connection Drops**: `Event::RequestFailed(kind: :connection_failed)` occurring during `Transmission` or `Finalizing`. -* **Missing Header Handling & Dual Retry Policy Contract**: +* **Missing Header Handling & Retry Policy Contract**: * *Why Headers Go Missing*: Intermediate proxies, reverse-proxies, or Google Front End (GFE) edge proxies can strip Scotty response headers or return raw HTML/text error pages on failure. - * *Control Plane (`start`, `query`, `cancel`)*: Missing `X-Goog-Upload-Status` is treated as **retriable** by `control_plane_retry_policy` (retry predicate returns `true`). Driver retries transparently to smooth over transient gateway noise. If retries exhaust, `Starting` transitions to `:error` via `fail_with_request_error` or `fail_with_bad_response` (cannot recover a session before upload URL is obtained). + * *Session Initiation (`start`)*: Missing `X-Goog-Upload-Status` is treated as **retriable** by `start_retry_policy` (retry predicate returns `true`) across **any response code, including 200 OK**. Driver retries transparently to smooth over transient gateway noise. If retries exhaust, `Starting` transitions to `:error` via `fail_with_request_error` or `fail_with_bad_response` (cannot recover a session before an upload URL is obtained). + * *Session Control (`query`, `cancel`)*: `control_plane_retry_policy` does **not** treat missing status headers as retriable, returning the completed `Event::HttpResponse` immediately to `Core` so it can manage protocol recovery or fail fast. * *Data Plane (`upload`, `upload, finalize`, standalone `finalize`)*: Missing `X-Goog-Upload-Status` is treated as **unretriable** by `data_plane_retry_policy` (retry predicate returns `false`). The Driver immediately returns `Event::HttpResponse` to `Core` so it classifies as `:response_cat2` and initiates Category 2 `Recovery` via `Instruction::SendQuery` rather than blindly re-transmitting data. * **Resolution**: Core transitions to `Recovery` and emits `Instruction::SendQuery.new(url: state.upload_url)` to obtain `server_offset`. diff --git a/gapic-common/design/reference-implementation.md b/gapic-common/design/reference-implementation.md index d8c8ca8..4534a5d 100644 --- a/gapic-common/design/reference-implementation.md +++ b/gapic-common/design/reference-implementation.md @@ -378,15 +378,16 @@ module Gapic @core = Core.new(config) @buffer = "".b @buffer_start_offset = 0 + @start_retry_policy = config.start_retry_policy || self.class.default_start_retry_policy @control_plane_retry_policy = config.control_plane_retry_policy || self.class.default_control_plane_retry_policy @data_plane_retry_policy = config.data_plane_retry_policy || self.class.default_data_plane_retry_policy end - # Default retry policy for control plane requests (start, query, cancel). - # Missing X-Goog-Upload-Status header is retriable (predicate returns true). + # Default retry policy for session initiation requests (start). + # Missing X-Goog-Upload-Status header is retriable across any response code, including 200 (predicate returns true). # # @return [Gapic::Common::RetryPolicy] - def self.default_control_plane_retry_policy + def self.default_start_retry_policy Gapic::Common::RetryPolicy.new( retry_codes: ["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"], initial_delay: 1.0, @@ -402,6 +403,19 @@ module Gapic ) end + # Default retry policy for session control requests (query, cancel). + # Does not retry on missing X-Goog-Upload-Status header. + # + # @return [Gapic::Common::RetryPolicy] + def self.default_control_plane_retry_policy + Gapic::Common::RetryPolicy.new( + retry_codes: ["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"], + initial_delay: 1.0, + max_delay: 15.0, + multiplier: 1.3 + ) + end + # Default retry policy for data plane requests (upload, finalize, upload_finalize). # Missing X-Goog-Upload-Status header is unretriable (predicate returns false), # causing Driver to yield Event::HttpResponse so Core initiates Recovery. @@ -494,13 +508,14 @@ module Gapic # Reads from stream until @buffer.bytesize reaches instruction.target_bytesize or stream hits EOF end - # Network operation: wraps start HTTP request in user_override_start_retry_policy or control_plane_retry_policy + # Network operation: wraps start HTTP request in user_override_start_retry_policy or start_retry_policy # @return [Event::HttpResponse, Event::RequestFailed] def execute_send_start(instruction) - policy = @config.user_override_start_retry_policy || @control_plane_retry_policy - # Executes POST initiation request via @client_stub with policy + policy = @config.user_override_start_retry_policy || @start_retry_policy + # Executes POST initiation request via @client_stub with policy in a retry loop. + # Retries missing X-Goog-Upload-Status header across any response code, including 200 OK. # Returns Event::HttpResponse for any completed HTTP response (including 4xx/5xx). - # Returns Event::RequestFailed(kind:, message:, source_error:) on unhandled transport error or retry exhaustion. + # Returns Event::RequestFailed(kind: :retries_exhausted, ...) on retry exhaustion. end # Network operation: wraps HTTP request in data_plane_retry_policy diff --git a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb index 31a50e2..96e95ec 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb @@ -29,6 +29,7 @@ module ResumableUpload :chunk_size, :content_type, :deadline, + :start_retry_policy, :control_plane_retry_policy, :data_plane_retry_policy, :user_override_start_retry_policy, @@ -42,6 +43,7 @@ def initialize initial_url:, chunk_size: nil, content_type: nil, deadline: nil, + start_retry_policy: nil, control_plane_retry_policy: nil, data_plane_retry_policy: nil, user_override_start_retry_policy: nil, @@ -55,6 +57,7 @@ def initialize initial_url:, chunk_size: chunk_size, content_type: content_type, deadline: deadline, + start_retry_policy: start_retry_policy, control_plane_retry_policy: control_plane_retry_policy, data_plane_retry_policy: data_plane_retry_policy, user_override_start_retry_policy: user_override_start_retry_policy, diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index a597d63..1341812 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -31,6 +31,7 @@ module ResumableUpload # Coordinates HTTP network operations, stream buffering, monotonic deadlines, # and delegates state transitions to Core. # + # rubocop:disable Metrics/ClassLength class Driver include Gapic::LoggingConcerns @@ -47,6 +48,8 @@ def initialize client_stub:, config:, logger: nil @core = Core.new config @buffer = "".b @buffer_start_offset = 0 + @start_retry_policy = config.start_retry_policy || + self.class.default_start_retry_policy @control_plane_retry_policy = config.control_plane_retry_policy || self.class.default_control_plane_retry_policy @data_plane_retry_policy = config.data_plane_retry_policy || @@ -54,7 +57,15 @@ def initialize client_stub:, config:, logger: nil end ## - # Default retry policy for control plane requests (start, query, cancel). + # Default retry policy for session initiation requests (start). + # + # @return [Gapic::Common::RetryPolicy] + def self.default_start_retry_policy + RetryPolicies.default_start + end + + ## + # Default retry policy for control plane requests (query, cancel). # # @return [Gapic::Common::RetryPolicy] def self.default_control_plane_retry_policy @@ -95,7 +106,8 @@ def run private def pending_event_type? obj - obj.is_a?(Event::ChunkRead) || obj.is_a?(Event::HttpResponse) || obj.is_a?(Event::RequestFailed) + obj.is_a?(Event::ChunkRead) || obj.is_a?(Event::HttpResponse) || + obj.is_a?(Event::RequestFailed) || obj.is_a?(Event::GlobalDeadlineExceeded) end def dispatch_instruction instruction @@ -196,13 +208,32 @@ def execute_fill_buffer instruction end def execute_send_start instruction - policy = @config.user_override_start_retry_policy || @control_plane_retry_policy + policy = (@config.user_override_start_retry_policy || @start_retry_policy).dup.start! + headers = start_headers instruction + + loop do + return Event::GlobalDeadlineExceeded.new if deadline_exceeded? + + event = make_post_request instruction.url, headers: headers, body: instruction.body, retry_policy: policy + return event unless event.is_a? Event::HttpResponse + + status_hdr = event.headers["x-goog-upload-status"] || event.headers["X-Goog-Upload-Status"] + return event unless status_hdr.nil? || status_hdr.empty? + + err = Gapic::Common::BadResponseError.new event.status, + "Missing X-Goog-Upload-Status header in start response" + can_retry = policy.send(:retry_with_deadline?) && policy.call(event) + unless can_retry + return Event::RequestFailed.new kind: :retries_exhausted, message: err.message, source_error: err + end + end + end + + def start_headers instruction headers = { "X-Goog-Upload-Protocol" => "resumable", "X-Goog-Upload-Command" => "start" } headers["X-Goog-Upload-Header-Content-Type"] = @config.content_type if @config.content_type headers["X-Goog-Upload-Header-Content-Length"] = @config.upload_size.to_s if @config.upload_size - headers = headers.merge(instruction.headers || {}) - - make_post_request instruction.url, headers: headers, body: instruction.body, retry_policy: policy + headers.merge(instruction.headers || {}) end def execute_send_chunk instruction @@ -215,7 +246,8 @@ def execute_send_chunk instruction slice_index = instruction.offset - @buffer_start_offset body = @buffer.byteslice slice_index, instruction.length - make_post_request instruction.url, headers: headers, body: body, retry_policy: @data_plane_retry_policy + make_post_request instruction.url, headers: headers, body: body, + retry_policy: @data_plane_retry_policy.dup.start! end def execute_send_finalize instruction @@ -224,21 +256,24 @@ def execute_send_finalize instruction "X-Goog-Upload-Offset" => @core.state.offset.to_s, "Content-Length" => "0" } - make_post_request instruction.url, headers: headers, body: "", retry_policy: @data_plane_retry_policy + make_post_request instruction.url, headers: headers, body: "", + retry_policy: @data_plane_retry_policy.dup.start! end def execute_send_query instruction headers = { "X-Goog-Upload-Command" => "query", "Content-Length" => "0" } - make_post_request instruction.url, headers: headers, body: "", retry_policy: @control_plane_retry_policy + make_post_request instruction.url, headers: headers, body: "", + retry_policy: @control_plane_retry_policy.dup.start! end def execute_send_cancel instruction headers = { "X-Goog-Upload-Command" => "cancel", "Content-Length" => "0" } - make_post_request instruction.url, headers: headers, body: "", retry_policy: @control_plane_retry_policy + make_post_request instruction.url, headers: headers, body: "", + retry_policy: @control_plane_retry_policy.dup.start! end def make_post_request url, headers:, body:, retry_policy: - options = { metadata: headers, retry_policy: retry_policy.dup.start! } + options = { metadata: headers, retry_policy: retry_policy } @logger&.debug do "ResumableUpload::Driver: POST #{url} (offset: #{@core.state.offset}, " \ "chunk_size: #{@core.state.chunk_size})" @@ -280,6 +315,7 @@ def rescue_faraday_error err end end end + # rubocop:enable Metrics/ClassLength end end end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb b/gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb index f10d2ab..d627cd2 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb @@ -24,11 +24,12 @@ module ResumableUpload # module RetryPolicies ## - # Default retry policy for control plane requests (start, query, cancel). - # Missing X-Goog-Upload-Status header is retriable (predicate returns true). + # Default retry policy for session initiation requests (start). + # Missing X-Goog-Upload-Status header is retriable across any response code, + # including 200 (predicate returns true). # # @return [Gapic::Common::RetryPolicy] - def self.default_control_plane + def self.default_start Gapic::Common::RetryPolicy.new( retry_codes: ["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"], initial_delay: 1.0, @@ -45,6 +46,20 @@ def self.default_control_plane ) end + ## + # Default retry policy for session control requests (query, cancel). + # Does not retry on missing X-Goog-Upload-Status header. + # + # @return [Gapic::Common::RetryPolicy] + def self.default_control_plane + Gapic::Common::RetryPolicy.new( + retry_codes: ["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"], + initial_delay: 1.0, + max_delay: 15.0, + multiplier: 1.3 + ) + end + ## # Default retry policy for data plane requests (upload, finalize). # Missing X-Goog-Upload-Status header is unretriable (predicate returns false). diff --git a/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb index 9634f16..f69410a 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb @@ -18,6 +18,9 @@ require "gapic/rest/resumable_upload" require "stringio" +## +# Tests for data types in resumable upload. +# class DataTypesTest < Minitest::Test include Gapic::Rest::ResumableUpload @@ -36,6 +39,7 @@ def test_complete_upload_config_defaults assert_nil config.chunk_size assert_nil config.content_type assert_nil config.deadline + assert_nil config.start_retry_policy assert_nil config.control_plane_retry_policy assert_nil config.data_plane_retry_policy assert_nil config.user_override_start_retry_policy diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb index 90aea77..d099560 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb @@ -85,6 +85,135 @@ def test_upload_recovers_when_chunk_response_lacks_status_header assert_chunk_request stub.requests[4], offset: "8", length: "2", body: "89", finalize: true end + def test_start_retries_when_response_lacks_status_header_even_on_200 + fast_policy = Gapic::Common::RetryPolicy.new( + initial_delay: 0.001, + max_delay: 0.002, + timeout: 1.0, + retry_predicate: lambda do |error_or_response| + headers = RetryPolicies.extract_headers error_or_response + if headers + status_hdr = headers["x-goog-upload-status"] || headers["X-Goog-Upload-Status"] + return true if status_hdr.nil? || status_hdr.empty? + end + nil + end + ) + responses = [ + # First start response: 200 OK but NO X-Goog-Upload-Status header + FakeResponse.new(status: 200, headers: {}, body: ""), + # Second start response: valid 200 OK with active header + FakeResponse.new( + status: 200, + headers: { + "X-Goog-Upload-URL" => "https://example.com/session/1", + "X-Goog-Upload-Status" => "active" + }, + body: "" + ), + FakeResponse.new(status: 200, headers: { "X-Goog-Upload-Status" => "final" }, body: '{"done":true}') + ] + stub = FakeClientStub.new responses + config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("0123"), + upload_size: 4, + chunk_size: 10, + start_retry_policy: fast_policy + ) + + driver = Driver.new client_stub: stub, config: config + result = driver.run + + assert_equal '{"done":true}', result.body + # 2 start requests + 1 chunk request = 3 requests + assert_equal 3, stub.requests.size + assert_start_request stub.requests[0] + assert_start_request stub.requests[1] + assert_chunk_request stub.requests[2], offset: "0", length: "4", body: "0123", finalize: true + end + + def test_start_exhausts_retries_when_responses_continually_lack_status_header + exhausting_policy = Gapic::Common::RetryPolicy.new( + initial_delay: 0.001, + max_delay: 0.002, + timeout: 0.01, + retry_predicate: lambda do |error_or_response| + headers = RetryPolicies.extract_headers error_or_response + if headers + status_hdr = headers["x-goog-upload-status"] || headers["X-Goog-Upload-Status"] + return true if status_hdr.nil? || status_hdr.empty? + end + nil + end + ) + # Return 200 OK without status headers repeatedly + responses = Array.new(10) { FakeResponse.new status: 200, headers: {}, body: "" } + stub = FakeClientStub.new responses + config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("0123"), + upload_size: 4, + chunk_size: 10, + start_retry_policy: exhausting_policy + ) + + driver = Driver.new client_stub: stub, config: config + err = assert_raises Gapic::Common::BadResponseError do + driver.run + end + + assert_match(/Missing X-Goog-Upload-Status/, err.message) + assert stub.requests.size > 1 + end + + def test_query_does_not_retry_on_missing_status_header_in_driver + responses = [ + FakeResponse.new( + status: 200, + headers: { + "X-Goog-Upload-URL" => "https://example.com/session/1", + "X-Goog-Upload-Status" => "active" + }, + body: "" + ), + # Chunk returns 503 without status header -> triggers Category 2 recovery + FakeResponse.new(status: 503, headers: {}, body: "Service Unavailable"), + # Query returns 200 with missing status header -> Core handles recovery retry, + # Driver does not retry query internally + FakeResponse.new(status: 200, headers: {}, body: ""), + # Next query succeeds + FakeResponse.new( + status: 200, + headers: { + "X-Goog-Upload-Status" => "active", + "X-Goog-Upload-Size-Received" => "0" + }, + body: "" + ), + FakeResponse.new(status: 200, headers: { "X-Goog-Upload-Status" => "final" }, body: '{"done":true}') + ] + stub = FakeClientStub.new responses + config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("0123"), + upload_size: 4, + chunk_size: 10 + ) + + driver = Driver.new client_stub: stub, config: config + result = driver.run + + assert_equal '{"done":true}', result.body + # Verify exact sequence: start, chunk, query1, query2, chunk + assert_equal 5, stub.requests.size + assert_start_request stub.requests[0] + assert_chunk_request stub.requests[1], offset: "0", length: "4", body: "0123", finalize: true + assert_query_request stub.requests[2] + assert_query_request stub.requests[3] + assert_chunk_request stub.requests[4], offset: "0", length: "4", body: "0123", finalize: true + end + private def build_scripted_responses From 3546633c744d9c926a772ab5b3d0af8e902c1256 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Wed, 2 Sep 2026 22:56:47 +0000 Subject: [PATCH 07/79] invalid events error and correct return type --- gapic-common/design/implementation-guide.md | 6 +- .../design/reference-implementation.md | 30 ++++++++- .../lib/gapic/rest/resumable_upload/driver.rb | 5 +- .../lib/gapic/rest/resumable_upload/errors.rb | 21 ++++++- .../lib/gapic/rest/resumable_upload/rules.rb | 51 ++++++++++++++-- .../rest/resumable_upload/driver_test.rb | 8 +-- .../gapic/rest/resumable_upload/rules_test.rb | 61 +++++++++++++++++-- 7 files changed, 161 insertions(+), 21 deletions(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index 7490627..23fff4c 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -76,7 +76,6 @@ module Gapic :in_flight_length, # [Integer] Byte length of in-flight chunk currently being transmitted :last_error # [StandardError, nil] Terminal exception ) do - # def eql?, def hash etc end end end @@ -103,7 +102,7 @@ end * `Instruction::RealignBuffer.new(server_offset:)`: Realign Driver in-memory buffer and stream position to match `server_offset`. * `Instruction::FillBuffer.new(target_bytesize:)`: Read from stream until in-memory buffer reaches `target_bytesize` bytes or stream encounters EOF. * `Instruction::NotifyProgress.new(bytes_uploaded:, total_bytes:)`: Invoke `on_progress` callback. -* `Instruction::TerminateSuccess.new(response:)`: Upload finalized cleanly; return response. +* `Instruction::TerminateSuccess.new(response:)`: Upload finalized cleanly; Driver returns `response.body`. * `Instruction::TerminateFailure.new(error:)`: Raise terminal exception. ### 2.5 Driver Buffer Invariants & Stream Position Model @@ -173,7 +172,7 @@ The Driver categorizes instructions into three execution types: * Execute physical stream reads or HTTP requests (wrapped in `Gapic::Common::RetryPolicy` for Category 1 transient errors). * Yield a single resulting `Event` (`ChunkRead`, `HttpResponse`, or `RequestFailed`) that becomes the input for the next cycle. 3. **Terminal Handlers** (`TerminateSuccess`, `TerminateFailure`): - * Break the event loop and return the final `Faraday::Response` or raise the terminal exception. + * Break the event loop and return the final response body string (`response.body`) or raise the terminal exception. Full implementation: [reference-implementation.md#3-driver-class](reference-implementation.md#3-driver-class) @@ -238,6 +237,7 @@ Full implementation: [reference-implementation.md#3-driver-class](reference-impl | **`Cancelling`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, _)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(event.body))` | | **`Cancelling`** | `:request_retries_exhausted` / `:request_connection_failed` / `:response_fatal_bad_response` | `Event::RequestFailed` or HTTP failure | `last_error = error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | | **Any Non-Terminal** | `:global_deadline_exceeded` | `Event::GlobalDeadlineExceeded` | `last_error = Gapic::Common::DeadlineExceededError.new`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | +| **Any State** | *Unmatched* | Any event not matched above | — | — | `fail_with_unmatched_transition(state, event)`: raises `InvalidTransitionError` stating in human terms what the protocol was doing (e.g. sending a chunk of data), what happened including HTTP status and `X-Goog-Upload-Status` header, and attaches the response. | ### 4.3 State Transition Graph diff --git a/gapic-common/design/reference-implementation.md b/gapic-common/design/reference-implementation.md index 4534a5d..cd81bc4 100644 --- a/gapic-common/design/reference-implementation.md +++ b/gapic-common/design/reference-implementation.md @@ -102,7 +102,7 @@ module Gapic in [_, :request_retries_exhausted | :request_connection_failed | :request_failed_unknown] fail_with_request_error(state, event) else - raise InvalidTransitionError, "Invalid event shape #{shape} for state #{state.status}" + fail_with_unmatched_transition(state, event) end end @@ -285,6 +285,30 @@ module Gapic [next_state, [Instruction::TerminateFailure.new(error: event.source_error)]] end + def self.fail_with_unmatched_transition(state, event) + shape = shape_of(event) + action = STATE_DESCRIPTIONS[state.status] || "processing #{state.status}" + happened = describe_event(event, shape) + message = "Resumable upload failed while #{action}: #{happened}." + response = event.is_a?(Event::HttpResponse) ? event : nil + raise InvalidTransitionError.new(message, state: state.status, event: event, response: response) + end + + def self.describe_event(event, shape) + case event + when Event::HttpResponse + upload_status = event.headers["x-goog-upload-status"] || event.headers["X-Goog-Upload-Status"] + status_desc = upload_status ? "'#{upload_status}'" : "missing" + "received an unexpected HTTP #{event.status} response (X-Goog-Upload-Status: #{status_desc})" + when Event::ChunkRead + "received unexpected stream chunk read (#{event.bytes_buffered} bytes, eof: #{event.eof})" + when Event::RequestFailed + "encountered unexpected request failure (#{event.kind}: #{event.message})" + else + "received unexpected event #{shape} (#{event.class.name})" + end + end + private def self.classify_http_response(response) @@ -439,7 +463,7 @@ module Gapic # Executes event loop until terminal state. # - # @return [Faraday::Response] Final response + # @return [String, Object] Final response body def run pending_event = Event::StartUpload @@ -470,7 +494,7 @@ module Gapic when Instruction::SendCancel pending_event = execute_send_cancel(instruction) when Instruction::TerminateSuccess - return instruction.response + return instruction.response.respond_to?(:body) ? instruction.response.body : instruction.response when Instruction::TerminateFailure raise instruction.error end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index 1341812..0a528e7 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -83,7 +83,7 @@ def self.default_data_plane_retry_policy ## # Executes event loop until terminal state. # - # @return [Faraday::Response, Object] Final response + # @return [String, Object] Final response body def run pending_event = Event::StartUpload.new @@ -120,7 +120,8 @@ def dispatch_instruction instruction when Instruction::SendFinalize then execute_send_finalize instruction when Instruction::SendQuery then execute_send_query instruction when Instruction::SendCancel then execute_send_cancel instruction - when Instruction::TerminateSuccess then instruction.response + when Instruction::TerminateSuccess + instruction.response.respond_to?(:body) ? instruction.response.body : instruction.response when Instruction::TerminateFailure then raise instruction.error end end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb index 7d83181..b88aaa6 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb @@ -20,9 +20,28 @@ module Gapic module Rest module ResumableUpload ## - # Raised when an invalid event is dispatched for the current protocol state. + # Raised when an invalid or unmatched event is dispatched for the current protocol state. # class InvalidTransitionError < Gapic::Common::Error + # @return [Gapic::Rest::ResumableUpload::Event::HttpResponse, Object, nil] + attr_reader :response + + # @return [Symbol, nil] Current protocol state + attr_reader :state + + # @return [Object, nil] Received event + attr_reader :event + + # @param message [String] + # @param state [Symbol, nil] + # @param event [Object, nil] + # @param response [Gapic::Rest::ResumableUpload::Event::HttpResponse, Object, nil] + def initialize message, state: nil, event: nil, response: nil + @state = state + @event = event + @response = response || (event if defined?(Event::HttpResponse) && event.is_a?(Event::HttpResponse)) + super message + end end ## diff --git a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb index 0231a8a..fff9c9b 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb @@ -32,6 +32,20 @@ module Rules DEFAULT_CHUNK_SIZE = 8_388_608 # 8 MB CAT2_STATUS_CODES = [400, 408, 409, 412, 416, 429, 499].freeze FATAL_STATUS_CODES = [401, 403, 404, 405, 410, 413, 415].freeze + STATE_DESCRIPTIONS = { + initializing: "initializing upload", + starting: "initiating upload session", + transmission_reading: "reading chunk from stream", + transmission_sending: "sending a chunk of data", + finalizing_sending_upload: "sending final data chunk", + finalizing_sending_finalize: "sending finalize command", + recovery: "querying upload offset for recovery", + cancelling: "cancelling upload session", + success: "in completed upload state", + cancelled: "in cancelled upload state", + error: "in error state", + rejected: "in rejected upload state" + }.freeze ## # Classifies incoming event into a canonical shape symbol. @@ -103,14 +117,19 @@ def self.step state, event, config fail_with_deadline_exceeded state in [_, :user_cancel] cancel_session state - in [_, :response_rejected] + in [:starting | :transmission_sending | :finalizing_sending_upload | + :finalizing_sending_finalize | :recovery | :cancelling, :response_rejected] fail_with_rejected state, event - in [_, :response_cat2 | :response_fatal_bad_response] + in [:starting | :cancelling, :response_cat2] | + [:starting | :transmission_sending | :finalizing_sending_upload | + :finalizing_sending_finalize | :recovery | :cancelling, :response_fatal_bad_response] fail_with_bad_response state, event - in [_, :request_retries_exhausted | :request_connection_failed | :request_failed_unknown] + in [:starting | :transmission_sending | :finalizing_sending_upload | + :finalizing_sending_finalize | :recovery | :cancelling, + :request_retries_exhausted | :request_connection_failed | :request_failed_unknown] fail_with_request_error state, event else - raise InvalidTransitionError, "Invalid event shape #{shape} for state #{state.status}" + fail_with_unmatched_transition state, event end end # rubocop:enable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength @@ -302,6 +321,30 @@ def self.fail_with_request_error state, event [next_state, [Instruction::TerminateFailure.new(error: err)]] end + def self.fail_with_unmatched_transition state, event + shape = shape_of event + action = STATE_DESCRIPTIONS[state.status] || "processing #{state.status}" + happened = describe_event event, shape + message = "Resumable upload failed while #{action}: #{happened}." + response = event.is_a?(Event::HttpResponse) ? event : nil + raise InvalidTransitionError.new(message, state: state.status, event: event, response: response) + end + + def self.describe_event event, shape + case event + when Event::HttpResponse + upload_status = event.headers["x-goog-upload-status"] || event.headers["X-Goog-Upload-Status"] + status_desc = upload_status ? "'#{upload_status}'" : "missing" + "received an unexpected HTTP #{event.status} response (X-Goog-Upload-Status: #{status_desc})" + when Event::ChunkRead + "received unexpected stream chunk read (#{event.bytes_buffered} bytes, eof: #{event.eof})" + when Event::RequestFailed + "encountered unexpected request failure (#{event.kind}: #{event.message})" + else + "received unexpected event #{shape} (#{event.class.name})" + end + end + ## # Resolves effective chunk size given user specification and backend granularity. # diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb index d099560..02e02af 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb @@ -55,7 +55,7 @@ def test_multi_chunk_upload_with_active_responses driver = Driver.new client_stub: stub, config: config result = driver.run - assert_equal '{"done":true}', result.body + assert_equal '{"done":true}', result assert_equal 4, stub.requests.size assert_start_request stub.requests[0] assert_chunk_request stub.requests[1], offset: "0", length: "4", body: "0123", finalize: false @@ -76,7 +76,7 @@ def test_upload_recovers_when_chunk_response_lacks_status_header driver = Driver.new client_stub: stub, config: config result = driver.run - assert_equal '{"done":true}', result.body + assert_equal '{"done":true}', result assert_equal 5, stub.requests.size assert_start_request stub.requests[0] assert_chunk_request stub.requests[1], offset: "0", length: "4", body: "0123", finalize: false @@ -125,7 +125,7 @@ def test_start_retries_when_response_lacks_status_header_even_on_200 driver = Driver.new client_stub: stub, config: config result = driver.run - assert_equal '{"done":true}', result.body + assert_equal '{"done":true}', result # 2 start requests + 1 chunk request = 3 requests assert_equal 3, stub.requests.size assert_start_request stub.requests[0] @@ -204,7 +204,7 @@ def test_query_does_not_retry_on_missing_status_header_in_driver driver = Driver.new client_stub: stub, config: config result = driver.run - assert_equal '{"done":true}', result.body + assert_equal '{"done":true}', result # Verify exact sequence: start, chunk, query1, query2, chunk assert_equal 5, stub.requests.size assert_start_request stub.requests[0] diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb index 2254225..9e03827 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb @@ -381,10 +381,63 @@ def test_transition_global_deadline_exceeded assert_instance_of Instruction::TerminateFailure, instructions.first end - def test_invalid_transition_raises_error - state = State.new status: :initializing - assert_raises InvalidTransitionError do - Rules.step state, Event::ChunkRead.new(bytes_buffered: 512, eof: false), @config + def test_invalid_transition_raises_actionable_error_with_response_details_and_header + state = State.new status: :transmission_sending + response = Event::HttpResponse.new( + status: 200, + headers: { "X-Goog-Upload-Status" => "final" }, + body: '{"done":true}' + ) + + err = assert_raises InvalidTransitionError do + Rules.step state, response, @config + end + + assert_equal( + "Resumable upload failed while sending a chunk of data: " \ + "received an unexpected HTTP 200 response (X-Goog-Upload-Status: 'final').", + err.message + ) + assert_same response, err.response + assert_same response, err.event + assert_equal :transmission_sending, err.state + end + + def test_invalid_transition_shows_missing_when_upload_status_header_absent + state = State.new status: :transmission_reading + response = Event::HttpResponse.new( + status: 200, + headers: {}, + body: "" + ) + + err = assert_raises InvalidTransitionError do + Rules.step state, response, @config + end + + assert_equal( + "Resumable upload failed while reading chunk from stream: " \ + "received an unexpected HTTP 200 response (X-Goog-Upload-Status: missing).", + err.message + ) + assert_same response, err.response + assert_equal :transmission_reading, err.state + end + + def test_invalid_transition_raises_error_for_non_http_event + state = State.new status: :starting + event = Event::ChunkRead.new bytes_buffered: 512, eof: false + err = assert_raises InvalidTransitionError do + Rules.step state, event, @config end + + assert_equal( + "Resumable upload failed while initiating upload session: " \ + "received unexpected stream chunk read (512 bytes, eof: false).", + err.message + ) + assert_nil err.response + assert_same event, err.event + assert_equal :starting, err.state end end From ff5a4d7d4d4aed023f16e896d9b3629cc22980ae Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Wed, 2 Sep 2026 23:07:01 +0000 Subject: [PATCH 08/79] tests: rules classification --- .../rules_classification_test.rb | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb new file mode 100644 index 0000000..21e4c78 --- /dev/null +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "test_helper" +require "gapic/rest/resumable_upload" + +## +# Tests for classification and header extraction rules in the Resumable Upload protocol. +# +class RulesClassificationTest < Minitest::Test + include Gapic::Rest::ResumableUpload + + def test_header_value_exact_match + headers = { "X-Goog-Upload-Status" => "active" } + assert_equal "active", Rules.header_value(headers, "X-Goog-Upload-Status") + end + + def test_header_value_case_insensitivity + assert_equal "active", Rules.header_value({ "x-goog-upload-status" => "active" }, "X-Goog-Upload-Status") + assert_equal "active", Rules.header_value({ "X-GOOG-UPLOAD-STATUS" => "active" }, "X-Goog-Upload-Status") + assert_equal "active", Rules.header_value({ "x-Goog-UpLoad-Status" => "active" }, "X-Goog-Upload-Status") + end + + def test_header_value_with_symbol_keys + headers = { :"x-goog-upload-status" => "active" } + assert_equal "active", Rules.header_value(headers, "X-Goog-Upload-Status") + end + + def test_header_value_missing_or_non_hash + assert_nil Rules.header_value({ "Content-Type" => "text/plain" }, "X-Goog-Upload-Status") + assert_nil Rules.header_value(nil, "X-Goog-Upload-Status") + assert_nil Rules.header_value([], "X-Goog-Upload-Status") + assert_nil Rules.header_value("string", "X-Goog-Upload-Status") + end + + def test_classify_http_response_active + resp_200 = Event::HttpResponse.new status: 200, headers: { "X-Goog-Upload-Status" => "active" }, body: "" + assert_equal :response_active, Rules.classify_http_response(resp_200) + + # Value case variations + ["Active", "ACTIVE", "aCtIvE"].each do |val| + resp = Event::HttpResponse.new status: 200, headers: { "X-Goog-Upload-Status" => val }, body: "" + assert_equal :response_active, Rules.classify_http_response(resp) + end + + # Non-200 with active maps to Category 2 + [503, 500, 400, 408].each do |code| + resp_non_200 = Event::HttpResponse.new status: code, headers: { "X-Goog-Upload-Status" => "active" }, body: "" + assert_equal :response_cat2, Rules.classify_http_response(resp_non_200) + end + end + + def test_classify_http_response_final + resp_200 = Event::HttpResponse.new status: 200, headers: { "X-Goog-Upload-Status" => "final" }, body: "" + assert_equal :response_final, Rules.classify_http_response(resp_200) + + # Value case variations + ["Final", "FINAL"].each do |val| + resp = Event::HttpResponse.new status: 200, headers: { "X-Goog-Upload-Status" => val }, body: "" + assert_equal :response_final, Rules.classify_http_response(resp) + end + + # Non-200 with final maps to response_rejected + [400, 404, 500].each do |code| + resp_non_200 = Event::HttpResponse.new status: code, headers: { "X-Goog-Upload-Status" => "final" }, body: "" + assert_equal :response_rejected, Rules.classify_http_response(resp_non_200) + end + end + + def test_classify_http_response_cancelled + resp_200 = Event::HttpResponse.new status: 200, headers: { "X-Goog-Upload-Status" => "cancelled" }, body: "" + assert_equal :response_cancelled, Rules.classify_http_response(resp_200) + + # Value case variations + ["Cancelled", "CANCELLED"].each do |val| + resp = Event::HttpResponse.new status: 200, headers: { "X-Goog-Upload-Status" => val }, body: "" + assert_equal :response_cancelled, Rules.classify_http_response(resp) + end + + # Non-200 with cancelled maps to fatal bad response + [400, 500].each do |code| + resp_non_200 = Event::HttpResponse.new status: code, headers: { "X-Goog-Upload-Status" => "cancelled" }, body: "" + assert_equal :response_fatal_bad_response, Rules.classify_http_response(resp_non_200) + end + end + + def test_classify_http_response_missing_header_non_fatal + # HTTP 200 missing header + resp_200 = Event::HttpResponse.new status: 200, headers: {}, body: "" + assert_equal :response_cat2, Rules.classify_http_response(resp_200) + + # Recoverable 4xx missing header + Rules::CAT2_STATUS_CODES.each do |code| + resp = Event::HttpResponse.new status: code, headers: {}, body: "" + assert_equal :response_cat2, Rules.classify_http_response(resp), "Expected #{code} to classify as :response_cat2" + end + + # 5xx server/gateway errors missing header + [500, 502, 503, 504].each do |code| + resp = Event::HttpResponse.new status: code, headers: {}, body: "" + assert_equal :response_cat2, Rules.classify_http_response(resp), "Expected #{code} to classify as :response_cat2" + end + + # Empty string header + resp_empty = Event::HttpResponse.new status: 200, headers: { "X-Goog-Upload-Status" => "" }, body: "" + assert_equal :response_cat2, Rules.classify_http_response(resp_empty) + end + + def test_classify_http_response_missing_header_fatal_status_codes + Rules::FATAL_STATUS_CODES.each do |code| + resp = Event::HttpResponse.new status: code, headers: {}, body: "" + assert_equal :response_fatal_bad_response, Rules.classify_http_response(resp), + "Expected fatal code #{code} to classify as :response_fatal_bad_response" + + resp_empty = Event::HttpResponse.new status: code, headers: { "X-Goog-Upload-Status" => "" }, body: "" + assert_equal :response_fatal_bad_response, Rules.classify_http_response(resp_empty), + "Expected fatal code #{code} with empty header to classify as :response_fatal_bad_response" + end + end + + def test_classify_http_response_unknown_header_values + ["absconded", "pending", "in_progress", "error", "unknown"].each do |unknown_val| + resp_200 = Event::HttpResponse.new status: 200, headers: { "X-Goog-Upload-Status" => unknown_val }, body: "" + assert_equal :response_fatal_bad_response, Rules.classify_http_response(resp_200) + + resp_400 = Event::HttpResponse.new status: 400, headers: { "X-Goog-Upload-Status" => unknown_val }, body: "" + assert_equal :response_fatal_bad_response, Rules.classify_http_response(resp_400) + end + end + + def test_classify_http_response_header_key_casing + keys = ["x-goog-upload-status", "X-GOOG-UPLOAD-STATUS", "X-Goog-Upload-Status", :"x-goog-upload-status"] + keys.each do |key| + resp = Event::HttpResponse.new status: 200, headers: { key => "active" }, body: "" + assert_equal :response_active, Rules.classify_http_response(resp) + end + end + + def test_shape_of_control_events + assert_equal :start_upload, Rules.shape_of(Event::StartUpload.new) + assert_equal :start_upload, Rules.shape_of(Event::StartUpload) + assert_equal :user_cancel, Rules.shape_of(Event::Cancel.new) + assert_equal :user_cancel, Rules.shape_of(Event::Cancel) + assert_equal :global_deadline_exceeded, Rules.shape_of(Event::GlobalDeadlineExceeded.new) + assert_equal :global_deadline_exceeded, Rules.shape_of(Event::GlobalDeadlineExceeded) + end + + def test_shape_of_chunk_read + full_chunk = Event::ChunkRead.new bytes_buffered: 4096, eof: false + assert_equal :chunk_read_full, Rules.shape_of(full_chunk) + + eof_data = Event::ChunkRead.new bytes_buffered: 1024, eof: true + assert_equal :chunk_read_eof_with_data, Rules.shape_of(eof_data) + + eof_empty = Event::ChunkRead.new bytes_buffered: 0, eof: true + assert_equal :chunk_read_eof_empty, Rules.shape_of(eof_empty) + end + + def test_shape_of_request_failed + exhausted = Event::RequestFailed.new kind: :retries_exhausted, message: "timeout" + assert_equal :request_retries_exhausted, Rules.shape_of(exhausted) + + conn_failed = Event::RequestFailed.new kind: :connection_failed, message: "dropped" + assert_equal :request_connection_failed, Rules.shape_of(conn_failed) + + other = Event::RequestFailed.new kind: :other, message: "unknown error" + assert_equal :request_failed_unknown, Rules.shape_of(other) + end + + def test_shape_of_http_response_delegates_to_classify + resp = Event::HttpResponse.new status: 200, headers: { "X-Goog-Upload-Status" => "active" }, body: "" + assert_equal :response_active, Rules.shape_of(resp) + end + + def test_shape_of_unknown_event + assert_equal :unknown, Rules.shape_of(Object.new) + assert_equal :unknown, Rules.shape_of(nil) + assert_equal :unknown, Rules.shape_of("unrecognized_event") + end +end From 12be42ce552b387cb6af5b6431af28e52d70f897 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Wed, 2 Sep 2026 23:14:54 +0000 Subject: [PATCH 09/79] tests: chunk size negotiation --- .../rules_classification_test.rb | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb index 21e4c78..aa56561 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb @@ -190,4 +190,53 @@ def test_shape_of_unknown_event assert_equal :unknown, Rules.shape_of(nil) assert_equal :unknown, Rules.shape_of("unrecognized_event") end + + def test_resolve_chunk_size_with_nil_or_non_positive_granularity + # nil granularity + assert_equal 1024, Rules.resolve_chunk_size(1024, nil) + assert_equal Rules::DEFAULT_CHUNK_SIZE, Rules.resolve_chunk_size(nil, nil) + + # 0 granularity + assert_equal 1024, Rules.resolve_chunk_size(1024, 0) + assert_equal Rules::DEFAULT_CHUNK_SIZE, Rules.resolve_chunk_size(nil, 0) + + # Negative granularity + assert_equal 1024, Rules.resolve_chunk_size(1024, -256) + assert_equal Rules::DEFAULT_CHUNK_SIZE, Rules.resolve_chunk_size(nil, -1) + end + + def test_resolve_chunk_size_when_divisible + # User-specified evenly divides granularity + assert_equal 1024, Rules.resolve_chunk_size(1024, 256) + assert_equal 1_048_576, Rules.resolve_chunk_size(1_048_576, 262_144) + + # Default chunk size (8_388_608) evenly divides 256 KB standard Scotty granularity + assert_equal 8_388_608, Rules.resolve_chunk_size(nil, 262_144) + assert_equal 8_388_608, Rules.resolve_chunk_size(nil, 524_288) + end + + def test_resolve_chunk_size_when_not_divisible_rounds_down + # User-specified rounds down to nearest multiple + assert_equal 768, Rules.resolve_chunk_size(1000, 256) + assert_equal 9_961_472, Rules.resolve_chunk_size(10_000_000, 262_144) + + # Default chunk size (8_388_608) rounds down with non-divisor granularity (500_000 * 16) + assert_equal 8_000_000, Rules.resolve_chunk_size(nil, 500_000) + assert_equal 8_192_000, Rules.resolve_chunk_size(nil, 1_024_000) + end + + def test_resolve_chunk_size_when_granularity_equal_to_chunk_size + assert_equal 256, Rules.resolve_chunk_size(256, 256) + assert_equal 262_144, Rules.resolve_chunk_size(262_144, 262_144) + assert_equal 8_388_608, Rules.resolve_chunk_size(nil, 8_388_608) + end + + def test_resolve_chunk_size_when_granularity_greater_than_chunk_size + # User chunk size strictly less than granularity promotes to granularity (avoids 0) + assert_equal 256, Rules.resolve_chunk_size(100, 256) + assert_equal 262_144, Rules.resolve_chunk_size(1, 262_144) + + # Default chunk size strictly less than large server granularity promotes to granularity + assert_equal 16_777_216, Rules.resolve_chunk_size(nil, 16_777_216) + end end From 9b6d844d2f1b292af16dd1016e5711fc69f35201 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Thu, 3 Sep 2026 00:36:39 +0000 Subject: [PATCH 10/79] tests: buffer realign --- .../resumable_upload/driver_buffer_test.rb | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb new file mode 100644 index 0000000..4930173 --- /dev/null +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb @@ -0,0 +1,252 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "test_helper" +require "gapic/rest/resumable_upload" +require "stringio" + +## +# Tests for Driver stream reading and buffer realignment mechanics. +# +class DriverBufferTest < Minitest::Test + include Gapic::Rest::ResumableUpload + + ## + # Stream double that returns at most max_chunk_size bytes per read call. + # + class ChunkedStream + def initialize data, max_chunk_size + @io = StringIO.new data + @max_chunk_size = max_chunk_size + end + + def read length = nil + return @io.read if length.nil? + + actual_length = [length, @max_chunk_size].min + @io.read actual_length + end + + def seek offset + @io.seek offset + end + + def pos + @io.pos + end + end + + ## + # Stream double that intentionally does not implement #seek. + # + class UnseekableStream + def initialize data + @io = StringIO.new data + end + + def read length = nil + @io.read length + end + + def pos + @io.pos + end + end + + def setup + @dummy_client = Object.new + end + + # ============================================================================ + # execute_fill_buffer tests + # ============================================================================ + + def test_fill_buffer_short_reads_accumulates_until_target + data = "abcdefghijklmnopqrstuvwxyz" * 4 # 104 bytes + stream = ChunkedStream.new data, 20 + driver = build_driver stream: stream + + event = driver.send :execute_fill_buffer, Instruction::FillBuffer.new(target_bytesize: 100) + + assert_instance_of Event::ChunkRead, event + assert_equal 100, event.bytes_buffered + refute event.eof + assert_equal data.byteslice(0, 100), driver.instance_variable_get(:@buffer) + end + + def test_fill_buffer_eof_exactly_at_target_boundary + data = "0123456789" * 10 # exactly 100 bytes + stream = StringIO.new data + driver = build_driver stream: stream + + event = driver.send :execute_fill_buffer, Instruction::FillBuffer.new(target_bytesize: 100) + + assert_instance_of Event::ChunkRead, event + assert_equal 100, event.bytes_buffered + # eof stays false until a subsequent read attempts to read past boundary + refute event.eof + assert_equal 100, stream.pos + + # Subsequent fill detects EOF + second_event = driver.send :execute_fill_buffer, Instruction::FillBuffer.new(target_bytesize: 101) + assert_equal 100, second_event.bytes_buffered + assert second_event.eof + end + + def test_fill_buffer_eof_mid_fill + data = "short data of 45 bytes......................." # 45 bytes + stream = StringIO.new data + driver = build_driver stream: stream + + event = driver.send :execute_fill_buffer, Instruction::FillBuffer.new(target_bytesize: 100) + + assert_instance_of Event::ChunkRead, event + assert_equal 45, event.bytes_buffered + assert event.eof + assert_equal data, driver.instance_variable_get(:@buffer) + end + + def test_fill_buffer_empty_stream + stream = StringIO.new "" + driver = build_driver stream: stream + + event = driver.send :execute_fill_buffer, Instruction::FillBuffer.new(target_bytesize: 100) + + assert_instance_of Event::ChunkRead, event + assert_equal 0, event.bytes_buffered + assert event.eof + assert_equal "".b, driver.instance_variable_get(:@buffer) + end + + # ============================================================================ + # execute_realign_buffer tests: trim within buffer + # ============================================================================ + + def test_realign_buffer_trim_exact_beginning + driver = build_driver stream: StringIO.new + driver.instance_variable_set :@buffer_start_offset, 1000 + driver.instance_variable_set :@buffer, "0123456789".b + + driver.send :execute_realign_buffer, Instruction::RealignBuffer.new(server_offset: 1000) + + assert_equal 1000, driver.instance_variable_get(:@buffer_start_offset) + assert_equal "0123456789".b, driver.instance_variable_get(:@buffer) + end + + def test_realign_buffer_trim_middle + driver = build_driver stream: StringIO.new + driver.instance_variable_set :@buffer_start_offset, 1000 + driver.instance_variable_set :@buffer, "0123456789".b + + driver.send :execute_realign_buffer, Instruction::RealignBuffer.new(server_offset: 1004) + + assert_equal 1004, driver.instance_variable_get(:@buffer_start_offset) + assert_equal "456789".b, driver.instance_variable_get(:@buffer) + end + + def test_realign_buffer_trim_exact_end + driver = build_driver stream: StringIO.new + driver.instance_variable_set :@buffer_start_offset, 1000 + driver.instance_variable_set :@buffer, "0123456789".b + + driver.send :execute_realign_buffer, Instruction::RealignBuffer.new(server_offset: 1010) + + assert_equal 1010, driver.instance_variable_get(:@buffer_start_offset) + assert_equal "".b, driver.instance_variable_get(:@buffer) + end + + # ============================================================================ + # execute_realign_buffer tests: rewind stream + # ============================================================================ + + def test_realign_buffer_rewind_seekable_stream + stream = StringIO.new "0123456789" * 100 + stream.seek 1000 + driver = build_driver stream: stream + driver.instance_variable_set :@buffer_start_offset, 1000 + driver.instance_variable_set :@buffer, "buffered".b + + driver.send :execute_realign_buffer, Instruction::RealignBuffer.new(server_offset: 500) + + assert_equal 500, driver.instance_variable_get(:@buffer_start_offset) + assert_equal "".b, driver.instance_variable_get(:@buffer) + assert_equal 500, stream.pos + end + + def test_realign_buffer_rewind_unseekable_stream_raises_error + stream = UnseekableStream.new "0123456789" * 100 + driver = build_driver stream: stream + driver.instance_variable_set :@buffer_start_offset, 1000 + driver.instance_variable_set :@buffer, "buffered".b + + err = assert_raises UnseekableStreamError do + driver.send :execute_realign_buffer, Instruction::RealignBuffer.new(server_offset: 500) + end + + assert_includes err.message, "offset 500" + assert_includes err.message, "buffered from 1000" + end + + # ============================================================================ + # execute_realign_buffer tests: fast forward stream + # ============================================================================ + + def test_realign_buffer_fast_forward_seekable_stream + stream = StringIO.new "0123456789" * 200 + stream.seek 1010 + driver = build_driver stream: stream + driver.instance_variable_set :@buffer_start_offset, 1000 + driver.instance_variable_set :@buffer, "0123456789".b # buffer ends at 1010 + + driver.send :execute_realign_buffer, Instruction::RealignBuffer.new(server_offset: 1050) + + assert_equal 1050, driver.instance_variable_get(:@buffer_start_offset) + assert_equal "".b, driver.instance_variable_get(:@buffer) + assert_equal 1050, stream.pos + end + + def test_realign_buffer_fast_forward_unseekable_stream + # Stream contains 1000 bytes. Buffer has consumed up to 10 bytes (buffer_start=0, length=10 -> buffer_end=10). + # Unseekable stream pos is currently at 10. + stream = UnseekableStream.new "0123456789" * 100 + stream.read 10 # advance stream to match buffer_end + assert_equal 10, stream.pos + + driver = build_driver stream: stream + driver.instance_variable_set :@buffer_start_offset, 0 + driver.instance_variable_set :@buffer, "0123456789".b # ends at offset 10 + + # Fast forward to 50 (discards 40 bytes from stream) + driver.send :execute_realign_buffer, Instruction::RealignBuffer.new(server_offset: 50) + + assert_equal 50, driver.instance_variable_get(:@buffer_start_offset) + assert_equal "".b, driver.instance_variable_get(:@buffer) + assert_equal 50, stream.pos + assert_equal "0123456789", stream.read(10) + end + + private + + def build_driver stream: + config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + stream: stream, + upload_size: 1000, + chunk_size: 100 + ) + Driver.new client_stub: @dummy_client, config: config + end +end From 8462abacac1324969a76c082027497d15d3a70a4 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Thu, 3 Sep 2026 00:37:01 +0000 Subject: [PATCH 11/79] test: error mapping in driver --- .../driver_error_mapping_test.rb | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 gapic-common/test/gapic/rest/resumable_upload/driver_error_mapping_test.rb diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_error_mapping_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_error_mapping_test.rb new file mode 100644 index 0000000..b3f4c17 --- /dev/null +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_error_mapping_test.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "test_helper" +require "gapic/rest/resumable_upload" +require "stringio" +require "faraday" + +## +# Tests for Driver network error mapping to protocol events. +# +class DriverErrorMappingTest < Minitest::Test + include Gapic::Rest::ResumableUpload + + ## + # Integration fake client stub that raises configured errors on make_post_request. + # + class FailingClientStub + attr_accessor :error_to_raise + + def make_post_request uri:, body: nil, params: {}, options: {} + raise @error_to_raise if @error_to_raise + + raise "No error configured" + end + end + + def setup + @client_stub = FailingClientStub.new + @config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("0123456789"), + upload_size: 10, + chunk_size: 4 + ) + @driver = Driver.new client_stub: @client_stub, config: @config + end + + # ============================================================================ + # SUT: rescue_request_error + # ============================================================================ + + def test_rescue_request_error_rest_deadline_exceeded + err = Gapic::Rest::DeadlineExceededError.new "RPC deadline exceeded", 504 + event = @driver.send :rescue_request_error, err + + assert_instance_of Event::RequestFailed, event + assert_equal :retries_exhausted, event.kind + assert_equal "RPC deadline exceeded", event.message + assert_same err, event.source_error + + # End-to-end via make_post_request + @client_stub.error_to_raise = err + integration_event = @driver.send :make_post_request, "https://example.com", headers: {}, body: "", + retry_policy: nil + assert_instance_of Event::RequestFailed, integration_event + assert_equal :retries_exhausted, integration_event.kind + assert_same err, integration_event.source_error + end + + def test_rescue_request_error_rest_error_with_status_code + err = Gapic::Rest::Error.new "Service Unavailable", 503, headers: { "Retry-After" => "15" } + event = @driver.send :rescue_request_error, err + + assert_instance_of Event::HttpResponse, event + assert_equal 503, event.status + assert_equal({ "Retry-After" => "15" }, event.headers) + assert_equal "Service Unavailable", event.body + + # End-to-end via make_post_request + @client_stub.error_to_raise = err + integration_event = @driver.send :make_post_request, "https://example.com", headers: {}, body: "", + retry_policy: nil + assert_instance_of Event::HttpResponse, integration_event + assert_equal 503, integration_event.status + assert_equal({ "Retry-After" => "15" }, integration_event.headers) + assert_equal "Service Unavailable", integration_event.body + end + + def test_rescue_request_error_rest_error_without_status_code + err = Gapic::Rest::Error.new "Client network error", nil + event = @driver.send :rescue_request_error, err + + assert_instance_of Event::RequestFailed, event + assert_equal :connection_failed, event.kind + assert_equal "Client network error", event.message + assert_same err, event.source_error + + # End-to-end via make_post_request + @client_stub.error_to_raise = err + integration_event = @driver.send :make_post_request, "https://example.com", headers: {}, body: "", + retry_policy: nil + assert_instance_of Event::RequestFailed, integration_event + assert_equal :connection_failed, integration_event.kind + assert_same err, integration_event.source_error + end + + def test_rescue_request_error_standard_error + err = RuntimeError.new "Unexpected low-level runtime error" + event = @driver.send :rescue_request_error, err + + assert_instance_of Event::RequestFailed, event + assert_equal :connection_failed, event.kind + assert_equal "Unexpected low-level runtime error", event.message + assert_same err, event.source_error + + # End-to-end via make_post_request + @client_stub.error_to_raise = err + integration_event = @driver.send :make_post_request, "https://example.com", headers: {}, body: "", + retry_policy: nil + assert_instance_of Event::RequestFailed, integration_event + assert_equal :connection_failed, integration_event.kind + assert_same err, integration_event.source_error + end + + # ============================================================================ + # SUT: rescue_faraday_error + # ============================================================================ + + def test_rescue_faraday_error_with_response + response_env = { + status: 400, + headers: { "x-goog-upload-status" => "final" }, + body: '{"error":{"message":"Bad Request"}}' + } + err = Faraday::ClientError.new "the server responded with status 400", response_env + event = @driver.send :rescue_faraday_error, err + + assert_instance_of Event::HttpResponse, event + assert_equal 400, event.status + assert_equal({ "x-goog-upload-status" => "final" }, event.headers) + assert_equal '{"error":{"message":"Bad Request"}}', event.body + + # End-to-end via make_post_request + @client_stub.error_to_raise = err + integration_event = @driver.send :make_post_request, "https://example.com", headers: {}, body: "", + retry_policy: nil + assert_instance_of Event::HttpResponse, integration_event + assert_equal 400, integration_event.status + assert_equal '{"error":{"message":"Bad Request"}}', integration_event.body + end + + def test_rescue_faraday_error_timeout + err = Faraday::TimeoutError.new "Net::ReadTimeout with https://example.com" + event = @driver.send :rescue_faraday_error, err + + assert_instance_of Event::RequestFailed, event + assert_equal :connection_failed, event.kind + assert_equal "Net::ReadTimeout with https://example.com", event.message + assert_same err, event.source_error + + # End-to-end via make_post_request + @client_stub.error_to_raise = err + integration_event = @driver.send :make_post_request, "https://example.com", headers: {}, body: "", + retry_policy: nil + assert_instance_of Event::RequestFailed, integration_event + assert_equal :connection_failed, integration_event.kind + assert_same err, integration_event.source_error + end + + def test_rescue_faraday_error_connection_failed + err = Faraday::ConnectionFailed.new "Connection refused - connect(2)" + event = @driver.send :rescue_faraday_error, err + + assert_instance_of Event::RequestFailed, event + assert_equal :connection_failed, event.kind + assert_equal "Connection refused - connect(2)", event.message + assert_same err, event.source_error + + # End-to-end via make_post_request + @client_stub.error_to_raise = err + integration_event = @driver.send :make_post_request, "https://example.com", headers: {}, body: "", + retry_policy: nil + assert_instance_of Event::RequestFailed, integration_event + assert_equal :connection_failed, integration_event.kind + assert_same err, integration_event.source_error + end + + def test_rescue_faraday_error_generic_without_response + err = Faraday::Error.new "Generic transport error without response env" + event = @driver.send :rescue_faraday_error, err + + assert_instance_of Event::RequestFailed, event + assert_equal :retries_exhausted, event.kind + assert_equal "Generic transport error without response env", event.message + assert_same err, event.source_error + + # End-to-end via make_post_request + @client_stub.error_to_raise = err + integration_event = @driver.send :make_post_request, "https://example.com", headers: {}, body: "", + retry_policy: nil + assert_instance_of Event::RequestFailed, integration_event + assert_equal :retries_exhausted, integration_event.kind + assert_same err, integration_event.source_error + end +end From f2f0ab7fd24a0d3e4de58535251e572c93627c31 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Thu, 3 Sep 2026 00:43:28 +0000 Subject: [PATCH 12/79] tests: retry policies --- .../resumable_upload/retry_policies_test.rb | 222 ++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 gapic-common/test/gapic/rest/resumable_upload/retry_policies_test.rb diff --git a/gapic-common/test/gapic/rest/resumable_upload/retry_policies_test.rb b/gapic-common/test/gapic/rest/resumable_upload/retry_policies_test.rb new file mode 100644 index 0000000..da1a692 --- /dev/null +++ b/gapic-common/test/gapic/rest/resumable_upload/retry_policies_test.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "test_helper" +require "gapic/rest/resumable_upload" +require "ostruct" +require "faraday" + +## +# Tests for ResumableUpload RetryPolicies and header extraction. +# +class RetryPoliciesTest < Minitest::Test + include Gapic::Rest::ResumableUpload + + # ============================================================================ + # SUT: extract_headers + # ============================================================================ + + def test_extract_headers_from_headers_method + obj = OpenStruct.new headers: { "X-Test-Header" => "value1" } + assert_equal({ "X-Test-Header" => "value1" }, RetryPolicies.extract_headers(obj)) + end + + def test_extract_headers_from_response_headers_method + obj = OpenStruct.new response_headers: { "X-Test-Header" => "value2" } + assert_equal({ "X-Test-Header" => "value2" }, RetryPolicies.extract_headers(obj)) + end + + def test_extract_headers_from_faraday_response_hash + err = Faraday::ClientError.new "error message", { headers: { "X-Test-Header" => "value3" } } + assert_equal({ "X-Test-Header" => "value3" }, RetryPolicies.extract_headers(err)) + end + + def test_extract_headers_returns_nil_when_no_headers_present + assert_nil RetryPolicies.extract_headers(StandardError.new("error")) + assert_nil RetryPolicies.extract_headers(nil) + assert_nil RetryPolicies.extract_headers(Object.new) + assert_nil RetryPolicies.extract_headers("string") + assert_nil RetryPolicies.extract_headers({}) + end + + # ============================================================================ + # SUT: RetryPolicies.default_start + # ============================================================================ + + def test_default_start_missing_status_header_retries_unconditionally + policy = RetryPolicies.default_start + + # Retriable code (503) without status header + err_503 = OpenStruct.new response_status: 503, headers: { "Content-Type" => "text/plain" } + assert policy.retry_error?(err_503) + + # Non-retriable code (400) without status header (still retries because missing status header is retriable) + err_400 = OpenStruct.new response_status: 400, headers: { "Content-Type" => "text/plain" } + assert policy.retry_error?(err_400) + + # Status 200 OK without status header + resp_200 = OpenStruct.new response_status: 200, headers: { "Content-Type" => "text/plain" } + assert policy.retry_error?(resp_200) + + # No status code without status header + err_no_code = OpenStruct.new headers: { "Content-Type" => "text/plain" } + assert policy.retry_error?(err_no_code) + + # Empty status header string + err_empty_status = OpenStruct.new response_status: 400, headers: { "X-Goog-Upload-Status" => "" } + assert policy.retry_error?(err_empty_status) + end + + def test_default_start_with_status_header_falls_back_to_codes + policy = RetryPolicies.default_start + + # Retriable code (503) with status header + err_503 = OpenStruct.new response_status: 503, headers: { "X-Goog-Upload-Status" => "active" } + assert policy.retry_error?(err_503) + + # Non-retriable code (400) with status header + err_400 = OpenStruct.new response_status: 400, headers: { "X-Goog-Upload-Status" => "active" } + refute policy.retry_error?(err_400) + + # Without status code with status header + err_no_code = OpenStruct.new headers: { "X-Goog-Upload-Status" => "active" } + refute policy.retry_error?(err_no_code) + end + + def test_default_start_no_headers_falls_back_to_codes + policy = RetryPolicies.default_start + + # Retriable code (503) without headers + err_503 = OpenStruct.new response_status: 503 + assert policy.retry_error?(err_503) + + # Non-retriable code (400) without headers + err_400 = OpenStruct.new response_status: 400 + refute policy.retry_error?(err_400) + + # Without status code and without headers + err_no_code = RuntimeError.new "generic network error" + refute policy.retry_error?(err_no_code) + end + + # ============================================================================ + # SUT: RetryPolicies.default_control_plane + # ============================================================================ + + def test_default_control_plane_missing_status_header_falls_back_to_codes + policy = RetryPolicies.default_control_plane + + # Retriable code (503) without status header + err_503 = OpenStruct.new response_status: 503, headers: { "Content-Type" => "text/plain" } + assert policy.retry_error?(err_503) + + # Non-retriable code (400) without status header + err_400 = OpenStruct.new response_status: 400, headers: { "Content-Type" => "text/plain" } + refute policy.retry_error?(err_400) + + # Without status code without status header + err_no_code = OpenStruct.new headers: { "Content-Type" => "text/plain" } + refute policy.retry_error?(err_no_code) + end + + def test_default_control_plane_with_status_header_falls_back_to_codes + policy = RetryPolicies.default_control_plane + + # Retriable code (503) with status header + err_503 = OpenStruct.new response_status: 503, headers: { "X-Goog-Upload-Status" => "active" } + assert policy.retry_error?(err_503) + + # Non-retriable code (400) with status header + err_400 = OpenStruct.new response_status: 400, headers: { "X-Goog-Upload-Status" => "active" } + refute policy.retry_error?(err_400) + + # Without status code with status header + err_no_code = OpenStruct.new headers: { "X-Goog-Upload-Status" => "active" } + refute policy.retry_error?(err_no_code) + end + + def test_default_control_plane_no_headers_falls_back_to_codes + policy = RetryPolicies.default_control_plane + + # Retriable code (503) without headers + err_503 = OpenStruct.new response_status: 503 + assert policy.retry_error?(err_503) + + # Non-retriable code (400) without headers + err_400 = OpenStruct.new response_status: 400 + refute policy.retry_error?(err_400) + + # Without status code and without headers + err_no_code = RuntimeError.new "generic network error" + refute policy.retry_error?(err_no_code) + end + + # ============================================================================ + # SUT: RetryPolicies.default_data_plane + # ============================================================================ + + def test_default_data_plane_missing_status_header_unretriable + policy = RetryPolicies.default_data_plane + + # Retriable code (503) without status header (predicate returns false -> unretriable) + err_503 = OpenStruct.new response_status: 503, headers: { "Content-Type" => "text/plain" } + refute policy.retry_error?(err_503) + + # Non-retriable code (400) without status header + err_400 = OpenStruct.new response_status: 400, headers: { "Content-Type" => "text/plain" } + refute policy.retry_error?(err_400) + + # Without status code without status header + err_no_code = OpenStruct.new headers: { "Content-Type" => "text/plain" } + refute policy.retry_error?(err_no_code) + + # Empty status header string + err_empty_status = OpenStruct.new response_status: 503, headers: { "X-Goog-Upload-Status" => "" } + refute policy.retry_error?(err_empty_status) + end + + def test_default_data_plane_with_status_header_falls_back_to_codes + policy = RetryPolicies.default_data_plane + + # Retriable code (503) with status header + err_503 = OpenStruct.new response_status: 503, headers: { "X-Goog-Upload-Status" => "active" } + assert policy.retry_error?(err_503) + + # Non-retriable code (400) with status header + err_400 = OpenStruct.new response_status: 400, headers: { "X-Goog-Upload-Status" => "active" } + refute policy.retry_error?(err_400) + + # Without status code with status header + err_no_code = OpenStruct.new headers: { "X-Goog-Upload-Status" => "active" } + refute policy.retry_error?(err_no_code) + end + + def test_default_data_plane_no_headers_falls_back_to_codes + policy = RetryPolicies.default_data_plane + + # Retriable code (503) without headers + err_503 = OpenStruct.new response_status: 503 + assert policy.retry_error?(err_503) + + # Non-retriable code (400) without headers + err_400 = OpenStruct.new response_status: 400 + refute policy.retry_error?(err_400) + + # Without status code and without headers + err_no_code = RuntimeError.new "generic network error" + refute policy.retry_error?(err_no_code) + end +end From 1cc1810e0843804c0b386332428bee5eee3542ff Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Thu, 3 Sep 2026 00:52:26 +0000 Subject: [PATCH 13/79] fixed the callback documentation and added callback tests --- gapic-common/design/implementation-guide.md | 2 +- .../design/reference-implementation.md | 4 +- .../lib/gapic/rest/resumable_upload/driver.rb | 2 - .../resumable_upload/driver_progress_test.rb | 130 ++++++++++++++++++ 4 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index 23fff4c..3c09154 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -167,7 +167,7 @@ The `Driver` is the synchronous execution engine for the pure protocol state mac The Driver categorizes instructions into three execution types: 1. **Synchronous Side-Effects** (`NotifyProgress`, `RealignBuffer`): * Executed immediately in-process. - * Do not yield a new `Event` and do not break the batch loop. + * Do not yield a new `Event` and do not break the batch loop. Exceptions raised within user callbacks (e.g. `on_progress`) are not swallowed and immediately propagate to the caller. 2. **I/O & Network Operations** (`FillBuffer`, `SendStart`, `SendChunk`, `SendFinalize`, `SendQuery`, `SendCancel`): * Execute physical stream reads or HTTP requests (wrapped in `Gapic::Common::RetryPolicy` for Category 1 transient errors). * Yield a single resulting `Event` (`ChunkRead`, `HttpResponse`, or `RequestFailed`) that becomes the input for the next cycle. diff --git a/gapic-common/design/reference-implementation.md b/gapic-common/design/reference-implementation.md index cd81bc4..8755629 100644 --- a/gapic-common/design/reference-implementation.md +++ b/gapic-common/design/reference-implementation.md @@ -514,11 +514,9 @@ module Gapic instructions.any? { |i| i.is_a?(Instruction::TerminateSuccess) || i.is_a?(Instruction::TerminateFailure) } end - # Synchronous side-effect: invokes user callback safely + # Synchronous side-effect: invokes user callback (exceptions propagate to caller) def execute_notify_progress(instruction) @config.on_progress&.call(instruction.bytes_uploaded, instruction.total_bytes) - rescue StandardError => e - @logger&.warn { "User progress callback raised exception: #{e.message}" } end # Synchronous side-effect: adjusts in-memory buffer window and stream diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index 0a528e7..f7bc676 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -140,8 +140,6 @@ def terminal_instructions? instructions def execute_notify_progress instruction @config.on_progress&.call instruction.bytes_uploaded, instruction.total_bytes - rescue StandardError => e - @logger&.warn { "User progress callback raised exception: #{e.message}" } end def execute_realign_buffer instruction diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb new file mode 100644 index 0000000..4b05281 --- /dev/null +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "test_helper" +require "gapic/rest/resumable_upload" +require "stringio" + +## +# Tests for Driver progress notification dispatching and callback error propagation. +# +class DriverProgressTest < Minitest::Test + include Gapic::Rest::ResumableUpload + + CustomCallbackError = Class.new StandardError + FakeResponse = Struct.new :status, :headers, :body, keyword_init: true + + class ScriptedClientStub + def initialize responses + @responses = responses + end + + def make_post_request uri:, body: nil, params: {}, options: {} + raise "No scripted response" if @responses.empty? + + @responses.shift + end + end + + def test_execute_notify_progress_without_callback_does_not_raise + driver = build_driver on_progress: nil + + instruction = Instruction::NotifyProgress.new bytes_uploaded: 1024, total_bytes: 4096 + # Must not raise when callback is nil + driver.send :execute_notify_progress, instruction + end + + def test_execute_notify_progress_happy_path_invoked_once + calls = [] + callback = ->(bytes_uploaded, total_bytes) { calls << [bytes_uploaded, total_bytes] } + driver = build_driver on_progress: callback + + instruction = Instruction::NotifyProgress.new bytes_uploaded: 500, total_bytes: 1000 + driver.send :execute_notify_progress, instruction + + assert_equal 1, calls.size + assert_equal [500, 1000], calls.first + end + + def test_execute_notify_progress_total_bytes_nil_passes_through + calls = [] + callback = ->(bytes_uploaded, total_bytes) { calls << [bytes_uploaded, total_bytes] } + driver = build_driver on_progress: callback + + instruction = Instruction::NotifyProgress.new bytes_uploaded: 250, total_bytes: nil + driver.send :execute_notify_progress, instruction + + assert_equal 1, calls.size + assert_equal [250, nil], calls.first + end + + def test_execute_notify_progress_raises_error_to_caller_when_callback_fails + callback = ->(_bytes, _total) { raise CustomCallbackError, "User UI crashed in progress callback" } + driver = build_driver on_progress: callback + + instruction = Instruction::NotifyProgress.new bytes_uploaded: 100, total_bytes: 1000 + err = assert_raises CustomCallbackError do + driver.send :execute_notify_progress, instruction + end + + assert_equal "User UI crashed in progress callback", err.message + end + + def test_driver_run_propagates_callback_error_end_to_end + # Script responses: 1. start response -> 2. chunk response (triggers NotifyProgress) + responses = [ + FakeResponse.new( + status: 200, + headers: { "x-goog-upload-url" => "https://example.com/upload/123", "x-goog-upload-status" => "active" }, + body: "" + ), + FakeResponse.new( + status: 200, + headers: { "x-goog-upload-status" => "active" }, + body: "" + ) + ] + stub = ScriptedClientStub.new responses + + callback = ->(_bytes, _total) { raise CustomCallbackError, "Terminal failure in user progress handler" } + config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("0123456789"), + upload_size: 10, + chunk_size: 4, + on_progress: callback + ) + driver = Driver.new client_stub: stub, config: config + + err = assert_raises CustomCallbackError do + driver.run + end + assert_equal "Terminal failure in user progress handler", err.message + end + + private + + def build_driver on_progress: nil + config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("0123456789"), + upload_size: 10, + chunk_size: 4, + on_progress: on_progress + ) + Driver.new client_stub: Object.new, config: config + end +end From 8da18015aa6ce5b409bd7446f5c53837dc4e5c39 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Sat, 5 Sep 2026 18:55:46 +0000 Subject: [PATCH 14/79] feat: sensible defaults for the global deadline, driver tests update --- gapic-common/design/implementation-guide.md | 22 +- .../design/reference-implementation.md | 21 +- gapic-common/design/test-plan.md | 202 ++++++++++++++++++ .../gapic/rest/resumable_upload/data_types.rb | 33 ++- .../lib/gapic/rest/resumable_upload/driver.rb | 23 +- .../rest/resumable_upload/data_types_test.rb | 2 +- .../resumable_upload/driver_config_test.rb | 137 ++++++++++++ .../resumable_upload/driver_retry_test.rb | 189 ++++++++++++++++ .../rest/resumable_upload/driver_test.rb | 131 +----------- 9 files changed, 620 insertions(+), 140 deletions(-) create mode 100644 gapic-common/design/test-plan.md create mode 100644 gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb create mode 100644 gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index 3c09154..b93c165 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -48,7 +48,7 @@ module Gapic :upload_size, # [Integer, nil] Total upload bytes if known upfront :chunk_size, # [Integer, nil] Explicit chunk size in bytes :content_type, # [String] MIME type of uploaded media - :deadline, # [Numeric, nil] Absolute monotonic deadline in seconds (Process.clock_gettime(Process::CLOCK_MONOTONIC)) + :timeout, # [Numeric, nil] Total upload timeout in seconds (zero/negative treated as nil) :start_retry_policy, # [Gapic::Common::RetryPolicy, nil] Default policy for start command :control_plane_retry_policy, # [Gapic::Common::RetryPolicy, nil] Policy for query/cancel commands :data_plane_retry_policy, # [Gapic::Common::RetryPolicy, nil] Policy for upload/finalize @@ -91,7 +91,7 @@ end * `message`: Human-readable summary string. * `source_error`: Original underlying exception, preserved for terminal error propagation and logging. * `Event::Cancel`: Caller requested session cancellation. -* `Event::GlobalDeadlineExceeded`: Absolute monotonic clock exceeded `config.deadline`. +* `Event::GlobalDeadlineExceeded`: Absolute monotonic clock exceeded the session deadline (`@deadline`) computed at the start of `Driver#run`. ### 2.4 Instructions Vocabulary (Core -> Driver) * `Instruction::SendStart.new(url:, headers:, body:)`: Execute initiation request to establish upload session. @@ -387,6 +387,24 @@ To realign the upload state, the `Driver` processes `Instruction::RealignBuffer( * If unseekable: reads and discards `server_offset - current_stream_pos` bytes from `stream`. * The Driver sets `buffer_start_offset = server_offset`. +### 6.3 Sensible Defaults for Global Deadline +Every upload session executed via `Driver#run` must have a finite, guaranteed upper bound on total wall-clock execution time. Without a mandatory global deadline, a session encountering repeated Category 2 protocol recoveries or intermittent network stalls could hang indefinitely. + +To guarantee termination, `Driver#run` establishes an absolute monotonic deadline at the very start of execution: +```ruby +@deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + resolve_timeout +``` + +#### Timeout Resolution Algorithm (`resolve_timeout`) +The total session timeout is resolved in priority order: +1. **Explicit User Timeout (`config.timeout`)**: If `config.timeout` is present and strictly positive (`config.timeout&.positive?`), that value is used directly. Zero or negative values are treated as unset (`nil`). +2. **Size-Proportional Timeout (`config.upload_size`)**: If total `upload_size` is known upfront, the timeout is computed assuming a minimum sustained upload throughput of `MIN_ASSUMED_THROUGHPUT = 1_048_576` bytes/sec (1 MB/s), floored by `BASE_TIMEOUT = 3_600` seconds (1 hour): + ```ruby + [config.upload_size.fdiv(MIN_ASSUMED_THROUGHPUT), BASE_TIMEOUT].max + ``` + *Rationale*: Using `BASE_TIMEOUT` as a floor prevents sub-millisecond timeouts for small payloads while scaling linearly for multi-gigabyte uploads. +3. **Default Base Timeout (`BASE_TIMEOUT`)**: If neither a positive timeout nor `upload_size` is provided (e.g., streaming uploads of unknown length), the timeout defaults to `BASE_TIMEOUT` (`3_600` seconds). + --- ## 7. Observability Standards diff --git a/gapic-common/design/reference-implementation.md b/gapic-common/design/reference-implementation.md index 8755629..fe124eb 100644 --- a/gapic-common/design/reference-implementation.md +++ b/gapic-common/design/reference-implementation.md @@ -392,6 +392,12 @@ module Gapic class Driver include Gapic::LoggingConcerns + # Minimum assumed upload throughput in bytes per second (1 MB/s) + MIN_ASSUMED_THROUGHPUT = 1_048_576 + + # Default base timeout in seconds (1 hour) + BASE_TIMEOUT = 3_600 + # @param client_stub [Gapic::Rest::ClientStub] # @param config [CompleteUploadConfig] # @param logger [Logger, nil] Optional logger @@ -465,6 +471,7 @@ module Gapic # # @return [String, Object] Final response body def run + @deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + resolve_timeout pending_event = Event::StartUpload loop do @@ -504,10 +511,20 @@ module Gapic private + def resolve_timeout + return @config.timeout if @config.timeout&.positive? + + if @config.upload_size + [@config.upload_size.fdiv(MIN_ASSUMED_THROUGHPUT), BASE_TIMEOUT].max + else + BASE_TIMEOUT + end + end + def deadline_exceeded? - return false unless @config.deadline + return false unless @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC) > @config.deadline + Process.clock_gettime(Process::CLOCK_MONOTONIC) > @deadline end def terminal_instructions?(instructions) diff --git a/gapic-common/design/test-plan.md b/gapic-common/design/test-plan.md new file mode 100644 index 0000000..81ec701 --- /dev/null +++ b/gapic-common/design/test-plan.md @@ -0,0 +1,202 @@ +# Resumable Upload Test Plan + +This document outlines the complete unit and integration test plan for the Resumable Upload protocol implementation in `gapic-common`. It details all test suites, systems under test (SUT), test doubles, test cases, and behavioral assertions added across the protocol layers. + +--- + +## 1. Test Architecture Overview + +```mermaid +flowchart TD + subgraph TestSuites["Test Suites"] + RC["rules_classification_test.rb
(Rules Classification & Utility)"] + RT["rules_test.rb
(Rules State Machine & Error Formatting)"] + RP["retry_policies_test.rb
(Retry Policies & Header Extraction)"] + DB["driver_buffer_test.rb
(Stream Buffering & Realignment)"] + DE["driver_error_mapping_test.rb
(Network Error Mapping)"] + DP["driver_progress_test.rb
(Progress Dispatch & Error Propagation)"] + DT["driver_test.rb
(Driver Upload Execution Loop)"] + DR["driver_retry_test.rb
(Driver Initiation & Query Retries)"] + DC["driver_config_test.rb
(Driver Configuration & Deadlines)"] + end + + subgraph SUT["Systems Under Test"] + RulesClassify["Rules.shape_of
Rules.classify_http_response
Rules.header_value
Rules.resolve_chunk_size"] + RulesStep["Rules.step
Rules.fail_with_unmatched_transition"] + Policies["RetryPolicies.default_start
RetryPolicies.default_control_plane
RetryPolicies.default_data_plane
RetryPolicies.extract_headers"] + DriverIO["Driver#execute_fill_buffer
Driver#execute_realign_buffer"] + DriverErr["Driver#rescue_request_error
Driver#rescue_faraday_error"] + DriverProg["Driver#execute_notify_progress"] + DriverRun["Driver#run
Driver#execute_send_chunk"] + DriverRetry["Driver#execute_send_start
Driver#execute_send_query"] + DriverConfig["Driver#resolve_timeout
Driver#deadline_exceeded?"] + end + + RC --> RulesClassify + RT --> RulesStep + RP --> Policies + DB --> DriverIO + DE --> DriverErr + DP --> DriverProg + DT --> DriverRun + DR --> DriverRetry + DC --> DriverConfig +``` + +--- + +## 2. Test Doubles & Fixtures + +| Double Name | Location | Description & Behavior | +| :--- | :--- | :--- | +| `ChunkedStream` | [driver_buffer_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb#L29) | Wraps `StringIO`; caps returned bytes per `#read(length)` call to simulate socket/pipe short reads. | +| `UnseekableStream` | [driver_buffer_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb#L53) | Wraps `StringIO` with `#read` but explicitly omits `#seek` (`respond_to?(:seek)` is `false`). | +| `FailingClientStub` | [driver_error_mapping_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/driver_error_mapping_test.rb#L30) | Integration fake client stub configured with `@error_to_raise` to verify exception rescue in `Driver#make_post_request`. | +| `ScriptedClientStub` | [driver_progress_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb#L30) / [driver_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb#L30) | Yields a deterministic sequence of HTTP response structs and records dispatched requests. | + +--- + +## 3. Detailed Test Suites & Cases + +### 3.1 Rules Classification & Utility ([rules_classification_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb)) + +#### A. Case-Insensitive Header Extraction (`Rules.header_value`) +* **Exact match**: Exact key casing (`"X-Goog-Upload-Status"` $\rightarrow$ `"active"`). +* **Key case-insensitivity**: Resolves lowercase (`"x-goog-upload-status"`), uppercase (`"X-GOOG-UPLOAD-STATUS"`), and mixed-case (`"x-Goog-UpLoad-Status"`). +* **Symbol keys**: Resolves symbols in headers hash (`:"x-goog-upload-status"`). +* **Missing or non-hash input**: Returns `nil` when key is absent, or when headers object is `nil`, `[]`, or a String. + +#### B. HTTP Response Classification (`Rules.classify_http_response`) +* **`active` status header**: + * HTTP 200 + `active` (and casing variants `"Active"`, `"ACTIVE"`, `"aCtIvE"`) $\rightarrow$ `:response_active`. + * Non-200 (HTTP 503, 500, 400, 408) + `active` $\rightarrow$ `:response_cat2`. +* **`final` status header**: + * HTTP 200 + `final` (and casing variants `"Final"`, `"FINAL"`) $\rightarrow$ `:response_final`. + * Non-200 (HTTP 400, 404, 500) + `final` $\rightarrow$ `:response_rejected`. +* **`cancelled` status header**: + * HTTP 200 + `cancelled` (and casing variants `"Cancelled"`, `"CANCELLED"`) $\rightarrow$ `:response_cancelled`. + * Non-200 (HTTP 400, 500) + `cancelled` $\rightarrow$ `:response_fatal_bad_response`. +* **Missing or empty status header (`nil`, `""`)**: + * HTTP 200 without status header $\rightarrow$ `:response_cat2`. + * All 7 recoverable status codes (`400, 408, 409, 412, 416, 429, 499`) without status header $\rightarrow$ `:response_cat2`. + * 5xx server/gateway errors (`500, 502, 503, 504`) without status header $\rightarrow$ `:response_cat2`. + * All 7 fatal status codes (`401, 403, 404, 405, 410, 413, 415`) without status header or with empty status header $\rightarrow$ `:response_fatal_bad_response`. +* **Unknown status header values**: + * Non-standard status strings (`"absconded"`, `"pending"`, `"in_progress"`, `"error"`, `"unknown"`) with HTTP 200 or 400 $\rightarrow$ `:response_fatal_bad_response`. +* **Header key variations**: + * Correct classification regardless of key casing (`"x-goog-upload-status"`, `"X-GOOG-UPLOAD-STATUS"`, `:"x-goog-upload-status"`). + +#### C. Event Shape Classification (`Rules.shape_of`) +* **Control events**: `Event::StartUpload`, `Event::Cancel`, `Event::GlobalDeadlineExceeded` as instances and class singletons. +* **Stream chunk reads**: Full chunks (`:chunk_read_full`), EOF with remaining data (`:chunk_read_eof_with_data`), EOF empty (`:chunk_read_eof_empty`). +* **Transport failures**: Retries exhausted (`:request_retries_exhausted`), connection failures (`:request_connection_failed`), unknown/other kinds (`:request_failed_unknown`). +* **HTTP responses**: Delegates cleanly to `classify_http_response`. +* **Unrecognized objects**: Arbitrary objects (`Object.new`, `nil`, `"string"`) $\rightarrow$ `:unknown`. + +#### D. Chunk Size Negotiation (`Rules.resolve_chunk_size`) +* **`nil`, `0`, or negative granularity**: Preserves user-specified chunk size or falls back to `DEFAULT_CHUNK_SIZE` (`8_388_608`) without modulo errors. +* **Evenly divisible**: Preserves chunk size when user size or default size divides server granularity evenly. +* **Not divisible (downward alignment)**: Rounds down to the nearest multiple of granularity (e.g. 1000 with granularity 256 $\rightarrow$ 768; 10 MB with 256 KB $\rightarrow$ 9,961,472 bytes; default 8 MB with 500 KB $\rightarrow$ 8 MB). +* **Equal size**: Preserves chunk size when size equals granularity (256 and 256 $\rightarrow$ 256). +* **Granularity strictly greater than chunk size**: Promotes chunk size to granularity to avoid rounding down to 0 (e.g. 100 with granularity 256 $\rightarrow$ 256; 1 with 256 KB $\rightarrow$ 256 KB; default 8 MB with 16 MB $\rightarrow$ 16 MB). + +--- + +### 3.2 State Machine Actionable Errors ([rules_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb)) + +* **Actionable description on unexpected HTTP response**: + * Unmatched event (HTTP 200 `Status: final` while in `:transmission_sending`) raises `InvalidTransitionError`. + * Verifies human phrasing: `"Resumable upload failed while sending a chunk of data: received an unexpected HTTP 200 response (X-Goog-Upload-Status: 'final')."` + * Verifies error properties: `err.response`, `err.event`, `err.state`. +* **Missing status header formatting in error**: + * Unexpected response lacking `X-Goog-Upload-Status` formats header description as `(X-Goog-Upload-Status: missing)`. +* **Actionable description for non-HTTP unexpected events**: + * Stream chunk read while in `:starting` raises message stating `"initiating upload session: received unexpected stream chunk read (512 bytes, eof: false)"` with `err.response == nil`. + +--- + +### 3.3 Driver Stream Buffering & Realignment ([driver_buffer_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb)) + +#### A. Stream Reading (`Driver#execute_fill_buffer`) +* **Short reads**: `ChunkedStream` returning at most 20 bytes per read call repeatedly accumulates until buffer hits target 100 bytes (`bytes_buffered: 100, eof: false`). +* **EOF at target boundary**: Stream with exactly 100 bytes for a 100-byte target stops reading once target is satisfied; `eof` remains `false` until subsequent read. +* **EOF mid-fill**: Stream with 45 bytes for a 100-byte target detects EOF, returns `bytes_buffered: 45, eof: true`, and stores 45 bytes in buffer. +* **Empty stream**: 0-byte stream returns `bytes_buffered: 0, eof: true` with empty buffer. + +#### B. Buffer Realignment (`Driver#execute_realign_buffer`) +* **Trim within buffer**: + * *Exact beginning*: `server_offset` matching buffer start leaves buffer intact. + * *Middle*: `server_offset` in middle slices buffer and updates start offset. + * *Exact end*: `server_offset` at end empties buffer and updates start offset. +* **Rewind stream**: + * *Seekable*: Rewinds stream position and resets buffer to target offset. + * *Unseekable*: Raises `UnseekableStreamError` with target and current buffer offsets in message. +* **Fast-forward stream**: + * *Seekable*: Seeks stream forward and resets buffer to target offset. + * *Unseekable*: Reads and discards needed bytes from stream to advance to target offset. + +--- + +### 3.4 Driver Network Error Mapping ([driver_error_mapping_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/driver_error_mapping_test.rb)) + +* **`Driver#rescue_request_error`**: + * `Gapic::Rest::DeadlineExceededError` $\rightarrow$ `Event::RequestFailed(kind: :retries_exhausted)` preserving error message and `source_error`. + * `Gapic::Rest::Error` with HTTP status code $\rightarrow$ `Event::HttpResponse(status:, headers:, body:)`. + * `Gapic::Rest::Error` without status code $\rightarrow$ `Event::RequestFailed(kind: :connection_failed)`. + * `StandardError` (`RuntimeError`) $\rightarrow$ `Event::RequestFailed(kind: :connection_failed)`. +* **`Driver#rescue_faraday_error`**: + * `Faraday::Error` with response hash $\rightarrow$ `Event::HttpResponse(status: 400, headers:, body:)`. + * `Faraday::TimeoutError` $\rightarrow$ `Event::RequestFailed(kind: :connection_failed)`. + * `Faraday::ConnectionFailed` $\rightarrow$ `Event::RequestFailed(kind: :connection_failed)`. + * Generic `Faraday::Error` without response $\rightarrow$ `Event::RequestFailed(kind: :retries_exhausted)`. +* **Integration verification**: All mappings verified both directly and end-to-end through `Driver#make_post_request` via `FailingClientStub`. + +--- + +### 3.5 Retry Policies & Header Extraction ([retry_policies_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/retry_policies_test.rb)) + +#### A. Header Extraction (`RetryPolicies.extract_headers`) +* Extracts from `#headers`, `#response_headers`, and Faraday `#response[:headers]`. Returns `nil` when no headers present. + +#### B. $3 \times 3$ Policy Matrix (`policy.retry_error?`) +| Policy | Headers Present, NO Upload-Status | Headers Present, WITH Upload-Status | NO Headers | +| :--- | :--- | :--- | :--- | +| **`default_start`** | **Retried unconditionally** (`true`) across 503, 400, 200, empty string header, and no error code. | **Falls back to codes**: retries 503; refutes 400 and no code. | **Falls back to codes**: retries 503; refutes 400 and no code. | +| **`default_control_plane`** | **Falls back to codes**: retries 503; refutes 400 and no code. | **Falls back to codes**: retries 503; refutes 400 and no code. | **Falls back to codes**: retries 503; refutes 400 and no code. | +| **`default_data_plane`** | **Unretriable** (`false`) across 503, 400, empty string header, and no code (triggers Cat 2 recovery). | **Falls back to codes**: retries 503; refutes 400 and no code. | **Falls back to codes**: retries 503; refutes 400 and no code. | + +--- + +### 3.6 Progress Notification Dispatching ([driver_progress_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb)) + +* **Safe no-op without callback**: `on_progress: nil` executes without raising. +* **Happy path**: Callback receives `(bytes_uploaded, total_bytes)` once per instruction. +* **Pass-through of `total_bytes: nil`**: `total_bytes` passed as `nil` when upload size is unspecified. +* **Unswallowed callback error propagation**: Exceptions raised within `on_progress` are not swallowed or caught; they immediately propagate to the caller in both `execute_notify_progress` and `Driver#run`. + +--- + +### 3.7 Driver Upload Execution Loop ([driver_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb)) + +* **Multi-chunk upload**: Multi-chunk stream uploads with active status headers succeed and return the final response body String. +* **Protocol recovery during chunk upload**: Missing status header on chunk response triggers `query` recovery and resumes chunk transmission from the server-confirmed offset. + +--- + +### 3.8 Driver Initiation & Query Retries ([driver_retry_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb)) + +* **Session initiation retry loop**: Missing status header on HTTP 200 during `start` triggers `start_retry_policy` and succeeds upon header arrival. +* **Initiation retry exhaustion**: Continuous missing status headers on `start` exhaust retries and dispatch `Event::RequestFailed(kind: :retries_exhausted)`. +* **Control plane non-retry**: Missing status header on `query` does not retry inside `execute_send_query`, returning `Event::HttpResponse` immediately to drive protocol recovery. + +--- + +### 3.9 Driver Configuration & Deadlines ([driver_config_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb)) + +* **Explicit positive timeout precedence**: `resolve_timeout` returns `config.timeout` when strictly positive. +* **Zero or negative timeout handling**: Zero or negative `config.timeout` is treated the same as `nil` (unset), falling back to size-based or `BASE_TIMEOUT` resolution. +* **Size-proportional timeout above base floor**: Large `upload_size` computes timeout as `upload_size.fdiv(MIN_ASSUMED_THROUGHPUT)`. +* **Base timeout floor for small uploads**: Small `upload_size` floors at `BASE_TIMEOUT` (`3_600` seconds). +* **Default base timeout when size is nil**: Unspecified `upload_size` defaults to `BASE_TIMEOUT`. +* **Deadline expiration enforcement**: Monotonic clock exceeding `@deadline` during `Driver#run` triggers `Event::GlobalDeadlineExceeded` and raises `Gapic::Common::DeadlineExceededError`. + diff --git a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb index 96e95ec..34a6b08 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb @@ -20,6 +20,33 @@ module ResumableUpload ## # Immutable configuration for initiating and executing a resumable upload session. # + # @!attribute [r] initial_url + # @return [String] Initial endpoint URI for session initiation + # @!attribute [r] initial_body + # @return [String, nil] Request payload for session initiation + # @!attribute [r] initial_headers + # @return [Hash] Additional headers for initiation + # @!attribute [r] stream + # @return [IO] Binary input stream to upload + # @!attribute [r] upload_size + # @return [Integer, nil] Total upload bytes if known upfront + # @!attribute [r] chunk_size + # @return [Integer, nil] Explicit chunk size in bytes + # @!attribute [r] content_type + # @return [String, nil] MIME type of uploaded media + # @!attribute [r] timeout + # @return [Numeric, nil] Total upload timeout in seconds (zero/negative values treated as nil) + # @!attribute [r] start_retry_policy + # @return [Gapic::Common::RetryPolicy, nil] Default policy for start command + # @!attribute [r] control_plane_retry_policy + # @return [Gapic::Common::RetryPolicy, nil] Policy for query/cancel commands + # @!attribute [r] data_plane_retry_policy + # @return [Gapic::Common::RetryPolicy, nil] Policy for upload/finalize + # @!attribute [r] user_override_start_retry_policy + # @return [Gapic::Common::RetryPolicy, nil] Optional user override for start command + # @!attribute [r] on_progress + # @return [Proc, nil] Callback invoked as `->(bytes_uploaded, total_bytes)` + # CompleteUploadConfig = Data.define( :initial_url, :initial_body, @@ -28,7 +55,7 @@ module ResumableUpload :upload_size, :chunk_size, :content_type, - :deadline, + :timeout, :start_retry_policy, :control_plane_retry_policy, :data_plane_retry_policy, @@ -42,7 +69,7 @@ def initialize initial_url:, upload_size: nil, chunk_size: nil, content_type: nil, - deadline: nil, + timeout: nil, start_retry_policy: nil, control_plane_retry_policy: nil, data_plane_retry_policy: nil, @@ -56,7 +83,7 @@ def initialize initial_url:, upload_size: upload_size, chunk_size: chunk_size, content_type: content_type, - deadline: deadline, + timeout: timeout, start_retry_policy: start_retry_policy, control_plane_retry_policy: control_plane_retry_policy, data_plane_retry_policy: data_plane_retry_policy, diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index f7bc676..1773825 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -35,6 +35,12 @@ module ResumableUpload class Driver include Gapic::LoggingConcerns + # Minimum assumed upload throughput in bytes per second (1 MB/s) + MIN_ASSUMED_THROUGHPUT = 1_048_576 + + # Default base timeout in seconds (1 hour) + BASE_TIMEOUT = 3_600 + # @return [Core] attr_reader :core @@ -82,9 +88,12 @@ def self.default_data_plane_retry_policy ## # Executes event loop until terminal state. + # Establishes a guaranteed monotonic deadline at the start of execution + # using {#resolve_timeout} so the upload cannot stall indefinitely. # # @return [String, Object] Final response body def run + @deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + resolve_timeout pending_event = Event::StartUpload.new loop do @@ -126,10 +135,20 @@ def dispatch_instruction instruction end end + def resolve_timeout + return @config.timeout if @config.timeout&.positive? + + if @config.upload_size + [@config.upload_size.fdiv(MIN_ASSUMED_THROUGHPUT), BASE_TIMEOUT].max + else + BASE_TIMEOUT + end + end + def deadline_exceeded? - return false unless @config.deadline + return false unless @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC) > @config.deadline + Process.clock_gettime(Process::CLOCK_MONOTONIC) > @deadline end def terminal_instructions? instructions diff --git a/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb index f69410a..132ee6d 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb @@ -38,7 +38,7 @@ def test_complete_upload_config_defaults assert_nil config.upload_size assert_nil config.chunk_size assert_nil config.content_type - assert_nil config.deadline + assert_nil config.timeout assert_nil config.start_retry_policy assert_nil config.control_plane_retry_policy assert_nil config.data_plane_retry_policy diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb new file mode 100644 index 0000000..a90b83e --- /dev/null +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "test_helper" +require "gapic/rest/resumable_upload" +require "stringio" + +## +# Tests for ResumableUpload Driver configuration and deadline resolution. +# +class DriverConfigTest < Minitest::Test + include Gapic::Rest::ResumableUpload + + # Fake client stub recording calls and yielding scripted responses. + class FakeClientStub + attr_reader :requests + + def initialize responses = [] + @responses = responses + @requests = [] + end + + def make_post_request uri:, body:, params:, options: + @requests << { uri: uri, body: body, params: params, options: options } + raise "Unexpected request: no scripted response left" if @responses.empty? + + @responses.shift + end + end + + def test_resolve_timeout_prefers_positive_config_timeout + stub = FakeClientStub.new + config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("0123"), + upload_size: 10 * 1_048_576, + timeout: 42 + ) + driver = Driver.new client_stub: stub, config: config + + assert_equal 42, driver.send(:resolve_timeout) + end + + def test_resolve_timeout_treats_zero_timeout_same_as_nil + stub = FakeClientStub.new + config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("0123"), + timeout: 0 + ) + driver = Driver.new client_stub: stub, config: config + + assert_equal Driver::BASE_TIMEOUT, driver.send(:resolve_timeout) + end + + def test_resolve_timeout_treats_negative_timeout_same_as_nil + stub = FakeClientStub.new + config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("0123"), + timeout: -10 + ) + driver = Driver.new client_stub: stub, config: config + + assert_equal Driver::BASE_TIMEOUT, driver.send(:resolve_timeout) + end + + def test_resolve_timeout_calculates_from_upload_size_above_base_timeout + stub = FakeClientStub.new + large_size = 7_200 * Driver::MIN_ASSUMED_THROUGHPUT # 7200 seconds at 1MB/s + config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("0123"), + upload_size: large_size + ) + driver = Driver.new client_stub: stub, config: config + + assert_in_delta 7_200.0, driver.send(:resolve_timeout), 0.001 + end + + def test_resolve_timeout_uses_base_timeout_floor_for_small_upload_size + stub = FakeClientStub.new + config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("0123"), + upload_size: 1_048_576 # 1 second at 1MB/s < 3600 + ) + driver = Driver.new client_stub: stub, config: config + + assert_equal Driver::BASE_TIMEOUT, driver.send(:resolve_timeout) + end + + def test_resolve_timeout_defaults_to_base_timeout_when_upload_size_nil + stub = FakeClientStub.new + config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("0123") + ) + driver = Driver.new client_stub: stub, config: config + + assert_equal Driver::BASE_TIMEOUT, driver.send(:resolve_timeout) + end + + def test_run_raises_deadline_exceeded_when_timeout_expires + stub = FakeClientStub.new + config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("0123"), + upload_size: 4, + chunk_size: 10, + timeout: 5 + ) + driver = Driver.new client_stub: stub, config: config + + # Stub monotonic clock so that initial check sets deadline at t=105, and subsequent checks read t=110 + clock_ticks = [100.0, 110.0, 110.0] + Process.stub :clock_gettime, ->(_clock_id) { clock_ticks.shift || 110.0 } do + assert_raises Gapic::Common::DeadlineExceededError do + driver.run + end + end + assert_empty stub.requests + end +end diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb new file mode 100644 index 0000000..536cec1 --- /dev/null +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "test_helper" +require "gapic/rest/resumable_upload" +require "stringio" + +## +# Tests for ResumableUpload Driver retry behavior during session start and queries. +# +# rubocop:disable Metrics/MethodLength +class DriverRetryTest < Minitest::Test + include Gapic::Rest::ResumableUpload + + FakeResponse = Struct.new :status, :headers, :body, keyword_init: true + + # Fake client stub recording calls and yielding scripted responses. + class FakeClientStub + attr_reader :requests + + def initialize responses + @responses = responses + @requests = [] + end + + def make_post_request uri:, body:, params:, options: + @requests << { uri: uri, body: body, params: params, options: options } + raise "Unexpected request: no scripted response left" if @responses.empty? + + @responses.shift + end + end + + def test_start_retries_when_response_lacks_status_header_even_on_200 + fast_policy = Gapic::Common::RetryPolicy.new( + initial_delay: 0.001, + max_delay: 0.002, + timeout: 1.0, + retry_predicate: lambda do |error_or_response| + headers = RetryPolicies.extract_headers error_or_response + if headers + status_hdr = headers["x-goog-upload-status"] || headers["X-Goog-Upload-Status"] + return true if status_hdr.nil? || status_hdr.empty? + end + nil + end + ) + responses = [ + FakeResponse.new(status: 200, headers: {}, body: ""), + FakeResponse.new( + status: 200, + headers: { + "X-Goog-Upload-URL" => "https://example.com/session/1", + "X-Goog-Upload-Status" => "active" + }, + body: "" + ), + FakeResponse.new(status: 200, headers: { "X-Goog-Upload-Status" => "final" }, body: '{"done":true}') + ] + stub = FakeClientStub.new responses + config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("0123"), + upload_size: 4, + chunk_size: 10, + start_retry_policy: fast_policy + ) + + driver = Driver.new client_stub: stub, config: config + result = driver.run + + assert_equal '{"done":true}', result + assert_equal 3, stub.requests.size + assert_start_request stub.requests[0] + assert_start_request stub.requests[1] + assert_chunk_request stub.requests[2], offset: "0", length: "4", body: "0123", finalize: true + end + + def test_start_exhausts_retries_when_responses_continually_lack_status_header + exhausting_policy = Gapic::Common::RetryPolicy.new( + initial_delay: 0.001, + max_delay: 0.002, + timeout: 0.01, + retry_predicate: lambda do |error_or_response| + headers = RetryPolicies.extract_headers error_or_response + if headers + status_hdr = headers["x-goog-upload-status"] || headers["X-Goog-Upload-Status"] + return true if status_hdr.nil? || status_hdr.empty? + end + nil + end + ) + responses = Array.new(10) { FakeResponse.new status: 200, headers: {}, body: "" } + stub = FakeClientStub.new responses + config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("0123"), + upload_size: 4, + chunk_size: 10, + start_retry_policy: exhausting_policy + ) + + driver = Driver.new client_stub: stub, config: config + err = assert_raises Gapic::Common::BadResponseError do + driver.run + end + + assert_match(/Missing X-Goog-Upload-Status/, err.message) + assert stub.requests.size > 1 + end + + def test_query_does_not_retry_on_missing_status_header_in_driver + responses = [ + FakeResponse.new( + status: 200, + headers: { + "X-Goog-Upload-URL" => "https://example.com/session/1", + "X-Goog-Upload-Status" => "active" + }, + body: "" + ), + FakeResponse.new(status: 503, headers: {}, body: "Service Unavailable"), + FakeResponse.new(status: 200, headers: {}, body: ""), + FakeResponse.new( + status: 200, + headers: { + "X-Goog-Upload-Status" => "active", + "X-Goog-Upload-Size-Received" => "0" + }, + body: "" + ), + FakeResponse.new(status: 200, headers: { "X-Goog-Upload-Status" => "final" }, body: '{"done":true}') + ] + stub = FakeClientStub.new responses + config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("0123"), + upload_size: 4, + chunk_size: 10 + ) + + driver = Driver.new client_stub: stub, config: config + result = driver.run + + assert_equal '{"done":true}', result + assert_equal 5, stub.requests.size + assert_start_request stub.requests[0] + assert_chunk_request stub.requests[1], offset: "0", length: "4", body: "0123", finalize: true + assert_query_request stub.requests[2] + assert_query_request stub.requests[3] + assert_chunk_request stub.requests[4], offset: "0", length: "4", body: "0123", finalize: true + end + + private + + def assert_start_request req + assert_equal "https://example.com/upload", req[:uri] + assert_equal "start", req[:options][:metadata]["X-Goog-Upload-Command"] + end + + def assert_query_request req + assert_equal "https://example.com/session/1", req[:uri] + assert_equal "query", req[:options][:metadata]["X-Goog-Upload-Command"] + end + + def assert_chunk_request req, offset:, length:, body:, finalize: + expected_cmd = finalize ? "upload, finalize" : "upload" + metadata = req[:options][:metadata] + assert_equal "https://example.com/session/1", req[:uri] + assert_equal expected_cmd, metadata["X-Goog-Upload-Command"] + assert_equal offset, metadata["X-Goog-Upload-Offset"] + assert_equal length, metadata["Content-Length"] + assert_equal body, req[:body] + end +end +# rubocop:enable Metrics/MethodLength diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb index 02e02af..f2af923 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb @@ -19,7 +19,7 @@ require "stringio" ## -# Tests for ResumableUpload Driver synchronous execution engine. +# Tests for ResumableUpload Driver synchronous upload execution engine. # class DriverTest < Minitest::Test include Gapic::Rest::ResumableUpload @@ -85,135 +85,6 @@ def test_upload_recovers_when_chunk_response_lacks_status_header assert_chunk_request stub.requests[4], offset: "8", length: "2", body: "89", finalize: true end - def test_start_retries_when_response_lacks_status_header_even_on_200 - fast_policy = Gapic::Common::RetryPolicy.new( - initial_delay: 0.001, - max_delay: 0.002, - timeout: 1.0, - retry_predicate: lambda do |error_or_response| - headers = RetryPolicies.extract_headers error_or_response - if headers - status_hdr = headers["x-goog-upload-status"] || headers["X-Goog-Upload-Status"] - return true if status_hdr.nil? || status_hdr.empty? - end - nil - end - ) - responses = [ - # First start response: 200 OK but NO X-Goog-Upload-Status header - FakeResponse.new(status: 200, headers: {}, body: ""), - # Second start response: valid 200 OK with active header - FakeResponse.new( - status: 200, - headers: { - "X-Goog-Upload-URL" => "https://example.com/session/1", - "X-Goog-Upload-Status" => "active" - }, - body: "" - ), - FakeResponse.new(status: 200, headers: { "X-Goog-Upload-Status" => "final" }, body: '{"done":true}') - ] - stub = FakeClientStub.new responses - config = CompleteUploadConfig.new( - initial_url: "https://example.com/upload", - stream: StringIO.new("0123"), - upload_size: 4, - chunk_size: 10, - start_retry_policy: fast_policy - ) - - driver = Driver.new client_stub: stub, config: config - result = driver.run - - assert_equal '{"done":true}', result - # 2 start requests + 1 chunk request = 3 requests - assert_equal 3, stub.requests.size - assert_start_request stub.requests[0] - assert_start_request stub.requests[1] - assert_chunk_request stub.requests[2], offset: "0", length: "4", body: "0123", finalize: true - end - - def test_start_exhausts_retries_when_responses_continually_lack_status_header - exhausting_policy = Gapic::Common::RetryPolicy.new( - initial_delay: 0.001, - max_delay: 0.002, - timeout: 0.01, - retry_predicate: lambda do |error_or_response| - headers = RetryPolicies.extract_headers error_or_response - if headers - status_hdr = headers["x-goog-upload-status"] || headers["X-Goog-Upload-Status"] - return true if status_hdr.nil? || status_hdr.empty? - end - nil - end - ) - # Return 200 OK without status headers repeatedly - responses = Array.new(10) { FakeResponse.new status: 200, headers: {}, body: "" } - stub = FakeClientStub.new responses - config = CompleteUploadConfig.new( - initial_url: "https://example.com/upload", - stream: StringIO.new("0123"), - upload_size: 4, - chunk_size: 10, - start_retry_policy: exhausting_policy - ) - - driver = Driver.new client_stub: stub, config: config - err = assert_raises Gapic::Common::BadResponseError do - driver.run - end - - assert_match(/Missing X-Goog-Upload-Status/, err.message) - assert stub.requests.size > 1 - end - - def test_query_does_not_retry_on_missing_status_header_in_driver - responses = [ - FakeResponse.new( - status: 200, - headers: { - "X-Goog-Upload-URL" => "https://example.com/session/1", - "X-Goog-Upload-Status" => "active" - }, - body: "" - ), - # Chunk returns 503 without status header -> triggers Category 2 recovery - FakeResponse.new(status: 503, headers: {}, body: "Service Unavailable"), - # Query returns 200 with missing status header -> Core handles recovery retry, - # Driver does not retry query internally - FakeResponse.new(status: 200, headers: {}, body: ""), - # Next query succeeds - FakeResponse.new( - status: 200, - headers: { - "X-Goog-Upload-Status" => "active", - "X-Goog-Upload-Size-Received" => "0" - }, - body: "" - ), - FakeResponse.new(status: 200, headers: { "X-Goog-Upload-Status" => "final" }, body: '{"done":true}') - ] - stub = FakeClientStub.new responses - config = CompleteUploadConfig.new( - initial_url: "https://example.com/upload", - stream: StringIO.new("0123"), - upload_size: 4, - chunk_size: 10 - ) - - driver = Driver.new client_stub: stub, config: config - result = driver.run - - assert_equal '{"done":true}', result - # Verify exact sequence: start, chunk, query1, query2, chunk - assert_equal 5, stub.requests.size - assert_start_request stub.requests[0] - assert_chunk_request stub.requests[1], offset: "0", length: "4", body: "0123", finalize: true - assert_query_request stub.requests[2] - assert_query_request stub.requests[3] - assert_chunk_request stub.requests[4], offset: "0", length: "4", body: "0123", finalize: true - end - private def build_scripted_responses From 7319e80c99bc5162186f8d8cf4bf32aa257fde09 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Sat, 5 Sep 2026 19:23:23 +0000 Subject: [PATCH 15/79] feat: reclassify timeouts --- gapic-common/design/implementation-guide.md | 6 +-- .../design/reference-implementation.md | 5 ++- .../lib/gapic/rest/resumable_upload/driver.rb | 6 ++- .../lib/gapic/rest/resumable_upload/events.rb | 9 +++- .../lib/gapic/rest/resumable_upload/rules.rb | 5 ++- .../driver_error_mapping_test.rb | 8 ++-- .../rules_classification_test.rb | 5 ++- .../gapic/rest/resumable_upload/rules_test.rb | 41 +++++++++++++++++++ 8 files changed, 70 insertions(+), 15 deletions(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index b93c165..81d191d 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -86,8 +86,8 @@ end * `Event::StartUpload`: Start the upload session. * `Event::ChunkRead.new(bytes_buffered:, eof:)`: Binary data buffered in Driver memory; reports total bytes ready in buffer and whether the stream hit EOF. * `Event::HttpResponse.new(status:, headers:, body:)`: Dispatched for any completed HTTP exchange over the wire (including 2xx, 4xx, 5xx, or responses with missing/unexpected headers). `Core` inspects status and headers to determine protocol progression or recovery. -* `Event::RequestFailed.new(kind:, message:, source_error:)`: Dispatched when an HTTP request fails to produce a usable HTTP response (e.g., transport connection errors or `RetryPolicy` exhaustion). - * `kind`: Normalized Symbol enum (`:retries_exhausted`, `:connection_failed`). `Core` branches on `kind` and treats other fields as opaque. +* `Event::RequestFailed.new(kind:, message:, source_error:)`: Dispatched when an HTTP request fails to produce a usable HTTP response (e.g., request timeout, transport connection errors, or `RetryPolicy` exhaustion). + * `kind`: Normalized Symbol enum (`:timeout`, `:connection_failed`, `:retries_exhausted`). `Core` branches on `kind` and treats other fields as opaque. * `message`: Human-readable summary string. * `source_error`: Original underlying exception, preserved for terminal error propagation and logging. * `Event::Cancel`: Caller requested session cancellation. @@ -343,7 +343,7 @@ The implementation distinguishes three categories of network and protocol-level * **Conditions Producing `:response_cat2`**: 1. **Non-200 Active Responses**: Any response with `X-Goog-Upload-Status: active` where HTTP status is non-200. 2. **Missing or Empty `X-Goog-Upload-Status` Header**: Any response lacking `X-Goog-Upload-Status` (or empty) whose HTTP status is **not** in `FATAL_STATUS_CODES` (Section 6.1.3). This includes HTTP 200, 5xx server/gateway errors (`500`, `502`, `503`, `504`), and recoverable client errors (`400`, `408`, `409`, `412`, `416`, `429`, `499`). - 3. **Unretried Data Plane Connection Drops**: `Event::RequestFailed(kind: :connection_failed)` occurring during `Transmission` or `Finalizing`. + 3. **Unretried Data Plane Connection Drops or Request Timeouts**: `Event::RequestFailed(kind: :connection_failed)` or `Event::RequestFailed(kind: :timeout)` (`:request_connection_failed`, `:request_timeout`) occurring during `Transmission` or `Finalizing`. * **Missing Header Handling & Retry Policy Contract**: * *Why Headers Go Missing*: Intermediate proxies, reverse-proxies, or Google Front End (GFE) edge proxies can strip Scotty response headers or return raw HTML/text error pages on failure. * *Session Initiation (`start`)*: Missing `X-Goog-Upload-Status` is treated as **retriable** by `start_retry_policy` (retry predicate returns `true`) across **any response code, including 200 OK**. Driver retries transparently to smooth over transient gateway noise. If retries exhaust, `Starting` transitions to `:error` via `fail_with_request_error` or `fail_with_bad_response` (cannot recover a session before an upload URL is obtained). diff --git a/gapic-common/design/reference-implementation.md b/gapic-common/design/reference-implementation.md index fe124eb..40606e1 100644 --- a/gapic-common/design/reference-implementation.md +++ b/gapic-common/design/reference-implementation.md @@ -42,6 +42,7 @@ module Gapic :global_deadline_exceeded when Event::RequestFailed case event.kind + when :timeout then :request_timeout when :retries_exhausted then :request_retries_exhausted when :connection_failed then :request_connection_failed else :request_failed_unknown @@ -75,7 +76,7 @@ module Gapic send_finalize(state) in [:transmission_sending, :response_active] ack_chunk(state, config) - in [:transmission_sending | :finalizing_sending_upload | :finalizing_sending_finalize, :response_cat2 | :request_connection_failed] + in [:transmission_sending | :finalizing_sending_upload | :finalizing_sending_finalize, :response_cat2 | :request_connection_failed | :request_timeout] enter_recovery(state) in [:finalizing_sending_upload, :response_final] complete_upload_with_data(state, event) @@ -99,7 +100,7 @@ module Gapic fail_with_rejected(state, event) in [_, :response_cat2 | :response_fatal_bad_response] fail_with_bad_response(state, event) - in [_, :request_retries_exhausted | :request_connection_failed | :request_failed_unknown] + in [_, :request_retries_exhausted | :request_connection_failed | :request_timeout | :request_failed_unknown] fail_with_request_error(state, event) else fail_with_unmatched_transition(state, event) diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index 1773825..bd7a85a 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -305,7 +305,7 @@ def make_post_request url, headers:, body:, retry_policy: def rescue_request_error err case err when Gapic::Rest::DeadlineExceededError - Event::RequestFailed.new kind: :retries_exhausted, message: err.message, source_error: err + Event::RequestFailed.new kind: :timeout, message: err.message, source_error: err when Gapic::Rest::Error if err.status_code Event::HttpResponse.new status: err.status_code, headers: err.headers || {}, body: err.message @@ -326,7 +326,9 @@ def rescue_faraday_error err headers: err.response[:headers] || {}, body: err.response[:body] ) - elsif err.is_a?(Faraday::TimeoutError) || err.is_a?(Faraday::ConnectionFailed) + elsif err.is_a? Faraday::TimeoutError + Event::RequestFailed.new kind: :timeout, message: err.message, source_error: err + elsif err.is_a? Faraday::ConnectionFailed Event::RequestFailed.new kind: :connection_failed, message: err.message, source_error: err else Event::RequestFailed.new kind: :retries_exhausted, message: err.message, source_error: err diff --git a/gapic-common/lib/gapic/rest/resumable_upload/events.rb b/gapic-common/lib/gapic/rest/resumable_upload/events.rb index a70e4b3..a36f057 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/events.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/events.rb @@ -45,7 +45,14 @@ def initialize status:, headers: {}, body: nil end ## - # Signals an HTTP request failure (e.g. transport connection failure or retries exhausted). + # Signals an HTTP request failure (e.g. request timeout, transport connection failure, or retries exhausted). + # + # @!attribute [r] kind + # @return [Symbol] Failure kind: `:timeout`, `:connection_failed`, or `:retries_exhausted` + # @!attribute [r] message + # @return [String, nil] Human-readable failure summary + # @!attribute [r] source_error + # @return [StandardError, nil] Original underlying exception # RequestFailed = Data.define :kind, :message, :source_error do def initialize kind:, message: nil, source_error: nil diff --git a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb index fff9c9b..465bdd0 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb @@ -99,7 +99,7 @@ def self.step state, event, config in [:transmission_sending, :response_active] ack_chunk state, config in [:transmission_sending | :finalizing_sending_upload | :finalizing_sending_finalize, - :response_cat2 | :request_connection_failed] + :response_cat2 | :request_connection_failed | :request_timeout] enter_recovery state in [:finalizing_sending_upload, :response_final] complete_upload_with_data state, event @@ -126,7 +126,7 @@ def self.step state, event, config fail_with_bad_response state, event in [:starting | :transmission_sending | :finalizing_sending_upload | :finalizing_sending_finalize | :recovery | :cancelling, - :request_retries_exhausted | :request_connection_failed | :request_failed_unknown] + :request_retries_exhausted | :request_connection_failed | :request_timeout | :request_failed_unknown] fail_with_request_error state, event else fail_with_unmatched_transition state, event @@ -412,6 +412,7 @@ def self.classify_chunk_read event def self.classify_request_failed event case event.kind + when :timeout then :request_timeout when :retries_exhausted then :request_retries_exhausted when :connection_failed then :request_connection_failed else :request_failed_unknown diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_error_mapping_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_error_mapping_test.rb index b3f4c17..9742e2e 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_error_mapping_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_error_mapping_test.rb @@ -58,7 +58,7 @@ def test_rescue_request_error_rest_deadline_exceeded event = @driver.send :rescue_request_error, err assert_instance_of Event::RequestFailed, event - assert_equal :retries_exhausted, event.kind + assert_equal :timeout, event.kind assert_equal "RPC deadline exceeded", event.message assert_same err, event.source_error @@ -67,7 +67,7 @@ def test_rescue_request_error_rest_deadline_exceeded integration_event = @driver.send :make_post_request, "https://example.com", headers: {}, body: "", retry_policy: nil assert_instance_of Event::RequestFailed, integration_event - assert_equal :retries_exhausted, integration_event.kind + assert_equal :timeout, integration_event.kind assert_same err, integration_event.source_error end @@ -158,7 +158,7 @@ def test_rescue_faraday_error_timeout event = @driver.send :rescue_faraday_error, err assert_instance_of Event::RequestFailed, event - assert_equal :connection_failed, event.kind + assert_equal :timeout, event.kind assert_equal "Net::ReadTimeout with https://example.com", event.message assert_same err, event.source_error @@ -167,7 +167,7 @@ def test_rescue_faraday_error_timeout integration_event = @driver.send :make_post_request, "https://example.com", headers: {}, body: "", retry_policy: nil assert_instance_of Event::RequestFailed, integration_event - assert_equal :connection_failed, integration_event.kind + assert_equal :timeout, integration_event.kind assert_same err, integration_event.source_error end diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb index aa56561..d3c95dc 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb @@ -170,7 +170,10 @@ def test_shape_of_chunk_read end def test_shape_of_request_failed - exhausted = Event::RequestFailed.new kind: :retries_exhausted, message: "timeout" + timeout = Event::RequestFailed.new kind: :timeout, message: "read timeout" + assert_equal :request_timeout, Rules.shape_of(timeout) + + exhausted = Event::RequestFailed.new kind: :retries_exhausted, message: "exhausted" assert_equal :request_retries_exhausted, Rules.shape_of(exhausted) conn_failed = Event::RequestFailed.new kind: :connection_failed, message: "dropped" diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb index 9e03827..e3e50e1 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb @@ -57,6 +57,9 @@ def test_shape_of_global_deadline_exceeded end def test_shape_of_request_failed + timeout = Event::RequestFailed.new kind: :timeout + assert_equal :request_timeout, Rules.shape_of(timeout) + retries = Event::RequestFailed.new kind: :retries_exhausted assert_equal :request_retries_exhausted, Rules.shape_of(retries) @@ -273,6 +276,44 @@ def test_transition_transmission_sending_connection_failed_triggers_recovery assert_instance_of Instruction::SendQuery, instructions.first end + def test_transition_transmission_sending_timeout_triggers_recovery + state = State.new status: :transmission_sending, upload_url: "https://example.com/session", offset: 0, + in_flight_length: 512 + req_failed = Event::RequestFailed.new kind: :timeout, message: "Read timeout" + next_state, instructions = Rules.step state, req_failed, @config + + assert_equal :recovery, next_state.status + assert_equal 0, next_state.in_flight_length + assert_equal 1, instructions.size + assert_instance_of Instruction::SendQuery, instructions.first + end + + def test_transition_finalizing_sending_upload_timeout_triggers_recovery + state = State.new status: :finalizing_sending_upload, upload_url: "https://example.com/session", offset: 512, + in_flight_length: 512 + req_failed = Event::RequestFailed.new kind: :timeout, message: "Read timeout" + next_state, instructions = Rules.step state, req_failed, @config + + assert_equal :recovery, next_state.status + assert_equal 0, next_state.in_flight_length + assert_equal 1, instructions.size + assert_instance_of Instruction::SendQuery, instructions.first + end + + def test_transition_starting_timeout_terminates_failure + state = State.new status: :starting + err = StandardError.new "Read timeout" + req_failed = Event::RequestFailed.new kind: :timeout, message: "Read timeout", source_error: err + next_state, instructions = Rules.step state, req_failed, @config + + assert_equal :error, next_state.status + assert_equal 0, next_state.in_flight_length + assert_equal err, next_state.last_error + assert_equal 1, instructions.size + assert_instance_of Instruction::TerminateFailure, instructions.first + assert_equal err, instructions.first.error + end + def test_transition_transmission_sending_retries_exhausted_terminates_failure state = State.new status: :transmission_sending, upload_url: "https://example.com/session", offset: 0, in_flight_length: 512 From 1eb61efee59cb50ceddd57c0a1f91ac07f237f5b Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Sat, 5 Sep 2026 20:03:36 +0000 Subject: [PATCH 16/79] fix: minor fixes --- gapic-common/design/implementation-guide.md | 12 +- .../design/reference-implementation.md | 11 +- gapic-common/design/test-plan.md | 60 ++- .../rest/resumable_upload/rules_error_test.rb | 173 ++++++++ .../resumable_upload/rules_recovery_test.rb | 122 ++++++ .../gapic/rest/resumable_upload/rules_test.rb | 373 ++---------------- gapic-common/test/test_helper.rb | 1 + 7 files changed, 383 insertions(+), 369 deletions(-) create mode 100644 gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb create mode 100644 gapic-common/test/gapic/rest/resumable_upload/rules_recovery_test.rb diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index 81d191d..af39b00 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -204,38 +204,38 @@ Full implementation: [reference-implementation.md#3-driver-class](reference-impl | **`Starting`** | `:response_active` | `Event::HttpResponse(200, headers, _)` with `Status: active` | `upload_url = headers['X-Goog-Upload-URL']`
`chunk_granularity = headers['...-Granularity']&.to_i`
`chunk_size = resolve(config, chunk_granularity)`
`offset = 0`
`status = :transmission_reading` | `Transmission \| Reading from stream` | `Instruction::FillBuffer.new(target_bytesize: state.chunk_size)` | | **`Starting`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, _)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | | **`Starting`** | `:response_cat2` / `:response_fatal_bad_response` | `Event::HttpResponse` (Non-200; see Section 6.1) | `last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | -| **`Starting`** | `:request_retries_exhausted` / `:request_connection_failed` | `Event::RequestFailed(kind:, message:, source_error:)` | `last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | +| **`Starting`** | `:request_retries_exhausted` / `:request_connection_failed` / `:request_timeout` | `Event::RequestFailed(kind:, message:, source_error:)` | `last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | | **`Transmission \| Reading from stream`** | `:chunk_read_full` | `Event::ChunkRead(bytes_buffered, eof: false)` | `in_flight_length = event.bytes_buffered`
`status = :transmission_sending` | `Transmission \| Sending` | `Instruction::SendChunk.new(url: state.upload_url, offset: state.offset, length: event.bytes_buffered, finalize: false)` | | **`Transmission \| Reading from stream`** | `:chunk_read_eof_with_data` | `Event::ChunkRead(bytes_buffered, eof: true)` where `bytes_buffered > 0` | `in_flight_length = event.bytes_buffered`
`status = :finalizing_sending_upload` | `Finalizing \| Sending with upload` | `Instruction::SendChunk.new(url: state.upload_url, offset: state.offset, length: event.bytes_buffered, finalize: true)` | | **`Transmission \| Reading from stream`** | `:chunk_read_eof_empty` | `Event::ChunkRead(bytes_buffered: 0, eof: true)` | `in_flight_length = 0`
`status = :finalizing_sending_finalize` | `Finalizing \| Sending finalize` | `Instruction::SendFinalize.new(url: state.upload_url)` | | **`Transmission \| Sending`** | `:response_active` | `Event::HttpResponse(200, headers, _)` with `Status: active` | `offset = state.offset + state.in_flight_length`
`in_flight_length = 0`
`status = :transmission_reading` | `Transmission \| Reading from stream` | `Instruction::NotifyProgress.new(bytes_uploaded: state.offset, total_bytes: config.upload_size)`
`Instruction::RealignBuffer.new(server_offset: state.offset)`
`Instruction::FillBuffer.new(target_bytesize: state.chunk_size)` | | **`Transmission \| Sending`** | `:response_cat2` | `Event::HttpResponse` (Category 2; see Section 6.1.2) | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | -| **`Transmission \| Sending`** | `:request_connection_failed` | `Event::RequestFailed(kind: :connection_failed)` | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | +| **`Transmission \| Sending`** | `:request_connection_failed` / `:request_timeout` | `Event::RequestFailed(kind: :connection_failed \| :timeout)` | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | | **`Transmission \| Sending`** | `:request_retries_exhausted` | `Event::RequestFailed(kind: :retries_exhausted)` | `in_flight_length = 0`
`last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | | **`Transmission \| Sending`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, _)` with `Status: final` | `in_flight_length = 0`
`status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | | **`Transmission \| Sending`** | `:response_fatal_bad_response` | `Event::HttpResponse` (Fatal status; see Section 6.1.3) | `in_flight_length = 0`
`last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | | **`Finalizing \| Sending with upload`** | `:response_final` | `Event::HttpResponse(200, headers, body)` with `Status: final` | `offset = state.offset + state.in_flight_length`
`in_flight_length = 0`
`status = :success` | `Success` | `Instruction::NotifyProgress.new(bytes_uploaded: state.offset, total_bytes: state.offset)`
`Instruction::TerminateSuccess.new(response: event)` | | **`Finalizing \| Sending with upload`** | `:response_cat2` | `Event::HttpResponse` (Category 2; see Section 6.1.2) | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | -| **`Finalizing \| Sending with upload`** | `:request_connection_failed` | `Event::RequestFailed(kind: :connection_failed)` | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | +| **`Finalizing \| Sending with upload`** | `:request_connection_failed` / `:request_timeout` | `Event::RequestFailed(kind: :connection_failed \| :timeout)` | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | | **`Finalizing \| Sending with upload`** | `:request_retries_exhausted` | `Event::RequestFailed(kind: :retries_exhausted)` | `in_flight_length = 0`
`last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | | **`Finalizing \| Sending with upload`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, body)` with `Status: final` | `in_flight_length = 0`
`status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | | **`Finalizing \| Sending with upload`** | `:response_fatal_bad_response` | `Event::HttpResponse` (Fatal status; see Section 6.1.3) | `in_flight_length = 0`
`last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | | **`Finalizing \| Sending finalize`** | `:response_final` | `Event::HttpResponse(200, headers, body)` with `Status: final` | `status = :success` | `Success` | `Instruction::TerminateSuccess.new(response: event)` | | **`Finalizing \| Sending finalize`** | `:response_cat2` | `Event::HttpResponse` (Category 2; see Section 6.1.2) | `status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | -| **`Finalizing \| Sending finalize`** | `:request_connection_failed` | `Event::RequestFailed(kind: :connection_failed)` | `status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | +| **`Finalizing \| Sending finalize`** | `:request_connection_failed` / `:request_timeout` | `Event::RequestFailed(kind: :connection_failed \| :timeout)` | `status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | | **`Finalizing \| Sending finalize`** | `:request_retries_exhausted` | `Event::RequestFailed(kind: :retries_exhausted)` | `last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | | **`Finalizing \| Sending finalize`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, body)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | | **`Finalizing \| Sending finalize`** | `:response_fatal_bad_response` | `Event::HttpResponse` (Fatal status; see Section 6.1.3) | `last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | | **`Recovery`** | `:response_active` | `Event::HttpResponse(200, headers, _)` with `Status: active` | `offset = headers['X-Goog-Upload-Size-Received'].to_i`
`in_flight_length = 0`
`status = :transmission_reading` | `Transmission \| Reading from stream` | `Instruction::RealignBuffer.new(server_offset: state.offset)`
`Instruction::FillBuffer.new(target_bytesize: state.chunk_size)` | | **`Recovery`** | `:response_final` | `Event::HttpResponse(200, headers, body)` with `Status: final` | `in_flight_length = 0`
`status = :success` | `Success` | `Instruction::TerminateSuccess.new(response: event)` | | **`Recovery`** | `:response_cat2` | `Event::HttpResponse` (Category 2; see Section 6.1.2) | `status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | -| **`Recovery`** | `:request_retries_exhausted` / `:request_connection_failed` | `Event::RequestFailed(kind:, ...)` | `last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | +| **`Recovery`** | `:request_retries_exhausted` / `:request_connection_failed` / `:request_timeout` | `Event::RequestFailed(kind:, ...)` | `last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | | **`Recovery`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, body)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | | **`Recovery`** | `:response_fatal_bad_response` | `Event::HttpResponse` (Fatal status; see Section 6.1.3) | `last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | | **Any Non-Terminal** | `:user_cancel` | `Event::Cancel` | `status = :cancelling` | `Cancelling` | `Instruction::SendCancel.new(url: state.upload_url)` | | **`Cancelling`** | `:response_cancelled` | `Event::HttpResponse(200, headers, _)` with `Status: cancelled` | `status = :cancelled` | `Cancelled` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadCancelledError.new)` | | **`Cancelling`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, _)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(event.body))` | -| **`Cancelling`** | `:request_retries_exhausted` / `:request_connection_failed` / `:response_fatal_bad_response` | `Event::RequestFailed` or HTTP failure | `last_error = error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | +| **`Cancelling`** | `:request_retries_exhausted` / `:request_connection_failed` / `:request_timeout` / `:response_fatal_bad_response` | `Event::RequestFailed` or HTTP failure | `last_error = error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | | **Any Non-Terminal** | `:global_deadline_exceeded` | `Event::GlobalDeadlineExceeded` | `last_error = Gapic::Common::DeadlineExceededError.new`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | | **Any State** | *Unmatched* | Any event not matched above | — | — | `fail_with_unmatched_transition(state, event)`: raises `InvalidTransitionError` stating in human terms what the protocol was doing (e.g. sending a chunk of data), what happened including HTTP status and `X-Goog-Upload-Status` header, and attaches the response. | diff --git a/gapic-common/design/reference-implementation.md b/gapic-common/design/reference-implementation.md index 40606e1..544557f 100644 --- a/gapic-common/design/reference-implementation.md +++ b/gapic-common/design/reference-implementation.md @@ -96,11 +96,16 @@ module Gapic fail_with_deadline_exceeded(state) in [_, :user_cancel] cancel_session(state) - in [_, :response_rejected] + in [:starting | :transmission_sending | :finalizing_sending_upload | + :finalizing_sending_finalize | :recovery | :cancelling, :response_rejected] fail_with_rejected(state, event) - in [_, :response_cat2 | :response_fatal_bad_response] + in [:starting | :cancelling, :response_cat2] | + [:starting | :transmission_sending | :finalizing_sending_upload | + :finalizing_sending_finalize | :recovery | :cancelling, :response_fatal_bad_response] fail_with_bad_response(state, event) - in [_, :request_retries_exhausted | :request_connection_failed | :request_timeout | :request_failed_unknown] + in [:starting | :transmission_sending | :finalizing_sending_upload | + :finalizing_sending_finalize | :recovery | :cancelling, + :request_retries_exhausted | :request_connection_failed | :request_timeout | :request_failed_unknown] fail_with_request_error(state, event) else fail_with_unmatched_transition(state, event) diff --git a/gapic-common/design/test-plan.md b/gapic-common/design/test-plan.md index 81ec701..fca670f 100644 --- a/gapic-common/design/test-plan.md +++ b/gapic-common/design/test-plan.md @@ -10,7 +10,9 @@ This document outlines the complete unit and integration test plan for the Resum flowchart TD subgraph TestSuites["Test Suites"] RC["rules_classification_test.rb
(Rules Classification & Utility)"] - RT["rules_test.rb
(Rules State Machine & Error Formatting)"] + RT["rules_test.rb
(Rules Progression & Lifecycle)"] + RR["rules_recovery_test.rb
(Rules Recovery Transitions)"] + RE["rules_error_test.rb
(Rules Terminal Errors & Formatting)"] RP["retry_policies_test.rb
(Retry Policies & Header Extraction)"] DB["driver_buffer_test.rb
(Stream Buffering & Realignment)"] DE["driver_error_mapping_test.rb
(Network Error Mapping)"] @@ -34,6 +36,8 @@ flowchart TD RC --> RulesClassify RT --> RulesStep + RR --> RulesStep + RE --> RulesStep RP --> Policies DB --> DriverIO DE --> DriverErr @@ -49,16 +53,16 @@ flowchart TD | Double Name | Location | Description & Behavior | | :--- | :--- | :--- | -| `ChunkedStream` | [driver_buffer_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb#L29) | Wraps `StringIO`; caps returned bytes per `#read(length)` call to simulate socket/pipe short reads. | -| `UnseekableStream` | [driver_buffer_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb#L53) | Wraps `StringIO` with `#read` but explicitly omits `#seek` (`respond_to?(:seek)` is `false`). | -| `FailingClientStub` | [driver_error_mapping_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/driver_error_mapping_test.rb#L30) | Integration fake client stub configured with `@error_to_raise` to verify exception rescue in `Driver#make_post_request`. | -| `ScriptedClientStub` | [driver_progress_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb#L30) / [driver_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb#L30) | Yields a deterministic sequence of HTTP response structs and records dispatched requests. | +| `ChunkedStream` | `driver_buffer_test.rb` | Wraps `StringIO`; caps returned bytes per `#read(length)` call to simulate socket/pipe short reads. | +| `UnseekableStream` | `driver_buffer_test.rb` | Wraps `StringIO` with `#read` but explicitly omits `#seek` (`respond_to?(:seek)` is `false`). | +| `FailingClientStub` | `driver_error_mapping_test.rb` | Integration fake client stub configured with `@error_to_raise` to verify exception rescue in `Driver#make_post_request`. | +| `ScriptedClientStub` | `driver_progress_test.rb` / `driver_test.rb` | Yields a deterministic sequence of HTTP response structs and records dispatched requests. | --- ## 3. Detailed Test Suites & Cases -### 3.1 Rules Classification & Utility ([rules_classification_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb)) +### 3.1 Rules Classification & Utility (`rules_classification_test.rb`) #### A. Case-Insensitive Header Extraction (`Rules.header_value`) * **Exact match**: Exact key casing (`"X-Goog-Upload-Status"` $\rightarrow$ `"active"`). @@ -102,12 +106,28 @@ flowchart TD --- -### 3.2 State Machine Actionable Errors ([rules_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb)) - +### 3.2 Rules State Machine (`rules_test.rb`, `rules_recovery_test.rb`, `rules_error_test.rb`) + +#### A. Normal Progression & Session Lifecycle (`rules_test.rb`) +* **Session initiation**: `:initializing` on `:start_upload` transitions to `:starting` and emits `Instruction::SendStart`. +* **Transmission start**: `:starting` on `:response_active` resolves chunk size and granularity, transitions to `:transmission_reading`, and emits `Instruction::FillBuffer`. +* **Chunk transmission & finalization**: `:transmission_reading` dispatches `SendChunk` (with or without `finalize: true`) or standalone `SendFinalize` depending on stream EOF and buffered bytes. +* **Chunk acknowledgment**: `:transmission_sending` on `:response_active` advances offset, emits `NotifyProgress`, `RealignBuffer`, and `FillBuffer`. +* **Cancellation flow**: `:user_cancel` transitions to `:cancelling` and emits `SendCancel`; `:response_cancelled` transitions to `:cancelled`. + +#### B. Protocol Recovery Transitions (`rules_recovery_test.rb`) +* **Entering recovery**: `:transmission_sending` (on `:response_cat2`, `:request_connection_failed`, `:request_timeout`) and `:finalizing_sending_upload` (on `:request_timeout`) transition to `:recovery` and emit `Instruction::SendQuery`. +* **Realignment from recovery**: `:recovery` on `:response_active` updates offset from `X-Goog-Upload-Size-Received`, emits `RealignBuffer` and `FillBuffer`, and transitions to `:transmission_reading`. +* **Finalized in recovery**: `:recovery` on `:response_final` transitions to `:success` and emits `TerminateSuccess`. +* **Retrying recovery query**: `:recovery` on `:response_cat2` remains in `:recovery` and re-emits `SendQuery`. + +#### C. Terminal Failures & Actionable Error Formatting (`rules_error_test.rb`) +* **Terminal failures (`:rejected` / `:error`)**: + * Session rejection (`:response_rejected` $\rightarrow$ `UploadRejectedError`), fatal bad responses (`:response_fatal_bad_response` $\rightarrow$ `BadResponseError`). + * Non-recoverable request failures: `:request_retries_exhausted` in `:transmission_sending`, and `:request_timeout` in `:starting` or `:recovery`. + * Global deadline expiration: `:global_deadline_exceeded` $\rightarrow$ `DeadlineExceededError`. * **Actionable description on unexpected HTTP response**: - * Unmatched event (HTTP 200 `Status: final` while in `:transmission_sending`) raises `InvalidTransitionError`. - * Verifies human phrasing: `"Resumable upload failed while sending a chunk of data: received an unexpected HTTP 200 response (X-Goog-Upload-Status: 'final')."` - * Verifies error properties: `err.response`, `err.event`, `err.state`. + * Unmatched event (HTTP 200 `Status: final` while in `:transmission_sending`) raises `InvalidTransitionError` with human phrasing (`"Resumable upload failed while sending a chunk of data: received an unexpected HTTP 200 response (X-Goog-Upload-Status: 'final')."`) and attaches `err.response`, `err.event`, `err.state`. * **Missing status header formatting in error**: * Unexpected response lacking `X-Goog-Upload-Status` formats header description as `(X-Goog-Upload-Status: missing)`. * **Actionable description for non-HTTP unexpected events**: @@ -115,7 +135,7 @@ flowchart TD --- -### 3.3 Driver Stream Buffering & Realignment ([driver_buffer_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb)) +### 3.3 Driver Stream Buffering & Realignment (`driver_buffer_test.rb`) #### A. Stream Reading (`Driver#execute_fill_buffer`) * **Short reads**: `ChunkedStream` returning at most 20 bytes per read call repeatedly accumulates until buffer hits target 100 bytes (`bytes_buffered: 100, eof: false`). @@ -137,23 +157,23 @@ flowchart TD --- -### 3.4 Driver Network Error Mapping ([driver_error_mapping_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/driver_error_mapping_test.rb)) +### 3.4 Driver Network Error Mapping (`driver_error_mapping_test.rb`) * **`Driver#rescue_request_error`**: - * `Gapic::Rest::DeadlineExceededError` $\rightarrow$ `Event::RequestFailed(kind: :retries_exhausted)` preserving error message and `source_error`. + * `Gapic::Rest::DeadlineExceededError` $\rightarrow$ `Event::RequestFailed(kind: :timeout)` preserving error message and `source_error`. * `Gapic::Rest::Error` with HTTP status code $\rightarrow$ `Event::HttpResponse(status:, headers:, body:)`. * `Gapic::Rest::Error` without status code $\rightarrow$ `Event::RequestFailed(kind: :connection_failed)`. * `StandardError` (`RuntimeError`) $\rightarrow$ `Event::RequestFailed(kind: :connection_failed)`. * **`Driver#rescue_faraday_error`**: * `Faraday::Error` with response hash $\rightarrow$ `Event::HttpResponse(status: 400, headers:, body:)`. - * `Faraday::TimeoutError` $\rightarrow$ `Event::RequestFailed(kind: :connection_failed)`. + * `Faraday::TimeoutError` $\rightarrow$ `Event::RequestFailed(kind: :timeout)`. * `Faraday::ConnectionFailed` $\rightarrow$ `Event::RequestFailed(kind: :connection_failed)`. * Generic `Faraday::Error` without response $\rightarrow$ `Event::RequestFailed(kind: :retries_exhausted)`. * **Integration verification**: All mappings verified both directly and end-to-end through `Driver#make_post_request` via `FailingClientStub`. --- -### 3.5 Retry Policies & Header Extraction ([retry_policies_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/retry_policies_test.rb)) +### 3.5 Retry Policies & Header Extraction (`retry_policies_test.rb`) #### A. Header Extraction (`RetryPolicies.extract_headers`) * Extracts from `#headers`, `#response_headers`, and Faraday `#response[:headers]`. Returns `nil` when no headers present. @@ -167,7 +187,7 @@ flowchart TD --- -### 3.6 Progress Notification Dispatching ([driver_progress_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb)) +### 3.6 Progress Notification Dispatching (`driver_progress_test.rb`) * **Safe no-op without callback**: `on_progress: nil` executes without raising. * **Happy path**: Callback receives `(bytes_uploaded, total_bytes)` once per instruction. @@ -176,14 +196,14 @@ flowchart TD --- -### 3.7 Driver Upload Execution Loop ([driver_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb)) +### 3.7 Driver Upload Execution Loop (`driver_test.rb`) * **Multi-chunk upload**: Multi-chunk stream uploads with active status headers succeed and return the final response body String. * **Protocol recovery during chunk upload**: Missing status header on chunk response triggers `query` recovery and resumes chunk transmission from the server-confirmed offset. --- -### 3.8 Driver Initiation & Query Retries ([driver_retry_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb)) +### 3.8 Driver Initiation & Query Retries (`driver_retry_test.rb`) * **Session initiation retry loop**: Missing status header on HTTP 200 during `start` triggers `start_retry_policy` and succeeds upon header arrival. * **Initiation retry exhaustion**: Continuous missing status headers on `start` exhaust retries and dispatch `Event::RequestFailed(kind: :retries_exhausted)`. @@ -191,7 +211,7 @@ flowchart TD --- -### 3.9 Driver Configuration & Deadlines ([driver_config_test.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb)) +### 3.9 Driver Configuration & Deadlines (`driver_config_test.rb`) * **Explicit positive timeout precedence**: `resolve_timeout` returns `config.timeout` when strictly positive. * **Zero or negative timeout handling**: Zero or negative `config.timeout` is treated the same as `nil` (unset), falling back to size-based or `BASE_TIMEOUT` resolution. diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb new file mode 100644 index 0000000..61d2d4f --- /dev/null +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "test_helper" +require "gapic/rest/resumable_upload" +require "stringio" + +## +# Tests for ResumableUpload Rules terminal error transitions and actionable error formatting. +# +class RulesErrorTest < Minitest::Test + include Gapic::Rest::ResumableUpload + + def setup + @config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + initial_headers: { "X-Custom" => "value" }, + initial_body: '{"name":"obj"}', + stream: StringIO.new("data"), + upload_size: 1024, + chunk_size: 512 + ) + end + + def test_transition_starting_rejected + state = State.new status: :starting + resp = Event::HttpResponse.new status: 403, headers: { "x-goog-upload-status" => "final" }, body: "Forbidden" + next_state, instructions = Rules.step state, resp, @config + + assert_equal :rejected, next_state.status + assert_instance_of Gapic::Common::UploadRejectedError, next_state.last_error + assert_equal 1, instructions.size + assert_instance_of Instruction::TerminateFailure, instructions.first + assert_equal next_state.last_error, instructions.first.error + end + + def test_transition_starting_fatal_error + state = State.new status: :starting + resp = Event::HttpResponse.new status: 400, headers: {}, body: "Bad Request" + next_state, instructions = Rules.step state, resp, @config + + assert_equal :error, next_state.status + assert_instance_of Gapic::Common::BadResponseError, next_state.last_error + assert_equal 400, next_state.last_error.status_code + assert_equal 1, instructions.size + assert_instance_of Instruction::TerminateFailure, instructions.first + end + + def test_transition_starting_request_failed + state = State.new status: :starting + err = StandardError.new "DNS resolution failed" + failed = Event::RequestFailed.new kind: :connection_failed, message: "DNS resolution failed", source_error: err + next_state, instructions = Rules.step state, failed, @config + + assert_equal :error, next_state.status + assert_equal err, next_state.last_error + assert_equal 1, instructions.size + assert_instance_of Instruction::TerminateFailure, instructions.first + end + + def test_transition_starting_timeout_terminates_failure + state = State.new status: :starting + err = StandardError.new "Read timeout" + req_failed = Event::RequestFailed.new kind: :timeout, message: "Read timeout", source_error: err + next_state, instructions = Rules.step state, req_failed, @config + + assert_equal :error, next_state.status + assert_equal 0, next_state.in_flight_length + assert_equal err, next_state.last_error + assert_equal 1, instructions.size + assert_instance_of Instruction::TerminateFailure, instructions.first + assert_equal err, instructions.first.error + end + + def test_transition_transmission_sending_retries_exhausted_terminates_failure + state = State.new status: :transmission_sending, upload_url: "https://example.com/session", offset: 0, + in_flight_length: 512 + err = StandardError.new "Retries exhausted" + req_failed = Event::RequestFailed.new kind: :retries_exhausted, message: "Retries exhausted", source_error: err + next_state, instructions = Rules.step state, req_failed, @config + + assert_equal :error, next_state.status + assert_equal 0, next_state.in_flight_length + assert_equal err, next_state.last_error + assert_equal 1, instructions.size + assert_instance_of Instruction::TerminateFailure, instructions.first + assert_equal err, instructions.first.error + end + + def test_transition_recovery_timeout_terminates_failure + state = State.new status: :recovery, upload_url: "https://example.com/session" + err = StandardError.new "Query read timeout" + req_failed = Event::RequestFailed.new kind: :timeout, message: "Query read timeout", source_error: err + next_state, instructions = Rules.step state, req_failed, @config + + assert_equal :error, next_state.status + assert_equal 0, next_state.in_flight_length + assert_equal err, next_state.last_error + assert_equal 1, instructions.size + assert_instance_of Instruction::TerminateFailure, instructions.first + assert_equal err, instructions.first.error + end + + def test_transition_global_deadline_exceeded + state = State.new status: :transmission_sending, upload_url: "https://example.com/session" + next_state, instructions = Rules.step state, Event::GlobalDeadlineExceeded.new, @config + + assert_equal :error, next_state.status + assert_instance_of Gapic::Common::DeadlineExceededError, next_state.last_error + assert_equal 1, instructions.size + assert_instance_of Instruction::TerminateFailure, instructions.first + end + + def test_invalid_transition_raises_actionable_error_with_response_details_and_header + state = State.new status: :transmission_sending + resp = Event::HttpResponse.new status: 200, headers: { "X-Goog-Upload-Status" => "final" }, body: '{"done":true}' + + err = assert_raises InvalidTransitionError do + Rules.step state, resp, @config + end + + expected_msg = "Resumable upload failed while sending a chunk of data: " \ + "received an unexpected HTTP 200 response (X-Goog-Upload-Status: 'final')." + assert_equal expected_msg, err.message + assert_equal :transmission_sending, err.state + assert_equal resp, err.event + assert_equal resp, err.response + end + + def test_invalid_transition_shows_missing_when_upload_status_header_absent + state = State.new status: :transmission_reading + resp = Event::HttpResponse.new status: 200, headers: {}, body: "" + + err = assert_raises InvalidTransitionError do + Rules.step state, resp, @config + end + + expected_msg = "Resumable upload failed while reading chunk from stream: " \ + "received an unexpected HTTP 200 response (X-Goog-Upload-Status: missing)." + assert_equal expected_msg, err.message + assert_equal :transmission_reading, err.state + assert_equal resp, err.response + end + + def test_invalid_transition_raises_error_for_non_http_event + state = State.new status: :starting + event = Event::ChunkRead.new bytes_buffered: 512, eof: false + + err = assert_raises InvalidTransitionError do + Rules.step state, event, @config + end + + expected_msg = "Resumable upload failed while initiating upload session: " \ + "received unexpected stream chunk read (512 bytes, eof: false)." + assert_equal expected_msg, err.message + assert_equal :starting, err.state + assert_equal event, err.event + assert_nil err.response + end +end diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_recovery_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_recovery_test.rb new file mode 100644 index 0000000..7fa0b26 --- /dev/null +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_recovery_test.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "test_helper" +require "gapic/rest/resumable_upload" +require "stringio" + +## +# Tests for ResumableUpload Rules protocol recovery state transitions. +# +class RulesRecoveryTest < Minitest::Test + include Gapic::Rest::ResumableUpload + + def setup + @config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + initial_headers: { "X-Custom" => "value" }, + initial_body: '{"name":"obj"}', + stream: StringIO.new("data"), + upload_size: 1024, + chunk_size: 512 + ) + end + + def test_transition_transmission_sending_cat2_triggers_recovery + state = State.new status: :transmission_sending, upload_url: "https://example.com/session", offset: 0, + in_flight_length: 512 + resp = Event::HttpResponse.new status: 503, headers: {} + next_state, instructions = Rules.step state, resp, @config + + assert_equal :recovery, next_state.status + assert_equal 0, next_state.in_flight_length + assert_equal 1, instructions.size + assert_instance_of Instruction::SendQuery, instructions.first + end + + def test_transition_transmission_sending_connection_failed_triggers_recovery + state = State.new status: :transmission_sending, upload_url: "https://example.com/session", offset: 0, + in_flight_length: 512 + req_failed = Event::RequestFailed.new kind: :connection_failed, message: "Network unreachable" + next_state, instructions = Rules.step state, req_failed, @config + + assert_equal :recovery, next_state.status + assert_equal 0, next_state.in_flight_length + assert_equal 1, instructions.size + assert_instance_of Instruction::SendQuery, instructions.first + end + + def test_transition_transmission_sending_timeout_triggers_recovery + state = State.new status: :transmission_sending, upload_url: "https://example.com/session", offset: 0, + in_flight_length: 512 + req_failed = Event::RequestFailed.new kind: :timeout, message: "Read timeout" + next_state, instructions = Rules.step state, req_failed, @config + + assert_equal :recovery, next_state.status + assert_equal 0, next_state.in_flight_length + assert_equal 1, instructions.size + assert_instance_of Instruction::SendQuery, instructions.first + end + + def test_transition_finalizing_sending_upload_timeout_triggers_recovery + state = State.new status: :finalizing_sending_upload, upload_url: "https://example.com/session", offset: 512, + in_flight_length: 512 + req_failed = Event::RequestFailed.new kind: :timeout, message: "Read timeout" + next_state, instructions = Rules.step state, req_failed, @config + + assert_equal :recovery, next_state.status + assert_equal 0, next_state.in_flight_length + assert_equal 1, instructions.size + assert_instance_of Instruction::SendQuery, instructions.first + end + + def test_transition_recovery_active_realigns_buffer + state = State.new status: :recovery, upload_url: "https://example.com/session", offset: 0, chunk_size: 512 + headers = { + "x-goog-upload-status" => "active", + "x-goog-upload-size-received" => "768" + } + resp = Event::HttpResponse.new status: 200, headers: headers + next_state, instructions = Rules.step state, resp, @config + + assert_equal :transmission_reading, next_state.status + assert_equal 768, next_state.offset + assert_equal 2, instructions.size + assert_instance_of Instruction::RealignBuffer, instructions[0] + assert_equal 768, instructions[0].server_offset + assert_instance_of Instruction::FillBuffer, instructions[1] + end + + def test_transition_recovery_final_completes_upload + state = State.new status: :recovery, upload_url: "https://example.com/session", offset: 512 + resp = Event::HttpResponse.new status: 200, headers: { "x-goog-upload-status" => "final" } + next_state, instructions = Rules.step state, resp, @config + + assert_equal :success, next_state.status + assert_equal 1, instructions.size + assert_instance_of Instruction::TerminateSuccess, instructions.first + end + + def test_transition_recovery_cat2_retries_query + state = State.new status: :recovery, upload_url: "https://example.com/session" + resp = Event::HttpResponse.new status: 416, headers: {} + next_state, instructions = Rules.step state, resp, @config + + assert_equal :recovery, next_state.status + assert_equal 1, instructions.size + assert_instance_of Instruction::SendQuery, instructions.first + end +end diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb index e3e50e1..17c69f6 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb @@ -18,120 +18,23 @@ require "gapic/rest/resumable_upload" require "stringio" +## +# Tests for ResumableUpload Rules normal progression and session lifecycle state transitions. +# class RulesTest < Minitest::Test include Gapic::Rest::ResumableUpload def setup @config = CompleteUploadConfig.new( - initial_url: "https://example.com/upload", - stream: StringIO.new("test content"), - upload_size: 1024, - chunk_size: 512 + initial_url: "https://example.com/upload", + initial_headers: { "X-Custom" => "value" }, + initial_body: '{"name":"obj"}', + stream: StringIO.new("data"), + upload_size: 1024, + chunk_size: 512 ) end - def test_shape_of_start_upload - assert_equal :start_upload, Rules.shape_of(Event::StartUpload.new) - assert_equal :start_upload, Rules.shape_of(Event::StartUpload) - end - - def test_shape_of_chunk_read - full = Event::ChunkRead.new bytes_buffered: 512, eof: false - assert_equal :chunk_read_full, Rules.shape_of(full) - - eof_data = Event::ChunkRead.new bytes_buffered: 256, eof: true - assert_equal :chunk_read_eof_with_data, Rules.shape_of(eof_data) - - eof_empty = Event::ChunkRead.new bytes_buffered: 0, eof: true - assert_equal :chunk_read_eof_empty, Rules.shape_of(eof_empty) - end - - def test_shape_of_cancel - assert_equal :user_cancel, Rules.shape_of(Event::Cancel.new) - assert_equal :user_cancel, Rules.shape_of(Event::Cancel) - end - - def test_shape_of_global_deadline_exceeded - assert_equal :global_deadline_exceeded, Rules.shape_of(Event::GlobalDeadlineExceeded.new) - assert_equal :global_deadline_exceeded, Rules.shape_of(Event::GlobalDeadlineExceeded) - end - - def test_shape_of_request_failed - timeout = Event::RequestFailed.new kind: :timeout - assert_equal :request_timeout, Rules.shape_of(timeout) - - retries = Event::RequestFailed.new kind: :retries_exhausted - assert_equal :request_retries_exhausted, Rules.shape_of(retries) - - conn = Event::RequestFailed.new kind: :connection_failed - assert_equal :request_connection_failed, Rules.shape_of(conn) - - unknown = Event::RequestFailed.new kind: :other - assert_equal :request_failed_unknown, Rules.shape_of(unknown) - end - - def test_shape_of_http_response_active - resp_200 = Event::HttpResponse.new status: 200, headers: { "X-Goog-Upload-Status" => "active" } - assert_equal :response_active, Rules.shape_of(resp_200) - - resp_500 = Event::HttpResponse.new status: 500, headers: { "x-goog-upload-status" => "active" } - assert_equal :response_cat2, Rules.shape_of(resp_500) - end - - def test_shape_of_http_response_final - resp_200 = Event::HttpResponse.new status: 200, headers: { "X-Goog-Upload-Status" => "final" } - assert_equal :response_final, Rules.shape_of(resp_200) - - resp_400 = Event::HttpResponse.new status: 400, headers: { "x-goog-upload-status" => "final" } - assert_equal :response_rejected, Rules.shape_of(resp_400) - end - - def test_shape_of_http_response_cancelled - resp_200 = Event::HttpResponse.new status: 200, headers: { "X-Goog-Upload-Status" => "cancelled" } - assert_equal :response_cancelled, Rules.shape_of(resp_200) - - resp_500 = Event::HttpResponse.new status: 500, headers: { "x-goog-upload-status" => "cancelled" } - assert_equal :response_fatal_bad_response, Rules.shape_of(resp_500) - end - - def test_shape_of_http_response_missing_header - [200, 400, 408, 409, 412, 416, 429, 499, 500, 502, 503, 504].each do |code| - resp = Event::HttpResponse.new status: code, headers: {} - assert_equal :response_cat2, Rules.shape_of(resp), "Status #{code} with missing header should be :response_cat2" - end - - [401, 403, 404, 405, 410, 413, 415].each do |code| - resp = Event::HttpResponse.new status: code, headers: {} - assert_equal :response_fatal_bad_response, Rules.shape_of(resp), - "Status #{code} with missing header should be :response_fatal_bad_response" - end - end - - # Section 5: Chunk Size Adjustment Rules - def test_resolve_chunk_size_no_granularity - assert_equal 8_388_608, Rules.resolve_chunk_size(nil, nil) - assert_equal 8_388_608, Rules.resolve_chunk_size(nil, 0) - assert_equal 4_000_000, Rules.resolve_chunk_size(4_000_000, nil) - assert_equal 4_000_000, Rules.resolve_chunk_size(4_000_000, 0) - end - - def test_resolve_chunk_size_default_with_granularity - # 8_388_608 % 256_000 = 196_608 -> 8_388_608 - 196_608 = 8_192_000 - assert_equal 8_192_000, Rules.resolve_chunk_size(nil, 256_000) - # If DEFAULT_CHUNK_SIZE < granularity, promote to granularity - assert_equal 16_000_000, Rules.resolve_chunk_size(nil, 16_000_000) - end - - def test_resolve_chunk_size_user_specified_with_granularity - # Case 3A: user_chunk_size >= granularity - assert_equal 512_000, Rules.resolve_chunk_size(512_000, 256_000) - assert_equal 768_000, Rules.resolve_chunk_size(1_000_000, 256_000) - - # Case 3B: user_chunk_size < granularity (promoted to granularity) - assert_equal 256_000, Rules.resolve_chunk_size(100_000, 256_000) - end - - # Section 4: State Machine Transitions def test_transition_initializing_to_starting state = State.new status: :initializing next_state, instructions = Rules.step state, Event::StartUpload.new, @config @@ -140,62 +43,34 @@ def test_transition_initializing_to_starting assert_equal 1, instructions.size assert_instance_of Instruction::SendStart, instructions.first assert_equal "https://example.com/upload", instructions.first.url + assert_equal({ "X-Custom" => "value" }, instructions.first.headers) + assert_equal '{"name":"obj"}', instructions.first.body end def test_transition_starting_to_transmission_reading state = State.new status: :starting headers = { - "X-Goog-Upload-URL" => "https://example.com/session123", - "X-Goog-Upload-Chunk-Granularity" => "256000", - "X-Goog-Upload-Status" => "active" + "x-goog-upload-status" => "active", + "x-goog-upload-url" => "https://example.com/session", + "x-goog-upload-chunk-granularity" => "256" } resp = Event::HttpResponse.new status: 200, headers: headers next_state, instructions = Rules.step state, resp, @config assert_equal :transmission_reading, next_state.status - assert_equal "https://example.com/session123", next_state.upload_url - assert_equal 256_000, next_state.chunk_granularity + assert_equal "https://example.com/session", next_state.upload_url + assert_equal 256, next_state.chunk_granularity + assert_equal 512, next_state.chunk_size assert_equal 0, next_state.offset + assert_equal 0, next_state.in_flight_length assert_equal 1, instructions.size assert_instance_of Instruction::FillBuffer, instructions.first - assert_equal next_state.chunk_size, instructions.first.target_bytesize - end - - def test_transition_starting_rejected - state = State.new status: :starting - resp = Event::HttpResponse.new status: 400, headers: { "x-goog-upload-status" => "final" }, body: "Invalid metadata" - next_state, instructions = Rules.step state, resp, @config - - assert_equal :rejected, next_state.status - assert_instance_of Gapic::Common::UploadRejectedError, next_state.last_error - assert_equal 1, instructions.size - assert_instance_of Instruction::TerminateFailure, instructions.first - end - - def test_transition_starting_fatal_error - state = State.new status: :starting - resp = Event::HttpResponse.new status: 401, headers: {} - next_state, instructions = Rules.step state, resp, @config - - assert_equal :error, next_state.status - assert_instance_of Gapic::Common::BadResponseError, next_state.last_error - assert_equal 401, next_state.last_error.status_code - assert_equal 1, instructions.size - assert_instance_of Instruction::TerminateFailure, instructions.first - end - - def test_transition_starting_request_failed - state = State.new status: :starting - failed = Event::RequestFailed.new kind: :connection_failed, message: "DNS resolution failed" - next_state, instructions = Rules.step state, failed, @config - - assert_equal :error, next_state.status - assert_equal 1, instructions.size - assert_instance_of Instruction::TerminateFailure, instructions.first + assert_equal 512, instructions.first.target_bytesize end def test_transition_transmission_reading_full_chunk - state = State.new status: :transmission_reading, upload_url: "https://example.com/session", offset: 0 + state = State.new status: :transmission_reading, upload_url: "https://example.com/session", offset: 0, + chunk_size: 512 event = Event::ChunkRead.new bytes_buffered: 512, eof: false next_state, instructions = Rules.step state, event, @config @@ -203,27 +78,29 @@ def test_transition_transmission_reading_full_chunk assert_equal 512, next_state.in_flight_length assert_equal 1, instructions.size assert_instance_of Instruction::SendChunk, instructions.first - refute instructions.first.finalize - assert_equal 512, instructions.first.length assert_equal 0, instructions.first.offset + assert_equal 512, instructions.first.length + refute instructions.first.finalize end def test_transition_transmission_reading_eof_with_data - state = State.new status: :transmission_reading, upload_url: "https://example.com/session", offset: 512 - event = Event::ChunkRead.new bytes_buffered: 256, eof: true + state = State.new status: :transmission_reading, upload_url: "https://example.com/session", offset: 512, + chunk_size: 512 + event = Event::ChunkRead.new bytes_buffered: 200, eof: true next_state, instructions = Rules.step state, event, @config assert_equal :finalizing_sending_upload, next_state.status - assert_equal 256, next_state.in_flight_length + assert_equal 200, next_state.in_flight_length assert_equal 1, instructions.size assert_instance_of Instruction::SendChunk, instructions.first - assert instructions.first.finalize - assert_equal 256, instructions.first.length assert_equal 512, instructions.first.offset + assert_equal 200, instructions.first.length + assert instructions.first.finalize end def test_transition_transmission_reading_eof_empty - state = State.new status: :transmission_reading, upload_url: "https://example.com/session", offset: 1024 + state = State.new status: :transmission_reading, upload_url: "https://example.com/session", offset: 1024, + chunk_size: 512 event = Event::ChunkRead.new bytes_buffered: 0, eof: true next_state, instructions = Rules.step state, event, @config @@ -235,7 +112,8 @@ def test_transition_transmission_reading_eof_empty end def test_transition_transmission_sending_ack_chunk - state = State.new status: :transmission_sending, offset: 0, in_flight_length: 512, chunk_size: 512 + state = State.new status: :transmission_sending, upload_url: "https://example.com/session", offset: 0, + in_flight_length: 512, chunk_size: 512 resp = Event::HttpResponse.new status: 200, headers: { "x-goog-upload-status" => "active" } next_state, instructions = Rules.step state, resp, @config @@ -245,90 +123,12 @@ def test_transition_transmission_sending_ack_chunk assert_equal 3, instructions.size assert_instance_of Instruction::NotifyProgress, instructions[0] assert_equal 512, instructions[0].bytes_uploaded - assert_equal 1024, instructions[0].total_bytes assert_instance_of Instruction::RealignBuffer, instructions[1] assert_equal 512, instructions[1].server_offset assert_instance_of Instruction::FillBuffer, instructions[2] assert_equal 512, instructions[2].target_bytesize end - def test_transition_transmission_sending_cat2_triggers_recovery - state = State.new status: :transmission_sending, upload_url: "https://example.com/session", offset: 0, - in_flight_length: 512 - resp = Event::HttpResponse.new status: 409, headers: {} - next_state, instructions = Rules.step state, resp, @config - - assert_equal :recovery, next_state.status - assert_equal 0, next_state.in_flight_length - assert_equal 1, instructions.size - assert_instance_of Instruction::SendQuery, instructions.first - end - - def test_transition_transmission_sending_connection_failed_triggers_recovery - state = State.new status: :transmission_sending, upload_url: "https://example.com/session", offset: 0, - in_flight_length: 512 - req_failed = Event::RequestFailed.new kind: :connection_failed, message: "Network unreachable" - next_state, instructions = Rules.step state, req_failed, @config - - assert_equal :recovery, next_state.status - assert_equal 0, next_state.in_flight_length - assert_equal 1, instructions.size - assert_instance_of Instruction::SendQuery, instructions.first - end - - def test_transition_transmission_sending_timeout_triggers_recovery - state = State.new status: :transmission_sending, upload_url: "https://example.com/session", offset: 0, - in_flight_length: 512 - req_failed = Event::RequestFailed.new kind: :timeout, message: "Read timeout" - next_state, instructions = Rules.step state, req_failed, @config - - assert_equal :recovery, next_state.status - assert_equal 0, next_state.in_flight_length - assert_equal 1, instructions.size - assert_instance_of Instruction::SendQuery, instructions.first - end - - def test_transition_finalizing_sending_upload_timeout_triggers_recovery - state = State.new status: :finalizing_sending_upload, upload_url: "https://example.com/session", offset: 512, - in_flight_length: 512 - req_failed = Event::RequestFailed.new kind: :timeout, message: "Read timeout" - next_state, instructions = Rules.step state, req_failed, @config - - assert_equal :recovery, next_state.status - assert_equal 0, next_state.in_flight_length - assert_equal 1, instructions.size - assert_instance_of Instruction::SendQuery, instructions.first - end - - def test_transition_starting_timeout_terminates_failure - state = State.new status: :starting - err = StandardError.new "Read timeout" - req_failed = Event::RequestFailed.new kind: :timeout, message: "Read timeout", source_error: err - next_state, instructions = Rules.step state, req_failed, @config - - assert_equal :error, next_state.status - assert_equal 0, next_state.in_flight_length - assert_equal err, next_state.last_error - assert_equal 1, instructions.size - assert_instance_of Instruction::TerminateFailure, instructions.first - assert_equal err, instructions.first.error - end - - def test_transition_transmission_sending_retries_exhausted_terminates_failure - state = State.new status: :transmission_sending, upload_url: "https://example.com/session", offset: 0, - in_flight_length: 512 - err = StandardError.new "Retries exhausted" - req_failed = Event::RequestFailed.new kind: :retries_exhausted, message: "Retries exhausted", source_error: err - next_state, instructions = Rules.step state, req_failed, @config - - assert_equal :error, next_state.status - assert_equal 0, next_state.in_flight_length - assert_equal err, next_state.last_error - assert_equal 1, instructions.size - assert_instance_of Instruction::TerminateFailure, instructions.first - assert_equal err, instructions.first.error - end - def test_transition_finalizing_sending_upload_success state = State.new status: :finalizing_sending_upload, offset: 512, in_flight_length: 512 resp = Event::HttpResponse.new status: 200, headers: { "x-goog-upload-status" => "final" }, body: '{"done":true}' @@ -353,43 +153,6 @@ def test_transition_finalizing_sending_finalize_success assert_instance_of Instruction::TerminateSuccess, instructions.first end - def test_transition_recovery_active_realigns_buffer - state = State.new status: :recovery, upload_url: "https://example.com/session", offset: 0, chunk_size: 512 - headers = { - "x-goog-upload-status" => "active", - "x-goog-upload-size-received" => "768" - } - resp = Event::HttpResponse.new status: 200, headers: headers - next_state, instructions = Rules.step state, resp, @config - - assert_equal :transmission_reading, next_state.status - assert_equal 768, next_state.offset - assert_equal 2, instructions.size - assert_instance_of Instruction::RealignBuffer, instructions[0] - assert_equal 768, instructions[0].server_offset - assert_instance_of Instruction::FillBuffer, instructions[1] - end - - def test_transition_recovery_final_completes_upload - state = State.new status: :recovery, upload_url: "https://example.com/session", offset: 512 - resp = Event::HttpResponse.new status: 200, headers: { "x-goog-upload-status" => "final" } - next_state, instructions = Rules.step state, resp, @config - - assert_equal :success, next_state.status - assert_equal 1, instructions.size - assert_instance_of Instruction::TerminateSuccess, instructions.first - end - - def test_transition_recovery_cat2_retries_query - state = State.new status: :recovery, upload_url: "https://example.com/session" - resp = Event::HttpResponse.new status: 416, headers: {} - next_state, instructions = Rules.step state, resp, @config - - assert_equal :recovery, next_state.status - assert_equal 1, instructions.size - assert_instance_of Instruction::SendQuery, instructions.first - end - def test_transition_cancellation_flow state = State.new status: :transmission_sending, upload_url: "https://example.com/session" next_state, instructions = Rules.step state, Event::Cancel.new, @config @@ -411,74 +174,4 @@ def test_transition_cancellation_flow assert_equal 1, final_instructions.size assert_instance_of Instruction::TerminateFailure, final_instructions.first end - - def test_transition_global_deadline_exceeded - state = State.new status: :transmission_sending - next_state, instructions = Rules.step state, Event::GlobalDeadlineExceeded.new, @config - - assert_equal :error, next_state.status - assert_instance_of Gapic::Common::DeadlineExceededError, next_state.last_error - assert_equal 1, instructions.size - assert_instance_of Instruction::TerminateFailure, instructions.first - end - - def test_invalid_transition_raises_actionable_error_with_response_details_and_header - state = State.new status: :transmission_sending - response = Event::HttpResponse.new( - status: 200, - headers: { "X-Goog-Upload-Status" => "final" }, - body: '{"done":true}' - ) - - err = assert_raises InvalidTransitionError do - Rules.step state, response, @config - end - - assert_equal( - "Resumable upload failed while sending a chunk of data: " \ - "received an unexpected HTTP 200 response (X-Goog-Upload-Status: 'final').", - err.message - ) - assert_same response, err.response - assert_same response, err.event - assert_equal :transmission_sending, err.state - end - - def test_invalid_transition_shows_missing_when_upload_status_header_absent - state = State.new status: :transmission_reading - response = Event::HttpResponse.new( - status: 200, - headers: {}, - body: "" - ) - - err = assert_raises InvalidTransitionError do - Rules.step state, response, @config - end - - assert_equal( - "Resumable upload failed while reading chunk from stream: " \ - "received an unexpected HTTP 200 response (X-Goog-Upload-Status: missing).", - err.message - ) - assert_same response, err.response - assert_equal :transmission_reading, err.state - end - - def test_invalid_transition_raises_error_for_non_http_event - state = State.new status: :starting - event = Event::ChunkRead.new bytes_buffered: 512, eof: false - err = assert_raises InvalidTransitionError do - Rules.step state, event, @config - end - - assert_equal( - "Resumable upload failed while initiating upload session: " \ - "received unexpected stream chunk read (512 bytes, eof: false).", - err.message - ) - assert_nil err.response - assert_same event, err.event - assert_equal :starting, err.state - end end diff --git a/gapic-common/test/test_helper.rb b/gapic-common/test/test_helper.rb index ea4cab9..5baa3d4 100644 --- a/gapic-common/test/test_helper.rb +++ b/gapic-common/test/test_helper.rb @@ -15,6 +15,7 @@ gem "minitest" require "minitest/autorun" require "minitest/focus" +require "minitest/mock" require "minitest/rg" require "pp" From 9764ccfc5fa1023d6e099975334fa265018638b7 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Sat, 5 Sep 2026 20:11:04 +0000 Subject: [PATCH 17/79] remove user_override for start policy param from config --- gapic-common/design/implementation-guide.md | 2 -- .../design/reference-implementation.md | 4 +-- .../gapic/rest/resumable_upload/data_types.rb | 29 ++++++++----------- .../lib/gapic/rest/resumable_upload/driver.rb | 2 +- .../rest/resumable_upload/data_types_test.rb | 1 - 5 files changed, 15 insertions(+), 23 deletions(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index af39b00..0de28d1 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -52,7 +52,6 @@ module Gapic :start_retry_policy, # [Gapic::Common::RetryPolicy, nil] Default policy for start command :control_plane_retry_policy, # [Gapic::Common::RetryPolicy, nil] Policy for query/cancel commands :data_plane_retry_policy, # [Gapic::Common::RetryPolicy, nil] Policy for upload/finalize - :user_override_start_retry_policy, # [Gapic::Common::RetryPolicy, nil] Optional user override for start command :on_progress # [Proc, nil] Callback: ->(bytes_uploaded, total_bytes) ) end @@ -188,7 +187,6 @@ Full implementation: [reference-implementation.md#3-driver-class](reference-impl 3. **Request Modification on 4xx**: Retrying Category 2 errors requires querying the backend for `server_offset` first. 4. **Standard Retry Configuration & Distinct Policies**: The Driver manages distinct retry policy configurations for Category 1 transient errors: * **Start Policy (`start_retry_policy`)**: Applies specifically to session initiation (`start`). Configured with standard retry codes (`["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"]`) and network errors (`[Faraday::ConnectionFailed, Faraday::TimeoutError, SocketError]`). A missing or empty `X-Goog-Upload-Status` header is treated as **retriable** (predicate returns `true`) across **any response code, including 200 OK**. - * **User Override for Start (`user_override_start_retry_policy`)**: If supplied by caller in `CompleteUploadConfig`, this policy overrides `start_retry_policy` exclusively for the `start` command. * **Control Plane Policy (`control_plane_retry_policy`)**: Applies to session control requests (`query`, `cancel`). Configured with standard retry codes and network errors. It does **not** retry on a missing `X-Goog-Upload-Status` header, allowing `Core` to evaluate responses immediately. * **Data Plane Policy (`data_plane_retry_policy`)**: Applies to data transmission requests (`upload`, `upload,finalize`, and standalone `finalize`). Shares the standard retry codes and network errors, but treats a missing `X-Goog-Upload-Status` header as **unretriable** (predicate returns `false`). This prevents blind chunk re-transmission and returns `Event::HttpResponse` immediately to `Core` so it can initiate Category 2 `Recovery`. diff --git a/gapic-common/design/reference-implementation.md b/gapic-common/design/reference-implementation.md index 544557f..1ab7ad5 100644 --- a/gapic-common/design/reference-implementation.md +++ b/gapic-common/design/reference-implementation.md @@ -553,10 +553,10 @@ module Gapic # Reads from stream until @buffer.bytesize reaches instruction.target_bytesize or stream hits EOF end - # Network operation: wraps start HTTP request in user_override_start_retry_policy or start_retry_policy + # Network operation: wraps start HTTP request in start_retry_policy # @return [Event::HttpResponse, Event::RequestFailed] def execute_send_start(instruction) - policy = @config.user_override_start_retry_policy || @start_retry_policy + policy = @start_retry_policy # Executes POST initiation request via @client_stub with policy in a retry loop. # Retries missing X-Goog-Upload-Status header across any response code, including 200 OK. # Returns Event::HttpResponse for any completed HTTP response (including 4xx/5xx). diff --git a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb index 34a6b08..e6352fe 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb @@ -42,8 +42,6 @@ module ResumableUpload # @return [Gapic::Common::RetryPolicy, nil] Policy for query/cancel commands # @!attribute [r] data_plane_retry_policy # @return [Gapic::Common::RetryPolicy, nil] Policy for upload/finalize - # @!attribute [r] user_override_start_retry_policy - # @return [Gapic::Common::RetryPolicy, nil] Optional user override for start command # @!attribute [r] on_progress # @return [Proc, nil] Callback invoked as `->(bytes_uploaded, total_bytes)` # @@ -59,7 +57,6 @@ module ResumableUpload :start_retry_policy, :control_plane_retry_policy, :data_plane_retry_policy, - :user_override_start_retry_policy, :on_progress ) do def initialize initial_url:, @@ -73,22 +70,20 @@ def initialize initial_url:, start_retry_policy: nil, control_plane_retry_policy: nil, data_plane_retry_policy: nil, - user_override_start_retry_policy: nil, on_progress: nil super( - initial_url: initial_url, - initial_body: initial_body, - initial_headers: initial_headers || {}, - stream: stream, - upload_size: upload_size, - chunk_size: chunk_size, - content_type: content_type, - timeout: timeout, - start_retry_policy: start_retry_policy, - control_plane_retry_policy: control_plane_retry_policy, - data_plane_retry_policy: data_plane_retry_policy, - user_override_start_retry_policy: user_override_start_retry_policy, - on_progress: on_progress + initial_url: initial_url, + initial_body: initial_body, + initial_headers: initial_headers || {}, + stream: stream, + upload_size: upload_size, + chunk_size: chunk_size, + content_type: content_type, + timeout: timeout, + start_retry_policy: start_retry_policy, + control_plane_retry_policy: control_plane_retry_policy, + data_plane_retry_policy: data_plane_retry_policy, + on_progress: on_progress ) end end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index bd7a85a..5c11e6b 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -226,7 +226,7 @@ def execute_fill_buffer instruction end def execute_send_start instruction - policy = (@config.user_override_start_retry_policy || @start_retry_policy).dup.start! + policy = @start_retry_policy.dup.start! headers = start_headers instruction loop do diff --git a/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb index 132ee6d..4a2ba5b 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb @@ -42,7 +42,6 @@ def test_complete_upload_config_defaults assert_nil config.start_retry_policy assert_nil config.control_plane_retry_policy assert_nil config.data_plane_retry_policy - assert_nil config.user_override_start_retry_policy assert_nil config.on_progress end From cea9e9efc1d4ca2da57aaee4db18ab59870b0ada Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Sat, 5 Sep 2026 21:32:28 +0000 Subject: [PATCH 18/79] feat: integration harness --- gapic-common/.toys/test-integration.rb | 139 ++++++++++++++++++++++++ gapic-common/integration/README.md | 39 +++++++ gapic-common/integration/test_helper.rb | 20 ++++ 3 files changed, 198 insertions(+) create mode 100644 gapic-common/.toys/test-integration.rb create mode 100644 gapic-common/integration/README.md create mode 100644 gapic-common/integration/test_helper.rb diff --git a/gapic-common/.toys/test-integration.rb b/gapic-common/.toys/test-integration.rb new file mode 100644 index 0000000..10a6645 --- /dev/null +++ b/gapic-common/.toys/test-integration.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "socket" +require "tmpdir" + +expand :minitest, name: "" do |t| + t.libs = ["lib", "integration"] + t.files = ["integration/**/*_test.rb"] + t.bundler = true +end + +alias_method :run_minitest, :run + +def run + if !ENV["SHOWCASE_ENDPOINT"].to_s.empty? + run_minitest + return + end + + bin = resolve_showcase_bin + if bin.nil? + if !ENV["CI"].to_s.empty? + logger.error "No SHOWCASE_ENDPOINT or gapic-showcase binary found in CI environment." + exit 1 + else + logger.warn "Skipping integration tests: no SHOWCASE_ENDPOINT or gapic-showcase binary found." + return + end + end + + verify_showcase_version! bin + + port = allocate_port + fallback_port = allocate_port + log_path = File.join Dir.tmpdir, "gapic-showcase-#{Process.pid}-#{Time.now.to_i}.log" + + pid = Process.spawn( + bin, "run", + "--port", ":#{port}", + "--fallback-port", ":#{fallback_port}", + out: log_path, + err: log_path, + pgroup: true + ) + + begin + wait_for_showcase! pid, port, log_path + ENV["SHOWCASE_ENDPOINT"] = "localhost:#{port}" + run_minitest + ensure + if pid + begin + Process.kill "-TERM", pid + Process.waitpid pid + rescue Errno::ESRCH, Errno::ECHILD + # Process already terminated + end + end + end +end + +def resolve_showcase_bin + env_bin = ENV["SHOWCASE_BIN"].to_s + return env_bin unless env_bin.empty? + + ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir| + candidate = File.join dir, "gapic-showcase" + return candidate if File.executable?(candidate) && !File.directory?(candidate) + end + nil +end + +def verify_showcase_version!(bin) + output = begin + IO.popen([bin, "--version"], err: [:child, :out], &:read).strip + rescue StandardError => e + logger.error "Failed to execute '#{bin} --version': #{e.message}" + exit 1 + end + + version_match = output[/\d+\.\d+(?:\.\d+)*/] + if version_match.nil? + logger.error "Could not parse version from '#{bin} --version' output: #{output.inspect}" + exit 1 + end + + if Gem::Version.new(version_match) < Gem::Version.new("0.43") + logger.error "gapic-showcase version #{version_match} is too old (minimum required is 0.43)." + exit 1 + end +end + +def allocate_port + server = TCPServer.open "127.0.0.1", 0 + port = server.addr[1] + server.close + port +end + +def wait_for_showcase!(pid, port, log_path) + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 10.0 + + loop do + exited_pid, status = Process.waitpid2 pid, Process::WNOHANG + if exited_pid + logger.error "gapic-showcase exited prematurely (status: #{status.exitstatus}). Log file: #{log_path}" + exit 1 + end + + begin + sock = TCPSocket.new "127.0.0.1", port + sock.close + return + rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH + # Server not ready yet + end + + if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline + logger.error "Timed out waiting 10s for gapic-showcase to listen on port #{port}. Log file: #{log_path}" + exit 1 + end + + sleep 0.1 + end +end diff --git a/gapic-common/integration/README.md b/gapic-common/integration/README.md new file mode 100644 index 0000000..99f5170 --- /dev/null +++ b/gapic-common/integration/README.md @@ -0,0 +1,39 @@ +# Integration Tests + +This directory contains integration tests for `gapic-common`, executed against a running `gapic-showcase` server via the `toys test-integration` command. + +## Running Integration Tests + +```bash +toys test-integration +``` + +You can pass standard Minitest flags to filter or seed test runs: + +```bash +toys test-integration --name /resumable_upload/ --seed 1234 +``` + +## Showcase Server Management & Lifecycle + +The `toys test-integration` command ([.toys/test-integration.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/.toys/test-integration.rb)) manages the `gapic-showcase` server lifecycle automatically: + +1. **Existing Endpoint (`SHOWCASE_ENDPOINT`)**: + - If `ENV["SHOWCASE_ENDPOINT"]` is present and non-empty, `toys test-integration` skips binary resolution and runs the Minitest suite directly against that endpoint. + +2. **Binary Resolution (`SHOWCASE_BIN` / `PATH`)**: + - When `SHOWCASE_ENDPOINT` is not set, the runner checks `ENV["SHOWCASE_BIN"]` first, then searches `ENV["PATH"]` for an executable `gapic-showcase` binary. + - **Version Check**: The runner executes ` --version` and verifies that the version is at least `0.43`. If the version is older than `0.43`, it logs an error and exits with status `1`. + +3. **Missing Endpoint and Binary**: + - **CI Environment (`ENV["CI"]` set)**: Fails immediately with exit status `1`. + - **Local Environment (`ENV["CI"]` unset)**: Logs an informational message and skips integration tests cleanly (exit status `0`). + +4. **Ephemeral Port Allocation & Polling**: + - Allocates two ephemeral TCP ports on `127.0.0.1` for `--port :` and `--fallback-port :` to avoid port collisions across concurrent runs. + - Spawns `gapic-showcase run --port : --fallback-port :` in a dedicated process group (`pgroup: true`) with `stdout` and `stderr` redirected to a temporary log file in `Dir.tmpdir`. + - Polls `127.0.0.1:` with a 10-second monotonic clock budget while checking `Process.waitpid2` (`WNOHANG`) on each iteration. If the process exits prematurely or fails to accept TCP connections within 10 seconds, the runner prints the path to the log file and exits with status `1`. + +5. **Execution & Teardown**: + - Sets `ENV["SHOWCASE_ENDPOINT"] = "localhost:#{port}"` and runs the Minitest suite (`integration/**/*_test.rb`). + - An `ensure` block sends `SIGTERM` to the entire process group (`-TERM`) and reaps the child process so no background showcase processes are leaked. diff --git a/gapic-common/integration/test_helper.rb b/gapic-common/integration/test_helper.rb new file mode 100644 index 0000000..c591633 --- /dev/null +++ b/gapic-common/integration/test_helper.rb @@ -0,0 +1,20 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "minitest/autorun" +require "minitest/focus" +require "minitest/mock" + +require "gapic/common" +require "gapic/rest" \ No newline at end of file From a294dfbc1eecbad0b4291f1817fca285c30037b1 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Sun, 6 Sep 2026 07:28:39 +0000 Subject: [PATCH 19/79] test: first integration test success --- gapic-common/.toys/test-integration.rb | 2 +- gapic-common/integration/README.md | 4 +- .../integration/integration_helper.rb | 65 +++++++++++++++++++ .../resumable_upload/golden_path_test.rb | 51 +++++++++++++++ gapic-common/integration/test_helper.rb | 20 ------ 5 files changed, 119 insertions(+), 23 deletions(-) create mode 100644 gapic-common/integration/integration_helper.rb create mode 100644 gapic-common/integration/resumable_upload/golden_path_test.rb delete mode 100644 gapic-common/integration/test_helper.rb diff --git a/gapic-common/.toys/test-integration.rb b/gapic-common/.toys/test-integration.rb index 10a6645..7929523 100644 --- a/gapic-common/.toys/test-integration.rb +++ b/gapic-common/.toys/test-integration.rb @@ -59,7 +59,7 @@ def run begin wait_for_showcase! pid, port, log_path - ENV["SHOWCASE_ENDPOINT"] = "localhost:#{port}" + ENV["SHOWCASE_ENDPOINT"] = "http://localhost:#{port}" run_minitest ensure if pid diff --git a/gapic-common/integration/README.md b/gapic-common/integration/README.md index 99f5170..7282ded 100644 --- a/gapic-common/integration/README.md +++ b/gapic-common/integration/README.md @@ -16,7 +16,7 @@ toys test-integration --name /resumable_upload/ --seed 1234 ## Showcase Server Management & Lifecycle -The `toys test-integration` command ([.toys/test-integration.rb](file:///usr/local/google/home/virost/src/omega/ruby-core-libraries/gapic-common/.toys/test-integration.rb)) manages the `gapic-showcase` server lifecycle automatically: +The `toys test-integration` command (`.toys/test-integration.rb`) manages the `gapic-showcase` server lifecycle automatically: 1. **Existing Endpoint (`SHOWCASE_ENDPOINT`)**: - If `ENV["SHOWCASE_ENDPOINT"]` is present and non-empty, `toys test-integration` skips binary resolution and runs the Minitest suite directly against that endpoint. @@ -35,5 +35,5 @@ The `toys test-integration` command ([.toys/test-integration.rb](file:///usr/loc - Polls `127.0.0.1:` with a 10-second monotonic clock budget while checking `Process.waitpid2` (`WNOHANG`) on each iteration. If the process exits prematurely or fails to accept TCP connections within 10 seconds, the runner prints the path to the log file and exits with status `1`. 5. **Execution & Teardown**: - - Sets `ENV["SHOWCASE_ENDPOINT"] = "localhost:#{port}"` and runs the Minitest suite (`integration/**/*_test.rb`). + - Sets `ENV["SHOWCASE_ENDPOINT"] = "http://localhost:#{port}"` and runs the Minitest suite (`integration/**/*_test.rb`). - An `ensure` block sends `SIGTERM` to the entire process group (`-TERM`) and reaps the child process so no background showcase processes are leaked. diff --git a/gapic-common/integration/integration_helper.rb b/gapic-common/integration/integration_helper.rb new file mode 100644 index 0000000..fa73740 --- /dev/null +++ b/gapic-common/integration/integration_helper.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "minitest/autorun" +require "minitest/focus" +require "minitest/mock" + +require "gapic/common" +require "gapic/rest" +require "gapic/rest/resumable_upload" + +## +# Helper methods for Showcase integration tests. +# +module ShowcaseIntegrationHelper + UPLOAD_PATH = "resumable/upload/v1beta1/files:upload" + + attr_reader :progress_records + + def showcase_endpoint + ENV["SHOWCASE_ENDPOINT"] + end + + def setup + skip "SHOWCASE_ENDPOINT is not set" if showcase_endpoint.to_s.empty? + super + end + + def payload size + pattern = "0123456789".b + (pattern * ((size / pattern.bytesize) + 1)).byteslice(0, size) + end + + def showcase_client_stub + Gapic::Rest::ClientStub.new( + endpoint: showcase_endpoint, + credentials: :dummy_credentials, + raise_faraday_errors: false + ) + end + + def build_config **overrides + @progress_records = [] + defaults = { + initial_url: UPLOAD_PATH, + on_progress: ->(bytes_uploaded, total_bytes) { @progress_records << [bytes_uploaded, total_bytes] } + } + Gapic::Rest::ResumableUpload::CompleteUploadConfig.new(**defaults, **overrides) + end +end + +Minitest::Test.prepend ShowcaseIntegrationHelper \ No newline at end of file diff --git a/gapic-common/integration/resumable_upload/golden_path_test.rb b/gapic-common/integration/resumable_upload/golden_path_test.rb new file mode 100644 index 0000000..352d0a0 --- /dev/null +++ b/gapic-common/integration/resumable_upload/golden_path_test.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "integration_helper" +require "json" +require "stringio" + +## +# Golden path integration tests for ResumableUpload Driver against Showcase. +# +class GoldenPathTest < Minitest::Test + def test_multi_chunk_known_size + size = 1_500_000 + chunk_size = 524_288 + stream = StringIO.new payload(size) + + config = build_config( + stream: stream, + upload_size: size, + chunk_size: chunk_size + ) + + driver = Gapic::Rest::ResumableUpload::Driver.new( + client_stub: showcase_client_stub, + config: config + ) + + result = driver.run + parsed = JSON.parse result + + assert_equal size, parsed["size"] + assert_equal [ + [524_288, 1_500_000], + [1_048_576, 1_500_000], + [1_500_000, 1_500_000] + ], progress_records + end +end diff --git a/gapic-common/integration/test_helper.rb b/gapic-common/integration/test_helper.rb deleted file mode 100644 index c591633..0000000 --- a/gapic-common/integration/test_helper.rb +++ /dev/null @@ -1,20 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -require "minitest/autorun" -require "minitest/focus" -require "minitest/mock" - -require "gapic/common" -require "gapic/rest" \ No newline at end of file From 59a525514402bedeaf27d1f6c1f149f7f9078504 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Sun, 6 Sep 2026 16:41:15 +0000 Subject: [PATCH 20/79] fix: adjust showcase test skip placement --- gapic-common/integration/integration_helper.rb | 10 ++++------ .../integration/resumable_upload/golden_path_test.rb | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/gapic-common/integration/integration_helper.rb b/gapic-common/integration/integration_helper.rb index fa73740..72900c2 100644 --- a/gapic-common/integration/integration_helper.rb +++ b/gapic-common/integration/integration_helper.rb @@ -23,9 +23,9 @@ require "gapic/rest/resumable_upload" ## -# Helper methods for Showcase integration tests. +# Base class for Showcase integration tests. # -module ShowcaseIntegrationHelper +class ShowcaseIntegrationTest < Minitest::Test UPLOAD_PATH = "resumable/upload/v1beta1/files:upload" attr_reader :progress_records @@ -41,7 +41,7 @@ def setup def payload size pattern = "0123456789".b - (pattern * ((size / pattern.bytesize) + 1)).byteslice(0, size) + (pattern * ((size / pattern.bytesize) + 1)).byteslice 0, size end def showcase_client_stub @@ -56,10 +56,8 @@ def build_config **overrides @progress_records = [] defaults = { initial_url: UPLOAD_PATH, - on_progress: ->(bytes_uploaded, total_bytes) { @progress_records << [bytes_uploaded, total_bytes] } + on_progress: ->(progress) { @progress_records << progress } } Gapic::Rest::ResumableUpload::CompleteUploadConfig.new(**defaults, **overrides) end end - -Minitest::Test.prepend ShowcaseIntegrationHelper \ No newline at end of file diff --git a/gapic-common/integration/resumable_upload/golden_path_test.rb b/gapic-common/integration/resumable_upload/golden_path_test.rb index 352d0a0..def9481 100644 --- a/gapic-common/integration/resumable_upload/golden_path_test.rb +++ b/gapic-common/integration/resumable_upload/golden_path_test.rb @@ -21,7 +21,7 @@ ## # Golden path integration tests for ResumableUpload Driver against Showcase. # -class GoldenPathTest < Minitest::Test +class GoldenPathTest < ShowcaseIntegrationTest def test_multi_chunk_known_size size = 1_500_000 chunk_size = 524_288 From bc308a1b165e26f1887b89d434bd160446f2483a Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Sun, 6 Sep 2026 16:41:34 +0000 Subject: [PATCH 21/79] feat: dedicated struct for progress notification --- gapic-common/design/implementation-guide.md | 7 +++++- .../design/reference-implementation.md | 8 ++++++- gapic-common/design/test-plan.md | 2 +- .../resumable_upload/golden_path_test.rb | 6 ++--- .../gapic/rest/resumable_upload/data_types.rb | 22 ++++++++++++++++++- .../lib/gapic/rest/resumable_upload/driver.rb | 8 ++++++- .../rest/resumable_upload/data_types_test.rb | 10 +++++++++ .../resumable_upload/driver_progress_test.rb | 12 +++++----- 8 files changed, 61 insertions(+), 14 deletions(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index 0de28d1..3c7f40c 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -52,7 +52,12 @@ module Gapic :start_retry_policy, # [Gapic::Common::RetryPolicy, nil] Default policy for start command :control_plane_retry_policy, # [Gapic::Common::RetryPolicy, nil] Policy for query/cancel commands :data_plane_retry_policy, # [Gapic::Common::RetryPolicy, nil] Policy for upload/finalize - :on_progress # [Proc, nil] Callback: ->(bytes_uploaded, total_bytes) + :on_progress # [Proc, nil] Callback: ->(progress) with a Progress instance + ) + + Progress = Data.define( + :bytes_uploaded, # [Integer] Cumulative bytes acknowledged by the server + :total_bytes # [Integer, nil] Total upload size in bytes if known ) end end diff --git a/gapic-common/design/reference-implementation.md b/gapic-common/design/reference-implementation.md index 1ab7ad5..f90bc2b 100644 --- a/gapic-common/design/reference-implementation.md +++ b/gapic-common/design/reference-implementation.md @@ -539,7 +539,13 @@ module Gapic # Synchronous side-effect: invokes user callback (exceptions propagate to caller) def execute_notify_progress(instruction) - @config.on_progress&.call(instruction.bytes_uploaded, instruction.total_bytes) + return unless @config.on_progress + + progress = Progress.new( + bytes_uploaded: instruction.bytes_uploaded, + total_bytes: instruction.total_bytes + ) + @config.on_progress.call(progress) end # Synchronous side-effect: adjusts in-memory buffer window and stream diff --git a/gapic-common/design/test-plan.md b/gapic-common/design/test-plan.md index fca670f..fe2fbc6 100644 --- a/gapic-common/design/test-plan.md +++ b/gapic-common/design/test-plan.md @@ -190,7 +190,7 @@ flowchart TD ### 3.6 Progress Notification Dispatching (`driver_progress_test.rb`) * **Safe no-op without callback**: `on_progress: nil` executes without raising. -* **Happy path**: Callback receives `(bytes_uploaded, total_bytes)` once per instruction. +* **Happy path**: Callback receives a `Progress` instance containing `bytes_uploaded` and `total_bytes` once per instruction. * **Pass-through of `total_bytes: nil`**: `total_bytes` passed as `nil` when upload size is unspecified. * **Unswallowed callback error propagation**: Exceptions raised within `on_progress` are not swallowed or caught; they immediately propagate to the caller in both `execute_notify_progress` and `Driver#run`. diff --git a/gapic-common/integration/resumable_upload/golden_path_test.rb b/gapic-common/integration/resumable_upload/golden_path_test.rb index def9481..a5229d3 100644 --- a/gapic-common/integration/resumable_upload/golden_path_test.rb +++ b/gapic-common/integration/resumable_upload/golden_path_test.rb @@ -43,9 +43,9 @@ def test_multi_chunk_known_size assert_equal size, parsed["size"] assert_equal [ - [524_288, 1_500_000], - [1_048_576, 1_500_000], - [1_500_000, 1_500_000] + Gapic::Rest::ResumableUpload::Progress.new(bytes_uploaded: 524_288, total_bytes: 1_500_000), + Gapic::Rest::ResumableUpload::Progress.new(bytes_uploaded: 1_048_576, total_bytes: 1_500_000), + Gapic::Rest::ResumableUpload::Progress.new(bytes_uploaded: 1_500_000, total_bytes: 1_500_000) ], progress_records end end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb index e6352fe..3225d4a 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb @@ -43,7 +43,7 @@ module ResumableUpload # @!attribute [r] data_plane_retry_policy # @return [Gapic::Common::RetryPolicy, nil] Policy for upload/finalize # @!attribute [r] on_progress - # @return [Proc, nil] Callback invoked as `->(bytes_uploaded, total_bytes)` + # @return [Proc, nil] Callback invoked as `->(progress)` with a {Progress} instance # CompleteUploadConfig = Data.define( :initial_url, @@ -88,6 +88,26 @@ def initialize initial_url:, end end + ## + # Immutable progress snapshot passed to the `on_progress` callback. + # + # @!attribute [r] bytes_uploaded + # @return [Integer] Cumulative bytes acknowledged by the server + # @!attribute [r] total_bytes + # @return [Integer, nil] Total upload size in bytes if known, or nil + # + Progress = Data.define( + :bytes_uploaded, + :total_bytes + ) do + def initialize bytes_uploaded:, total_bytes: nil + super( + bytes_uploaded: bytes_uploaded, + total_bytes: total_bytes + ) + end + end + ## # Immutable state snapshot representing the current protocol progression. # diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index 5c11e6b..b338456 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -158,7 +158,13 @@ def terminal_instructions? instructions end def execute_notify_progress instruction - @config.on_progress&.call instruction.bytes_uploaded, instruction.total_bytes + return unless @config.on_progress + + progress = Progress.new( + bytes_uploaded: instruction.bytes_uploaded, + total_bytes: instruction.total_bytes + ) + @config.on_progress.call progress end def execute_realign_buffer instruction diff --git a/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb index 4a2ba5b..e9ee605 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb @@ -94,4 +94,14 @@ def test_instruction_instantiation realign = Instruction::RealignBuffer.new server_offset: 500 assert_equal 500, realign.server_offset end + + def test_progress_instantiation + progress = Progress.new bytes_uploaded: 512, total_bytes: 2048 + assert_equal 512, progress.bytes_uploaded + assert_equal 2048, progress.total_bytes + + progress_unknown = Progress.new bytes_uploaded: 1024 + assert_equal 1024, progress_unknown.bytes_uploaded + assert_nil progress_unknown.total_bytes + end end diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb index 4b05281..7938a33 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb @@ -49,30 +49,30 @@ def test_execute_notify_progress_without_callback_does_not_raise def test_execute_notify_progress_happy_path_invoked_once calls = [] - callback = ->(bytes_uploaded, total_bytes) { calls << [bytes_uploaded, total_bytes] } + callback = ->(progress) { calls << progress } driver = build_driver on_progress: callback instruction = Instruction::NotifyProgress.new bytes_uploaded: 500, total_bytes: 1000 driver.send :execute_notify_progress, instruction assert_equal 1, calls.size - assert_equal [500, 1000], calls.first + assert_equal Progress.new(bytes_uploaded: 500, total_bytes: 1000), calls.first end def test_execute_notify_progress_total_bytes_nil_passes_through calls = [] - callback = ->(bytes_uploaded, total_bytes) { calls << [bytes_uploaded, total_bytes] } + callback = ->(progress) { calls << progress } driver = build_driver on_progress: callback instruction = Instruction::NotifyProgress.new bytes_uploaded: 250, total_bytes: nil driver.send :execute_notify_progress, instruction assert_equal 1, calls.size - assert_equal [250, nil], calls.first + assert_equal Progress.new(bytes_uploaded: 250, total_bytes: nil), calls.first end def test_execute_notify_progress_raises_error_to_caller_when_callback_fails - callback = ->(_bytes, _total) { raise CustomCallbackError, "User UI crashed in progress callback" } + callback = ->(_progress) { raise CustomCallbackError, "User UI crashed in progress callback" } driver = build_driver on_progress: callback instruction = Instruction::NotifyProgress.new bytes_uploaded: 100, total_bytes: 1000 @@ -99,7 +99,7 @@ def test_driver_run_propagates_callback_error_end_to_end ] stub = ScriptedClientStub.new responses - callback = ->(_bytes, _total) { raise CustomCallbackError, "Terminal failure in user progress handler" } + callback = ->(_progress) { raise CustomCallbackError, "Terminal failure in user progress handler" } config = CompleteUploadConfig.new( initial_url: "https://example.com/upload", stream: StringIO.new("0123456789"), From b652d9effa6323897b210d32fa18794030c33af4 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Sun, 6 Sep 2026 16:42:33 +0000 Subject: [PATCH 22/79] fix: missing slash in path --- gapic-common/integration/integration_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gapic-common/integration/integration_helper.rb b/gapic-common/integration/integration_helper.rb index 72900c2..642811a 100644 --- a/gapic-common/integration/integration_helper.rb +++ b/gapic-common/integration/integration_helper.rb @@ -26,7 +26,7 @@ # Base class for Showcase integration tests. # class ShowcaseIntegrationTest < Minitest::Test - UPLOAD_PATH = "resumable/upload/v1beta1/files:upload" + UPLOAD_PATH = "/resumable/upload/v1beta1/files:upload" attr_reader :progress_records From f38673828fb911ae4da318326b7cf72cfc203f44 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Sun, 6 Sep 2026 18:06:46 +0000 Subject: [PATCH 23/79] refactor: progress construction --- gapic-common/design/implementation-guide.md | 6 +++--- gapic-common/design/reference-implementation.md | 14 +++++--------- .../lib/gapic/rest/resumable_upload/driver.rb | 8 +------- .../gapic/rest/resumable_upload/instructions.rb | 8 ++++---- .../lib/gapic/rest/resumable_upload/rules.rb | 6 ++++-- .../rest/resumable_upload/driver_progress_test.rb | 8 ++++---- .../test/gapic/rest/resumable_upload/rules_test.rb | 4 ++-- 7 files changed, 23 insertions(+), 31 deletions(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index 3c7f40c..fc0b462 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -105,7 +105,7 @@ end * `Instruction::SendCancel.new(url:)`: Cancel upload session on server (`cancel` command). * `Instruction::RealignBuffer.new(server_offset:)`: Realign Driver in-memory buffer and stream position to match `server_offset`. * `Instruction::FillBuffer.new(target_bytesize:)`: Read from stream until in-memory buffer reaches `target_bytesize` bytes or stream encounters EOF. -* `Instruction::NotifyProgress.new(bytes_uploaded:, total_bytes:)`: Invoke `on_progress` callback. +* `Instruction::NotifyProgress.new(progress:)`: Invoke `on_progress` callback with a `Progress` instance. * `Instruction::TerminateSuccess.new(response:)`: Upload finalized cleanly; Driver returns `response.body`. * `Instruction::TerminateFailure.new(error:)`: Raise terminal exception. @@ -211,13 +211,13 @@ Full implementation: [reference-implementation.md#3-driver-class](reference-impl | **`Transmission \| Reading from stream`** | `:chunk_read_full` | `Event::ChunkRead(bytes_buffered, eof: false)` | `in_flight_length = event.bytes_buffered`
`status = :transmission_sending` | `Transmission \| Sending` | `Instruction::SendChunk.new(url: state.upload_url, offset: state.offset, length: event.bytes_buffered, finalize: false)` | | **`Transmission \| Reading from stream`** | `:chunk_read_eof_with_data` | `Event::ChunkRead(bytes_buffered, eof: true)` where `bytes_buffered > 0` | `in_flight_length = event.bytes_buffered`
`status = :finalizing_sending_upload` | `Finalizing \| Sending with upload` | `Instruction::SendChunk.new(url: state.upload_url, offset: state.offset, length: event.bytes_buffered, finalize: true)` | | **`Transmission \| Reading from stream`** | `:chunk_read_eof_empty` | `Event::ChunkRead(bytes_buffered: 0, eof: true)` | `in_flight_length = 0`
`status = :finalizing_sending_finalize` | `Finalizing \| Sending finalize` | `Instruction::SendFinalize.new(url: state.upload_url)` | -| **`Transmission \| Sending`** | `:response_active` | `Event::HttpResponse(200, headers, _)` with `Status: active` | `offset = state.offset + state.in_flight_length`
`in_flight_length = 0`
`status = :transmission_reading` | `Transmission \| Reading from stream` | `Instruction::NotifyProgress.new(bytes_uploaded: state.offset, total_bytes: config.upload_size)`
`Instruction::RealignBuffer.new(server_offset: state.offset)`
`Instruction::FillBuffer.new(target_bytesize: state.chunk_size)` | +| **`Transmission \| Sending`** | `:response_active` | `Event::HttpResponse(200, headers, _)` with `Status: active` | `offset = state.offset + state.in_flight_length`
`in_flight_length = 0`
`status = :transmission_reading` | `Transmission \| Reading from stream` | `Instruction::NotifyProgress.new(progress: Progress.new(bytes_uploaded: state.offset, total_bytes: config.upload_size))`
`Instruction::RealignBuffer.new(server_offset: state.offset)`
`Instruction::FillBuffer.new(target_bytesize: state.chunk_size)` | | **`Transmission \| Sending`** | `:response_cat2` | `Event::HttpResponse` (Category 2; see Section 6.1.2) | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | | **`Transmission \| Sending`** | `:request_connection_failed` / `:request_timeout` | `Event::RequestFailed(kind: :connection_failed \| :timeout)` | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | | **`Transmission \| Sending`** | `:request_retries_exhausted` | `Event::RequestFailed(kind: :retries_exhausted)` | `in_flight_length = 0`
`last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | | **`Transmission \| Sending`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, _)` with `Status: final` | `in_flight_length = 0`
`status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | | **`Transmission \| Sending`** | `:response_fatal_bad_response` | `Event::HttpResponse` (Fatal status; see Section 6.1.3) | `in_flight_length = 0`
`last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | -| **`Finalizing \| Sending with upload`** | `:response_final` | `Event::HttpResponse(200, headers, body)` with `Status: final` | `offset = state.offset + state.in_flight_length`
`in_flight_length = 0`
`status = :success` | `Success` | `Instruction::NotifyProgress.new(bytes_uploaded: state.offset, total_bytes: state.offset)`
`Instruction::TerminateSuccess.new(response: event)` | +| **`Finalizing \| Sending with upload`** | `:response_final` | `Event::HttpResponse(200, headers, body)` with `Status: final` | `offset = state.offset + state.in_flight_length`
`in_flight_length = 0`
`status = :success` | `Success` | `Instruction::NotifyProgress.new(progress: Progress.new(bytes_uploaded: state.offset, total_bytes: state.offset))`
`Instruction::TerminateSuccess.new(response: event)` | | **`Finalizing \| Sending with upload`** | `:response_cat2` | `Event::HttpResponse` (Category 2; see Section 6.1.2) | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | | **`Finalizing \| Sending with upload`** | `:request_connection_failed` / `:request_timeout` | `Event::RequestFailed(kind: :connection_failed \| :timeout)` | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | | **`Finalizing \| Sending with upload`** | `:request_retries_exhausted` | `Event::RequestFailed(kind: :retries_exhausted)` | `in_flight_length = 0`
`last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | diff --git a/gapic-common/design/reference-implementation.md b/gapic-common/design/reference-implementation.md index f90bc2b..01c6c78 100644 --- a/gapic-common/design/reference-implementation.md +++ b/gapic-common/design/reference-implementation.md @@ -185,8 +185,9 @@ module Gapic offset: new_offset, in_flight_length: 0 ) + progress = Progress.new(bytes_uploaded: new_offset, total_bytes: config.upload_size) instructions = [ - Instruction::NotifyProgress.new(bytes_uploaded: new_offset, total_bytes: config.upload_size), + Instruction::NotifyProgress.new(progress: progress), Instruction::RealignBuffer.new(server_offset: new_offset), Instruction::FillBuffer.new(target_bytesize: state.chunk_size) ] @@ -216,8 +217,9 @@ module Gapic offset: new_offset, in_flight_length: 0 ) + progress = Progress.new(bytes_uploaded: new_offset, total_bytes: new_offset) instructions = [ - Instruction::NotifyProgress.new(bytes_uploaded: new_offset, total_bytes: new_offset), + Instruction::NotifyProgress.new(progress: progress), Instruction::TerminateSuccess.new(response: event) ] [next_state, instructions] @@ -539,13 +541,7 @@ module Gapic # Synchronous side-effect: invokes user callback (exceptions propagate to caller) def execute_notify_progress(instruction) - return unless @config.on_progress - - progress = Progress.new( - bytes_uploaded: instruction.bytes_uploaded, - total_bytes: instruction.total_bytes - ) - @config.on_progress.call(progress) + @config.on_progress&.call(instruction.progress) end # Synchronous side-effect: adjusts in-memory buffer window and stream diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index b338456..89e6a3b 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -158,13 +158,7 @@ def terminal_instructions? instructions end def execute_notify_progress instruction - return unless @config.on_progress - - progress = Progress.new( - bytes_uploaded: instruction.bytes_uploaded, - total_bytes: instruction.total_bytes - ) - @config.on_progress.call progress + @config.on_progress&.call instruction.progress end def execute_realign_buffer instruction diff --git a/gapic-common/lib/gapic/rest/resumable_upload/instructions.rb b/gapic-common/lib/gapic/rest/resumable_upload/instructions.rb index 5f0723a..4415c44 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/instructions.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/instructions.rb @@ -85,11 +85,11 @@ def initialize target_bytesize: end ## - # Invoke user progress callback with bytes_uploaded and total_bytes. + # Invoke user progress callback with a Progress instance. # - NotifyProgress = Data.define :bytes_uploaded, :total_bytes do - def initialize bytes_uploaded:, total_bytes: nil - super bytes_uploaded: bytes_uploaded, total_bytes: total_bytes + NotifyProgress = Data.define :progress do + def initialize progress: + super progress: progress end end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb index 465bdd0..97b6613 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb @@ -209,8 +209,9 @@ def self.ack_chunk state, config offset: new_offset, in_flight_length: 0 ) + progress = Progress.new bytes_uploaded: new_offset, total_bytes: config.upload_size instructions = [ - Instruction::NotifyProgress.new(bytes_uploaded: new_offset, total_bytes: config.upload_size), + Instruction::NotifyProgress.new(progress: progress), Instruction::RealignBuffer.new(server_offset: new_offset), Instruction::FillBuffer.new(target_bytesize: state.chunk_size) ] @@ -240,8 +241,9 @@ def self.complete_upload_with_data state, event offset: new_offset, in_flight_length: 0 ) + progress = Progress.new bytes_uploaded: new_offset, total_bytes: new_offset instructions = [ - Instruction::NotifyProgress.new(bytes_uploaded: new_offset, total_bytes: new_offset), + Instruction::NotifyProgress.new(progress: progress), Instruction::TerminateSuccess.new(response: event) ] [next_state, instructions] diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb index 7938a33..d67d9fb 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb @@ -42,7 +42,7 @@ def make_post_request uri:, body: nil, params: {}, options: {} def test_execute_notify_progress_without_callback_does_not_raise driver = build_driver on_progress: nil - instruction = Instruction::NotifyProgress.new bytes_uploaded: 1024, total_bytes: 4096 + instruction = Instruction::NotifyProgress.new progress: Progress.new(bytes_uploaded: 1024, total_bytes: 4096) # Must not raise when callback is nil driver.send :execute_notify_progress, instruction end @@ -52,7 +52,7 @@ def test_execute_notify_progress_happy_path_invoked_once callback = ->(progress) { calls << progress } driver = build_driver on_progress: callback - instruction = Instruction::NotifyProgress.new bytes_uploaded: 500, total_bytes: 1000 + instruction = Instruction::NotifyProgress.new progress: Progress.new(bytes_uploaded: 500, total_bytes: 1000) driver.send :execute_notify_progress, instruction assert_equal 1, calls.size @@ -64,7 +64,7 @@ def test_execute_notify_progress_total_bytes_nil_passes_through callback = ->(progress) { calls << progress } driver = build_driver on_progress: callback - instruction = Instruction::NotifyProgress.new bytes_uploaded: 250, total_bytes: nil + instruction = Instruction::NotifyProgress.new progress: Progress.new(bytes_uploaded: 250, total_bytes: nil) driver.send :execute_notify_progress, instruction assert_equal 1, calls.size @@ -75,7 +75,7 @@ def test_execute_notify_progress_raises_error_to_caller_when_callback_fails callback = ->(_progress) { raise CustomCallbackError, "User UI crashed in progress callback" } driver = build_driver on_progress: callback - instruction = Instruction::NotifyProgress.new bytes_uploaded: 100, total_bytes: 1000 + instruction = Instruction::NotifyProgress.new progress: Progress.new(bytes_uploaded: 100, total_bytes: 1000) err = assert_raises CustomCallbackError do driver.send :execute_notify_progress, instruction end diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb index 17c69f6..02c6d4e 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb @@ -122,7 +122,7 @@ def test_transition_transmission_sending_ack_chunk assert_equal 0, next_state.in_flight_length assert_equal 3, instructions.size assert_instance_of Instruction::NotifyProgress, instructions[0] - assert_equal 512, instructions[0].bytes_uploaded + assert_equal Progress.new(bytes_uploaded: 512, total_bytes: 1024), instructions[0].progress assert_instance_of Instruction::RealignBuffer, instructions[1] assert_equal 512, instructions[1].server_offset assert_instance_of Instruction::FillBuffer, instructions[2] @@ -139,7 +139,7 @@ def test_transition_finalizing_sending_upload_success assert_equal 0, next_state.in_flight_length assert_equal 2, instructions.size assert_instance_of Instruction::NotifyProgress, instructions[0] - assert_equal 1024, instructions[0].bytes_uploaded + assert_equal Progress.new(bytes_uploaded: 1024, total_bytes: 1024), instructions[0].progress assert_instance_of Instruction::TerminateSuccess, instructions[1] end From 454f86180ff15de731ea05e1b0cfb69e457a36ac Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Sun, 6 Sep 2026 19:07:43 +0000 Subject: [PATCH 24/79] refactor: decisions --- gapic-common/design/implementation-guide.md | 19 +- .../design/reference-implementation.md | 161 +++++++++-------- .../lib/gapic/rest/resumable_upload/core.rb | 11 +- .../gapic/rest/resumable_upload/data_types.rb | 28 +++ .../lib/gapic/rest/resumable_upload/events.rb | 2 + .../lib/gapic/rest/resumable_upload/rules.rb | 157 +++++++++------- .../gapic/rest/resumable_upload/core_test.rb | 11 ++ .../gapic/rest/resumable_upload/rules_test.rb | 167 ++++++++++++++++++ 8 files changed, 411 insertions(+), 145 deletions(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index fc0b462..e1257f1 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -23,10 +23,10 @@ The `Driver` executes all operations with side-effects. It interacts with HTTP t Crucially, the Driver delegates all **Category 1 (Transient)** transport retries directly to `Gapic::Common::RetryPolicy`. Transient retries occur entirely within the Driver's network execution wrapper. The `Core` state machine is never exposed to transient noise, receiving only verified successful HTTP responses or terminal transport exceptions. ### 1.2 Core (State Container) -The `Core` maintains the immutable `State` snapshot. When `Core#dispatch(event)` is invoked by the Driver, Core forwards `@state`, the event, and static configuration to `Rules.step`. Core mutates `@state` to the returned next state and yields instructions back to the Driver. Core contains zero protocol branching logic and zero side effects. +The `Core` maintains the immutable `State` snapshot. When `Core#dispatch(event)` is invoked by the Driver, Core forwards `@state`, the event, and static configuration to `Rules.decide`. Core mutates `@state` to `decision.next_state`, records the decision in `@last_decision`, and returns `decision.instructions` back to the Driver. Core contains zero protocol branching logic and zero side effects. ### 1.3 Rules (Pure Decision Function) -The `Rules` module encapsulates the Resumable Upload Protocol state transitions as a pure functional module. Given a state snapshot, an input event, and configuration, `Rules.step` computes the next protocol state and emitted driver instructions. +The `Rules` module encapsulates the Resumable Upload Protocol state transitions as a pure functional module. Given a state snapshot, an input event, and configuration, `Rules.decide` evaluates the transition router and returns a `Decision` snapshot containing `from_status`, `shape`, `next_state`, and `instructions`. ### 1.4 Stream Buffering Because arbitrary Ruby `IO` objects (network sockets, pipes, `STDIN`) do not support seeking (`#seek`), the Driver buffers the current in-flight chunk in memory (bounded by chunk size, default: 8MB). When `RetryPolicy` executes transport retries, or when `Core` triggers Category 2 recovery realignments within the buffered range, the Driver retransmits directly from memory. The buffer is discarded only after receiving a `200 OK` durably confirming receipt of the chunk. @@ -64,7 +64,7 @@ module Gapic end ``` -### 2.2 Protocol State (`State`) +### 2.2 Protocol State (`State`) & Decisions (`Decision`) ```ruby module Gapic module Rest @@ -81,6 +81,13 @@ module Gapic :last_error # [StandardError, nil] Terminal exception ) do end + + Decision = Data.define( + :from_status, # [Symbol] Status before transition + :shape, # [Symbol] Classified canonical event shape + :next_state, # [State] Resulting protocol state snapshot + :instructions # [Array] Emitted instructions for the Driver + ) end end end @@ -153,14 +160,16 @@ The complete reference implementation for `Rules`, `Core`, and `Driver` is locat ### 3.1 Rules Module (`Gapic::Rest::ResumableUpload::Rules`) The `Rules` module is a pure functional transition engine with zero state awareness and zero side effects. It provides two primary entry points: * `Rules.shape_of(event)`: Classifies raw input events (`Event::StartUpload`, `Event::ChunkRead`, `Event::HttpResponse`, `Event::RequestFailed`, `Event::Cancel`, `Event::GlobalDeadlineExceeded`) into canonical symbols. -* `Rules.step(state, event, config)`: Evaluates `case [state.status, shape]` pattern matching to compute the next state snapshot and emitted driver instructions (`[next_state, instructions]`). +* `Rules.decide(state, event, config)`: Evaluates `case [state.status, shape]` pattern matching to select a transition recipe symbol, dispatches via `public_send(recipe, state, event, config)`, and returns a `Decision` snapshot (`from_status`, `shape`, `next_state`, `instructions`). +* `Rules.step(state, event, config)`: Convenience tuple wrapper around `Rules.decide` returning `[decision.next_state, decision.instructions]`. Full implementation: [reference-implementation.md#1-rules-module](reference-implementation.md#1-rules-module) ### 3.2 Core Class (`Gapic::Rest::ResumableUpload::Core`) The `Core` class is the state container holding the immutable `State` snapshot. It exposes: * `#state`: Reader for the current `State` snapshot. -* `#dispatch(event)`: Invokes `Rules.step(@state, event, @config)`, updates `@state = next_state`, and returns the emitted instructions array to the Driver. +* `#last_decision`: Reader for decisions recorded during the most recent `#dispatch`. +* `#dispatch(event)`: Invokes `Rules.decide(@state, event, @config)`, updates `@state = decision.next_state` and `@last_decision = [decision]`, and returns `decision.instructions` to the Driver. Full implementation: [reference-implementation.md#2-core-class](reference-implementation.md#2-core-class) diff --git a/gapic-common/design/reference-implementation.md b/gapic-common/design/reference-implementation.md index 01c6c78..c0de8bf 100644 --- a/gapic-common/design/reference-implementation.md +++ b/gapic-common/design/reference-implementation.md @@ -54,65 +54,78 @@ module Gapic end end - # Top-level transition router. Matches [state.status, shape]. + # Top-level transition decision engine. Matches [state.status, shape]. # # @param state [State] Current state # @param event [Object] Input event # @param config [CompleteUploadConfig] Static configuration - # @return [Array>] Tuple of [next_state, instructions] - def self.step(state, event, config) + # @return [Decision] Decision snapshot + def self.decide(state, event, config) shape = shape_of(event) - case [state.status, shape] - in [:initializing, :start_upload] - start_session(state, config) - in [:starting, :response_active] - begin_transmission(state, event, config) - in [:transmission_reading, :chunk_read_full] - send_chunk(state, event) - in [:transmission_reading, :chunk_read_eof_with_data] - send_upload_finalize(state, event) - in [:transmission_reading, :chunk_read_eof_empty] - send_finalize(state) - in [:transmission_sending, :response_active] - ack_chunk(state, config) - in [:transmission_sending | :finalizing_sending_upload | :finalizing_sending_finalize, :response_cat2 | :request_connection_failed | :request_timeout] - enter_recovery(state) - in [:finalizing_sending_upload, :response_final] - complete_upload_with_data(state, event) - in [:finalizing_sending_finalize, :response_final] - complete_upload_finalized(state, event) - in [:recovery, :response_active] - realign_from_recovery(state, event) - in [:recovery, :response_final] - complete_upload_finalized(state, event) - in [:recovery, :response_cat2] - retry_recovery(state) - in [:cancelling, :response_cancelled] - complete_cancellation(state) - in [:cancelling, :user_cancel] - [state, []] - in [_, :global_deadline_exceeded] - fail_with_deadline_exceeded(state) - in [_, :user_cancel] - cancel_session(state) - in [:starting | :transmission_sending | :finalizing_sending_upload | - :finalizing_sending_finalize | :recovery | :cancelling, :response_rejected] - fail_with_rejected(state, event) - in [:starting | :cancelling, :response_cat2] | - [:starting | :transmission_sending | :finalizing_sending_upload | - :finalizing_sending_finalize | :recovery | :cancelling, :response_fatal_bad_response] - fail_with_bad_response(state, event) - in [:starting | :transmission_sending | :finalizing_sending_upload | - :finalizing_sending_finalize | :recovery | :cancelling, - :request_retries_exhausted | :request_connection_failed | :request_timeout | :request_failed_unknown] - fail_with_request_error(state, event) - else - fail_with_unmatched_transition(state, event) - end + recipe = case [state.status, shape] + in [:initializing, :start_upload] + :start_session + in [:starting, :response_active] + :begin_transmission + in [:transmission_reading, :chunk_read_full] + :send_chunk + in [:transmission_reading, :chunk_read_eof_with_data] + :send_upload_finalize + in [:transmission_reading, :chunk_read_eof_empty] + :send_finalize + in [:transmission_sending, :response_active] + :ack_chunk + in [:transmission_sending | :finalizing_sending_upload | :finalizing_sending_finalize, + :response_cat2 | :request_connection_failed | :request_timeout] + :enter_recovery + in [:finalizing_sending_upload, :response_final] + :complete_upload_with_data + in [:finalizing_sending_finalize | :recovery, :response_final] + :complete_upload_finalized + in [:recovery, :response_active] + :realign_from_recovery + in [:recovery, :response_cat2] + :retry_recovery + in [:cancelling, :response_cancelled] + :complete_cancellation + in [:cancelling, :user_cancel] + :ignore_duplicate_cancel + in [_, :global_deadline_exceeded] + :fail_with_deadline_exceeded + in [_, :user_cancel] + :cancel_session + in [:starting | :transmission_sending | :finalizing_sending_upload | + :finalizing_sending_finalize | :recovery | :cancelling, :response_rejected] + :fail_with_rejected + in [:starting | :cancelling, :response_cat2] | + [:starting | :transmission_sending | :finalizing_sending_upload | + :finalizing_sending_finalize | :recovery | :cancelling, :response_fatal_bad_response] + :fail_with_bad_response + in [:starting | :transmission_sending | :finalizing_sending_upload | + :finalizing_sending_finalize | :recovery | :cancelling, + :request_retries_exhausted | :request_connection_failed | :request_timeout | + :request_failed_unknown] + :fail_with_request_error + else + :fail_with_unmatched_transition + end + + next_state, instructions = public_send(recipe, state, event, config) + Decision.new( + from_status: state.status, + shape: shape, + next_state: next_state, + instructions: instructions + ) end - def self.start_session(state, config) + def self.step(state, event, config) + decision = decide(state, event, config) + [decision.next_state, decision.instructions] + end + + def self.start_session(state, _event, config) next_state = state.with(status: :starting) instructions = [ Instruction::SendStart.new( @@ -138,7 +151,7 @@ module Gapic [next_state, [Instruction::FillBuffer.new(target_bytesize: chunk_size)]] end - def self.send_chunk(state, event) + def self.send_chunk(state, event, _config) next_state = state.with( status: :transmission_sending, in_flight_length: event.bytes_buffered @@ -154,7 +167,7 @@ module Gapic [next_state, instructions] end - def self.send_upload_finalize(state, event) + def self.send_upload_finalize(state, event, _config) next_state = state.with( status: :finalizing_sending_upload, in_flight_length: event.bytes_buffered @@ -170,7 +183,7 @@ module Gapic [next_state, instructions] end - def self.send_finalize(state) + def self.send_finalize(state, _event, _config) next_state = state.with( status: :finalizing_sending_finalize, in_flight_length: 0 @@ -178,7 +191,7 @@ module Gapic [next_state, [Instruction::SendFinalize.new(url: state.upload_url)]] end - def self.ack_chunk(state, config) + def self.ack_chunk(state, _event, config) new_offset = state.offset + state.in_flight_length next_state = state.with( status: :transmission_reading, @@ -194,7 +207,7 @@ module Gapic [next_state, instructions] end - def self.enter_recovery(state) + def self.enter_recovery(state, _event, _config) next_state = state.with( status: :recovery, in_flight_length: 0 @@ -202,7 +215,7 @@ module Gapic [next_state, [Instruction::SendQuery.new(url: state.upload_url)]] end - def self.retry_recovery(state) + def self.retry_recovery(state, _event, _config) next_state = state.with( status: :recovery, in_flight_length: 0 @@ -210,7 +223,7 @@ module Gapic [next_state, [Instruction::SendQuery.new(url: state.upload_url)]] end - def self.complete_upload_with_data(state, event) + def self.complete_upload_with_data(state, event, _config) new_offset = state.offset + state.in_flight_length next_state = state.with( status: :success, @@ -225,7 +238,7 @@ module Gapic [next_state, instructions] end - def self.complete_upload_finalized(state, event) + def self.complete_upload_finalized(state, event, _config) next_state = state.with( status: :success, in_flight_length: 0 @@ -233,7 +246,7 @@ module Gapic [next_state, [Instruction::TerminateSuccess.new(response: event)]] end - def self.realign_from_recovery(state, event) + def self.realign_from_recovery(state, event, _config) server_offset = event.headers["x-goog-upload-size-received"].to_i next_state = state.with( status: :transmission_reading, @@ -247,17 +260,21 @@ module Gapic [next_state, instructions] end - def self.complete_cancellation(state) + def self.complete_cancellation(state, _event, _config) next_state = state.with(status: :cancelled, in_flight_length: 0) [next_state, [Instruction::TerminateFailure.new(error: Gapic::Common::UploadCancelledError.new)]] end - def self.cancel_session(state) + def self.ignore_duplicate_cancel(state, _event, _config) + [state, []] + end + + def self.cancel_session(state, _event, _config) next_state = state.with(status: :cancelling) [next_state, [Instruction::SendCancel.new(url: state.upload_url)]] end - def self.fail_with_deadline_exceeded(state) + def self.fail_with_deadline_exceeded(state, _event, _config) next_state = state.with( status: :error, in_flight_length: 0, @@ -266,7 +283,7 @@ module Gapic [next_state, [Instruction::TerminateFailure.new(error: next_state.last_error)]] end - def self.fail_with_rejected(state, event) + def self.fail_with_rejected(state, event, _config) next_state = state.with( status: :rejected, in_flight_length: 0, @@ -275,7 +292,7 @@ module Gapic [next_state, [Instruction::TerminateFailure.new(error: next_state.last_error)]] end - def self.fail_with_bad_response(state, event) + def self.fail_with_bad_response(state, event, _config) next_state = state.with( status: :error, in_flight_length: 0, @@ -284,7 +301,7 @@ module Gapic [next_state, [Instruction::TerminateFailure.new(error: next_state.last_error)]] end - def self.fail_with_request_error(state, event) + def self.fail_with_request_error(state, event, _config) next_state = state.with( status: :error, in_flight_length: 0, @@ -293,7 +310,7 @@ module Gapic [next_state, [Instruction::TerminateFailure.new(error: event.source_error)]] end - def self.fail_with_unmatched_transition(state, event) + def self.fail_with_unmatched_transition(state, event, _config) shape = shape_of(event) action = STATE_DESCRIPTIONS[state.status] || "processing #{state.status}" happened = describe_event(event, shape) @@ -362,11 +379,12 @@ module Gapic module Rest module ResumableUpload class Core - attr_reader :state + attr_reader :state, :last_decision # @param config [CompleteUploadConfig] def initialize(config) @config = config + @last_decision = [] @state = State.new( status: :initializing, upload_url: nil, offset: 0, chunk_size: config.chunk_size || 8_388_608, @@ -379,9 +397,10 @@ module Gapic # @param event [Object] Input event # @return [Array] Driver instructions def dispatch(event) - next_state, instructions = Rules.step(@state, event, @config) - @state = next_state - instructions + decision = Rules.decide(@state, event, @config) + @state = decision.next_state + @last_decision = [decision] + decision.instructions end end end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/core.rb b/gapic-common/lib/gapic/rest/resumable_upload/core.rb index 3c51037..e5fd436 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/core.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/core.rb @@ -28,9 +28,13 @@ class Core # @return [State] Current immutable state snapshot attr_reader :state + # @return [Array] Decisions emitted during the last dispatch + attr_reader :last_decision + # @param config [CompleteUploadConfig] def initialize config @config = config + @last_decision = [] @state = State.new( status: :initializing, upload_url: nil, @@ -48,9 +52,10 @@ def initialize config # @param event [Object] Input event # @return [Array] Driver instructions def dispatch event - next_state, instructions = Rules.step @state, event, @config - @state = next_state - instructions + decision = Rules.decide @state, event, @config + @state = decision.next_state + @last_decision = [decision] + decision.instructions end end end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb index 3225d4a..5621400 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb @@ -138,6 +138,34 @@ def initialize status: :initializing, ) end end + + ## + # Immutable decision snapshot emitted by Rules.decide. + # + # @!attribute [r] from_status + # @return [Symbol] The protocol status before the transition + # @!attribute [r] shape + # @return [Symbol] The canonical event shape + # @!attribute [r] next_state + # @return [State] The new protocol state snapshot after transition + # @!attribute [r] instructions + # @return [Array] Emitted instructions for the Driver + # + Decision = Data.define( + :from_status, + :shape, + :next_state, + :instructions + ) do + def initialize from_status:, shape:, next_state:, instructions: [] + super( + from_status: from_status, + shape: shape, + next_state: next_state, + instructions: instructions + ) + end + end end end end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/events.rb b/gapic-common/lib/gapic/rest/resumable_upload/events.rb index a36f057..26cc285 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/events.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/events.rb @@ -19,6 +19,8 @@ module Rest module ResumableUpload ## # Event vocabulary emitted by the Driver and dispatched to Core/Rules. + # Events are `outside-in` signaling. Something happened, e.g. a chunk of data + # was successfully read, and the Driver is reporting that to Core/Rules. # module Event ## diff --git a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb index 97b6613..9af5b41 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb @@ -74,67 +74,88 @@ def self.shape_of event end ## - # Top-level transition router. Matches [state.status, shape]. + # Top-level transition decision engine. Matches [state.status, shape]. # # @param state [State] Current state # @param event [Object] Input event # @param config [CompleteUploadConfig] Static configuration - # @return [Array>] Tuple of [next_state, instructions] + # @return [Decision] Decision snapshot # # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength - def self.step state, event, config + def self.decide state, event, config shape = shape_of event - case [state.status, shape] - in [:initializing, :start_upload] - start_session state, config - in [:starting, :response_active] - begin_transmission state, event, config - in [:transmission_reading, :chunk_read_full] - send_chunk state, event - in [:transmission_reading, :chunk_read_eof_with_data] - send_upload_finalize state, event - in [:transmission_reading, :chunk_read_eof_empty] - send_finalize state - in [:transmission_sending, :response_active] - ack_chunk state, config - in [:transmission_sending | :finalizing_sending_upload | :finalizing_sending_finalize, - :response_cat2 | :request_connection_failed | :request_timeout] - enter_recovery state - in [:finalizing_sending_upload, :response_final] - complete_upload_with_data state, event - in [:finalizing_sending_finalize | :recovery, :response_final] - complete_upload_finalized state, event - in [:recovery, :response_active] - realign_from_recovery state, event - in [:recovery, :response_cat2] - retry_recovery state - in [:cancelling, :response_cancelled] - complete_cancellation state - in [:cancelling, :user_cancel] - [state, []] - in [_, :global_deadline_exceeded] - fail_with_deadline_exceeded state - in [_, :user_cancel] - cancel_session state - in [:starting | :transmission_sending | :finalizing_sending_upload | - :finalizing_sending_finalize | :recovery | :cancelling, :response_rejected] - fail_with_rejected state, event - in [:starting | :cancelling, :response_cat2] | - [:starting | :transmission_sending | :finalizing_sending_upload | - :finalizing_sending_finalize | :recovery | :cancelling, :response_fatal_bad_response] - fail_with_bad_response state, event - in [:starting | :transmission_sending | :finalizing_sending_upload | - :finalizing_sending_finalize | :recovery | :cancelling, - :request_retries_exhausted | :request_connection_failed | :request_timeout | :request_failed_unknown] - fail_with_request_error state, event - else - fail_with_unmatched_transition state, event - end + recipe = case [state.status, shape] + in [:initializing, :start_upload] + :start_session + in [:starting, :response_active] + :begin_transmission + in [:transmission_reading, :chunk_read_full] + :send_chunk + in [:transmission_reading, :chunk_read_eof_with_data] + :send_upload_finalize + in [:transmission_reading, :chunk_read_eof_empty] + :send_finalize + in [:transmission_sending, :response_active] + :ack_chunk + in [:transmission_sending | :finalizing_sending_upload | :finalizing_sending_finalize, + :response_cat2 | :request_connection_failed | :request_timeout] + :enter_recovery + in [:finalizing_sending_upload, :response_final] + :complete_upload_with_data + in [:finalizing_sending_finalize | :recovery, :response_final] + :complete_upload_finalized + in [:recovery, :response_active] + :realign_from_recovery + in [:recovery, :response_cat2] + :retry_recovery + in [:cancelling, :response_cancelled] + :complete_cancellation + in [:cancelling, :user_cancel] + :ignore_duplicate_cancel + in [_, :global_deadline_exceeded] + :fail_with_deadline_exceeded + in [_, :user_cancel] + :cancel_session + in [:starting | :transmission_sending | :finalizing_sending_upload | + :finalizing_sending_finalize | :recovery | :cancelling, :response_rejected] + :fail_with_rejected + in [:starting | :cancelling, :response_cat2] | + [:starting | :transmission_sending | :finalizing_sending_upload | + :finalizing_sending_finalize | :recovery | :cancelling, :response_fatal_bad_response] + :fail_with_bad_response + in [:starting | :transmission_sending | :finalizing_sending_upload | + :finalizing_sending_finalize | :recovery | :cancelling, + :request_retries_exhausted | :request_connection_failed | :request_timeout | + :request_failed_unknown] + :fail_with_request_error + else + :fail_with_unmatched_transition + end + + next_state, instructions = public_send recipe, state, event, config + Decision.new( + from_status: state.status, + shape: shape, + next_state: next_state, + instructions: instructions + ) end # rubocop:enable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength - def self.start_session state, config + ## + # Top-level transition router. Matches [state.status, shape]. + # + # @param state [State] Current state + # @param event [Object] Input event + # @param config [CompleteUploadConfig] Static configuration + # @return [Array>] Tuple of [next_state, instructions] + def self.step state, event, config + decision = decide state, event, config + [decision.next_state, decision.instructions] + end + + def self.start_session state, _event, config next_state = state.with status: :starting instructions = [ Instruction::SendStart.new( @@ -162,7 +183,7 @@ def self.begin_transmission state, event, config [next_state, [Instruction::FillBuffer.new(target_bytesize: chunk_size)]] end - def self.send_chunk state, event + def self.send_chunk state, event, _config next_state = state.with( status: :transmission_sending, in_flight_length: event.bytes_buffered @@ -178,7 +199,7 @@ def self.send_chunk state, event [next_state, instructions] end - def self.send_upload_finalize state, event + def self.send_upload_finalize state, event, _config next_state = state.with( status: :finalizing_sending_upload, in_flight_length: event.bytes_buffered @@ -194,7 +215,7 @@ def self.send_upload_finalize state, event [next_state, instructions] end - def self.send_finalize state + def self.send_finalize state, _event, _config next_state = state.with( status: :finalizing_sending_finalize, in_flight_length: 0 @@ -202,7 +223,7 @@ def self.send_finalize state [next_state, [Instruction::SendFinalize.new(url: state.upload_url)]] end - def self.ack_chunk state, config + def self.ack_chunk state, _event, config new_offset = state.offset + state.in_flight_length next_state = state.with( status: :transmission_reading, @@ -218,7 +239,7 @@ def self.ack_chunk state, config [next_state, instructions] end - def self.enter_recovery state + def self.enter_recovery state, _event, _config next_state = state.with( status: :recovery, in_flight_length: 0 @@ -226,7 +247,7 @@ def self.enter_recovery state [next_state, [Instruction::SendQuery.new(url: state.upload_url)]] end - def self.retry_recovery state + def self.retry_recovery state, _event, _config next_state = state.with( status: :recovery, in_flight_length: 0 @@ -234,7 +255,7 @@ def self.retry_recovery state [next_state, [Instruction::SendQuery.new(url: state.upload_url)]] end - def self.complete_upload_with_data state, event + def self.complete_upload_with_data state, event, _config new_offset = state.offset + state.in_flight_length next_state = state.with( status: :success, @@ -249,7 +270,7 @@ def self.complete_upload_with_data state, event [next_state, instructions] end - def self.complete_upload_finalized state, event + def self.complete_upload_finalized state, event, _config next_state = state.with( status: :success, in_flight_length: 0 @@ -257,7 +278,7 @@ def self.complete_upload_finalized state, event [next_state, [Instruction::TerminateSuccess.new(response: event)]] end - def self.realign_from_recovery state, event + def self.realign_from_recovery state, event, _config server_offset_str = header_value event.headers, "x-goog-upload-size-received" server_offset = server_offset_str.to_i next_state = state.with( @@ -272,18 +293,22 @@ def self.realign_from_recovery state, event [next_state, instructions] end - def self.complete_cancellation state + def self.complete_cancellation state, _event, _config err = Gapic::Common::UploadCancelledError.new next_state = state.with status: :cancelled, in_flight_length: 0, last_error: err [next_state, [Instruction::TerminateFailure.new(error: err)]] end - def self.cancel_session state + def self.ignore_duplicate_cancel state, _event, _config + [state, []] + end + + def self.cancel_session state, _event, _config next_state = state.with status: :cancelling [next_state, [Instruction::SendCancel.new(url: state.upload_url)]] end - def self.fail_with_deadline_exceeded state + def self.fail_with_deadline_exceeded state, _event, _config err = Gapic::Common::DeadlineExceededError.new next_state = state.with( status: :error, @@ -293,7 +318,7 @@ def self.fail_with_deadline_exceeded state [next_state, [Instruction::TerminateFailure.new(error: err)]] end - def self.fail_with_rejected state, event + def self.fail_with_rejected state, event, _config err = Gapic::Common::UploadRejectedError.new event.body next_state = state.with( status: :rejected, @@ -303,7 +328,7 @@ def self.fail_with_rejected state, event [next_state, [Instruction::TerminateFailure.new(error: err)]] end - def self.fail_with_bad_response state, event + def self.fail_with_bad_response state, event, _config err = Gapic::Common::BadResponseError.new event.status next_state = state.with( status: :error, @@ -313,7 +338,7 @@ def self.fail_with_bad_response state, event [next_state, [Instruction::TerminateFailure.new(error: err)]] end - def self.fail_with_request_error state, event + def self.fail_with_request_error state, event, _config err = event.source_error || Gapic::Common::Error.new(event.message || "Request failed") next_state = state.with( status: :error, @@ -323,7 +348,7 @@ def self.fail_with_request_error state, event [next_state, [Instruction::TerminateFailure.new(error: err)]] end - def self.fail_with_unmatched_transition state, event + def self.fail_with_unmatched_transition state, event, _config shape = shape_of event action = STATE_DESCRIPTIONS[state.status] || "processing #{state.status}" happened = describe_event event, shape diff --git a/gapic-common/test/gapic/rest/resumable_upload/core_test.rb b/gapic-common/test/gapic/rest/resumable_upload/core_test.rb index 5c51093..13ed0ab 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/core_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/core_test.rb @@ -40,6 +40,7 @@ def test_initial_state assert_nil state.chunk_granularity assert_equal 0, state.in_flight_length assert_nil state.last_error + assert_empty @core.last_decision end def test_dispatch_updates_state_and_returns_instructions @@ -47,6 +48,11 @@ def test_dispatch_updates_state_and_returns_instructions assert_equal :starting, @core.state.status assert_equal 1, instructions.size assert_instance_of Instruction::SendStart, instructions.first + assert_equal 1, @core.last_decision.size + assert_equal :initializing, @core.last_decision.first.from_status + assert_equal :start_upload, @core.last_decision.first.shape + assert_equal @core.state, @core.last_decision.first.next_state + assert_equal instructions, @core.last_decision.first.instructions resp = Event::HttpResponse.new( status: 200, @@ -64,5 +70,10 @@ def test_dispatch_updates_state_and_returns_instructions assert_equal 1, instructions.size assert_instance_of Instruction::FillBuffer, instructions.first assert_equal 1024, instructions.first.target_bytesize + assert_equal 1, @core.last_decision.size + assert_equal :starting, @core.last_decision.first.from_status + assert_equal :response_active, @core.last_decision.first.shape + assert_equal @core.state, @core.last_decision.first.next_state + assert_equal instructions, @core.last_decision.first.instructions end end diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb index 02c6d4e..99f2941 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb @@ -174,4 +174,171 @@ def test_transition_cancellation_flow assert_equal 1, final_instructions.size assert_instance_of Instruction::TerminateFailure, final_instructions.first end + + def test_decide_assertions_per_row + # Row 1: [:initializing, :start_upload] -> :start_session + decision = Rules.decide State.new(status: :initializing), Event::StartUpload.new, @config + assert_equal :initializing, decision.from_status + assert_equal :start_upload, decision.shape + assert_equal :starting, decision.next_state.status + assert_instance_of Instruction::SendStart, decision.instructions.first + + # Row 2: [:starting, :response_active] -> :begin_transmission + active_resp = Event::HttpResponse.new( + status: 200, + headers: { "x-goog-upload-status" => "active", "x-goog-upload-url" => "https://example.com/session" } + ) + decision = Rules.decide State.new(status: :starting), active_resp, @config + assert_equal :starting, decision.from_status + assert_equal :response_active, decision.shape + assert_equal :transmission_reading, decision.next_state.status + assert_instance_of Instruction::FillBuffer, decision.instructions.first + + # Row 3: [:transmission_reading, :chunk_read_full] -> :send_chunk + decision = Rules.decide( + State.new(status: :transmission_reading, upload_url: "https://example.com/session"), + Event::ChunkRead.new(bytes_buffered: 512, eof: false), + @config + ) + assert_equal :transmission_reading, decision.from_status + assert_equal :chunk_read_full, decision.shape + assert_equal :transmission_sending, decision.next_state.status + assert_instance_of Instruction::SendChunk, decision.instructions.first + refute decision.instructions.first.finalize + + # Row 4: [:transmission_reading, :chunk_read_eof_with_data] -> :send_upload_finalize + decision = Rules.decide( + State.new(status: :transmission_reading, upload_url: "https://example.com/session"), + Event::ChunkRead.new(bytes_buffered: 256, eof: true), + @config + ) + assert_equal :transmission_reading, decision.from_status + assert_equal :chunk_read_eof_with_data, decision.shape + assert_equal :finalizing_sending_upload, decision.next_state.status + assert_instance_of Instruction::SendChunk, decision.instructions.first + assert decision.instructions.first.finalize + + # Row 5: [:transmission_reading, :chunk_read_eof_empty] -> :send_finalize + decision = Rules.decide( + State.new(status: :transmission_reading, upload_url: "https://example.com/session"), + Event::ChunkRead.new(bytes_buffered: 0, eof: true), + @config + ) + assert_equal :transmission_reading, decision.from_status + assert_equal :chunk_read_eof_empty, decision.shape + assert_equal :finalizing_sending_finalize, decision.next_state.status + assert_instance_of Instruction::SendFinalize, decision.instructions.first + + # Row 6: [:transmission_sending, :response_active] -> :ack_chunk + decision = Rules.decide( + State.new(status: :transmission_sending, upload_url: "https://example.com/session", offset: 0, in_flight_length: 512), + active_resp, + @config + ) + assert_equal :transmission_sending, decision.from_status + assert_equal :response_active, decision.shape + assert_equal :transmission_reading, decision.next_state.status + assert_equal 3, decision.instructions.size + + # Row 7: [:transmission_sending, :response_cat2] -> :enter_recovery + cat2_resp = Event::HttpResponse.new status: 503, headers: {} + decision = Rules.decide( + State.new(status: :transmission_sending, upload_url: "https://example.com/session"), + cat2_resp, + @config + ) + assert_equal :transmission_sending, decision.from_status + assert_equal :response_cat2, decision.shape + assert_equal :recovery, decision.next_state.status + assert_instance_of Instruction::SendQuery, decision.instructions.first + + # Row 8: [:finalizing_sending_upload, :response_final] -> :complete_upload_with_data + final_resp = Event::HttpResponse.new status: 200, headers: { "x-goog-upload-status" => "final" } + decision = Rules.decide( + State.new(status: :finalizing_sending_upload, offset: 512, in_flight_length: 512), + final_resp, + @config + ) + assert_equal :finalizing_sending_upload, decision.from_status + assert_equal :response_final, decision.shape + assert_equal :success, decision.next_state.status + assert_equal 2, decision.instructions.size + + # Row 9: [:finalizing_sending_finalize, :response_final] -> :complete_upload_finalized + decision = Rules.decide State.new(status: :finalizing_sending_finalize), final_resp, @config + assert_equal :finalizing_sending_finalize, decision.from_status + assert_equal :response_final, decision.shape + assert_equal :success, decision.next_state.status + assert_instance_of Instruction::TerminateSuccess, decision.instructions.first + + # Row 10: [:recovery, :response_active] -> :realign_from_recovery + recovery_active_resp = Event::HttpResponse.new( + status: 200, + headers: { "x-goog-upload-status" => "active", "x-goog-upload-size-received" => "256" } + ) + decision = Rules.decide State.new(status: :recovery), recovery_active_resp, @config + assert_equal :recovery, decision.from_status + assert_equal :response_active, decision.shape + assert_equal :transmission_reading, decision.next_state.status + assert_instance_of Instruction::RealignBuffer, decision.instructions.first + + # Row 11: [:recovery, :response_cat2] -> :retry_recovery + decision = Rules.decide State.new(status: :recovery), cat2_resp, @config + assert_equal :recovery, decision.from_status + assert_equal :response_cat2, decision.shape + assert_equal :recovery, decision.next_state.status + assert_instance_of Instruction::SendQuery, decision.instructions.first + + # Row 12: [:cancelling, :response_cancelled] -> :complete_cancellation + cancelled_resp = Event::HttpResponse.new status: 200, headers: { "x-goog-upload-status" => "cancelled" } + decision = Rules.decide State.new(status: :cancelling), cancelled_resp, @config + assert_equal :cancelling, decision.from_status + assert_equal :response_cancelled, decision.shape + assert_equal :cancelled, decision.next_state.status + assert_instance_of Instruction::TerminateFailure, decision.instructions.first + + # Row 13: [:cancelling, :user_cancel] -> :ignore_duplicate_cancel + decision = Rules.decide State.new(status: :cancelling), Event::Cancel.new, @config + assert_equal :cancelling, decision.from_status + assert_equal :user_cancel, decision.shape + assert_equal :cancelling, decision.next_state.status + assert_empty decision.instructions + + # Row 14: [_, :global_deadline_exceeded] -> :fail_with_deadline_exceeded + decision = Rules.decide State.new(status: :transmission_sending), Event::GlobalDeadlineExceeded.new, @config + assert_equal :transmission_sending, decision.from_status + assert_equal :global_deadline_exceeded, decision.shape + assert_equal :error, decision.next_state.status + assert_instance_of Gapic::Common::DeadlineExceededError, decision.next_state.last_error + + # Row 15: [_, :user_cancel] -> :cancel_session + decision = Rules.decide State.new(status: :transmission_sending), Event::Cancel.new, @config + assert_equal :transmission_sending, decision.from_status + assert_equal :user_cancel, decision.shape + assert_equal :cancelling, decision.next_state.status + assert_instance_of Instruction::SendCancel, decision.instructions.first + + # Row 16: [:starting, :response_rejected] -> :fail_with_rejected + rejected_resp = Event::HttpResponse.new status: 403, headers: { "x-goog-upload-status" => "final" }, body: "Rejected" + decision = Rules.decide State.new(status: :starting), rejected_resp, @config + assert_equal :starting, decision.from_status + assert_equal :response_rejected, decision.shape + assert_equal :rejected, decision.next_state.status + assert_instance_of Gapic::Common::UploadRejectedError, decision.next_state.last_error + + # Row 17: [:starting, :response_cat2] -> :fail_with_bad_response + decision = Rules.decide State.new(status: :starting), cat2_resp, @config + assert_equal :starting, decision.from_status + assert_equal :response_cat2, decision.shape + assert_equal :error, decision.next_state.status + assert_instance_of Gapic::Common::BadResponseError, decision.next_state.last_error + + # Row 18: [:starting, :request_retries_exhausted] -> :fail_with_request_error + req_failed = Event::RequestFailed.new kind: :retries_exhausted, message: "Exhausted" + decision = Rules.decide State.new(status: :starting), req_failed, @config + assert_equal :starting, decision.from_status + assert_equal :request_retries_exhausted, decision.shape + assert_equal :error, decision.next_state.status + assert_instance_of Instruction::TerminateFailure, decision.instructions.first + end end From b50d7acb1c629eba19c8303870bf3bc28780475b Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Sun, 6 Sep 2026 19:29:59 +0000 Subject: [PATCH 25/79] fix: update decision, refactor tests --- gapic-common/design/implementation-guide.md | 9 +- .../design/reference-implementation.md | 5 +- .../lib/gapic/rest/resumable_upload/core.rb | 6 +- .../gapic/rest/resumable_upload/data_types.rb | 6 +- .../lib/gapic/rest/resumable_upload/rules.rb | 1 + .../gapic/rest/resumable_upload/core_test.rb | 24 +- .../resumable_upload/rules_decide_test.rb | 245 ++++++++++++++++++ .../gapic/rest/resumable_upload/rules_test.rb | 167 ------------ 8 files changed, 275 insertions(+), 188 deletions(-) create mode 100644 gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index e1257f1..51cdc9b 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -26,7 +26,7 @@ Crucially, the Driver delegates all **Category 1 (Transient)** transport retries The `Core` maintains the immutable `State` snapshot. When `Core#dispatch(event)` is invoked by the Driver, Core forwards `@state`, the event, and static configuration to `Rules.decide`. Core mutates `@state` to `decision.next_state`, records the decision in `@last_decision`, and returns `decision.instructions` back to the Driver. Core contains zero protocol branching logic and zero side effects. ### 1.3 Rules (Pure Decision Function) -The `Rules` module encapsulates the Resumable Upload Protocol state transitions as a pure functional module. Given a state snapshot, an input event, and configuration, `Rules.decide` evaluates the transition router and returns a `Decision` snapshot containing `from_status`, `shape`, `next_state`, and `instructions`. +The `Rules` module encapsulates the Resumable Upload Protocol state transitions as a pure functional module. Given a state snapshot, an input event, and configuration, `Rules.decide` evaluates the transition router and returns a `Decision` snapshot containing `from_status`, `shape`, `recipe`, `next_state`, and `instructions`. ### 1.4 Stream Buffering Because arbitrary Ruby `IO` objects (network sockets, pipes, `STDIN`) do not support seeking (`#seek`), the Driver buffers the current in-flight chunk in memory (bounded by chunk size, default: 8MB). When `RetryPolicy` executes transport retries, or when `Core` triggers Category 2 recovery realignments within the buffered range, the Driver retransmits directly from memory. The buffer is discarded only after receiving a `200 OK` durably confirming receipt of the chunk. @@ -85,6 +85,7 @@ module Gapic Decision = Data.define( :from_status, # [Symbol] Status before transition :shape, # [Symbol] Classified canonical event shape + :recipe, # [Symbol] Selected transition recipe method name :next_state, # [State] Resulting protocol state snapshot :instructions # [Array] Emitted instructions for the Driver ) @@ -160,7 +161,7 @@ The complete reference implementation for `Rules`, `Core`, and `Driver` is locat ### 3.1 Rules Module (`Gapic::Rest::ResumableUpload::Rules`) The `Rules` module is a pure functional transition engine with zero state awareness and zero side effects. It provides two primary entry points: * `Rules.shape_of(event)`: Classifies raw input events (`Event::StartUpload`, `Event::ChunkRead`, `Event::HttpResponse`, `Event::RequestFailed`, `Event::Cancel`, `Event::GlobalDeadlineExceeded`) into canonical symbols. -* `Rules.decide(state, event, config)`: Evaluates `case [state.status, shape]` pattern matching to select a transition recipe symbol, dispatches via `public_send(recipe, state, event, config)`, and returns a `Decision` snapshot (`from_status`, `shape`, `next_state`, `instructions`). +* `Rules.decide(state, event, config)`: Evaluates `case [state.status, shape]` pattern matching to select a transition recipe symbol, dispatches via `public_send(recipe, state, event, config)`, and returns a `Decision` snapshot (`from_status`, `shape`, `recipe`, `next_state`, `instructions`). * `Rules.step(state, event, config)`: Convenience tuple wrapper around `Rules.decide` returning `[decision.next_state, decision.instructions]`. Full implementation: [reference-implementation.md#1-rules-module](reference-implementation.md#1-rules-module) @@ -168,8 +169,8 @@ Full implementation: [reference-implementation.md#1-rules-module](reference-impl ### 3.2 Core Class (`Gapic::Rest::ResumableUpload::Core`) The `Core` class is the state container holding the immutable `State` snapshot. It exposes: * `#state`: Reader for the current `State` snapshot. -* `#last_decision`: Reader for decisions recorded during the most recent `#dispatch`. -* `#dispatch(event)`: Invokes `Rules.decide(@state, event, @config)`, updates `@state = decision.next_state` and `@last_decision = [decision]`, and returns `decision.instructions` to the Driver. +* `#last_decision`: Reader for the `Decision` recorded during the most recent `#dispatch` (or `nil`). +* `#dispatch(event)`: Invokes `Rules.decide(@state, event, @config)`, updates `@state = decision.next_state` and `@last_decision = decision`, and returns `decision.instructions` to the Driver. Full implementation: [reference-implementation.md#2-core-class](reference-implementation.md#2-core-class) diff --git a/gapic-common/design/reference-implementation.md b/gapic-common/design/reference-implementation.md index c0de8bf..f709511 100644 --- a/gapic-common/design/reference-implementation.md +++ b/gapic-common/design/reference-implementation.md @@ -115,6 +115,7 @@ module Gapic Decision.new( from_status: state.status, shape: shape, + recipe: recipe, next_state: next_state, instructions: instructions ) @@ -384,7 +385,7 @@ module Gapic # @param config [CompleteUploadConfig] def initialize(config) @config = config - @last_decision = [] + @last_decision = nil @state = State.new( status: :initializing, upload_url: nil, offset: 0, chunk_size: config.chunk_size || 8_388_608, @@ -399,7 +400,7 @@ module Gapic def dispatch(event) decision = Rules.decide(@state, event, @config) @state = decision.next_state - @last_decision = [decision] + @last_decision = decision decision.instructions end end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/core.rb b/gapic-common/lib/gapic/rest/resumable_upload/core.rb index e5fd436..1f4b001 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/core.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/core.rb @@ -28,13 +28,13 @@ class Core # @return [State] Current immutable state snapshot attr_reader :state - # @return [Array] Decisions emitted during the last dispatch + # @return [Decision, nil] Decision emitted during the last dispatch attr_reader :last_decision # @param config [CompleteUploadConfig] def initialize config @config = config - @last_decision = [] + @last_decision = nil @state = State.new( status: :initializing, upload_url: nil, @@ -54,7 +54,7 @@ def initialize config def dispatch event decision = Rules.decide @state, event, @config @state = decision.next_state - @last_decision = [decision] + @last_decision = decision decision.instructions end end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb index 5621400..50e873d 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb @@ -146,6 +146,8 @@ def initialize status: :initializing, # @return [Symbol] The protocol status before the transition # @!attribute [r] shape # @return [Symbol] The canonical event shape + # @!attribute [r] recipe + # @return [Symbol] Selected transition recipe method name # @!attribute [r] next_state # @return [State] The new protocol state snapshot after transition # @!attribute [r] instructions @@ -154,13 +156,15 @@ def initialize status: :initializing, Decision = Data.define( :from_status, :shape, + :recipe, :next_state, :instructions ) do - def initialize from_status:, shape:, next_state:, instructions: [] + def initialize from_status:, shape:, recipe:, next_state:, instructions: [] super( from_status: from_status, shape: shape, + recipe: recipe, next_state: next_state, instructions: instructions ) diff --git a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb index 9af5b41..cf75d90 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb @@ -137,6 +137,7 @@ def self.decide state, event, config Decision.new( from_status: state.status, shape: shape, + recipe: recipe, next_state: next_state, instructions: instructions ) diff --git a/gapic-common/test/gapic/rest/resumable_upload/core_test.rb b/gapic-common/test/gapic/rest/resumable_upload/core_test.rb index 13ed0ab..86a59d8 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/core_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/core_test.rb @@ -40,7 +40,7 @@ def test_initial_state assert_nil state.chunk_granularity assert_equal 0, state.in_flight_length assert_nil state.last_error - assert_empty @core.last_decision + assert_nil @core.last_decision end def test_dispatch_updates_state_and_returns_instructions @@ -48,11 +48,12 @@ def test_dispatch_updates_state_and_returns_instructions assert_equal :starting, @core.state.status assert_equal 1, instructions.size assert_instance_of Instruction::SendStart, instructions.first - assert_equal 1, @core.last_decision.size - assert_equal :initializing, @core.last_decision.first.from_status - assert_equal :start_upload, @core.last_decision.first.shape - assert_equal @core.state, @core.last_decision.first.next_state - assert_equal instructions, @core.last_decision.first.instructions + assert_instance_of Decision, @core.last_decision + assert_equal :initializing, @core.last_decision.from_status + assert_equal :start_upload, @core.last_decision.shape + assert_equal :start_session, @core.last_decision.recipe + assert_equal @core.state, @core.last_decision.next_state + assert_equal instructions, @core.last_decision.instructions resp = Event::HttpResponse.new( status: 200, @@ -70,10 +71,11 @@ def test_dispatch_updates_state_and_returns_instructions assert_equal 1, instructions.size assert_instance_of Instruction::FillBuffer, instructions.first assert_equal 1024, instructions.first.target_bytesize - assert_equal 1, @core.last_decision.size - assert_equal :starting, @core.last_decision.first.from_status - assert_equal :response_active, @core.last_decision.first.shape - assert_equal @core.state, @core.last_decision.first.next_state - assert_equal instructions, @core.last_decision.first.instructions + assert_instance_of Decision, @core.last_decision + assert_equal :starting, @core.last_decision.from_status + assert_equal :response_active, @core.last_decision.shape + assert_equal :begin_transmission, @core.last_decision.recipe + assert_equal @core.state, @core.last_decision.next_state + assert_equal instructions, @core.last_decision.instructions end end diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb new file mode 100644 index 0000000..5c0ef64 --- /dev/null +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb @@ -0,0 +1,245 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "test_helper" +require "gapic/rest/resumable_upload" +require "stringio" + +## +# Tests for ResumableUpload Rules.decide transitions per router row. +# +class RulesDecideTest < Minitest::Test + include Gapic::Rest::ResumableUpload + + def setup + @config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + initial_headers: { "X-Custom" => "value" }, + initial_body: '{"name":"obj"}', + stream: StringIO.new("data"), + upload_size: 1024, + chunk_size: 512 + ) + end + + def test_row_initializing_start_upload + decision = Rules.decide State.new(status: :initializing), Event::StartUpload.new, @config + assert_equal :initializing, decision.from_status + assert_equal :start_upload, decision.shape + assert_equal :start_session, decision.recipe + assert_equal :starting, decision.next_state.status + assert_instance_of Instruction::SendStart, decision.instructions.first + end + + def test_row_starting_response_active + active_resp = Event::HttpResponse.new( + status: 200, + headers: { "x-goog-upload-status" => "active", "x-goog-upload-url" => "https://example.com/session" } + ) + decision = Rules.decide State.new(status: :starting), active_resp, @config + assert_equal :starting, decision.from_status + assert_equal :response_active, decision.shape + assert_equal :begin_transmission, decision.recipe + assert_equal :transmission_reading, decision.next_state.status + assert_instance_of Instruction::FillBuffer, decision.instructions.first + end + + def test_row_transmission_reading_chunk_read_full + decision = Rules.decide( + State.new(status: :transmission_reading, upload_url: "https://example.com/session"), + Event::ChunkRead.new(bytes_buffered: 512, eof: false), + @config + ) + assert_equal :transmission_reading, decision.from_status + assert_equal :chunk_read_full, decision.shape + assert_equal :send_chunk, decision.recipe + assert_equal :transmission_sending, decision.next_state.status + assert_instance_of Instruction::SendChunk, decision.instructions.first + refute decision.instructions.first.finalize + end + + def test_row_transmission_reading_chunk_read_eof_with_data + decision = Rules.decide( + State.new(status: :transmission_reading, upload_url: "https://example.com/session"), + Event::ChunkRead.new(bytes_buffered: 256, eof: true), + @config + ) + assert_equal :transmission_reading, decision.from_status + assert_equal :chunk_read_eof_with_data, decision.shape + assert_equal :send_upload_finalize, decision.recipe + assert_equal :finalizing_sending_upload, decision.next_state.status + assert_instance_of Instruction::SendChunk, decision.instructions.first + assert decision.instructions.first.finalize + end + + def test_row_transmission_reading_chunk_read_eof_empty + decision = Rules.decide( + State.new(status: :transmission_reading, upload_url: "https://example.com/session"), + Event::ChunkRead.new(bytes_buffered: 0, eof: true), + @config + ) + assert_equal :transmission_reading, decision.from_status + assert_equal :chunk_read_eof_empty, decision.shape + assert_equal :send_finalize, decision.recipe + assert_equal :finalizing_sending_finalize, decision.next_state.status + assert_instance_of Instruction::SendFinalize, decision.instructions.first + end + + def test_row_transmission_sending_response_active + active_resp = Event::HttpResponse.new( + status: 200, + headers: { "x-goog-upload-status" => "active", "x-goog-upload-url" => "https://example.com/session" } + ) + decision = Rules.decide( + State.new(status: :transmission_sending, upload_url: "https://example.com/session", offset: 0, in_flight_length: 512), + active_resp, + @config + ) + assert_equal :transmission_sending, decision.from_status + assert_equal :response_active, decision.shape + assert_equal :ack_chunk, decision.recipe + assert_equal :transmission_reading, decision.next_state.status + assert_equal 3, decision.instructions.size + end + + def test_row_transmission_sending_enter_recovery + cat2_resp = Event::HttpResponse.new status: 503, headers: {} + decision = Rules.decide( + State.new(status: :transmission_sending, upload_url: "https://example.com/session"), + cat2_resp, + @config + ) + assert_equal :transmission_sending, decision.from_status + assert_equal :response_cat2, decision.shape + assert_equal :enter_recovery, decision.recipe + assert_equal :recovery, decision.next_state.status + assert_instance_of Instruction::SendQuery, decision.instructions.first + end + + def test_row_finalizing_sending_upload_response_final + final_resp = Event::HttpResponse.new status: 200, headers: { "x-goog-upload-status" => "final" } + decision = Rules.decide( + State.new(status: :finalizing_sending_upload, offset: 512, in_flight_length: 512), + final_resp, + @config + ) + assert_equal :finalizing_sending_upload, decision.from_status + assert_equal :response_final, decision.shape + assert_equal :complete_upload_with_data, decision.recipe + assert_equal :success, decision.next_state.status + assert_equal 2, decision.instructions.size + end + + def test_row_finalizing_sending_finalize_response_final + final_resp = Event::HttpResponse.new status: 200, headers: { "x-goog-upload-status" => "final" } + decision = Rules.decide State.new(status: :finalizing_sending_finalize), final_resp, @config + assert_equal :finalizing_sending_finalize, decision.from_status + assert_equal :response_final, decision.shape + assert_equal :complete_upload_finalized, decision.recipe + assert_equal :success, decision.next_state.status + assert_instance_of Instruction::TerminateSuccess, decision.instructions.first + end + + def test_row_recovery_response_active + recovery_active_resp = Event::HttpResponse.new( + status: 200, + headers: { "x-goog-upload-status" => "active", "x-goog-upload-size-received" => "256" } + ) + decision = Rules.decide State.new(status: :recovery), recovery_active_resp, @config + assert_equal :recovery, decision.from_status + assert_equal :response_active, decision.shape + assert_equal :realign_from_recovery, decision.recipe + assert_equal :transmission_reading, decision.next_state.status + assert_instance_of Instruction::RealignBuffer, decision.instructions.first + end + + def test_row_recovery_response_cat2 + cat2_resp = Event::HttpResponse.new status: 503, headers: {} + decision = Rules.decide State.new(status: :recovery), cat2_resp, @config + assert_equal :recovery, decision.from_status + assert_equal :response_cat2, decision.shape + assert_equal :retry_recovery, decision.recipe + assert_equal :recovery, decision.next_state.status + assert_instance_of Instruction::SendQuery, decision.instructions.first + end + + def test_row_cancelling_response_cancelled + cancelled_resp = Event::HttpResponse.new status: 200, headers: { "x-goog-upload-status" => "cancelled" } + decision = Rules.decide State.new(status: :cancelling), cancelled_resp, @config + assert_equal :cancelling, decision.from_status + assert_equal :response_cancelled, decision.shape + assert_equal :complete_cancellation, decision.recipe + assert_equal :cancelled, decision.next_state.status + assert_instance_of Instruction::TerminateFailure, decision.instructions.first + end + + def test_row_cancelling_user_cancel + decision = Rules.decide State.new(status: :cancelling), Event::Cancel.new, @config + assert_equal :cancelling, decision.from_status + assert_equal :user_cancel, decision.shape + assert_equal :ignore_duplicate_cancel, decision.recipe + assert_equal :cancelling, decision.next_state.status + assert_empty decision.instructions + end + + def test_row_global_deadline_exceeded + decision = Rules.decide State.new(status: :transmission_sending), Event::GlobalDeadlineExceeded.new, @config + assert_equal :transmission_sending, decision.from_status + assert_equal :global_deadline_exceeded, decision.shape + assert_equal :fail_with_deadline_exceeded, decision.recipe + assert_equal :error, decision.next_state.status + assert_instance_of Gapic::Common::DeadlineExceededError, decision.next_state.last_error + end + + def test_row_user_cancel + decision = Rules.decide State.new(status: :transmission_sending), Event::Cancel.new, @config + assert_equal :transmission_sending, decision.from_status + assert_equal :user_cancel, decision.shape + assert_equal :cancel_session, decision.recipe + assert_equal :cancelling, decision.next_state.status + assert_instance_of Instruction::SendCancel, decision.instructions.first + end + + def test_row_response_rejected + rejected_resp = Event::HttpResponse.new status: 403, headers: { "x-goog-upload-status" => "final" }, body: "Rejected" + decision = Rules.decide State.new(status: :starting), rejected_resp, @config + assert_equal :starting, decision.from_status + assert_equal :response_rejected, decision.shape + assert_equal :fail_with_rejected, decision.recipe + assert_equal :rejected, decision.next_state.status + assert_instance_of Gapic::Common::UploadRejectedError, decision.next_state.last_error + end + + def test_row_fail_with_bad_response + cat2_resp = Event::HttpResponse.new status: 503, headers: {} + decision = Rules.decide State.new(status: :starting), cat2_resp, @config + assert_equal :starting, decision.from_status + assert_equal :response_cat2, decision.shape + assert_equal :fail_with_bad_response, decision.recipe + assert_equal :error, decision.next_state.status + assert_instance_of Gapic::Common::BadResponseError, decision.next_state.last_error + end + + def test_row_fail_with_request_error + req_failed = Event::RequestFailed.new kind: :retries_exhausted, message: "Exhausted" + decision = Rules.decide State.new(status: :starting), req_failed, @config + assert_equal :starting, decision.from_status + assert_equal :request_retries_exhausted, decision.shape + assert_equal :fail_with_request_error, decision.recipe + assert_equal :error, decision.next_state.status + assert_instance_of Instruction::TerminateFailure, decision.instructions.first + end +end diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb index 99f2941..02c6d4e 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb @@ -174,171 +174,4 @@ def test_transition_cancellation_flow assert_equal 1, final_instructions.size assert_instance_of Instruction::TerminateFailure, final_instructions.first end - - def test_decide_assertions_per_row - # Row 1: [:initializing, :start_upload] -> :start_session - decision = Rules.decide State.new(status: :initializing), Event::StartUpload.new, @config - assert_equal :initializing, decision.from_status - assert_equal :start_upload, decision.shape - assert_equal :starting, decision.next_state.status - assert_instance_of Instruction::SendStart, decision.instructions.first - - # Row 2: [:starting, :response_active] -> :begin_transmission - active_resp = Event::HttpResponse.new( - status: 200, - headers: { "x-goog-upload-status" => "active", "x-goog-upload-url" => "https://example.com/session" } - ) - decision = Rules.decide State.new(status: :starting), active_resp, @config - assert_equal :starting, decision.from_status - assert_equal :response_active, decision.shape - assert_equal :transmission_reading, decision.next_state.status - assert_instance_of Instruction::FillBuffer, decision.instructions.first - - # Row 3: [:transmission_reading, :chunk_read_full] -> :send_chunk - decision = Rules.decide( - State.new(status: :transmission_reading, upload_url: "https://example.com/session"), - Event::ChunkRead.new(bytes_buffered: 512, eof: false), - @config - ) - assert_equal :transmission_reading, decision.from_status - assert_equal :chunk_read_full, decision.shape - assert_equal :transmission_sending, decision.next_state.status - assert_instance_of Instruction::SendChunk, decision.instructions.first - refute decision.instructions.first.finalize - - # Row 4: [:transmission_reading, :chunk_read_eof_with_data] -> :send_upload_finalize - decision = Rules.decide( - State.new(status: :transmission_reading, upload_url: "https://example.com/session"), - Event::ChunkRead.new(bytes_buffered: 256, eof: true), - @config - ) - assert_equal :transmission_reading, decision.from_status - assert_equal :chunk_read_eof_with_data, decision.shape - assert_equal :finalizing_sending_upload, decision.next_state.status - assert_instance_of Instruction::SendChunk, decision.instructions.first - assert decision.instructions.first.finalize - - # Row 5: [:transmission_reading, :chunk_read_eof_empty] -> :send_finalize - decision = Rules.decide( - State.new(status: :transmission_reading, upload_url: "https://example.com/session"), - Event::ChunkRead.new(bytes_buffered: 0, eof: true), - @config - ) - assert_equal :transmission_reading, decision.from_status - assert_equal :chunk_read_eof_empty, decision.shape - assert_equal :finalizing_sending_finalize, decision.next_state.status - assert_instance_of Instruction::SendFinalize, decision.instructions.first - - # Row 6: [:transmission_sending, :response_active] -> :ack_chunk - decision = Rules.decide( - State.new(status: :transmission_sending, upload_url: "https://example.com/session", offset: 0, in_flight_length: 512), - active_resp, - @config - ) - assert_equal :transmission_sending, decision.from_status - assert_equal :response_active, decision.shape - assert_equal :transmission_reading, decision.next_state.status - assert_equal 3, decision.instructions.size - - # Row 7: [:transmission_sending, :response_cat2] -> :enter_recovery - cat2_resp = Event::HttpResponse.new status: 503, headers: {} - decision = Rules.decide( - State.new(status: :transmission_sending, upload_url: "https://example.com/session"), - cat2_resp, - @config - ) - assert_equal :transmission_sending, decision.from_status - assert_equal :response_cat2, decision.shape - assert_equal :recovery, decision.next_state.status - assert_instance_of Instruction::SendQuery, decision.instructions.first - - # Row 8: [:finalizing_sending_upload, :response_final] -> :complete_upload_with_data - final_resp = Event::HttpResponse.new status: 200, headers: { "x-goog-upload-status" => "final" } - decision = Rules.decide( - State.new(status: :finalizing_sending_upload, offset: 512, in_flight_length: 512), - final_resp, - @config - ) - assert_equal :finalizing_sending_upload, decision.from_status - assert_equal :response_final, decision.shape - assert_equal :success, decision.next_state.status - assert_equal 2, decision.instructions.size - - # Row 9: [:finalizing_sending_finalize, :response_final] -> :complete_upload_finalized - decision = Rules.decide State.new(status: :finalizing_sending_finalize), final_resp, @config - assert_equal :finalizing_sending_finalize, decision.from_status - assert_equal :response_final, decision.shape - assert_equal :success, decision.next_state.status - assert_instance_of Instruction::TerminateSuccess, decision.instructions.first - - # Row 10: [:recovery, :response_active] -> :realign_from_recovery - recovery_active_resp = Event::HttpResponse.new( - status: 200, - headers: { "x-goog-upload-status" => "active", "x-goog-upload-size-received" => "256" } - ) - decision = Rules.decide State.new(status: :recovery), recovery_active_resp, @config - assert_equal :recovery, decision.from_status - assert_equal :response_active, decision.shape - assert_equal :transmission_reading, decision.next_state.status - assert_instance_of Instruction::RealignBuffer, decision.instructions.first - - # Row 11: [:recovery, :response_cat2] -> :retry_recovery - decision = Rules.decide State.new(status: :recovery), cat2_resp, @config - assert_equal :recovery, decision.from_status - assert_equal :response_cat2, decision.shape - assert_equal :recovery, decision.next_state.status - assert_instance_of Instruction::SendQuery, decision.instructions.first - - # Row 12: [:cancelling, :response_cancelled] -> :complete_cancellation - cancelled_resp = Event::HttpResponse.new status: 200, headers: { "x-goog-upload-status" => "cancelled" } - decision = Rules.decide State.new(status: :cancelling), cancelled_resp, @config - assert_equal :cancelling, decision.from_status - assert_equal :response_cancelled, decision.shape - assert_equal :cancelled, decision.next_state.status - assert_instance_of Instruction::TerminateFailure, decision.instructions.first - - # Row 13: [:cancelling, :user_cancel] -> :ignore_duplicate_cancel - decision = Rules.decide State.new(status: :cancelling), Event::Cancel.new, @config - assert_equal :cancelling, decision.from_status - assert_equal :user_cancel, decision.shape - assert_equal :cancelling, decision.next_state.status - assert_empty decision.instructions - - # Row 14: [_, :global_deadline_exceeded] -> :fail_with_deadline_exceeded - decision = Rules.decide State.new(status: :transmission_sending), Event::GlobalDeadlineExceeded.new, @config - assert_equal :transmission_sending, decision.from_status - assert_equal :global_deadline_exceeded, decision.shape - assert_equal :error, decision.next_state.status - assert_instance_of Gapic::Common::DeadlineExceededError, decision.next_state.last_error - - # Row 15: [_, :user_cancel] -> :cancel_session - decision = Rules.decide State.new(status: :transmission_sending), Event::Cancel.new, @config - assert_equal :transmission_sending, decision.from_status - assert_equal :user_cancel, decision.shape - assert_equal :cancelling, decision.next_state.status - assert_instance_of Instruction::SendCancel, decision.instructions.first - - # Row 16: [:starting, :response_rejected] -> :fail_with_rejected - rejected_resp = Event::HttpResponse.new status: 403, headers: { "x-goog-upload-status" => "final" }, body: "Rejected" - decision = Rules.decide State.new(status: :starting), rejected_resp, @config - assert_equal :starting, decision.from_status - assert_equal :response_rejected, decision.shape - assert_equal :rejected, decision.next_state.status - assert_instance_of Gapic::Common::UploadRejectedError, decision.next_state.last_error - - # Row 17: [:starting, :response_cat2] -> :fail_with_bad_response - decision = Rules.decide State.new(status: :starting), cat2_resp, @config - assert_equal :starting, decision.from_status - assert_equal :response_cat2, decision.shape - assert_equal :error, decision.next_state.status - assert_instance_of Gapic::Common::BadResponseError, decision.next_state.last_error - - # Row 18: [:starting, :request_retries_exhausted] -> :fail_with_request_error - req_failed = Event::RequestFailed.new kind: :retries_exhausted, message: "Exhausted" - decision = Rules.decide State.new(status: :starting), req_failed, @config - assert_equal :starting, decision.from_status - assert_equal :request_retries_exhausted, decision.shape - assert_equal :error, decision.next_state.status - assert_instance_of Instruction::TerminateFailure, decision.instructions.first - end end From 9c40f652c1749744759c7b3186e382692730d7c2 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Sun, 6 Sep 2026 19:50:12 +0000 Subject: [PATCH 26/79] feat: proper logging in Driver --- gapic-common/lib/gapic/logging_concerns.rb | 4 + .../lib/gapic/rest/resumable_upload/driver.rb | 306 +++++++++++++++++- .../resumable_upload/driver_config_test.rb | 2 +- .../driver_error_mapping_test.rb | 2 +- .../resumable_upload/driver_logging_test.rb | 295 +++++++++++++++++ .../resumable_upload/driver_progress_test.rb | 2 +- .../resumable_upload/driver_retry_test.rb | 2 +- .../rest/resumable_upload/driver_test.rb | 2 +- 8 files changed, 594 insertions(+), 21 deletions(-) create mode 100644 gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb diff --git a/gapic-common/lib/gapic/logging_concerns.rb b/gapic-common/lib/gapic/logging_concerns.rb index 78ae13d..8be096f 100644 --- a/gapic-common/lib/gapic/logging_concerns.rb +++ b/gapic-common/lib/gapic/logging_concerns.rb @@ -67,6 +67,10 @@ def debug(&) log(Logger::DEBUG, &) end + def warn(&) + log(Logger::WARN, &) + end + ## # @private # Builder for a log entry, passed to {StubLogger#log}. diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index 89e6a3b..53457c4 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -14,6 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +require "uri" require "gapic/logging_concerns" require "gapic/rest/error" require "gapic/rest/resumable_upload/core" @@ -44,13 +45,19 @@ class Driver # @return [Core] attr_reader :core + # @return [String, nil] Current upload session ID + attr_reader :upload_id + # @param client_stub [Gapic::Rest::ClientStub] # @param config [CompleteUploadConfig] # @param logger [Logger, nil] Optional logger def initialize client_stub:, config:, logger: nil @client_stub = client_stub @config = config - @logger = logger + setup_logging logger: logger || (client_stub.respond_to?(:logger) ? client_stub.logger : nil), + service: "ResumableUpload", + endpoint: client_stub.respond_to?(:endpoint) ? client_stub.endpoint : nil, + client_id: client_stub.object_id @core = Core.new config @buffer = "".b @buffer_start_offset = 0 @@ -93,15 +100,20 @@ def self.default_data_plane_retry_policy # # @return [String, Object] Final response body def run + @upload_id = LoggingConcerns.random_uuid4 @deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + resolve_timeout pending_event = Event::StartUpload.new loop do instructions = @core.dispatch pending_event + log_decision @core.last_decision + log_lifecycle @core.last_decision pending_event = nil if deadline_exceeded? && !terminal_instructions?(instructions) instructions = @core.dispatch Event::GlobalDeadlineExceeded.new + log_decision @core.last_decision + log_lifecycle @core.last_decision end instructions.each do |instruction| @@ -166,6 +178,25 @@ def execute_realign_buffer instruction buffer_start = @buffer_start_offset buffer_end = @buffer_start_offset + @buffer.bytesize + realign_case = if server_offset >= buffer_start && server_offset <= buffer_end + "within_buffer" + elsif server_offset < buffer_start + "rewind" + else + "fast_forward" + end + + stub_logger.debug do |entry| + entry.set_system_name + entry.set_service + entry.set "uploadId", @upload_id + entry.set "realignCase", realign_case + entry.set "offset", server_offset + entry.set "bufferStart", buffer_start + entry.set "bufferEnd", buffer_end + entry.message = "Realigning buffer (#{realign_case}) to offset #{server_offset}" + end + if server_offset >= buffer_start && server_offset <= buffer_end realign_within_buffer server_offset elsif server_offset < buffer_start @@ -183,6 +214,15 @@ def realign_within_buffer server_offset def realign_rewind_stream server_offset unless @config.stream.respond_to? :seek + stub_logger.warn do |entry| + entry.set_system_name + entry.set_service + entry.set "uploadId", @upload_id + entry.set "offset", server_offset + entry.set "bufferStart", @buffer_start_offset + entry.message = "Cannot rewind unseekable stream to offset #{server_offset} " \ + "(buffered from #{@buffer_start_offset})" + end raise UnseekableStreamError, "Cannot rewind unseekable stream to offset #{server_offset} (buffered from #{@buffer_start_offset})" end @@ -228,22 +268,28 @@ def execute_fill_buffer instruction def execute_send_start instruction policy = @start_retry_policy.dup.start! headers = start_headers instruction + attempt = 1 loop do return Event::GlobalDeadlineExceeded.new if deadline_exceeded? - event = make_post_request instruction.url, headers: headers, body: instruction.body, retry_policy: policy + event = make_post_request instruction.url, headers: headers, body: instruction.body, + retry_policy: policy, method_name: "ResumableUpload.start", + retry_attempt: attempt return event unless event.is_a? Event::HttpResponse - status_hdr = event.headers["x-goog-upload-status"] || event.headers["X-Goog-Upload-Status"] + status_hdr = Rules.header_value event.headers, "x-goog-upload-status" return event unless status_hdr.nil? || status_hdr.empty? err = Gapic::Common::BadResponseError.new event.status, "Missing X-Goog-Upload-Status header in start response" can_retry = policy.send(:retry_with_deadline?) && policy.call(event) unless can_retry - return Event::RequestFailed.new kind: :retries_exhausted, message: err.message, source_error: err + failed_event = Event::RequestFailed.new kind: :retries_exhausted, message: err.message, source_error: err + log_wire_failure failed_event, attempt + return failed_event end + attempt += 1 end end @@ -265,7 +311,8 @@ def execute_send_chunk instruction body = @buffer.byteslice slice_index, instruction.length make_post_request instruction.url, headers: headers, body: body, - retry_policy: @data_plane_retry_policy.dup.start! + retry_policy: @data_plane_retry_policy.dup.start!, + method_name: "ResumableUpload.upload" end def execute_send_finalize instruction @@ -275,31 +322,258 @@ def execute_send_finalize instruction "Content-Length" => "0" } make_post_request instruction.url, headers: headers, body: "", - retry_policy: @data_plane_retry_policy.dup.start! + retry_policy: @data_plane_retry_policy.dup.start!, + method_name: "ResumableUpload.finalize" end def execute_send_query instruction headers = { "X-Goog-Upload-Command" => "query", "Content-Length" => "0" } make_post_request instruction.url, headers: headers, body: "", - retry_policy: @control_plane_retry_policy.dup.start! + retry_policy: @control_plane_retry_policy.dup.start!, + method_name: "ResumableUpload.query" end def execute_send_cancel instruction headers = { "X-Goog-Upload-Command" => "cancel", "Content-Length" => "0" } make_post_request instruction.url, headers: headers, body: "", - retry_policy: @control_plane_retry_policy.dup.start! + retry_policy: @control_plane_retry_policy.dup.start!, + method_name: "ResumableUpload.cancel" end - def make_post_request url, headers:, body:, retry_policy: + def make_post_request url, headers:, body:, retry_policy:, method_name: nil, retry_attempt: 1 options = { metadata: headers, retry_policy: retry_policy } - @logger&.debug do - "ResumableUpload::Driver: POST #{url} (offset: #{@core.state.offset}, " \ - "chunk_size: #{@core.state.chunk_size})" - end - response = @client_stub.make_post_request uri: url, body: body, params: {}, options: options - Event::HttpResponse.new status: response.status, headers: response.headers || {}, body: response.body + log_wire_send url, headers: headers, body: body, retry_attempt: retry_attempt + + response = @client_stub.make_post_request uri: url, body: body, params: {}, + options: options, method_name: method_name + event = Event::HttpResponse.new status: response.status, headers: response.headers || {}, body: response.body + log_wire_receive event, retry_attempt + event rescue StandardError => e - rescue_request_error e + event = rescue_request_error e + if event.is_a? Event::HttpResponse + log_wire_receive event, retry_attempt + else + log_wire_failure event, retry_attempt + end + event + end + + def log_wire_send url, headers:, body:, retry_attempt: + command = Rules.header_value headers, "x-goog-upload-command" + offset = Rules.header_value headers, "x-goog-upload-offset" + stub_logger.debug do |entry| + entry.set_system_name + entry.set_service + entry.set "uploadId", @upload_id + entry.set "command", command + entry.set "url", abridge_url(url) + entry.set "offset", offset.to_i if offset + entry.set "length", body.to_s.bytesize + entry.set "body", abridge_bytes(body) + entry.set "headers", abridge_headers(headers) + entry.set "retryAttempt", retry_attempt + entry.message = "Sending #{command}" + end + end + + # rubocop:disable Metrics/AbcSize + def log_wire_receive event, retry_attempt + stub_logger.debug do |entry| + entry.set_system_name + entry.set_service + entry.set "uploadId", @upload_id + entry.set "httpStatus", event.status + upload_status = Rules.header_value event.headers, "x-goog-upload-status" + entry.set "uploadStatus", upload_status if upload_status + size_recv = Rules.header_value event.headers, "x-goog-upload-size-received" + entry.set "sizeReceived", size_recv.to_i if size_recv + gran = Rules.header_value event.headers, "x-goog-upload-chunk-granularity" + entry.set "granularity", gran.to_i if gran + entry.set "headers", abridge_headers(event.headers) + entry.set "body", event.status >= 400 ? abridge_error_body(event.body) : abridge_bytes(event.body) + entry.set "retryAttempt", retry_attempt + entry.message = "Received #{event.status}" + end + end + # rubocop:enable Metrics/AbcSize + + def log_wire_failure event, retry_attempt + stub_logger.debug do |entry| + entry.set_system_name + entry.set_service + entry.set "uploadId", @upload_id + entry.set "kind", event.kind + entry.set "message", event.message + entry.set "retryAttempt", retry_attempt + entry.message = "Request Failed" + end + end + + # rubocop:disable Metrics/AbcSize + def log_decision decision + return unless decision + + stub_logger.debug do |entry| + entry.set_system_name + entry.set_service + entry.set "uploadId", @upload_id + entry.set "fromStatus", decision.from_status + entry.set "shape", decision.shape + entry.set "recipe", decision.recipe + entry.set "toStatus", decision.next_state.status + entry.set "offset", decision.next_state.offset + entry.set "inFlightLength", decision.next_state.in_flight_length + entry.set("instructions", decision.instructions.map { |i| summarize_instruction i }) + entry.message = "Rules: #{decision.from_status} + #{decision.shape} -> " \ + "#{decision.recipe} -> #{decision.next_state.status}" + end + end + # rubocop:enable Metrics/AbcSize + + # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/BlockLength + def log_lifecycle decision + return unless decision + + recipe = decision.recipe + if recipe == :send_chunk + stub_logger.debug do |entry| + entry.set_system_name + entry.set_service + entry.set "uploadId", @upload_id + entry.set "recipe", recipe + entry.set "offset", decision.next_state.offset + entry.set "length", decision.next_state.in_flight_length + entry.message = "Sending upload chunk" + end + return + end + + stub_logger.info do |entry| + entry.set_system_name + entry.set_service + entry.set "uploadId", @upload_id + entry.set "recipe", recipe + + case recipe + when :start_session + entry.set "uploadSize", @config.upload_size + entry.set "requestedChunkSize", @config.chunk_size + entry.message = "Initiating resumable upload" + when :begin_transmission + entry.set "effectiveChunkSize", decision.next_state.chunk_size + entry.set "granularity", decision.next_state.chunk_granularity + entry.set "uploadUrl", abridge_url(decision.next_state.upload_url) + entry.message = "Upload session established" + when :send_upload_finalize + entry.set "offset", decision.next_state.offset + entry.set "length", decision.next_state.in_flight_length + entry.message = "Sending final upload chunk and finalizing" + when :send_finalize + entry.set "offset", decision.next_state.offset + entry.message = "Finalizing upload session" + when :enter_recovery + entry.set "offset", decision.next_state.offset + entry.message = "Entering upload recovery" + when :retry_recovery + entry.set "offset", decision.next_state.offset + entry.message = "Retrying upload recovery query" + when :realign_from_recovery + entry.set "serverOffset", decision.next_state.offset + entry.message = "Realigning upload offset from recovery" + when :complete_upload_with_data, :complete_upload_finalized + entry.set "bytesUploaded", decision.next_state.offset + entry.message = "Resumable upload completed" + when :cancel_session + entry.message = "Cancelling resumable upload" + when :complete_cancellation + entry.message = "Resumable upload cancelled" + else + err_msg = decision.next_state.last_error&.to_s + entry.set "error", err_msg if err_msg + entry.message = "Resumable upload transition: #{recipe}" + end + end + end + # rubocop:enable Metrics/MethodLength,Metrics/AbcSize,Metrics/BlockLength + + # rubocop:disable Metrics/MethodLength + def summarize_instruction instruction + case instruction + when Instruction::SendStart + { "type" => "SendStart", "url" => abridge_url(instruction.url) } + when Instruction::SendChunk + { + "type" => "SendChunk", + "url" => abridge_url(instruction.url), + "offset" => instruction.offset, + "length" => instruction.length, + "finalize" => instruction.finalize + } + when Instruction::SendFinalize + { "type" => "SendFinalize", "url" => abridge_url(instruction.url) } + when Instruction::SendQuery + { "type" => "SendQuery", "url" => abridge_url(instruction.url) } + when Instruction::SendCancel + { "type" => "SendCancel", "url" => abridge_url(instruction.url) } + when Instruction::RealignBuffer + { "type" => "RealignBuffer", "serverOffset" => instruction.server_offset } + when Instruction::FillBuffer + { "type" => "FillBuffer", "targetBytesize" => instruction.target_bytesize } + when Instruction::NotifyProgress + { + "type" => "NotifyProgress", + "bytesUploaded" => instruction.progress.bytes_uploaded, + "totalBytes" => instruction.progress.total_bytes + } + when Instruction::TerminateSuccess + { "type" => "TerminateSuccess" } + when Instruction::TerminateFailure + { "type" => "TerminateFailure", "error" => instruction.error.to_s } + else + { "type" => instruction.class.name } + end + end + # rubocop:enable Metrics/MethodLength + + def abridge_bytes data + return "" if data.nil? || data.empty? + return data if data.bytesize <= 64 + + first_bytes = data.byteslice 0, 32 + "<#{data.bytesize} bytes; first 32: #{first_bytes}>" + end + + def abridge_error_body data + return "" if data.nil? || data.empty? + + data.to_s[0, 512] + end + + def abridge_url url + return nil if url.nil? + + uri = URI.parse url.to_s + if uri.query && !uri.query.empty? + elided = uri.query.split("&").map do |pair| + key, _val = pair.split "=", 2 + "#{key}=<...>" + end.join "&" + uri.query = nil + return "#{uri}?#{elided}" + end + uri.to_s + rescue URI::InvalidURIError + url.to_s + end + + def abridge_headers headers + return {} unless headers.is_a? Hash + + headers.each_with_object({}) do |(k, v), acc| + key_str = k.to_s + acc[key_str] = key_str.downcase.start_with?("x-goog-upload-") ? v : "<...>" + end end def rescue_request_error err diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb index a90b83e..64a2f5d 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb @@ -33,7 +33,7 @@ def initialize responses = [] @requests = [] end - def make_post_request uri:, body:, params:, options: + def make_post_request uri:, body:, params:, options:, method_name: nil @requests << { uri: uri, body: body, params: params, options: options } raise "Unexpected request: no scripted response left" if @responses.empty? diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_error_mapping_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_error_mapping_test.rb index 9742e2e..21bf5fe 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_error_mapping_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_error_mapping_test.rb @@ -31,7 +31,7 @@ class DriverErrorMappingTest < Minitest::Test class FailingClientStub attr_accessor :error_to_raise - def make_post_request uri:, body: nil, params: {}, options: {} + def make_post_request uri:, body: nil, params: {}, options: {}, method_name: nil raise @error_to_raise if @error_to_raise raise "No error configured" diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb new file mode 100644 index 0000000..1cb8fa3 --- /dev/null +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb @@ -0,0 +1,295 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "test_helper" +require "gapic/rest/resumable_upload" +require "stringio" + +## +# Tests for ResumableUpload Driver structured logging across lifecycle, decisions, +# wire exchanges, buffer realignments, and data abridging. +# +class DriverLoggingTest < Minitest::Test + include Gapic::Rest::ResumableUpload + + FakeHttpResponse = Struct.new :status, :headers, :body + + class RecordingLogger < Logger + attr_reader :entries + + def initialize + super(StringIO.new) + @entries = [] + end + + def add severity, message = nil, progname = nil + severity ||= UNKNOWN + if message.nil? + if block_given? + message = yield + else + message = progname + progname = @progname + end + end + @entries << { severity: severity, message: message, progname: progname } + true + end + end + + class ScriptedClientStub + attr_reader :requests, :logger, :endpoint + + def initialize responses, logger: nil, endpoint: "https://storage.googleapis.com" + @responses = responses.dup + @requests = [] + @logger = logger + @endpoint = endpoint + end + + def make_post_request uri:, body: nil, params: {}, options: {}, method_name: nil + @requests << { uri: uri, body: body, params: params, options: options, method_name: method_name } + raise "No scripted response left" if @responses.empty? + + resp = @responses.shift + raise resp if resp.is_a? Exception + + resp + end + end + + class UnseekableStream + def initialize data + @io = StringIO.new data + end + + def read length = nil + @io.read length + end + end + + def test_logs_decision_lifecycle_and_wire_entries_with_upload_id_and_method_name + logger = RecordingLogger.new + # 100 bytes data so body > 64 bytes triggers abridging + payload = "A" * 100 + stream = StringIO.new payload + + responses = [ + FakeHttpResponse.new( + 200, + { + "X-Goog-Upload-Status" => "active", + "X-Goog-Upload-URL" => "https://storage.googleapis.com/upload/session?upload_id=secret123&part=1", + "X-Goog-Upload-Chunk-Granularity" => "256", + "X-Secret-Header" => "super-secret-value" + }, + "" + ), + FakeHttpResponse.new( + 200, + { "X-Goog-Upload-Status" => "final" }, + '{"id":"obj-1"}' + ) + ] + + stub = ScriptedClientStub.new responses, logger: logger + config = CompleteUploadConfig.new( + initial_url: "https://storage.googleapis.com/upload/storage/v1/b/bucket/o?uploadType=resumable&key=secretKey", + initial_headers: { "Authorization" => "Bearer secret-token", "X-Goog-Upload-Header-Content-Length" => "100" }, + initial_body: '{"name":"test.bin"}', + stream: stream, + upload_size: 100, + chunk_size: 256 + ) + + driver = Driver.new client_stub: stub, config: config + result = driver.run + + assert_equal '{"id":"obj-1"}', result + refute_empty logger.entries + + # Verify method_name passed to make_post_request + assert_equal "ResumableUpload.start", stub.requests[0][:method_name] + assert_equal "ResumableUpload.upload", stub.requests[1][:method_name] + + # Every log entry must have uploadId matching driver.upload_id and be a Google::Logging::Message + logger.entries.each do |entry| + msg = entry[:message] + assert_instance_of Google::Logging::Message, msg + assert_equal driver.upload_id, msg.fields["uploadId"] + refute_nil driver.upload_id + end + + # Verify Decision logs (DEBUG) + decision_entries = logger.entries.select { |e| e[:message].message.start_with?("Rules:") } + assert_equal 4, decision_entries.size + + first_decision = decision_entries.first[:message] + assert_equal Logger::DEBUG, decision_entries.first[:severity] + assert_equal "Rules: initializing + start_upload -> start_session -> starting", first_decision.message + assert_equal "initializing", first_decision.fields["fromStatus"] + assert_equal "start_upload", first_decision.fields["shape"] + assert_equal "start_session", first_decision.fields["recipe"] + assert_equal "starting", first_decision.fields["toStatus"] + # Instructions in decision logs must be summaries without raw bodies + inst_summary = first_decision.fields["instructions"].first + assert_equal "SendStart", inst_summary["type"] + assert_equal "https://storage.googleapis.com/upload/storage/v1/b/bucket/o?uploadType=<...>&key=<...>", + inst_summary["url"] + refute inst_summary.key?("body") + + # Verify Lifecycle logs: start_session (INFO), begin_transmission (INFO), send_upload_finalize (INFO), complete (INFO) + info_entries = logger.entries.select { |e| e[:severity] == Logger::INFO } + info_messages = info_entries.map { |e| e[:message].message } + assert_includes info_messages, "Initiating resumable upload" + assert_includes info_messages, "Upload session established" + assert_includes info_messages, "Sending final upload chunk and finalizing" + assert_includes info_messages, "Resumable upload completed" + + # Verify Wire logs (DEBUG): URL query values elided, non-X-Goog-Upload headers elided, >64B chunk body abridged + send_entries = logger.entries.select { |e| e[:message].message.start_with?("Sending ") } + upload_send = send_entries.find { |e| e[:message].fields["command"] == "upload, finalize" }[:message] + assert_equal "https://storage.googleapis.com/upload/session?upload_id=<...>&part=<...>", upload_send.fields["url"] + assert_equal 1, upload_send.fields["retryAttempt"] + assert_match(/^<100 bytes; first 32: A{32}>$/, upload_send.fields["body"]) + + # Verify Authorization header is elided in wire logs + start_send = send_entries.find { |e| e[:message].fields["command"] == "start" }[:message] + assert_equal "<...>", start_send.fields["headers"]["Authorization"] + assert_equal "100", start_send.fields["headers"]["X-Goog-Upload-Header-Content-Length"] + + # Verify Received wire logs + recv_entries = logger.entries.select { |e| e[:message].message.start_with?("Received ") } + first_recv = recv_entries.first[:message] + assert_equal 200, first_recv.fields["httpStatus"] + assert_equal "active", first_recv.fields["uploadStatus"] + assert_equal 256, first_recv.fields["granularity"] + assert_equal "<...>", first_recv.fields["headers"]["X-Secret-Header"] + end + + def test_send_chunk_lifecycle_is_logged_at_debug_level_only + logger = RecordingLogger.new + stream = StringIO.new("A" * 512) + + responses = [ + FakeHttpResponse.new( + 200, + { + "X-Goog-Upload-Status" => "active", + "X-Goog-Upload-URL" => "https://storage.googleapis.com/session" + }, + "" + ), + FakeHttpResponse.new( + 200, + { "X-Goog-Upload-Status" => "active" }, + "" + ), + FakeHttpResponse.new( + 200, + { "X-Goog-Upload-Status" => "active" }, + "" + ), + FakeHttpResponse.new( + 200, + { "X-Goog-Upload-Status" => "final" }, + '{"done":true}' + ) + ] + + stub = ScriptedClientStub.new responses, logger: logger + config = CompleteUploadConfig.new( + initial_url: "https://storage.googleapis.com/upload", + stream: stream, + upload_size: 512, + chunk_size: 256 + ) + + driver = Driver.new client_stub: stub, config: config + driver.run + + send_chunk_entries = logger.entries.select do |e| + e[:message].fields["recipe"] == "send_chunk" && !e[:message].message.start_with?("Rules:") + end + refute_empty send_chunk_entries + send_chunk_entries.each do |entry| + assert_equal Logger::DEBUG, entry[:severity] + end + end + + def test_realign_within_buffer_logs_debug_and_unseekable_rewind_logs_warn + logger = RecordingLogger.new + stream = UnseekableStream.new("A" * 512) + + responses = [ + FakeHttpResponse.new( + 200, + { + "X-Goog-Upload-Status" => "active", + "X-Goog-Upload-URL" => "https://storage.googleapis.com/session" + }, + "" + ), + # First chunk send fails with 503 triggering recovery + FakeHttpResponse.new(503, {}, "Backend error"), + # Query response asks to rewind before buffer start (0) when buffer_start is 0 -> test within_buffer first + FakeHttpResponse.new( + 200, + { + "X-Goog-Upload-Status" => "active", + "X-Goog-Upload-Size-Received" => "128" + }, + "" + ), + # Next chunk fails with 503 triggering recovery + FakeHttpResponse.new(503, {}, "Backend error"), + # Query response asks to rewind to offset 0 (which is behind buffer_start_offset 128 on unseekable stream) + FakeHttpResponse.new( + 200, + { + "X-Goog-Upload-Status" => "active", + "X-Goog-Upload-Size-Received" => "0" + }, + "" + ) + ] + + stub = ScriptedClientStub.new responses, logger: logger + config = CompleteUploadConfig.new( + initial_url: "https://storage.googleapis.com/upload", + stream: stream, + upload_size: 512, + chunk_size: 256 + ) + + driver = Driver.new client_stub: stub, config: config + assert_raises UnseekableStreamError do + driver.run + end + + realign_entries = logger.entries.select { |e| e[:message].fields.key?("realignCase") } + assert_equal 2, realign_entries.size + assert_equal "within_buffer", realign_entries[0][:message].fields["realignCase"] + assert_equal 128, realign_entries[0][:message].fields["offset"] + + assert_equal "rewind", realign_entries[1][:message].fields["realignCase"] + assert_equal 0, realign_entries[1][:message].fields["offset"] + + warn_entries = logger.entries.select { |e| e[:severity] == Logger::WARN } + assert_equal 1, warn_entries.size + assert_match(/Cannot rewind unseekable stream to offset 0/, warn_entries.first[:message].message) + end +end diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb index d67d9fb..669275b 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb @@ -32,7 +32,7 @@ def initialize responses @responses = responses end - def make_post_request uri:, body: nil, params: {}, options: {} + def make_post_request uri:, body: nil, params: {}, options: {}, method_name: nil raise "No scripted response" if @responses.empty? @responses.shift diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb index 536cec1..96f066a 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb @@ -36,7 +36,7 @@ def initialize responses @requests = [] end - def make_post_request uri:, body:, params:, options: + def make_post_request uri:, body:, params:, options:, method_name: nil @requests << { uri: uri, body: body, params: params, options: options } raise "Unexpected request: no scripted response left" if @responses.empty? diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb index f2af923..d5ad6b8 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb @@ -35,7 +35,7 @@ def initialize responses @requests = [] end - def make_post_request uri:, body:, params:, options: + def make_post_request uri:, body:, params:, options:, method_name: nil @requests << { uri: uri, body: body, params: params, options: options } raise "Unexpected request: no scripted response left" if @responses.empty? From 5e8d962387a6a70578097fd3cfdc826dbf69ed1e Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Sun, 6 Sep 2026 20:27:02 +0000 Subject: [PATCH 27/79] refactor: logging in driver --- .../lib/gapic/rest/resumable_upload.rb | 2 + .../lib/gapic/rest/resumable_upload/driver.rb | 304 +++--------------- .../rest/resumable_upload/driver/abridge.rb | 119 +++++++ .../resumable_upload/driver/upload_log.rb | 216 +++++++++++++ .../resumable_upload/driver/abridge_test.rb | 89 +++++ .../driver/upload_log_test.rb | 181 +++++++++++ .../resumable_upload/driver_logging_test.rb | 293 ++++++----------- gapic-common/test/test_helper.rb | 16 + 8 files changed, 761 insertions(+), 459 deletions(-) create mode 100644 gapic-common/lib/gapic/rest/resumable_upload/driver/abridge.rb create mode 100644 gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb create mode 100644 gapic-common/test/gapic/rest/resumable_upload/driver/abridge_test.rb create mode 100644 gapic-common/test/gapic/rest/resumable_upload/driver/upload_log_test.rb diff --git a/gapic-common/lib/gapic/rest/resumable_upload.rb b/gapic-common/lib/gapic/rest/resumable_upload.rb index 6100614..e5462e6 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload.rb @@ -19,6 +19,8 @@ require "gapic/rest/resumable_upload/events" require "gapic/rest/resumable_upload/instructions" require "gapic/rest/resumable_upload/retry_policies" +require "gapic/rest/resumable_upload/driver/abridge" +require "gapic/rest/resumable_upload/driver/upload_log" require "gapic/rest/resumable_upload/rules" require "gapic/rest/resumable_upload/core" require "gapic/rest/resumable_upload/driver" diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index 53457c4..ead4f6a 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -23,6 +23,7 @@ require "gapic/rest/resumable_upload/events" require "gapic/rest/resumable_upload/instructions" require "gapic/rest/resumable_upload/retry_policies" +require "gapic/rest/resumable_upload/driver/upload_log" module Gapic module Rest @@ -48,19 +49,27 @@ class Driver # @return [String, nil] Current upload session ID attr_reader :upload_id - # @param client_stub [Gapic::Rest::ClientStub] - # @param config [CompleteUploadConfig] - # @param logger [Logger, nil] Optional logger - def initialize client_stub:, config:, logger: nil + ## + # Initializes a new Resumable Upload Driver. + # + # @param client_stub [Gapic::Rest::ClientStub] Underlying REST client stub + # @param config [CompleteUploadConfig] Configuration for this upload session + # @param core [Core, nil] Optional Core state machine (defaults to new Core with config) + # @param logger [Logger, nil] Optional logger override + def initialize client_stub:, config:, core: nil, logger: nil @client_stub = client_stub @config = config - setup_logging logger: logger || (client_stub.respond_to?(:logger) ? client_stub.logger : nil), - service: "ResumableUpload", - endpoint: client_stub.respond_to?(:endpoint) ? client_stub.endpoint : nil, - client_id: client_stub.object_id - @core = Core.new config + @core = core || Core.new(config) @buffer = "".b @buffer_start_offset = 0 + + endpoint = client_stub.respond_to?(:endpoint) ? client_stub.endpoint : nil + setup_logging logger: logger || (client_stub.respond_to?(:logger) ? client_stub.logger : nil), + system_name: "gapic-common", + service: "ResumableUpload", + endpoint: endpoint + @upload_log = UploadLog.new stub_logger, upload_id: "unstarted" + @start_retry_policy = config.start_retry_policy || self.class.default_start_retry_policy @control_plane_retry_policy = config.control_plane_retry_policy || @@ -100,20 +109,16 @@ def self.default_data_plane_retry_policy # # @return [String, Object] Final response body def run - @upload_id = LoggingConcerns.random_uuid4 + @upload_log = UploadLog.new stub_logger, upload_id: LoggingConcerns.random_uuid4 @deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + resolve_timeout pending_event = Event::StartUpload.new loop do - instructions = @core.dispatch pending_event - log_decision @core.last_decision - log_lifecycle @core.last_decision + instructions = dispatch_event pending_event pending_event = nil if deadline_exceeded? && !terminal_instructions?(instructions) - instructions = @core.dispatch Event::GlobalDeadlineExceeded.new - log_decision @core.last_decision - log_lifecycle @core.last_decision + instructions = dispatch_event Event::GlobalDeadlineExceeded.new end instructions.each do |instruction| @@ -126,6 +131,18 @@ def run private + def dispatch_event event + instructions = begin + @core.dispatch event + rescue InvalidTransitionError => e + @upload_log.unmatched_transition @core.state, event, e + raise + end + @upload_log.decision @core.last_decision + @upload_log.lifecycle @core.last_decision, @config + instructions + end + def pending_event_type? obj obj.is_a?(Event::ChunkRead) || obj.is_a?(Event::HttpResponse) || obj.is_a?(Event::RequestFailed) || obj.is_a?(Event::GlobalDeadlineExceeded) @@ -186,16 +203,10 @@ def execute_realign_buffer instruction "fast_forward" end - stub_logger.debug do |entry| - entry.set_system_name - entry.set_service - entry.set "uploadId", @upload_id - entry.set "realignCase", realign_case - entry.set "offset", server_offset - entry.set "bufferStart", buffer_start - entry.set "bufferEnd", buffer_end - entry.message = "Realigning buffer (#{realign_case}) to offset #{server_offset}" - end + unseekable = realign_case == "rewind" && !@config.stream.respond_to?(:seek) + @upload_log.buffer_realign realign_case, server_offset: server_offset, + current_offset: buffer_start, + unseekable: unseekable if server_offset >= buffer_start && server_offset <= buffer_end realign_within_buffer server_offset @@ -214,15 +225,6 @@ def realign_within_buffer server_offset def realign_rewind_stream server_offset unless @config.stream.respond_to? :seek - stub_logger.warn do |entry| - entry.set_system_name - entry.set_service - entry.set "uploadId", @upload_id - entry.set "offset", server_offset - entry.set "bufferStart", @buffer_start_offset - entry.message = "Cannot rewind unseekable stream to offset #{server_offset} " \ - "(buffered from #{@buffer_start_offset})" - end raise UnseekableStreamError, "Cannot rewind unseekable stream to offset #{server_offset} (buffered from #{@buffer_start_offset})" end @@ -275,7 +277,7 @@ def execute_send_start instruction event = make_post_request instruction.url, headers: headers, body: instruction.body, retry_policy: policy, method_name: "ResumableUpload.start", - retry_attempt: attempt + start_attempt: attempt return event unless event.is_a? Event::HttpResponse status_hdr = Rules.header_value event.headers, "x-goog-upload-status" @@ -286,7 +288,7 @@ def execute_send_start instruction can_retry = policy.send(:retry_with_deadline?) && policy.call(event) unless can_retry failed_event = Event::RequestFailed.new kind: :retries_exhausted, message: err.message, source_error: err - log_wire_failure failed_event, attempt + @upload_log.wire_failure failed_event return failed_event end attempt += 1 @@ -340,242 +342,26 @@ def execute_send_cancel instruction method_name: "ResumableUpload.cancel" end - def make_post_request url, headers:, body:, retry_policy:, method_name: nil, retry_attempt: 1 + def make_post_request url, headers:, body:, retry_policy:, method_name: nil, start_attempt: 1 options = { metadata: headers, retry_policy: retry_policy } - log_wire_send url, headers: headers, body: body, retry_attempt: retry_attempt + @upload_log.wire_send method: "POST", url: url, headers: headers, + start_attempt: start_attempt, body_size: body.to_s.bytesize, body: body response = @client_stub.make_post_request uri: url, body: body, params: {}, options: options, method_name: method_name event = Event::HttpResponse.new status: response.status, headers: response.headers || {}, body: response.body - log_wire_receive event, retry_attempt + @upload_log.wire_receive event event rescue StandardError => e event = rescue_request_error e if event.is_a? Event::HttpResponse - log_wire_receive event, retry_attempt + @upload_log.wire_receive event else - log_wire_failure event, retry_attempt + @upload_log.wire_failure event end event end - def log_wire_send url, headers:, body:, retry_attempt: - command = Rules.header_value headers, "x-goog-upload-command" - offset = Rules.header_value headers, "x-goog-upload-offset" - stub_logger.debug do |entry| - entry.set_system_name - entry.set_service - entry.set "uploadId", @upload_id - entry.set "command", command - entry.set "url", abridge_url(url) - entry.set "offset", offset.to_i if offset - entry.set "length", body.to_s.bytesize - entry.set "body", abridge_bytes(body) - entry.set "headers", abridge_headers(headers) - entry.set "retryAttempt", retry_attempt - entry.message = "Sending #{command}" - end - end - - # rubocop:disable Metrics/AbcSize - def log_wire_receive event, retry_attempt - stub_logger.debug do |entry| - entry.set_system_name - entry.set_service - entry.set "uploadId", @upload_id - entry.set "httpStatus", event.status - upload_status = Rules.header_value event.headers, "x-goog-upload-status" - entry.set "uploadStatus", upload_status if upload_status - size_recv = Rules.header_value event.headers, "x-goog-upload-size-received" - entry.set "sizeReceived", size_recv.to_i if size_recv - gran = Rules.header_value event.headers, "x-goog-upload-chunk-granularity" - entry.set "granularity", gran.to_i if gran - entry.set "headers", abridge_headers(event.headers) - entry.set "body", event.status >= 400 ? abridge_error_body(event.body) : abridge_bytes(event.body) - entry.set "retryAttempt", retry_attempt - entry.message = "Received #{event.status}" - end - end - # rubocop:enable Metrics/AbcSize - - def log_wire_failure event, retry_attempt - stub_logger.debug do |entry| - entry.set_system_name - entry.set_service - entry.set "uploadId", @upload_id - entry.set "kind", event.kind - entry.set "message", event.message - entry.set "retryAttempt", retry_attempt - entry.message = "Request Failed" - end - end - - # rubocop:disable Metrics/AbcSize - def log_decision decision - return unless decision - - stub_logger.debug do |entry| - entry.set_system_name - entry.set_service - entry.set "uploadId", @upload_id - entry.set "fromStatus", decision.from_status - entry.set "shape", decision.shape - entry.set "recipe", decision.recipe - entry.set "toStatus", decision.next_state.status - entry.set "offset", decision.next_state.offset - entry.set "inFlightLength", decision.next_state.in_flight_length - entry.set("instructions", decision.instructions.map { |i| summarize_instruction i }) - entry.message = "Rules: #{decision.from_status} + #{decision.shape} -> " \ - "#{decision.recipe} -> #{decision.next_state.status}" - end - end - # rubocop:enable Metrics/AbcSize - - # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/BlockLength - def log_lifecycle decision - return unless decision - - recipe = decision.recipe - if recipe == :send_chunk - stub_logger.debug do |entry| - entry.set_system_name - entry.set_service - entry.set "uploadId", @upload_id - entry.set "recipe", recipe - entry.set "offset", decision.next_state.offset - entry.set "length", decision.next_state.in_flight_length - entry.message = "Sending upload chunk" - end - return - end - - stub_logger.info do |entry| - entry.set_system_name - entry.set_service - entry.set "uploadId", @upload_id - entry.set "recipe", recipe - - case recipe - when :start_session - entry.set "uploadSize", @config.upload_size - entry.set "requestedChunkSize", @config.chunk_size - entry.message = "Initiating resumable upload" - when :begin_transmission - entry.set "effectiveChunkSize", decision.next_state.chunk_size - entry.set "granularity", decision.next_state.chunk_granularity - entry.set "uploadUrl", abridge_url(decision.next_state.upload_url) - entry.message = "Upload session established" - when :send_upload_finalize - entry.set "offset", decision.next_state.offset - entry.set "length", decision.next_state.in_flight_length - entry.message = "Sending final upload chunk and finalizing" - when :send_finalize - entry.set "offset", decision.next_state.offset - entry.message = "Finalizing upload session" - when :enter_recovery - entry.set "offset", decision.next_state.offset - entry.message = "Entering upload recovery" - when :retry_recovery - entry.set "offset", decision.next_state.offset - entry.message = "Retrying upload recovery query" - when :realign_from_recovery - entry.set "serverOffset", decision.next_state.offset - entry.message = "Realigning upload offset from recovery" - when :complete_upload_with_data, :complete_upload_finalized - entry.set "bytesUploaded", decision.next_state.offset - entry.message = "Resumable upload completed" - when :cancel_session - entry.message = "Cancelling resumable upload" - when :complete_cancellation - entry.message = "Resumable upload cancelled" - else - err_msg = decision.next_state.last_error&.to_s - entry.set "error", err_msg if err_msg - entry.message = "Resumable upload transition: #{recipe}" - end - end - end - # rubocop:enable Metrics/MethodLength,Metrics/AbcSize,Metrics/BlockLength - - # rubocop:disable Metrics/MethodLength - def summarize_instruction instruction - case instruction - when Instruction::SendStart - { "type" => "SendStart", "url" => abridge_url(instruction.url) } - when Instruction::SendChunk - { - "type" => "SendChunk", - "url" => abridge_url(instruction.url), - "offset" => instruction.offset, - "length" => instruction.length, - "finalize" => instruction.finalize - } - when Instruction::SendFinalize - { "type" => "SendFinalize", "url" => abridge_url(instruction.url) } - when Instruction::SendQuery - { "type" => "SendQuery", "url" => abridge_url(instruction.url) } - when Instruction::SendCancel - { "type" => "SendCancel", "url" => abridge_url(instruction.url) } - when Instruction::RealignBuffer - { "type" => "RealignBuffer", "serverOffset" => instruction.server_offset } - when Instruction::FillBuffer - { "type" => "FillBuffer", "targetBytesize" => instruction.target_bytesize } - when Instruction::NotifyProgress - { - "type" => "NotifyProgress", - "bytesUploaded" => instruction.progress.bytes_uploaded, - "totalBytes" => instruction.progress.total_bytes - } - when Instruction::TerminateSuccess - { "type" => "TerminateSuccess" } - when Instruction::TerminateFailure - { "type" => "TerminateFailure", "error" => instruction.error.to_s } - else - { "type" => instruction.class.name } - end - end - # rubocop:enable Metrics/MethodLength - - def abridge_bytes data - return "" if data.nil? || data.empty? - return data if data.bytesize <= 64 - - first_bytes = data.byteslice 0, 32 - "<#{data.bytesize} bytes; first 32: #{first_bytes}>" - end - - def abridge_error_body data - return "" if data.nil? || data.empty? - - data.to_s[0, 512] - end - - def abridge_url url - return nil if url.nil? - - uri = URI.parse url.to_s - if uri.query && !uri.query.empty? - elided = uri.query.split("&").map do |pair| - key, _val = pair.split "=", 2 - "#{key}=<...>" - end.join "&" - uri.query = nil - return "#{uri}?#{elided}" - end - uri.to_s - rescue URI::InvalidURIError - url.to_s - end - - def abridge_headers headers - return {} unless headers.is_a? Hash - - headers.each_with_object({}) do |(k, v), acc| - key_str = k.to_s - acc[key_str] = key_str.downcase.start_with?("x-goog-upload-") ? v : "<...>" - end - end - def rescue_request_error err case err when Gapic::Rest::DeadlineExceededError diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver/abridge.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver/abridge.rb new file mode 100644 index 0000000..c3b5ec4 --- /dev/null +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver/abridge.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "uri" + +module Gapic + module Rest + module ResumableUpload + class Driver + ## + # @private + # Pure functions for redacting and abridging sensitive data and large payloads in logs. + # + module Abridge + module_function + + def bytes data + return nil if data.nil? + + str = data.to_s + if str.bytesize >= 64 + "#{str.byteslice(0, 32).unpack1('H*')}... <#{str.bytesize} bytes>" + else + str.unpack1 "H*" + end + end + + def error_body data + return nil if data.nil? + + data.to_s[0, 512] + end + + def url url + return nil if url.nil? + + uri = URI.parse url.to_s + if uri.query && !uri.query.empty? + elided = uri.query.split("&").map do |pair| + key, _val = pair.split "=", 2 + "#{key}=<...>" + end.join "&" + uri.query = nil + return "#{uri}?#{elided}" + end + uri.to_s + rescue URI::InvalidURIError + url.to_s + end + + def headers headers + return {} unless headers.is_a? Hash + + headers.each_with_object({}) do |(k, v), acc| + key_str = k.to_s + acc[key_str] = key_str.downcase.start_with?("x-goog-upload-") ? v : "<...>" + end + end + + def instructions instructions + instructions.map { |i| instruction i } + end + + # rubocop:disable Metrics/MethodLength + def instruction instruction + case instruction + when Instruction::SendStart + { "type" => "SendStart", "url" => url(instruction.url) } + when Instruction::SendChunk + { + "type" => "SendChunk", + "url" => url(instruction.url), + "offset" => instruction.offset, + "length" => instruction.length, + "finalize" => instruction.finalize + } + when Instruction::SendFinalize + { "type" => "SendFinalize", "url" => url(instruction.url) } + when Instruction::SendQuery + { "type" => "SendQuery", "url" => url(instruction.url) } + when Instruction::SendCancel + { "type" => "SendCancel", "url" => url(instruction.url) } + when Instruction::RealignBuffer + { "type" => "RealignBuffer", "serverOffset" => instruction.server_offset } + when Instruction::FillBuffer + { "type" => "FillBuffer", "targetBytesize" => instruction.target_bytesize } + when Instruction::NotifyProgress + { + "type" => "NotifyProgress", + "bytesUploaded" => instruction.progress.bytes_uploaded, + "totalBytes" => instruction.progress.total_bytes + } + when Instruction::TerminateSuccess + { "type" => "TerminateSuccess" } + when Instruction::TerminateFailure + { "type" => "TerminateFailure", "error" => instruction.error.to_s } + else + { "type" => instruction.class.name } + end + end + # rubocop:enable Metrics/MethodLength + end + end + end + end +end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb new file mode 100644 index 0000000..1698c5e --- /dev/null +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "google/logging/message" +require "gapic/rest/resumable_upload/driver/abridge" + +module Gapic + module Rest + module ResumableUpload + class Driver + ## + # @private + # Structured logging helper for a single Resumable Upload run. + # + class UploadLog + attr_reader :upload_id + + def initialize stub_logger, upload_id: + @stub_logger = stub_logger + @upload_id = upload_id + end + + def decision decision + msg = "Rules: #{decision.from_status} + #{decision.shape} -> " \ + "#{decision.recipe} -> #{decision.next_state.status}" + entry( + :debug, + msg, + fromStatus: decision.from_status, + shape: decision.shape, + recipe: decision.recipe, + toStatus: decision.next_state.status, + offset: decision.next_state.offset, + inFlightLength: decision.next_state.in_flight_length, + instructions: Abridge.instructions(decision.instructions) + ) + end + + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def lifecycle decision, config + case decision.recipe + when :start_session + entry( + :info, + "Initiating resumable upload", + recipe: decision.recipe, + uploadSize: config.upload_size, + requestedChunkSize: config.chunk_size + ) + when :begin_transmission + entry( + :info, + "Upload session established", + recipe: decision.recipe, + effectiveChunkSize: decision.next_state.chunk_size, + granularity: decision.next_state.chunk_granularity, + uploadUrl: Abridge.url(decision.next_state.upload_url) + ) + when :send_chunk + entry( + :debug, + "Sending upload chunk", + recipe: decision.recipe, + offset: decision.next_state.offset, + inFlightLength: decision.next_state.in_flight_length + ) + when :ack_chunk + entry( + :info, + "Upload chunk acknowledged", + recipe: decision.recipe, + offset: decision.next_state.offset + ) + when :complete_upload + entry( + :info, + "Resumable upload completed", + recipe: decision.recipe, + offset: decision.next_state.offset + ) + when :enter_recovery + entry( + :info, + "Entering upload recovery", + recipe: decision.recipe, + offset: decision.next_state.offset + ) + when :resume_from_query + entry( + :info, + "Resuming upload from server offset", + recipe: decision.recipe, + offset: decision.next_state.offset + ) + when :send_cancel + entry( + :info, + "Canceling resumable upload", + recipe: decision.recipe, + uploadUrl: Abridge.url(decision.next_state.upload_url) + ) + when :complete_cancellation + entry( + :info, + "Resumable upload canceled", + recipe: decision.recipe + ) + when :fail_with_bad_session, + :fail_with_range_drift, + :fail_with_protocol_error, + :fail_with_terminal_error, + :fail_with_unmatched_transition + error_msg = decision.next_state.last_error&.message || decision.next_state.last_error.to_s + entry( + :warn, + "Resumable upload failed", + recipe: decision.recipe, + error: error_msg + ) + end + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + def wire_send method:, url:, headers:, start_attempt:, body_size: nil, body: nil, body_is_error: false + fields = { + method: method, + url: Abridge.url(url), + headers: Abridge.headers(headers), + startAttempt: start_attempt + } + fields[:bodySize] = body_size if body_size + fields[:body] = body_is_error ? Abridge.error_body(body) : Abridge.bytes(body) if body + + entry :debug, "Sending #{method} request", **fields + end + + def wire_receive event + entry( + :debug, + "Received HTTP #{event.status}", + status: event.status, + headers: Abridge.headers(event.headers), + body: event.status >= 400 ? Abridge.error_body(event.body) : Abridge.bytes(event.body) + ) + end + + def wire_failure event + entry( + :debug, + "Request failed: #{event.kind}", + kind: event.kind, + error: event.message + ) + end + + def buffer_realign action, server_offset:, current_offset:, unseekable: false + if unseekable + entry( + :warn, + "Server offset rewind on unseekable stream", + action: action, + serverOffset: server_offset, + currentOffset: current_offset + ) + end + + entry( + :debug, + "Buffer realignment: #{action}", + action: action, + serverOffset: server_offset, + currentOffset: current_offset + ) + end + + def unmatched_transition state, event, error + entry( + :warn, + "Unmatched transition", + status: state.status, + shape: Rules.shape_of(event), + error: error.message + ) + end + + private + + def entry severity, log_msg, **fields + @stub_logger.public_send severity do |builder| + builder.set_system_name + builder.set_service + builder.set "uploadId", @upload_id + fields.each do |k, v| + builder.set k.to_s, v + end + builder.message = log_msg + end + end + end + end + end + end +end diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver/abridge_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver/abridge_test.rb new file mode 100644 index 0000000..2dc93b0 --- /dev/null +++ b/gapic-common/test/gapic/rest/resumable_upload/driver/abridge_test.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "test_helper" +require "gapic/rest/resumable_upload" + +## +# Unit tests for Gapic::Rest::ResumableUpload::Driver::Abridge. +# +class AbridgeTest < Minitest::Test + include Gapic::Rest::ResumableUpload + + def test_bytes_hex_encodes_short_payloads + assert_nil Driver::Abridge.bytes(nil) + + short = "hello" + assert_equal short.unpack1("H*"), Driver::Abridge.bytes(short) + + boundary = "A" * 63 + assert_equal boundary.unpack1("H*"), Driver::Abridge.bytes(boundary) + end + + def test_bytes_abridges_and_hex_encodes_large_payloads + large = "A" * 100 + expected_prefix = ("A" * 32).unpack1 "H*" + assert_equal "#{expected_prefix}... <100 bytes>", Driver::Abridge.bytes(large) + end + + def test_error_body_truncates_at_512_bytes + assert_nil Driver::Abridge.error_body(nil) + + long_err = "E" * 600 + assert_equal 512, Driver::Abridge.error_body(long_err).bytesize + end + + def test_url_elides_query_parameter_values + assert_nil Driver::Abridge.url(nil) + + url = "https://storage.googleapis.com/upload/storage/v1/b/bucket/o?uploadType=resumable&sid=SECRET123" + assert_equal "https://storage.googleapis.com/upload/storage/v1/b/bucket/o?uploadType=<...>&sid=<...>", + Driver::Abridge.url(url) + end + + def test_headers_retains_x_goog_upload_and_redacts_others + headers = { + "X-Goog-Upload-Command" => "upload, finalize", + "X-Goog-Upload-Offset" => "0", + "Authorization" => "Bearer SECRET123", + "Content-Type" => "application/octet-stream" + } + + abridged = Driver::Abridge.headers headers + assert_equal "upload, finalize", abridged["X-Goog-Upload-Command"] + assert_equal "0", abridged["X-Goog-Upload-Offset"] + assert_equal "<...>", abridged["Authorization"] + assert_equal "<...>", abridged["Content-Type"] + end + + def test_instructions_summarizes_without_bodies + instructions = [ + Instruction::SendStart.new(url: "https://example.com/upload?key=SECRET", headers: {}, body: "secret_body"), + Instruction::SendChunk.new(url: "https://example.com/session?id=123", offset: 0, length: 64, finalize: true) + ] + + summary = Driver::Abridge.instructions instructions + assert_equal "SendStart", summary[0]["type"] + assert_equal "https://example.com/upload?key=<...>", summary[0]["url"] + refute summary[0].key?("body") + + assert_equal "SendChunk", summary[1]["type"] + assert_equal "https://example.com/session?id=<...>", summary[1]["url"] + assert_equal 0, summary[1]["offset"] + assert_equal 64, summary[1]["length"] + assert_equal true, summary[1]["finalize"] + end +end diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver/upload_log_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver/upload_log_test.rb new file mode 100644 index 0000000..882d50e --- /dev/null +++ b/gapic-common/test/gapic/rest/resumable_upload/driver/upload_log_test.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "test_helper" +require "gapic/rest/resumable_upload" + +## +# Unit tests for Gapic::Rest::ResumableUpload::Driver::UploadLog. +# +class UploadLogTest < Minitest::Test + include Gapic::Rest::ResumableUpload + + def setup + @recording = RecordingLogger.new + stub_logger = Gapic::LoggingConcerns::StubLogger.new logger: @recording, service: "ResumableUpload" + @upload_log = Driver::UploadLog.new stub_logger, upload_id: "test-upload-id" + @config = CompleteUploadConfig.new initial_url: "https://example.com/upload", + initial_body: nil, + initial_headers: {}, + stream: StringIO.new("data"), + upload_size: 1024, + chunk_size: 256, + content_type: "text/plain", + timeout: nil, + start_retry_policy: nil, + control_plane_retry_policy: nil, + data_plane_retry_policy: nil, + on_progress: nil + end + + def test_decision_logs_debug_with_fields + next_state = State.new status: :starting, offset: 0, in_flight_length: 0 + decision = Decision.new from_status: :initializing, shape: :start_upload, recipe: :start_session, + next_state: next_state, instructions: [] + + @upload_log.decision decision + + assert_equal 1, @recording.entries.size + entry = @recording.entries.first + assert_equal Logger::DEBUG, entry.severity + fields = entry.message.fields + assert_equal "test-upload-id", fields["uploadId"] + assert_equal "initializing", fields["fromStatus"] + assert_equal "start_upload", fields["shape"] + assert_equal "start_session", fields["recipe"] + assert_equal "starting", fields["toStatus"] + assert_equal 0, fields["offset"] + assert_equal 0, fields["inFlightLength"] + assert_equal [], fields["instructions"] + end + + def test_lifecycle_start_session_logs_info + next_state = State.new status: :starting + decision = Decision.new from_status: :initializing, shape: :start_upload, recipe: :start_session, + next_state: next_state, instructions: [] + + @upload_log.lifecycle decision, @config + + entry = @recording.entries.first + assert_equal Logger::INFO, entry.severity + fields = entry.message.fields + assert_equal "start_session", fields["recipe"] + assert_equal 1024, fields["uploadSize"] + assert_equal 256, fields["requestedChunkSize"] + end + + def test_lifecycle_send_chunk_logs_debug + next_state = State.new status: :uploading, offset: 256, in_flight_length: 256 + decision = Decision.new from_status: :uploading, shape: :chunk_read, recipe: :send_chunk, + next_state: next_state, instructions: [] + + @upload_log.lifecycle decision, @config + + entry = @recording.entries.first + assert_equal Logger::DEBUG, entry.severity + fields = entry.message.fields + assert_equal "send_chunk", fields["recipe"] + assert_equal 256, fields["offset"] + assert_equal 256, fields["inFlightLength"] + end + + def test_lifecycle_terminal_failure_logs_warn + err = StandardError.new "upload failed" + next_state = State.new status: :failed, last_error: err + decision = Decision.new from_status: :uploading, shape: :http_5xx, recipe: :fail_with_terminal_error, + next_state: next_state, instructions: [] + + @upload_log.lifecycle decision, @config + + entry = @recording.entries.first + assert_equal Logger::WARN, entry.severity + fields = entry.message.fields + assert_equal "fail_with_terminal_error", fields["recipe"] + assert_equal "upload failed", fields["error"] + end + + def test_wire_send_logs_debug_with_start_attempt_and_hex_body + @upload_log.wire_send method: "POST", + url: "https://example.com/session?key=SECRET", + headers: { "X-Goog-Upload-Command" => "upload", "Authorization" => "Bearer SECRET" }, + start_attempt: 2, + body_size: 4, + body: "test" + + entry = @recording.entries.first + assert_equal Logger::DEBUG, entry.severity + fields = entry.message.fields + assert_equal "POST", fields["method"] + assert_equal "https://example.com/session?key=<...>", fields["url"] + assert_equal 2, fields["startAttempt"] + assert_equal 4, fields["bodySize"] + assert_equal "74657374", fields["body"] + assert_equal "<...>", fields["headers"]["Authorization"] + assert_equal "upload", fields["headers"]["X-Goog-Upload-Command"] + end + + def test_wire_receive_logs_debug + event = Event::HttpResponse.new status: 200, + headers: { "X-Goog-Upload-Status" => "active" }, + body: "ok" + + @upload_log.wire_receive event + + entry = @recording.entries.first + assert_equal Logger::DEBUG, entry.severity + fields = entry.message.fields + assert_equal 200, fields["status"] + assert_equal "6f6b", fields["body"] + end + + def test_wire_failure_logs_debug + event = Event::RequestFailed.new kind: :timeout, message: "timed out" + + @upload_log.wire_failure event + + entry = @recording.entries.first + assert_equal Logger::DEBUG, entry.severity + fields = entry.message.fields + assert_equal "timeout", fields["kind"] + end + + def test_buffer_realign_logs_warn_and_debug_on_unseekable_rewind + @upload_log.buffer_realign "rewind", server_offset: 0, current_offset: 256, unseekable: true + + assert_equal 2, @recording.entries.size + warn_entry, debug_entry = @recording.entries + assert_equal Logger::WARN, warn_entry.severity + assert_equal "rewind", warn_entry.message.fields["action"] + assert_equal 0, warn_entry.message.fields["serverOffset"] + assert_equal 256, warn_entry.message.fields["currentOffset"] + + assert_equal Logger::DEBUG, debug_entry.severity + end + + def test_unmatched_transition_logs_warn + state = State.new status: :initializing + event = Event::HttpResponse.new status: 200, headers: {}, body: "" + err = InvalidTransitionError.new "no transition" + + @upload_log.unmatched_transition state, event, err + + entry = @recording.entries.first + assert_equal Logger::WARN, entry.severity + fields = entry.message.fields + assert_equal "initializing", fields["status"] + assert_equal "no transition", fields["error"] + end +end diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb index 1cb8fa3..f06f8fa 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb @@ -19,277 +19,170 @@ require "stringio" ## -# Tests for ResumableUpload Driver structured logging across lifecycle, decisions, -# wire exchanges, buffer realignments, and data abridging. +# Integration and unit tests for Driver logging concerns. # class DriverLoggingTest < Minitest::Test include Gapic::Rest::ResumableUpload - FakeHttpResponse = Struct.new :status, :headers, :body + FakeResponse = Struct.new :status, :headers, :body - class RecordingLogger < Logger - attr_reader :entries + class FakeStub + attr_reader :method_names - def initialize - super(StringIO.new) - @entries = [] + def initialize responses + @responses = responses + @method_names = [] end - def add severity, message = nil, progname = nil - severity ||= UNKNOWN - if message.nil? - if block_given? - message = yield - else - message = progname - progname = @progname - end - end - @entries << { severity: severity, message: message, progname: progname } - true - end - end - - class ScriptedClientStub - attr_reader :requests, :logger, :endpoint - - def initialize responses, logger: nil, endpoint: "https://storage.googleapis.com" - @responses = responses.dup - @requests = [] - @logger = logger - @endpoint = endpoint + def endpoint + "https://storage.googleapis.com" end - def make_post_request uri:, body: nil, params: {}, options: {}, method_name: nil - @requests << { uri: uri, body: body, params: params, options: options, method_name: method_name } - raise "No scripted response left" if @responses.empty? - - resp = @responses.shift - raise resp if resp.is_a? Exception - - resp + def make_post_request uri:, body:, params:, options:, method_name: nil + _ = uri + _ = body + _ = params + _ = options + @method_names << method_name + @responses.shift end end - class UnseekableStream - def initialize data - @io = StringIO.new data - end - - def read length = nil - @io.read length - end - end - - def test_logs_decision_lifecycle_and_wire_entries_with_upload_id_and_method_name - logger = RecordingLogger.new - # 100 bytes data so body > 64 bytes triggers abridging - payload = "A" * 100 - stream = StringIO.new payload - + def test_all_entries_share_upload_id_and_pass_method_names + recording = RecordingLogger.new responses = [ - FakeHttpResponse.new( + FakeResponse.new( 200, { - "X-Goog-Upload-Status" => "active", - "X-Goog-Upload-URL" => "https://storage.googleapis.com/upload/session?upload_id=secret123&part=1", - "X-Goog-Upload-Chunk-Granularity" => "256", - "X-Secret-Header" => "super-secret-value" + "X-Goog-Upload-Status" => "active", + "X-Goog-Upload-URL" => "https://storage.googleapis.com/session?id=123" }, "" ), - FakeHttpResponse.new( + FakeResponse.new( 200, { "X-Goog-Upload-Status" => "final" }, - '{"id":"obj-1"}' + "done" ) ] - stub = ScriptedClientStub.new responses, logger: logger + stub = FakeStub.new responses config = CompleteUploadConfig.new( - initial_url: "https://storage.googleapis.com/upload/storage/v1/b/bucket/o?uploadType=resumable&key=secretKey", - initial_headers: { "Authorization" => "Bearer secret-token", "X-Goog-Upload-Header-Content-Length" => "100" }, - initial_body: '{"name":"test.bin"}', - stream: stream, - upload_size: 100, - chunk_size: 256 + initial_url: "https://storage.googleapis.com/upload", + stream: StringIO.new("hello world"), + upload_size: 11, + chunk_size: 256 ) - driver = Driver.new client_stub: stub, config: config - result = driver.run - - assert_equal '{"id":"obj-1"}', result - refute_empty logger.entries + driver = Driver.new client_stub: stub, config: config, logger: recording + driver.run - # Verify method_name passed to make_post_request - assert_equal "ResumableUpload.start", stub.requests[0][:method_name] - assert_equal "ResumableUpload.upload", stub.requests[1][:method_name] + refute_empty recording.entries + upload_ids = recording.entries.map { |e| e.message.fields["uploadId"] }.uniq + assert_equal 1, upload_ids.size + refute_nil upload_ids.first - # Every log entry must have uploadId matching driver.upload_id and be a Google::Logging::Message - logger.entries.each do |entry| - msg = entry[:message] - assert_instance_of Google::Logging::Message, msg - assert_equal driver.upload_id, msg.fields["uploadId"] - refute_nil driver.upload_id - end - - # Verify Decision logs (DEBUG) - decision_entries = logger.entries.select { |e| e[:message].message.start_with?("Rules:") } - assert_equal 4, decision_entries.size + assert_equal ["ResumableUpload.start", "ResumableUpload.upload"], stub.method_names + end - first_decision = decision_entries.first[:message] - assert_equal Logger::DEBUG, decision_entries.first[:severity] - assert_equal "Rules: initializing + start_upload -> start_session -> starting", first_decision.message - assert_equal "initializing", first_decision.fields["fromStatus"] - assert_equal "start_upload", first_decision.fields["shape"] - assert_equal "start_session", first_decision.fields["recipe"] - assert_equal "starting", first_decision.fields["toStatus"] - # Instructions in decision logs must be summaries without raw bodies - inst_summary = first_decision.fields["instructions"].first - assert_equal "SendStart", inst_summary["type"] - assert_equal "https://storage.googleapis.com/upload/storage/v1/b/bucket/o?uploadType=<...>&key=<...>", - inst_summary["url"] - refute inst_summary.key?("body") + def test_unmatched_transition_logs_warn_and_reraises + recording = RecordingLogger.new + stub = FakeStub.new [] + config = CompleteUploadConfig.new( + initial_url: "https://storage.googleapis.com/upload", + stream: StringIO.new("hello"), + upload_size: 5, + chunk_size: 256 + ) - # Verify Lifecycle logs: start_session (INFO), begin_transmission (INFO), send_upload_finalize (INFO), complete (INFO) - info_entries = logger.entries.select { |e| e[:severity] == Logger::INFO } - info_messages = info_entries.map { |e| e[:message].message } - assert_includes info_messages, "Initiating resumable upload" - assert_includes info_messages, "Upload session established" - assert_includes info_messages, "Sending final upload chunk and finalizing" - assert_includes info_messages, "Resumable upload completed" + failing_core = Minitest::Mock.new + failing_core.expect :dispatch, nil do |_event| + raise InvalidTransitionError, "unmatched transition in state" + end + failing_core.expect :state, State.new(status: :initializing) - # Verify Wire logs (DEBUG): URL query values elided, non-X-Goog-Upload headers elided, >64B chunk body abridged - send_entries = logger.entries.select { |e| e[:message].message.start_with?("Sending ") } - upload_send = send_entries.find { |e| e[:message].fields["command"] == "upload, finalize" }[:message] - assert_equal "https://storage.googleapis.com/upload/session?upload_id=<...>&part=<...>", upload_send.fields["url"] - assert_equal 1, upload_send.fields["retryAttempt"] - assert_match(/^<100 bytes; first 32: A{32}>$/, upload_send.fields["body"]) + driver = Driver.new client_stub: stub, config: config, core: failing_core, logger: recording - # Verify Authorization header is elided in wire logs - start_send = send_entries.find { |e| e[:message].fields["command"] == "start" }[:message] - assert_equal "<...>", start_send.fields["headers"]["Authorization"] - assert_equal "100", start_send.fields["headers"]["X-Goog-Upload-Header-Content-Length"] + assert_raises InvalidTransitionError do + driver.run + end - # Verify Received wire logs - recv_entries = logger.entries.select { |e| e[:message].message.start_with?("Received ") } - first_recv = recv_entries.first[:message] - assert_equal 200, first_recv.fields["httpStatus"] - assert_equal "active", first_recv.fields["uploadStatus"] - assert_equal 256, first_recv.fields["granularity"] - assert_equal "<...>", first_recv.fields["headers"]["X-Secret-Header"] + warn_entries = recording.entries.select { |e| e.severity == Logger::WARN } + assert_equal 1, warn_entries.size + fields = warn_entries.first.message.fields + assert_equal "initializing", fields["status"] + assert_equal "unmatched transition in state", fields["error"] end - def test_send_chunk_lifecycle_is_logged_at_debug_level_only - logger = RecordingLogger.new - stream = StringIO.new("A" * 512) - + def test_redaction_of_payload_session_url_and_authorization_header + recording = RecordingLogger.new + sentinel_payload = "SECRET123_PAYLOAD_DATA" responses = [ - FakeHttpResponse.new( + FakeResponse.new( 200, { "X-Goog-Upload-Status" => "active", - "X-Goog-Upload-URL" => "https://storage.googleapis.com/session" + "X-Goog-Upload-URL" => "https://storage.googleapis.com/session?sid=SECRET123" }, "" ), - FakeHttpResponse.new( - 200, - { "X-Goog-Upload-Status" => "active" }, - "" - ), - FakeHttpResponse.new( - 200, - { "X-Goog-Upload-Status" => "active" }, - "" - ), - FakeHttpResponse.new( + FakeResponse.new( 200, { "X-Goog-Upload-Status" => "final" }, - '{"done":true}' + "done" ) ] - stub = ScriptedClientStub.new responses, logger: logger + stub = FakeStub.new responses config = CompleteUploadConfig.new( - initial_url: "https://storage.googleapis.com/upload", - stream: stream, - upload_size: 512, - chunk_size: 256 + initial_url: "https://storage.googleapis.com/upload?key=SECRET123", + initial_headers: { "Authorization" => "Bearer SECRET123" }, + stream: StringIO.new(sentinel_payload), + upload_size: sentinel_payload.bytesize, + chunk_size: 256 ) - driver = Driver.new client_stub: stub, config: config + driver = Driver.new client_stub: stub, config: config, logger: recording driver.run - send_chunk_entries = logger.entries.select do |e| - e[:message].fields["recipe"] == "send_chunk" && !e[:message].message.start_with?("Rules:") - end - refute_empty send_chunk_entries - send_chunk_entries.each do |entry| - assert_equal Logger::DEBUG, entry[:severity] + recording.entries.each do |entry| + full_dump = entry.message.to_s + refute_includes full_dump, "SECRET123" + refute_includes full_dump, sentinel_payload end end - def test_realign_within_buffer_logs_debug_and_unseekable_rewind_logs_warn - logger = RecordingLogger.new - stream = UnseekableStream.new("A" * 512) - + def test_total_logged_bytes_for_run_under_64kb + recording = RecordingLogger.new + chunk_data = "X" * 32_768 responses = [ - FakeHttpResponse.new( + FakeResponse.new( 200, { "X-Goog-Upload-Status" => "active", - "X-Goog-Upload-URL" => "https://storage.googleapis.com/session" - }, - "" - ), - # First chunk send fails with 503 triggering recovery - FakeHttpResponse.new(503, {}, "Backend error"), - # Query response asks to rewind before buffer start (0) when buffer_start is 0 -> test within_buffer first - FakeHttpResponse.new( - 200, - { - "X-Goog-Upload-Status" => "active", - "X-Goog-Upload-Size-Received" => "128" + "X-Goog-Upload-URL" => "https://storage.googleapis.com/session?id=1" }, "" ), - # Next chunk fails with 503 triggering recovery - FakeHttpResponse.new(503, {}, "Backend error"), - # Query response asks to rewind to offset 0 (which is behind buffer_start_offset 128 on unseekable stream) - FakeHttpResponse.new( + FakeResponse.new( 200, - { - "X-Goog-Upload-Status" => "active", - "X-Goog-Upload-Size-Received" => "0" - }, - "" + { "X-Goog-Upload-Status" => "final" }, + "done" ) ] - stub = ScriptedClientStub.new responses, logger: logger + stub = FakeStub.new responses config = CompleteUploadConfig.new( initial_url: "https://storage.googleapis.com/upload", - stream: stream, - upload_size: 512, - chunk_size: 256 + stream: StringIO.new(chunk_data), + upload_size: chunk_data.bytesize, + chunk_size: 65_536 ) - driver = Driver.new client_stub: stub, config: config - assert_raises UnseekableStreamError do - driver.run - end - - realign_entries = logger.entries.select { |e| e[:message].fields.key?("realignCase") } - assert_equal 2, realign_entries.size - assert_equal "within_buffer", realign_entries[0][:message].fields["realignCase"] - assert_equal 128, realign_entries[0][:message].fields["offset"] - - assert_equal "rewind", realign_entries[1][:message].fields["realignCase"] - assert_equal 0, realign_entries[1][:message].fields["offset"] + driver = Driver.new client_stub: stub, config: config, logger: recording + driver.run - warn_entries = logger.entries.select { |e| e[:severity] == Logger::WARN } - assert_equal 1, warn_entries.size - assert_match(/Cannot rewind unseekable stream to offset 0/, warn_entries.first[:message].message) + total_bytes = recording.entries.sum { |e| e.message.to_s.bytesize } + assert_operator total_bytes, :<, 65_536 end end diff --git a/gapic-common/test/test_helper.rb b/gapic-common/test/test_helper.rb index 5baa3d4..4903722 100644 --- a/gapic-common/test/test_helper.rb +++ b/gapic-common/test/test_helper.rb @@ -128,3 +128,19 @@ def spoof_logging_env enabled: nil, cloud_run: false ensure ENV["GOOGLE_SDK_RUBY_LOGGING_GEMS"] = old_enabled end + +class RecordingLogger < Logger + Entry = Data.define :severity, :message + + attr_reader :entries + + def initialize + super nil + @entries = [] + end + + def add severity, message = nil, progname = nil + msg = block_given? ? yield : (message || progname) + @entries << Entry.new(severity: severity, message: msg) + end +end From 8fbccfc80c1dc07e5f50b9240bd6f4609ed43843 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Sun, 6 Sep 2026 20:39:08 +0000 Subject: [PATCH 28/79] test: corpus-level tests --- .../rest/resumable_upload/driver/abridge.rb | 8 +- .../resumable_upload/driver_logging_test.rb | 81 ++++++++++--------- gapic-common/test/test_helper.rb | 8 ++ 3 files changed, 56 insertions(+), 41 deletions(-) diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver/abridge.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver/abridge.rb index c3b5ec4..7d73b29 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver/abridge.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver/abridge.rb @@ -66,7 +66,13 @@ def headers headers headers.each_with_object({}) do |(k, v), acc| key_str = k.to_s - acc[key_str] = key_str.downcase.start_with?("x-goog-upload-") ? v : "<...>" + acc[key_str] = if key_str.downcase == "x-goog-upload-url" + url v + elsif key_str.downcase.start_with? "x-goog-upload-" + v + else + "<...>" + end end end diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb index f06f8fa..4ba4568 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb @@ -114,53 +114,56 @@ def test_unmatched_transition_logs_warn_and_reraises assert_equal "unmatched transition in state", fields["error"] end - def test_redaction_of_payload_session_url_and_authorization_header + def test_full_log_corpus_redacts_secrets recording = RecordingLogger.new - sentinel_payload = "SECRET123_PAYLOAD_DATA" + run_two_chunk_upload_with_secret recording + + corpus = log_corpus recording + refute_includes corpus, "SECRET-123456" + end + + def test_full_log_corpus_size_under_64kib + recording = RecordingLogger.new + run_two_chunk_upload_with_secret recording + + corpus = log_corpus recording + assert_operator corpus.bytesize, :<, 65_536 + end + + private + + def run_two_chunk_upload_with_secret recording + chunk_size = 8 * 1024 * 1024 + secret = "SECRET-123456" + binary_prefix = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09".b + + half = (chunk_size / 2) - 10 + chunk1 = binary_prefix + ("A" * half) + secret + ("A" * (chunk_size - 10 - half - secret.bytesize)) + chunk2 = binary_prefix + ("A" * (chunk_size - 10)) + stream_data = chunk1 + chunk2 + responses = [ FakeResponse.new( 200, { "X-Goog-Upload-Status" => "active", - "X-Goog-Upload-URL" => "https://storage.googleapis.com/session?sid=SECRET123" + "X-Goog-Upload-URL" => "https://storage.googleapis.com/session?sid=#{secret}" }, "" ), FakeResponse.new( 200, - { "X-Goog-Upload-Status" => "final" }, - "done" - ) - ] - - stub = FakeStub.new responses - config = CompleteUploadConfig.new( - initial_url: "https://storage.googleapis.com/upload?key=SECRET123", - initial_headers: { "Authorization" => "Bearer SECRET123" }, - stream: StringIO.new(sentinel_payload), - upload_size: sentinel_payload.bytesize, - chunk_size: 256 - ) - - driver = Driver.new client_stub: stub, config: config, logger: recording - driver.run - - recording.entries.each do |entry| - full_dump = entry.message.to_s - refute_includes full_dump, "SECRET123" - refute_includes full_dump, sentinel_payload - end - end - - def test_total_logged_bytes_for_run_under_64kb - recording = RecordingLogger.new - chunk_data = "X" * 32_768 - responses = [ + { + "X-Goog-Upload-Status" => "active", + "X-Goog-Upload-Size-Received" => chunk_size.to_s + }, + "" + ), FakeResponse.new( 200, { - "X-Goog-Upload-Status" => "active", - "X-Goog-Upload-URL" => "https://storage.googleapis.com/session?id=1" + "X-Goog-Upload-Status" => "active", + "X-Goog-Upload-Size-Received" => (chunk_size * 2).to_s }, "" ), @@ -173,16 +176,14 @@ def test_total_logged_bytes_for_run_under_64kb stub = FakeStub.new responses config = CompleteUploadConfig.new( - initial_url: "https://storage.googleapis.com/upload", - stream: StringIO.new(chunk_data), - upload_size: chunk_data.bytesize, - chunk_size: 65_536 + initial_url: "https://storage.googleapis.com/upload?token=#{secret}", + initial_headers: { "Authorization" => "Bearer #{secret}" }, + stream: StringIO.new(stream_data), + upload_size: stream_data.bytesize, + chunk_size: chunk_size ) driver = Driver.new client_stub: stub, config: config, logger: recording driver.run - - total_bytes = recording.entries.sum { |e| e.message.to_s.bytesize } - assert_operator total_bytes, :<, 65_536 end end diff --git a/gapic-common/test/test_helper.rb b/gapic-common/test/test_helper.rb index 4903722..fcf5283 100644 --- a/gapic-common/test/test_helper.rb +++ b/gapic-common/test/test_helper.rb @@ -144,3 +144,11 @@ def add severity, message = nil, progname = nil @entries << Entry.new(severity: severity, message: msg) end end + +def log_corpus recording_logger + formatter = Google::Logging::StructuredFormatter.new + recording_logger.entries.map do |entry| + sev = Logger::SEV_LABEL[entry.severity] || "INFO" + formatter.call sev, Time.now, nil, entry.message + end.join +end From 7b946728b9c392ed292177143e19955261494aad Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Sun, 6 Sep 2026 21:10:39 +0000 Subject: [PATCH 29/79] fix: nailing down the recipes in a fixed list --- .../resumable_upload/driver/upload_log.rb | 140 ++++++++---------- .../lib/gapic/rest/resumable_upload/rules.rb | 27 ++++ .../driver/upload_log_test.rb | 54 ++++--- .../resumable_upload/driver_logging_test.rb | 83 +++++++++++ .../resumable_upload/rules_decide_test.rb | 6 + .../gapic/rest/resumable_upload/rules_test.rb | 6 + 6 files changed, 216 insertions(+), 100 deletions(-) diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb index 1698c5e..dba3fa8 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb @@ -26,6 +26,31 @@ class Driver # Structured logging helper for a single Resumable Upload run. # class UploadLog + SILENT_RECIPES = [ + :ignore_duplicate_cancel + ].freeze + + LIFECYCLE = { + start_session: [:info, "Initiating resumable upload"], + begin_transmission: [:info, "Upload session established"], + send_chunk: [:debug, "Sending upload chunk"], + send_upload_finalize: [:info, "Sending final upload chunk"], + send_finalize: [:info, "Sending finalize command"], + ack_chunk: [:info, "Upload chunk acknowledged"], + enter_recovery: [:info, "Entering upload recovery"], + retry_recovery: [:info, "Retrying upload recovery query"], + realign_from_recovery: [:info, "Resuming upload from server offset"], + complete_upload_with_data: [:info, "Resumable upload completed"], + complete_upload_finalized: [:info, "Resumable upload completed"], + cancel_session: [:info, "Canceling resumable upload"], + complete_cancellation: [:info, "Resumable upload canceled"], + fail_with_deadline_exceeded: [:warn, "Resumable upload failed"], + fail_with_rejected: [:warn, "Resumable upload failed"], + fail_with_bad_response: [:warn, "Resumable upload failed"], + fail_with_request_error: [:warn, "Resumable upload failed"], + fail_with_unmatched_transition: [:warn, "Resumable upload failed"] + }.freeze + attr_reader :upload_id def initialize stub_logger, upload_id: @@ -49,90 +74,15 @@ def decision decision ) end - # rubocop:disable Metrics/AbcSize, Metrics/MethodLength def lifecycle decision, config - case decision.recipe - when :start_session - entry( - :info, - "Initiating resumable upload", - recipe: decision.recipe, - uploadSize: config.upload_size, - requestedChunkSize: config.chunk_size - ) - when :begin_transmission - entry( - :info, - "Upload session established", - recipe: decision.recipe, - effectiveChunkSize: decision.next_state.chunk_size, - granularity: decision.next_state.chunk_granularity, - uploadUrl: Abridge.url(decision.next_state.upload_url) - ) - when :send_chunk - entry( - :debug, - "Sending upload chunk", - recipe: decision.recipe, - offset: decision.next_state.offset, - inFlightLength: decision.next_state.in_flight_length - ) - when :ack_chunk - entry( - :info, - "Upload chunk acknowledged", - recipe: decision.recipe, - offset: decision.next_state.offset - ) - when :complete_upload - entry( - :info, - "Resumable upload completed", - recipe: decision.recipe, - offset: decision.next_state.offset - ) - when :enter_recovery - entry( - :info, - "Entering upload recovery", - recipe: decision.recipe, - offset: decision.next_state.offset - ) - when :resume_from_query - entry( - :info, - "Resuming upload from server offset", - recipe: decision.recipe, - offset: decision.next_state.offset - ) - when :send_cancel - entry( - :info, - "Canceling resumable upload", - recipe: decision.recipe, - uploadUrl: Abridge.url(decision.next_state.upload_url) - ) - when :complete_cancellation - entry( - :info, - "Resumable upload canceled", - recipe: decision.recipe - ) - when :fail_with_bad_session, - :fail_with_range_drift, - :fail_with_protocol_error, - :fail_with_terminal_error, - :fail_with_unmatched_transition - error_msg = decision.next_state.last_error&.message || decision.next_state.last_error.to_s - entry( - :warn, - "Resumable upload failed", - recipe: decision.recipe, - error: error_msg - ) - end + return if SILENT_RECIPES.include? decision.recipe + + severity, message = LIFECYCLE[decision.recipe] + return unless severity + + extra_fields = lifecycle_fields decision, config + entry severity, message, recipe: decision.recipe, **extra_fields end - # rubocop:enable Metrics/AbcSize, Metrics/MethodLength def wire_send method:, url:, headers:, start_attempt:, body_size: nil, body: nil, body_is_error: false fields = { @@ -198,6 +148,32 @@ def unmatched_transition state, event, error private + def lifecycle_fields decision, config + state = decision.next_state + case decision.recipe + when :start_session + { uploadSize: config.upload_size, requestedChunkSize: config.chunk_size } + when :begin_transmission + { + effectiveChunkSize: state.chunk_size, + granularity: state.chunk_granularity, + uploadUrl: Abridge.url(state.upload_url) + } + when :send_chunk, :send_upload_finalize + { offset: state.offset, inFlightLength: state.in_flight_length } + when :send_finalize, :ack_chunk, :enter_recovery, :retry_recovery, + :realign_from_recovery, :complete_upload_with_data, :complete_upload_finalized + { offset: state.offset } + when :cancel_session + { uploadUrl: Abridge.url(state.upload_url) } + when :fail_with_deadline_exceeded, :fail_with_rejected, :fail_with_bad_response, + :fail_with_request_error, :fail_with_unmatched_transition + { error: state.last_error&.message || state.last_error.to_s } + else + {} + end + end + def entry severity, log_msg, **fields @stub_logger.public_send severity do |builder| builder.set_system_name diff --git a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb index cf75d90..5e27097 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb @@ -47,6 +47,31 @@ module Rules rejected: "in rejected upload state" }.freeze + ## + # Canonical list of recipe symbols emitted by {Rules.decide}. + # + RECIPES = [ + :start_session, + :begin_transmission, + :send_chunk, + :send_upload_finalize, + :send_finalize, + :ack_chunk, + :enter_recovery, + :retry_recovery, + :realign_from_recovery, + :complete_upload_with_data, + :complete_upload_finalized, + :cancel_session, + :complete_cancellation, + :ignore_duplicate_cancel, + :fail_with_deadline_exceeded, + :fail_with_rejected, + :fail_with_bad_response, + :fail_with_request_error, + :fail_with_unmatched_transition + ].freeze + ## # Classifies incoming event into a canonical shape symbol. # @@ -133,6 +158,8 @@ def self.decide state, event, config :fail_with_unmatched_transition end + raise ArgumentError, "unknown recipe: #{recipe}" unless RECIPES.include? recipe + next_state, instructions = public_send recipe, state, event, config Decision.new( from_status: state.status, diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver/upload_log_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver/upload_log_test.rb index 882d50e..3f2870c 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver/upload_log_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver/upload_log_test.rb @@ -41,10 +41,8 @@ def setup on_progress: nil end - def test_decision_logs_debug_with_fields - next_state = State.new status: :starting, offset: 0, in_flight_length: 0 - decision = Decision.new from_status: :initializing, shape: :start_upload, recipe: :start_session, - next_state: next_state, instructions: [] + def test_decision_logs_debug_with_fields_from_rules_decide + decision = Rules.decide State.new(status: :initializing), Event::StartUpload.new, @config @upload_log.decision decision @@ -59,13 +57,11 @@ def test_decision_logs_debug_with_fields assert_equal "starting", fields["toStatus"] assert_equal 0, fields["offset"] assert_equal 0, fields["inFlightLength"] - assert_equal [], fields["instructions"] + assert_equal [{ "type" => "SendStart", "url" => "https://example.com/upload" }], fields["instructions"] end def test_lifecycle_start_session_logs_info - next_state = State.new status: :starting - decision = Decision.new from_status: :initializing, shape: :start_upload, recipe: :start_session, - next_state: next_state, instructions: [] + decision = Rules.decide State.new(status: :initializing), Event::StartUpload.new, @config @upload_log.lifecycle decision, @config @@ -78,9 +74,9 @@ def test_lifecycle_start_session_logs_info end def test_lifecycle_send_chunk_logs_debug - next_state = State.new status: :uploading, offset: 256, in_flight_length: 256 - decision = Decision.new from_status: :uploading, shape: :chunk_read, recipe: :send_chunk, - next_state: next_state, instructions: [] + state = State.new status: :transmission_reading, upload_url: "https://example.com/session", offset: 0, chunk_size: 256 + event = Event::ChunkRead.new bytes_buffered: 256, eof: false + decision = Rules.decide state, event, @config @upload_log.lifecycle decision, @config @@ -88,23 +84,45 @@ def test_lifecycle_send_chunk_logs_debug assert_equal Logger::DEBUG, entry.severity fields = entry.message.fields assert_equal "send_chunk", fields["recipe"] - assert_equal 256, fields["offset"] + assert_equal 0, fields["offset"] assert_equal 256, fields["inFlightLength"] end def test_lifecycle_terminal_failure_logs_warn - err = StandardError.new "upload failed" - next_state = State.new status: :failed, last_error: err - decision = Decision.new from_status: :uploading, shape: :http_5xx, recipe: :fail_with_terminal_error, - next_state: next_state, instructions: [] + state = State.new status: :starting + event = Event::HttpResponse.new status: 403, headers: { "x-goog-upload-status" => "final" }, body: "Forbidden" + decision = Rules.decide state, event, @config @upload_log.lifecycle decision, @config entry = @recording.entries.first assert_equal Logger::WARN, entry.severity fields = entry.message.fields - assert_equal "fail_with_terminal_error", fields["recipe"] - assert_equal "upload failed", fields["error"] + assert_equal "fail_with_rejected", fields["recipe"] + assert_includes fields["error"], "Forbidden" + end + + def test_lifecycle_silent_recipes_emit_no_logs + state = State.new status: :cancelling + decision = Rules.decide state, Event::Cancel.new, @config + assert_equal :ignore_duplicate_cancel, decision.recipe + + @upload_log.lifecycle decision, @config + + assert_empty @recording.entries + end + + def test_lifecycle_table_matches_rules_recipes + lifecycle_keys = Driver::UploadLog::LIFECYCLE.keys + silent_keys = Driver::UploadLog::SILENT_RECIPES + all_upload_log_recipes = lifecycle_keys + silent_keys + + assert_empty Rules::RECIPES - all_upload_log_recipes, + "Rules recipes not covered by UploadLog::LIFECYCLE or SILENT_RECIPES" + assert_empty all_upload_log_recipes - Rules::RECIPES, + "Extra recipes in UploadLog::LIFECYCLE or SILENT_RECIPES not in Rules::RECIPES" + assert_empty lifecycle_keys & silent_keys, + "Recipes present in both UploadLog::LIFECYCLE and SILENT_RECIPES" end def test_wire_send_logs_debug_with_start_attempt_and_hex_body diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb index 4ba4568..cbf8c5a 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb @@ -83,6 +83,89 @@ def test_all_entries_share_upload_id_and_pass_method_names refute_nil upload_ids.first assert_equal ["ResumableUpload.start", "ResumableUpload.upload"], stub.method_names + + info_recipes = recording.entries.select { |e| e.severity == Logger::INFO }.map { |e| e.message.fields["recipe"] } + assert_includes ["complete_upload_with_data", "complete_upload_finalized"], info_recipes.last + end + + def test_multi_chunk_upload_logs_ack_chunk_and_completion + recording = RecordingLogger.new + run_two_chunk_upload_with_secret recording + + info_recipes = recording.entries.select { |e| e.severity == Logger::INFO }.map { |e| e.message.fields["recipe"] } + assert_includes info_recipes, "ack_chunk" + assert(info_recipes.any? { |r| ["complete_upload_with_data", "complete_upload_finalized"].include? r }) + end + + def test_recovery_scenario_logs_enter_recovery_and_realign + recording = RecordingLogger.new + responses = [ + FakeResponse.new( + 200, + { + "X-Goog-Upload-Status" => "active", + "X-Goog-Upload-URL" => "https://storage.googleapis.com/session?id=123" + }, + "" + ), + FakeResponse.new(503, {}, "Service Unavailable"), + FakeResponse.new( + 200, + { + "X-Goog-Upload-Status" => "active", + "X-Goog-Upload-Size-Received" => "0" + }, + "" + ), + FakeResponse.new( + 200, + { "X-Goog-Upload-Status" => "final" }, + "done" + ) + ] + + stub = FakeStub.new responses + config = CompleteUploadConfig.new( + initial_url: "https://storage.googleapis.com/upload", + stream: StringIO.new("hello world"), + upload_size: 11, + chunk_size: 256 + ) + + driver = Driver.new client_stub: stub, config: config, logger: recording + driver.run + + info_recipes = recording.entries.select { |e| e.severity == Logger::INFO }.map { |e| e.message.fields["recipe"] } + assert_includes info_recipes, "enter_recovery" + assert_includes info_recipes, "realign_from_recovery" + end + + def test_fatal_failure_logs_warn_with_fail_with_recipe + recording = RecordingLogger.new + responses = [ + FakeResponse.new( + 403, + { "X-Goog-Upload-Status" => "final" }, + "Forbidden" + ) + ] + + stub = FakeStub.new responses + config = CompleteUploadConfig.new( + initial_url: "https://storage.googleapis.com/upload", + stream: StringIO.new("hello world"), + upload_size: 11, + chunk_size: 256 + ) + + driver = Driver.new client_stub: stub, config: config, logger: recording + assert_raises Gapic::Common::UploadRejectedError do + driver.run + end + + warn_entries = recording.entries.select { |e| e.severity == Logger::WARN } + refute_empty warn_entries + assert(warn_entries.any? { |e| e.message.fields["recipe"]&.start_with? "fail_with_" }) end def test_unmatched_transition_logs_warn_and_reraises diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb index 5c0ef64..d100f36 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb @@ -242,4 +242,10 @@ def test_row_fail_with_request_error assert_equal :error, decision.next_state.status assert_instance_of Instruction::TerminateFailure, decision.instructions.first end + + def test_recipes_constant_matches_all_recipes + Rules::RECIPES.each do |recipe| + assert_respond_to Rules, recipe, "Rules must implement recipe method :#{recipe}" + end + end end diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb index 02c6d4e..009b2d7 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb @@ -174,4 +174,10 @@ def test_transition_cancellation_flow assert_equal 1, final_instructions.size assert_instance_of Instruction::TerminateFailure, final_instructions.first end + + def test_all_recipes_respond_to_rules_method + Rules::RECIPES.each do |recipe| + assert_respond_to Rules, recipe + end + end end From 40d860501ce40b06effbec3b67e3b3bb19531ed9 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Sun, 6 Sep 2026 21:26:48 +0000 Subject: [PATCH 30/79] fix: reshuffle logging --- .../rest/resumable_upload/driver/abridge.rb | 2 +- .../resumable_upload/driver/upload_log.rb | 60 +++++++++++-------- .../resumable_upload/driver/abridge_test.rb | 8 ++- .../driver/upload_log_test.rb | 17 +++++- .../resumable_upload/driver_logging_test.rb | 6 +- .../resumable_upload/rules_decide_test.rb | 6 -- 6 files changed, 62 insertions(+), 37 deletions(-) diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver/abridge.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver/abridge.rb index 7d73b29..141d70a 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver/abridge.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver/abridge.rb @@ -41,7 +41,7 @@ def bytes data def error_body data return nil if data.nil? - data.to_s[0, 512] + data.to_s.dup.force_encoding(Encoding::UTF_8).scrub[0, 512] end def url url diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb index dba3fa8..fcb8974 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb @@ -27,28 +27,28 @@ class Driver # class UploadLog SILENT_RECIPES = [ - :ignore_duplicate_cancel + :ack_chunk, # per-chunk transition, doesn't belong at INFO + :ignore_duplicate_cancel, # duplicate cancel signal, no state change + :fail_with_unmatched_transition # raises before Decision exists, logged by #unmatched_transition ].freeze LIFECYCLE = { - start_session: [:info, "Initiating resumable upload"], - begin_transmission: [:info, "Upload session established"], - send_chunk: [:debug, "Sending upload chunk"], - send_upload_finalize: [:info, "Sending final upload chunk"], - send_finalize: [:info, "Sending finalize command"], - ack_chunk: [:info, "Upload chunk acknowledged"], - enter_recovery: [:info, "Entering upload recovery"], - retry_recovery: [:info, "Retrying upload recovery query"], - realign_from_recovery: [:info, "Resuming upload from server offset"], - complete_upload_with_data: [:info, "Resumable upload completed"], - complete_upload_finalized: [:info, "Resumable upload completed"], - cancel_session: [:info, "Canceling resumable upload"], - complete_cancellation: [:info, "Resumable upload canceled"], - fail_with_deadline_exceeded: [:warn, "Resumable upload failed"], - fail_with_rejected: [:warn, "Resumable upload failed"], - fail_with_bad_response: [:warn, "Resumable upload failed"], - fail_with_request_error: [:warn, "Resumable upload failed"], - fail_with_unmatched_transition: [:warn, "Resumable upload failed"] + start_session: [:info, "Initiating resumable upload"], + begin_transmission: [:info, "Upload session established"], + send_chunk: [:debug, "Sending upload chunk"], + send_upload_finalize: [:info, "Sending final upload chunk"], + send_finalize: [:info, "Sending finalize command"], + enter_recovery: [:info, "Entering upload recovery"], + retry_recovery: [:info, "Retrying upload recovery query"], + realign_from_recovery: [:info, "Resuming upload from server offset"], + complete_upload_with_data: [:info, "Resumable upload completed"], + complete_upload_finalized: [:info, "Resumable upload completed"], + cancel_session: [:info, "Canceling resumable upload"], + complete_cancellation: [:info, "Resumable upload canceled"], + fail_with_deadline_exceeded: [:warn, "Resumable upload failed"], + fail_with_rejected: [:warn, "Resumable upload failed"], + fail_with_bad_response: [:warn, "Resumable upload failed"], + fail_with_request_error: [:warn, "Resumable upload failed"] }.freeze attr_reader :upload_id @@ -85,12 +85,16 @@ def lifecycle decision, config end def wire_send method:, url:, headers:, start_attempt:, body_size: nil, body: nil, body_is_error: false + command = Rules.header_value headers, "x-goog-upload-command" + offset = Rules.header_value headers, "x-goog-upload-offset" fields = { method: method, url: Abridge.url(url), headers: Abridge.headers(headers), startAttempt: start_attempt } + fields[:command] = command if command + fields[:offset] = offset.to_i if offset fields[:bodySize] = body_size if body_size fields[:body] = body_is_error ? Abridge.error_body(body) : Abridge.bytes(body) if body @@ -98,13 +102,19 @@ def wire_send method:, url:, headers:, start_attempt:, body_size: nil, body: nil end def wire_receive event - entry( - :debug, - "Received HTTP #{event.status}", + upload_status = Rules.header_value event.headers, "x-goog-upload-status" + size_recv = Rules.header_value event.headers, "x-goog-upload-size-received" + gran = Rules.header_value event.headers, "x-goog-upload-chunk-granularity" + fields = { status: event.status, headers: Abridge.headers(event.headers), body: event.status >= 400 ? Abridge.error_body(event.body) : Abridge.bytes(event.body) - ) + } + fields[:uploadStatus] = upload_status if upload_status + fields[:sizeReceived] = size_recv.to_i if size_recv + fields[:granularity] = gran.to_i if gran + + entry :debug, "Received HTTP #{event.status}", **fields end def wire_failure event @@ -161,13 +171,13 @@ def lifecycle_fields decision, config } when :send_chunk, :send_upload_finalize { offset: state.offset, inFlightLength: state.in_flight_length } - when :send_finalize, :ack_chunk, :enter_recovery, :retry_recovery, + when :send_finalize, :enter_recovery, :retry_recovery, :realign_from_recovery, :complete_upload_with_data, :complete_upload_finalized { offset: state.offset } when :cancel_session { uploadUrl: Abridge.url(state.upload_url) } when :fail_with_deadline_exceeded, :fail_with_rejected, :fail_with_bad_response, - :fail_with_request_error, :fail_with_unmatched_transition + :fail_with_request_error { error: state.last_error&.message || state.last_error.to_s } else {} diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver/abridge_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver/abridge_test.rb index 2dc93b0..e52ea00 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver/abridge_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver/abridge_test.rb @@ -39,11 +39,17 @@ def test_bytes_abridges_and_hex_encodes_large_payloads assert_equal "#{expected_prefix}... <100 bytes>", Driver::Abridge.bytes(large) end - def test_error_body_truncates_at_512_bytes + def test_error_body_truncates_at_512_bytes_and_scrubs_invalid_utf8 assert_nil Driver::Abridge.error_body(nil) long_err = "E" * 600 assert_equal 512, Driver::Abridge.error_body(long_err).bytesize + + invalid_utf8 = "error \xFF\xFE message".b + scrubbed = Driver::Abridge.error_body invalid_utf8 + assert scrubbed.valid_encoding? + assert_includes scrubbed, "error " + assert_includes scrubbed, " message" end def test_url_elides_query_parameter_values diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver/upload_log_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver/upload_log_test.rb index 3f2870c..3a03529 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver/upload_log_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver/upload_log_test.rb @@ -128,7 +128,11 @@ def test_lifecycle_table_matches_rules_recipes def test_wire_send_logs_debug_with_start_attempt_and_hex_body @upload_log.wire_send method: "POST", url: "https://example.com/session?key=SECRET", - headers: { "X-Goog-Upload-Command" => "upload", "Authorization" => "Bearer SECRET" }, + headers: { + "X-Goog-Upload-Command" => "upload", + "X-Goog-Upload-Offset" => "256", + "Authorization" => "Bearer SECRET" + }, start_attempt: 2, body_size: 4, body: "test" @@ -137,6 +141,8 @@ def test_wire_send_logs_debug_with_start_attempt_and_hex_body assert_equal Logger::DEBUG, entry.severity fields = entry.message.fields assert_equal "POST", fields["method"] + assert_equal "upload", fields["command"] + assert_equal 256, fields["offset"] assert_equal "https://example.com/session?key=<...>", fields["url"] assert_equal 2, fields["startAttempt"] assert_equal 4, fields["bodySize"] @@ -147,7 +153,11 @@ def test_wire_send_logs_debug_with_start_attempt_and_hex_body def test_wire_receive_logs_debug event = Event::HttpResponse.new status: 200, - headers: { "X-Goog-Upload-Status" => "active" }, + headers: { + "X-Goog-Upload-Status" => "active", + "X-Goog-Upload-Size-Received" => "256", + "X-Goog-Upload-Chunk-Granularity" => "256" + }, body: "ok" @upload_log.wire_receive event @@ -156,6 +166,9 @@ def test_wire_receive_logs_debug assert_equal Logger::DEBUG, entry.severity fields = entry.message.fields assert_equal 200, fields["status"] + assert_equal "active", fields["uploadStatus"] + assert_equal 256, fields["sizeReceived"] + assert_equal 256, fields["granularity"] assert_equal "6f6b", fields["body"] end diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb index cbf8c5a..3e6313c 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb @@ -88,12 +88,14 @@ def test_all_entries_share_upload_id_and_pass_method_names assert_includes ["complete_upload_with_data", "complete_upload_finalized"], info_recipes.last end - def test_multi_chunk_upload_logs_ack_chunk_and_completion + def test_multi_chunk_upload_logs_lifecycle_entries recording = RecordingLogger.new run_two_chunk_upload_with_secret recording info_recipes = recording.entries.select { |e| e.severity == Logger::INFO }.map { |e| e.message.fields["recipe"] } - assert_includes info_recipes, "ack_chunk" + refute_includes info_recipes, "ack_chunk" + assert_includes info_recipes, "start_session" + assert_includes info_recipes, "begin_transmission" assert(info_recipes.any? { |r| ["complete_upload_with_data", "complete_upload_finalized"].include? r }) end diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb index d100f36..5c0ef64 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb @@ -242,10 +242,4 @@ def test_row_fail_with_request_error assert_equal :error, decision.next_state.status assert_instance_of Instruction::TerminateFailure, decision.instructions.first end - - def test_recipes_constant_matches_all_recipes - Rules::RECIPES.each do |recipe| - assert_respond_to Rules, recipe, "Rules must implement recipe method :#{recipe}" - end - end end From 29b1486576ffd5ad1a896884162fdc6bbde9db79 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Sun, 6 Sep 2026 21:31:26 +0000 Subject: [PATCH 31/79] fix: log client id --- gapic-common/lib/gapic/rest/resumable_upload/driver.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index ead4f6a..7e5f6dc 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -67,7 +67,8 @@ def initialize client_stub:, config:, core: nil, logger: nil setup_logging logger: logger || (client_stub.respond_to?(:logger) ? client_stub.logger : nil), system_name: "gapic-common", service: "ResumableUpload", - endpoint: endpoint + endpoint: endpoint, + client_id: client_stub.object_id @upload_log = UploadLog.new stub_logger, upload_id: "unstarted" @start_retry_policy = config.start_retry_policy || From 402c6c19f16a235eb3fceba119372d22f13478b0 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Mon, 7 Sep 2026 00:21:05 +0000 Subject: [PATCH 32/79] feat: client stub changes to avoid logging large requests --- gapic-common/lib/gapic/rest/client_stub.rb | 16 ++++++-- .../test/gapic/rest/client_stub_test.rb | 41 +++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/gapic-common/lib/gapic/rest/client_stub.rb b/gapic-common/lib/gapic/rest/client_stub.rb index 0ec818c..1fd05b4 100644 --- a/gapic-common/lib/gapic/rest/client_stub.rb +++ b/gapic-common/lib/gapic/rest/client_stub.rb @@ -287,17 +287,27 @@ def log_request method_name, request_id, try_number, body, metadata entry.set "requestId", request_id entry.message = "Sending request to #{entry.service}.#{method_name} (try #{try_number})" end - body = body.to_s + body_str = body.to_s metadata = metadata.to_h rescue {} - return if body.empty? && metadata.empty? + return if body_str.empty? && metadata.empty? stub_logger.debug do |entry| entry.set "requestId", request_id - entry.set "request", body + entry.set "request", abridge_request_body(body_str) entry.set "headers", metadata entry.message = "(request payload as JSON)" end end + def abridge_request_body body_str + utf8_body = body_str.dup.force_encoding Encoding::UTF_8 + if body_str.bytesize > 1024 || !utf8_body.valid_encoding? + prefix_hex = body_str.byteslice(0, 32).unpack1 "H*" + "<#{body_str.bytesize} bytes, first 32: #{prefix_hex}>" + else + utf8_body + end + end + def log_response method_name, request_id, try_number, response, is_server_streaming return unless stub_logger&.enabled? stub_logger.info do |entry| diff --git a/gapic-common/test/gapic/rest/client_stub_test.rb b/gapic-common/test/gapic/rest/client_stub_test.rb index f60c9aa..f6c2420 100644 --- a/gapic-common/test/gapic/rest/client_stub_test.rb +++ b/gapic-common/test/gapic/rest/client_stub_test.rb @@ -173,4 +173,45 @@ def test_universe_domain_credentials_mismatch credentials: creds end end + + def test_log_request_retains_valid_utf8_under_1kib + recording = RecordingLogger.new + client_stub = ::Gapic::Rest::ClientStub.new endpoint: "google.example.com", + credentials: :dummy_credentials, + logger: recording + payload = '{"hello":"world"}' + client_stub.send :log_request, "MyMethod", "req-1", 1, payload, { "x-test" => "val" } + + debug_entry = recording.entries.find { |e| e.severity == Logger::DEBUG } + refute_nil debug_entry + assert_equal payload, debug_entry.message.fields["request"] + end + + def test_log_request_abridges_body_over_1kib + recording = RecordingLogger.new + client_stub = ::Gapic::Rest::ClientStub.new endpoint: "google.example.com", + credentials: :dummy_credentials, + logger: recording + payload = "A" * 1025 + expected_prefix = ("A" * 32).unpack1 "H*" + client_stub.send :log_request, "MyMethod", "req-1", 1, payload, {} + + debug_entry = recording.entries.find { |e| e.severity == Logger::DEBUG } + refute_nil debug_entry + assert_equal "<1025 bytes, first 32: #{expected_prefix}>", debug_entry.message.fields["request"] + end + + def test_log_request_abridges_invalid_utf8_body + recording = RecordingLogger.new + client_stub = ::Gapic::Rest::ClientStub.new endpoint: "google.example.com", + credentials: :dummy_credentials, + logger: recording + payload = "\xFF\xFE\x00\x01binary".b + expected_prefix = payload.unpack1 "H*" + client_stub.send :log_request, "MyMethod", "req-1", 1, payload, {} + + debug_entry = recording.entries.find { |e| e.severity == Logger::DEBUG } + refute_nil debug_entry + assert_equal "<10 bytes, first 32: #{expected_prefix}>", debug_entry.message.fields["request"] + end end From 0816ce28834335fab831995835d09437d8dd6f40 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Mon, 7 Sep 2026 00:31:45 +0000 Subject: [PATCH 33/79] test: added logging on failing tests in integration --- gapic-common/integration/README.md | 12 ++++++++++++ gapic-common/integration/integration_helper.rb | 15 ++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/gapic-common/integration/README.md b/gapic-common/integration/README.md index 7282ded..d699fd3 100644 --- a/gapic-common/integration/README.md +++ b/gapic-common/integration/README.md @@ -37,3 +37,15 @@ The `toys test-integration` command (`.toys/test-integration.rb`) manages the `g 5. **Execution & Teardown**: - Sets `ENV["SHOWCASE_ENDPOINT"] = "http://localhost:#{port}"` and runs the Minitest suite (`integration/**/*_test.rb`). - An `ensure` block sends `SIGTERM` to the entire process group (`-TERM`) and reaps the child process so no background showcase processes are leaked. + +## Logging and Diagnostics + +Each integration test captures `DEBUG`-level client and driver logs into an in-memory buffer during execution: + +- **Automatic Failure Dump**: If a test fails or raises an unhandled exception, the captured trace is automatically dumped to `stderr` during `teardown`. +- **Force Log Dump (`SHOWCASE_LOG`)**: Set `SHOWCASE_LOG=1` (or any non-empty value) to dump the captured trace for all executed tests, including passing ones: + +```bash +SHOWCASE_LOG=1 toys test-integration +``` + diff --git a/gapic-common/integration/integration_helper.rb b/gapic-common/integration/integration_helper.rb index 642811a..a388245 100644 --- a/gapic-common/integration/integration_helper.rb +++ b/gapic-common/integration/integration_helper.rb @@ -14,6 +14,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +require "logger" +require "stringio" require "minitest/autorun" require "minitest/focus" require "minitest/mock" @@ -28,6 +30,7 @@ class ShowcaseIntegrationTest < Minitest::Test UPLOAD_PATH = "/resumable/upload/v1beta1/files:upload" + attr_reader :logger attr_reader :progress_records def showcase_endpoint @@ -36,6 +39,15 @@ def showcase_endpoint def setup skip "SHOWCASE_ENDPOINT is not set" if showcase_endpoint.to_s.empty? + @log_output = StringIO.new + @logger = Logger.new @log_output, level: Logger::DEBUG + super + end + + def teardown + if (!passed? || !ENV["SHOWCASE_LOG"].to_s.empty?) && @log_output && !@log_output.string.empty? + warn "\n--- Captured trace for #{name} ---\n#{@log_output.string}--- End trace ---\n" + end super end @@ -48,7 +60,8 @@ def showcase_client_stub Gapic::Rest::ClientStub.new( endpoint: showcase_endpoint, credentials: :dummy_credentials, - raise_faraday_errors: false + raise_faraday_errors: false, + logger: @logger ) end From b92334b10cf78d7118cf779908bf765480859b71 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Mon, 7 Sep 2026 00:48:42 +0000 Subject: [PATCH 34/79] docs: update docs for logging --- gapic-common/design/implementation-guide.md | 95 ++++++++++++++- .../design/reference-implementation.md | 115 ++++++++++++------ 2 files changed, 168 insertions(+), 42 deletions(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index 51cdc9b..1be68ce 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -422,9 +422,92 @@ The total session timeout is resolved in priority order: ## 7. Observability Standards -When injecting optional loggers (`logger: nil`), utilities must use block syntax to prevent string formatting overhead when debug levels are disabled: -```ruby -@logger&.debug do - "ResumableUpload::Driver: Transmitting chunk offset #{@core.state.offset} (effective size: #{@core.state.chunk_size})" -end -``` \ No newline at end of file +### 7.1 Architecture & Separation of Concerns +Because `Rules` is a pure decision engine and `Core` is a side-effect-free state container, protocol decisions are encoded as immutable `Decision` data structures and logged exclusively by the `Driver` via `Driver::UploadLog`. + +Each invocation of `Driver#run` generates a fresh UUIDv4 session identifier (`uploadId`) that is attached to every log entry emitted during that run. Structured log entries are constructed using `Gapic::LoggingConcerns` (`StubLogger` yielding a `LogEntryBuilder` producing `Google::Logging::Message` instances). Machine-readable state and telemetry are stored in `Google::Logging::Message#fields`, allowing log message text to evolve independently without breaking structured queries. + +### 7.2 Log Level & Recipe Mapping +The `Driver` emits structured logs across three severity levels (`INFO`, `DEBUG`, `WARN`). High-frequency per-chunk acknowledgements (`:ack_chunk`) and duplicate cancellation signals (`:ignore_duplicate_cancel`) are suppressed from `INFO` lifecycle logs to avoid log volume bloat on multi-gigabyte uploads. + +| Severity | Category | Trigger / Recipe | Message Summary | +| :--- | :--- | :--- | :--- | +| `INFO` | Lifecycle | `:start_session` | Initiating resumable upload | +| `INFO` | Lifecycle | `:begin_transmission` | Upload session established | +| `INFO` | Lifecycle | `:send_upload_finalize` | Sending final upload chunk | +| `INFO` | Lifecycle | `:send_finalize` | Sending finalize command | +| `INFO` | Lifecycle | `:enter_recovery` | Entering upload recovery | +| `INFO` | Lifecycle | `:retry_recovery` | Retrying upload recovery query | +| `INFO` | Lifecycle | `:realign_from_recovery` | Resuming upload from server offset | +| `INFO` | Lifecycle | `:complete_upload_with_data`, `:complete_upload_finalized` | Resumable upload completed | +| `INFO` | Lifecycle | `:cancel_session` | Canceling resumable upload | +| `INFO` | Lifecycle | `:complete_cancellation` | Resumable upload canceled | +| `DEBUG` | Lifecycle | `:send_chunk` | Sending upload chunk | +| `DEBUG` | Decision | Every `Core#dispatch` transition | `Rules: + -> -> ` | +| `DEBUG` | Wire | Outbound HTTP request (`wire_send`) | `Sending request` | +| `DEBUG` | Wire | Inbound HTTP response (`wire_receive`) | `Received HTTP ` | +| `DEBUG` | Wire | Transport exception (`wire_failure`) | `Request failed: ` | +| `DEBUG` | Buffer | Stream/buffer realignment (`buffer_realign`) | `Buffer realignment: ` | +| `WARN` | Lifecycle | `:fail_with_deadline_exceeded`, `:fail_with_rejected`, `:fail_with_bad_response`, `:fail_with_request_error` | Resumable upload failed | +| `WARN` | Transition | `InvalidTransitionError` (`unmatched_transition`) | Unmatched transition | +| `WARN` | Buffer | Backward server offset rewind on unseekable stream | Server offset rewind on unseekable stream | + +### 7.3 Structured Field Glossary +All log entries emitted by `UploadLog` populate structured fields in `Google::Logging::Message#fields`: + +* **Common Context Fields** (present on all entries): + * `system`: `"gapic-common"` + * `serviceName`: `"ResumableUpload"` + * `clientId`: Object ID of the underlying `Gapic::Rest::ClientStub`. + * `uploadId`: Unique UUIDv4 identifying the specific `Driver#run` execution. +* **Decision & Lifecycle Fields**: + * `fromStatus`: Protocol status symbol prior to event dispatch. + * `toStatus`: Resulting protocol status symbol (`decision.next_state.status`). + * `shape`: Canonical event shape symbol classified by `Rules.shape_of`. + * `recipe`: Transition recipe method symbol executed by `Rules`. + * `offset`: Current server-confirmed byte offset (`Integer`). + * `inFlightLength`: Byte length of the chunk currently in flight (`Integer`). + * `instructions`: Array of abridged instruction hashes emitted by the transition. + * `uploadSize`: Total expected upload size in bytes from `config.upload_size` (on `:start_session`). + * `requestedChunkSize`: Configured chunk size in bytes from `config.chunk_size` (on `:start_session`). + * `effectiveChunkSize`: Negotiated chunk size aligned to server granularity (on `:begin_transmission`). + * `granularity`: Server chunk alignment modulus from `X-Goog-Upload-Chunk-Granularity` (on `:begin_transmission`). + * `uploadUrl`: Abridged session upload URL (on `:begin_transmission` and `:cancel_session`). + * `error`: Terminal exception message string (on `fail_with_*` and `unmatched_transition`). +* **Wire & Transport Fields**: + * `method`: HTTP verb (`:post`, `:put`, etc.). + * `url`: Abridged request target URI. + * `headers`: Redacted HTTP header hash. + * `startAttempt`: Retry attempt counter (`Integer`). + * `command`: Value of `X-Goog-Upload-Command` header. + * `bodySize`: Total byte length of request payload (`Integer`). + * `body`: Abridged payload or error body snippet. + * `status`: HTTP response status code (`Integer`). + * `uploadStatus`: Value of `X-Goog-Upload-Status` response header. + * `sizeReceived`: Parsed integer value of `X-Goog-Upload-Size-Received` response header. + * `kind`: Transport failure classification symbol (`:retries_exhausted`, `:connection_failed`, etc.). +* **Buffer Realignment Fields**: + * `action`: Realignment strategy applied (`:keep_buffer`, `:discard_prefix`, `:seek_backward`, etc.). + * `serverOffset`: Target byte offset reported by the server (`Integer`). + * `currentOffset`: Local buffer start offset before realignment (`Integer`). + +### 7.4 Redaction & Payload Abridgement +To prevent credential leakage and bound total log volume (guaranteeing under 64 KiB of log output even for multi-megabyte uploads), `Driver::Abridge` and `ClientStub` enforce strict sanitization rules before any entry is passed to the logger: + +1. **URL Query Elision (`Abridge.url`)**: Upload session URLs contain capability tokens in their query parameters (e.g., `upload_id`, `sid`). `Abridge.url` parses the URI and replaces every query parameter value with `<...>` (e.g., `https://storage.googleapis.com/upload?upload_id=<...>`). +2. **Header Allowlisting (`Abridge.headers`)**: Only protocol control headers prefixed with `x-goog-upload-` retain their values in log entries (with `x-goog-upload-url` passed through `Abridge.url`). All other request and response headers—including `Authorization` or custom metadata—are replaced with `"<...>"`. Note that Faraday injects `Authorization` headers below the `ClientStub` logging layer; tests verify that bearer tokens never appear in logs. +3. **Binary Payload Abridgement (`Abridge.bytes` & `ClientStub#abridge_request_body`)**: + * In `Driver::Abridge.bytes`, binary payloads of 64 bytes or more are abridged to their first 32 bytes encoded in hexadecimal followed by the total byte size: `"... "`. + * In `Gapic::Rest::ClientStub#log_request`, any request body exceeding 1 KiB (1024 bytes) or containing non-UTF-8 binary data is abridged to `">"`, preventing 8 MiB upload chunks from being dumped into `DEBUG` logs. +4. **Error Body Truncation (`Abridge.error_body`)**: HTTP error response bodies (status $\ge 400$) are forced to UTF-8 encoding with invalid byte sequences scrubbed and truncated to at most 512 characters. + +### 7.5 Enabling & Configuring Logging +Logging is disabled by default (`logger: nil`) and incurs zero allocation overhead when inactive. Users and test harnesses can enable logging via two mechanisms: + +1. **Environment Variable Opt-In (`GOOGLE_SDK_RUBY_LOGGING_GEMS`)**: + Setting the `GOOGLE_SDK_RUBY_LOGGING_GEMS` environment variable activates default `Logger` instances writing to `$stderr` at `DEBUG` level (using `Google::Logging::StructuredFormatter` when running in a Google Cloud environment): + * `GOOGLE_SDK_RUBY_LOGGING_GEMS=all` or `GOOGLE_SDK_RUBY_LOGGING_GEMS=true`: Enables logging across all Google Cloud Ruby SDK components. + * `GOOGLE_SDK_RUBY_LOGGING_GEMS=gapic-common`: Enables logging specifically for `gapic-common` (including `ResumableUpload::Driver` and `ClientStub`). + * `GOOGLE_SDK_RUBY_LOGGING_GEMS=false` or `none`: Explicitly disables SDK logging even if a default logger is configured. +2. **Explicit Logger Injection**: + Pass any Ruby `::Logger`-compatible instance directly to `Driver.new(client_stub: stub, config: config, logger: my_logger)` or configure it on the parent service client config. \ No newline at end of file diff --git a/gapic-common/design/reference-implementation.md b/gapic-common/design/reference-implementation.md index f709511..1c7086b 100644 --- a/gapic-common/design/reference-implementation.md +++ b/gapic-common/design/reference-implementation.md @@ -428,14 +428,23 @@ module Gapic # @param client_stub [Gapic::Rest::ClientStub] # @param config [CompleteUploadConfig] - # @param logger [Logger, nil] Optional logger - def initialize(client_stub:, config:, logger: nil) + # @param core [Core, nil] Optional Core state machine (defaults to new Core with config) + # @param logger [Logger, nil] Optional logger override + def initialize(client_stub:, config:, core: nil, logger: nil) @client_stub = client_stub @config = config - @logger = logger - @core = Core.new(config) + @core = core || Core.new(config) @buffer = "".b @buffer_start_offset = 0 + + endpoint = client_stub.respond_to?(:endpoint) ? client_stub.endpoint : nil + setup_logging logger: logger || (client_stub.respond_to?(:logger) ? client_stub.logger : nil), + system_name: "gapic-common", + service: "ResumableUpload", + endpoint: endpoint, + client_id: client_stub.object_id + @upload_log = UploadLog.new(stub_logger, upload_id: "unstarted") + @start_retry_policy = config.start_retry_policy || self.class.default_start_retry_policy @control_plane_retry_policy = config.control_plane_retry_policy || self.class.default_control_plane_retry_policy @data_plane_retry_policy = config.data_plane_retry_policy || self.class.default_data_plane_retry_policy @@ -499,46 +508,61 @@ module Gapic # # @return [String, Object] Final response body def run + @upload_log = UploadLog.new(stub_logger, upload_id: LoggingConcerns.random_uuid4) @deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + resolve_timeout - pending_event = Event::StartUpload + pending_event = Event::StartUpload.new loop do - instructions = @core.dispatch(pending_event) + instructions = dispatch_event(pending_event) pending_event = nil if deadline_exceeded? && !terminal_instructions?(instructions) - instructions = @core.dispatch(Event::GlobalDeadlineExceeded) + instructions = dispatch_event(Event::GlobalDeadlineExceeded.new) end instructions.each do |instruction| - case instruction - when Instruction::NotifyProgress - execute_notify_progress(instruction) - when Instruction::RealignBuffer - execute_realign_buffer(instruction) - when Instruction::FillBuffer - pending_event = execute_fill_buffer(instruction) - when Instruction::SendStart - pending_event = execute_send_start(instruction) - when Instruction::SendChunk - pending_event = execute_send_chunk(instruction) - when Instruction::SendFinalize - pending_event = execute_send_finalize(instruction) - when Instruction::SendQuery - pending_event = execute_send_query(instruction) - when Instruction::SendCancel - pending_event = execute_send_cancel(instruction) - when Instruction::TerminateSuccess - return instruction.response.respond_to?(:body) ? instruction.response.body : instruction.response - when Instruction::TerminateFailure - raise instruction.error - end + result = dispatch_instruction(instruction) + pending_event = result if pending_event_type?(result) + return result if instruction.is_a?(Instruction::TerminateSuccess) end end end private + def dispatch_event(event) + instructions = begin + @core.dispatch(event) + rescue InvalidTransitionError => e + @upload_log.unmatched_transition(@core.state, event, e) + raise + end + @upload_log.decision(@core.last_decision) + @upload_log.lifecycle(@core.last_decision, @config) + instructions + end + + def pending_event_type?(obj) + obj.is_a?(Event::ChunkRead) || obj.is_a?(Event::HttpResponse) || + obj.is_a?(Event::RequestFailed) || obj.is_a?(Event::GlobalDeadlineExceeded) + end + + def dispatch_instruction(instruction) + case instruction + when Instruction::NotifyProgress then execute_notify_progress(instruction) + when Instruction::RealignBuffer then execute_realign_buffer(instruction) + when Instruction::FillBuffer then execute_fill_buffer(instruction) + when Instruction::SendStart then execute_send_start(instruction) + when Instruction::SendChunk then execute_send_chunk(instruction) + when Instruction::SendFinalize then execute_send_finalize(instruction) + when Instruction::SendQuery then execute_send_query(instruction) + when Instruction::SendCancel then execute_send_cancel(instruction) + when Instruction::TerminateSuccess + instruction.response.respond_to?(:body) ? instruction.response.body : instruction.response + when Instruction::TerminateFailure then raise instruction.error + end + end + def resolve_timeout return @config.timeout if @config.timeout&.positive? @@ -566,6 +590,7 @@ module Gapic # Synchronous side-effect: adjusts in-memory buffer window and stream def execute_realign_buffer(instruction) + # Logs buffer_realign via @upload_log (WARN on unseekable rewind, DEBUG otherwise) # Implements Section 2.5 buffer alignment Cases 1, 2, and 3 end @@ -578,20 +603,38 @@ module Gapic # Network operation: wraps start HTTP request in start_retry_policy # @return [Event::HttpResponse, Event::RequestFailed] def execute_send_start(instruction) - policy = @start_retry_policy - # Executes POST initiation request via @client_stub with policy in a retry loop. + # Executes POST initiation request via make_post_request(..., method_name: "ResumableUpload.start"). # Retries missing X-Goog-Upload-Status header across any response code, including 200 OK. - # Returns Event::HttpResponse for any completed HTTP response (including 4xx/5xx). - # Returns Event::RequestFailed(kind: :retries_exhausted, ...) on retry exhaustion. + # Logs @upload_log.wire_failure on retry exhaustion. end # Network operation: wraps HTTP request in data_plane_retry_policy # @return [Event::HttpResponse, Event::RequestFailed] def execute_send_chunk(instruction) # Slices body from @buffer[instruction.offset - @buffer_start_offset, instruction.length] - # Executes POST request via @client_stub with @data_plane_retry_policy - # Returns Event::HttpResponse for any completed HTTP response (including 4xx/5xx). - # Returns Event::RequestFailed(kind:, message:, source_error:) on unhandled transport error or retry exhaustion. + # Executes POST request via make_post_request(..., method_name: "ResumableUpload.upload") + end + + def make_post_request(url, headers:, body:, retry_policy:, method_name: nil, start_attempt: 1) + options = { metadata: headers, retry_policy: retry_policy } + @upload_log.wire_send( + method: "POST", url: url, headers: headers, + start_attempt: start_attempt, body_size: body.to_s.bytesize, body: body + ) + response = @client_stub.make_post_request( + uri: url, body: body, params: {}, options: options, method_name: method_name + ) + event = Event::HttpResponse.new(status: response.status, headers: response.headers || {}, body: response.body) + @upload_log.wire_receive(event) + event + rescue StandardError => e + event = rescue_request_error(e) + if event.is_a?(Event::HttpResponse) + @upload_log.wire_receive(event) + else + @upload_log.wire_failure(event) + end + event end end end From ed2ad7fc3bc7200f46424317c0298ca688016637 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Mon, 7 Sep 2026 01:09:12 +0000 Subject: [PATCH 35/79] docs: sync docs --- gapic-common/design/implementation-guide.md | 20 ++++--- gapic-common/design/test-plan.md | 65 +++++++++++++++++++++ 2 files changed, 77 insertions(+), 8 deletions(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index 1be68ce..d01a8cc 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -473,26 +473,30 @@ All log entries emitted by `UploadLog` populate structured fields in `Google::Lo * `effectiveChunkSize`: Negotiated chunk size aligned to server granularity (on `:begin_transmission`). * `granularity`: Server chunk alignment modulus from `X-Goog-Upload-Chunk-Granularity` (on `:begin_transmission`). * `uploadUrl`: Abridged session upload URL (on `:begin_transmission` and `:cancel_session`). - * `error`: Terminal exception message string (on `fail_with_*` and `unmatched_transition`). + * `status`: Current protocol status symbol (on `unmatched_transition`). + * `error`: Exception message string (on `fail_with_*` and `unmatched_transition`). * **Wire & Transport Fields**: - * `method`: HTTP verb (`:post`, `:put`, etc.). + * `method`: Always the string `"POST"`. * `url`: Abridged request target URI. * `headers`: Redacted HTTP header hash. * `startAttempt`: Retry attempt counter (`Integer`). - * `command`: Value of `X-Goog-Upload-Command` header. + * `command`: Value of `X-Goog-Upload-Command` request header. + * `offset`: Parsed integer value of `X-Goog-Upload-Offset` request header (`wire_send`). * `bodySize`: Total byte length of request payload (`Integer`). * `body`: Abridged payload or error body snippet. - * `status`: HTTP response status code (`Integer`). + * `status`: HTTP response status code (`Integer`, on `wire_receive`). * `uploadStatus`: Value of `X-Goog-Upload-Status` response header. * `sizeReceived`: Parsed integer value of `X-Goog-Upload-Size-Received` response header. - * `kind`: Transport failure classification symbol (`:retries_exhausted`, `:connection_failed`, etc.). + * `granularity`: Parsed integer value of `X-Goog-Upload-Chunk-Granularity` response header (`wire_receive`). + * `kind`: Transport failure classification symbol (`:timeout`, `:connection_failed`, `:retries_exhausted`). + * `error`: Exception message string (`wire_failure`). * **Buffer Realignment Fields**: - * `action`: Realignment strategy applied (`:keep_buffer`, `:discard_prefix`, `:seek_backward`, etc.). + * `action`: Realignment strategy string (`"within_buffer"`, `"rewind"`, or `"fast_forward"`). * `serverOffset`: Target byte offset reported by the server (`Integer`). * `currentOffset`: Local buffer start offset before realignment (`Integer`). ### 7.4 Redaction & Payload Abridgement -To prevent credential leakage and bound total log volume (guaranteeing under 64 KiB of log output even for multi-megabyte uploads), `Driver::Abridge` and `ClientStub` enforce strict sanitization rules before any entry is passed to the logger: +To prevent credential leakage and ensure log volume is proportional to the number of requests and independent of payload size, `Driver::Abridge` and `ClientStub` enforce strict sanitization rules before any entry is passed to the logger: 1. **URL Query Elision (`Abridge.url`)**: Upload session URLs contain capability tokens in their query parameters (e.g., `upload_id`, `sid`). `Abridge.url` parses the URI and replaces every query parameter value with `<...>` (e.g., `https://storage.googleapis.com/upload?upload_id=<...>`). 2. **Header Allowlisting (`Abridge.headers`)**: Only protocol control headers prefixed with `x-goog-upload-` retain their values in log entries (with `x-goog-upload-url` passed through `Abridge.url`). All other request and response headers—including `Authorization` or custom metadata—are replaced with `"<...>"`. Note that Faraday injects `Authorization` headers below the `ClientStub` logging layer; tests verify that bearer tokens never appear in logs. @@ -502,7 +506,7 @@ To prevent credential leakage and bound total log volume (guaranteeing under 64 4. **Error Body Truncation (`Abridge.error_body`)**: HTTP error response bodies (status $\ge 400$) are forced to UTF-8 encoding with invalid byte sequences scrubbed and truncated to at most 512 characters. ### 7.5 Enabling & Configuring Logging -Logging is disabled by default (`logger: nil`) and incurs zero allocation overhead when inactive. Users and test harnesses can enable logging via two mechanisms: +Logging is disabled by default (`logger: nil`) and incurs negligible allocation overhead when inactive. Users and test harnesses can enable logging via two mechanisms: 1. **Environment Variable Opt-In (`GOOGLE_SDK_RUBY_LOGGING_GEMS`)**: Setting the `GOOGLE_SDK_RUBY_LOGGING_GEMS` environment variable activates default `Logger` instances writing to `$stderr` at `DEBUG` level (using `Google::Logging::StructuredFormatter` when running in a Google Cloud environment): diff --git a/gapic-common/design/test-plan.md b/gapic-common/design/test-plan.md index fe2fbc6..eb6b81f 100644 --- a/gapic-common/design/test-plan.md +++ b/gapic-common/design/test-plan.md @@ -20,6 +20,9 @@ flowchart TD DT["driver_test.rb
(Driver Upload Execution Loop)"] DR["driver_retry_test.rb
(Driver Initiation & Query Retries)"] DC["driver_config_test.rb
(Driver Configuration & Deadlines)"] + AB["driver/abridge_test.rb
(Payload & Header Redaction)"] + UL["driver/upload_log_test.rb
(UploadLog Structured Entries)"] + DL["driver_logging_test.rb
(Driver Logging & Corpus Invariants)"] end subgraph SUT["Systems Under Test"] @@ -32,6 +35,7 @@ flowchart TD DriverRun["Driver#run
Driver#execute_send_chunk"] DriverRetry["Driver#execute_send_start
Driver#execute_send_query"] DriverConfig["Driver#resolve_timeout
Driver#deadline_exceeded?"] + DriverLog["Driver::Abridge
Driver::UploadLog
Driver#run Logging"] end RC --> RulesClassify @@ -45,6 +49,9 @@ flowchart TD DT --> DriverRun DR --> DriverRetry DC --> DriverConfig + AB --> DriverLog + UL --> DriverLog + DL --> DriverLog ``` --- @@ -57,6 +64,8 @@ flowchart TD | `UnseekableStream` | `driver_buffer_test.rb` | Wraps `StringIO` with `#read` but explicitly omits `#seek` (`respond_to?(:seek)` is `false`). | | `FailingClientStub` | `driver_error_mapping_test.rb` | Integration fake client stub configured with `@error_to_raise` to verify exception rescue in `Driver#make_post_request`. | | `ScriptedClientStub` | `driver_progress_test.rb` / `driver_test.rb` | Yields a deterministic sequence of HTTP response structs and records dispatched requests. | +| `RecordingLogger` | `test_helper.rb` | Real `Logger` at `DEBUG` level whose formatter appends every emitted `Google::Logging::Message` and severity to an in-memory array. Ensures log blocks execute completely rather than being stubbed out (catching any exceptions inside log blocks that `StubLogger#log` would otherwise rescue). | +| `FakeStub` | `driver_logging_test.rb` | Returns scripted HTTP responses and records `method_name` arguments passed to `make_post_request`. | --- @@ -220,3 +229,59 @@ flowchart TD * **Default base timeout when size is nil**: Unspecified `upload_size` defaults to `BASE_TIMEOUT`. * **Deadline expiration enforcement**: Monotonic clock exceeding `@deadline` during `Driver#run` triggers `Event::GlobalDeadlineExceeded` and raises `Gapic::Common::DeadlineExceededError`. +--- + +### 3.10 Payload & Header Abridgement (`driver/abridge_test.rb`) + +* **Binary payload hex encoding & abridgement (`Driver::Abridge.bytes`)**: + * `nil` returns `nil`; short payloads (< 64 bytes, including 63-byte boundary) are full-hex-encoded via `unpack1("H*")`. + * Payloads $\ge 64$ bytes are abridged to the first 32 bytes in hex followed by total byte size (`"<32-byte hex>... <100 bytes>"`). +* **Error body sanitization (`Driver::Abridge.error_body`)**: + * Truncates error response strings to at most 512 bytes. + * Forces UTF-8 encoding and scrubs invalid byte sequences (`\xFF\xFE`) so malformed error payloads never raise encoding errors during log serialization. +* **URL query parameter elision (`Driver::Abridge.url`)**: + * Parses URIs and replaces every query parameter value with `<...>` (`uploadType=<...>&sid=<...>`) so capability session IDs never leak into logs. +* **Header allowlisting (`Driver::Abridge.headers`)**: + * Preserves values for headers prefixed with `x-goog-upload-` (case-insensitive) and abridges URLs in `x-goog-upload-url`. + * Redacts all other headers (`Authorization`, `Content-Type`, custom metadata) to `"<...>"`. +* **Instruction summarization (`Driver::Abridge.instructions`)**: + * Summarizes emitted instruction structs (`SendStart`, `SendChunk`) into hashes with abridged URLs and metadata while omitting raw request body payloads. + +--- + +### 3.11 Structured Upload Log Helper (`driver/upload_log_test.rb`) + +* **Bijective recipe coverage (`test_lifecycle_table_matches_rules_recipes`)**: + * Verifies that `UploadLog::LIFECYCLE.keys + UploadLog::SILENT_RECIPES` equals `Rules::RECIPES` with zero unmapped recipes and zero overlap between active and silent lists. +* **State machine decision logging (`UploadLog#decision`)**: + * Emits `DEBUG` entries containing `uploadId`, `fromStatus`, `shape`, `recipe`, `toStatus`, `offset`, `inFlightLength`, and abridged `instructions`. +* **Lifecycle milestone logging (`UploadLog#lifecycle`)**: + * Emits `INFO` entries for session milestones (`:start_session` with `uploadSize` and `requestedChunkSize`), `DEBUG` for per-chunk transmission (`:send_chunk`), and `WARN` for terminal failures (`:fail_with_rejected` with `error` field). + * Asserts silent recipes (`:ignore_duplicate_cancel`, `:ack_chunk`) emit no lifecycle log entries. +* **Wire trace logging (`wire_send`, `wire_receive`, `wire_failure`)**: + * `wire_send` logs `DEBUG` with HTTP verb, abridged URL, redacted headers, `startAttempt`, `bodySize`, and hex-encoded/abridged body. + * `wire_receive` logs `DEBUG` with HTTP status code, parsed `uploadStatus`, `sizeReceived`, `granularity`, and hex body. + * `wire_failure` logs `DEBUG` with failure classification `kind` and exception message. +* **Buffer realignment logging (`UploadLog#buffer_realign`)**: + * Logs `DEBUG` on normal realignment and additionally emits a `WARN` entry (`"Server offset rewind on unseekable stream"`) with `action`, `serverOffset`, and `currentOffset` when rewinding an unseekable stream. +* **Unmatched state transition logging (`UploadLog#unmatched_transition`)**: + * Emits a `WARN` entry capturing current `status`, event `shape`, and exception `error` message before `InvalidTransitionError` propagates. + +--- + +### 3.12 End-to-End Driver Logging & Corpus Invariants (`driver_logging_test.rb`) + +* **Shared session correlation & RPC method names (`test_all_entries_share_upload_id_and_pass_method_names`)**: + * Verifies every log entry emitted during `Driver#run` shares a single non-nil UUIDv4 `uploadId`. + * Verifies `Driver` passes explicit `method_name` strings (`"ResumableUpload.start"`, `"ResumableUpload.upload"`) to `ClientStub#make_post_request`. +* **Multi-chunk upload lifecycle (`test_multi_chunk_upload_logs_lifecycle_entries`)**: + * Confirms multi-chunk upload emits `INFO` lifecycle entries for `start_session`, `begin_transmission`, and completion while suppressing per-chunk `ack_chunk` at `INFO`. +* **Protocol recovery logging (`test_recovery_scenario_logs_enter_recovery_and_realign`)**: + * Simulates HTTP 503 during chunk upload followed by recovery query; asserts `INFO` logs include both `enter_recovery` and `realign_from_recovery`. +* **Terminal failure & unmatched transition logging (`test_fatal_failure_logs_warn_with_fail_with_recipe`, `test_unmatched_transition_logs_warn_and_reraises`)**: + * Confirms fatal HTTP 403 rejection emits `WARN` with a `fail_with_*` recipe, and unexpected Core state transitions emit `WARN` prior to raising `InvalidTransitionError`. +* **End-to-end secret redaction (`test_full_log_corpus_redacts_secrets`)**: + * Executes a 16 MiB two-chunk upload containing a sentinel secret (`"SECRET-123456"`) in the stream payload, session query URL (`sid=SECRET-123456`), initiation query token (`token=SECRET-123456`), and `Authorization: Bearer SECRET-123456` header. + * Asserts the sentinel string is completely absent across the entire serialized log corpus. +* **Bounded log corpus size (`test_full_log_corpus_size_under_64kib`)**: + * Asserts that the total serialized byte size of all log entries emitted across a 16 MiB multi-chunk upload run is strictly under 64 KiB (65,536 bytes). From 9318e679408efe881330cd9fa5748fac635539dc Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Mon, 7 Sep 2026 01:25:03 +0000 Subject: [PATCH 36/79] tests: case 2 of golden path, separate doc for integration testing --- gapic-common/design/integration-test-plan.md | 83 +++++++++++++++++++ gapic-common/design/test-plan.md | 4 +- .../resumable_upload/golden_path_test.rb | 23 +++++ 3 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 gapic-common/design/integration-test-plan.md diff --git a/gapic-common/design/integration-test-plan.md b/gapic-common/design/integration-test-plan.md new file mode 100644 index 0000000..64b87ae --- /dev/null +++ b/gapic-common/design/integration-test-plan.md @@ -0,0 +1,83 @@ +# Resumable Upload Integration Test Plan + +This document outlines the integration test architecture and test suites for the Resumable Upload protocol implementation in `gapic-common`. Unlike the unit test suite ([test-plan.md](./test-plan.md)), which isolates protocol state transitions and driver components against test doubles, the integration test suite exercises the full stack end-to-end over real HTTP/REST connections against a live `gapic-showcase` server. + +--- + +## 1. Integration Test Architecture Overview + +```mermaid +flowchart TD + subgraph Runner["Test Runner (.toys/test-integration.rb)"] + Toys["toys test-integration"] + Lifecycle["Showcase Lifecycle Manager
(Verify version >= 0.43, allocate ports, spawn & health-check)"] + end + + subgraph Harness["Test Harness (integration/integration_helper.rb)"] + BaseClass["ShowcaseIntegrationTest
(Minitest::Test)"] + PayloadGen["payload(size)
Deterministic binary stream"] + TraceLog["StringIO Logger
(Emits debug wire traces on test failure)"] + end + + subgraph SUT["System Under Test"] + Driver["Gapic::Rest::ResumableUpload::Driver"] + Stub["Gapic::Rest::ClientStub
(raise_faraday_errors: false)"] + end + + subgraph Server["External Server"] + Showcase["gapic-showcase
(/resumable/upload/v1beta1/files:upload)"] + end + + Toys --> Lifecycle + Lifecycle --> Showcase + Toys --> BaseClass + BaseClass --> PayloadGen + BaseClass --> TraceLog + BaseClass --> Driver + Driver --> Stub + Stub <-->|"HTTP POST / PUT (REST)"| Showcase +``` + +### 1.1 Execution & Server Lifecycle (`.toys/test-integration.rb`) +* **External Endpoint Override**: If `SHOWCASE_ENDPOINT` is set in the environment, the runner connects directly to that address without spawning a local server. +* **Automatic Server Provisioning**: When `SHOWCASE_ENDPOINT` is unset, the runner locates the `gapic-showcase` binary on `PATH` (or via `SHOWCASE_BIN`), verifies that its version is at least `0.43`, allocates ephemeral ports, and spawns `gapic-showcase run`. +* **Health Check & Teardown**: Polls the TCP socket with a 10-second deadline before invoking Minitest, and guarantees process cleanup (`SIGTERM` + `waitpid`) in an `ensure` block. + +### 1.2 Test Harness (`integration/integration_helper.rb`) +* **`ShowcaseIntegrationTest`**: Base class providing helper methods for test configuration: + * `showcase_client_stub`: Instantiates a real `Gapic::Rest::ClientStub` targeting `SHOWCASE_ENDPOINT` with `raise_faraday_errors: false` and an attached `DEBUG` logger. + * `build_config`: Creates a `CompleteUploadConfig` targeting `/resumable/upload/v1beta1/files:upload` with a default `on_progress` callback that appends every `Progress` struct to `@progress_records`. + * `payload(size)`: Generates deterministic binary strings of arbitrary byte length for stream uploads. + * **Diagnostic Trace Capture**: Buffers `DEBUG`-level driver logs in memory during each test run and dumps the full trace to `stderr` only if a test fails (or when `SHOWCASE_LOG` is set). + +--- + +## 2. Detailed Test Suites & Cases + +### 2.1 Golden Path Suite (`integration/resumable_upload/golden_path_test.rb`) + +Tests standard, uninterrupted resumable upload workflows against `gapic-showcase`. + +#### Case 1. Multi-chunk upload with known size (`test_multi_chunk_known_size`) +* **Scenario**: Uploads a 1.5 MB (`1_500_000` bytes) stream with an explicit `upload_size: 1_500_000` and `chunk_size: 524_288` (512 KiB). +* **Protocol Flow**: + 1. `start` command initiates the session with `X-Goog-Upload-Header-Content-Length: 1500000`. + 2. Chunk 1 transmits bytes `0..524287` (`upload`). + 3. Chunk 2 transmits bytes `524288..1048575` (`upload`). + 4. Chunk 3 transmits remaining bytes `1048576..1499999` with `upload, finalize`. +* **Assertions**: + * Returned JSON body parses cleanly and reports `"size" == 1_500_000`. + * `progress_records` contains exactly 3 `Progress` notifications matching cumulative byte offsets: + * `Progress(bytes_uploaded: 524_288, total_bytes: 1_500_000)` + * `Progress(bytes_uploaded: 1_048_576, total_bytes: 1_500_000)` + * `Progress(bytes_uploaded: 1_500_000, total_bytes: 1_500_000)` + +#### Case 2. Default chunk size on small upload (`test_small_upload_default_chunk_size`) +* **Scenario**: Uploads a ~100 KB (`100_000` bytes) payload with `upload_size: 100_000` and no `chunk_size` specified. +* **Protocol Flow**: + 1. `start` command initiates the session; chunk size defaults to 8 MiB (`8_388_608` bytes). + 2. The entire 100,000-byte payload fits within a single buffer read and is transmitted in one `upload, finalize` request. +* **Assertions**: + * Returned JSON body reports `"size" == 100_000`. + * `progress_records` contains exactly 1 `Progress` notification: + * `Progress(bytes_uploaded: 100_000, total_bytes: 100_000)` diff --git a/gapic-common/design/test-plan.md b/gapic-common/design/test-plan.md index eb6b81f..3618615 100644 --- a/gapic-common/design/test-plan.md +++ b/gapic-common/design/test-plan.md @@ -1,6 +1,6 @@ -# Resumable Upload Test Plan +# Resumable Upload Unit Test Plan -This document outlines the complete unit and integration test plan for the Resumable Upload protocol implementation in `gapic-common`. It details all test suites, systems under test (SUT), test doubles, test cases, and behavioral assertions added across the protocol layers. +This document outlines the complete unit test plan for the Resumable Upload protocol implementation in `gapic-common`. It details all unit test suites, systems under test (SUT), test doubles, test cases, and behavioral assertions across the protocol layers. For end-to-end integration tests against `gapic-showcase`, see [integration-test-plan.md](./integration-test-plan.md). --- diff --git a/gapic-common/integration/resumable_upload/golden_path_test.rb b/gapic-common/integration/resumable_upload/golden_path_test.rb index a5229d3..49cfbb8 100644 --- a/gapic-common/integration/resumable_upload/golden_path_test.rb +++ b/gapic-common/integration/resumable_upload/golden_path_test.rb @@ -48,4 +48,27 @@ def test_multi_chunk_known_size Gapic::Rest::ResumableUpload::Progress.new(bytes_uploaded: 1_500_000, total_bytes: 1_500_000) ], progress_records end + + def test_small_upload_default_chunk_size + size = 100_000 + stream = StringIO.new payload(size) + + config = build_config( + stream: stream, + upload_size: size + ) + + driver = Gapic::Rest::ResumableUpload::Driver.new( + client_stub: showcase_client_stub, + config: config + ) + + result = driver.run + parsed = JSON.parse result + + assert_equal size, parsed["size"] + assert_equal [ + Gapic::Rest::ResumableUpload::Progress.new(bytes_uploaded: size, total_bytes: size) + ], progress_records + end end From 70f16225c7d8302045645a0799c1406ff86e4fa3 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Mon, 7 Sep 2026 01:44:15 +0000 Subject: [PATCH 37/79] test: case 3 of golden path --- gapic-common/design/integration-test-plan.md | 20 ++++++++++++++ .../integration/integration_helper.rb | 17 ++++++++++++ .../resumable_upload/golden_path_test.rb | 26 +++++++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/gapic-common/design/integration-test-plan.md b/gapic-common/design/integration-test-plan.md index 64b87ae..c494a0a 100644 --- a/gapic-common/design/integration-test-plan.md +++ b/gapic-common/design/integration-test-plan.md @@ -48,6 +48,7 @@ flowchart TD * `showcase_client_stub`: Instantiates a real `Gapic::Rest::ClientStub` targeting `SHOWCASE_ENDPOINT` with `raise_faraday_errors: false` and an attached `DEBUG` logger. * `build_config`: Creates a `CompleteUploadConfig` targeting `/resumable/upload/v1beta1/files:upload` with a default `on_progress` callback that appends every `Progress` struct to `@progress_records`. * `payload(size)`: Generates deterministic binary strings of arbitrary byte length for stream uploads. + * `UnseekableStream`: Stream wrapper around `StringIO` that exposes `#read` and `#pos` while omitting `#seek` (`respond_to?(:seek)` is `false`). * **Diagnostic Trace Capture**: Buffers `DEBUG`-level driver logs in memory during each test run and dumps the full trace to `stderr` only if a test fails (or when `SHOWCASE_LOG` is set). --- @@ -81,3 +82,22 @@ Tests standard, uninterrupted resumable upload workflows against `gapic-showcase * Returned JSON body reports `"size" == 100_000`. * `progress_records` contains exactly 1 `Progress` notification: * `Progress(bytes_uploaded: 100_000, total_bytes: 100_000)` + +#### Case 3. Standalone finalize on unseekable stream (`test_standalone_finalize_unseekable_stream`) +* **Scenario**: Uploads a `786_432`-byte payload (`3 * 262_144` bytes) wrapped in an `UnseekableStream`, with `chunk_size: 262_144` (256 KiB) and `upload_size` omitted (`nil`). +* **Coverage**: + * Unknown total upload size on `start` (omitted `X-Goog-Upload-Header-Content-Length`). + * End-of-exact-boundary stream reading path (payload is an exact multiple of `chunk_size`, so EOF is not detected until the subsequent buffer fill). + * Standalone `SendFinalize` instruction (`upload_command: "finalize"` with empty body). +* **Protocol Flow**: + 1. `start` command initiates the session without a total content length header. + 2. Chunk 1 transmits bytes `0..262143` (`upload`). + 3. Chunk 2 transmits bytes `262144..524287` (`upload`). + 4. Chunk 3 transmits bytes `524288..786431` (`upload`). + 5. Next buffer read returns 0 bytes at EOF (`:chunk_read_eof_empty`), emitting `SendFinalize` to send a standalone `finalize` request at offset `786432`. +* **Assertions**: + * Returned JSON body reports `"size" == 786_432`. + * `progress_records` contains 3 `Progress` notifications with `total_bytes: nil`: + * `Progress(bytes_uploaded: 262_144, total_bytes: nil)` + * `Progress(bytes_uploaded: 524_288, total_bytes: nil)` + * `Progress(bytes_uploaded: 786_432, total_bytes: nil)` diff --git a/gapic-common/integration/integration_helper.rb b/gapic-common/integration/integration_helper.rb index a388245..cf031de 100644 --- a/gapic-common/integration/integration_helper.rb +++ b/gapic-common/integration/integration_helper.rb @@ -30,6 +30,23 @@ class ShowcaseIntegrationTest < Minitest::Test UPLOAD_PATH = "/resumable/upload/v1beta1/files:upload" + ## + # Stream double that intentionally does not implement #seek. + # + class UnseekableStream + def initialize data + @io = StringIO.new data + end + + def read length = nil + @io.read length + end + + def pos + @io.pos + end + end + attr_reader :logger attr_reader :progress_records diff --git a/gapic-common/integration/resumable_upload/golden_path_test.rb b/gapic-common/integration/resumable_upload/golden_path_test.rb index 49cfbb8..a0a5d5f 100644 --- a/gapic-common/integration/resumable_upload/golden_path_test.rb +++ b/gapic-common/integration/resumable_upload/golden_path_test.rb @@ -71,4 +71,30 @@ def test_small_upload_default_chunk_size Gapic::Rest::ResumableUpload::Progress.new(bytes_uploaded: size, total_bytes: size) ], progress_records end + + def test_standalone_finalize_unseekable_stream + chunk_size = 262_144 + size = 3 * chunk_size + stream = UnseekableStream.new payload(size) + + config = build_config( + stream: stream, + chunk_size: chunk_size + ) + + driver = Gapic::Rest::ResumableUpload::Driver.new( + client_stub: showcase_client_stub, + config: config + ) + + result = driver.run + parsed = JSON.parse result + + assert_equal size, parsed["size"] + assert_equal [ + Gapic::Rest::ResumableUpload::Progress.new(bytes_uploaded: 262_144, total_bytes: nil), + Gapic::Rest::ResumableUpload::Progress.new(bytes_uploaded: 524_288, total_bytes: nil), + Gapic::Rest::ResumableUpload::Progress.new(bytes_uploaded: 786_432, total_bytes: nil) + ], progress_records + end end From 7864a6beec65a5bd2b7c1101358e9cd2bfe5ab78 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Mon, 7 Sep 2026 18:39:36 +0000 Subject: [PATCH 38/79] feat: add phase to progress notification --- gapic-common/design/implementation-guide.md | 45 ++++++---- gapic-common/design/integration-test-plan.md | 30 ++++--- .../design/reference-implementation.md | 55 +++++++++--- .../resumable_upload/golden_path_test.rb | 24 ++++-- .../gapic/rest/resumable_upload/data_types.rb | 18 +++- .../rest/resumable_upload/driver/abridge.rb | 1 + .../lib/gapic/rest/resumable_upload/rules.rb | 86 ++++++++++++++++--- .../gapic/rest/resumable_upload/core_test.rb | 14 +-- .../rest/resumable_upload/data_types_test.rb | 22 +++-- .../resumable_upload/driver/abridge_test.rb | 8 +- .../driver/upload_log_test.rb | 5 +- .../resumable_upload/driver_progress_test.rb | 12 +-- .../rest/resumable_upload/driver_test.rb | 27 +++++- .../resumable_upload/rules_decide_test.rb | 66 ++++++++++++-- .../resumable_upload/rules_recovery_test.rb | 40 ++++++--- .../gapic/rest/resumable_upload/rules_test.rb | 56 +++++++----- 16 files changed, 383 insertions(+), 126 deletions(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index d01a8cc..e9c564b 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -56,14 +56,23 @@ module Gapic ) Progress = Data.define( - :bytes_uploaded, # [Integer] Cumulative bytes acknowledged by the server + :phase, # [Symbol] Upload lifecycle phase, one of Progress::PHASES + :bytes_uploaded, # [Integer] Cumulative bytes acknowledged by the server (may decrease on recovery rewind) :total_bytes # [Integer, nil] Total upload size in bytes if known - ) + ) do + self::PHASES = %i[initiating uploading recovering finalizing cancelling completed].freeze + end end end end ``` +**Progress Notification Contract (`on_progress`):** +* `on_progress` fires whenever upload status or server-confirmed byte offset changes. Sequential callbacks may report the same `bytes_uploaded`. +* `bytes_uploaded` represents the server-confirmed offset and is **not guaranteed to be monotonic** — a server rewind during recovery can decrease this value. +* Terminal failures and completed cancellations do not emit `Progress` notifications; however, entering the `:cancelling` phase does. +* Public phases (`Progress::PHASES`): `:initiating`, `:uploading`, `:recovering`, `:finalizing`, `:cancelling`, `:completed`. + ### 2.2 Protocol State (`State`) & Decisions (`Decision`) ```ruby module Gapic @@ -213,39 +222,39 @@ Full implementation: [reference-implementation.md#3-driver-class](reference-impl | From State | Event Shape | Event & Input Payload | State Mutations | To State | Emitted Instructions & Parameters | | :--- | :--- | :--- | :--- | :--- | :--- | -| **`Initializing`** | `:start_upload` | `Event::StartUpload` | `status = :starting` | `Starting` | `Instruction::SendStart.new(url: config.initial_url, headers: config.initial_headers, body: config.initial_body)` | -| **`Starting`** | `:response_active` | `Event::HttpResponse(200, headers, _)` with `Status: active` | `upload_url = headers['X-Goog-Upload-URL']`
`chunk_granularity = headers['...-Granularity']&.to_i`
`chunk_size = resolve(config, chunk_granularity)`
`offset = 0`
`status = :transmission_reading` | `Transmission \| Reading from stream` | `Instruction::FillBuffer.new(target_bytesize: state.chunk_size)` | +| **`Initializing`** | `:start_upload` | `Event::StartUpload` | `status = :starting` | `Starting` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :initiating, bytes_uploaded: 0, total_bytes: config.upload_size))`
`Instruction::SendStart.new(url: config.initial_url, headers: config.initial_headers, body: config.initial_body)` | +| **`Starting`** | `:response_active` | `Event::HttpResponse(200, headers, _)` with `Status: active` | `upload_url = headers['X-Goog-Upload-URL']`
`chunk_granularity = headers['...-Granularity']&.to_i`
`chunk_size = resolve(config, chunk_granularity)`
`offset = 0`
`status = :transmission_reading` | `Transmission \| Reading from stream` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :uploading, bytes_uploaded: 0, total_bytes: config.upload_size))`
`Instruction::FillBuffer.new(target_bytesize: state.chunk_size)` | | **`Starting`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, _)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | | **`Starting`** | `:response_cat2` / `:response_fatal_bad_response` | `Event::HttpResponse` (Non-200; see Section 6.1) | `last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | | **`Starting`** | `:request_retries_exhausted` / `:request_connection_failed` / `:request_timeout` | `Event::RequestFailed(kind:, message:, source_error:)` | `last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | | **`Transmission \| Reading from stream`** | `:chunk_read_full` | `Event::ChunkRead(bytes_buffered, eof: false)` | `in_flight_length = event.bytes_buffered`
`status = :transmission_sending` | `Transmission \| Sending` | `Instruction::SendChunk.new(url: state.upload_url, offset: state.offset, length: event.bytes_buffered, finalize: false)` | -| **`Transmission \| Reading from stream`** | `:chunk_read_eof_with_data` | `Event::ChunkRead(bytes_buffered, eof: true)` where `bytes_buffered > 0` | `in_flight_length = event.bytes_buffered`
`status = :finalizing_sending_upload` | `Finalizing \| Sending with upload` | `Instruction::SendChunk.new(url: state.upload_url, offset: state.offset, length: event.bytes_buffered, finalize: true)` | -| **`Transmission \| Reading from stream`** | `:chunk_read_eof_empty` | `Event::ChunkRead(bytes_buffered: 0, eof: true)` | `in_flight_length = 0`
`status = :finalizing_sending_finalize` | `Finalizing \| Sending finalize` | `Instruction::SendFinalize.new(url: state.upload_url)` | -| **`Transmission \| Sending`** | `:response_active` | `Event::HttpResponse(200, headers, _)` with `Status: active` | `offset = state.offset + state.in_flight_length`
`in_flight_length = 0`
`status = :transmission_reading` | `Transmission \| Reading from stream` | `Instruction::NotifyProgress.new(progress: Progress.new(bytes_uploaded: state.offset, total_bytes: config.upload_size))`
`Instruction::RealignBuffer.new(server_offset: state.offset)`
`Instruction::FillBuffer.new(target_bytesize: state.chunk_size)` | -| **`Transmission \| Sending`** | `:response_cat2` | `Event::HttpResponse` (Category 2; see Section 6.1.2) | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | -| **`Transmission \| Sending`** | `:request_connection_failed` / `:request_timeout` | `Event::RequestFailed(kind: :connection_failed \| :timeout)` | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | +| **`Transmission \| Reading from stream`** | `:chunk_read_eof_with_data` | `Event::ChunkRead(bytes_buffered, eof: true)` where `bytes_buffered > 0` | `in_flight_length = event.bytes_buffered`
`status = :finalizing_sending_upload` | `Finalizing \| Sending with upload` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :finalizing, bytes_uploaded: state.offset, total_bytes: config.upload_size))`
`Instruction::SendChunk.new(url: state.upload_url, offset: state.offset, length: event.bytes_buffered, finalize: true)` | +| **`Transmission \| Reading from stream`** | `:chunk_read_eof_empty` | `Event::ChunkRead(bytes_buffered: 0, eof: true)` | `in_flight_length = 0`
`status = :finalizing_sending_finalize` | `Finalizing \| Sending finalize` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :finalizing, bytes_uploaded: state.offset, total_bytes: config.upload_size))`
`Instruction::SendFinalize.new(url: state.upload_url)` | +| **`Transmission \| Sending`** | `:response_active` | `Event::HttpResponse(200, headers, _)` with `Status: active` | `offset = state.offset + state.in_flight_length`
`in_flight_length = 0`
`status = :transmission_reading` | `Transmission \| Reading from stream` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :uploading, bytes_uploaded: state.offset, total_bytes: config.upload_size))`
`Instruction::RealignBuffer.new(server_offset: state.offset)`
`Instruction::FillBuffer.new(target_bytesize: state.chunk_size)` | +| **`Transmission \| Sending`** | `:response_cat2` | `Event::HttpResponse` (Category 2; see Section 6.1.2) | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :recovering, bytes_uploaded: state.offset, total_bytes: config.upload_size))`
`Instruction::SendQuery.new(url: state.upload_url)` | +| **`Transmission \| Sending`** | `:request_connection_failed` / `:request_timeout` | `Event::RequestFailed(kind: :connection_failed \| :timeout)` | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :recovering, bytes_uploaded: state.offset, total_bytes: config.upload_size))`
`Instruction::SendQuery.new(url: state.upload_url)` | | **`Transmission \| Sending`** | `:request_retries_exhausted` | `Event::RequestFailed(kind: :retries_exhausted)` | `in_flight_length = 0`
`last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | | **`Transmission \| Sending`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, _)` with `Status: final` | `in_flight_length = 0`
`status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | | **`Transmission \| Sending`** | `:response_fatal_bad_response` | `Event::HttpResponse` (Fatal status; see Section 6.1.3) | `in_flight_length = 0`
`last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | -| **`Finalizing \| Sending with upload`** | `:response_final` | `Event::HttpResponse(200, headers, body)` with `Status: final` | `offset = state.offset + state.in_flight_length`
`in_flight_length = 0`
`status = :success` | `Success` | `Instruction::NotifyProgress.new(progress: Progress.new(bytes_uploaded: state.offset, total_bytes: state.offset))`
`Instruction::TerminateSuccess.new(response: event)` | -| **`Finalizing \| Sending with upload`** | `:response_cat2` | `Event::HttpResponse` (Category 2; see Section 6.1.2) | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | -| **`Finalizing \| Sending with upload`** | `:request_connection_failed` / `:request_timeout` | `Event::RequestFailed(kind: :connection_failed \| :timeout)` | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | +| **`Finalizing \| Sending with upload`** | `:response_final` | `Event::HttpResponse(200, headers, body)` with `Status: final` | `offset = state.offset + state.in_flight_length`
`in_flight_length = 0`
`status = :success` | `Success` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :completed, bytes_uploaded: state.offset, total_bytes: state.offset))`
`Instruction::TerminateSuccess.new(response: event)` | +| **`Finalizing \| Sending with upload`** | `:response_cat2` | `Event::HttpResponse` (Category 2; see Section 6.1.2) | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :recovering, bytes_uploaded: state.offset, total_bytes: config.upload_size))`
`Instruction::SendQuery.new(url: state.upload_url)` | +| **`Finalizing \| Sending with upload`** | `:request_connection_failed` / `:request_timeout` | `Event::RequestFailed(kind: :connection_failed \| :timeout)` | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :recovering, bytes_uploaded: state.offset, total_bytes: config.upload_size))`
`Instruction::SendQuery.new(url: state.upload_url)` | | **`Finalizing \| Sending with upload`** | `:request_retries_exhausted` | `Event::RequestFailed(kind: :retries_exhausted)` | `in_flight_length = 0`
`last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | | **`Finalizing \| Sending with upload`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, body)` with `Status: final` | `in_flight_length = 0`
`status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | | **`Finalizing \| Sending with upload`** | `:response_fatal_bad_response` | `Event::HttpResponse` (Fatal status; see Section 6.1.3) | `in_flight_length = 0`
`last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | -| **`Finalizing \| Sending finalize`** | `:response_final` | `Event::HttpResponse(200, headers, body)` with `Status: final` | `status = :success` | `Success` | `Instruction::TerminateSuccess.new(response: event)` | -| **`Finalizing \| Sending finalize`** | `:response_cat2` | `Event::HttpResponse` (Category 2; see Section 6.1.2) | `status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | -| **`Finalizing \| Sending finalize`** | `:request_connection_failed` / `:request_timeout` | `Event::RequestFailed(kind: :connection_failed \| :timeout)` | `status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | +| **`Finalizing \| Sending finalize`** | `:response_final` | `Event::HttpResponse(200, headers, body)` with `Status: final` | `status = :success` | `Success` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :completed, bytes_uploaded: state.offset, total_bytes: state.offset))`
`Instruction::TerminateSuccess.new(response: event)` | +| **`Finalizing \| Sending finalize`** | `:response_cat2` | `Event::HttpResponse` (Category 2; see Section 6.1.2) | `status = :recovery` | `Recovery` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :recovering, bytes_uploaded: state.offset, total_bytes: config.upload_size))`
`Instruction::SendQuery.new(url: state.upload_url)` | +| **`Finalizing \| Sending finalize`** | `:request_connection_failed` / `:request_timeout` | `Event::RequestFailed(kind: :connection_failed \| :timeout)` | `status = :recovery` | `Recovery` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :recovering, bytes_uploaded: state.offset, total_bytes: config.upload_size))`
`Instruction::SendQuery.new(url: state.upload_url)` | | **`Finalizing \| Sending finalize`** | `:request_retries_exhausted` | `Event::RequestFailed(kind: :retries_exhausted)` | `last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | | **`Finalizing \| Sending finalize`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, body)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | | **`Finalizing \| Sending finalize`** | `:response_fatal_bad_response` | `Event::HttpResponse` (Fatal status; see Section 6.1.3) | `last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | -| **`Recovery`** | `:response_active` | `Event::HttpResponse(200, headers, _)` with `Status: active` | `offset = headers['X-Goog-Upload-Size-Received'].to_i`
`in_flight_length = 0`
`status = :transmission_reading` | `Transmission \| Reading from stream` | `Instruction::RealignBuffer.new(server_offset: state.offset)`
`Instruction::FillBuffer.new(target_bytesize: state.chunk_size)` | -| **`Recovery`** | `:response_final` | `Event::HttpResponse(200, headers, body)` with `Status: final` | `in_flight_length = 0`
`status = :success` | `Success` | `Instruction::TerminateSuccess.new(response: event)` | +| **`Recovery`** | `:response_active` | `Event::HttpResponse(200, headers, _)` with `Status: active` | `offset = headers['X-Goog-Upload-Size-Received'].to_i`
`in_flight_length = 0`
`status = :transmission_reading` | `Transmission \| Reading from stream` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :uploading, bytes_uploaded: state.offset, total_bytes: config.upload_size))`
`Instruction::RealignBuffer.new(server_offset: state.offset)`
`Instruction::FillBuffer.new(target_bytesize: state.chunk_size)` | +| **`Recovery`** | `:response_final` | `Event::HttpResponse(200, headers, body)` with `Status: final` | `in_flight_length = 0`
`status = :success` | `Success` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :completed, bytes_uploaded: state.offset, total_bytes: state.offset))`
`Instruction::TerminateSuccess.new(response: event)` | | **`Recovery`** | `:response_cat2` | `Event::HttpResponse` (Category 2; see Section 6.1.2) | `status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | | **`Recovery`** | `:request_retries_exhausted` / `:request_connection_failed` / `:request_timeout` | `Event::RequestFailed(kind:, ...)` | `last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | | **`Recovery`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, body)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | | **`Recovery`** | `:response_fatal_bad_response` | `Event::HttpResponse` (Fatal status; see Section 6.1.3) | `last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | -| **Any Non-Terminal** | `:user_cancel` | `Event::Cancel` | `status = :cancelling` | `Cancelling` | `Instruction::SendCancel.new(url: state.upload_url)` | +| **Any Non-Terminal** | `:user_cancel` | `Event::Cancel` | `status = :cancelling` | `Cancelling` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :cancelling, bytes_uploaded: state.offset, total_bytes: config.upload_size))`
`Instruction::SendCancel.new(url: state.upload_url)` | | **`Cancelling`** | `:response_cancelled` | `Event::HttpResponse(200, headers, _)` with `Status: cancelled` | `status = :cancelled` | `Cancelled` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadCancelledError.new)` | | **`Cancelling`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, _)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(event.body))` | | **`Cancelling`** | `:request_retries_exhausted` / `:request_connection_failed` / `:request_timeout` / `:response_fatal_bad_response` | `Event::RequestFailed` or HTTP failure | `last_error = error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | diff --git a/gapic-common/design/integration-test-plan.md b/gapic-common/design/integration-test-plan.md index c494a0a..9107280 100644 --- a/gapic-common/design/integration-test-plan.md +++ b/gapic-common/design/integration-test-plan.md @@ -68,10 +68,13 @@ Tests standard, uninterrupted resumable upload workflows against `gapic-showcase 4. Chunk 3 transmits remaining bytes `1048576..1499999` with `upload, finalize`. * **Assertions**: * Returned JSON body parses cleanly and reports `"size" == 1_500_000`. - * `progress_records` contains exactly 3 `Progress` notifications matching cumulative byte offsets: - * `Progress(bytes_uploaded: 524_288, total_bytes: 1_500_000)` - * `Progress(bytes_uploaded: 1_048_576, total_bytes: 1_500_000)` - * `Progress(bytes_uploaded: 1_500_000, total_bytes: 1_500_000)` + * `progress_records` contains 6 `Progress` notifications across lifecycle phases: + * `Progress(phase: :initiating, bytes_uploaded: 0, total_bytes: 1_500_000)` + * `Progress(phase: :uploading, bytes_uploaded: 0, total_bytes: 1_500_000)` + * `Progress(phase: :uploading, bytes_uploaded: 524_288, total_bytes: 1_500_000)` + * `Progress(phase: :uploading, bytes_uploaded: 1_048_576, total_bytes: 1_500_000)` + * `Progress(phase: :finalizing, bytes_uploaded: 1_048_576, total_bytes: 1_500_000)` + * `Progress(phase: :completed, bytes_uploaded: 1_500_000, total_bytes: 1_500_000)` #### Case 2. Default chunk size on small upload (`test_small_upload_default_chunk_size`) * **Scenario**: Uploads a ~100 KB (`100_000` bytes) payload with `upload_size: 100_000` and no `chunk_size` specified. @@ -80,8 +83,11 @@ Tests standard, uninterrupted resumable upload workflows against `gapic-showcase 2. The entire 100,000-byte payload fits within a single buffer read and is transmitted in one `upload, finalize` request. * **Assertions**: * Returned JSON body reports `"size" == 100_000`. - * `progress_records` contains exactly 1 `Progress` notification: - * `Progress(bytes_uploaded: 100_000, total_bytes: 100_000)` + * `progress_records` contains 4 `Progress` notifications: + * `Progress(phase: :initiating, bytes_uploaded: 0, total_bytes: 100_000)` + * `Progress(phase: :uploading, bytes_uploaded: 0, total_bytes: 100_000)` + * `Progress(phase: :finalizing, bytes_uploaded: 0, total_bytes: 100_000)` + * `Progress(phase: :completed, bytes_uploaded: 100_000, total_bytes: 100_000)` #### Case 3. Standalone finalize on unseekable stream (`test_standalone_finalize_unseekable_stream`) * **Scenario**: Uploads a `786_432`-byte payload (`3 * 262_144` bytes) wrapped in an `UnseekableStream`, with `chunk_size: 262_144` (256 KiB) and `upload_size` omitted (`nil`). @@ -97,7 +103,11 @@ Tests standard, uninterrupted resumable upload workflows against `gapic-showcase 5. Next buffer read returns 0 bytes at EOF (`:chunk_read_eof_empty`), emitting `SendFinalize` to send a standalone `finalize` request at offset `786432`. * **Assertions**: * Returned JSON body reports `"size" == 786_432`. - * `progress_records` contains 3 `Progress` notifications with `total_bytes: nil`: - * `Progress(bytes_uploaded: 262_144, total_bytes: nil)` - * `Progress(bytes_uploaded: 524_288, total_bytes: nil)` - * `Progress(bytes_uploaded: 786_432, total_bytes: nil)` + * `progress_records` contains 7 `Progress` notifications: + * `Progress(phase: :initiating, bytes_uploaded: 0, total_bytes: nil)` + * `Progress(phase: :uploading, bytes_uploaded: 0, total_bytes: nil)` + * `Progress(phase: :uploading, bytes_uploaded: 262_144, total_bytes: nil)` + * `Progress(phase: :uploading, bytes_uploaded: 524_288, total_bytes: nil)` + * `Progress(phase: :uploading, bytes_uploaded: 786_432, total_bytes: nil)` + * `Progress(phase: :finalizing, bytes_uploaded: 786_432, total_bytes: nil)` + * `Progress(phase: :completed, bytes_uploaded: 786_432, total_bytes: 786_432)` diff --git a/gapic-common/design/reference-implementation.md b/gapic-common/design/reference-implementation.md index 1c7086b..3002a47 100644 --- a/gapic-common/design/reference-implementation.md +++ b/gapic-common/design/reference-implementation.md @@ -128,7 +128,9 @@ module Gapic def self.start_session(state, _event, config) next_state = state.with(status: :starting) + progress = Progress.new(phase: :initiating, bytes_uploaded: next_state.offset, total_bytes: config.upload_size) instructions = [ + Instruction::NotifyProgress.new(progress: progress), Instruction::SendStart.new( url: config.initial_url, headers: config.initial_headers, @@ -149,7 +151,12 @@ module Gapic offset: 0, in_flight_length: 0 ) - [next_state, [Instruction::FillBuffer.new(target_bytesize: chunk_size)]] + progress = Progress.new(phase: :uploading, bytes_uploaded: next_state.offset, total_bytes: config.upload_size) + instructions = [ + Instruction::NotifyProgress.new(progress: progress), + Instruction::FillBuffer.new(target_bytesize: chunk_size) + ] + [next_state, instructions] end def self.send_chunk(state, event, _config) @@ -168,12 +175,14 @@ module Gapic [next_state, instructions] end - def self.send_upload_finalize(state, event, _config) + def self.send_upload_finalize(state, event, config) next_state = state.with( status: :finalizing_sending_upload, in_flight_length: event.bytes_buffered ) + progress = Progress.new(phase: :finalizing, bytes_uploaded: next_state.offset, total_bytes: config.upload_size) instructions = [ + Instruction::NotifyProgress.new(progress: progress), Instruction::SendChunk.new( url: state.upload_url, offset: state.offset, @@ -184,12 +193,17 @@ module Gapic [next_state, instructions] end - def self.send_finalize(state, _event, _config) + def self.send_finalize(state, _event, config) next_state = state.with( status: :finalizing_sending_finalize, in_flight_length: 0 ) - [next_state, [Instruction::SendFinalize.new(url: state.upload_url)]] + progress = Progress.new(phase: :finalizing, bytes_uploaded: next_state.offset, total_bytes: config.upload_size) + instructions = [ + Instruction::NotifyProgress.new(progress: progress), + Instruction::SendFinalize.new(url: state.upload_url) + ] + [next_state, instructions] end def self.ack_chunk(state, _event, config) @@ -199,7 +213,7 @@ module Gapic offset: new_offset, in_flight_length: 0 ) - progress = Progress.new(bytes_uploaded: new_offset, total_bytes: config.upload_size) + progress = Progress.new(phase: :uploading, bytes_uploaded: new_offset, total_bytes: config.upload_size) instructions = [ Instruction::NotifyProgress.new(progress: progress), Instruction::RealignBuffer.new(server_offset: new_offset), @@ -208,12 +222,17 @@ module Gapic [next_state, instructions] end - def self.enter_recovery(state, _event, _config) + def self.enter_recovery(state, _event, config) next_state = state.with( status: :recovery, in_flight_length: 0 ) - [next_state, [Instruction::SendQuery.new(url: state.upload_url)]] + progress = Progress.new(phase: :recovering, bytes_uploaded: next_state.offset, total_bytes: config.upload_size) + instructions = [ + Instruction::NotifyProgress.new(progress: progress), + Instruction::SendQuery.new(url: state.upload_url) + ] + [next_state, instructions] end def self.retry_recovery(state, _event, _config) @@ -231,7 +250,7 @@ module Gapic offset: new_offset, in_flight_length: 0 ) - progress = Progress.new(bytes_uploaded: new_offset, total_bytes: new_offset) + progress = Progress.new(phase: :completed, bytes_uploaded: new_offset, total_bytes: new_offset) instructions = [ Instruction::NotifyProgress.new(progress: progress), Instruction::TerminateSuccess.new(response: event) @@ -244,17 +263,24 @@ module Gapic status: :success, in_flight_length: 0 ) - [next_state, [Instruction::TerminateSuccess.new(response: event)]] + progress = Progress.new(phase: :completed, bytes_uploaded: next_state.offset, total_bytes: next_state.offset) + instructions = [ + Instruction::NotifyProgress.new(progress: progress), + Instruction::TerminateSuccess.new(response: event) + ] + [next_state, instructions] end - def self.realign_from_recovery(state, event, _config) + def self.realign_from_recovery(state, event, config) server_offset = event.headers["x-goog-upload-size-received"].to_i next_state = state.with( status: :transmission_reading, offset: server_offset, in_flight_length: 0 ) + progress = Progress.new(phase: :uploading, bytes_uploaded: server_offset, total_bytes: config.upload_size) instructions = [ + Instruction::NotifyProgress.new(progress: progress), Instruction::RealignBuffer.new(server_offset: server_offset), Instruction::FillBuffer.new(target_bytesize: state.chunk_size) ] @@ -270,9 +296,14 @@ module Gapic [state, []] end - def self.cancel_session(state, _event, _config) + def self.cancel_session(state, _event, config) next_state = state.with(status: :cancelling) - [next_state, [Instruction::SendCancel.new(url: state.upload_url)]] + progress = Progress.new(phase: :cancelling, bytes_uploaded: next_state.offset, total_bytes: config.upload_size) + instructions = [ + Instruction::NotifyProgress.new(progress: progress), + Instruction::SendCancel.new(url: state.upload_url) + ] + [next_state, instructions] end def self.fail_with_deadline_exceeded(state, _event, _config) diff --git a/gapic-common/integration/resumable_upload/golden_path_test.rb b/gapic-common/integration/resumable_upload/golden_path_test.rb index a0a5d5f..fc87e4e 100644 --- a/gapic-common/integration/resumable_upload/golden_path_test.rb +++ b/gapic-common/integration/resumable_upload/golden_path_test.rb @@ -43,9 +43,12 @@ def test_multi_chunk_known_size assert_equal size, parsed["size"] assert_equal [ - Gapic::Rest::ResumableUpload::Progress.new(bytes_uploaded: 524_288, total_bytes: 1_500_000), - Gapic::Rest::ResumableUpload::Progress.new(bytes_uploaded: 1_048_576, total_bytes: 1_500_000), - Gapic::Rest::ResumableUpload::Progress.new(bytes_uploaded: 1_500_000, total_bytes: 1_500_000) + Gapic::Rest::ResumableUpload::Progress.new(phase: :initiating, bytes_uploaded: 0, total_bytes: 1_500_000), + Gapic::Rest::ResumableUpload::Progress.new(phase: :uploading, bytes_uploaded: 0, total_bytes: 1_500_000), + Gapic::Rest::ResumableUpload::Progress.new(phase: :uploading, bytes_uploaded: 524_288, total_bytes: 1_500_000), + Gapic::Rest::ResumableUpload::Progress.new(phase: :uploading, bytes_uploaded: 1_048_576, total_bytes: 1_500_000), + Gapic::Rest::ResumableUpload::Progress.new(phase: :finalizing, bytes_uploaded: 1_048_576, total_bytes: 1_500_000), + Gapic::Rest::ResumableUpload::Progress.new(phase: :completed, bytes_uploaded: 1_500_000, total_bytes: 1_500_000) ], progress_records end @@ -68,7 +71,10 @@ def test_small_upload_default_chunk_size assert_equal size, parsed["size"] assert_equal [ - Gapic::Rest::ResumableUpload::Progress.new(bytes_uploaded: size, total_bytes: size) + Gapic::Rest::ResumableUpload::Progress.new(phase: :initiating, bytes_uploaded: 0, total_bytes: size), + Gapic::Rest::ResumableUpload::Progress.new(phase: :uploading, bytes_uploaded: 0, total_bytes: size), + Gapic::Rest::ResumableUpload::Progress.new(phase: :finalizing, bytes_uploaded: 0, total_bytes: size), + Gapic::Rest::ResumableUpload::Progress.new(phase: :completed, bytes_uploaded: size, total_bytes: size) ], progress_records end @@ -92,9 +98,13 @@ def test_standalone_finalize_unseekable_stream assert_equal size, parsed["size"] assert_equal [ - Gapic::Rest::ResumableUpload::Progress.new(bytes_uploaded: 262_144, total_bytes: nil), - Gapic::Rest::ResumableUpload::Progress.new(bytes_uploaded: 524_288, total_bytes: nil), - Gapic::Rest::ResumableUpload::Progress.new(bytes_uploaded: 786_432, total_bytes: nil) + Gapic::Rest::ResumableUpload::Progress.new(phase: :initiating, bytes_uploaded: 0, total_bytes: nil), + Gapic::Rest::ResumableUpload::Progress.new(phase: :uploading, bytes_uploaded: 0, total_bytes: nil), + Gapic::Rest::ResumableUpload::Progress.new(phase: :uploading, bytes_uploaded: 262_144, total_bytes: nil), + Gapic::Rest::ResumableUpload::Progress.new(phase: :uploading, bytes_uploaded: 524_288, total_bytes: nil), + Gapic::Rest::ResumableUpload::Progress.new(phase: :uploading, bytes_uploaded: 786_432, total_bytes: nil), + Gapic::Rest::ResumableUpload::Progress.new(phase: :finalizing, bytes_uploaded: 786_432, total_bytes: nil), + Gapic::Rest::ResumableUpload::Progress.new(phase: :completed, bytes_uploaded: 786_432, total_bytes: 786_432) ], progress_records end end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb index 50e873d..7832d5c 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb @@ -16,6 +16,7 @@ module Gapic module Rest + # rubocop:disable Metrics/ModuleLength module ResumableUpload ## # Immutable configuration for initiating and executing a resumable upload session. @@ -91,17 +92,29 @@ def initialize initial_url:, ## # Immutable progress snapshot passed to the `on_progress` callback. # + # @!attribute [r] phase + # @return [Symbol] Current upload phase, one of {PHASES} # @!attribute [r] bytes_uploaded - # @return [Integer] Cumulative bytes acknowledged by the server + # @return [Integer] Cumulative bytes acknowledged by the server. Note that this is the + # server-confirmed offset and is not guaranteed to be monotonic — a server rewind during + # recovery can decrease this value. # @!attribute [r] total_bytes # @return [Integer, nil] Total upload size in bytes if known, or nil # Progress = Data.define( + :phase, :bytes_uploaded, :total_bytes ) do - def initialize bytes_uploaded:, total_bytes: nil + self::PHASES = [:initiating, :uploading, :recovering, :finalizing, :cancelling, :completed].freeze + + def initialize phase:, bytes_uploaded:, total_bytes: nil + unless self.class::PHASES.include? phase + raise ArgumentError, "Invalid phase: #{phase.inspect}. Expected one of #{self.class::PHASES.inspect}" + end + super( + phase: phase, bytes_uploaded: bytes_uploaded, total_bytes: total_bytes ) @@ -171,5 +184,6 @@ def initialize from_status:, shape:, recipe:, next_state:, instructions: [] end end end + # rubocop:enable Metrics/ModuleLength end end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver/abridge.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver/abridge.rb index 141d70a..254e124 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver/abridge.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver/abridge.rb @@ -106,6 +106,7 @@ def instruction instruction when Instruction::NotifyProgress { "type" => "NotifyProgress", + "phase" => instruction.progress.phase.to_s, "bytesUploaded" => instruction.progress.bytes_uploaded, "totalBytes" => instruction.progress.total_bytes } diff --git a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb index 5e27097..5671009 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb @@ -72,6 +72,37 @@ module Rules :fail_with_unmatched_transition ].freeze + ## + # Mapping of notifying recipes to their emitted {Progress} phase. + # + RECIPE_PHASES = { + start_session: :initiating, + begin_transmission: :uploading, + ack_chunk: :uploading, + realign_from_recovery: :uploading, + enter_recovery: :recovering, + send_upload_finalize: :finalizing, + send_finalize: :finalizing, + complete_upload_with_data: :completed, + complete_upload_finalized: :completed, + cancel_session: :cancelling + }.freeze + + ## + # Recipes that do not emit {Instruction::NotifyProgress}. + # + NON_NOTIFYING_RECIPES = [ + :send_chunk, + :retry_recovery, + :complete_cancellation, + :ignore_duplicate_cancel, + :fail_with_deadline_exceeded, + :fail_with_rejected, + :fail_with_bad_response, + :fail_with_request_error, + :fail_with_unmatched_transition + ].freeze + ## # Classifies incoming event into a canonical shape symbol. # @@ -185,7 +216,9 @@ def self.step state, event, config def self.start_session state, _event, config next_state = state.with status: :starting + progress = Progress.new phase: :initiating, bytes_uploaded: next_state.offset, total_bytes: config.upload_size instructions = [ + Instruction::NotifyProgress.new(progress: progress), Instruction::SendStart.new( url: config.initial_url, headers: config.initial_headers, @@ -208,7 +241,12 @@ def self.begin_transmission state, event, config offset: 0, in_flight_length: 0 ) - [next_state, [Instruction::FillBuffer.new(target_bytesize: chunk_size)]] + progress = Progress.new phase: :uploading, bytes_uploaded: next_state.offset, total_bytes: config.upload_size + instructions = [ + Instruction::NotifyProgress.new(progress: progress), + Instruction::FillBuffer.new(target_bytesize: chunk_size) + ] + [next_state, instructions] end def self.send_chunk state, event, _config @@ -227,12 +265,14 @@ def self.send_chunk state, event, _config [next_state, instructions] end - def self.send_upload_finalize state, event, _config + def self.send_upload_finalize state, event, config next_state = state.with( status: :finalizing_sending_upload, in_flight_length: event.bytes_buffered ) + progress = Progress.new phase: :finalizing, bytes_uploaded: next_state.offset, total_bytes: config.upload_size instructions = [ + Instruction::NotifyProgress.new(progress: progress), Instruction::SendChunk.new( url: state.upload_url, offset: state.offset, @@ -243,12 +283,17 @@ def self.send_upload_finalize state, event, _config [next_state, instructions] end - def self.send_finalize state, _event, _config + def self.send_finalize state, _event, config next_state = state.with( status: :finalizing_sending_finalize, in_flight_length: 0 ) - [next_state, [Instruction::SendFinalize.new(url: state.upload_url)]] + progress = Progress.new phase: :finalizing, bytes_uploaded: next_state.offset, total_bytes: config.upload_size + instructions = [ + Instruction::NotifyProgress.new(progress: progress), + Instruction::SendFinalize.new(url: state.upload_url) + ] + [next_state, instructions] end def self.ack_chunk state, _event, config @@ -258,7 +303,7 @@ def self.ack_chunk state, _event, config offset: new_offset, in_flight_length: 0 ) - progress = Progress.new bytes_uploaded: new_offset, total_bytes: config.upload_size + progress = Progress.new phase: :uploading, bytes_uploaded: new_offset, total_bytes: config.upload_size instructions = [ Instruction::NotifyProgress.new(progress: progress), Instruction::RealignBuffer.new(server_offset: new_offset), @@ -267,12 +312,17 @@ def self.ack_chunk state, _event, config [next_state, instructions] end - def self.enter_recovery state, _event, _config + def self.enter_recovery state, _event, config next_state = state.with( status: :recovery, in_flight_length: 0 ) - [next_state, [Instruction::SendQuery.new(url: state.upload_url)]] + progress = Progress.new phase: :recovering, bytes_uploaded: next_state.offset, total_bytes: config.upload_size + instructions = [ + Instruction::NotifyProgress.new(progress: progress), + Instruction::SendQuery.new(url: state.upload_url) + ] + [next_state, instructions] end def self.retry_recovery state, _event, _config @@ -290,7 +340,7 @@ def self.complete_upload_with_data state, event, _config offset: new_offset, in_flight_length: 0 ) - progress = Progress.new bytes_uploaded: new_offset, total_bytes: new_offset + progress = Progress.new phase: :completed, bytes_uploaded: new_offset, total_bytes: new_offset instructions = [ Instruction::NotifyProgress.new(progress: progress), Instruction::TerminateSuccess.new(response: event) @@ -303,10 +353,15 @@ def self.complete_upload_finalized state, event, _config status: :success, in_flight_length: 0 ) - [next_state, [Instruction::TerminateSuccess.new(response: event)]] + progress = Progress.new phase: :completed, bytes_uploaded: next_state.offset, total_bytes: next_state.offset + instructions = [ + Instruction::NotifyProgress.new(progress: progress), + Instruction::TerminateSuccess.new(response: event) + ] + [next_state, instructions] end - def self.realign_from_recovery state, event, _config + def self.realign_from_recovery state, event, config server_offset_str = header_value event.headers, "x-goog-upload-size-received" server_offset = server_offset_str.to_i next_state = state.with( @@ -314,7 +369,9 @@ def self.realign_from_recovery state, event, _config offset: server_offset, in_flight_length: 0 ) + progress = Progress.new phase: :uploading, bytes_uploaded: server_offset, total_bytes: config.upload_size instructions = [ + Instruction::NotifyProgress.new(progress: progress), Instruction::RealignBuffer.new(server_offset: server_offset), Instruction::FillBuffer.new(target_bytesize: state.chunk_size) ] @@ -331,9 +388,14 @@ def self.ignore_duplicate_cancel state, _event, _config [state, []] end - def self.cancel_session state, _event, _config + def self.cancel_session state, _event, config next_state = state.with status: :cancelling - [next_state, [Instruction::SendCancel.new(url: state.upload_url)]] + progress = Progress.new phase: :cancelling, bytes_uploaded: next_state.offset, total_bytes: config.upload_size + instructions = [ + Instruction::NotifyProgress.new(progress: progress), + Instruction::SendCancel.new(url: state.upload_url) + ] + [next_state, instructions] end def self.fail_with_deadline_exceeded state, _event, _config diff --git a/gapic-common/test/gapic/rest/resumable_upload/core_test.rb b/gapic-common/test/gapic/rest/resumable_upload/core_test.rb index 86a59d8..c36ddc0 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/core_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/core_test.rb @@ -46,8 +46,10 @@ def test_initial_state def test_dispatch_updates_state_and_returns_instructions instructions = @core.dispatch Event::StartUpload.new assert_equal :starting, @core.state.status - assert_equal 1, instructions.size - assert_instance_of Instruction::SendStart, instructions.first + assert_equal 2, instructions.size + assert_instance_of Instruction::NotifyProgress, instructions[0] + assert_equal :initiating, instructions[0].progress.phase + assert_instance_of Instruction::SendStart, instructions[1] assert_instance_of Decision, @core.last_decision assert_equal :initializing, @core.last_decision.from_status assert_equal :start_upload, @core.last_decision.shape @@ -68,9 +70,11 @@ def test_dispatch_updates_state_and_returns_instructions assert_equal "https://example.com/session/1", @core.state.upload_url assert_equal 512, @core.state.chunk_granularity assert_equal 1024, @core.state.chunk_size - assert_equal 1, instructions.size - assert_instance_of Instruction::FillBuffer, instructions.first - assert_equal 1024, instructions.first.target_bytesize + assert_equal 2, instructions.size + assert_instance_of Instruction::NotifyProgress, instructions[0] + assert_equal :uploading, instructions[0].progress.phase + assert_instance_of Instruction::FillBuffer, instructions[1] + assert_equal 1024, instructions[1].target_bytesize assert_instance_of Decision, @core.last_decision assert_equal :starting, @core.last_decision.from_status assert_equal :response_active, @core.last_decision.shape diff --git a/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb index e9ee605..41ac8fb 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb @@ -96,12 +96,24 @@ def test_instruction_instantiation end def test_progress_instantiation - progress = Progress.new bytes_uploaded: 512, total_bytes: 2048 - assert_equal 512, progress.bytes_uploaded - assert_equal 2048, progress.total_bytes - - progress_unknown = Progress.new bytes_uploaded: 1024 + Progress::PHASES.each do |phase| + progress = Progress.new phase: phase, bytes_uploaded: 512, total_bytes: 2048 + assert_equal phase, progress.phase + assert_equal 512, progress.bytes_uploaded + assert_equal 2048, progress.total_bytes + end + + progress_unknown = Progress.new phase: :uploading, bytes_uploaded: 1024 + assert_equal :uploading, progress_unknown.phase assert_equal 1024, progress_unknown.bytes_uploaded assert_nil progress_unknown.total_bytes + + assert_raises ArgumentError do + Progress.new bytes_uploaded: 512, total_bytes: 2048 + end + + assert_raises ArgumentError do + Progress.new phase: :invalid_phase, bytes_uploaded: 512, total_bytes: 2048 + end end end diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver/abridge_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver/abridge_test.rb index e52ea00..14e1e80 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver/abridge_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver/abridge_test.rb @@ -78,7 +78,8 @@ def test_headers_retains_x_goog_upload_and_redacts_others def test_instructions_summarizes_without_bodies instructions = [ Instruction::SendStart.new(url: "https://example.com/upload?key=SECRET", headers: {}, body: "secret_body"), - Instruction::SendChunk.new(url: "https://example.com/session?id=123", offset: 0, length: 64, finalize: true) + Instruction::SendChunk.new(url: "https://example.com/session?id=123", offset: 0, length: 64, finalize: true), + Instruction::NotifyProgress.new(progress: Progress.new(phase: :uploading, bytes_uploaded: 64, total_bytes: 1024)) ] summary = Driver::Abridge.instructions instructions @@ -91,5 +92,10 @@ def test_instructions_summarizes_without_bodies assert_equal 0, summary[1]["offset"] assert_equal 64, summary[1]["length"] assert_equal true, summary[1]["finalize"] + + assert_equal "NotifyProgress", summary[2]["type"] + assert_equal "uploading", summary[2]["phase"] + assert_equal 64, summary[2]["bytesUploaded"] + assert_equal 1024, summary[2]["totalBytes"] end end diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver/upload_log_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver/upload_log_test.rb index 3a03529..bde9e24 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver/upload_log_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver/upload_log_test.rb @@ -57,7 +57,10 @@ def test_decision_logs_debug_with_fields_from_rules_decide assert_equal "starting", fields["toStatus"] assert_equal 0, fields["offset"] assert_equal 0, fields["inFlightLength"] - assert_equal [{ "type" => "SendStart", "url" => "https://example.com/upload" }], fields["instructions"] + assert_equal [ + { "type" => "NotifyProgress", "phase" => "initiating", "bytesUploaded" => 0, "totalBytes" => 1024 }, + { "type" => "SendStart", "url" => "https://example.com/upload" } + ], fields["instructions"] end def test_lifecycle_start_session_logs_info diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb index 669275b..5bddcde 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb @@ -42,7 +42,7 @@ def make_post_request uri:, body: nil, params: {}, options: {}, method_name: nil def test_execute_notify_progress_without_callback_does_not_raise driver = build_driver on_progress: nil - instruction = Instruction::NotifyProgress.new progress: Progress.new(bytes_uploaded: 1024, total_bytes: 4096) + instruction = Instruction::NotifyProgress.new progress: Progress.new(phase: :uploading, bytes_uploaded: 1024, total_bytes: 4096) # Must not raise when callback is nil driver.send :execute_notify_progress, instruction end @@ -52,11 +52,11 @@ def test_execute_notify_progress_happy_path_invoked_once callback = ->(progress) { calls << progress } driver = build_driver on_progress: callback - instruction = Instruction::NotifyProgress.new progress: Progress.new(bytes_uploaded: 500, total_bytes: 1000) + instruction = Instruction::NotifyProgress.new progress: Progress.new(phase: :uploading, bytes_uploaded: 500, total_bytes: 1000) driver.send :execute_notify_progress, instruction assert_equal 1, calls.size - assert_equal Progress.new(bytes_uploaded: 500, total_bytes: 1000), calls.first + assert_equal Progress.new(phase: :uploading, bytes_uploaded: 500, total_bytes: 1000), calls.first end def test_execute_notify_progress_total_bytes_nil_passes_through @@ -64,18 +64,18 @@ def test_execute_notify_progress_total_bytes_nil_passes_through callback = ->(progress) { calls << progress } driver = build_driver on_progress: callback - instruction = Instruction::NotifyProgress.new progress: Progress.new(bytes_uploaded: 250, total_bytes: nil) + instruction = Instruction::NotifyProgress.new progress: Progress.new(phase: :uploading, bytes_uploaded: 250, total_bytes: nil) driver.send :execute_notify_progress, instruction assert_equal 1, calls.size - assert_equal Progress.new(bytes_uploaded: 250, total_bytes: nil), calls.first + assert_equal Progress.new(phase: :uploading, bytes_uploaded: 250, total_bytes: nil), calls.first end def test_execute_notify_progress_raises_error_to_caller_when_callback_fails callback = ->(_progress) { raise CustomCallbackError, "User UI crashed in progress callback" } driver = build_driver on_progress: callback - instruction = Instruction::NotifyProgress.new progress: Progress.new(bytes_uploaded: 100, total_bytes: 1000) + instruction = Instruction::NotifyProgress.new progress: Progress.new(phase: :uploading, bytes_uploaded: 100, total_bytes: 1000) err = assert_raises CustomCallbackError do driver.send :execute_notify_progress, instruction end diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb index d5ad6b8..20f9e64 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb @@ -44,12 +44,14 @@ def make_post_request uri:, body:, params:, options:, method_name: nil end def test_multi_chunk_upload_with_active_responses + progress_records = [] stub = FakeClientStub.new build_scripted_responses config = CompleteUploadConfig.new( initial_url: "https://example.com/upload", stream: StringIO.new("0123456789"), upload_size: 10, - chunk_size: 4 + chunk_size: 4, + on_progress: ->(p) { progress_records << p } ) driver = Driver.new client_stub: stub, config: config @@ -61,16 +63,27 @@ def test_multi_chunk_upload_with_active_responses assert_chunk_request stub.requests[1], offset: "0", length: "4", body: "0123", finalize: false assert_chunk_request stub.requests[2], offset: "4", length: "4", body: "4567", finalize: false assert_chunk_request stub.requests[3], offset: "8", length: "2", body: "89", finalize: true + + assert_equal [ + Progress.new(phase: :initiating, bytes_uploaded: 0, total_bytes: 10), + Progress.new(phase: :uploading, bytes_uploaded: 0, total_bytes: 10), + Progress.new(phase: :uploading, bytes_uploaded: 4, total_bytes: 10), + Progress.new(phase: :uploading, bytes_uploaded: 8, total_bytes: 10), + Progress.new(phase: :finalizing, bytes_uploaded: 8, total_bytes: 10), + Progress.new(phase: :completed, bytes_uploaded: 10, total_bytes: 10) + ], progress_records end def test_upload_recovers_when_chunk_response_lacks_status_header + progress_records = [] responses = build_recovery_responses stub = FakeClientStub.new responses config = CompleteUploadConfig.new( initial_url: "https://example.com/upload", stream: StringIO.new("0123456789"), upload_size: 10, - chunk_size: 4 + chunk_size: 4, + on_progress: ->(p) { progress_records << p } ) driver = Driver.new client_stub: stub, config: config @@ -83,6 +96,16 @@ def test_upload_recovers_when_chunk_response_lacks_status_header assert_query_request stub.requests[2] assert_chunk_request stub.requests[3], offset: "4", length: "4", body: "4567", finalize: false assert_chunk_request stub.requests[4], offset: "8", length: "2", body: "89", finalize: true + + assert_equal [ + Progress.new(phase: :initiating, bytes_uploaded: 0, total_bytes: 10), + Progress.new(phase: :uploading, bytes_uploaded: 0, total_bytes: 10), + Progress.new(phase: :recovering, bytes_uploaded: 0, total_bytes: 10), + Progress.new(phase: :uploading, bytes_uploaded: 4, total_bytes: 10), + Progress.new(phase: :uploading, bytes_uploaded: 8, total_bytes: 10), + Progress.new(phase: :finalizing, bytes_uploaded: 8, total_bytes: 10), + Progress.new(phase: :completed, bytes_uploaded: 10, total_bytes: 10) + ], progress_records end private diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb index 5c0ef64..df994b5 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb @@ -35,13 +35,27 @@ def setup ) end + def test_recipe_phases_partition + notifying = Rules::RECIPE_PHASES.keys + non_notifying = Rules::NON_NOTIFYING_RECIPES + all_classified = notifying + non_notifying + + assert_empty Rules::RECIPES - all_classified, + "Recipes missing from RECIPE_PHASES or NON_NOTIFYING_RECIPES" + assert_empty all_classified - Rules::RECIPES, + "Phantom recipes in RECIPE_PHASES or NON_NOTIFYING_RECIPES" + assert_empty notifying & non_notifying, + "Recipes present in both RECIPE_PHASES and NON_NOTIFYING_RECIPES" + end + def test_row_initializing_start_upload decision = Rules.decide State.new(status: :initializing), Event::StartUpload.new, @config assert_equal :initializing, decision.from_status assert_equal :start_upload, decision.shape assert_equal :start_session, decision.recipe assert_equal :starting, decision.next_state.status - assert_instance_of Instruction::SendStart, decision.instructions.first + assert_recipe_progress_notification decision + assert_instance_of Instruction::SendStart, decision.instructions[1] end def test_row_starting_response_active @@ -54,7 +68,8 @@ def test_row_starting_response_active assert_equal :response_active, decision.shape assert_equal :begin_transmission, decision.recipe assert_equal :transmission_reading, decision.next_state.status - assert_instance_of Instruction::FillBuffer, decision.instructions.first + assert_recipe_progress_notification decision + assert_instance_of Instruction::FillBuffer, decision.instructions[1] end def test_row_transmission_reading_chunk_read_full @@ -67,6 +82,7 @@ def test_row_transmission_reading_chunk_read_full assert_equal :chunk_read_full, decision.shape assert_equal :send_chunk, decision.recipe assert_equal :transmission_sending, decision.next_state.status + assert_recipe_progress_notification decision assert_instance_of Instruction::SendChunk, decision.instructions.first refute decision.instructions.first.finalize end @@ -81,8 +97,9 @@ def test_row_transmission_reading_chunk_read_eof_with_data assert_equal :chunk_read_eof_with_data, decision.shape assert_equal :send_upload_finalize, decision.recipe assert_equal :finalizing_sending_upload, decision.next_state.status - assert_instance_of Instruction::SendChunk, decision.instructions.first - assert decision.instructions.first.finalize + assert_recipe_progress_notification decision + assert_instance_of Instruction::SendChunk, decision.instructions[1] + assert decision.instructions[1].finalize end def test_row_transmission_reading_chunk_read_eof_empty @@ -95,7 +112,8 @@ def test_row_transmission_reading_chunk_read_eof_empty assert_equal :chunk_read_eof_empty, decision.shape assert_equal :send_finalize, decision.recipe assert_equal :finalizing_sending_finalize, decision.next_state.status - assert_instance_of Instruction::SendFinalize, decision.instructions.first + assert_recipe_progress_notification decision + assert_instance_of Instruction::SendFinalize, decision.instructions[1] end def test_row_transmission_sending_response_active @@ -112,6 +130,7 @@ def test_row_transmission_sending_response_active assert_equal :response_active, decision.shape assert_equal :ack_chunk, decision.recipe assert_equal :transmission_reading, decision.next_state.status + assert_recipe_progress_notification decision assert_equal 3, decision.instructions.size end @@ -126,7 +145,8 @@ def test_row_transmission_sending_enter_recovery assert_equal :response_cat2, decision.shape assert_equal :enter_recovery, decision.recipe assert_equal :recovery, decision.next_state.status - assert_instance_of Instruction::SendQuery, decision.instructions.first + assert_recipe_progress_notification decision + assert_instance_of Instruction::SendQuery, decision.instructions[1] end def test_row_finalizing_sending_upload_response_final @@ -140,6 +160,7 @@ def test_row_finalizing_sending_upload_response_final assert_equal :response_final, decision.shape assert_equal :complete_upload_with_data, decision.recipe assert_equal :success, decision.next_state.status + assert_recipe_progress_notification decision assert_equal 2, decision.instructions.size end @@ -150,7 +171,8 @@ def test_row_finalizing_sending_finalize_response_final assert_equal :response_final, decision.shape assert_equal :complete_upload_finalized, decision.recipe assert_equal :success, decision.next_state.status - assert_instance_of Instruction::TerminateSuccess, decision.instructions.first + assert_recipe_progress_notification decision + assert_instance_of Instruction::TerminateSuccess, decision.instructions[1] end def test_row_recovery_response_active @@ -163,7 +185,8 @@ def test_row_recovery_response_active assert_equal :response_active, decision.shape assert_equal :realign_from_recovery, decision.recipe assert_equal :transmission_reading, decision.next_state.status - assert_instance_of Instruction::RealignBuffer, decision.instructions.first + assert_recipe_progress_notification decision + assert_instance_of Instruction::RealignBuffer, decision.instructions[1] end def test_row_recovery_response_cat2 @@ -173,6 +196,7 @@ def test_row_recovery_response_cat2 assert_equal :response_cat2, decision.shape assert_equal :retry_recovery, decision.recipe assert_equal :recovery, decision.next_state.status + assert_recipe_progress_notification decision assert_instance_of Instruction::SendQuery, decision.instructions.first end @@ -183,6 +207,7 @@ def test_row_cancelling_response_cancelled assert_equal :response_cancelled, decision.shape assert_equal :complete_cancellation, decision.recipe assert_equal :cancelled, decision.next_state.status + assert_recipe_progress_notification decision assert_instance_of Instruction::TerminateFailure, decision.instructions.first end @@ -192,6 +217,7 @@ def test_row_cancelling_user_cancel assert_equal :user_cancel, decision.shape assert_equal :ignore_duplicate_cancel, decision.recipe assert_equal :cancelling, decision.next_state.status + assert_recipe_progress_notification decision assert_empty decision.instructions end @@ -201,6 +227,7 @@ def test_row_global_deadline_exceeded assert_equal :global_deadline_exceeded, decision.shape assert_equal :fail_with_deadline_exceeded, decision.recipe assert_equal :error, decision.next_state.status + assert_recipe_progress_notification decision assert_instance_of Gapic::Common::DeadlineExceededError, decision.next_state.last_error end @@ -210,7 +237,8 @@ def test_row_user_cancel assert_equal :user_cancel, decision.shape assert_equal :cancel_session, decision.recipe assert_equal :cancelling, decision.next_state.status - assert_instance_of Instruction::SendCancel, decision.instructions.first + assert_recipe_progress_notification decision + assert_instance_of Instruction::SendCancel, decision.instructions[1] end def test_row_response_rejected @@ -220,6 +248,7 @@ def test_row_response_rejected assert_equal :response_rejected, decision.shape assert_equal :fail_with_rejected, decision.recipe assert_equal :rejected, decision.next_state.status + assert_recipe_progress_notification decision assert_instance_of Gapic::Common::UploadRejectedError, decision.next_state.last_error end @@ -230,6 +259,7 @@ def test_row_fail_with_bad_response assert_equal :response_cat2, decision.shape assert_equal :fail_with_bad_response, decision.recipe assert_equal :error, decision.next_state.status + assert_recipe_progress_notification decision assert_instance_of Gapic::Common::BadResponseError, decision.next_state.last_error end @@ -240,6 +270,24 @@ def test_row_fail_with_request_error assert_equal :request_retries_exhausted, decision.shape assert_equal :fail_with_request_error, decision.recipe assert_equal :error, decision.next_state.status + assert_recipe_progress_notification decision assert_instance_of Instruction::TerminateFailure, decision.instructions.first end + + private + + def assert_recipe_progress_notification decision + if Rules::RECIPE_PHASES.key? decision.recipe + expected_phase = Rules::RECIPE_PHASES[decision.recipe] + first_inst = decision.instructions.first + assert_instance_of Instruction::NotifyProgress, first_inst, + "Expected #{decision.recipe} to emit NotifyProgress as first instruction" + assert_equal expected_phase, first_inst.progress.phase, + "Expected #{decision.recipe} to emit phase #{expected_phase}" + else + assert_includes Rules::NON_NOTIFYING_RECIPES, decision.recipe + refute decision.instructions.any? { |i| i.is_a? Instruction::NotifyProgress }, + "Expected non-notifying recipe #{decision.recipe} to emit no NotifyProgress" + end + end end diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_recovery_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_recovery_test.rb index 7fa0b26..f0f2b72 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_recovery_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_recovery_test.rb @@ -43,8 +43,10 @@ def test_transition_transmission_sending_cat2_triggers_recovery assert_equal :recovery, next_state.status assert_equal 0, next_state.in_flight_length - assert_equal 1, instructions.size - assert_instance_of Instruction::SendQuery, instructions.first + assert_equal 2, instructions.size + assert_instance_of Instruction::NotifyProgress, instructions[0] + assert_equal Progress.new(phase: :recovering, bytes_uploaded: 0, total_bytes: 1024), instructions[0].progress + assert_instance_of Instruction::SendQuery, instructions[1] end def test_transition_transmission_sending_connection_failed_triggers_recovery @@ -55,8 +57,10 @@ def test_transition_transmission_sending_connection_failed_triggers_recovery assert_equal :recovery, next_state.status assert_equal 0, next_state.in_flight_length - assert_equal 1, instructions.size - assert_instance_of Instruction::SendQuery, instructions.first + assert_equal 2, instructions.size + assert_instance_of Instruction::NotifyProgress, instructions[0] + assert_equal Progress.new(phase: :recovering, bytes_uploaded: 0, total_bytes: 1024), instructions[0].progress + assert_instance_of Instruction::SendQuery, instructions[1] end def test_transition_transmission_sending_timeout_triggers_recovery @@ -67,8 +71,10 @@ def test_transition_transmission_sending_timeout_triggers_recovery assert_equal :recovery, next_state.status assert_equal 0, next_state.in_flight_length - assert_equal 1, instructions.size - assert_instance_of Instruction::SendQuery, instructions.first + assert_equal 2, instructions.size + assert_instance_of Instruction::NotifyProgress, instructions[0] + assert_equal Progress.new(phase: :recovering, bytes_uploaded: 0, total_bytes: 1024), instructions[0].progress + assert_instance_of Instruction::SendQuery, instructions[1] end def test_transition_finalizing_sending_upload_timeout_triggers_recovery @@ -79,8 +85,10 @@ def test_transition_finalizing_sending_upload_timeout_triggers_recovery assert_equal :recovery, next_state.status assert_equal 0, next_state.in_flight_length - assert_equal 1, instructions.size - assert_instance_of Instruction::SendQuery, instructions.first + assert_equal 2, instructions.size + assert_instance_of Instruction::NotifyProgress, instructions[0] + assert_equal Progress.new(phase: :recovering, bytes_uploaded: 512, total_bytes: 1024), instructions[0].progress + assert_instance_of Instruction::SendQuery, instructions[1] end def test_transition_recovery_active_realigns_buffer @@ -94,10 +102,12 @@ def test_transition_recovery_active_realigns_buffer assert_equal :transmission_reading, next_state.status assert_equal 768, next_state.offset - assert_equal 2, instructions.size - assert_instance_of Instruction::RealignBuffer, instructions[0] - assert_equal 768, instructions[0].server_offset - assert_instance_of Instruction::FillBuffer, instructions[1] + assert_equal 3, instructions.size + assert_instance_of Instruction::NotifyProgress, instructions[0] + assert_equal Progress.new(phase: :uploading, bytes_uploaded: 768, total_bytes: 1024), instructions[0].progress + assert_instance_of Instruction::RealignBuffer, instructions[1] + assert_equal 768, instructions[1].server_offset + assert_instance_of Instruction::FillBuffer, instructions[2] end def test_transition_recovery_final_completes_upload @@ -106,8 +116,10 @@ def test_transition_recovery_final_completes_upload next_state, instructions = Rules.step state, resp, @config assert_equal :success, next_state.status - assert_equal 1, instructions.size - assert_instance_of Instruction::TerminateSuccess, instructions.first + assert_equal 2, instructions.size + assert_instance_of Instruction::NotifyProgress, instructions[0] + assert_equal Progress.new(phase: :completed, bytes_uploaded: 512, total_bytes: 512), instructions[0].progress + assert_instance_of Instruction::TerminateSuccess, instructions[1] end def test_transition_recovery_cat2_retries_query diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb index 009b2d7..fd0879f 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb @@ -40,11 +40,13 @@ def test_transition_initializing_to_starting next_state, instructions = Rules.step state, Event::StartUpload.new, @config assert_equal :starting, next_state.status - assert_equal 1, instructions.size - assert_instance_of Instruction::SendStart, instructions.first - assert_equal "https://example.com/upload", instructions.first.url - assert_equal({ "X-Custom" => "value" }, instructions.first.headers) - assert_equal '{"name":"obj"}', instructions.first.body + assert_equal 2, instructions.size + assert_instance_of Instruction::NotifyProgress, instructions[0] + assert_equal Progress.new(phase: :initiating, bytes_uploaded: 0, total_bytes: 1024), instructions[0].progress + assert_instance_of Instruction::SendStart, instructions[1] + assert_equal "https://example.com/upload", instructions[1].url + assert_equal({ "X-Custom" => "value" }, instructions[1].headers) + assert_equal '{"name":"obj"}', instructions[1].body end def test_transition_starting_to_transmission_reading @@ -63,9 +65,11 @@ def test_transition_starting_to_transmission_reading assert_equal 512, next_state.chunk_size assert_equal 0, next_state.offset assert_equal 0, next_state.in_flight_length - assert_equal 1, instructions.size - assert_instance_of Instruction::FillBuffer, instructions.first - assert_equal 512, instructions.first.target_bytesize + assert_equal 2, instructions.size + assert_instance_of Instruction::NotifyProgress, instructions[0] + assert_equal Progress.new(phase: :uploading, bytes_uploaded: 0, total_bytes: 1024), instructions[0].progress + assert_instance_of Instruction::FillBuffer, instructions[1] + assert_equal 512, instructions[1].target_bytesize end def test_transition_transmission_reading_full_chunk @@ -91,11 +95,13 @@ def test_transition_transmission_reading_eof_with_data assert_equal :finalizing_sending_upload, next_state.status assert_equal 200, next_state.in_flight_length - assert_equal 1, instructions.size - assert_instance_of Instruction::SendChunk, instructions.first - assert_equal 512, instructions.first.offset - assert_equal 200, instructions.first.length - assert instructions.first.finalize + assert_equal 2, instructions.size + assert_instance_of Instruction::NotifyProgress, instructions[0] + assert_equal Progress.new(phase: :finalizing, bytes_uploaded: 512, total_bytes: 1024), instructions[0].progress + assert_instance_of Instruction::SendChunk, instructions[1] + assert_equal 512, instructions[1].offset + assert_equal 200, instructions[1].length + assert instructions[1].finalize end def test_transition_transmission_reading_eof_empty @@ -106,9 +112,11 @@ def test_transition_transmission_reading_eof_empty assert_equal :finalizing_sending_finalize, next_state.status assert_equal 0, next_state.in_flight_length - assert_equal 1, instructions.size - assert_instance_of Instruction::SendFinalize, instructions.first - assert_equal "https://example.com/session", instructions.first.url + assert_equal 2, instructions.size + assert_instance_of Instruction::NotifyProgress, instructions[0] + assert_equal Progress.new(phase: :finalizing, bytes_uploaded: 1024, total_bytes: 1024), instructions[0].progress + assert_instance_of Instruction::SendFinalize, instructions[1] + assert_equal "https://example.com/session", instructions[1].url end def test_transition_transmission_sending_ack_chunk @@ -122,7 +130,7 @@ def test_transition_transmission_sending_ack_chunk assert_equal 0, next_state.in_flight_length assert_equal 3, instructions.size assert_instance_of Instruction::NotifyProgress, instructions[0] - assert_equal Progress.new(bytes_uploaded: 512, total_bytes: 1024), instructions[0].progress + assert_equal Progress.new(phase: :uploading, bytes_uploaded: 512, total_bytes: 1024), instructions[0].progress assert_instance_of Instruction::RealignBuffer, instructions[1] assert_equal 512, instructions[1].server_offset assert_instance_of Instruction::FillBuffer, instructions[2] @@ -139,7 +147,7 @@ def test_transition_finalizing_sending_upload_success assert_equal 0, next_state.in_flight_length assert_equal 2, instructions.size assert_instance_of Instruction::NotifyProgress, instructions[0] - assert_equal Progress.new(bytes_uploaded: 1024, total_bytes: 1024), instructions[0].progress + assert_equal Progress.new(phase: :completed, bytes_uploaded: 1024, total_bytes: 1024), instructions[0].progress assert_instance_of Instruction::TerminateSuccess, instructions[1] end @@ -149,8 +157,10 @@ def test_transition_finalizing_sending_finalize_success next_state, instructions = Rules.step state, resp, @config assert_equal :success, next_state.status - assert_equal 1, instructions.size - assert_instance_of Instruction::TerminateSuccess, instructions.first + assert_equal 2, instructions.size + assert_instance_of Instruction::NotifyProgress, instructions[0] + assert_equal Progress.new(phase: :completed, bytes_uploaded: 1024, total_bytes: 1024), instructions[0].progress + assert_instance_of Instruction::TerminateSuccess, instructions[1] end def test_transition_cancellation_flow @@ -158,8 +168,10 @@ def test_transition_cancellation_flow next_state, instructions = Rules.step state, Event::Cancel.new, @config assert_equal :cancelling, next_state.status - assert_equal 1, instructions.size - assert_instance_of Instruction::SendCancel, instructions.first + assert_equal 2, instructions.size + assert_instance_of Instruction::NotifyProgress, instructions[0] + assert_equal Progress.new(phase: :cancelling, bytes_uploaded: 0, total_bytes: 1024), instructions[0].progress + assert_instance_of Instruction::SendCancel, instructions[1] # Duplicate cancel in cancelling state does nothing dup_state, dup_instructions = Rules.step next_state, Event::Cancel.new, @config From 8ddadfce7c5e72476891eccdbfa86b36900bf2b3 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Mon, 7 Sep 2026 18:50:29 +0000 Subject: [PATCH 39/79] chore: a bit of docs --- gapic-common/design/implementation-guide.md | 1 + gapic-common/lib/gapic/rest/resumable_upload/data_types.rb | 2 ++ 2 files changed, 3 insertions(+) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index e9c564b..be5bb51 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -60,6 +60,7 @@ module Gapic :bytes_uploaded, # [Integer] Cumulative bytes acknowledged by the server (may decrease on recovery rewind) :total_bytes # [Integer, nil] Total upload size in bytes if known ) do + # Important to define it via `self.`, since this block is not a class body self::PHASES = %i[initiating uploading recovering finalizing cancelling completed].freeze end end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb index 7832d5c..05a8a6f 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb @@ -106,9 +106,11 @@ def initialize initial_url:, :bytes_uploaded, :total_bytes ) do + # Important to define it via `self.`, since this block is not a class body self::PHASES = [:initiating, :uploading, :recovering, :finalizing, :cancelling, :completed].freeze def initialize phase:, bytes_uploaded:, total_bytes: nil + # Must use `self.class::` to access constants from the class scope unless self.class::PHASES.include? phase raise ArgumentError, "Invalid phase: #{phase.inspect}. Expected one of #{self.class::PHASES.inspect}" end From 970a42881728e7a7c1dfa7aba565368bbcfb7f19 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Mon, 7 Sep 2026 20:13:04 +0000 Subject: [PATCH 40/79] fix: bound transport retries --- gapic-common/design/implementation-guide.md | 10 ++- .../lib/gapic/rest/resumable_upload/driver.rb | 17 ++++- .../resumable_upload/driver_config_test.rb | 64 ++++++++++++++++++- 3 files changed, 86 insertions(+), 5 deletions(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index be5bb51..1ba7be3 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -60,7 +60,7 @@ module Gapic :bytes_uploaded, # [Integer] Cumulative bytes acknowledged by the server (may decrease on recovery rewind) :total_bytes # [Integer, nil] Total upload size in bytes if known ) do - # Important to define it via `self.`, since this block is not a class body + # Important to define it via `self.`, since this block is not a class body self::PHASES = %i[initiating uploading recovering finalizing cancelling completed].freeze end end @@ -428,6 +428,14 @@ The total session timeout is resolved in priority order: *Rationale*: Using `BASE_TIMEOUT` as a floor prevents sub-millisecond timeouts for small payloads while scaling linearly for multi-gigabyte uploads. 3. **Default Base Timeout (`BASE_TIMEOUT`)**: If neither a positive timeout nor `upload_size` is provided (e.g., streaming uploads of unknown length), the timeout defaults to `BASE_TIMEOUT` (`3_600` seconds). +#### Bounding Transport Retries by Global Deadline +Transport retries and individual HTTP exchanges must never exceed the remaining global deadline. When `Driver#make_post_request` invokes `ClientStub#make_post_request`, it computes the per-request timeout from the remaining session budget (`max(deadline - monotonic_now, 0)`), additionally capped by `retry_policy.timeout`: +```ruby +remaining = [@deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max +timeout = retry_policy&.timeout ? [remaining, retry_policy.timeout].min : remaining +``` +This timeout is passed in `options[:timeout]`, ensuring that underlying Faraday requests and `Gapic::Common::RetryPolicy` evaluations always respect the remaining upload budget. + --- ## 7. Observability Standards diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index 7e5f6dc..bce334b 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -175,6 +175,17 @@ def resolve_timeout end end + def request_timeout retry_policy + remaining = if @deadline + [@deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max + else + resolve_timeout + end + return [remaining, retry_policy.timeout].min if retry_policy&.timeout + + remaining + end + def deadline_exceeded? return false unless @deadline @@ -344,7 +355,11 @@ def execute_send_cancel instruction end def make_post_request url, headers:, body:, retry_policy:, method_name: nil, start_attempt: 1 - options = { metadata: headers, retry_policy: retry_policy } + options = { + metadata: headers, + retry_policy: retry_policy, + timeout: request_timeout(retry_policy) + } @upload_log.wire_send method: "POST", url: url, headers: headers, start_attempt: start_attempt, body_size: body.to_s.bytesize, body: body diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb index 64a2f5d..b76ffa0 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb @@ -28,19 +28,26 @@ class DriverConfigTest < Minitest::Test class FakeClientStub attr_reader :requests - def initialize responses = [] + def initialize responses = [], on_request: nil @responses = responses @requests = [] + @on_request = on_request end def make_post_request uri:, body:, params:, options:, method_name: nil - @requests << { uri: uri, body: body, params: params, options: options } + @requests << { uri: uri, body: body, params: params, options: options, method_name: method_name } + @on_request&.call raise "Unexpected request: no scripted response left" if @responses.empty? - @responses.shift + resp = @responses.shift + raise resp if resp.is_a? Exception + + resp.respond_to?(:call) ? resp.call : resp end end + FakeResponse = Data.define :status, :headers, :body + def test_resolve_timeout_prefers_positive_config_timeout stub = FakeClientStub.new config = CompleteUploadConfig.new( @@ -134,4 +141,55 @@ def test_run_raises_deadline_exceeded_when_timeout_expires end assert_empty stub.requests end + + def test_make_post_request_passes_timeout_close_to_remaining_budget_and_decreases_across_calls + current_time = 1000.0 + stub = FakeClientStub.new(scripted_recovery_responses, on_request: -> { current_time += 10.0 }) + config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("0123"), + upload_size: 4, + chunk_size: 10, + timeout: 100.0, + data_plane_retry_policy: Gapic::Common::RetryPolicy.new(timeout: 85.0) + ) + driver = Driver.new client_stub: stub, config: config + + Process.stub :clock_gettime, ->(_clock_id) { current_time } do + assert_equal "done", driver.run + end + + timeouts = stub.requests.map { |req| req[:options][:timeout] } + assert_equal [100.0, 85.0, 80.0, 70.0], timeouts + timeouts.each_cons 2 do |prev_timeout, next_timeout| + assert_operator prev_timeout, :>, next_timeout + end + end + + private + + def scripted_recovery_responses + [ + FakeResponse.new( + status: 200, + headers: { "X-Goog-Upload-Status" => "active", "X-Goog-Upload-URL" => "https://example.com/session" }, + body: "" + ), + Gapic::Rest::Error.new( + "Service Unavailable", + 503, + headers: { "X-Goog-Upload-Status" => "active" } + ), + FakeResponse.new( + status: 200, + headers: { "X-Goog-Upload-Status" => "active", "X-Goog-Upload-Size-Received" => "0" }, + body: "" + ), + FakeResponse.new( + status: 200, + headers: { "X-Goog-Upload-Status" => "final" }, + body: "done" + ) + ] + end end From bd386cfa753b0e0589a80e3eebe6045033410339 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Mon, 7 Sep 2026 20:25:08 +0000 Subject: [PATCH 41/79] fix: boundary condition around transport timeout --- .../lib/gapic/rest/resumable_upload/driver.rb | 2 ++ .../resumable_upload/driver_config_test.rb | 34 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index bce334b..f1ad547 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -355,6 +355,8 @@ def execute_send_cancel instruction end def make_post_request url, headers:, body:, retry_policy:, method_name: nil, start_attempt: 1 + return Event::GlobalDeadlineExceeded.new if deadline_exceeded? + options = { metadata: headers, retry_policy: retry_policy, diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb index b76ffa0..6edf227 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb @@ -142,6 +142,40 @@ def test_run_raises_deadline_exceeded_when_timeout_expires assert_empty stub.requests end + def test_run_raises_deadline_exceeded_when_clock_advances_past_deadline_mid_batch + current_time = 100.0 + responses = [ + FakeResponse.new( + status: 200, + headers: { "X-Goog-Upload-Status" => "active", "X-Goog-Upload-URL" => "https://example.com/session" }, + body: "" + ) + ] + stub = FakeClientStub.new responses + # Advance clock past deadline (105.0) mid-batch during NotifyProgress(:finalizing) before SendChunk + on_progress = lambda do |progress| + current_time = 110.0 if progress.phase == :finalizing + end + config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("0123"), + upload_size: 4, + chunk_size: 10, + timeout: 5, + on_progress: on_progress + ) + driver = Driver.new client_stub: stub, config: config + + Process.stub :clock_gettime, ->(_clock_id) { current_time } do + assert_raises Gapic::Common::DeadlineExceededError do + driver.run + end + end + + # Only the start request was made; SendChunk hit deadline_exceeded? inside make_post_request + assert_equal 1, stub.requests.size + end + def test_make_post_request_passes_timeout_close_to_remaining_budget_and_decreases_across_calls current_time = 1000.0 stub = FakeClientStub.new(scripted_recovery_responses, on_request: -> { current_time += 10.0 }) From a34cfbf77ea76ca0e0ebece707170dd8d704cb53 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Mon, 7 Sep 2026 20:36:12 +0000 Subject: [PATCH 42/79] fix: configurable retry policies --- gapic-common/design/implementation-guide.md | 7 +- .../design/reference-implementation.md | 73 ++++++------ .../gapic/rest/resumable_upload/data_types.rb | 14 ++- .../lib/gapic/rest/resumable_upload/driver.rb | 24 +++- .../rest/resumable_upload/retry_policies.rb | 78 +++++++------ .../driver_retry_policy_test.rb | 107 ++++++++++++++++++ .../resumable_upload/driver_retry_test.rb | 32 +----- 7 files changed, 224 insertions(+), 111 deletions(-) create mode 100644 gapic-common/test/gapic/rest/resumable_upload/driver_retry_policy_test.rb diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index 1ba7be3..ae7b536 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -49,9 +49,9 @@ module Gapic :chunk_size, # [Integer, nil] Explicit chunk size in bytes :content_type, # [String] MIME type of uploaded media :timeout, # [Numeric, nil] Total upload timeout in seconds (zero/negative treated as nil) - :start_retry_policy, # [Gapic::Common::RetryPolicy, nil] Default policy for start command - :control_plane_retry_policy, # [Gapic::Common::RetryPolicy, nil] Policy for query/cancel commands - :data_plane_retry_policy, # [Gapic::Common::RetryPolicy, nil] Policy for upload/finalize + :start_retry_policy, # [Gapic::Common::RetryPolicy, Hash, nil] Policy or hash override for start command + :control_plane_retry_policy, # [Gapic::Common::RetryPolicy, Hash, nil] Policy or hash override for query/cancel commands + :data_plane_retry_policy, # [Gapic::Common::RetryPolicy, Hash, nil] Policy or hash override for upload/finalize :on_progress # [Proc, nil] Callback: ->(progress) with a Progress instance ) @@ -214,6 +214,7 @@ Full implementation: [reference-implementation.md#3-driver-class](reference-impl * **Start Policy (`start_retry_policy`)**: Applies specifically to session initiation (`start`). Configured with standard retry codes (`["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"]`) and network errors (`[Faraday::ConnectionFailed, Faraday::TimeoutError, SocketError]`). A missing or empty `X-Goog-Upload-Status` header is treated as **retriable** (predicate returns `true`) across **any response code, including 200 OK**. * **Control Plane Policy (`control_plane_retry_policy`)**: Applies to session control requests (`query`, `cancel`). Configured with standard retry codes and network errors. It does **not** retry on a missing `X-Goog-Upload-Status` header, allowing `Core` to evaluate responses immediately. * **Data Plane Policy (`data_plane_retry_policy`)**: Applies to data transmission requests (`upload`, `upload,finalize`, and standalone `finalize`). Shares the standard retry codes and network errors, but treats a missing `X-Goog-Upload-Status` header as **unretriable** (predicate returns `false`). This prevents blind chunk re-transmission and returns `Event::HttpResponse` immediately to `Core` so it can initiate Category 2 `Recovery`. + * **Retry Policy Override Contract**: Each retry policy configuration field accepts a `Gapic::Common::RetryPolicy` instance, a `Hash`, or `nil`. Passing a `RetryPolicy` instance replaces the default policy entirely. Passing a `Hash` constructs a new `RetryPolicy` and applies the category's defaults (`RetryPolicy.new(**hash).apply_defaults(defaults)`), overriding the specified fields while preserving unspecified defaults such as `retry_codes` and `retry_predicate`. Passing `nil` constructs the default policy directly from the category defaults. ### 4.2 State Transition & Data Mutation Specification diff --git a/gapic-common/design/reference-implementation.md b/gapic-common/design/reference-implementation.md index 3002a47..d43eb1c 100644 --- a/gapic-common/design/reference-implementation.md +++ b/gapic-common/design/reference-implementation.md @@ -476,9 +476,9 @@ module Gapic client_id: client_stub.object_id @upload_log = UploadLog.new(stub_logger, upload_id: "unstarted") - @start_retry_policy = config.start_retry_policy || self.class.default_start_retry_policy - @control_plane_retry_policy = config.control_plane_retry_policy || self.class.default_control_plane_retry_policy - @data_plane_retry_policy = config.data_plane_retry_policy || self.class.default_data_plane_retry_policy + @start_retry_policy = resolve_retry_policy(config.start_retry_policy, RetryPolicies::START_DEFAULTS) + @control_plane_retry_policy = resolve_retry_policy(config.control_plane_retry_policy, RetryPolicies::CONTROL_PLANE_DEFAULTS) + @data_plane_retry_policy = resolve_retry_policy(config.data_plane_retry_policy, RetryPolicies::DATA_PLANE_DEFAULTS) end # Default retry policy for session initiation requests (start). @@ -486,19 +486,7 @@ module Gapic # # @return [Gapic::Common::RetryPolicy] def self.default_start_retry_policy - Gapic::Common::RetryPolicy.new( - retry_codes: ["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"], - initial_delay: 1.0, - max_delay: 15.0, - multiplier: 1.3, - retry_predicate: lambda do |error_or_response| - if error_or_response.respond_to?(:headers) - status_hdr = error_or_response.headers["x-goog-upload-status"] - return true if status_hdr.nil? || status_hdr.empty? - end - nil - end - ) + RetryPolicies.default_start end # Default retry policy for session control requests (query, cancel). @@ -506,12 +494,7 @@ module Gapic # # @return [Gapic::Common::RetryPolicy] def self.default_control_plane_retry_policy - Gapic::Common::RetryPolicy.new( - retry_codes: ["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"], - initial_delay: 1.0, - max_delay: 15.0, - multiplier: 1.3 - ) + RetryPolicies.default_control_plane end # Default retry policy for data plane requests (upload, finalize, upload_finalize). @@ -520,19 +503,7 @@ module Gapic # # @return [Gapic::Common::RetryPolicy] def self.default_data_plane_retry_policy - Gapic::Common::RetryPolicy.new( - retry_codes: ["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"], - initial_delay: 1.0, - max_delay: 15.0, - multiplier: 1.3, - retry_predicate: lambda do |error_or_response| - if error_or_response.respond_to?(:headers) - status_hdr = error_or_response.headers["x-goog-upload-status"] - return false if status_hdr.nil? || status_hdr.empty? - end - nil - end - ) + RetryPolicies.default_data_plane end # Executes event loop until terminal state. @@ -594,6 +565,19 @@ module Gapic end end + def resolve_retry_policy(value, defaults) + case value + when Gapic::Common::RetryPolicy + value + when Hash + Gapic::Common::RetryPolicy.new(**value).apply_defaults(defaults) + when nil + Gapic::Common::RetryPolicy.new(**defaults) + else + raise ArgumentError, "Expected RetryPolicy, Hash, or nil, got #{value.class}" + end + end + def resolve_timeout return @config.timeout if @config.timeout&.positive? @@ -604,6 +588,17 @@ module Gapic end end + def request_timeout(retry_policy) + remaining = if @deadline + [@deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max + else + resolve_timeout + end + return [remaining, retry_policy.timeout].min if retry_policy&.timeout + + remaining + end + def deadline_exceeded? return false unless @deadline @@ -647,7 +642,13 @@ module Gapic end def make_post_request(url, headers:, body:, retry_policy:, method_name: nil, start_attempt: 1) - options = { metadata: headers, retry_policy: retry_policy } + return Event::GlobalDeadlineExceeded.new if deadline_exceeded? + + options = { + metadata: headers, + retry_policy: retry_policy, + timeout: request_timeout(retry_policy) + } @upload_log.wire_send( method: "POST", url: url, headers: headers, start_attempt: start_attempt, body_size: body.to_s.bytesize, body: body diff --git a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb index 05a8a6f..362e421 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb @@ -38,11 +38,19 @@ module ResumableUpload # @!attribute [r] timeout # @return [Numeric, nil] Total upload timeout in seconds (zero/negative values treated as nil) # @!attribute [r] start_retry_policy - # @return [Gapic::Common::RetryPolicy, nil] Default policy for start command + # @return [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for session initiation (start). + # Passing a {Gapic::Common::RetryPolicy} replaces the default policy. + # Passing a Hash overrides specified settings while preserving unspecified defaults + # (such as retry codes and predicates). # @!attribute [r] control_plane_retry_policy - # @return [Gapic::Common::RetryPolicy, nil] Policy for query/cancel commands + # @return [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for session control commands (query/cancel). + # Passing a {Gapic::Common::RetryPolicy} replaces the default policy. + # Passing a Hash overrides specified settings while preserving unspecified defaults. # @!attribute [r] data_plane_retry_policy - # @return [Gapic::Common::RetryPolicy, nil] Policy for upload/finalize + # @return [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for data transmission commands (upload/finalize). + # Passing a {Gapic::Common::RetryPolicy} replaces the default policy. + # Passing a Hash overrides specified settings while preserving unspecified defaults + # (such as retry codes and predicates). # @!attribute [r] on_progress # @return [Proc, nil] Callback invoked as `->(progress)` with a {Progress} instance # diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index f1ad547..170e718 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -71,12 +71,11 @@ def initialize client_stub:, config:, core: nil, logger: nil client_id: client_stub.object_id @upload_log = UploadLog.new stub_logger, upload_id: "unstarted" - @start_retry_policy = config.start_retry_policy || - self.class.default_start_retry_policy - @control_plane_retry_policy = config.control_plane_retry_policy || - self.class.default_control_plane_retry_policy - @data_plane_retry_policy = config.data_plane_retry_policy || - self.class.default_data_plane_retry_policy + @start_retry_policy = resolve_retry_policy config.start_retry_policy, RetryPolicies::START_DEFAULTS + @control_plane_retry_policy = resolve_retry_policy config.control_plane_retry_policy, + RetryPolicies::CONTROL_PLANE_DEFAULTS + @data_plane_retry_policy = resolve_retry_policy config.data_plane_retry_policy, + RetryPolicies::DATA_PLANE_DEFAULTS end ## @@ -165,6 +164,19 @@ def dispatch_instruction instruction end end + def resolve_retry_policy value, defaults + case value + when Gapic::Common::RetryPolicy + value + when Hash + Gapic::Common::RetryPolicy.new(**value).apply_defaults(defaults) + when nil + Gapic::Common::RetryPolicy.new(**defaults) + else + raise ArgumentError, "Expected RetryPolicy, Hash, or nil, got #{value.class}" + end + end + def resolve_timeout return @config.timeout if @config.timeout&.positive? diff --git a/gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb b/gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb index d627cd2..2de45ba 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb @@ -23,6 +23,47 @@ module ResumableUpload # Default retry policy generators for control plane and data plane requests. # module RetryPolicies + START_PREDICATE = lambda do |error_or_response| + headers = extract_headers error_or_response + if headers + status_hdr = headers["x-goog-upload-status"] || headers["X-Goog-Upload-Status"] + return true if status_hdr.nil? || status_hdr.empty? + end + nil + end + + DATA_PLANE_PREDICATE = lambda do |error_or_response| + headers = extract_headers error_or_response + if headers + status_hdr = headers["x-goog-upload-status"] || headers["X-Goog-Upload-Status"] + return false if status_hdr.nil? || status_hdr.empty? + end + nil + end + + START_DEFAULTS = { + retry_codes: ["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"].freeze, + initial_delay: 1.0, + max_delay: 15.0, + multiplier: 1.3, + retry_predicate: START_PREDICATE + }.freeze + + CONTROL_PLANE_DEFAULTS = { + retry_codes: ["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"].freeze, + initial_delay: 1.0, + max_delay: 15.0, + multiplier: 1.3 + }.freeze + + DATA_PLANE_DEFAULTS = { + retry_codes: ["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"].freeze, + initial_delay: 1.0, + max_delay: 15.0, + multiplier: 1.3, + retry_predicate: DATA_PLANE_PREDICATE + }.freeze + ## # Default retry policy for session initiation requests (start). # Missing X-Goog-Upload-Status header is retriable across any response code, @@ -30,20 +71,7 @@ module RetryPolicies # # @return [Gapic::Common::RetryPolicy] def self.default_start - Gapic::Common::RetryPolicy.new( - retry_codes: ["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"], - initial_delay: 1.0, - max_delay: 15.0, - multiplier: 1.3, - retry_predicate: lambda do |error_or_response| - headers = extract_headers error_or_response - if headers - status_hdr = headers["x-goog-upload-status"] || headers["X-Goog-Upload-Status"] - return true if status_hdr.nil? || status_hdr.empty? - end - nil - end - ) + Gapic::Common::RetryPolicy.new(**START_DEFAULTS) end ## @@ -52,12 +80,7 @@ def self.default_start # # @return [Gapic::Common::RetryPolicy] def self.default_control_plane - Gapic::Common::RetryPolicy.new( - retry_codes: ["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"], - initial_delay: 1.0, - max_delay: 15.0, - multiplier: 1.3 - ) + Gapic::Common::RetryPolicy.new(**CONTROL_PLANE_DEFAULTS) end ## @@ -66,20 +89,7 @@ def self.default_control_plane # # @return [Gapic::Common::RetryPolicy] def self.default_data_plane - Gapic::Common::RetryPolicy.new( - retry_codes: ["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"], - initial_delay: 1.0, - max_delay: 15.0, - multiplier: 1.3, - retry_predicate: lambda do |error_or_response| - headers = extract_headers error_or_response - if headers - status_hdr = headers["x-goog-upload-status"] || headers["X-Goog-Upload-Status"] - return false if status_hdr.nil? || status_hdr.empty? - end - nil - end - ) + Gapic::Common::RetryPolicy.new(**DATA_PLANE_DEFAULTS) end ## diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_retry_policy_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_retry_policy_test.rb new file mode 100644 index 0000000..fc48607 --- /dev/null +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_retry_policy_test.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "test_helper" +require "gapic/rest/resumable_upload" +require "stringio" + +## +# Tests for ResumableUpload Driver retry policy resolution and configuration overrides. +# +class DriverRetryPolicyTest < Minitest::Test + include Gapic::Rest::ResumableUpload + + def setup + @config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("0123") + ) + @driver = Driver.new client_stub: Object.new, config: @config + end + + def test_resolve_retry_policy_with_nil_returns_default_policy + policy = @driver.send :resolve_retry_policy, nil, RetryPolicies::START_DEFAULTS + + assert_kind_of Gapic::Common::RetryPolicy, policy + assert_equal RetryPolicies.default_start.retry_codes, policy.retry_codes + assert_in_delta 1.0, policy.initial_delay + assert_in_delta 15.0, policy.max_delay + assert_in_delta 1.3, policy.multiplier + assert_same RetryPolicies::START_PREDICATE, policy.retry_predicate + end + + def test_resolve_retry_policy_with_policy_instance_returns_as_is + custom_policy = Gapic::Common::RetryPolicy.new initial_delay: 5.0 + resolved = @driver.send :resolve_retry_policy, custom_policy, RetryPolicies::START_DEFAULTS + + assert_same custom_policy, resolved + assert_nil resolved.retry_predicate + assert_empty resolved.retry_codes + end + + def test_resolve_retry_policy_with_hash_applies_defaults_and_preserves_codes_and_predicate + hash_override = { initial_delay: 0.25, max_delay: 2.0 } + resolved = @driver.send :resolve_retry_policy, hash_override, RetryPolicies::START_DEFAULTS + + assert_kind_of Gapic::Common::RetryPolicy, resolved + assert_in_delta 0.25, resolved.initial_delay + assert_in_delta 2.0, resolved.max_delay + assert_in_delta 1.3, resolved.multiplier + assert_equal RetryPolicies.default_start.retry_codes, resolved.retry_codes + assert_same RetryPolicies::START_PREDICATE, resolved.retry_predicate + end + + def test_resolve_retry_policy_with_data_plane_hash_preserves_data_plane_predicate + hash_override = { timeout: 60.0 } + resolved = @driver.send :resolve_retry_policy, hash_override, RetryPolicies::DATA_PLANE_DEFAULTS + + assert_kind_of Gapic::Common::RetryPolicy, resolved + assert_in_delta 60.0, resolved.timeout + assert_equal RetryPolicies.default_data_plane.retry_codes, resolved.retry_codes + assert_same RetryPolicies::DATA_PLANE_PREDICATE, resolved.retry_predicate + end + + def test_resolve_retry_policy_with_invalid_type_raises_argument_error + err = assert_raises ArgumentError do + @driver.send :resolve_retry_policy, "invalid", RetryPolicies::START_DEFAULTS + end + assert_match(/Expected RetryPolicy, Hash, or nil/, err.message) + end + + def test_driver_initialize_resolves_hash_overrides_from_config + config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("0123"), + start_retry_policy: { initial_delay: 0.1 }, + control_plane_retry_policy: { max_delay: 5.0 }, + data_plane_retry_policy: { multiplier: 2.0 } + ) + driver = Driver.new client_stub: Object.new, config: config + + start_policy = driver.instance_variable_get :@start_retry_policy + control_policy = driver.instance_variable_get :@control_plane_retry_policy + data_policy = driver.instance_variable_get :@data_plane_retry_policy + + assert_in_delta 0.1, start_policy.initial_delay + assert_same RetryPolicies::START_PREDICATE, start_policy.retry_predicate + + assert_in_delta 5.0, control_policy.max_delay + assert_nil control_policy.retry_predicate + + assert_in_delta 2.0, data_policy.multiplier + assert_same RetryPolicies::DATA_PLANE_PREDICATE, data_policy.retry_predicate + end +end diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb index 96f066a..43a18b2 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb @@ -37,7 +37,7 @@ def initialize responses end def make_post_request uri:, body:, params:, options:, method_name: nil - @requests << { uri: uri, body: body, params: params, options: options } + @requests << { uri: uri, body: body, params: params, options: options, method_name: method_name } raise "Unexpected request: no scripted response left" if @responses.empty? @responses.shift @@ -45,19 +45,6 @@ def make_post_request uri:, body:, params:, options:, method_name: nil end def test_start_retries_when_response_lacks_status_header_even_on_200 - fast_policy = Gapic::Common::RetryPolicy.new( - initial_delay: 0.001, - max_delay: 0.002, - timeout: 1.0, - retry_predicate: lambda do |error_or_response| - headers = RetryPolicies.extract_headers error_or_response - if headers - status_hdr = headers["x-goog-upload-status"] || headers["X-Goog-Upload-Status"] - return true if status_hdr.nil? || status_hdr.empty? - end - nil - end - ) responses = [ FakeResponse.new(status: 200, headers: {}, body: ""), FakeResponse.new( @@ -76,7 +63,7 @@ def test_start_retries_when_response_lacks_status_header_even_on_200 stream: StringIO.new("0123"), upload_size: 4, chunk_size: 10, - start_retry_policy: fast_policy + start_retry_policy: { initial_delay: 0.001, max_delay: 0.002, timeout: 1.0 } ) driver = Driver.new client_stub: stub, config: config @@ -90,19 +77,6 @@ def test_start_retries_when_response_lacks_status_header_even_on_200 end def test_start_exhausts_retries_when_responses_continually_lack_status_header - exhausting_policy = Gapic::Common::RetryPolicy.new( - initial_delay: 0.001, - max_delay: 0.002, - timeout: 0.01, - retry_predicate: lambda do |error_or_response| - headers = RetryPolicies.extract_headers error_or_response - if headers - status_hdr = headers["x-goog-upload-status"] || headers["X-Goog-Upload-Status"] - return true if status_hdr.nil? || status_hdr.empty? - end - nil - end - ) responses = Array.new(10) { FakeResponse.new status: 200, headers: {}, body: "" } stub = FakeClientStub.new responses config = CompleteUploadConfig.new( @@ -110,7 +84,7 @@ def test_start_exhausts_retries_when_responses_continually_lack_status_header stream: StringIO.new("0123"), upload_size: 4, chunk_size: 10, - start_retry_policy: exhausting_policy + start_retry_policy: { initial_delay: 0.001, max_delay: 0.002, timeout: 0.01 } ) driver = Driver.new client_stub: stub, config: config From 07ce995bddbfb372524de92e5cb864705c205d6f Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Mon, 7 Sep 2026 20:47:48 +0000 Subject: [PATCH 43/79] tests: nail down some invariants --- .../resumable_upload/driver_retry_policy_test.rb | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_retry_policy_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_retry_policy_test.rb index fc48607..40636e8 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_retry_policy_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_retry_policy_test.rb @@ -74,6 +74,22 @@ def test_resolve_retry_policy_with_data_plane_hash_preserves_data_plane_predicat assert_same RetryPolicies::DATA_PLANE_PREDICATE, resolved.retry_predicate end + def test_resolve_retry_policy_with_empty_retry_codes_array_honors_empty_array + hash_override = { retry_codes: [] } + resolved = @driver.send :resolve_retry_policy, hash_override, RetryPolicies::START_DEFAULTS + + assert_kind_of Gapic::Common::RetryPolicy, resolved + assert_empty resolved.retry_codes + assert_same RetryPolicies::START_PREDICATE, resolved.retry_predicate + end + + def test_resolve_retry_policy_with_unknown_hash_key_raises_argument_error + err = assert_raises ArgumentError do + @driver.send :resolve_retry_policy, { unknown_key: 123 }, RetryPolicies::START_DEFAULTS + end + assert_match(/unknown keyword: :unknown_key/, err.message) + end + def test_resolve_retry_policy_with_invalid_type_raises_argument_error err = assert_raises ArgumentError do @driver.send :resolve_retry_policy, "invalid", RetryPolicies::START_DEFAULTS From ec864955c7e1142228bfba0372f077c80963ba17 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Tue, 8 Sep 2026 14:45:56 +0000 Subject: [PATCH 44/79] tests: integration testing helper defaults --- gapic-common/design/integration-test-plan.md | 20 +++++++- .../integration/integration_helper.rb | 46 +++++++++++++++--- .../chunk_granularity_test.rb | 48 +++++++++++++++++++ 3 files changed, 106 insertions(+), 8 deletions(-) create mode 100644 gapic-common/integration/resumable_upload/chunk_granularity_test.rb diff --git a/gapic-common/design/integration-test-plan.md b/gapic-common/design/integration-test-plan.md index 9107280..72d1fb7 100644 --- a/gapic-common/design/integration-test-plan.md +++ b/gapic-common/design/integration-test-plan.md @@ -46,7 +46,8 @@ flowchart TD ### 1.2 Test Harness (`integration/integration_helper.rb`) * **`ShowcaseIntegrationTest`**: Base class providing helper methods for test configuration: * `showcase_client_stub`: Instantiates a real `Gapic::Rest::ClientStub` targeting `SHOWCASE_ENDPOINT` with `raise_faraday_errors: false` and an attached `DEBUG` logger. - * `build_config`: Creates a `CompleteUploadConfig` targeting `/resumable/upload/v1beta1/files:upload` with a default `on_progress` callback that appends every `Progress` struct to `@progress_records`. + * `build_config(scenario: nil, scenario_config: {}, **overrides)`: Creates a `CompleteUploadConfig` targeting `/resumable/upload/v1beta1/files:upload`. When `scenario` is provided, injects `X-Goog-Test-Scenario` and `X-Goog-Test-Scenario-Config` (with a generated `client_uuid` merged with `scenario_config`) into `initial_headers`. Configures fast retry policies (`FAST_RETRY = { initial_delay: 0.01, max_delay: 0.05, multiplier: 1, timeout: 2 }`), a default 10-second session timeout, a default payload of `786_432` bytes (`3 * 262_144`), default chunk size of `262_144` bytes, and an `on_progress` callback appending each `Progress` struct to `@progress_records`. + * `phases` & `offsets`: Convenience accessors returning `@progress_records.map(&:phase)` and `@progress_records.map(&:bytes_uploaded)`. * `payload(size)`: Generates deterministic binary strings of arbitrary byte length for stream uploads. * `UnseekableStream`: Stream wrapper around `StringIO` that exposes `#read` and `#pos` while omitting `#seek` (`respond_to?(:seek)` is `false`). * **Diagnostic Trace Capture**: Buffers `DEBUG`-level driver logs in memory during each test run and dumps the full trace to `stderr` only if a test fails (or when `SHOWCASE_LOG` is set). @@ -111,3 +112,20 @@ Tests standard, uninterrupted resumable upload workflows against `gapic-showcase * `Progress(phase: :uploading, bytes_uploaded: 786_432, total_bytes: nil)` * `Progress(phase: :finalizing, bytes_uploaded: 786_432, total_bytes: nil)` * `Progress(phase: :completed, bytes_uploaded: 786_432, total_bytes: 786_432)` + +### 2.2 Chunk Granularity Suite (`integration/resumable_upload/chunk_granularity_test.rb`) + +Tests dynamic chunk size resolution when the server mandates a byte alignment modulus via `X-Goog-Upload-Chunk-Granularity`. + +#### Case 1. Downward alignment to server granularity (`test_chunk_granularity_alignment`) +* **Scenario**: Uploads a `1_000_000`-byte payload with `scenario: "chunk_granularity"`, an explicit unaligned user `chunk_size: 300_000`, and `timeout: 5`. +* **Protocol Flow**: + 1. `start` command initiates the session; Showcase returns `X-Goog-Upload-Chunk-Granularity: 256`. + 2. Client resolves the effective chunk size down to the nearest multiple of 256: `300_000 - (300_000 % 256) = 299_776` bytes. + 3. Chunks 1, 2, and 3 transmit `299_776` bytes each (`upload`), advancing confirmed offsets to `299_776`, `599_552`, and `899_328`. + 4. Final chunk transmits the remaining `100_672` bytes (`899_328..999_999`) with `upload, finalize`. +* **Assertions**: + * Returned JSON body reports `"size" == 1_000_000`. + * `offsets` equals `[0, 0, 299_776, 599_552, 899_328, 899_328, 1_000_000]`. + * `phases` equals `[:initiating, :uploading, :uploading, :uploading, :uploading, :finalizing, :completed]`. + diff --git a/gapic-common/integration/integration_helper.rb b/gapic-common/integration/integration_helper.rb index cf031de..951eadc 100644 --- a/gapic-common/integration/integration_helper.rb +++ b/gapic-common/integration/integration_helper.rb @@ -14,7 +14,9 @@ # See the License for the specific language governing permissions and # limitations under the License. +require "json" require "logger" +require "securerandom" require "stringio" require "minitest/autorun" require "minitest/focus" @@ -29,6 +31,9 @@ # class ShowcaseIntegrationTest < Minitest::Test UPLOAD_PATH = "/resumable/upload/v1beta1/files:upload" + FAST_RETRY = { initial_delay: 0.01, max_delay: 0.05, multiplier: 1, timeout: 2 }.freeze + DEFAULT_CHUNK_SIZE = 262_144 + DEFAULT_PAYLOAD_SIZE = DEFAULT_CHUNK_SIZE * 3 ## # Stream double that intentionally does not implement #seek. @@ -50,6 +55,14 @@ def pos attr_reader :logger attr_reader :progress_records + def phases + @progress_records.map(&:phase) + end + + def offsets + @progress_records.map(&:bytes_uploaded) + end + def showcase_endpoint ENV["SHOWCASE_ENDPOINT"] end @@ -75,19 +88,38 @@ def payload size def showcase_client_stub Gapic::Rest::ClientStub.new( - endpoint: showcase_endpoint, - credentials: :dummy_credentials, + endpoint: showcase_endpoint, + credentials: :dummy_credentials, raise_faraday_errors: false, - logger: @logger + logger: @logger ) end - def build_config **overrides + def build_config scenario: nil, scenario_config: {}, **overrides @progress_records = [] + headers = (overrides[:initial_headers] || {}).dup + if scenario + headers["X-Goog-Test-Scenario"] = scenario + headers["X-Goog-Test-Scenario-Config"] = JSON.generate( + { "client_uuid" => SecureRandom.uuid }.merge(scenario_config) + ) + end + defaults = { - initial_url: UPLOAD_PATH, - on_progress: ->(progress) { @progress_records << progress } + initial_url: UPLOAD_PATH, + initial_headers: headers, + start_retry_policy: FAST_RETRY, + control_plane_retry_policy: FAST_RETRY, + data_plane_retry_policy: FAST_RETRY, + timeout: 10, + chunk_size: DEFAULT_CHUNK_SIZE, + on_progress: ->(progress) { @progress_records << progress } } - Gapic::Rest::ResumableUpload::CompleteUploadConfig.new(**defaults, **overrides) + unless overrides.key? :stream + defaults[:stream] = StringIO.new payload(DEFAULT_PAYLOAD_SIZE) + defaults[:upload_size] = DEFAULT_PAYLOAD_SIZE + end + + Gapic::Rest::ResumableUpload::CompleteUploadConfig.new(**defaults, **overrides, initial_headers: headers) end end diff --git a/gapic-common/integration/resumable_upload/chunk_granularity_test.rb b/gapic-common/integration/resumable_upload/chunk_granularity_test.rb new file mode 100644 index 0000000..ea05965 --- /dev/null +++ b/gapic-common/integration/resumable_upload/chunk_granularity_test.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "integration_helper" +require "json" +require "stringio" + +## +# Integration tests for chunk granularity alignment against Showcase. +# +class ChunkGranularityTest < ShowcaseIntegrationTest + # Verifies chunk size alignment to server-specified granularity (300_000 -> 299_776) and progress notifications. + def test_chunk_granularity_alignment + size = 1_000_000 + config = build_config( + scenario: "chunk_granularity", + stream: StringIO.new(payload(size)), + upload_size: size, + chunk_size: 300_000, + timeout: 5 + ) + + driver = Gapic::Rest::ResumableUpload::Driver.new( + client_stub: showcase_client_stub, + config: config + ) + + result = driver.run + parsed = JSON.parse result + + assert_equal size, parsed["size"] + assert_equal [0, 0, 299_776, 599_552, 899_328, 899_328, 1_000_000], offsets + assert_equal [:initiating, :uploading, :uploading, :uploading, :uploading, :finalizing, :completed], phases + end +end From 903b9cbbd08212657be5d95dfffad6c1ebaaa502 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Tue, 8 Sep 2026 14:53:31 +0000 Subject: [PATCH 45/79] tests: recovery integration --- .../resumable_upload/error_recovery_test.rb | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 gapic-common/integration/resumable_upload/error_recovery_test.rb diff --git a/gapic-common/integration/resumable_upload/error_recovery_test.rb b/gapic-common/integration/resumable_upload/error_recovery_test.rb new file mode 100644 index 0000000..f9ab894 --- /dev/null +++ b/gapic-common/integration/resumable_upload/error_recovery_test.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "integration_helper" +require "json" +require "stringio" + +## +# Suite B: Integration tests for Category 1 transient retries and Category 2 error recovery against Showcase. +# +class ErrorRecoveryTest < ShowcaseIntegrationTest + SCENARIO = "non_fatal_error_on_chunk_upload" + + # B1. Verifies Category 1 transient error (503) is retried transparently by FAST_RETRY without entering recovery. + def test_cat1_error_retried_transparently + config = build_config( + scenario: SCENARIO, + scenario_config: { error_code: 503, failure_count: 1, after_offset: 0 }, + data_plane_retry_policy: FAST_RETRY + ) + + driver = Gapic::Rest::ResumableUpload::Driver.new( + client_stub: showcase_client_stub, + config: config + ) + + result = driver.run + parsed = JSON.parse result + + assert_equal DEFAULT_PAYLOAD_SIZE, parsed["size"] + refute_includes phases, :recovering + assert_equal [:initiating, :uploading, :uploading, :uploading, :uploading, :finalizing, :completed], phases + assert_equal [0, 0, 262_144, 524_288, 786_432, 786_432, 786_432], offsets + end + + # B2. Verifies simple Category 2 error (409) at offset 0 triggers recovery query and resumes upload to completion. + def test_simple_cat2_error_recovery + config = build_config( + scenario: SCENARIO, + scenario_config: { error_code: 409, failure_count: 1, after_offset: 0 } + ) + + driver = Gapic::Rest::ResumableUpload::Driver.new( + client_stub: showcase_client_stub, + config: config + ) + + result = driver.run + parsed = JSON.parse result + + assert_equal DEFAULT_PAYLOAD_SIZE, parsed["size"] + assert_equal( + [:initiating, :uploading, :recovering, :uploading, :uploading, :uploading, :uploading, :finalizing, :completed], + phases + ) + assert_equal [0, 0, 0, 0, 262_144, 524_288, 786_432, 786_432, 786_432], offsets + end + + # B3. Verifies two consecutive Category 2 recoveries (409) on chunk 2 at offset 262_144. + def test_two_consecutive_cat2_recoveries_on_chunk_2 + config = build_config( + scenario: SCENARIO, + scenario_config: { error_code: 409, failure_count: 2, after_offset: 262_144 } + ) + + driver = Gapic::Rest::ResumableUpload::Driver.new( + client_stub: showcase_client_stub, + config: config + ) + + result = driver.run + parsed = JSON.parse result + + assert_equal DEFAULT_PAYLOAD_SIZE, parsed["size"] + assert_equal( + [ + :initiating, :uploading, :uploading, + :recovering, :uploading, + :recovering, :uploading, + :uploading, :uploading, :finalizing, :completed + ], + phases + ) + assert_equal [0, 0, 262_144, 262_144, 262_144, 262_144, 262_144, 524_288, 786_432, 786_432, 786_432], offsets + end + + # B4. Verifies Category 2 error (409) on the finalizing chunk (upload, finalize) recovers and completes. + def test_cat2_failure_on_finalizing_chunk + size = (DEFAULT_CHUNK_SIZE * 3) - 100 + config = build_config( + scenario: SCENARIO, + scenario_config: { error_code: 409, failure_count: 1, after_offset: DEFAULT_CHUNK_SIZE * 2 }, + stream: StringIO.new(payload(size)), + upload_size: size + ) + + driver = Gapic::Rest::ResumableUpload::Driver.new( + client_stub: showcase_client_stub, + config: config + ) + + result = driver.run + parsed = JSON.parse result + + assert_equal size, parsed["size"] + assert_equal( + [:initiating, :uploading, :uploading, :uploading, :finalizing, :recovering, :uploading, :finalizing, :completed], + phases + ) + assert_equal [0, 0, 262_144, 524_288, 524_288, 524_288, 524_288, 524_288, size], offsets + end + + # B5. Verifies unrecoverable 500 without status header triggers repeated recovery until DeadlineExceededError. + def test_no_headers_failure_recovers_until_deadline_exceeded + config = build_config( + scenario: SCENARIO, + scenario_config: { failure_count: 0, action_after_failures: "terminate" }, + timeout: 0.3 + ) + + driver = Gapic::Rest::ResumableUpload::Driver.new( + client_stub: showcase_client_stub, + config: config + ) + + assert_raises Gapic::Common::DeadlineExceededError do + driver.run + end + + assert_operator phases.count(:recovering), :>=, 2 + end +end From 051b4a0d1b774432553d540eb9e8c725f881675b Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Tue, 8 Sep 2026 17:37:56 +0000 Subject: [PATCH 46/79] test: small fixes --- gapic-common/design/integration-test-plan.md | 62 +++++++++++++++++++ .../design/reference-implementation.md | 2 + .../resumable_upload/error_recovery_test.rb | 7 +-- .../resumable_upload/golden_path_test.rb | 3 +- .../lib/gapic/rest/resumable_upload/driver.rb | 6 ++ 5 files changed, 75 insertions(+), 5 deletions(-) diff --git a/gapic-common/design/integration-test-plan.md b/gapic-common/design/integration-test-plan.md index 72d1fb7..528f454 100644 --- a/gapic-common/design/integration-test-plan.md +++ b/gapic-common/design/integration-test-plan.md @@ -129,3 +129,65 @@ Tests dynamic chunk size resolution when the server mandates a byte alignment mo * `offsets` equals `[0, 0, 299_776, 599_552, 899_328, 899_328, 1_000_000]`. * `phases` equals `[:initiating, :uploading, :uploading, :uploading, :uploading, :finalizing, :completed]`. +### 2.3 Error Recovery Suite (`integration/resumable_upload/error_recovery_test.rb`) + +Tests Category 1 transient transport retries and Category 2 protocol recovery workflows against `scenario: "non_fatal_error_on_chunk_upload"`. + +#### Case 1. Category 1 transient error retried transparently (`test_cat1_error_retried_transparently`) +* **Scenario**: Injects a single `503 Service Unavailable` response at offset `0` (`error_code: 503, failure_count: 1, after_offset: 0`). +* **Protocol Flow**: + 1. `start` establishes the session (`200 active`). + 2. First attempt to upload chunk 1 (`0..262143`) receives `503`. + 3. `Driver` intercepts the transient error via `data_plane_retry_policy` (`FAST_RETRY`) and retries the chunk transparently without entering protocol `Recovery`. + 4. Subsequent chunks and standalone `finalize` succeed normally. +* **Assertions**: + * Returned JSON body reports `"size" == 786_432`. + * `phases` does not include `:recovering` (`[:initiating, :uploading, :uploading, :uploading, :uploading, :finalizing, :completed]`). + * `offsets` equals `[0, 0, 262_144, 524_288, 786_432, 786_432, 786_432]`. + +#### Case 2. Simple Category 2 error recovery at offset 0 (`test_simple_cat2_error_recovery`) +* **Scenario**: Injects a single `409 Conflict` with `X-Goog-Upload-Status: active` at offset `0` (`error_code: 409, failure_count: 1, after_offset: 0`). +* **Protocol Flow**: + 1. First upload chunk receives `409` (`:response_cat2`). + 2. `Core` transitions to `Recovery` (`:recovering`) and issues `SendQuery`. + 3. Server responds with `X-Goog-Upload-Size-Received: 0`; client realigns buffer to offset `0`, retransmits chunk 1, and completes the upload. +* **Assertions**: + * Returned JSON body reports `"size" == 786_432`. + * `phases` equals `[:initiating, :uploading, :recovering, :uploading, :uploading, :uploading, :uploading, :finalizing, :completed]`. + * `offsets` equals `[0, 0, 0, 0, 262_144, 524_288, 786_432, 786_432, 786_432]`. + +#### Case 3. Two consecutive Category 2 recoveries on chunk 2 (`test_two_consecutive_cat2_recoveries_on_chunk_2`) +* **Scenario**: Injects two consecutive `409` errors at offset `262_144` (`error_code: 409, failure_count: 2, after_offset: 262_144`). +* **Protocol Flow**: + 1. Chunk 1 (`0..262143`) succeeds. + 2. First attempt at chunk 2 (`262144..524287`) fails with `409` -> `:recovering` -> `query` (offset `262_144`) -> `:uploading`. + 3. Second attempt at chunk 2 fails with `409` -> `:recovering` -> `query` (offset `262_144`) -> `:uploading`. + 4. Third attempt at chunk 2 succeeds; chunk 3 and `finalize` complete normally. +* **Assertions**: + * Returned JSON body reports `"size" == 786_432`. + * `phases` equals `[:initiating, :uploading, :uploading, :recovering, :uploading, :recovering, :uploading, :uploading, :uploading, :finalizing, :completed]`. + * `offsets` equals `[0, 0, 262_144, 262_144, 262_144, 262_144, 262_144, 524_288, 786_432, 786_432, 786_432]`. + +#### Case 4. Category 2 failure on finalizing chunk (`test_cat2_failure_on_finalizing_chunk`) +* **Scenario**: Uploads a `786_332`-byte payload (`3 * 262_144 - 100`) where chunk 3 (`524288..786331`) carries `upload, finalize`, injecting a `409` error at offset `524_288` (`error_code: 409, failure_count: 1, after_offset: 524_288`). +* **Protocol Flow**: + 1. Chunks 1 and 2 succeed, advancing offset to `524_288`. + 2. Client enters `:finalizing` and transmits chunk 3 with `upload, finalize`. + 3. Server returns `409` (`:response_cat2`); client transitions from `:finalizing` to `:recovering`, queries server (`X-Goog-Upload-Size-Received: 524288`), realigns buffer, re-enters `:finalizing`, and retransmits `upload, finalize` to completion. +* **Assertions**: + * Returned JSON body reports `"size" == 786_332`. + * `phases` equals `[:initiating, :uploading, :uploading, :uploading, :finalizing, :recovering, :uploading, :finalizing, :completed]`. + * `offsets` equals `[0, 0, 262_144, 524_288, 524_288, 524_288, 524_288, 524_288, 786_332]`. + +#### Case 5. Repeated no-header failures until global deadline exceeded (`test_no_headers_failure_recovers_until_deadline_exceeded`) +* **Scenario**: Configures `failure_count: 0, action_after_failures: "terminate"` with a 1-second session `timeout`. +* **Protocol Flow**: + 1. Server responds to every upload chunk with HTTP `500` and no `X-Goog-Upload-Status` header. + 2. `data_plane_retry_policy` treats missing `X-Goog-Upload-Status` as unretriable (`predicate` returns `false`), yielding `Event::HttpResponse(500)` to `Core`. + 3. `Core` classifies the response as Category 2 (`:response_cat2`), enters `:recovering`, queries the server (which returns `200 active` at offset `0`), and retries the upload. + 4. This recovery loop repeats until the 1-second global session deadline expires and `Driver#run` raises `Gapic::Common::DeadlineExceededError`. +* **Assertions**: + * Raises `Gapic::Common::DeadlineExceededError`. + * `phases.count(:recovering) >= 2`. + + diff --git a/gapic-common/design/reference-implementation.md b/gapic-common/design/reference-implementation.md index d43eb1c..f341453 100644 --- a/gapic-common/design/reference-implementation.md +++ b/gapic-common/design/reference-implementation.md @@ -660,6 +660,8 @@ module Gapic @upload_log.wire_receive(event) event rescue StandardError => e + return Event::GlobalDeadlineExceeded.new if deadline_exceeded? + event = rescue_request_error(e) if event.is_a?(Event::HttpResponse) @upload_log.wire_receive(event) diff --git a/gapic-common/integration/resumable_upload/error_recovery_test.rb b/gapic-common/integration/resumable_upload/error_recovery_test.rb index f9ab894..1cffc4b 100644 --- a/gapic-common/integration/resumable_upload/error_recovery_test.rb +++ b/gapic-common/integration/resumable_upload/error_recovery_test.rb @@ -27,9 +27,8 @@ class ErrorRecoveryTest < ShowcaseIntegrationTest # B1. Verifies Category 1 transient error (503) is retried transparently by FAST_RETRY without entering recovery. def test_cat1_error_retried_transparently config = build_config( - scenario: SCENARIO, - scenario_config: { error_code: 503, failure_count: 1, after_offset: 0 }, - data_plane_retry_policy: FAST_RETRY + scenario: SCENARIO, + scenario_config: { error_code: 503, failure_count: 1, after_offset: 0 } ) driver = Gapic::Rest::ResumableUpload::Driver.new( @@ -128,7 +127,7 @@ def test_no_headers_failure_recovers_until_deadline_exceeded config = build_config( scenario: SCENARIO, scenario_config: { failure_count: 0, action_after_failures: "terminate" }, - timeout: 0.3 + timeout: 1 ) driver = Gapic::Rest::ResumableUpload::Driver.new( diff --git a/gapic-common/integration/resumable_upload/golden_path_test.rb b/gapic-common/integration/resumable_upload/golden_path_test.rb index fc87e4e..d40c31d 100644 --- a/gapic-common/integration/resumable_upload/golden_path_test.rb +++ b/gapic-common/integration/resumable_upload/golden_path_test.rb @@ -58,7 +58,8 @@ def test_small_upload_default_chunk_size config = build_config( stream: stream, - upload_size: size + upload_size: size, + chunk_size: nil # use default chunk size ) driver = Gapic::Rest::ResumableUpload::Driver.new( diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index 170e718..6aa7c5c 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -383,6 +383,12 @@ def make_post_request url, headers:, body:, retry_policy:, method_name: nil, sta @upload_log.wire_receive event event rescue StandardError => e + # If the global deadline expired during the HTTP call (e.g. Net::HTTP connection or read timeout + # triggered by request_timeout reaching 0 at @deadline), emit GlobalDeadlineExceeded rather than + # Event::RequestFailed. Otherwise, in states like Recovery where Event::RequestFailed is immediately + # terminal, the state machine would raise the underlying transport error instead of DeadlineExceededError. + return Event::GlobalDeadlineExceeded.new if deadline_exceeded? + event = rescue_request_error e if event.is_a? Event::HttpResponse @upload_log.wire_receive event From 23eaad839344b777736f3ed48b6f8af281f4f5a0 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Wed, 9 Sep 2026 23:22:01 +0000 Subject: [PATCH 47/79] feat: actionable errors for rest-resumable --- gapic-common/design/implementation-guide.md | 52 +++-- gapic-common/design/test-plan.md | 8 +- .../resumable_upload/error_recovery_test.rb | 2 +- gapic-common/lib/gapic/common/error.rb | 48 ----- gapic-common/lib/gapic/rest/error.rb | 4 +- .../lib/gapic/rest/resumable_upload/driver.rb | 12 +- .../resumable_upload/driver/upload_log.rb | 10 +- .../lib/gapic/rest/resumable_upload/errors.rb | 202 ++++++++++++++++++ .../lib/gapic/rest/resumable_upload/events.rb | 8 +- .../lib/gapic/rest/resumable_upload/rules.rb | 10 +- gapic-common/test/gapic/rest/error_test.rb | 13 ++ .../resumable_upload/driver_config_test.rb | 4 +- .../resumable_upload/driver_logging_test.rb | 117 +++++++++- .../resumable_upload/driver_retry_test.rb | 2 +- .../resumable_upload/rules_decide_test.rb | 6 +- .../rest/resumable_upload/rules_error_test.rb | 94 +++++++- .../gapic/rest/resumable_upload/rules_test.rb | 2 +- 17 files changed, 500 insertions(+), 94 deletions(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index ae7b536..b85c191 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -107,7 +107,7 @@ end ### 2.3 Events Vocabulary (Driver -> Core) * `Event::StartUpload`: Start the upload session. * `Event::ChunkRead.new(bytes_buffered:, eof:)`: Binary data buffered in Driver memory; reports total bytes ready in buffer and whether the stream hit EOF. -* `Event::HttpResponse.new(status:, headers:, body:)`: Dispatched for any completed HTTP exchange over the wire (including 2xx, 4xx, 5xx, or responses with missing/unexpected headers). `Core` inspects status and headers to determine protocol progression or recovery. +* `Event::HttpResponse.new(status:, headers:, body:, error: nil)`: Dispatched for any completed HTTP exchange over the wire (including 2xx, 4xx, 5xx, or responses with missing/unexpected headers). Carries optional parsed `error` (`Gapic::Rest::Error`) when rescued from transport errors. `Core` inspects status and headers to determine protocol progression or recovery. * `Event::RequestFailed.new(kind:, message:, source_error:)`: Dispatched when an HTTP request fails to produce a usable HTTP response (e.g., request timeout, transport connection errors, or `RetryPolicy` exhaustion). * `kind`: Normalized Symbol enum (`:timeout`, `:connection_failed`, `:retries_exhausted`). `Core` branches on `kind` and treats other fields as opaque. * `message`: Human-readable summary string. @@ -226,8 +226,8 @@ Full implementation: [reference-implementation.md#3-driver-class](reference-impl | :--- | :--- | :--- | :--- | :--- | :--- | | **`Initializing`** | `:start_upload` | `Event::StartUpload` | `status = :starting` | `Starting` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :initiating, bytes_uploaded: 0, total_bytes: config.upload_size))`
`Instruction::SendStart.new(url: config.initial_url, headers: config.initial_headers, body: config.initial_body)` | | **`Starting`** | `:response_active` | `Event::HttpResponse(200, headers, _)` with `Status: active` | `upload_url = headers['X-Goog-Upload-URL']`
`chunk_granularity = headers['...-Granularity']&.to_i`
`chunk_size = resolve(config, chunk_granularity)`
`offset = 0`
`status = :transmission_reading` | `Transmission \| Reading from stream` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :uploading, bytes_uploaded: 0, total_bytes: config.upload_size))`
`Instruction::FillBuffer.new(target_bytesize: state.chunk_size)` | -| **`Starting`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, _)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | -| **`Starting`** | `:response_cat2` / `:response_fatal_bad_response` | `Event::HttpResponse` (Non-200; see Section 6.1) | `last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | +| **`Starting`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, _)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Rest::ResumableUpload::UploadRejectedError.from(event))` | +| **`Starting`** | `:response_cat2` / `:response_fatal_bad_response` | `Event::HttpResponse` (Non-200; see Section 6.1) | `last_error = Gapic::Rest::ResumableUpload::BadResponseError.from(event)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | | **`Starting`** | `:request_retries_exhausted` / `:request_connection_failed` / `:request_timeout` | `Event::RequestFailed(kind:, message:, source_error:)` | `last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | | **`Transmission \| Reading from stream`** | `:chunk_read_full` | `Event::ChunkRead(bytes_buffered, eof: false)` | `in_flight_length = event.bytes_buffered`
`status = :transmission_sending` | `Transmission \| Sending` | `Instruction::SendChunk.new(url: state.upload_url, offset: state.offset, length: event.bytes_buffered, finalize: false)` | | **`Transmission \| Reading from stream`** | `:chunk_read_eof_with_data` | `Event::ChunkRead(bytes_buffered, eof: true)` where `bytes_buffered > 0` | `in_flight_length = event.bytes_buffered`
`status = :finalizing_sending_upload` | `Finalizing \| Sending with upload` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :finalizing, bytes_uploaded: state.offset, total_bytes: config.upload_size))`
`Instruction::SendChunk.new(url: state.upload_url, offset: state.offset, length: event.bytes_buffered, finalize: true)` | @@ -236,31 +236,31 @@ Full implementation: [reference-implementation.md#3-driver-class](reference-impl | **`Transmission \| Sending`** | `:response_cat2` | `Event::HttpResponse` (Category 2; see Section 6.1.2) | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :recovering, bytes_uploaded: state.offset, total_bytes: config.upload_size))`
`Instruction::SendQuery.new(url: state.upload_url)` | | **`Transmission \| Sending`** | `:request_connection_failed` / `:request_timeout` | `Event::RequestFailed(kind: :connection_failed \| :timeout)` | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :recovering, bytes_uploaded: state.offset, total_bytes: config.upload_size))`
`Instruction::SendQuery.new(url: state.upload_url)` | | **`Transmission \| Sending`** | `:request_retries_exhausted` | `Event::RequestFailed(kind: :retries_exhausted)` | `in_flight_length = 0`
`last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | -| **`Transmission \| Sending`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, _)` with `Status: final` | `in_flight_length = 0`
`status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | -| **`Transmission \| Sending`** | `:response_fatal_bad_response` | `Event::HttpResponse` (Fatal status; see Section 6.1.3) | `in_flight_length = 0`
`last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | +| **`Transmission \| Sending`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, _)` with `Status: final` | `in_flight_length = 0`
`status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Rest::ResumableUpload::UploadRejectedError.from(event))` | +| **`Transmission \| Sending`** | `:response_fatal_bad_response` | `Event::HttpResponse` (Fatal status; see Section 6.1.3) | `in_flight_length = 0`
`last_error = Gapic::Rest::ResumableUpload::BadResponseError.from(event)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | | **`Finalizing \| Sending with upload`** | `:response_final` | `Event::HttpResponse(200, headers, body)` with `Status: final` | `offset = state.offset + state.in_flight_length`
`in_flight_length = 0`
`status = :success` | `Success` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :completed, bytes_uploaded: state.offset, total_bytes: state.offset))`
`Instruction::TerminateSuccess.new(response: event)` | | **`Finalizing \| Sending with upload`** | `:response_cat2` | `Event::HttpResponse` (Category 2; see Section 6.1.2) | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :recovering, bytes_uploaded: state.offset, total_bytes: config.upload_size))`
`Instruction::SendQuery.new(url: state.upload_url)` | | **`Finalizing \| Sending with upload`** | `:request_connection_failed` / `:request_timeout` | `Event::RequestFailed(kind: :connection_failed \| :timeout)` | `in_flight_length = 0`
`status = :recovery` | `Recovery` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :recovering, bytes_uploaded: state.offset, total_bytes: config.upload_size))`
`Instruction::SendQuery.new(url: state.upload_url)` | | **`Finalizing \| Sending with upload`** | `:request_retries_exhausted` | `Event::RequestFailed(kind: :retries_exhausted)` | `in_flight_length = 0`
`last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | -| **`Finalizing \| Sending with upload`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, body)` with `Status: final` | `in_flight_length = 0`
`status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | -| **`Finalizing \| Sending with upload`** | `:response_fatal_bad_response` | `Event::HttpResponse` (Fatal status; see Section 6.1.3) | `in_flight_length = 0`
`last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | +| **`Finalizing \| Sending with upload`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, body)` with `Status: final` | `in_flight_length = 0`
`status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Rest::ResumableUpload::UploadRejectedError.from(event))` | +| **`Finalizing \| Sending with upload`** | `:response_fatal_bad_response` | `Event::HttpResponse` (Fatal status; see Section 6.1.3) | `in_flight_length = 0`
`last_error = Gapic::Rest::ResumableUpload::BadResponseError.from(event)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | | **`Finalizing \| Sending finalize`** | `:response_final` | `Event::HttpResponse(200, headers, body)` with `Status: final` | `status = :success` | `Success` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :completed, bytes_uploaded: state.offset, total_bytes: state.offset))`
`Instruction::TerminateSuccess.new(response: event)` | | **`Finalizing \| Sending finalize`** | `:response_cat2` | `Event::HttpResponse` (Category 2; see Section 6.1.2) | `status = :recovery` | `Recovery` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :recovering, bytes_uploaded: state.offset, total_bytes: config.upload_size))`
`Instruction::SendQuery.new(url: state.upload_url)` | | **`Finalizing \| Sending finalize`** | `:request_connection_failed` / `:request_timeout` | `Event::RequestFailed(kind: :connection_failed \| :timeout)` | `status = :recovery` | `Recovery` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :recovering, bytes_uploaded: state.offset, total_bytes: config.upload_size))`
`Instruction::SendQuery.new(url: state.upload_url)` | | **`Finalizing \| Sending finalize`** | `:request_retries_exhausted` | `Event::RequestFailed(kind: :retries_exhausted)` | `last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | -| **`Finalizing \| Sending finalize`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, body)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | -| **`Finalizing \| Sending finalize`** | `:response_fatal_bad_response` | `Event::HttpResponse` (Fatal status; see Section 6.1.3) | `last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | +| **`Finalizing \| Sending finalize`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, body)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Rest::ResumableUpload::UploadRejectedError.from(event))` | +| **`Finalizing \| Sending finalize`** | `:response_fatal_bad_response` | `Event::HttpResponse` (Fatal status; see Section 6.1.3) | `last_error = Gapic::Rest::ResumableUpload::BadResponseError.from(event)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | | **`Recovery`** | `:response_active` | `Event::HttpResponse(200, headers, _)` with `Status: active` | `offset = headers['X-Goog-Upload-Size-Received'].to_i`
`in_flight_length = 0`
`status = :transmission_reading` | `Transmission \| Reading from stream` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :uploading, bytes_uploaded: state.offset, total_bytes: config.upload_size))`
`Instruction::RealignBuffer.new(server_offset: state.offset)`
`Instruction::FillBuffer.new(target_bytesize: state.chunk_size)` | | **`Recovery`** | `:response_final` | `Event::HttpResponse(200, headers, body)` with `Status: final` | `in_flight_length = 0`
`status = :success` | `Success` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :completed, bytes_uploaded: state.offset, total_bytes: state.offset))`
`Instruction::TerminateSuccess.new(response: event)` | | **`Recovery`** | `:response_cat2` | `Event::HttpResponse` (Category 2; see Section 6.1.2) | `status = :recovery` | `Recovery` | `Instruction::SendQuery.new(url: state.upload_url)` | | **`Recovery`** | `:request_retries_exhausted` / `:request_connection_failed` / `:request_timeout` | `Event::RequestFailed(kind:, ...)` | `last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | -| **`Recovery`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, body)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(response.body))` | -| **`Recovery`** | `:response_fatal_bad_response` | `Event::HttpResponse` (Fatal status; see Section 6.1.3) | `last_error = Gapic::Common::BadResponseError.new(event.status)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | +| **`Recovery`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, body)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Rest::ResumableUpload::UploadRejectedError.from(event))` | +| **`Recovery`** | `:response_fatal_bad_response` | `Event::HttpResponse` (Fatal status; see Section 6.1.3) | `last_error = Gapic::Rest::ResumableUpload::BadResponseError.from(event)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | | **Any Non-Terminal** | `:user_cancel` | `Event::Cancel` | `status = :cancelling` | `Cancelling` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :cancelling, bytes_uploaded: state.offset, total_bytes: config.upload_size))`
`Instruction::SendCancel.new(url: state.upload_url)` | -| **`Cancelling`** | `:response_cancelled` | `Event::HttpResponse(200, headers, _)` with `Status: cancelled` | `status = :cancelled` | `Cancelled` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadCancelledError.new)` | -| **`Cancelling`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, _)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Common::UploadRejectedError.new(event.body))` | +| **`Cancelling`** | `:response_cancelled` | `Event::HttpResponse(200, headers, _)` with `Status: cancelled` | `status = :cancelled` | `Cancelled` | `Instruction::TerminateFailure.new(error: Gapic::Rest::ResumableUpload::UploadCancelledError.from(event))` | +| **`Cancelling`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, _)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Rest::ResumableUpload::UploadRejectedError.from(event))` | | **`Cancelling`** | `:request_retries_exhausted` / `:request_connection_failed` / `:request_timeout` / `:response_fatal_bad_response` | `Event::RequestFailed` or HTTP failure | `last_error = error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | -| **Any Non-Terminal** | `:global_deadline_exceeded` | `Event::GlobalDeadlineExceeded` | `last_error = Gapic::Common::DeadlineExceededError.new`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | +| **Any Non-Terminal** | `:global_deadline_exceeded` | `Event::GlobalDeadlineExceeded` | `last_error = Gapic::Rest::ResumableUpload::DeadlineExceededError.new`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | | **Any State** | *Unmatched* | Any event not matched above | — | — | `fail_with_unmatched_transition(state, event)`: raises `InvalidTransitionError` stating in human terms what the protocol was doing (e.g. sending a chunk of data), what happened including HTTP status and `X-Goog-Upload-Status` header, and attaches the response. | ### 4.3 State Transition Graph @@ -389,11 +389,31 @@ The implementation distinguishes three categories of network and protocol-level Responses with these status codes are classified as `:response_fatal_bad_response` even if the `X-Goog-Upload-Status` header is absent. * **Other Terminal Conditions**: * **Retry Exhaustion**: Any `Event::RequestFailed(kind: :retries_exhausted)` occurring at any stage. When Category 1 transport retries are exhausted by the `RetryPolicy`, failure is immediate and terminal; it does not enter Category 2 `Recovery`. - * **Session Rejection**: Any response with `X-Goog-Upload-Status: final` and non-2xx status code (`:response_rejected` -> raises `Gapic::Common::UploadRejectedError`). - * **Initiation Failure**: Any 4xx/5xx or `Event::RequestFailed` during `Starting` (`:error` -> raises `Gapic::Common::BadResponseError` or source error). + * **Session Rejection**: Any response with `X-Goog-Upload-Status: final` and non-2xx status code (`:response_rejected` -> raises `Gapic::Rest::ResumableUpload::UploadRejectedError`). + * **Initiation Failure**: Any 4xx/5xx or `Event::RequestFailed` during `Starting` (`:error` -> raises `Gapic::Rest::ResumableUpload::BadResponseError` or source error). + * **Session Cancellation**: Cancelled upload sessions raise `Gapic::Rest::ResumableUpload::UploadCancelledError`. + * **Global Deadline Expiration**: Monotonic clock exceeding session deadline raises `Gapic::Rest::ResumableUpload::DeadlineExceededError`. * **Unseekable Stream Rewind**: Server offset rolled back behind retained buffer (`server_offset < buffer_start_offset`) on an unseekable stream (raises `Gapic::Rest::ResumableUpload::UnseekableStreamError`). * **Resolution**: Core transitions to `:rejected` or `:error` and emits `Instruction::TerminateFailure`. +#### 6.1.4 Actionable Terminal Errors & Metadata Propagation +Terminal errors subclass `Gapic::Rest::Error` (or `Gapic::Rest::DeadlineExceededError`) so downstream SDK callers can inspect HTTP error metadata: +* **Error Classes**: + * `BadResponseError < Gapic::Rest::Error`: Unrecoverable non-2xx HTTP responses or invalid payloads. Retains `attr_reader :response_body` returning `event.body`. + * `UploadRejectedError < Gapic::Rest::Error`: Backend explicitly rejected the session with `X-Goog-Upload-Status: final`. Retains `attr_reader :response_body` returning `event.body`. + * `UploadCancelledError < Gapic::Rest::Error`: Upload session cancelled by caller. + * `DeadlineExceededError < Gapic::Rest::DeadlineExceededError`: Upload deadline exceeded with optional root cause. +* **Metadata Sourcing & De-prefixing**: + * When `event.error` is present (from `Gapic::Rest::Error.wrap_faraday_error`), factories source `status_code`, `status`, `details`/`status_details`, and `headers`/`header`. + * The prefix literal `Gapic::Rest::Error::REST_ERROR_PREFIX` (`"An error has occurred when making a REST request"`) is stripped from `event.error.message` to avoid redundant prefixes. + * The resulting actionable message follows the format: + `"Resumable upload failed with HTTP #{status_code} #{status_name}: #{inner_message}"` + (e.g., `"Resumable upload failed with HTTP 403 Permission Denied: The caller does not have permission"`). +* **Fallback Formatting**: + * When `event.error` is absent, factories fall back to `event.status` and `event.headers`, naming the status and including the detailed `X-Goog-Upload-Status` header: + `"Resumable upload failed with HTTP #{event.status} #{status_name} (X-Goog-Upload-Status: #{upload_status_desc})"`. + * `response_body` on `BadResponseError` and `UploadRejectedError` returns `event.body`. + ### 6.2 Recovery and Buffer Alignment When `Core` resolves a `query` response in the `Recovery` state, it updates `State.offset` (`protocol_state_offset`) to `server_offset` (extracted from `X-Goog-Upload-Size-Received`) and transitions to `Transmission | Reading from stream`. diff --git a/gapic-common/design/test-plan.md b/gapic-common/design/test-plan.md index 3618615..55b718f 100644 --- a/gapic-common/design/test-plan.md +++ b/gapic-common/design/test-plan.md @@ -135,6 +135,10 @@ flowchart TD * Session rejection (`:response_rejected` $\rightarrow$ `UploadRejectedError`), fatal bad responses (`:response_fatal_bad_response` $\rightarrow$ `BadResponseError`). * Non-recoverable request failures: `:request_retries_exhausted` in `:transmission_sending`, and `:request_timeout` in `:starting` or `:recovery`. * Global deadline expiration: `:global_deadline_exceeded` $\rightarrow$ `DeadlineExceededError`. +* **Actionable description and metadata propagation**: + * Wrapped errors (`event.error`) de-prefix `Gapic::Rest::Error::REST_ERROR_PREFIX` and format as `"Resumable upload failed with HTTP #{status_code} #{status_name}: #{inner_message}"`. + * Preserves `status_code`, `status`, `details`/`status_details`, `headers`/`header`, and `response_body` (on `UploadRejectedError`) end-to-end. + * Fallback without `event.error` names the HTTP status code and status, appending detailed header `(X-Goog-Upload-Status: '...')`. * **Actionable description on unexpected HTTP response**: * Unmatched event (HTTP 200 `Status: final` while in `:transmission_sending`) raises `InvalidTransitionError` with human phrasing (`"Resumable upload failed while sending a chunk of data: received an unexpected HTTP 200 response (X-Goog-Upload-Status: 'final')."`) and attaches `err.response`, `err.event`, `err.state`. * **Missing status header formatting in error**: @@ -227,7 +231,7 @@ flowchart TD * **Size-proportional timeout above base floor**: Large `upload_size` computes timeout as `upload_size.fdiv(MIN_ASSUMED_THROUGHPUT)`. * **Base timeout floor for small uploads**: Small `upload_size` floors at `BASE_TIMEOUT` (`3_600` seconds). * **Default base timeout when size is nil**: Unspecified `upload_size` defaults to `BASE_TIMEOUT`. -* **Deadline expiration enforcement**: Monotonic clock exceeding `@deadline` during `Driver#run` triggers `Event::GlobalDeadlineExceeded` and raises `Gapic::Common::DeadlineExceededError`. +* **Deadline expiration enforcement**: Monotonic clock exceeding `@deadline` during `Driver#run` triggers `Event::GlobalDeadlineExceeded` and raises `Gapic::Rest::ResumableUpload::DeadlineExceededError`. --- @@ -260,7 +264,7 @@ flowchart TD * Asserts silent recipes (`:ignore_duplicate_cancel`, `:ack_chunk`) emit no lifecycle log entries. * **Wire trace logging (`wire_send`, `wire_receive`, `wire_failure`)**: * `wire_send` logs `DEBUG` with HTTP verb, abridged URL, redacted headers, `startAttempt`, `bodySize`, and hex-encoded/abridged body. - * `wire_receive` logs `DEBUG` with HTTP status code, parsed `uploadStatus`, `sizeReceived`, `granularity`, and hex body. + * `wire_receive` logs `DEBUG` with HTTP status code, parsed `uploadStatus`, optional `errorStatus` from `event.error.status`, `sizeReceived`, `granularity`, and abridged body (using `event.error.message` when HTTP $\ge 400$ and present). * `wire_failure` logs `DEBUG` with failure classification `kind` and exception message. * **Buffer realignment logging (`UploadLog#buffer_realign`)**: * Logs `DEBUG` on normal realignment and additionally emits a `WARN` entry (`"Server offset rewind on unseekable stream"`) with `action`, `serverOffset`, and `currentOffset` when rewinding an unseekable stream. diff --git a/gapic-common/integration/resumable_upload/error_recovery_test.rb b/gapic-common/integration/resumable_upload/error_recovery_test.rb index 1cffc4b..fd0f948 100644 --- a/gapic-common/integration/resumable_upload/error_recovery_test.rb +++ b/gapic-common/integration/resumable_upload/error_recovery_test.rb @@ -135,7 +135,7 @@ def test_no_headers_failure_recovers_until_deadline_exceeded config: config ) - assert_raises Gapic::Common::DeadlineExceededError do + assert_raises Gapic::Rest::ResumableUpload::DeadlineExceededError do driver.run end diff --git a/gapic-common/lib/gapic/common/error.rb b/gapic-common/lib/gapic/common/error.rb index dc10dd3..6d2646c 100644 --- a/gapic-common/lib/gapic/common/error.rb +++ b/gapic-common/lib/gapic/common/error.rb @@ -19,53 +19,5 @@ module Common # Gapic Common exception class class Error < StandardError end - - ## - # Raised when Scotty backend explicitly rejects the upload session - # (returns non-2xx with X-Goog-Upload-Status: final). - # - class UploadRejectedError < Error - # @return [String, nil] Response body from backend - attr_reader :response_body - - # @param response_body [String, nil] - def initialize response_body = nil - @response_body = response_body - super "Upload was rejected by server: #{response_body}" - end - end - - ## - # Raised when the upload session is cancelled. - # - class UploadCancelledError < Error - def initialize message = "Upload session was cancelled" - super message - end - end - - ## - # Raised when an unrecoverable HTTP response is received. - # - class BadResponseError < Error - # @return [Integer, nil] HTTP status code - attr_reader :status_code - - # @param status_code [Integer, nil] - # @param message [String, nil] - def initialize status_code = nil, message = nil - @status_code = status_code - super(message || "Received unexpected response with status code: #{status_code}") - end - end - - ## - # Raised when an upload exceeds its global monotonic deadline. - # - class DeadlineExceededError < Error - def initialize message = "Upload deadline exceeded" - super message - end - end end end diff --git a/gapic-common/lib/gapic/rest/error.rb b/gapic-common/lib/gapic/rest/error.rb index 910604b..357b3b3 100644 --- a/gapic-common/lib/gapic/rest/error.rb +++ b/gapic-common/lib/gapic/rest/error.rb @@ -22,6 +22,8 @@ module Gapic module Rest # Gapic REST exception class class Error < ::Gapic::Common::Error + REST_ERROR_PREFIX = "An error has occurred when making a REST request".freeze + # @return [Integer, nil] the http status code for the error attr_reader :status_code # @return [Object, nil] the text representation of status as parsed from the response body @@ -79,7 +81,7 @@ def parse_faraday_error err if err.response_body msg, code, status, details = try_parse_from_body err.response_body - message = "An error has occurred when making a REST request: #{msg}" unless msg.nil? + message = "#{REST_ERROR_PREFIX}: #{msg}" unless msg.nil? status_code = code unless code.nil? end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index 6aa7c5c..721d7d2 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -307,8 +307,9 @@ def execute_send_start instruction status_hdr = Rules.header_value event.headers, "x-goog-upload-status" return event unless status_hdr.nil? || status_hdr.empty? - err = Gapic::Common::BadResponseError.new event.status, - "Missing X-Goog-Upload-Status header in start response" + err = BadResponseError.new "Missing X-Goog-Upload-Status header in start response", + event.status, + headers: event.headers can_retry = policy.send(:retry_with_deadline?) && policy.call(event) unless can_retry failed_event = Event::RequestFailed.new kind: :retries_exhausted, message: err.message, source_error: err @@ -404,7 +405,8 @@ def rescue_request_error err Event::RequestFailed.new kind: :timeout, message: err.message, source_error: err when Gapic::Rest::Error if err.status_code - Event::HttpResponse.new status: err.status_code, headers: err.headers || {}, body: err.message + Event::HttpResponse.new status: err.status_code, headers: err.headers || {}, body: err.message, + error: err else Event::RequestFailed.new kind: :connection_failed, message: err.message, source_error: err end @@ -417,10 +419,12 @@ def rescue_request_error err def rescue_faraday_error err if err.response && err.response[:status] + rest_err = Gapic::Rest::Error.wrap_faraday_error err Event::HttpResponse.new( status: err.response[:status], headers: err.response[:headers] || {}, - body: err.response[:body] + body: err.response[:body], + error: rest_err ) elsif err.is_a? Faraday::TimeoutError Event::RequestFailed.new kind: :timeout, message: err.message, source_error: err diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb index fcb8974..5b80e8b 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb @@ -105,12 +105,14 @@ def wire_receive event upload_status = Rules.header_value event.headers, "x-goog-upload-status" size_recv = Rules.header_value event.headers, "x-goog-upload-size-received" gran = Rules.header_value event.headers, "x-goog-upload-chunk-granularity" + err = event.error if event.respond_to? :error fields = { status: event.status, headers: Abridge.headers(event.headers), - body: event.status >= 400 ? Abridge.error_body(event.body) : Abridge.bytes(event.body) + body: wire_receive_body(event, err) } fields[:uploadStatus] = upload_status if upload_status + fields[:errorStatus] = err.status if err&.status fields[:sizeReceived] = size_recv.to_i if size_recv fields[:granularity] = gran.to_i if gran @@ -158,6 +160,12 @@ def unmatched_transition state, event, error private + def wire_receive_body event, err + return Abridge.bytes event.body if event.status < 400 + + err&.message ? Abridge.error_body(err.message) : Abridge.error_body(event.body) + end + def lifecycle_fields decision, config state = decision.next_state case decision.recipe diff --git a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb index b88aaa6..ca6709b 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb @@ -15,10 +15,117 @@ # limitations under the License. require "gapic/common/error" +require "gapic/rest/error" module Gapic module Rest module ResumableUpload + HTTP_STATUS_PHRASES = { + 400 => "Bad Request", + 401 => "Unauthorized", + 403 => "Forbidden", + 404 => "Not Found", + 405 => "Method Not Allowed", + 408 => "Request Timeout", + 409 => "Conflict", + 410 => "Gone", + 411 => "Length Required", + 412 => "Precondition Failed", + 413 => "Payload Too Large", + 415 => "Unsupported Media Type", + 416 => "Range Not Satisfiable", + 429 => "Too Many Requests", + 499 => "Client Closed Request", + 500 => "Internal Server Error", + 502 => "Bad Gateway", + 503 => "Service Unavailable", + 504 => "Gateway Timeout" + }.freeze + + ## + # @private + # Internal formatting helper for terminal error message and attribute extraction. + # + module ErrorBuilder + class << self + def format_status status + return nil if status.nil? || status.to_s.empty? + + status.to_s.split("_").map(&:capitalize).join(" ") + end + + def clean_message raw_message + return nil if raw_message.nil? || raw_message.empty? + + prefix = Gapic::Rest::Error::REST_ERROR_PREFIX + msg = raw_message.to_s + msg = msg.sub(/\A#{Regexp.escape prefix}:\s*/, "") if msg.start_with? prefix + msg = msg.sub(/\A:\s*/, "").strip + msg.empty? ? nil : msg + end + + def build_attributes source + if source.respond_to?(:error) && source.error + build_from_wrapped_error source + elsif source.is_a? Gapic::Rest::Error + build_from_rest_error source + elsif source.respond_to? :status + build_from_http_event source + elsif source.is_a? Integer + status_name = HTTP_STATUS_PHRASES[source] + status_part = status_name ? " #{status_name}" : "" + ["Resumable upload failed with HTTP #{source}#{status_part}".strip, source, nil, nil, nil] + else + [source.to_s, nil, nil, nil, nil] + end + end + + private + + def build_from_wrapped_error source + err = source.error + status_code = err.status_code || (source.respond_to?(:status) ? source.status : nil) + status = err.status + status_name = format_status(status) || HTTP_STATUS_PHRASES[status_code] + status_part = status_name ? " #{status_name}" : "" + inner_msg = clean_message err.message + msg = if inner_msg + "Resumable upload failed with HTTP #{status_code}#{status_part}: #{inner_msg}" + else + "Resumable upload failed with HTTP #{status_code}#{status_part}" + end + headers = err.headers || (source.respond_to?(:headers) ? source.headers : nil) + [msg, status_code, status, err.details, headers] + end + + def build_from_rest_error source + status_code = source.status_code + status = source.status + status_name = format_status(status) || HTTP_STATUS_PHRASES[status_code] + status_part = status_name ? " #{status_name}" : "" + inner_msg = clean_message source.message + msg = if inner_msg + "Resumable upload failed with HTTP #{status_code}#{status_part}: #{inner_msg}" + else + "Resumable upload failed with HTTP #{status_code}#{status_part}" + end + [msg, status_code, status, source.details, source.headers] + end + + def build_from_http_event source + status_code = source.status + headers = source.respond_to?(:headers) && source.headers ? source.headers : {} + upload_status = headers["x-goog-upload-status"] || headers["X-Goog-Upload-Status"] + status_desc = upload_status ? "'#{upload_status}'" : "missing" + status_name = HTTP_STATUS_PHRASES[status_code] + status_part = status_name ? " #{status_name}" : "" + msg = "Resumable upload failed with HTTP #{status_code}#{status_part} " \ + "(X-Goog-Upload-Status: #{status_desc})" + [msg, status_code, nil, nil, headers] + end + end + end + ## # Raised when an invalid or unmatched event is dispatched for the current protocol state. # @@ -49,6 +156,101 @@ def initialize message, state: nil, event: nil, response: nil # class UnseekableStreamError < Gapic::Common::Error end + + ## + # Raised when an unrecoverable HTTP response is received. + # + class BadResponseError < Gapic::Rest::Error + # @return [String, nil] Response body from backend + attr_reader :response_body + + # @param message [String, nil] + # @param status_code [Integer, nil] + # @param status [String, nil] + # @param details [Object, nil] + # @param headers [Object, nil] + # @param response_body [String, nil] + def initialize message = nil, status_code = nil, status: nil, details: nil, headers: nil, response_body: nil + @response_body = response_body + if message.is_a?(Integer) && status_code.is_a?(String) + message, status_code = status_code, message + elsif message.is_a?(Integer) && status_code.nil? + status_code = message + message = nil + end + message ||= "Received unexpected response with status code: #{status_code}" if status_code + super message, status_code, status: status, details: details, headers: headers + end + + def self.from source, response_body: nil + body = response_body || (source.respond_to?(:body) ? source.body : nil) + message, status_code, status, details, headers = ErrorBuilder.build_attributes source + new message, status_code, status: status, details: details, headers: headers, response_body: body + end + end + + ## + # Raised when Scotty backend explicitly rejects the upload session + # (returns non-2xx with X-Goog-Upload-Status: final). + # + class UploadRejectedError < Gapic::Rest::Error + # @return [String, nil] Response body from backend + attr_reader :response_body + + # @param message [String, nil] + # @param status_code [Integer, nil] + # @param status [String, nil] + # @param details [Object, nil] + # @param headers [Object, nil] + # @param response_body [String, nil] + def initialize message = nil, status_code = nil, status: nil, details: nil, headers: nil, response_body: nil + @response_body = response_body + if status_code.nil? && response_body.nil? && message && + !message.start_with?("Resumable upload failed", "Upload was rejected") + @response_body = message + message = "Upload was rejected by server: #{message}" + end + super message, status_code, status: status, details: details, headers: headers + end + + def self.from source, response_body: nil + body = response_body || (source.respond_to?(:body) ? source.body : nil) + message, status_code, status, details, headers = ErrorBuilder.build_attributes source + new message, status_code, status: status, details: details, headers: headers, response_body: body + end + end + + ## + # Raised when the upload session is cancelled. + # + class UploadCancelledError < Gapic::Rest::Error + def initialize message = "Upload session was cancelled", status_code = nil, status: nil, details: nil, + headers: nil + super message, status_code, status: status, details: details, headers: headers + end + + def self.from source + if source.respond_to? :status + new "Upload session was cancelled", source.status, + headers: (source.respond_to?(:headers) ? source.headers : nil) + elsif source.is_a? Gapic::Rest::Error + new source.message, source.status_code, status: source.status, details: source.details, + headers: source.headers + else + new + end + end + end + + ## + # Raised when an upload exceeds its global monotonic deadline. + # + class DeadlineExceededError < Gapic::Rest::DeadlineExceededError + def initialize message = "Upload deadline exceeded", status_code = nil, status: nil, details: nil, + headers: nil, root_cause: nil + super message, status_code, status: status, details: details, headers: headers, root_cause: root_cause + end + end end end end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/events.rb b/gapic-common/lib/gapic/rest/resumable_upload/events.rb index 26cc285..9a7b5b8 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/events.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/events.rb @@ -38,11 +38,11 @@ def initialize bytes_buffered: 0, eof: false end ## - # Signals a completed HTTP exchange over the wire (status, headers, body). + # Signals a completed HTTP exchange over the wire (status, headers, body, error). # - HttpResponse = Data.define :status, :headers, :body do - def initialize status:, headers: {}, body: nil - super status: status, headers: headers || {}, body: body + HttpResponse = Data.define :status, :headers, :body, :error do + def initialize status:, headers: {}, body: nil, error: nil + super status: status, headers: headers || {}, body: body, error: error end end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb index 5671009..d3c4801 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb @@ -378,8 +378,8 @@ def self.realign_from_recovery state, event, config [next_state, instructions] end - def self.complete_cancellation state, _event, _config - err = Gapic::Common::UploadCancelledError.new + def self.complete_cancellation state, event, _config + err = UploadCancelledError.from event next_state = state.with status: :cancelled, in_flight_length: 0, last_error: err [next_state, [Instruction::TerminateFailure.new(error: err)]] end @@ -399,7 +399,7 @@ def self.cancel_session state, _event, config end def self.fail_with_deadline_exceeded state, _event, _config - err = Gapic::Common::DeadlineExceededError.new + err = DeadlineExceededError.new next_state = state.with( status: :error, in_flight_length: 0, @@ -409,7 +409,7 @@ def self.fail_with_deadline_exceeded state, _event, _config end def self.fail_with_rejected state, event, _config - err = Gapic::Common::UploadRejectedError.new event.body + err = UploadRejectedError.from event next_state = state.with( status: :rejected, in_flight_length: 0, @@ -419,7 +419,7 @@ def self.fail_with_rejected state, event, _config end def self.fail_with_bad_response state, event, _config - err = Gapic::Common::BadResponseError.new event.status + err = BadResponseError.from event next_state = state.with( status: :error, in_flight_length: 0, diff --git a/gapic-common/test/gapic/rest/error_test.rb b/gapic-common/test/gapic/rest/error_test.rb index 6593db1..96c16d9 100644 --- a/gapic-common/test/gapic/rest/error_test.rb +++ b/gapic-common/test/gapic/rest/error_test.rb @@ -323,4 +323,17 @@ def test_surface_absent_details assert_nil gapic_err.details end + + def test_rest_error_prefix_constant + assert_equal "An error has occurred when making a REST request", ::Gapic::Rest::Error::REST_ERROR_PREFIX + + faraday_err = OpenStruct.new( + message: "raw", + response_body: JSON.dump({ "error" => { "message" => "Quota exceeded", "code" => 429 } }), + response_headers: {}, + response_status: 429 + ) + gapic_err = ::Gapic::Rest::Error.wrap_faraday_error faraday_err + assert_equal "#{::Gapic::Rest::Error::REST_ERROR_PREFIX}: Quota exceeded", gapic_err.message + end end diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb index 6edf227..dc6fb1f 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb @@ -135,7 +135,7 @@ def test_run_raises_deadline_exceeded_when_timeout_expires # Stub monotonic clock so that initial check sets deadline at t=105, and subsequent checks read t=110 clock_ticks = [100.0, 110.0, 110.0] Process.stub :clock_gettime, ->(_clock_id) { clock_ticks.shift || 110.0 } do - assert_raises Gapic::Common::DeadlineExceededError do + assert_raises DeadlineExceededError do driver.run end end @@ -167,7 +167,7 @@ def test_run_raises_deadline_exceeded_when_clock_advances_past_deadline_mid_batc driver = Driver.new client_stub: stub, config: config Process.stub :clock_gettime, ->(_clock_id) { current_time } do - assert_raises Gapic::Common::DeadlineExceededError do + assert_raises DeadlineExceededError do driver.run end end diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb index 3e6313c..204e83a 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb @@ -44,7 +44,10 @@ def make_post_request uri:, body:, params:, options:, method_name: nil _ = params _ = options @method_names << method_name - @responses.shift + resp = @responses.shift + raise resp if resp.is_a?(Exception) || (resp.is_a?(Class) && resp < Exception) + + resp end end @@ -161,7 +164,7 @@ def test_fatal_failure_logs_warn_with_fail_with_recipe ) driver = Driver.new client_stub: stub, config: config, logger: recording - assert_raises Gapic::Common::UploadRejectedError do + assert_raises UploadRejectedError do driver.run end @@ -271,4 +274,114 @@ def run_two_chunk_upload_with_secret recording driver = Driver.new client_stub: stub, config: config, logger: recording driver.run end + + def test_wire_receive_logs_error_status_and_abridged_error_message + recording = RecordingLogger.new + upload_log = Driver::UploadLog.new recording, upload_id: "test-upload-1" + + err = Gapic::Rest::Error.new( + "#{Gapic::Rest::Error::REST_ERROR_PREFIX}: Permission denied on resource", + 403, + status: "PERMISSION_DENIED" + ) + event = Event::HttpResponse.new( + status: 403, + headers: { "x-goog-upload-status" => "final" }, + body: '{"raw":"error"}', + error: err + ) + + upload_log.wire_receive event + + debug_entries = recording.entries.select { |e| e.severity == Logger::DEBUG && e.message.message.include?("403") } + refute_empty debug_entries + fields = debug_entries.first.message.fields + assert_equal 403, fields["status"] + assert_equal "PERMISSION_DENIED", fields["errorStatus"] + assert_equal "#{Gapic::Rest::Error::REST_ERROR_PREFIX}: Permission denied on resource", fields["body"] + end + + def test_wire_receive_fallback_body_when_error_absent + recording = RecordingLogger.new + upload_log = Driver::UploadLog.new recording, upload_id: "test-upload-2" + + event = Event::HttpResponse.new( + status: 503, + headers: {}, + body: "Server unavailable", + error: nil + ) + + upload_log.wire_receive event + + debug_entries = recording.entries.select { |e| e.severity == Logger::DEBUG && e.message.message.include?("503") } + refute_empty debug_entries + fields = debug_entries.first.message.fields + assert_equal 503, fields["status"] + assert_nil fields["errorStatus"] + assert_equal "Server unavailable", fields["body"] + end + + def test_lifecycle_warn_carries_rich_message_when_driver_fails + recording = RecordingLogger.new + wrapped_err = Gapic::Rest::Error.new( + "#{Gapic::Rest::Error::REST_ERROR_PREFIX}: Bucket access denied", + 403, + status: "PERMISSION_DENIED", + headers: { "x-goog-upload-status" => "final" } + ) + stub = FakeStub.new [wrapped_err] + config = CompleteUploadConfig.new( + initial_url: "https://storage.googleapis.com/upload", + stream: StringIO.new("data"), + upload_size: 4, + chunk_size: 256 + ) + + driver = Driver.new client_stub: stub, config: config, logger: recording + err = assert_raises UploadRejectedError do + driver.run + end + + assert_equal "Resumable upload failed with HTTP 403 Permission Denied: Bucket access denied", err.message + + warn_entries = recording.entries.select { |e| e.severity == Logger::WARN } + refute_empty warn_entries + fail_warn = warn_entries.find { |e| e.message.fields["recipe"] == "fail_with_rejected" } + refute_nil fail_warn + assert_equal "Resumable upload failed with HTTP 403 Permission Denied: Bucket access denied", + fail_warn.message.fields["error"] + + debug_entries = recording.entries.select { |e| e.severity == Logger::DEBUG && e.message.message.include?("403") } + refute_empty debug_entries + wire_recv = debug_entries.first + assert_equal "PERMISSION_DENIED", wire_recv.message.fields["errorStatus"] + end + + def test_driver_error_mapping_populates_error_on_rescue + stub = FakeStub.new [] + config = CompleteUploadConfig.new( + initial_url: "https://storage.googleapis.com/upload", + stream: StringIO.new("data"), + upload_size: 4, + chunk_size: 256 + ) + driver = Driver.new client_stub: stub, config: config + + rest_err = Gapic::Rest::Error.new "Forbidden", 403, status: "PERMISSION_DENIED" + event = driver.send :rescue_request_error, rest_err + assert_instance_of Event::HttpResponse, event + assert_equal rest_err, event.error + + faraday_err = Faraday::ClientError.new "Client error", { + status: 400, + headers: { "content-type" => "application/json" }, + body: '{"error":{"message":"Bad input","code":400,"status":"INVALID_ARGUMENT"}}' + } + faraday_event = driver.send :rescue_faraday_error, faraday_err + assert_instance_of Event::HttpResponse, faraday_event + assert_instance_of Gapic::Rest::Error, faraday_event.error + assert_equal 400, faraday_event.error.status_code + assert_equal "INVALID_ARGUMENT", faraday_event.error.status + end end diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb index 43a18b2..ec976a5 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb @@ -88,7 +88,7 @@ def test_start_exhausts_retries_when_responses_continually_lack_status_header ) driver = Driver.new client_stub: stub, config: config - err = assert_raises Gapic::Common::BadResponseError do + err = assert_raises BadResponseError do driver.run end diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb index df994b5..42ecb91 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb @@ -228,7 +228,7 @@ def test_row_global_deadline_exceeded assert_equal :fail_with_deadline_exceeded, decision.recipe assert_equal :error, decision.next_state.status assert_recipe_progress_notification decision - assert_instance_of Gapic::Common::DeadlineExceededError, decision.next_state.last_error + assert_instance_of DeadlineExceededError, decision.next_state.last_error end def test_row_user_cancel @@ -249,7 +249,7 @@ def test_row_response_rejected assert_equal :fail_with_rejected, decision.recipe assert_equal :rejected, decision.next_state.status assert_recipe_progress_notification decision - assert_instance_of Gapic::Common::UploadRejectedError, decision.next_state.last_error + assert_instance_of UploadRejectedError, decision.next_state.last_error end def test_row_fail_with_bad_response @@ -260,7 +260,7 @@ def test_row_fail_with_bad_response assert_equal :fail_with_bad_response, decision.recipe assert_equal :error, decision.next_state.status assert_recipe_progress_notification decision - assert_instance_of Gapic::Common::BadResponseError, decision.next_state.last_error + assert_instance_of BadResponseError, decision.next_state.last_error end def test_row_fail_with_request_error diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb index 61d2d4f..9850c4b 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb @@ -41,7 +41,8 @@ def test_transition_starting_rejected next_state, instructions = Rules.step state, resp, @config assert_equal :rejected, next_state.status - assert_instance_of Gapic::Common::UploadRejectedError, next_state.last_error + assert_instance_of UploadRejectedError, next_state.last_error + assert_equal "Forbidden", next_state.last_error.response_body assert_equal 1, instructions.size assert_instance_of Instruction::TerminateFailure, instructions.first assert_equal next_state.last_error, instructions.first.error @@ -53,7 +54,7 @@ def test_transition_starting_fatal_error next_state, instructions = Rules.step state, resp, @config assert_equal :error, next_state.status - assert_instance_of Gapic::Common::BadResponseError, next_state.last_error + assert_instance_of BadResponseError, next_state.last_error assert_equal 400, next_state.last_error.status_code assert_equal 1, instructions.size assert_instance_of Instruction::TerminateFailure, instructions.first @@ -119,7 +120,7 @@ def test_transition_global_deadline_exceeded next_state, instructions = Rules.step state, Event::GlobalDeadlineExceeded.new, @config assert_equal :error, next_state.status - assert_instance_of Gapic::Common::DeadlineExceededError, next_state.last_error + assert_instance_of DeadlineExceededError, next_state.last_error assert_equal 1, instructions.size assert_instance_of Instruction::TerminateFailure, instructions.first end @@ -170,4 +171,91 @@ def test_invalid_transition_raises_error_for_non_http_event assert_equal event, err.event assert_nil err.response end + + def test_rejected_with_wrapped_error_deprefixes_message_and_preserves_metadata + details = [{ "reason" => "ACCESS_DENIED" }] + headers = { "x-goog-upload-status" => "final", "content-type" => "application/json" } + wrapped_err = Gapic::Rest::Error.new( + "#{Gapic::Rest::Error::REST_ERROR_PREFIX}: The caller does not have permission", + 403, + status: "PERMISSION_DENIED", + details: details, + headers: headers + ) + resp = Event::HttpResponse.new( + status: 403, + headers: headers, + body: '{"error":{"message":"The caller does not have permission"}}', + error: wrapped_err + ) + + state = State.new status: :starting + next_state, instructions = Rules.step state, resp, @config + + assert_equal :rejected, next_state.status + err = next_state.last_error + assert_instance_of UploadRejectedError, err + assert_equal "Resumable upload failed with HTTP 403 Permission Denied: The caller does not have permission", + err.message + assert_equal 403, err.status_code + assert_equal "PERMISSION_DENIED", err.status + assert_equal details, err.details + assert_equal details, err.status_details + assert_equal headers, err.headers + assert_equal headers, err.header + assert_equal '{"error":{"message":"The caller does not have permission"}}', err.response_body + assert_equal err, instructions.first.error + end + + def test_rejected_fallback_without_wrapped_error + resp = Event::HttpResponse.new status: 403, headers: { "x-goog-upload-status" => "final" }, body: "Forbidden" + state = State.new status: :starting + next_state, _instructions = Rules.step state, resp, @config + + assert_equal :rejected, next_state.status + err = next_state.last_error + assert_instance_of UploadRejectedError, err + assert_equal "Resumable upload failed with HTTP 403 Forbidden (X-Goog-Upload-Status: 'final')", err.message + assert_equal 403, err.status_code + assert_equal "Forbidden", err.response_body + end + + def test_bad_response_with_wrapped_error_deprefixes_message_and_preserves_metadata + details = ["Quota limit details"] + headers = { "x-goog-upload-status" => "active" } + wrapped_err = Gapic::Rest::Error.new( + "#{Gapic::Rest::Error::REST_ERROR_PREFIX}: Quota limit reached", + 429, + status: "RESOURCE_EXHAUSTED", + details: details, + headers: headers + ) + resp = Event::HttpResponse.new status: 429, headers: headers, body: "Too many requests", error: wrapped_err + + state = State.new status: :starting + next_state, _instructions = Rules.step state, resp, @config + + assert_equal :error, next_state.status + err = next_state.last_error + assert_instance_of BadResponseError, err + assert_equal "Resumable upload failed with HTTP 429 Resource Exhausted: Quota limit reached", err.message + assert_equal 429, err.status_code + assert_equal "RESOURCE_EXHAUSTED", err.status + assert_equal details, err.status_details + assert_equal headers, err.headers + assert_equal "Too many requests", err.response_body + end + + def test_bad_response_fallback_without_wrapped_error + resp = Event::HttpResponse.new status: 503, headers: {}, body: "Service unavailable" + state = State.new status: :starting + next_state, _instructions = Rules.step state, resp, @config + + assert_equal :error, next_state.status + err = next_state.last_error + assert_instance_of BadResponseError, err + assert_equal "Resumable upload failed with HTTP 503 Service Unavailable (X-Goog-Upload-Status: missing)", err.message + assert_equal 503, err.status_code + assert_equal "Service unavailable", err.response_body + end end diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb index fd0879f..96922ba 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb @@ -182,7 +182,7 @@ def test_transition_cancellation_flow resp = Event::HttpResponse.new status: 200, headers: { "x-goog-upload-status" => "cancelled" } final_state, final_instructions = Rules.step next_state, resp, @config assert_equal :cancelled, final_state.status - assert_instance_of Gapic::Common::UploadCancelledError, final_state.last_error + assert_instance_of UploadCancelledError, final_state.last_error assert_equal 1, final_instructions.size assert_instance_of Instruction::TerminateFailure, final_instructions.first end From e9d6b88f98ee853ce7d5cfd4b8c2249f72652b4e Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Wed, 9 Sep 2026 23:28:03 +0000 Subject: [PATCH 48/79] feat: log response body for actionable errors --- gapic-common/design/implementation-guide.md | 1 + gapic-common/design/test-plan.md | 4 +- .../resumable_upload/driver/upload_log.rb | 11 ++- .../resumable_upload/driver_logging_test.rb | 87 +++++++++++++++++++ 4 files changed, 100 insertions(+), 3 deletions(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index b85c191..2d3fe91 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -514,6 +514,7 @@ All log entries emitted by `UploadLog` populate structured fields in `Google::Lo * `uploadUrl`: Abridged session upload URL (on `:begin_transmission` and `:cancel_session`). * `status`: Current protocol status symbol (on `unmatched_transition`). * `error`: Exception message string (on `fail_with_*` and `unmatched_transition`). + * `responseBody`: Abridged error response body from `last_error.response_body` when present (on `fail_with_*`). * **Wire & Transport Fields**: * `method`: Always the string `"POST"`. * `url`: Abridged request target URI. diff --git a/gapic-common/design/test-plan.md b/gapic-common/design/test-plan.md index 55b718f..057c701 100644 --- a/gapic-common/design/test-plan.md +++ b/gapic-common/design/test-plan.md @@ -282,8 +282,8 @@ flowchart TD * Confirms multi-chunk upload emits `INFO` lifecycle entries for `start_session`, `begin_transmission`, and completion while suppressing per-chunk `ack_chunk` at `INFO`. * **Protocol recovery logging (`test_recovery_scenario_logs_enter_recovery_and_realign`)**: * Simulates HTTP 503 during chunk upload followed by recovery query; asserts `INFO` logs include both `enter_recovery` and `realign_from_recovery`. -* **Terminal failure & unmatched transition logging (`test_fatal_failure_logs_warn_with_fail_with_recipe`, `test_unmatched_transition_logs_warn_and_reraises`)**: - * Confirms fatal HTTP 403 rejection emits `WARN` with a `fail_with_*` recipe, and unexpected Core state transitions emit `WARN` prior to raising `InvalidTransitionError`. +* **Terminal failure & unmatched transition logging (`test_fatal_failure_logs_warn_with_fail_with_recipe`, `test_unmatched_transition_logs_warn_and_reraises`, `test_lifecycle_warn_includes_response_body_for_rejected_error`, `test_lifecycle_warn_includes_response_body_for_bad_response_error`, `test_lifecycle_warn_omits_response_body_when_error_lacks_it`)**: + * Confirms fatal HTTP 403 rejection emits `WARN` with a `fail_with_*` recipe, unexpected Core state transitions emit `WARN` prior to raising `InvalidTransitionError`, and terminal failure entries capture abridged `responseBody` when present on `last_error` (or omit it when absent). * **End-to-end secret redaction (`test_full_log_corpus_redacts_secrets`)**: * Executes a 16 MiB two-chunk upload containing a sentinel secret (`"SECRET-123456"`) in the stream payload, session query URL (`sid=SECRET-123456`), initiation query token (`token=SECRET-123456`), and `Authorization: Bearer SECRET-123456` header. * Asserts the sentinel string is completely absent across the entire serialized log corpus. diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb index 5b80e8b..8522de9 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb @@ -186,12 +186,21 @@ def lifecycle_fields decision, config { uploadUrl: Abridge.url(state.upload_url) } when :fail_with_deadline_exceeded, :fail_with_rejected, :fail_with_bad_response, :fail_with_request_error - { error: state.last_error&.message || state.last_error.to_s } + failure_fields state else {} end end + def failure_fields state + err = state.last_error + fields = { error: err&.message || err.to_s } + if err.respond_to?(:response_body) && err.response_body + fields[:responseBody] = Abridge.error_body err.response_body + end + fields + end + def entry severity, log_msg, **fields @stub_logger.public_send severity do |builder| builder.set_system_name diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb index 204e83a..146cf3e 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb @@ -384,4 +384,91 @@ def test_driver_error_mapping_populates_error_on_rescue assert_equal 400, faraday_event.error.status_code assert_equal "INVALID_ARGUMENT", faraday_event.error.status end + + def test_lifecycle_warn_includes_response_body_for_rejected_error + recording = RecordingLogger.new + raw_body = '{"error":{"code":403,"message":"Rejected by backend"}}' + faraday_err = Faraday::ClientError.new "Client error", { + status: 403, + headers: { "x-goog-upload-status" => "final" }, + body: raw_body + } + stub = FakeStub.new [faraday_err] + config = CompleteUploadConfig.new( + initial_url: "https://storage.googleapis.com/upload", + stream: StringIO.new("data"), + upload_size: 4, + chunk_size: 256 + ) + + driver = Driver.new client_stub: stub, config: config, logger: recording + err = assert_raises UploadRejectedError do + driver.run + end + + assert_equal raw_body, err.response_body + + warn_entries = recording.entries.select { |e| e.severity == Logger::WARN } + fail_warn = warn_entries.find { |e| e.message.fields["recipe"] == "fail_with_rejected" } + refute_nil fail_warn + assert_equal raw_body, fail_warn.message.fields["responseBody"] + end + + def test_lifecycle_warn_includes_response_body_for_bad_response_error + recording = RecordingLogger.new + raw_body = "Bad gateway" + faraday_err = Faraday::ClientError.new "Server error", { + status: 502, + headers: {}, + body: raw_body + } + stub = FakeStub.new [faraday_err] + config = CompleteUploadConfig.new( + initial_url: "https://storage.googleapis.com/upload", + stream: StringIO.new("data"), + upload_size: 4, + chunk_size: 256 + ) + + driver = Driver.new client_stub: stub, config: config, logger: recording + err = assert_raises BadResponseError do + driver.run + end + + assert_equal raw_body, err.response_body + + warn_entries = recording.entries.select { |e| e.severity == Logger::WARN } + fail_warn = warn_entries.find { |e| e.message.fields["recipe"] == "fail_with_bad_response" } + refute_nil fail_warn + assert_equal raw_body, fail_warn.message.fields["responseBody"] + end + + def test_lifecycle_warn_omits_response_body_when_error_lacks_it + recording = RecordingLogger.new + upload_log = Driver::UploadLog.new recording, upload_id: "test-upload-no-body" + state = State.initial.with( + status: :failed, + last_error: DeadlineExceededError.new("Upload deadline exceeded") + ) + decision = Decision.new( + from_status: :transferring, + shape: :deadline_exceeded, + recipe: :fail_with_deadline_exceeded, + next_state: state, + instructions: [] + ) + + upload_log.lifecycle decision, CompleteUploadConfig.new( + initial_url: "https://storage.googleapis.com/upload", + stream: StringIO.new("data"), + upload_size: 4, + chunk_size: 256 + ) + + warn_entries = recording.entries.select { |e| e.severity == Logger::WARN } + fail_warn = warn_entries.find { |e| e.message.fields["recipe"] == "fail_with_deadline_exceeded" } + refute_nil fail_warn + assert_equal "Upload deadline exceeded", fail_warn.message.fields["error"] + assert_nil fail_warn.message.fields["responseBody"] + end end From 7bbcbe323629648122322f88e17b1e0397533dbd Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Wed, 9 Sep 2026 23:46:49 +0000 Subject: [PATCH 49/79] fix: errors differentiation --- gapic-common/design/implementation-guide.md | 13 +- gapic-common/design/test-plan.md | 9 +- .../lib/gapic/rest/resumable_upload/errors.rb | 61 ++++---- .../resumable_upload/driver_logging_test.rb | 139 +++++++++--------- .../rest/resumable_upload/rules_error_test.rb | 15 +- 5 files changed, 127 insertions(+), 110 deletions(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index 2d3fe91..f0f1483 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -397,21 +397,22 @@ The implementation distinguishes three categories of network and protocol-level * **Resolution**: Core transitions to `:rejected` or `:error` and emits `Instruction::TerminateFailure`. #### 6.1.4 Actionable Terminal Errors & Metadata Propagation -Terminal errors subclass `Gapic::Rest::Error` (or `Gapic::Rest::DeadlineExceededError`) so downstream SDK callers can inspect HTTP error metadata: +Terminal errors provide actionable context so downstream SDK callers can inspect error metadata: * **Error Classes**: * `BadResponseError < Gapic::Rest::Error`: Unrecoverable non-2xx HTTP responses or invalid payloads. Retains `attr_reader :response_body` returning `event.body`. * `UploadRejectedError < Gapic::Rest::Error`: Backend explicitly rejected the session with `X-Goog-Upload-Status: final`. Retains `attr_reader :response_body` returning `event.body`. - * `UploadCancelledError < Gapic::Rest::Error`: Upload session cancelled by caller. - * `DeadlineExceededError < Gapic::Rest::DeadlineExceededError`: Upload deadline exceeded with optional root cause. + * `UploadCancelledError < Gapic::Common::Error`: Upload session cancelled by caller. + * `DeadlineExceededError < Gapic::Common::Error`: Upload deadline exceeded with optional root cause (`attr_reader :root_cause`). * **Metadata Sourcing & De-prefixing**: * When `event.error` is present (from `Gapic::Rest::Error.wrap_faraday_error`), factories source `status_code`, `status`, `details`/`status_details`, and `headers`/`header`. * The prefix literal `Gapic::Rest::Error::REST_ERROR_PREFIX` (`"An error has occurred when making a REST request"`) is stripped from `event.error.message` to avoid redundant prefixes. * The resulting actionable message follows the format: - `"Resumable upload failed with HTTP #{status_code} #{status_name}: #{inner_message}"` - (e.g., `"Resumable upload failed with HTTP 403 Permission Denied: The caller does not have permission"`). + * For `UploadRejectedError`: `"Upload rejected by server with HTTP #{status_code} #{status_name}: #{inner_message}"` (e.g., `"Upload rejected by server with HTTP 403 Permission Denied: The caller does not have permission"`). + * For `BadResponseError`: `"Resumable upload failed with HTTP #{status_code} #{status_name}: #{inner_message}"` (e.g., `"Resumable upload failed with HTTP 429 Resource Exhausted: Quota limit reached"`). * **Fallback Formatting**: * When `event.error` is absent, factories fall back to `event.status` and `event.headers`, naming the status and including the detailed `X-Goog-Upload-Status` header: - `"Resumable upload failed with HTTP #{event.status} #{status_name} (X-Goog-Upload-Status: #{upload_status_desc})"`. + * For `UploadRejectedError`: `"Upload rejected by server with HTTP #{event.status} #{status_name} (X-Goog-Upload-Status: 'final')"`. + * For `BadResponseError`: `"Resumable upload failed with HTTP #{event.status} #{status_name} (X-Goog-Upload-Status: #{upload_status_desc})"`. * `response_body` on `BadResponseError` and `UploadRejectedError` returns `event.body`. ### 6.2 Recovery and Buffer Alignment diff --git a/gapic-common/design/test-plan.md b/gapic-common/design/test-plan.md index 057c701..60ef93c 100644 --- a/gapic-common/design/test-plan.md +++ b/gapic-common/design/test-plan.md @@ -134,10 +134,13 @@ flowchart TD * **Terminal failures (`:rejected` / `:error`)**: * Session rejection (`:response_rejected` $\rightarrow$ `UploadRejectedError`), fatal bad responses (`:response_fatal_bad_response` $\rightarrow$ `BadResponseError`). * Non-recoverable request failures: `:request_retries_exhausted` in `:transmission_sending`, and `:request_timeout` in `:starting` or `:recovery`. - * Global deadline expiration: `:global_deadline_exceeded` $\rightarrow$ `DeadlineExceededError`. + * Global deadline expiration: `:global_deadline_exceeded` $\rightarrow$ `DeadlineExceededError < Gapic::Common::Error`. + * Session cancellation: `:complete_cancellation` $\rightarrow$ `UploadCancelledError < Gapic::Common::Error`. * **Actionable description and metadata propagation**: - * Wrapped errors (`event.error`) de-prefix `Gapic::Rest::Error::REST_ERROR_PREFIX` and format as `"Resumable upload failed with HTTP #{status_code} #{status_name}: #{inner_message}"`. - * Preserves `status_code`, `status`, `details`/`status_details`, `headers`/`header`, and `response_body` (on `UploadRejectedError`) end-to-end. + * Wrapped errors (`event.error`) de-prefix `Gapic::Rest::Error::REST_ERROR_PREFIX` and format with appropriate prefixes: + * `UploadRejectedError`: `"Upload rejected by server with HTTP #{status_code} #{status_name}: #{inner_message}"`. + * `BadResponseError`: `"Resumable upload failed with HTTP #{status_code} #{status_name}: #{inner_message}"`. + * Preserves `status_code`, `status`, `details`/`status_details`, `headers`/`header`, and `response_body` (on `UploadRejectedError` and `BadResponseError`) end-to-end. * Fallback without `event.error` names the HTTP status code and status, appending detailed header `(X-Goog-Upload-Status: '...')`. * **Actionable description on unexpected HTTP response**: * Unmatched event (HTTP 200 `Status: final` while in `:transmission_sending`) raises `InvalidTransitionError` with human phrasing (`"Resumable upload failed while sending a chunk of data: received an unexpected HTTP 200 response (X-Goog-Upload-Status: 'final')."`) and attaches `err.response`, `err.event`, `err.state`. diff --git a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb index ca6709b..577be7d 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb @@ -64,17 +64,17 @@ def clean_message raw_message msg.empty? ? nil : msg end - def build_attributes source + def build_attributes source, prefix: "Resumable upload failed" if source.respond_to?(:error) && source.error - build_from_wrapped_error source + build_from_wrapped_error source, prefix: prefix elsif source.is_a? Gapic::Rest::Error - build_from_rest_error source + build_from_rest_error source, prefix: prefix elsif source.respond_to? :status - build_from_http_event source + build_from_http_event source, prefix: prefix elsif source.is_a? Integer status_name = HTTP_STATUS_PHRASES[source] status_part = status_name ? " #{status_name}" : "" - ["Resumable upload failed with HTTP #{source}#{status_part}".strip, source, nil, nil, nil] + ["#{prefix} with HTTP #{source}#{status_part}".strip, source, nil, nil, nil] else [source.to_s, nil, nil, nil, nil] end @@ -82,7 +82,7 @@ def build_attributes source private - def build_from_wrapped_error source + def build_from_wrapped_error source, prefix: err = source.error status_code = err.status_code || (source.respond_to?(:status) ? source.status : nil) status = err.status @@ -90,36 +90,36 @@ def build_from_wrapped_error source status_part = status_name ? " #{status_name}" : "" inner_msg = clean_message err.message msg = if inner_msg - "Resumable upload failed with HTTP #{status_code}#{status_part}: #{inner_msg}" + "#{prefix} with HTTP #{status_code}#{status_part}: #{inner_msg}" else - "Resumable upload failed with HTTP #{status_code}#{status_part}" + "#{prefix} with HTTP #{status_code}#{status_part}" end headers = err.headers || (source.respond_to?(:headers) ? source.headers : nil) [msg, status_code, status, err.details, headers] end - def build_from_rest_error source + def build_from_rest_error source, prefix: status_code = source.status_code status = source.status status_name = format_status(status) || HTTP_STATUS_PHRASES[status_code] status_part = status_name ? " #{status_name}" : "" inner_msg = clean_message source.message msg = if inner_msg - "Resumable upload failed with HTTP #{status_code}#{status_part}: #{inner_msg}" + "#{prefix} with HTTP #{status_code}#{status_part}: #{inner_msg}" else - "Resumable upload failed with HTTP #{status_code}#{status_part}" + "#{prefix} with HTTP #{status_code}#{status_part}" end [msg, status_code, status, source.details, source.headers] end - def build_from_http_event source + def build_from_http_event source, prefix: status_code = source.status headers = source.respond_to?(:headers) && source.headers ? source.headers : {} upload_status = headers["x-goog-upload-status"] || headers["X-Goog-Upload-Status"] status_desc = upload_status ? "'#{upload_status}'" : "missing" status_name = HTTP_STATUS_PHRASES[status_code] status_part = status_name ? " #{status_name}" : "" - msg = "Resumable upload failed with HTTP #{status_code}#{status_part} " \ + msg = "#{prefix} with HTTP #{status_code}#{status_part} " \ "(X-Goog-Upload-Status: #{status_desc})" [msg, status_code, nil, nil, headers] end @@ -206,16 +206,17 @@ class UploadRejectedError < Gapic::Rest::Error def initialize message = nil, status_code = nil, status: nil, details: nil, headers: nil, response_body: nil @response_body = response_body if status_code.nil? && response_body.nil? && message && - !message.start_with?("Resumable upload failed", "Upload was rejected") + !message.start_with?("Upload rejected by server") @response_body = message - message = "Upload was rejected by server: #{message}" + message = "Upload rejected by server: #{message}" end super message, status_code, status: status, details: details, headers: headers end def self.from source, response_body: nil body = response_body || (source.respond_to?(:body) ? source.body : nil) - message, status_code, status, details, headers = ErrorBuilder.build_attributes source + message, status_code, status, details, headers = + ErrorBuilder.build_attributes source, prefix: "Upload rejected by server" new message, status_code, status: status, details: details, headers: headers, response_body: body end end @@ -223,19 +224,14 @@ def self.from source, response_body: nil ## # Raised when the upload session is cancelled. # - class UploadCancelledError < Gapic::Rest::Error - def initialize message = "Upload session was cancelled", status_code = nil, status: nil, details: nil, - headers: nil - super message, status_code, status: status, details: details, headers: headers + class UploadCancelledError < Gapic::Common::Error + def initialize message = "Upload session was cancelled" + super message end - def self.from source - if source.respond_to? :status - new "Upload session was cancelled", source.status, - headers: (source.respond_to?(:headers) ? source.headers : nil) - elsif source.is_a? Gapic::Rest::Error - new source.message, source.status_code, status: source.status, details: source.details, - headers: source.headers + def self.from source = nil + if source.is_a?(String) && !source.empty? + new source else new end @@ -245,10 +241,13 @@ def self.from source ## # Raised when an upload exceeds its global monotonic deadline. # - class DeadlineExceededError < Gapic::Rest::DeadlineExceededError - def initialize message = "Upload deadline exceeded", status_code = nil, status: nil, details: nil, - headers: nil, root_cause: nil - super message, status_code, status: status, details: details, headers: headers, root_cause: root_cause + class DeadlineExceededError < Gapic::Common::Error + # @return [Object, nil] Root cause exception if deadline exceeded during a retry loop + attr_reader :root_cause + + def initialize message = "Upload deadline exceeded", root_cause: nil + super message + @root_cause = root_cause end end end diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb index 146cf3e..d4f9686 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb @@ -218,66 +218,10 @@ def test_full_log_corpus_size_under_64kib assert_operator corpus.bytesize, :<, 65_536 end - private - - def run_two_chunk_upload_with_secret recording - chunk_size = 8 * 1024 * 1024 - secret = "SECRET-123456" - binary_prefix = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09".b - - half = (chunk_size / 2) - 10 - chunk1 = binary_prefix + ("A" * half) + secret + ("A" * (chunk_size - 10 - half - secret.bytesize)) - chunk2 = binary_prefix + ("A" * (chunk_size - 10)) - stream_data = chunk1 + chunk2 - - responses = [ - FakeResponse.new( - 200, - { - "X-Goog-Upload-Status" => "active", - "X-Goog-Upload-URL" => "https://storage.googleapis.com/session?sid=#{secret}" - }, - "" - ), - FakeResponse.new( - 200, - { - "X-Goog-Upload-Status" => "active", - "X-Goog-Upload-Size-Received" => chunk_size.to_s - }, - "" - ), - FakeResponse.new( - 200, - { - "X-Goog-Upload-Status" => "active", - "X-Goog-Upload-Size-Received" => (chunk_size * 2).to_s - }, - "" - ), - FakeResponse.new( - 200, - { "X-Goog-Upload-Status" => "final" }, - "done" - ) - ] - - stub = FakeStub.new responses - config = CompleteUploadConfig.new( - initial_url: "https://storage.googleapis.com/upload?token=#{secret}", - initial_headers: { "Authorization" => "Bearer #{secret}" }, - stream: StringIO.new(stream_data), - upload_size: stream_data.bytesize, - chunk_size: chunk_size - ) - - driver = Driver.new client_stub: stub, config: config, logger: recording - driver.run - end - def test_wire_receive_logs_error_status_and_abridged_error_message recording = RecordingLogger.new - upload_log = Driver::UploadLog.new recording, upload_id: "test-upload-1" + stub_logger = Gapic::LoggingConcerns::StubLogger.new logger: recording, service: "ResumableUpload" + upload_log = Driver::UploadLog.new stub_logger, upload_id: "test-upload-1" err = Gapic::Rest::Error.new( "#{Gapic::Rest::Error::REST_ERROR_PREFIX}: Permission denied on resource", @@ -303,7 +247,8 @@ def test_wire_receive_logs_error_status_and_abridged_error_message def test_wire_receive_fallback_body_when_error_absent recording = RecordingLogger.new - upload_log = Driver::UploadLog.new recording, upload_id: "test-upload-2" + stub_logger = Gapic::LoggingConcerns::StubLogger.new logger: recording, service: "ResumableUpload" + upload_log = Driver::UploadLog.new stub_logger, upload_id: "test-upload-2" event = Event::HttpResponse.new( status: 503, @@ -343,13 +288,13 @@ def test_lifecycle_warn_carries_rich_message_when_driver_fails driver.run end - assert_equal "Resumable upload failed with HTTP 403 Permission Denied: Bucket access denied", err.message + assert_equal "Upload rejected by server with HTTP 403 Permission Denied: Bucket access denied", err.message warn_entries = recording.entries.select { |e| e.severity == Logger::WARN } refute_empty warn_entries fail_warn = warn_entries.find { |e| e.message.fields["recipe"] == "fail_with_rejected" } refute_nil fail_warn - assert_equal "Resumable upload failed with HTTP 403 Permission Denied: Bucket access denied", + assert_equal "Upload rejected by server with HTTP 403 Permission Denied: Bucket access denied", fail_warn.message.fields["error"] debug_entries = recording.entries.select { |e| e.severity == Logger::DEBUG && e.message.message.include?("403") } @@ -416,10 +361,10 @@ def test_lifecycle_warn_includes_response_body_for_rejected_error def test_lifecycle_warn_includes_response_body_for_bad_response_error recording = RecordingLogger.new - raw_body = "Bad gateway" - faraday_err = Faraday::ClientError.new "Server error", { - status: 502, - headers: {}, + raw_body = '{"error":{"message":"Invalid input","code":400}}' + faraday_err = Faraday::ClientError.new "Client error", { + status: 400, + headers: { "x-goog-upload-status" => "active" }, body: raw_body } stub = FakeStub.new [faraday_err] @@ -445,9 +390,10 @@ def test_lifecycle_warn_includes_response_body_for_bad_response_error def test_lifecycle_warn_omits_response_body_when_error_lacks_it recording = RecordingLogger.new - upload_log = Driver::UploadLog.new recording, upload_id: "test-upload-no-body" - state = State.initial.with( - status: :failed, + stub_logger = Gapic::LoggingConcerns::StubLogger.new logger: recording, service: "ResumableUpload" + upload_log = Driver::UploadLog.new stub_logger, upload_id: "test-upload-no-body" + state = State.new( + status: :error, last_error: DeadlineExceededError.new("Upload deadline exceeded") ) decision = Decision.new( @@ -471,4 +417,61 @@ def test_lifecycle_warn_omits_response_body_when_error_lacks_it assert_equal "Upload deadline exceeded", fail_warn.message.fields["error"] assert_nil fail_warn.message.fields["responseBody"] end + + private + + def run_two_chunk_upload_with_secret recording + chunk_size = 8 * 1024 * 1024 + secret = "SECRET-123456" + binary_prefix = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09".b + + half = (chunk_size / 2) - 10 + chunk1 = binary_prefix + ("A" * half) + secret + ("A" * (chunk_size - 10 - half - secret.bytesize)) + chunk2 = binary_prefix + ("A" * (chunk_size - 10)) + stream_data = chunk1 + chunk2 + + responses = [ + FakeResponse.new( + 200, + { + "X-Goog-Upload-Status" => "active", + "X-Goog-Upload-URL" => "https://storage.googleapis.com/session?sid=#{secret}" + }, + "" + ), + FakeResponse.new( + 200, + { + "X-Goog-Upload-Status" => "active", + "X-Goog-Upload-Size-Received" => chunk_size.to_s + }, + "" + ), + FakeResponse.new( + 200, + { + "X-Goog-Upload-Status" => "active", + "X-Goog-Upload-Size-Received" => (chunk_size * 2).to_s + }, + "" + ), + FakeResponse.new( + 200, + { "X-Goog-Upload-Status" => "final" }, + "done" + ) + ] + + stub = FakeStub.new responses + config = CompleteUploadConfig.new( + initial_url: "https://storage.googleapis.com/upload?token=#{secret}", + initial_headers: { "Authorization" => "Bearer #{secret}" }, + stream: StringIO.new(stream_data), + upload_size: stream_data.bytesize, + chunk_size: chunk_size + ) + + driver = Driver.new client_stub: stub, config: config, logger: recording + driver.run + end end diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb index 9850c4b..941f0fb 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb @@ -195,7 +195,7 @@ def test_rejected_with_wrapped_error_deprefixes_message_and_preserves_metadata assert_equal :rejected, next_state.status err = next_state.last_error assert_instance_of UploadRejectedError, err - assert_equal "Resumable upload failed with HTTP 403 Permission Denied: The caller does not have permission", + assert_equal "Upload rejected by server with HTTP 403 Permission Denied: The caller does not have permission", err.message assert_equal 403, err.status_code assert_equal "PERMISSION_DENIED", err.status @@ -215,7 +215,7 @@ def test_rejected_fallback_without_wrapped_error assert_equal :rejected, next_state.status err = next_state.last_error assert_instance_of UploadRejectedError, err - assert_equal "Resumable upload failed with HTTP 403 Forbidden (X-Goog-Upload-Status: 'final')", err.message + assert_equal "Upload rejected by server with HTTP 403 Forbidden (X-Goog-Upload-Status: 'final')", err.message assert_equal 403, err.status_code assert_equal "Forbidden", err.response_body end @@ -258,4 +258,15 @@ def test_bad_response_fallback_without_wrapped_error assert_equal 503, err.status_code assert_equal "Service unavailable", err.response_body end + + def test_error_class_inheritance_hierarchy + assert_operator UploadCancelledError, :<, Gapic::Common::Error + refute_operator UploadCancelledError, :<, Gapic::Rest::Error + + assert_operator DeadlineExceededError, :<, Gapic::Common::Error + refute_operator DeadlineExceededError, :<, Gapic::Rest::Error + + assert_operator UploadRejectedError, :<, Gapic::Rest::Error + assert_operator BadResponseError, :<, Gapic::Rest::Error + end end From 33566564e2f8858a9e2aa690ef1443fcac32a437 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Thu, 10 Sep 2026 00:05:05 +0000 Subject: [PATCH 50/79] fix: simplify error construction, add test' --- gapic-common/design/implementation-guide.md | 4 +- gapic-common/design/test-plan.md | 4 +- .../lib/gapic/rest/resumable_upload/errors.rb | 73 ++++++------------- .../resumable_upload/driver_logging_test.rb | 62 +++++++++++++++- .../rest/resumable_upload/rules_error_test.rb | 18 ++++- 5 files changed, 101 insertions(+), 60 deletions(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index f0f1483..7c4e045 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -407,8 +407,8 @@ Terminal errors provide actionable context so downstream SDK callers can inspect * When `event.error` is present (from `Gapic::Rest::Error.wrap_faraday_error`), factories source `status_code`, `status`, `details`/`status_details`, and `headers`/`header`. * The prefix literal `Gapic::Rest::Error::REST_ERROR_PREFIX` (`"An error has occurred when making a REST request"`) is stripped from `event.error.message` to avoid redundant prefixes. * The resulting actionable message follows the format: - * For `UploadRejectedError`: `"Upload rejected by server with HTTP #{status_code} #{status_name}: #{inner_message}"` (e.g., `"Upload rejected by server with HTTP 403 Permission Denied: The caller does not have permission"`). - * For `BadResponseError`: `"Resumable upload failed with HTTP #{status_code} #{status_name}: #{inner_message}"` (e.g., `"Resumable upload failed with HTTP 429 Resource Exhausted: Quota limit reached"`). + * For `UploadRejectedError`: `"Upload rejected by server with HTTP #{status_code} #{status_name}: #{inner_message}"` (e.g., `"Upload rejected by server with HTTP 403 PERMISSION_DENIED: The caller does not have permission"`). + * For `BadResponseError`: `"Resumable upload failed with HTTP #{status_code} #{status_name}: #{inner_message}"` (e.g., `"Resumable upload failed with HTTP 429 RESOURCE_EXHAUSTED: Quota limit reached"`). * **Fallback Formatting**: * When `event.error` is absent, factories fall back to `event.status` and `event.headers`, naming the status and including the detailed `X-Goog-Upload-Status` header: * For `UploadRejectedError`: `"Upload rejected by server with HTTP #{event.status} #{status_name} (X-Goog-Upload-Status: 'final')"`. diff --git a/gapic-common/design/test-plan.md b/gapic-common/design/test-plan.md index 60ef93c..6831516 100644 --- a/gapic-common/design/test-plan.md +++ b/gapic-common/design/test-plan.md @@ -285,8 +285,8 @@ flowchart TD * Confirms multi-chunk upload emits `INFO` lifecycle entries for `start_session`, `begin_transmission`, and completion while suppressing per-chunk `ack_chunk` at `INFO`. * **Protocol recovery logging (`test_recovery_scenario_logs_enter_recovery_and_realign`)**: * Simulates HTTP 503 during chunk upload followed by recovery query; asserts `INFO` logs include both `enter_recovery` and `realign_from_recovery`. -* **Terminal failure & unmatched transition logging (`test_fatal_failure_logs_warn_with_fail_with_recipe`, `test_unmatched_transition_logs_warn_and_reraises`, `test_lifecycle_warn_includes_response_body_for_rejected_error`, `test_lifecycle_warn_includes_response_body_for_bad_response_error`, `test_lifecycle_warn_omits_response_body_when_error_lacks_it`)**: - * Confirms fatal HTTP 403 rejection emits `WARN` with a `fail_with_*` recipe, unexpected Core state transitions emit `WARN` prior to raising `InvalidTransitionError`, and terminal failure entries capture abridged `responseBody` when present on `last_error` (or omit it when absent). +* **Terminal failure & unmatched transition logging (`test_fatal_failure_logs_warn_with_fail_with_recipe`, `test_unmatched_transition_logs_warn_and_reraises`, `test_lifecycle_warn_includes_response_body_for_rejected_error`, `test_lifecycle_warn_includes_response_body_for_bad_response_error`, `test_lifecycle_warn_omits_response_body_when_error_lacks_it`, `test_error_info_reason_in_details_survives_in_error_and_logs`)**: + * Confirms fatal HTTP 403 rejection emits `WARN` with a `fail_with_*` recipe, unexpected Core state transitions emit `WARN` prior to raising `InvalidTransitionError`, terminal failure entries capture abridged `responseBody` when present on `last_error` (or omit it when absent), and unpacked `Google::Rpc::ErrorInfo` reason in `details` survives through the raised error and lifecycle warning logs. * **End-to-end secret redaction (`test_full_log_corpus_redacts_secrets`)**: * Executes a 16 MiB two-chunk upload containing a sentinel secret (`"SECRET-123456"`) in the stream payload, session query URL (`sid=SECRET-123456`), initiation query token (`token=SECRET-123456`), and `Authorization: Bearer SECRET-123456` header. * Asserts the sentinel string is completely absent across the entire serialized log corpus. diff --git a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb index 577be7d..3bc5547 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb @@ -20,6 +20,9 @@ module Gapic module Rest module ResumableUpload + ## + # @private + # HTTP_STATUS_PHRASES = { 400 => "Bad Request", 401 => "Unauthorized", @@ -51,7 +54,7 @@ class << self def format_status status return nil if status.nil? || status.to_s.empty? - status.to_s.split("_").map(&:capitalize).join(" ") + status.to_s end def clean_message raw_message @@ -64,27 +67,19 @@ def clean_message raw_message msg.empty? ? nil : msg end - def build_attributes source, prefix: "Resumable upload failed" - if source.respond_to?(:error) && source.error - build_from_wrapped_error source, prefix: prefix - elsif source.is_a? Gapic::Rest::Error - build_from_rest_error source, prefix: prefix - elsif source.respond_to? :status - build_from_http_event source, prefix: prefix - elsif source.is_a? Integer - status_name = HTTP_STATUS_PHRASES[source] - status_part = status_name ? " #{status_name}" : "" - ["#{prefix} with HTTP #{source}#{status_part}".strip, source, nil, nil, nil] + def build_attributes event, prefix: "Resumable upload failed" + if event.respond_to?(:error) && event.error + build_from_wrapped_error event, prefix: prefix else - [source.to_s, nil, nil, nil, nil] + build_from_http_event event, prefix: prefix end end private - def build_from_wrapped_error source, prefix: - err = source.error - status_code = err.status_code || (source.respond_to?(:status) ? source.status : nil) + def build_from_wrapped_error event, prefix: + err = event.error + status_code = err.status_code || (event.respond_to?(:status) ? event.status : nil) status = err.status status_name = format_status(status) || HTTP_STATUS_PHRASES[status_code] status_part = status_name ? " #{status_name}" : "" @@ -94,27 +89,13 @@ def build_from_wrapped_error source, prefix: else "#{prefix} with HTTP #{status_code}#{status_part}" end - headers = err.headers || (source.respond_to?(:headers) ? source.headers : nil) + headers = err.headers || (event.respond_to?(:headers) ? event.headers : nil) [msg, status_code, status, err.details, headers] end - def build_from_rest_error source, prefix: - status_code = source.status_code - status = source.status - status_name = format_status(status) || HTTP_STATUS_PHRASES[status_code] - status_part = status_name ? " #{status_name}" : "" - inner_msg = clean_message source.message - msg = if inner_msg - "#{prefix} with HTTP #{status_code}#{status_part}: #{inner_msg}" - else - "#{prefix} with HTTP #{status_code}#{status_part}" - end - [msg, status_code, status, source.details, source.headers] - end - - def build_from_http_event source, prefix: - status_code = source.status - headers = source.respond_to?(:headers) && source.headers ? source.headers : {} + def build_from_http_event event, prefix: + status_code = event.status + headers = event.respond_to?(:headers) && event.headers ? event.headers : {} upload_status = headers["x-goog-upload-status"] || headers["X-Goog-Upload-Status"] status_desc = upload_status ? "'#{upload_status}'" : "missing" status_name = HTTP_STATUS_PHRASES[status_code] @@ -172,19 +153,12 @@ class BadResponseError < Gapic::Rest::Error # @param response_body [String, nil] def initialize message = nil, status_code = nil, status: nil, details: nil, headers: nil, response_body: nil @response_body = response_body - if message.is_a?(Integer) && status_code.is_a?(String) - message, status_code = status_code, message - elsif message.is_a?(Integer) && status_code.nil? - status_code = message - message = nil - end - message ||= "Received unexpected response with status code: #{status_code}" if status_code super message, status_code, status: status, details: details, headers: headers end - def self.from source, response_body: nil - body = response_body || (source.respond_to?(:body) ? source.body : nil) - message, status_code, status, details, headers = ErrorBuilder.build_attributes source + def self.from event, response_body: nil + body = response_body || (event.respond_to?(:body) ? event.body : nil) + message, status_code, status, details, headers = ErrorBuilder.build_attributes event new message, status_code, status: status, details: details, headers: headers, response_body: body end end @@ -205,18 +179,13 @@ class UploadRejectedError < Gapic::Rest::Error # @param response_body [String, nil] def initialize message = nil, status_code = nil, status: nil, details: nil, headers: nil, response_body: nil @response_body = response_body - if status_code.nil? && response_body.nil? && message && - !message.start_with?("Upload rejected by server") - @response_body = message - message = "Upload rejected by server: #{message}" - end super message, status_code, status: status, details: details, headers: headers end - def self.from source, response_body: nil - body = response_body || (source.respond_to?(:body) ? source.body : nil) + def self.from event, response_body: nil + body = response_body || (event.respond_to?(:body) ? event.body : nil) message, status_code, status, details, headers = - ErrorBuilder.build_attributes source, prefix: "Upload rejected by server" + ErrorBuilder.build_attributes event, prefix: "Upload rejected by server" new message, status_code, status: status, details: details, headers: headers, response_body: body end end diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb index d4f9686..1f539e0 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb @@ -17,6 +17,7 @@ require "test_helper" require "gapic/rest/resumable_upload" require "stringio" +require "google/rpc/error_details_pb" ## # Integration and unit tests for Driver logging concerns. @@ -288,13 +289,13 @@ def test_lifecycle_warn_carries_rich_message_when_driver_fails driver.run end - assert_equal "Upload rejected by server with HTTP 403 Permission Denied: Bucket access denied", err.message + assert_equal "Upload rejected by server with HTTP 403 PERMISSION_DENIED: Bucket access denied", err.message warn_entries = recording.entries.select { |e| e.severity == Logger::WARN } refute_empty warn_entries fail_warn = warn_entries.find { |e| e.message.fields["recipe"] == "fail_with_rejected" } refute_nil fail_warn - assert_equal "Upload rejected by server with HTTP 403 Permission Denied: Bucket access denied", + assert_equal "Upload rejected by server with HTTP 403 PERMISSION_DENIED: Bucket access denied", fail_warn.message.fields["error"] debug_entries = recording.entries.select { |e| e.severity == Logger::DEBUG && e.message.message.include?("403") } @@ -418,6 +419,63 @@ def test_lifecycle_warn_omits_response_body_when_error_lacks_it assert_nil fail_warn.message.fields["responseBody"] end + def test_error_info_reason_in_details_survives_in_error_and_logs + recording = RecordingLogger.new + error_info = Google::Rpc::ErrorInfo.new( + reason: "SERVICE_DISABLED", + domain: "googleapis.com", + metadata: { "consumer" => "projects/12345", "service" => "storage.googleapis.com" } + ) + error_info_any = Google::Protobuf::Any.pack error_info + raw_body = JSON.dump( + { + "error" => { + "code" => 403, + "message" => "Google Cloud Storage API has not been used in project 12345 or it is disabled.", + "status" => "PERMISSION_DENIED", + "details" => [JSON.parse(error_info_any.to_json)] + } + } + ) + faraday_err = Faraday::ClientError.new "Client error", { + status: 403, + headers: { "x-goog-upload-status" => "final" }, + body: raw_body + } + stub = FakeStub.new [faraday_err] + config = CompleteUploadConfig.new( + initial_url: "https://storage.googleapis.com/upload", + stream: StringIO.new("data"), + upload_size: 4, + chunk_size: 256 + ) + + driver = Driver.new client_stub: stub, config: config, logger: recording + err = assert_raises UploadRejectedError do + driver.run + end + + refute_nil err.details + unpacked_info = err.details.find { |d| d.is_a? Google::Rpc::ErrorInfo } + refute_nil unpacked_info + assert_equal "SERVICE_DISABLED", unpacked_info.reason + assert_equal "googleapis.com", unpacked_info.domain + assert_equal "projects/12345", unpacked_info.metadata["consumer"] + + expected_msg = "Upload rejected by server with HTTP 403 PERMISSION_DENIED: " \ + "Google Cloud Storage API has not been used in project 12345 or it is disabled." + assert_equal expected_msg, err.message + assert_equal 403, err.status_code + assert_equal "PERMISSION_DENIED", err.status + assert_equal raw_body, err.response_body + + warn_entries = recording.entries.select { |e| e.severity == Logger::WARN } + fail_warn = warn_entries.find { |e| e.message.fields["recipe"] == "fail_with_rejected" } + refute_nil fail_warn + assert_equal expected_msg, fail_warn.message.fields["error"] + assert_equal raw_body, fail_warn.message.fields["responseBody"] + end + private def run_two_chunk_upload_with_secret recording diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb index 941f0fb..1e0ec74 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb @@ -195,7 +195,7 @@ def test_rejected_with_wrapped_error_deprefixes_message_and_preserves_metadata assert_equal :rejected, next_state.status err = next_state.last_error assert_instance_of UploadRejectedError, err - assert_equal "Upload rejected by server with HTTP 403 Permission Denied: The caller does not have permission", + assert_equal "Upload rejected by server with HTTP 403 PERMISSION_DENIED: The caller does not have permission", err.message assert_equal 403, err.status_code assert_equal "PERMISSION_DENIED", err.status @@ -238,7 +238,7 @@ def test_bad_response_with_wrapped_error_deprefixes_message_and_preserves_metada assert_equal :error, next_state.status err = next_state.last_error assert_instance_of BadResponseError, err - assert_equal "Resumable upload failed with HTTP 429 Resource Exhausted: Quota limit reached", err.message + assert_equal "Resumable upload failed with HTTP 429 RESOURCE_EXHAUSTED: Quota limit reached", err.message assert_equal 429, err.status_code assert_equal "RESOURCE_EXHAUSTED", err.status assert_equal details, err.status_details @@ -259,6 +259,20 @@ def test_bad_response_fallback_without_wrapped_error assert_equal "Service unavailable", err.response_body end + def test_format_status_preserves_canonical_status_token + wrapped_err = Gapic::Rest::Error.new( + "#{Gapic::Rest::Error::REST_ERROR_PREFIX}: Object not found", + 404, + status: "NOT_FOUND", + headers: { "x-goog-upload-status" => "final" } + ) + resp = Event::HttpResponse.new status: 404, headers: { "x-goog-upload-status" => "final" }, + body: "Not found", error: wrapped_err + err = UploadRejectedError.from resp + assert_equal "Upload rejected by server with HTTP 404 NOT_FOUND: Object not found", err.message + assert_equal "NOT_FOUND", err.status + end + def test_error_class_inheritance_hierarchy assert_operator UploadCancelledError, :<, Gapic::Common::Error refute_operator UploadCancelledError, :<, Gapic::Rest::Error From 8ec859ed1946344d11895cb6e9274737fd06a0b4 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Thu, 10 Sep 2026 00:16:39 +0000 Subject: [PATCH 51/79] docs: sync --- .../design/reference-implementation.md | 67 ++++++++++++++++--- 1 file changed, 56 insertions(+), 11 deletions(-) diff --git a/gapic-common/design/reference-implementation.md b/gapic-common/design/reference-implementation.md index f341453..d1fb029 100644 --- a/gapic-common/design/reference-implementation.md +++ b/gapic-common/design/reference-implementation.md @@ -287,9 +287,10 @@ module Gapic [next_state, instructions] end - def self.complete_cancellation(state, _event, _config) - next_state = state.with(status: :cancelled, in_flight_length: 0) - [next_state, [Instruction::TerminateFailure.new(error: Gapic::Common::UploadCancelledError.new)]] + def self.complete_cancellation(state, event, _config) + err = UploadCancelledError.from(event) + next_state = state.with(status: :cancelled, in_flight_length: 0, last_error: err) + [next_state, [Instruction::TerminateFailure.new(error: err)]] end def self.ignore_duplicate_cancel(state, _event, _config) @@ -307,39 +308,43 @@ module Gapic end def self.fail_with_deadline_exceeded(state, _event, _config) + err = DeadlineExceededError.new next_state = state.with( status: :error, in_flight_length: 0, - last_error: Gapic::Common::DeadlineExceededError.new + last_error: err ) - [next_state, [Instruction::TerminateFailure.new(error: next_state.last_error)]] + [next_state, [Instruction::TerminateFailure.new(error: err)]] end def self.fail_with_rejected(state, event, _config) + err = UploadRejectedError.from(event) next_state = state.with( status: :rejected, in_flight_length: 0, - last_error: Gapic::Common::UploadRejectedError.new(event.body) + last_error: err ) - [next_state, [Instruction::TerminateFailure.new(error: next_state.last_error)]] + [next_state, [Instruction::TerminateFailure.new(error: err)]] end def self.fail_with_bad_response(state, event, _config) + err = BadResponseError.from(event) next_state = state.with( status: :error, in_flight_length: 0, - last_error: Gapic::Common::BadResponseError.new(event.status) + last_error: err ) - [next_state, [Instruction::TerminateFailure.new(error: next_state.last_error)]] + [next_state, [Instruction::TerminateFailure.new(error: err)]] end def self.fail_with_request_error(state, event, _config) + err = event.source_error || Gapic::Common::Error.new(event.message || "Request failed") next_state = state.with( status: :error, in_flight_length: 0, - last_error: event.source_error + last_error: err ) - [next_state, [Instruction::TerminateFailure.new(error: event.source_error)]] + [next_state, [Instruction::TerminateFailure.new(error: err)]] end def self.fail_with_unmatched_transition(state, event, _config) @@ -670,6 +675,46 @@ module Gapic end event end + + def rescue_request_error(err) + case err + when Gapic::Rest::DeadlineExceededError + Event::RequestFailed.new(kind: :timeout, message: err.message, source_error: err) + when Gapic::Rest::Error + if err.status_code + Event::HttpResponse.new( + status: err.status_code, + headers: err.headers || {}, + body: err.message, + error: err + ) + else + Event::RequestFailed.new(kind: :connection_failed, message: err.message, source_error: err) + end + when Faraday::Error + rescue_faraday_error(err) + else + Event::RequestFailed.new(kind: :connection_failed, message: err.message, source_error: err) + end + end + + def rescue_faraday_error(err) + if err.response && err.response[:status] + rest_err = Gapic::Rest::Error.wrap_faraday_error(err) + Event::HttpResponse.new( + status: err.response[:status], + headers: err.response[:headers] || {}, + body: err.response[:body], + error: rest_err + ) + elsif err.is_a?(Faraday::TimeoutError) + Event::RequestFailed.new(kind: :timeout, message: err.message, source_error: err) + elsif err.is_a?(Faraday::ConnectionFailed) + Event::RequestFailed.new(kind: :connection_failed, message: err.message, source_error: err) + else + Event::RequestFailed.new(kind: :retries_exhausted, message: err.message, source_error: err) + end + end end end end From ba08baf66cf83dbafb4970d7cf5df571c30955e1 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Thu, 10 Sep 2026 00:41:04 +0000 Subject: [PATCH 52/79] test: integration suite A --- gapic-common/design/integration-test-plan.md | 62 +++++++ .../resumable_upload/error_on_start_test.rb | 156 ++++++++++++++++++ .../lib/gapic/rest/resumable_upload/driver.rb | 1 + .../rest/resumable_upload/retry_policies.rb | 21 +++ .../driver_retry_policy_test.rb | 16 ++ 5 files changed, 256 insertions(+) create mode 100644 gapic-common/integration/resumable_upload/error_on_start_test.rb diff --git a/gapic-common/design/integration-test-plan.md b/gapic-common/design/integration-test-plan.md index 528f454..f47f121 100644 --- a/gapic-common/design/integration-test-plan.md +++ b/gapic-common/design/integration-test-plan.md @@ -190,4 +190,66 @@ Tests Category 1 transient transport retries and Category 2 protocol recovery wo * Raises `Gapic::Common::DeadlineExceededError`. * `phases.count(:recovering) >= 2`. +### 2.4 Error on Start Suite (`integration/resumable_upload/error_on_start_test.rb`) + +Tests non-fatal transient retries, missing status headers, retry exhaustion, fatal errors, and session isolation during the session initiation (`start`) phase. Uses a 100-byte payload and SDK default retry policies. + +#### Case 1. Non-fatal transient error on start (`test_non_fatal_error_on_start_503`) +* **Scenario**: Injects a single `503 Service Unavailable` on the initial `start` request (`scenario: "non_fatal_error_on_start"`, `error_code: 503, failure_count: 1`). +* **Protocol Flow**: + 1. First `start` POST request receives `503`. + 2. `start_retry_policy` transparently retries the initiation request. + 3. Second attempt succeeds (`200 active`), returning session URL. + 4. 100-byte upload completes normally. +* **Assertions**: + * Returned JSON body reports `"size" == 100`. + * Exactly 1 `:initiating` notification in `phases` (`phases.count(:initiating) == 1`). + * `phases.last == :completed`. + +#### Case 2. Missing status header / 400 on start (`test_missing_header_retriable_on_start_400`) +* **Scenario**: Injects a single `400 Bad Request` without an `X-Goog-Upload-Status` header on `start` (`scenario: "non_fatal_error_on_start"`, `error_code: 400, failure_count: 1`). +* **Protocol Flow**: + 1. First `start` POST request receives `400` with no upload status header. + 2. `START_PREDICATE` identifies the missing status header on start as retriable (for non-fatal status codes) and retries the initiation request. + 3. Second attempt succeeds (`200 active`). + 4. 100-byte payload is transmitted and finalized. +* **Assertions**: + * Returned JSON body reports `"size" == 100`. + * `phases.count(:initiating) == 1`. + * `phases.last == :completed`. + +#### Case 3. Retry exhaustion and session deadline on start (`test_retry_exhaustion_on_start_times_out`) +* **Scenario**: Injects repeated `503 Service Unavailable` responses (`failure_count: 10_000`) with a 3-second session `timeout` (`scenario: "non_fatal_error_on_start"`). +* **Protocol Flow**: + 1. `start` command encounters continuous 503 errors. + 2. Client retries with exponential backoff until the 3-second global session deadline expires. + 3. Client terminates failure before entering transmission. +* **Assertions**: + * Raises a `Gapic::Common::Error` (`BadResponseError` or `DeadlineExceededError`). + * Total elapsed time is close to 3 seconds (`2.5s <= elapsed <= 4.5s`). + * `phases` contains no `:uploading` entries (`refute_includes phases, :uploading`). + +#### Case 4. Fatal errors on start (`test_fatal_error_on_start_raises_bad_response_immediately`) +* **Scenario**: Injects fatal HTTP status codes (`403 Forbidden` and `404 Not Found`) on `start` (`scenario: "fatal_error_on_start"`). +* **Protocol Flow**: + 1. Initial `start` request receives a fatal status code (`403` or `404`). + 2. `START_PREDICATE` refutes retry on fatal status codes (`Rules::FATAL_STATUS_CODES`). + 3. `Driver#execute_send_start` returns the fatal response directly to `Core`. + 4. `Rules` classifies the response as `:response_fatal_bad_response` and emits `:fail_with_bad_response`. + 5. Session terminates immediately with `BadResponseError`. +* **Assertions**: + * Raises `Gapic::Rest::ResumableUpload::BadResponseError` with error message containing the HTTP status code. + * Elapsed time is < 0.5s, confirming zero retries were attempted. + * Refutes any `:uploading` phases. + +#### Case 5. Sequential session isolation (`test_sequential_runs_session_isolation`) +* **Scenario**: Executes two sequential upload runs under Case 1 (`non_fatal_error_on_start`, 503, failure_count: 1) with distinct client UUIDs. +* **Protocol Flow**: + 1. First run executes and succeeds. + 2. Second run executes with a newly generated `client_uuid` and independent progress tracking. +* **Assertions**: + * Both runs successfully complete uploading 100 bytes. + * Demonstrates Showcase session state isolation across sequential client sessions. + + diff --git a/gapic-common/integration/resumable_upload/error_on_start_test.rb b/gapic-common/integration/resumable_upload/error_on_start_test.rb new file mode 100644 index 0000000..160cb6b --- /dev/null +++ b/gapic-common/integration/resumable_upload/error_on_start_test.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "integration_helper" +require "json" +require "securerandom" +require "stringio" + +## +# Suite A: Integration tests for non-fatal and fatal errors on session initiation (`start`). +# +class ErrorOnStartTest < ShowcaseIntegrationTest + PAYLOAD_SIZE = 100 + + def build_start_error_config scenario:, scenario_config: {}, **overrides + build_config( + scenario: scenario, + scenario_config: scenario_config, + start_retry_policy: nil, + control_plane_retry_policy: nil, + data_plane_retry_policy: nil, + stream: StringIO.new(payload(PAYLOAD_SIZE)), + upload_size: PAYLOAD_SIZE, + **overrides + ) + end + + # A1. Verifies non-fatal transient error (503) on start is retried and upload completes. + def test_non_fatal_error_on_start_503 + config = build_start_error_config( + scenario: "non_fatal_error_on_start", + scenario_config: { error_code: 503, failure_count: 1 } + ) + + driver = Gapic::Rest::ResumableUpload::Driver.new( + client_stub: showcase_client_stub, + config: config + ) + + result = driver.run + parsed = JSON.parse result + + assert_equal PAYLOAD_SIZE, parsed["size"] + assert_equal 1, phases.count(:initiating) + assert_equal :completed, phases.last + end + + # A2. Verifies missing status header / 400 on start is retried and upload completes. + def test_missing_header_retriable_on_start_400 + config = build_start_error_config( + scenario: "non_fatal_error_on_start", + scenario_config: { error_code: 400, failure_count: 1 } + ) + + driver = Gapic::Rest::ResumableUpload::Driver.new( + client_stub: showcase_client_stub, + config: config + ) + + result = driver.run + parsed = JSON.parse result + + assert_equal PAYLOAD_SIZE, parsed["size"] + assert_equal 1, phases.count(:initiating) + assert_equal :completed, phases.last + end + + # A3. Verifies retry exhaustion on start with high failure count times out within ~3s without uploading. + def test_retry_exhaustion_on_start_times_out + config = build_start_error_config( + scenario: "non_fatal_error_on_start", + scenario_config: { error_code: 503, failure_count: 10_000 }, + timeout: 3 + ) + + driver = Gapic::Rest::ResumableUpload::Driver.new( + client_stub: showcase_client_stub, + config: config + ) + + t0 = Process.clock_gettime Process::CLOCK_MONOTONIC + err = assert_raises Gapic::Common::Error do + driver.run + end + t1 = Process.clock_gettime Process::CLOCK_MONOTONIC + + elapsed = t1 - t0 + assert_operator elapsed, :>=, 2.5 + assert_operator elapsed, :<=, 4.5 + is_expected_error = err.is_a?(Gapic::Rest::ResumableUpload::BadResponseError) || + err.is_a?(Gapic::Rest::ResumableUpload::DeadlineExceededError) + assert is_expected_error, "Expected BadResponseError or DeadlineExceededError, got #{err.class}" + refute_includes phases, :uploading + end + + # A4. Verifies fatal errors on start (403 and 404) immediately raise BadResponseError in < 0.5s without retrying. + def test_fatal_error_on_start_raises_bad_response_immediately + [403, 404].each do |code| + config = build_start_error_config( + scenario: "fatal_error_on_start", + scenario_config: { error_code: code } + ) + + driver = Gapic::Rest::ResumableUpload::Driver.new( + client_stub: showcase_client_stub, + config: config + ) + + t0 = Process.clock_gettime Process::CLOCK_MONOTONIC + err = assert_raises Gapic::Rest::ResumableUpload::BadResponseError do + driver.run + end + t1 = Process.clock_gettime Process::CLOCK_MONOTONIC + + elapsed = t1 - t0 + assert_operator elapsed, :<, 0.5, "Expected failure in < 0.5s for HTTP #{code}, took #{elapsed}s" + assert_match(/#{code}/, err.message) + refute_includes phases, :uploading + end + end + + # A5. Verifies sequential executions with fresh client UUIDs remain isolated. + def test_sequential_runs_session_isolation + 2.times do + config = build_start_error_config( + scenario: "non_fatal_error_on_start", + scenario_config: { error_code: 503, failure_count: 1 } + ) + + driver = Gapic::Rest::ResumableUpload::Driver.new( + client_stub: showcase_client_stub, + config: config + ) + + result = driver.run + parsed = JSON.parse result + + assert_equal PAYLOAD_SIZE, parsed["size"] + assert_equal 1, phases.count(:initiating) + assert_equal :completed, phases.last + end + end +end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index 721d7d2..791c2ab 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -306,6 +306,7 @@ def execute_send_start instruction status_hdr = Rules.header_value event.headers, "x-goog-upload-status" return event unless status_hdr.nil? || status_hdr.empty? + return event if Rules::FATAL_STATUS_CODES.include? event.status err = BadResponseError.new "Missing X-Goog-Upload-Status header in start response", event.status, diff --git a/gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb b/gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb index 2de45ba..82d9a8c 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb @@ -15,6 +15,7 @@ # limitations under the License. require "gapic/common/retry_policy" +require "gapic/rest/resumable_upload/rules" module Gapic module Rest @@ -24,6 +25,9 @@ module ResumableUpload # module RetryPolicies START_PREDICATE = lambda do |error_or_response| + status = extract_status_code error_or_response + return false if Rules::FATAL_STATUS_CODES.include? status + headers = extract_headers error_or_response if headers status_hdr = headers["x-goog-upload-status"] || headers["X-Goog-Upload-Status"] @@ -106,6 +110,23 @@ def self.extract_headers error_or_response error_or_response.response[:headers] end end + + ## + # Extracts HTTP status code from Faraday response, error, or event object. + # + # @param error_or_response [Object] + # @return [Integer, nil] + def self.extract_status_code error_or_response + if error_or_response.respond_to? :status + error_or_response.status + elsif error_or_response.respond_to?(:response) && error_or_response.response.is_a?(Hash) + error_or_response.response[:status] + elsif error_or_response.respond_to? :status_code + error_or_response.status_code + elsif error_or_response.respond_to? :response_status + error_or_response.response_status + end + end end end end diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_retry_policy_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_retry_policy_test.rb index 40636e8..d6d9ed3 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_retry_policy_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_retry_policy_test.rb @@ -120,4 +120,20 @@ def test_driver_initialize_resolves_hash_overrides_from_config assert_in_delta 2.0, data_policy.multiplier assert_same RetryPolicies::DATA_PLANE_PREDICATE, data_policy.retry_predicate end + + def test_start_predicate_refutes_fatal_status_codes + [401, 403, 404, 405, 410, 413, 415].each do |code| + response_double = OpenStruct.new status: code, headers: {} + refute RetryPolicies::START_PREDICATE.call(response_double), + "Expected START_PREDICATE to return false for fatal status #{code}" + end + end + + def test_start_predicate_retries_missing_header_for_non_fatal_codes + [200, 400, 500, 503].each do |code| + response_double = OpenStruct.new status: code, headers: {} + assert RetryPolicies::START_PREDICATE.call(response_double), + "Expected START_PREDICATE to return true for non-fatal status #{code} with missing status header" + end + end end From 41c011e09d28722fce9ee956c5734e7e481ba3b6 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Thu, 10 Sep 2026 01:07:22 +0000 Subject: [PATCH 53/79] fix: test adjustment --- gapic-common/design/integration-test-plan.md | 14 +++++------ gapic-common/design/test-plan.md | 12 +++++---- .../resumable_upload/error_on_start_test.rb | 21 +++++++++------- .../lib/gapic/rest/resumable_upload/driver.rb | 11 +++++--- .../rest/resumable_upload/retry_policies.rb | 13 +++++----- .../driver_retry_policy_test.rb | 4 +++ .../resumable_upload/driver_retry_test.rb | 25 ++++++++++++++++++- 7 files changed, 69 insertions(+), 31 deletions(-) diff --git a/gapic-common/design/integration-test-plan.md b/gapic-common/design/integration-test-plan.md index f47f121..d229788 100644 --- a/gapic-common/design/integration-test-plan.md +++ b/gapic-common/design/integration-test-plan.md @@ -192,10 +192,10 @@ Tests Category 1 transient transport retries and Category 2 protocol recovery wo ### 2.4 Error on Start Suite (`integration/resumable_upload/error_on_start_test.rb`) -Tests non-fatal transient retries, missing status headers, retry exhaustion, fatal errors, and session isolation during the session initiation (`start`) phase. Uses a 100-byte payload and SDK default retry policies. +Tests non-fatal transient retries, missing status headers, retry exhaustion, fatal errors, and session isolation during the session initiation (`start`) phase. Uses a 100-byte payload, configuring `FAST_RETRY` for non-exhaustion retry cases to minimize test execution latency while retaining default policies for exhaustion and fatal checks. #### Case 1. Non-fatal transient error on start (`test_non_fatal_error_on_start_503`) -* **Scenario**: Injects a single `503 Service Unavailable` on the initial `start` request (`scenario: "non_fatal_error_on_start"`, `error_code: 503, failure_count: 1`). +* **Scenario**: Injects a single `503 Service Unavailable` on the initial `start` request (`scenario: "non_fatal_error_on_start"`, `error_code: 503, failure_count: 1`, `start_retry_policy: FAST_RETRY`). * **Protocol Flow**: 1. First `start` POST request receives `503`. 2. `start_retry_policy` transparently retries the initiation request. @@ -207,7 +207,7 @@ Tests non-fatal transient retries, missing status headers, retry exhaustion, fat * `phases.last == :completed`. #### Case 2. Missing status header / 400 on start (`test_missing_header_retriable_on_start_400`) -* **Scenario**: Injects a single `400 Bad Request` without an `X-Goog-Upload-Status` header on `start` (`scenario: "non_fatal_error_on_start"`, `error_code: 400, failure_count: 1`). +* **Scenario**: Injects a single `400 Bad Request` without an `X-Goog-Upload-Status` header on `start` (`scenario: "non_fatal_error_on_start"`, `error_code: 400, failure_count: 1`, `start_retry_policy: FAST_RETRY`). * **Protocol Flow**: 1. First `start` POST request receives `400` with no upload status header. 2. `START_PREDICATE` identifies the missing status header on start as retriable (for non-fatal status codes) and retries the initiation request. @@ -219,14 +219,14 @@ Tests non-fatal transient retries, missing status headers, retry exhaustion, fat * `phases.last == :completed`. #### Case 3. Retry exhaustion and session deadline on start (`test_retry_exhaustion_on_start_times_out`) -* **Scenario**: Injects repeated `503 Service Unavailable` responses (`failure_count: 10_000`) with a 3-second session `timeout` (`scenario: "non_fatal_error_on_start"`). +* **Scenario**: Injects repeated `503 Service Unavailable` responses (`failure_count: 10_000`) with default start retry policy and a 3-second session `timeout` (`scenario: "non_fatal_error_on_start"`). * **Protocol Flow**: 1. `start` command encounters continuous 503 errors. - 2. Client retries with exponential backoff until the 3-second global session deadline expires. + 2. Client retries with default exponential backoff until the 3-second global session deadline expires. 3. Client terminates failure before entering transmission. * **Assertions**: * Raises a `Gapic::Common::Error` (`BadResponseError` or `DeadlineExceededError`). - * Total elapsed time is close to 3 seconds (`2.5s <= elapsed <= 4.5s`). + * Total elapsed time is close to 3 seconds (`2.5s <= elapsed <= 6.0s`, accounting for default backoff delays and network latency). * `phases` contains no `:uploading` entries (`refute_includes phases, :uploading`). #### Case 4. Fatal errors on start (`test_fatal_error_on_start_raises_bad_response_immediately`) @@ -243,7 +243,7 @@ Tests non-fatal transient retries, missing status headers, retry exhaustion, fat * Refutes any `:uploading` phases. #### Case 5. Sequential session isolation (`test_sequential_runs_session_isolation`) -* **Scenario**: Executes two sequential upload runs under Case 1 (`non_fatal_error_on_start`, 503, failure_count: 1) with distinct client UUIDs. +* **Scenario**: Executes two sequential upload runs under Case 1 (`non_fatal_error_on_start`, 503, failure_count: 1, `start_retry_policy: FAST_RETRY`) with distinct client UUIDs. * **Protocol Flow**: 1. First run executes and succeeds. 2. Second run executes with a newly generated `client_uuid` and independent progress tracking. diff --git a/gapic-common/design/test-plan.md b/gapic-common/design/test-plan.md index 6831516..74da019 100644 --- a/gapic-common/design/test-plan.md +++ b/gapic-common/design/test-plan.md @@ -189,15 +189,16 @@ flowchart TD --- -### 3.5 Retry Policies & Header Extraction (`retry_policies_test.rb`) +### 3.5 Retry Policies & Extraction (`retry_policies_test.rb`, `driver_retry_policy_test.rb`) -#### A. Header Extraction (`RetryPolicies.extract_headers`) -* Extracts from `#headers`, `#response_headers`, and Faraday `#response[:headers]`. Returns `nil` when no headers present. +#### A. Header & Status Extraction (`RetryPolicies.extract_headers`, `RetryPolicies.extract_status_code`) +* `extract_headers`: Extracts from `#headers`, `#response_headers`, and Faraday `#response[:headers]`. Returns `nil` when no headers present. +* `extract_status_code`: Extracts integer status from `#status_code`, Faraday `#response[:status]`, `#response_status`, and `#status`. #### B. $3 \times 3$ Policy Matrix (`policy.retry_error?`) | Policy | Headers Present, NO Upload-Status | Headers Present, WITH Upload-Status | NO Headers | | :--- | :--- | :--- | :--- | -| **`default_start`** | **Retried unconditionally** (`true`) across 503, 400, 200, empty string header, and no error code. | **Falls back to codes**: retries 503; refutes 400 and no code. | **Falls back to codes**: retries 503; refutes 400 and no code. | +| **`default_start`** | **Retried for non-fatal** (`true` across 503, 400, 200, empty string header); **Refuted** (`false`) for fatal status codes (401, 403, 404, 405, 410, 413, 415) across response doubles and `Gapic::Rest::Error`. | **Falls back to codes**: retries 503; refutes 400 and no code. | **Falls back to codes**: retries 503; refutes 400 and no code. | | **`default_control_plane`** | **Falls back to codes**: retries 503; refutes 400 and no code. | **Falls back to codes**: retries 503; refutes 400 and no code. | **Falls back to codes**: retries 503; refutes 400 and no code. | | **`default_data_plane`** | **Unretriable** (`false`) across 503, 400, empty string header, and no code (triggers Cat 2 recovery). | **Falls back to codes**: retries 503; refutes 400 and no code. | **Falls back to codes**: retries 503; refutes 400 and no code. | @@ -222,7 +223,8 @@ flowchart TD ### 3.8 Driver Initiation & Query Retries (`driver_retry_test.rb`) * **Session initiation retry loop**: Missing status header on HTTP 200 during `start` triggers `start_retry_policy` and succeeds upon header arrival. -* **Initiation retry exhaustion**: Continuous missing status headers on `start` exhaust retries and dispatch `Event::RequestFailed(kind: :retries_exhausted)`. +* **Initiation retry exhaustion on 200**: Continuous missing status headers on HTTP 200 during `start` exhaust retries and dispatch `Event::RequestFailed(kind: :retries_exhausted)` with `"Missing X-Goog-Upload-Status header in start response"`. +* **Initiation retry exhaustion on non-200**: Continuous non-200 HTTP responses (e.g. 503) lacking status header exhaust retries and return `Event::HttpResponse` directly, allowing `Rules` to raise `BadResponseError` preserving the HTTP status code and message. * **Control plane non-retry**: Missing status header on `query` does not retry inside `execute_send_query`, returning `Event::HttpResponse` immediately to drive protocol recovery. --- diff --git a/gapic-common/integration/resumable_upload/error_on_start_test.rb b/gapic-common/integration/resumable_upload/error_on_start_test.rb index 160cb6b..f43d23d 100644 --- a/gapic-common/integration/resumable_upload/error_on_start_test.rb +++ b/gapic-common/integration/resumable_upload/error_on_start_test.rb @@ -25,11 +25,11 @@ class ErrorOnStartTest < ShowcaseIntegrationTest PAYLOAD_SIZE = 100 - def build_start_error_config scenario:, scenario_config: {}, **overrides + def build_start_error_config scenario:, scenario_config: {}, start_retry_policy: nil, **overrides build_config( scenario: scenario, scenario_config: scenario_config, - start_retry_policy: nil, + start_retry_policy: start_retry_policy, control_plane_retry_policy: nil, data_plane_retry_policy: nil, stream: StringIO.new(payload(PAYLOAD_SIZE)), @@ -41,8 +41,9 @@ def build_start_error_config scenario:, scenario_config: {}, **overrides # A1. Verifies non-fatal transient error (503) on start is retried and upload completes. def test_non_fatal_error_on_start_503 config = build_start_error_config( - scenario: "non_fatal_error_on_start", - scenario_config: { error_code: 503, failure_count: 1 } + scenario: "non_fatal_error_on_start", + scenario_config: { error_code: 503, failure_count: 1 }, + start_retry_policy: FAST_RETRY ) driver = Gapic::Rest::ResumableUpload::Driver.new( @@ -61,8 +62,9 @@ def test_non_fatal_error_on_start_503 # A2. Verifies missing status header / 400 on start is retried and upload completes. def test_missing_header_retriable_on_start_400 config = build_start_error_config( - scenario: "non_fatal_error_on_start", - scenario_config: { error_code: 400, failure_count: 1 } + scenario: "non_fatal_error_on_start", + scenario_config: { error_code: 400, failure_count: 1 }, + start_retry_policy: FAST_RETRY ) driver = Gapic::Rest::ResumableUpload::Driver.new( @@ -99,7 +101,7 @@ def test_retry_exhaustion_on_start_times_out elapsed = t1 - t0 assert_operator elapsed, :>=, 2.5 - assert_operator elapsed, :<=, 4.5 + assert_operator elapsed, :<=, 6.0 is_expected_error = err.is_a?(Gapic::Rest::ResumableUpload::BadResponseError) || err.is_a?(Gapic::Rest::ResumableUpload::DeadlineExceededError) assert is_expected_error, "Expected BadResponseError or DeadlineExceededError, got #{err.class}" @@ -136,8 +138,9 @@ def test_fatal_error_on_start_raises_bad_response_immediately def test_sequential_runs_session_isolation 2.times do config = build_start_error_config( - scenario: "non_fatal_error_on_start", - scenario_config: { error_code: 503, failure_count: 1 } + scenario: "non_fatal_error_on_start", + scenario_config: { error_code: 503, failure_count: 1 }, + start_retry_policy: FAST_RETRY ) driver = Gapic::Rest::ResumableUpload::Driver.new( diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index 791c2ab..854060f 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -313,9 +313,14 @@ def execute_send_start instruction headers: event.headers can_retry = policy.send(:retry_with_deadline?) && policy.call(event) unless can_retry - failed_event = Event::RequestFailed.new kind: :retries_exhausted, message: err.message, source_error: err - @upload_log.wire_failure failed_event - return failed_event + if event.status == 200 + failed_event = Event::RequestFailed.new( + kind: :retries_exhausted, message: err.message, source_error: err + ) + @upload_log.wire_failure failed_event + return failed_event + end + return event end attempt += 1 end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb b/gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb index 82d9a8c..118c3af 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb @@ -117,14 +117,15 @@ def self.extract_headers error_or_response # @param error_or_response [Object] # @return [Integer, nil] def self.extract_status_code error_or_response - if error_or_response.respond_to? :status - error_or_response.status - elsif error_or_response.respond_to?(:response) && error_or_response.response.is_a?(Hash) - error_or_response.response[:status] - elsif error_or_response.respond_to? :status_code + if error_or_response.respond_to?(:status_code) && error_or_response.status_code.is_a?(Integer) error_or_response.status_code - elsif error_or_response.respond_to? :response_status + elsif error_or_response.respond_to?(:response) && error_or_response.response.is_a?(Hash) && + error_or_response.response[:status].is_a?(Integer) + error_or_response.response[:status] + elsif error_or_response.respond_to?(:response_status) && error_or_response.response_status.is_a?(Integer) error_or_response.response_status + elsif error_or_response.respond_to?(:status) && error_or_response.status.is_a?(Integer) + error_or_response.status end end end diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_retry_policy_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_retry_policy_test.rb index d6d9ed3..50a75a3 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_retry_policy_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_retry_policy_test.rb @@ -126,6 +126,10 @@ def test_start_predicate_refutes_fatal_status_codes response_double = OpenStruct.new status: code, headers: {} refute RetryPolicies::START_PREDICATE.call(response_double), "Expected START_PREDICATE to return false for fatal status #{code}" + + gapic_err = Gapic::Rest::Error.new "Error", code, status: "FATAL_ERROR", headers: {} + refute RetryPolicies::START_PREDICATE.call(gapic_err), + "Expected START_PREDICATE to return false for Gapic::Rest::Error with fatal status #{code}" end end diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb index ec976a5..6e61616 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb @@ -76,7 +76,7 @@ def test_start_retries_when_response_lacks_status_header_even_on_200 assert_chunk_request stub.requests[2], offset: "0", length: "4", body: "0123", finalize: true end - def test_start_exhausts_retries_when_responses_continually_lack_status_header + def test_start_exhausts_retries_when_200_responses_continually_lack_status_header responses = Array.new(10) { FakeResponse.new status: 200, headers: {}, body: "" } stub = FakeClientStub.new responses config = CompleteUploadConfig.new( @@ -93,6 +93,29 @@ def test_start_exhausts_retries_when_responses_continually_lack_status_header end assert_match(/Missing X-Goog-Upload-Status/, err.message) + assert_equal 200, err.status_code + assert stub.requests.size > 1 + end + + def test_start_exhausts_retries_when_non_200_responses_continually_lack_status_header + responses = Array.new(10) { FakeResponse.new status: 503, headers: {}, body: "Service Unavailable" } + stub = FakeClientStub.new responses + config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("0123"), + upload_size: 4, + chunk_size: 10, + start_retry_policy: { initial_delay: 0.001, max_delay: 0.002, timeout: 0.01 } + ) + + driver = Driver.new client_stub: stub, config: config + err = assert_raises BadResponseError do + driver.run + end + + assert_equal 503, err.status_code + assert_includes err.message, "503" + refute_match(/Missing X-Goog-Upload-Status/, err.message) assert stub.requests.size > 1 end From 6296b5cc5c6bf65d49e3a960b0f5a3473eabb462 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Thu, 10 Sep 2026 03:29:45 +0000 Subject: [PATCH 54/79] chore: better documentation --- gapic-common/design/integration-test-plan.md | 4 +- .../lib/gapic/rest/resumable_upload/core.rb | 12 +- .../gapic/rest/resumable_upload/data_types.rb | 73 +++++- .../lib/gapic/rest/resumable_upload/driver.rb | 175 +++++++++++++- .../rest/resumable_upload/driver/abridge.rb | 42 ++++ .../resumable_upload/driver/upload_log.rb | 102 ++++++++ .../lib/gapic/rest/resumable_upload/errors.rb | 119 +++++++-- .../lib/gapic/rest/resumable_upload/events.rb | 45 ++++ .../rest/resumable_upload/instructions.rb | 116 +++++++++ .../rest/resumable_upload/retry_policies.rb | 32 ++- .../lib/gapic/rest/resumable_upload/rules.rb | 226 +++++++++++++++++- 11 files changed, 907 insertions(+), 39 deletions(-) diff --git a/gapic-common/design/integration-test-plan.md b/gapic-common/design/integration-test-plan.md index d229788..d483f87 100644 --- a/gapic-common/design/integration-test-plan.md +++ b/gapic-common/design/integration-test-plan.md @@ -185,9 +185,9 @@ Tests Category 1 transient transport retries and Category 2 protocol recovery wo 1. Server responds to every upload chunk with HTTP `500` and no `X-Goog-Upload-Status` header. 2. `data_plane_retry_policy` treats missing `X-Goog-Upload-Status` as unretriable (`predicate` returns `false`), yielding `Event::HttpResponse(500)` to `Core`. 3. `Core` classifies the response as Category 2 (`:response_cat2`), enters `:recovering`, queries the server (which returns `200 active` at offset `0`), and retries the upload. - 4. This recovery loop repeats until the 1-second global session deadline expires and `Driver#run` raises `Gapic::Common::DeadlineExceededError`. + 4. This recovery loop repeats until the 1-second global session deadline expires and `Driver#run` raises `Gapic::Rest::ResumableUpload::DeadlineExceededError`. * **Assertions**: - * Raises `Gapic::Common::DeadlineExceededError`. + * Raises `Gapic::Rest::ResumableUpload::DeadlineExceededError`. * `phases.count(:recovering) >= 2`. ### 2.4 Error on Start Suite (`integration/resumable_upload/error_on_start_test.rb`) diff --git a/gapic-common/lib/gapic/rest/resumable_upload/core.rb b/gapic-common/lib/gapic/rest/resumable_upload/core.rb index 1f4b001..aeb4919 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/core.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/core.rb @@ -21,17 +21,25 @@ module Gapic module Rest module ResumableUpload ## + # @private # State machine container holding the immutable State snapshot. # Contains zero protocol branching logic and zero side-effects. # class Core + # @private # @return [State] Current immutable state snapshot attr_reader :state + # @private # @return [Decision, nil] Decision emitted during the last dispatch attr_reader :last_decision - # @param config [CompleteUploadConfig] + ## + # @private + # Initializes a Core state machine container. + # + # @param config [CompleteUploadConfig] Upload session configuration + # def initialize config @config = config @last_decision = nil @@ -47,10 +55,12 @@ def initialize config end ## + # @private # Dispatches event to Rules and updates internal state snapshot. # # @param event [Object] Input event # @return [Array] Driver instructions + # def dispatch event decision = Rules.decide @state, event, @config @state = decision.next_state diff --git a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb index 362e421..b7140fc 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb @@ -68,6 +68,22 @@ module ResumableUpload :data_plane_retry_policy, :on_progress ) do + ## + # Initializes a new upload configuration. + # + # @param initial_url [String] Initial endpoint URI for session initiation + # @param stream [IO] Binary input stream to upload + # @param initial_body [String, nil] Request payload for session initiation + # @param initial_headers [Hash] Additional headers for initiation + # @param upload_size [Integer, nil] Total upload bytes if known upfront + # @param chunk_size [Integer, nil] Explicit chunk size in bytes + # @param content_type [String, nil] MIME type of uploaded media + # @param timeout [Numeric, nil] Total upload timeout in seconds (zero/negative values treated as nil) + # @param start_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for session initiation + # @param control_plane_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for control commands + # @param data_plane_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for data commands + # @param on_progress [Proc, nil] Callback invoked as `->(progress)` with a {Progress} instance + # def initialize initial_url:, stream:, initial_body: nil, @@ -101,7 +117,7 @@ def initialize initial_url:, # Immutable progress snapshot passed to the `on_progress` callback. # # @!attribute [r] phase - # @return [Symbol] Current upload phase, one of {PHASES} + # @return [Symbol] Current upload phase, one of {Progress::PHASES} # @!attribute [r] bytes_uploaded # @return [Integer] Cumulative bytes acknowledged by the server. Note that this is the # server-confirmed offset and is not guaranteed to be monotonic — a server rewind during @@ -114,9 +130,14 @@ def initialize initial_url:, :bytes_uploaded, :total_bytes ) do - # Important to define it via `self.`, since this block is not a class body - self::PHASES = [:initiating, :uploading, :recovering, :finalizing, :cancelling, :completed].freeze - + ## + # Initializes a new progress snapshot. + # + # @param phase [Symbol] Current upload phase, one of {Progress::PHASES} + # @param bytes_uploaded [Integer] Cumulative bytes acknowledged by the server + # @param total_bytes [Integer, nil] Total upload size in bytes if known, or nil + # @raise [ArgumentError] If the phase is not one of {Progress::PHASES} + # def initialize phase:, bytes_uploaded:, total_bytes: nil # Must use `self.class::` to access constants from the class scope unless self.class::PHASES.include? phase @@ -132,8 +153,29 @@ def initialize phase:, bytes_uploaded:, total_bytes: nil end ## + # Allowed lifecycle phases for an upload session. + # @return [Array] + Progress::PHASES = [:initiating, :uploading, :recovering, :finalizing, :cancelling, :completed].freeze + + ## + # @private # Immutable state snapshot representing the current protocol progression. # + # @!attribute [r] status + # @return [Symbol] Protocol lifecycle status symbol + # @!attribute [r] upload_url + # @return [String, nil] Session upload URL returned by Scotty backend + # @!attribute [r] offset + # @return [Integer] Contiguous bytes acknowledged by server + # @!attribute [r] chunk_size + # @return [Integer] Resolved effective chunk size in bytes + # @!attribute [r] chunk_granularity + # @return [Integer, nil] Alignment modulus returned by server + # @!attribute [r] in_flight_length + # @return [Integer] Byte length of in-flight chunk currently being transmitted + # @!attribute [r] last_error + # @return [StandardError, nil] Terminal exception if in an error or rejected status + # State = Data.define( :status, :upload_url, @@ -143,6 +185,18 @@ def initialize phase:, bytes_uploaded:, total_bytes: nil :in_flight_length, :last_error ) do + ## + # @private + # Initializes a protocol state snapshot. + # + # @param status [Symbol] Protocol lifecycle status symbol + # @param upload_url [String, nil] Session upload URL + # @param offset [Integer] Contiguous bytes acknowledged by server + # @param chunk_size [Integer] Resolved effective chunk size in bytes + # @param chunk_granularity [Integer, nil] Alignment modulus returned by server + # @param in_flight_length [Integer] Byte length of in-flight chunk + # @param last_error [StandardError, nil] Terminal exception + # def initialize status: :initializing, upload_url: nil, offset: 0, @@ -163,6 +217,7 @@ def initialize status: :initializing, end ## + # @private # Immutable decision snapshot emitted by Rules.decide. # # @!attribute [r] from_status @@ -183,6 +238,16 @@ def initialize status: :initializing, :next_state, :instructions ) do + ## + # @private + # Initializes a decision snapshot. + # + # @param from_status [Symbol] The protocol status before the transition + # @param shape [Symbol] The canonical event shape + # @param recipe [Symbol] Selected transition recipe method name + # @param next_state [State] Resulting protocol state snapshot + # @param instructions [Array] Emitted instructions for the Driver + # def initialize from_status:, shape:, recipe:, next_state:, instructions: [] super( from_status: from_status, diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index 854060f..53bdee3 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -37,15 +37,23 @@ module ResumableUpload class Driver include Gapic::LoggingConcerns - # Minimum assumed upload throughput in bytes per second (1 MB/s) + ## + # @private + # Minimum assumed upload throughput in bytes per second (1 MB/s). + # @return [Integer] MIN_ASSUMED_THROUGHPUT = 1_048_576 - # Default base timeout in seconds (1 hour) + ## + # @private + # Default base timeout in seconds (1 hour). + # @return [Integer] BASE_TIMEOUT = 3_600 + # @private # @return [Core] attr_reader :core + # @private # @return [String, nil] Current upload session ID attr_reader :upload_id @@ -131,6 +139,13 @@ def run private + ## + # @private + # Dispatches an event to Core, logging decisions and transitions. + # + # @param event [Object] Input event + # @return [Array] Emitted instructions + # def dispatch_event event instructions = begin @core.dispatch event @@ -143,11 +158,25 @@ def dispatch_event event instructions end + ## + # @private + # Checks whether an instruction execution result represents a pending event. + # + # @param obj [Object] Execution result + # @return [Boolean] + # def pending_event_type? obj obj.is_a?(Event::ChunkRead) || obj.is_a?(Event::HttpResponse) || obj.is_a?(Event::RequestFailed) || obj.is_a?(Event::GlobalDeadlineExceeded) end + ## + # @private + # Executes an instruction emitted by the state machine. + # + # @param instruction [Object] Instruction to execute + # @return [Object, nil] Resulting event or terminal response + # def dispatch_instruction instruction case instruction when Instruction::NotifyProgress then execute_notify_progress instruction @@ -164,6 +193,14 @@ def dispatch_instruction instruction end end + ## + # @private + # Resolves a configured retry policy or applies defaults. + # + # @param value [Gapic::Common::RetryPolicy, Hash, nil] Configured policy or overrides + # @param defaults [Hash] Default policy configuration + # @return [Gapic::Common::RetryPolicy] + # def resolve_retry_policy value, defaults case value when Gapic::Common::RetryPolicy @@ -177,6 +214,12 @@ def resolve_retry_policy value, defaults end end + ## + # @private + # Resolves the total upload deadline timeout in seconds. + # + # @return [Numeric] Timeout in seconds + # def resolve_timeout return @config.timeout if @config.timeout&.positive? @@ -187,6 +230,13 @@ def resolve_timeout end end + ## + # @private + # Computes the per-request timeout bounded by the global monotonic deadline. + # + # @param retry_policy [Gapic::Common::RetryPolicy, nil] Target command retry policy + # @return [Numeric] Effective per-request timeout + # def request_timeout retry_policy remaining = if @deadline [@deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max @@ -198,22 +248,46 @@ def request_timeout retry_policy remaining end + ## + # @private + # Checks whether the monotonic clock has exceeded the session deadline. + # + # @return [Boolean] + # def deadline_exceeded? return false unless @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC) > @deadline end + ## + # @private + # Determines whether the instruction list contains a terminal instruction. + # + # @param instructions [Array] Instruction list + # @return [Boolean] + # def terminal_instructions? instructions instructions.any? do |i| i.is_a?(Instruction::TerminateSuccess) || i.is_a?(Instruction::TerminateFailure) end end + ## + # @private + # Invokes caller progress callback with snapshot. + # + # @param instruction [Instruction::NotifyProgress] Progress instruction + # def execute_notify_progress instruction @config.on_progress&.call instruction.progress end + ## + # @private + # Realigns in-memory buffer and underlying stream to match server offset. + # + # @param instruction [Instruction::RealignBuffer] Realign instruction + # def execute_realign_buffer instruction server_offset = instruction.server_offset buffer_start = @buffer_start_offset @@ -241,12 +315,25 @@ def execute_realign_buffer instruction end end + ## + # @private + # Slices the in-memory buffer when server offset falls within current buffer range. + # + # @param server_offset [Integer] Target server offset + # def realign_within_buffer server_offset slice_index = server_offset - @buffer_start_offset @buffer = @buffer.byteslice(slice_index..-1) || "".b @buffer_start_offset = server_offset end + ## + # @private + # Rewinds seekable stream when server offset is before current buffer window. + # + # @param server_offset [Integer] Target server offset + # @raise [UnseekableStreamError] If stream does not respond to #seek + # def realign_rewind_stream server_offset unless @config.stream.respond_to? :seek raise UnseekableStreamError, @@ -258,6 +345,13 @@ def realign_rewind_stream server_offset @buffer_start_offset = server_offset end + ## + # @private + # Fast-forwards stream by seeking or discarding bytes. + # + # @param server_offset [Integer] Target server offset + # @param buffer_end [Integer] Current end offset of buffered data + # def realign_fast_forward_stream server_offset, buffer_end @buffer = "".b if @config.stream.respond_to? :seek @@ -274,6 +368,13 @@ def realign_fast_forward_stream server_offset, buffer_end @buffer_start_offset = server_offset end + ## + # @private + # Fills internal buffer from stream up to target byte size or EOF. + # + # @param instruction [Instruction::FillBuffer] FillBuffer instruction + # @return [Event::ChunkRead] Chunk read event + # def execute_fill_buffer instruction target = instruction.target_bytesize eof = false @@ -291,6 +392,13 @@ def execute_fill_buffer instruction Event::ChunkRead.new bytes_buffered: @buffer.bytesize, eof: eof end + ## + # @private + # Executes session initiation HTTP request. + # + # @param instruction [Instruction::SendStart] SendStart instruction + # @return [Event::HttpResponse, Event::RequestFailed, Event::GlobalDeadlineExceeded] + # def execute_send_start instruction policy = @start_retry_policy.dup.start! headers = start_headers instruction @@ -326,6 +434,13 @@ def execute_send_start instruction end end + ## + # @private + # Builds initiation HTTP headers from instruction and config. + # + # @param instruction [Instruction::SendStart] Start instruction + # @return [Hash] HTTP request headers + # def start_headers instruction headers = { "X-Goog-Upload-Protocol" => "resumable", "X-Goog-Upload-Command" => "start" } headers["X-Goog-Upload-Header-Content-Type"] = @config.content_type if @config.content_type @@ -333,6 +448,13 @@ def start_headers instruction headers.merge(instruction.headers || {}) end + ## + # @private + # Transmits a buffered chunk over HTTP. + # + # @param instruction [Instruction::SendChunk] SendChunk instruction + # @return [Event::HttpResponse, Event::RequestFailed, Event::GlobalDeadlineExceeded] + # def execute_send_chunk instruction headers = { "X-Goog-Upload-Command" => instruction.finalize ? "upload, finalize" : "upload", @@ -348,6 +470,13 @@ def execute_send_chunk instruction method_name: "ResumableUpload.upload" end + ## + # @private + # Sends a standalone finalize command over HTTP. + # + # @param instruction [Instruction::SendFinalize] SendFinalize instruction + # @return [Event::HttpResponse, Event::RequestFailed, Event::GlobalDeadlineExceeded] + # def execute_send_finalize instruction headers = { "X-Goog-Upload-Command" => "finalize", @@ -359,6 +488,13 @@ def execute_send_finalize instruction method_name: "ResumableUpload.finalize" end + ## + # @private + # Sends an offset query command over HTTP. + # + # @param instruction [Instruction::SendQuery] SendQuery instruction + # @return [Event::HttpResponse, Event::RequestFailed, Event::GlobalDeadlineExceeded] + # def execute_send_query instruction headers = { "X-Goog-Upload-Command" => "query", "Content-Length" => "0" } make_post_request instruction.url, headers: headers, body: "", @@ -366,6 +502,13 @@ def execute_send_query instruction method_name: "ResumableUpload.query" end + ## + # @private + # Sends a cancellation command over HTTP. + # + # @param instruction [Instruction::SendCancel] SendCancel instruction + # @return [Event::HttpResponse, Event::RequestFailed, Event::GlobalDeadlineExceeded] + # def execute_send_cancel instruction headers = { "X-Goog-Upload-Command" => "cancel", "Content-Length" => "0" } make_post_request instruction.url, headers: headers, body: "", @@ -373,6 +516,18 @@ def execute_send_cancel instruction method_name: "ResumableUpload.cancel" end + ## + # @private + # Dispatches an HTTP POST request through client stub. + # + # @param url [String] Target URL + # @param headers [Hash] Request headers + # @param body [String] Request body + # @param retry_policy [Gapic::Common::RetryPolicy] Command retry policy + # @param method_name [String, nil] RPC method name for logging + # @param start_attempt [Integer] Attempt counter + # @return [Event::HttpResponse, Event::RequestFailed, Event::GlobalDeadlineExceeded] + # def make_post_request url, headers:, body:, retry_policy:, method_name: nil, start_attempt: 1 return Event::GlobalDeadlineExceeded.new if deadline_exceeded? @@ -405,6 +560,13 @@ def make_post_request url, headers:, body:, retry_policy:, method_name: nil, sta event end + ## + # @private + # Converts client stub transport exceptions into canonical events. + # + # @param err [StandardError] Rescued transport error + # @return [Event::HttpResponse, Event::RequestFailed] + # def rescue_request_error err case err when Gapic::Rest::DeadlineExceededError @@ -423,6 +585,13 @@ def rescue_request_error err end end + ## + # @private + # Converts Faraday client exceptions into canonical events. + # + # @param err [Faraday::Error] Rescued Faraday error + # @return [Event::HttpResponse, Event::RequestFailed] + # def rescue_faraday_error err if err.response && err.response[:status] rest_err = Gapic::Rest::Error.wrap_faraday_error err diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver/abridge.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver/abridge.rb index 254e124..36cae29 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver/abridge.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver/abridge.rb @@ -27,6 +27,13 @@ class Driver module Abridge module_function + ## + # @private + # Formats binary payload into truncated hex representation. + # + # @param data [Object, nil] Binary or string payload + # @return [String, nil] Truncated hex representation or nil + # def bytes data return nil if data.nil? @@ -38,12 +45,26 @@ def bytes data end end + ## + # @private + # Truncates error body to a safe log length. + # + # @param data [Object, nil] Error body payload + # @return [String, nil] UTF-8 scrubbed and truncated string + # def error_body data return nil if data.nil? data.to_s.dup.force_encoding(Encoding::UTF_8).scrub[0, 512] end + ## + # @private + # Redacts query parameter values in URLs for safe logging. + # + # @param url [Object, nil] URL string or URI + # @return [String, nil] URL with query values elided + # def url url return nil if url.nil? @@ -61,6 +82,13 @@ def url url url.to_s end + ## + # @private + # Redacts non-protocol headers for safe logging. + # + # @param headers [Object] Headers hash + # @return [Hash] Redacted headers + # def headers headers return {} unless headers.is_a? Hash @@ -76,11 +104,25 @@ def headers headers end end + ## + # @private + # Converts a list of instructions into log-safe representation hashes. + # + # @param instructions [Array] List of instructions + # @return [Array] Log-safe instruction summaries + # def instructions instructions instructions.map { |i| instruction i } end # rubocop:disable Metrics/MethodLength + ## + # @private + # Converts an instruction into a log-safe representation hash. + # + # @param instruction [Object] Instruction object + # @return [Hash] Log-safe instruction summary + # def instruction instruction case instruction when Instruction::SendStart diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb index 8522de9..bc8707c 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb @@ -26,12 +26,20 @@ class Driver # Structured logging helper for a single Resumable Upload run. # class UploadLog + ## + # @private + # Recipes omitted from INFO lifecycle logging. + # @return [Array] SILENT_RECIPES = [ :ack_chunk, # per-chunk transition, doesn't belong at INFO :ignore_duplicate_cancel, # duplicate cancel signal, no state change :fail_with_unmatched_transition # raises before Decision exists, logged by #unmatched_transition ].freeze + ## + # @private + # Severity and message mapping for lifecycle transitions. + # @return [Hash] LIFECYCLE = { start_session: [:info, "Initiating resumable upload"], begin_transmission: [:info, "Upload session established"], @@ -51,13 +59,28 @@ class UploadLog fail_with_request_error: [:warn, "Resumable upload failed"] }.freeze + # @private + # @return [String] attr_reader :upload_id + ## + # @private + # Initializes a new UploadLog logger wrapper. + # + # @param stub_logger [Logger, Object] Underlying structured logger + # @param upload_id [String] Unique session identifier + # def initialize stub_logger, upload_id: @stub_logger = stub_logger @upload_id = upload_id end + ## + # @private + # Logs state machine transition decision at DEBUG level. + # + # @param decision [Decision] Decision snapshot + # def decision decision msg = "Rules: #{decision.from_status} + #{decision.shape} -> " \ "#{decision.recipe} -> #{decision.next_state.status}" @@ -74,6 +97,13 @@ def decision decision ) end + ## + # @private + # Logs high-level protocol lifecycle milestone if configured. + # + # @param decision [Decision] Decision snapshot + # @param config [CompleteUploadConfig] Upload configuration + # def lifecycle decision, config return if SILENT_RECIPES.include? decision.recipe @@ -84,6 +114,18 @@ def lifecycle decision, config entry severity, message, recipe: decision.recipe, **extra_fields end + ## + # @private + # Logs an outgoing HTTP request at DEBUG level. + # + # @param method [String] HTTP method + # @param url [String] Request URL + # @param headers [Hash] Request headers + # @param start_attempt [Integer] Attempt index for start command + # @param body_size [Integer, nil] Byte size of request payload + # @param body [Object, nil] Request payload + # @param body_is_error [Boolean] Whether body contains an error payload + # def wire_send method:, url:, headers:, start_attempt:, body_size: nil, body: nil, body_is_error: false command = Rules.header_value headers, "x-goog-upload-command" offset = Rules.header_value headers, "x-goog-upload-offset" @@ -101,6 +143,12 @@ def wire_send method:, url:, headers:, start_attempt:, body_size: nil, body: nil entry :debug, "Sending #{method} request", **fields end + ## + # @private + # Logs a received HTTP response at DEBUG level. + # + # @param event [Event::HttpResponse] Received HTTP event + # def wire_receive event upload_status = Rules.header_value event.headers, "x-goog-upload-status" size_recv = Rules.header_value event.headers, "x-goog-upload-size-received" @@ -119,6 +167,12 @@ def wire_receive event entry :debug, "Received HTTP #{event.status}", **fields end + ## + # @private + # Logs a network or transport failure at DEBUG level. + # + # @param event [Event::RequestFailed] Failure event + # def wire_failure event entry( :debug, @@ -128,6 +182,15 @@ def wire_failure event ) end + ## + # @private + # Logs buffer realignment action. + # + # @param action [String] Realignment action description + # @param server_offset [Integer] Target server offset + # @param current_offset [Integer] Current buffer start offset + # @param unseekable [Boolean] Whether rewind was attempted on an unseekable stream + # def buffer_realign action, server_offset:, current_offset:, unseekable: false if unseekable entry( @@ -148,6 +211,14 @@ def buffer_realign action, server_offset:, current_offset:, unseekable: false ) end + ## + # @private + # Logs an invalid or unmatched state machine transition at WARN level. + # + # @param state [State] Current state + # @param event [Object] Triggering event + # @param error [StandardError] Resulting error + # def unmatched_transition state, event, error entry( :warn, @@ -160,12 +231,28 @@ def unmatched_transition state, event, error private + ## + # @private + # Formats response body for wire log entry. + # + # @param event [Event::HttpResponse] Response event + # @param err [Gapic::Rest::Error, nil] Error instance + # @return [String, nil] Formatted body + # def wire_receive_body event, err return Abridge.bytes event.body if event.status < 400 err&.message ? Abridge.error_body(err.message) : Abridge.error_body(event.body) end + ## + # @private + # Extracts relevant state fields for lifecycle logging. + # + # @param decision [Decision] Decision snapshot + # @param config [CompleteUploadConfig] Upload configuration + # @return [Hash] Metadata fields for log entry + # def lifecycle_fields decision, config state = decision.next_state case decision.recipe @@ -192,6 +279,13 @@ def lifecycle_fields decision, config end end + ## + # @private + # Extracts error and response details for failure lifecycle logs. + # + # @param state [State] Current protocol state + # @return [Hash] Failure metadata fields + # def failure_fields state err = state.last_error fields = { error: err&.message || err.to_s } @@ -201,6 +295,14 @@ def failure_fields state fields end + ## + # @private + # Dispatches structured log entry to stub logger. + # + # @param severity [Symbol] Log severity level + # @param log_msg [String] Primary log message + # @param fields [Hash] Structured key-value fields + # def entry severity, log_msg, **fields @stub_logger.public_send severity do |builder| builder.set_system_name diff --git a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb index 3bc5547..fe8c378 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb @@ -22,7 +22,8 @@ module Rest module ResumableUpload ## # @private - # + # HTTP status code to reason phrase mapping. + # @return [Hash] HTTP_STATUS_PHRASES = { 400 => "Bad Request", 401 => "Unauthorized", @@ -51,12 +52,24 @@ module ResumableUpload # module ErrorBuilder class << self + ## + # @private + # Formats status representation. + # + # @param status [Object, nil] Status value + # @return [String, nil] def format_status status return nil if status.nil? || status.to_s.empty? status.to_s end + ## + # @private + # Strips REST error prefix from message string. + # + # @param raw_message [String, nil] Raw error message + # @return [String, nil] def clean_message raw_message return nil if raw_message.nil? || raw_message.empty? @@ -67,6 +80,13 @@ def clean_message raw_message msg.empty? ? nil : msg end + ## + # @private + # Builds error attributes tuple from an HTTP event or wrapped error. + # + # @param event [Object] HTTP response event or failure event + # @param prefix [String] Error message prefix + # @return [Array] Tuple of [message, status_code, status, details, headers] def build_attributes event, prefix: "Resumable upload failed" if event.respond_to?(:error) && event.error build_from_wrapped_error event, prefix: prefix @@ -77,6 +97,13 @@ def build_attributes event, prefix: "Resumable upload failed" private + ## + # @private + # Builds error attributes when a wrapped REST error is available. + # + # @param event [Object] HTTP response event containing wrapped error + # @param prefix [String] Error message prefix + # @return [Array] Tuple of [message, status_code, status, details, headers] def build_from_wrapped_error event, prefix: err = event.error status_code = err.status_code || (event.respond_to?(:status) ? event.status : nil) @@ -93,6 +120,13 @@ def build_from_wrapped_error event, prefix: [msg, status_code, status, err.details, headers] end + ## + # @private + # Builds error attributes directly from raw HTTP response event. + # + # @param event [Object] HTTP response event + # @param prefix [String] Error message prefix + # @return [Array] Tuple of [message, status_code, status, details, headers] def build_from_http_event event, prefix: status_code = event.status headers = event.respond_to?(:headers) && event.headers ? event.headers : {} @@ -110,6 +144,13 @@ def build_from_http_event event, prefix: ## # Raised when an invalid or unmatched event is dispatched for the current protocol state. # + # @!attribute [r] response + # @return [Gapic::Rest::ResumableUpload::Event::HttpResponse, Object, nil] Associated HTTP response + # @!attribute [r] state + # @return [Symbol, nil] Current protocol state + # @!attribute [r] event + # @return [Object, nil] Received event + # class InvalidTransitionError < Gapic::Common::Error # @return [Gapic::Rest::ResumableUpload::Event::HttpResponse, Object, nil] attr_reader :response @@ -120,10 +161,13 @@ class InvalidTransitionError < Gapic::Common::Error # @return [Object, nil] Received event attr_reader :event - # @param message [String] - # @param state [Symbol, nil] - # @param event [Object, nil] - # @param response [Gapic::Rest::ResumableUpload::Event::HttpResponse, Object, nil] + ## + # Initializes a new InvalidTransitionError. + # + # @param message [String] Descriptive error message + # @param state [Symbol, nil] Current protocol state + # @param event [Object, nil] Received event + # @param response [Gapic::Rest::ResumableUpload::Event::HttpResponse, Object, nil] Associated HTTP response def initialize message, state: nil, event: nil, response: nil @state = state @event = event @@ -141,21 +185,33 @@ class UnseekableStreamError < Gapic::Common::Error ## # Raised when an unrecoverable HTTP response is received. # + # @!attribute [r] response_body + # @return [String, nil] Response body from backend + # class BadResponseError < Gapic::Rest::Error # @return [String, nil] Response body from backend attr_reader :response_body - # @param message [String, nil] - # @param status_code [Integer, nil] - # @param status [String, nil] - # @param details [Object, nil] - # @param headers [Object, nil] - # @param response_body [String, nil] + ## + # Initializes a new BadResponseError. + # + # @param message [String, nil] Error message + # @param status_code [Integer, nil] HTTP status code + # @param status [String, nil] Status description + # @param details [Object, nil] Error details + # @param headers [Object, nil] Response headers + # @param response_body [String, nil] Response body def initialize message = nil, status_code = nil, status: nil, details: nil, headers: nil, response_body: nil @response_body = response_body super message, status_code, status: status, details: details, headers: headers end + ## + # Creates a BadResponseError from an HTTP response event. + # + # @param event [Object] HTTP response event + # @param response_body [String, nil] Optional response body override + # @return [BadResponseError] def self.from event, response_body: nil body = response_body || (event.respond_to?(:body) ? event.body : nil) message, status_code, status, details, headers = ErrorBuilder.build_attributes event @@ -167,21 +223,33 @@ def self.from event, response_body: nil # Raised when Scotty backend explicitly rejects the upload session # (returns non-2xx with X-Goog-Upload-Status: final). # + # @!attribute [r] response_body + # @return [String, nil] Response body from backend + # class UploadRejectedError < Gapic::Rest::Error # @return [String, nil] Response body from backend attr_reader :response_body - # @param message [String, nil] - # @param status_code [Integer, nil] - # @param status [String, nil] - # @param details [Object, nil] - # @param headers [Object, nil] - # @param response_body [String, nil] + ## + # Initializes a new UploadRejectedError. + # + # @param message [String, nil] Error message + # @param status_code [Integer, nil] HTTP status code + # @param status [String, nil] Status description + # @param details [Object, nil] Error details + # @param headers [Object, nil] Response headers + # @param response_body [String, nil] Response body def initialize message = nil, status_code = nil, status: nil, details: nil, headers: nil, response_body: nil @response_body = response_body super message, status_code, status: status, details: details, headers: headers end + ## + # Creates an UploadRejectedError from an HTTP response event. + # + # @param event [Object] HTTP response event + # @param response_body [String, nil] Optional response body override + # @return [UploadRejectedError] def self.from event, response_body: nil body = response_body || (event.respond_to?(:body) ? event.body : nil) message, status_code, status, details, headers = @@ -194,10 +262,19 @@ def self.from event, response_body: nil # Raised when the upload session is cancelled. # class UploadCancelledError < Gapic::Common::Error + ## + # Initializes a new UploadCancelledError. + # + # @param message [String] Cancellation message def initialize message = "Upload session was cancelled" super message end + ## + # Creates an UploadCancelledError from a source event or message string. + # + # @param source [Object, String, nil] Source event or message + # @return [UploadCancelledError] def self.from source = nil if source.is_a?(String) && !source.empty? new source @@ -210,10 +287,18 @@ def self.from source = nil ## # Raised when an upload exceeds its global monotonic deadline. # + # @!attribute [r] root_cause + # @return [Object, nil] Root cause exception if deadline exceeded during a retry loop + # class DeadlineExceededError < Gapic::Common::Error # @return [Object, nil] Root cause exception if deadline exceeded during a retry loop attr_reader :root_cause + ## + # Initializes a new DeadlineExceededError. + # + # @param message [String] Deadline exceeded message + # @param root_cause [Object, nil] Root cause exception def initialize message = "Upload deadline exceeded", root_cause: nil super message @root_cause = root_cause diff --git a/gapic-common/lib/gapic/rest/resumable_upload/events.rb b/gapic-common/lib/gapic/rest/resumable_upload/events.rb index 9a7b5b8..ac215a3 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/events.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/events.rb @@ -18,35 +18,70 @@ module Gapic module Rest module ResumableUpload ## + # @private # Event vocabulary emitted by the Driver and dispatched to Core/Rules. # Events are `outside-in` signaling. Something happened, e.g. a chunk of data # was successfully read, and the Driver is reporting that to Core/Rules. # module Event ## + # @private # Signals the start of the upload session. # StartUpload = Data.define ## + # @private # Signals that binary data was read from the stream into the Driver's buffer. # + # @!attribute [r] bytes_buffered + # @return [Integer] Number of bytes currently held in the Driver buffer + # @!attribute [r] eof + # @return [Boolean] Whether stream EOF was encountered during the read + # ChunkRead = Data.define :bytes_buffered, :eof do + ## + # @private + # Initializes a ChunkRead event. + # + # @param bytes_buffered [Integer] Number of bytes currently held in buffer + # @param eof [Boolean] Whether stream EOF was encountered + # def initialize bytes_buffered: 0, eof: false super bytes_buffered: bytes_buffered, eof: eof end end ## + # @private # Signals a completed HTTP exchange over the wire (status, headers, body, error). # + # @!attribute [r] status + # @return [Integer] HTTP status code + # @!attribute [r] headers + # @return [Hash] Response headers + # @!attribute [r] body + # @return [String, Object, nil] Response body + # @!attribute [r] error + # @return [Gapic::Rest::Error, nil] Wrapped REST error if status >= 400 + # HttpResponse = Data.define :status, :headers, :body, :error do + ## + # @private + # Initializes an HttpResponse event. + # + # @param status [Integer] HTTP status code + # @param headers [Hash] Response headers + # @param body [String, Object, nil] Response body + # @param error [Gapic::Rest::Error, nil] Wrapped REST error + # def initialize status:, headers: {}, body: nil, error: nil super status: status, headers: headers || {}, body: body, error: error end end ## + # @private # Signals an HTTP request failure (e.g. request timeout, transport connection failure, or retries exhausted). # # @!attribute [r] kind @@ -57,17 +92,27 @@ def initialize status:, headers: {}, body: nil, error: nil # @return [StandardError, nil] Original underlying exception # RequestFailed = Data.define :kind, :message, :source_error do + ## + # @private + # Initializes a RequestFailed event. + # + # @param kind [Symbol] Failure kind (`:timeout`, `:connection_failed`, `:retries_exhausted`) + # @param message [String, nil] Human-readable failure summary + # @param source_error [StandardError, nil] Original underlying exception + # def initialize kind:, message: nil, source_error: nil super kind: kind, message: message, source_error: source_error end end ## + # @private # Signals a caller-requested session cancellation. # Cancel = Data.define ## + # @private # Signals that the global monotonic clock exceeded the configured deadline. # GlobalDeadlineExceeded = Data.define diff --git a/gapic-common/lib/gapic/rest/resumable_upload/instructions.rb b/gapic-common/lib/gapic/rest/resumable_upload/instructions.rb index 4415c44..eb188f9 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/instructions.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/instructions.rb @@ -18,94 +18,210 @@ module Gapic module Rest module ResumableUpload ## + # @private # Instruction vocabulary emitted by Rules/Core to be executed by Driver. # module Instruction ## + # @private # Execute initiation request to establish upload session. # + # @!attribute [r] url + # @return [String] Initial endpoint URI + # @!attribute [r] headers + # @return [Hash] Additional headers for initiation request + # @!attribute [r] body + # @return [String, nil] Request payload for session initiation + # SendStart = Data.define :url, :headers, :body do + ## + # @private + # Initializes a SendStart instruction. + # + # @param url [String] Initial endpoint URI + # @param headers [Hash] Additional headers + # @param body [String, nil] Request payload + # def initialize url:, headers: {}, body: nil super url: url, headers: headers || {}, body: body end end ## + # @private # Transmit buffered chunk starting at offset for length bytes. # + # @!attribute [r] url + # @return [String] Session upload URL + # @!attribute [r] offset + # @return [Integer] Byte offset within the full upload stream + # @!attribute [r] length + # @return [Integer] Number of bytes to transmit from buffer + # @!attribute [r] finalize + # @return [Boolean] Whether to append finalize command to upload request + # SendChunk = Data.define :url, :offset, :length, :finalize do + ## + # @private + # Initializes a SendChunk instruction. + # + # @param url [String] Session upload URL + # @param offset [Integer] Byte offset within upload stream + # @param length [Integer] Number of bytes to transmit + # @param finalize [Boolean] Whether to combine upload and finalize commands + # def initialize url:, offset:, length:, finalize: false super url: url, offset: offset, length: length, finalize: finalize end end ## + # @private # Send standalone finalize command when all data bytes were already uploaded. # + # @!attribute [r] url + # @return [String] Session upload URL + # SendFinalize = Data.define :url do + ## + # @private + # Initializes a SendFinalize instruction. + # + # @param url [String] Session upload URL + # def initialize url: super url: url end end ## + # @private # Query backend for current acknowledged offset. # + # @!attribute [r] url + # @return [String] Session upload URL + # SendQuery = Data.define :url do + ## + # @private + # Initializes a SendQuery instruction. + # + # @param url [String] Session upload URL + # def initialize url: super url: url end end ## + # @private # Cancel upload session on backend. # + # @!attribute [r] url + # @return [String] Session upload URL + # SendCancel = Data.define :url do + ## + # @private + # Initializes a SendCancel instruction. + # + # @param url [String] Session upload URL + # def initialize url: super url: url end end ## + # @private # Realign Driver in-memory buffer and stream position to match server_offset. # + # @!attribute [r] server_offset + # @return [Integer] Acknowledged byte offset reported by server + # RealignBuffer = Data.define :server_offset do + ## + # @private + # Initializes a RealignBuffer instruction. + # + # @param server_offset [Integer] Target server byte offset + # def initialize server_offset: super server_offset: server_offset end end ## + # @private # Read from stream until in-memory buffer reaches target_bytesize or stream hits EOF. # + # @!attribute [r] target_bytesize + # @return [Integer] Target buffer size in bytes + # FillBuffer = Data.define :target_bytesize do + ## + # @private + # Initializes a FillBuffer instruction. + # + # @param target_bytesize [Integer] Target buffer size in bytes + # def initialize target_bytesize: super target_bytesize: target_bytesize end end ## + # @private # Invoke user progress callback with a Progress instance. # + # @!attribute [r] progress + # @return [Gapic::Rest::ResumableUpload::Progress] Progress notification snapshot + # NotifyProgress = Data.define :progress do + ## + # @private + # Initializes a NotifyProgress instruction. + # + # @param progress [Gapic::Rest::ResumableUpload::Progress] Progress notification snapshot + # def initialize progress: super progress: progress end end ## + # @private # Upload finalized cleanly; return response. # + # @!attribute [r] response + # @return [Gapic::Rest::ResumableUpload::Event::HttpResponse, Object] Final response object + # TerminateSuccess = Data.define :response do + ## + # @private + # Initializes a TerminateSuccess instruction. + # + # @param response [Gapic::Rest::ResumableUpload::Event::HttpResponse, Object] Final response object + # def initialize response: super response: response end end ## + # @private # Terminate upload with error. # + # @!attribute [r] error + # @return [StandardError] Terminal exception to raise + # TerminateFailure = Data.define :error do + ## + # @private + # Initializes a TerminateFailure instruction. + # + # @param error [StandardError] Terminal exception to raise + # def initialize error: super error: error end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb b/gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb index 118c3af..cc125ee 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb @@ -21,9 +21,15 @@ module Gapic module Rest module ResumableUpload ## + # @private # Default retry policy generators for control plane and data plane requests. # module RetryPolicies + ## + # @private + # Retry predicate determining retriability for session initiation requests. + # Retries missing status header on non-fatal codes. + # @return [Proc] START_PREDICATE = lambda do |error_or_response| status = extract_status_code error_or_response return false if Rules::FATAL_STATUS_CODES.include? status @@ -36,6 +42,11 @@ module RetryPolicies nil end + ## + # @private + # Retry predicate determining retriability for data plane requests. + # Disallows retry when upload status header is missing. + # @return [Proc] DATA_PLANE_PREDICATE = lambda do |error_or_response| headers = extract_headers error_or_response if headers @@ -45,6 +56,10 @@ module RetryPolicies nil end + ## + # @private + # Default options for start command retry policy. + # @return [Hash] START_DEFAULTS = { retry_codes: ["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"].freeze, initial_delay: 1.0, @@ -53,6 +68,10 @@ module RetryPolicies retry_predicate: START_PREDICATE }.freeze + ## + # @private + # Default options for query and cancel commands retry policy. + # @return [Hash] CONTROL_PLANE_DEFAULTS = { retry_codes: ["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"].freeze, initial_delay: 1.0, @@ -60,6 +79,10 @@ module RetryPolicies multiplier: 1.3 }.freeze + ## + # @private + # Default options for upload and finalize commands retry policy. + # @return [Hash] DATA_PLANE_DEFAULTS = { retry_codes: ["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"].freeze, initial_delay: 1.0, @@ -69,6 +92,7 @@ module RetryPolicies }.freeze ## + # @private # Default retry policy for session initiation requests (start). # Missing X-Goog-Upload-Status header is retriable across any response code, # including 200 (predicate returns true). @@ -79,6 +103,7 @@ def self.default_start end ## + # @private # Default retry policy for session control requests (query, cancel). # Does not retry on missing X-Goog-Upload-Status header. # @@ -88,6 +113,7 @@ def self.default_control_plane end ## + # @private # Default retry policy for data plane requests (upload, finalize). # Missing X-Goog-Upload-Status header is unretriable (predicate returns false). # @@ -97,9 +123,10 @@ def self.default_data_plane end ## + # @private # Extracts headers hash from Faraday response or error object. # - # @param error_or_response [Object] + # @param error_or_response [Object] Response, error, or hash object # @return [Hash, nil] def self.extract_headers error_or_response if error_or_response.respond_to? :headers @@ -112,9 +139,10 @@ def self.extract_headers error_or_response end ## + # @private # Extracts HTTP status code from Faraday response, error, or event object. # - # @param error_or_response [Object] + # @param error_or_response [Object] Response, error, or event object # @return [Integer, nil] def self.extract_status_code error_or_response if error_or_response.respond_to?(:status_code) && error_or_response.status_code.is_a?(Integer) diff --git a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb index d3c4801..3978c2e 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb @@ -24,14 +24,34 @@ module Gapic module Rest module ResumableUpload ## + # @private # Pure functional transition engine for the Resumable Upload Protocol. # Contains zero side-effects and zero persistent state. # # rubocop:disable Metrics/ModuleLength module Rules + ## + # @private + # Default chunk size in bytes (8 MB). + # @return [Integer] DEFAULT_CHUNK_SIZE = 8_388_608 # 8 MB + + ## + # @private + # HTTP status codes eligible for Category 2 (recovery) handling. + # @return [Array] CAT2_STATUS_CODES = [400, 408, 409, 412, 416, 429, 499].freeze + + ## + # @private + # HTTP status codes that are immediately fatal and non-retriable. + # @return [Array] FATAL_STATUS_CODES = [401, 403, 404, 405, 410, 413, 415].freeze + + ## + # @private + # Human-readable state descriptions for error reporting. + # @return [Hash] STATE_DESCRIPTIONS = { initializing: "initializing upload", starting: "initiating upload session", @@ -48,8 +68,9 @@ module Rules }.freeze ## + # @private # Canonical list of recipe symbols emitted by {Rules.decide}. - # + # @return [Array] RECIPES = [ :start_session, :begin_transmission, @@ -73,8 +94,9 @@ module Rules ].freeze ## + # @private # Mapping of notifying recipes to their emitted {Progress} phase. - # + # @return [Hash] RECIPE_PHASES = { start_session: :initiating, begin_transmission: :uploading, @@ -89,8 +111,9 @@ module Rules }.freeze ## + # @private # Recipes that do not emit {Instruction::NotifyProgress}. - # + # @return [Array] NON_NOTIFYING_RECIPES = [ :send_chunk, :retry_recovery, @@ -104,6 +127,7 @@ module Rules ].freeze ## + # @private # Classifies incoming event into a canonical shape symbol. # # @param event [Object] Input event @@ -130,6 +154,7 @@ def self.shape_of event end ## + # @private # Top-level transition decision engine. Matches [state.status, shape]. # # @param state [State] Current state @@ -203,6 +228,7 @@ def self.decide state, event, config # rubocop:enable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength ## + # @private # Top-level transition router. Matches [state.status, shape]. # # @param state [State] Current state @@ -214,6 +240,14 @@ def self.step state, event, config [decision.next_state, decision.instructions] end + ## + # @private + # Initiates the upload session. + # + # @param state [State] Current state + # @param _event [Object] Dispatched event + # @param config [CompleteUploadConfig] Session configuration + # @return [Array>] Tuple of [next_state, instructions] def self.start_session state, _event, config next_state = state.with status: :starting progress = Progress.new phase: :initiating, bytes_uploaded: next_state.offset, total_bytes: config.upload_size @@ -228,6 +262,14 @@ def self.start_session state, _event, config [next_state, instructions] end + ## + # @private + # Processes initiation response and begins data reading. + # + # @param state [State] Current state + # @param event [Event::HttpResponse] Initiation response + # @param config [CompleteUploadConfig] Session configuration + # @return [Array>] Tuple of [next_state, instructions] def self.begin_transmission state, event, config granularity_str = header_value event.headers, "x-goog-upload-chunk-granularity" granularity = granularity_str&.to_i @@ -249,6 +291,14 @@ def self.begin_transmission state, event, config [next_state, instructions] end + ## + # @private + # Emits instruction to transmit a filled data chunk. + # + # @param state [State] Current state + # @param event [Event::ChunkRead] Chunk read event + # @param _config [CompleteUploadConfig] Session configuration + # @return [Array>] Tuple of [next_state, instructions] def self.send_chunk state, event, _config next_state = state.with( status: :transmission_sending, @@ -265,6 +315,14 @@ def self.send_chunk state, event, _config [next_state, instructions] end + ## + # @private + # Emits instruction to transmit the final data chunk with finalize. + # + # @param state [State] Current state + # @param event [Event::ChunkRead] Chunk read event with EOF + # @param config [CompleteUploadConfig] Session configuration + # @return [Array>] Tuple of [next_state, instructions] def self.send_upload_finalize state, event, config next_state = state.with( status: :finalizing_sending_upload, @@ -283,6 +341,14 @@ def self.send_upload_finalize state, event, config [next_state, instructions] end + ## + # @private + # Emits instruction to send a zero-length finalize command. + # + # @param state [State] Current state + # @param _event [Object] Dispatched event + # @param config [CompleteUploadConfig] Session configuration + # @return [Array>] Tuple of [next_state, instructions] def self.send_finalize state, _event, config next_state = state.with( status: :finalizing_sending_finalize, @@ -296,6 +362,14 @@ def self.send_finalize state, _event, config [next_state, instructions] end + ## + # @private + # Acknowledges transmitted chunk and advances offset. + # + # @param state [State] Current state + # @param _event [Object] Dispatched event + # @param config [CompleteUploadConfig] Session configuration + # @return [Array>] Tuple of [next_state, instructions] def self.ack_chunk state, _event, config new_offset = state.offset + state.in_flight_length next_state = state.with( @@ -312,6 +386,14 @@ def self.ack_chunk state, _event, config [next_state, instructions] end + ## + # @private + # Transitions to recovery state to query backend byte offset. + # + # @param state [State] Current state + # @param _event [Object] Dispatched event + # @param config [CompleteUploadConfig] Session configuration + # @return [Array>] Tuple of [next_state, instructions] def self.enter_recovery state, _event, config next_state = state.with( status: :recovery, @@ -325,6 +407,14 @@ def self.enter_recovery state, _event, config [next_state, instructions] end + ## + # @private + # Retries offset query during recovery. + # + # @param state [State] Current state + # @param _event [Object] Dispatched event + # @param _config [CompleteUploadConfig] Session configuration + # @return [Array>] Tuple of [next_state, instructions] def self.retry_recovery state, _event, _config next_state = state.with( status: :recovery, @@ -333,6 +423,14 @@ def self.retry_recovery state, _event, _config [next_state, [Instruction::SendQuery.new(url: state.upload_url)]] end + ## + # @private + # Completes upload when final chunk transmission succeeds. + # + # @param state [State] Current state + # @param event [Event::HttpResponse] Final HTTP response + # @param _config [CompleteUploadConfig] Session configuration + # @return [Array>] Tuple of [next_state, instructions] def self.complete_upload_with_data state, event, _config new_offset = state.offset + state.in_flight_length next_state = state.with( @@ -348,6 +446,14 @@ def self.complete_upload_with_data state, event, _config [next_state, instructions] end + ## + # @private + # Completes upload when standalone finalize succeeds. + # + # @param state [State] Current state + # @param event [Event::HttpResponse] Final HTTP response + # @param _config [CompleteUploadConfig] Session configuration + # @return [Array>] Tuple of [next_state, instructions] def self.complete_upload_finalized state, event, _config next_state = state.with( status: :success, @@ -361,6 +467,14 @@ def self.complete_upload_finalized state, event, _config [next_state, instructions] end + ## + # @private + # Realigns buffer and resumes transmission from recovered offset. + # + # @param state [State] Current state + # @param event [Event::HttpResponse] Query response containing acknowledged offset + # @param config [CompleteUploadConfig] Session configuration + # @return [Array>] Tuple of [next_state, instructions] def self.realign_from_recovery state, event, config server_offset_str = header_value event.headers, "x-goog-upload-size-received" server_offset = server_offset_str.to_i @@ -378,16 +492,40 @@ def self.realign_from_recovery state, event, config [next_state, instructions] end + ## + # @private + # Completes session cancellation and emits failure instruction. + # + # @param state [State] Current state + # @param event [Object] Cancellation response event + # @param _config [CompleteUploadConfig] Session configuration + # @return [Array>] Tuple of [next_state, instructions] def self.complete_cancellation state, event, _config err = UploadCancelledError.from event next_state = state.with status: :cancelled, in_flight_length: 0, last_error: err [next_state, [Instruction::TerminateFailure.new(error: err)]] end + ## + # @private + # Ignores redundant cancel signal when cancellation is already in progress. + # + # @param state [State] Current state + # @param _event [Object] Dispatched event + # @param _config [CompleteUploadConfig] Session configuration + # @return [Array>] Tuple of [next_state, instructions] def self.ignore_duplicate_cancel state, _event, _config [state, []] end + ## + # @private + # Initiates session cancellation request. + # + # @param state [State] Current state + # @param _event [Object] Dispatched event + # @param config [CompleteUploadConfig] Session configuration + # @return [Array>] Tuple of [next_state, instructions] def self.cancel_session state, _event, config next_state = state.with status: :cancelling progress = Progress.new phase: :cancelling, bytes_uploaded: next_state.offset, total_bytes: config.upload_size @@ -398,6 +536,14 @@ def self.cancel_session state, _event, config [next_state, instructions] end + ## + # @private + # Fails upload due to exceeded execution deadline. + # + # @param state [State] Current state + # @param _event [Object] Dispatched event + # @param _config [CompleteUploadConfig] Session configuration + # @return [Array>] Tuple of [next_state, instructions] def self.fail_with_deadline_exceeded state, _event, _config err = DeadlineExceededError.new next_state = state.with( @@ -408,6 +554,14 @@ def self.fail_with_deadline_exceeded state, _event, _config [next_state, [Instruction::TerminateFailure.new(error: err)]] end + ## + # @private + # Fails upload when backend explicitly rejects session. + # + # @param state [State] Current state + # @param event [Event::HttpResponse] Rejected HTTP response + # @param _config [CompleteUploadConfig] Session configuration + # @return [Array>] Tuple of [next_state, instructions] def self.fail_with_rejected state, event, _config err = UploadRejectedError.from event next_state = state.with( @@ -418,6 +572,14 @@ def self.fail_with_rejected state, event, _config [next_state, [Instruction::TerminateFailure.new(error: err)]] end + ## + # @private + # Fails upload when an unrecoverable HTTP response is encountered. + # + # @param state [State] Current state + # @param event [Event::HttpResponse] Fatal HTTP response + # @param _config [CompleteUploadConfig] Session configuration + # @return [Array>] Tuple of [next_state, instructions] def self.fail_with_bad_response state, event, _config err = BadResponseError.from event next_state = state.with( @@ -428,6 +590,14 @@ def self.fail_with_bad_response state, event, _config [next_state, [Instruction::TerminateFailure.new(error: err)]] end + ## + # @private + # Fails upload when an unrecoverable network or request error occurs. + # + # @param state [State] Current state + # @param event [Event::RequestFailed] Request failure event + # @param _config [CompleteUploadConfig] Session configuration + # @return [Array>] Tuple of [next_state, instructions] def self.fail_with_request_error state, event, _config err = event.source_error || Gapic::Common::Error.new(event.message || "Request failed") next_state = state.with( @@ -438,6 +608,14 @@ def self.fail_with_request_error state, event, _config [next_state, [Instruction::TerminateFailure.new(error: err)]] end + ## + # @private + # Raises InvalidTransitionError for unmatched state and event pair. + # + # @param state [State] Current state + # @param event [Object] Dispatched event + # @param _config [CompleteUploadConfig] Session configuration + # @raise [InvalidTransitionError] def self.fail_with_unmatched_transition state, event, _config shape = shape_of event action = STATE_DESCRIPTIONS[state.status] || "processing #{state.status}" @@ -447,6 +625,13 @@ def self.fail_with_unmatched_transition state, event, _config raise InvalidTransitionError.new(message, state: state.status, event: event, response: response) end + ## + # @private + # Formats human-readable summary of an event. + # + # @param event [Object] Event instance + # @param shape [Symbol] Event shape symbol + # @return [String] Formatted description def self.describe_event event, shape case event when Event::HttpResponse @@ -463,10 +648,11 @@ def self.describe_event event, shape end ## + # @private # Resolves effective chunk size given user specification and backend granularity. # - # @param user_chunk_size [Integer, nil] - # @param chunk_granularity [Integer, nil] + # @param user_chunk_size [Integer, nil] Configured chunk size + # @param chunk_granularity [Integer, nil] Backend alignment granularity # @return [Integer] Effective chunk size in bytes def self.resolve_chunk_size user_chunk_size, chunk_granularity base_size = user_chunk_size || DEFAULT_CHUNK_SIZE @@ -477,10 +663,11 @@ def self.resolve_chunk_size user_chunk_size, chunk_granularity end ## + # @private # Classifies an HTTP response into a canonical response shape. # - # @param response [Event::HttpResponse] - # @return [Symbol] + # @param response [Event::HttpResponse] Response event + # @return [Symbol] Canonical response shape def self.classify_http_response response status_header = header_value(response.headers, "x-goog-upload-status")&.downcase @@ -503,11 +690,12 @@ def self.classify_http_response response end ## + # @private # Case-insensitive header lookup helper. # - # @param headers [Hash, Object] - # @param key [String] - # @return [String, nil] + # @param headers [Hash, Object] Headers collection + # @param key [String] Target header key + # @return [String, nil] Header value def self.header_value headers, key return nil unless headers.is_a? Hash return headers[key] if headers.key? key @@ -517,6 +705,12 @@ def self.header_value headers, key val end + ## + # @private + # Classifies chunk read event by buffer size and EOF flag. + # + # @param event [Event::ChunkRead] Chunk read event + # @return [Symbol] Canonical chunk shape def self.classify_chunk_read event if !event.eof :chunk_read_full @@ -527,6 +721,12 @@ def self.classify_chunk_read event end end + ## + # @private + # Classifies request failure event by failure kind. + # + # @param event [Event::RequestFailed] Request failed event + # @return [Symbol] Canonical failure shape def self.classify_request_failed event case event.kind when :timeout then :request_timeout @@ -536,6 +736,12 @@ def self.classify_request_failed event end end + ## + # @private + # Classifies raw event class objects. + # + # @param event_class [Class] Event class + # @return [Symbol] Canonical shape def self.classify_event_class event_class if event_class == Event::StartUpload :start_upload From f8b475c74bd9722722a6d8a2d6d1746dd723fb3f Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Thu, 10 Sep 2026 03:49:52 +0000 Subject: [PATCH 55/79] feat: add resume handle --- gapic-common/design/implementation-guide.md | 35 +++- gapic-common/design/test-plan.md | 13 +- .../gapic/rest/resumable_upload/data_types.rb | 28 ++++ .../lib/gapic/rest/resumable_upload/driver.rb | 15 +- .../lib/gapic/rest/resumable_upload/errors.rb | 153 +++++++++++++++++- .../lib/gapic/rest/resumable_upload/rules.rb | 26 ++- .../rest/resumable_upload/data_types_test.rb | 14 ++ .../resumable_upload/driver_buffer_test.rb | 39 +++++ .../rest/resumable_upload/rules_error_test.rb | 130 +++++++++++++++ 9 files changed, 433 insertions(+), 20 deletions(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index 7c4e045..ee9a5f4 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -22,6 +22,8 @@ The `Driver` executes all operations with side-effects. It interacts with HTTP t Crucially, the Driver delegates all **Category 1 (Transient)** transport retries directly to `Gapic::Common::RetryPolicy`. Transient retries occur entirely within the Driver's network execution wrapper. The `Core` state machine is never exposed to transient noise, receiving only verified successful HTTP responses or terminal transport exceptions. +The Driver also exposes `Driver#resume_handle`, returning a `ResumeHandle` (or `nil` if initiation has not established an upload URL). Reading this property mid-run provides a best-effort snapshot of current session parameters. + ### 1.2 Core (State Container) The `Core` maintains the immutable `State` snapshot. When `Core#dispatch(event)` is invoked by the Driver, Core forwards `@state`, the event, and static configuration to `Rules.decide`. Core mutates `@state` to `decision.next_state`, records the decision in `@last_decision`, and returns `decision.instructions` back to the Driver. Core contains zero protocol branching logic and zero side effects. @@ -104,7 +106,22 @@ module Gapic end ``` -### 2.3 Events Vocabulary (Driver -> Core) +### 2.3 Resume Handle (`ResumeHandle`) +```ruby +module Gapic + module Rest + module ResumableUpload + ResumeHandle = Data.define( + :upload_url, # [String] Upload session URL provided by the server + :chunk_size # [Integer] Effective chunk size in bytes + ) + end + end +end +``` +`ResumeHandle` captures server-provided parameters that can be persisted to resume the upload session at a later time. + +### 2.4 Events Vocabulary (Driver -> Core) * `Event::StartUpload`: Start the upload session. * `Event::ChunkRead.new(bytes_buffered:, eof:)`: Binary data buffered in Driver memory; reports total bytes ready in buffer and whether the stream hit EOF. * `Event::HttpResponse.new(status:, headers:, body:, error: nil)`: Dispatched for any completed HTTP exchange over the wire (including 2xx, 4xx, 5xx, or responses with missing/unexpected headers). Carries optional parsed `error` (`Gapic::Rest::Error`) when rescued from transport errors. `Core` inspects status and headers to determine protocol progression or recovery. @@ -115,7 +132,7 @@ end * `Event::Cancel`: Caller requested session cancellation. * `Event::GlobalDeadlineExceeded`: Absolute monotonic clock exceeded the session deadline (`@deadline`) computed at the start of `Driver#run`. -### 2.4 Instructions Vocabulary (Core -> Driver) +### 2.5 Instructions Vocabulary (Core -> Driver) * `Instruction::SendStart.new(url:, headers:, body:)`: Execute initiation request to establish upload session. * `Instruction::SendChunk.new(url:, offset:, length:, finalize:)`: Transmit buffered chunk of specified `length` starting at `offset`. If `finalize` is true, sends command `upload, finalize`. * `Instruction::SendFinalize.new(url:)`: Send standalone `finalize` command when all data bytes were already acknowledged. @@ -399,10 +416,16 @@ The implementation distinguishes three categories of network and protocol-level #### 6.1.4 Actionable Terminal Errors & Metadata Propagation Terminal errors provide actionable context so downstream SDK callers can inspect error metadata: * **Error Classes**: - * `BadResponseError < Gapic::Rest::Error`: Unrecoverable non-2xx HTTP responses or invalid payloads. Retains `attr_reader :response_body` returning `event.body`. - * `UploadRejectedError < Gapic::Rest::Error`: Backend explicitly rejected the session with `X-Goog-Upload-Status: final`. Retains `attr_reader :response_body` returning `event.body`. - * `UploadCancelledError < Gapic::Common::Error`: Upload session cancelled by caller. - * `DeadlineExceededError < Gapic::Common::Error`: Upload deadline exceeded with optional root cause (`attr_reader :root_cause`). + * `BadResponseError < Gapic::Rest::Error`: Unrecoverable non-2xx HTTP responses or invalid payloads. Retains `attr_reader :response_body` returning `event.body`, and includes `HasResumeHandle`. + * `UploadRejectedError < Gapic::Rest::Error`: Backend explicitly rejected the session with `X-Goog-Upload-Status: final`. Retains `attr_reader :response_body` returning `event.body`. Does NOT include `HasResumeHandle` (session is terminated permanently). + * `UploadCancelledError < Gapic::Common::Error`: Upload session cancelled by caller. Does NOT include `HasResumeHandle` (session is terminated permanently). + * `DeadlineExceededError < Gapic::Common::Error`: Upload deadline exceeded with optional root cause (`attr_reader :root_cause`), and includes `HasResumeHandle`. + * `UnseekableStreamError < Gapic::Common::Error`: Stream rewind required on an unseekable stream; includes `HasResumeHandle`. + * `InvalidTransitionError < Gapic::Common::Error`: Unexpected event dispatched for state; includes `HasResumeHandle`. + * `StreamMismatchError < Gapic::Common::Error`: Stream content or length does not match resumed upload specifications; includes `HasResumeHandle`. +* **Resume Handle Propagation (`HasResumeHandle`)**: + * The `HasResumeHandle` mixin exposes `attr_reader :resume_handle` returning a `ResumeHandle` (or `nil` if session initiation was incomplete). + * Whenever `resume_handle` is non-nil, the uniform suffix `" (upload_session is resumable: see #resume_handle)"` is automatically appended to the error message. * **Metadata Sourcing & De-prefixing**: * When `event.error` is present (from `Gapic::Rest::Error.wrap_faraday_error`), factories source `status_code`, `status`, `details`/`status_details`, and `headers`/`header`. * The prefix literal `Gapic::Rest::Error::REST_ERROR_PREFIX` (`"An error has occurred when making a REST request"`) is stripped from `event.error.message` to avoid redundant prefixes. diff --git a/gapic-common/design/test-plan.md b/gapic-common/design/test-plan.md index 74da019..42ca655 100644 --- a/gapic-common/design/test-plan.md +++ b/gapic-common/design/test-plan.md @@ -148,6 +148,14 @@ flowchart TD * Unexpected response lacking `X-Goog-Upload-Status` formats header description as `(X-Goog-Upload-Status: missing)`. * **Actionable description for non-HTTP unexpected events**: * Stream chunk read while in `:starting` raises message stating `"initiating upload session: received unexpected stream chunk read (512 bytes, eof: false)"` with `err.response == nil`. +* **Resume Handle & Error Metadata (`HasResumeHandle`)**: + * `HasResumeHandle` mixin inclusion verified on `BadResponseError`, `DeadlineExceededError`, `UnseekableStreamError`, `InvalidTransitionError`, and `StreamMismatchError`. + * `HasResumeHandle` explicitly refuted on terminal dead-session errors (`UploadRejectedError`, `UploadCancelledError`). + * `Rules.resume_handle_from`: Returns `nil` when state is `nil` or `upload_url` is `nil`; returns populated `ResumeHandle` with `upload_url` and `chunk_size` when established. + * Resumable error suffix: When `resume_handle` is present, uniform suffix `" (upload_session is resumable: see #resume_handle)"` is appended to the message on `DeadlineExceededError`, `BadResponseError`, `InvalidTransitionError`, `UnseekableStreamError`, and `StreamMismatchError`. + * Suffix omission: When `resume_handle` is `nil` (e.g. before session creation), error message omits the resumable suffix. + * Terminal dead sessions: `UploadRejectedError` and `UploadCancelledError` do not respond to `:resume_handle` and do not include the suffix. + * `StreamMismatchError`: Verified with `.new` and `.from`, ensuring `resume_handle` and formatted messages with/without handle. --- @@ -166,10 +174,13 @@ flowchart TD * *Exact end*: `server_offset` at end empties buffer and updates start offset. * **Rewind stream**: * *Seekable*: Rewinds stream position and resets buffer to target offset. - * *Unseekable*: Raises `UnseekableStreamError` with target and current buffer offsets in message. + * *Unseekable*: Raises `UnseekableStreamError` with target and current buffer offsets in message. If `upload_url` is established in `Driver#resume_handle`, attaches `resume_handle` and appends the uniform resumable suffix. * **Fast-forward stream**: * *Seekable*: Seeks stream forward and resets buffer to target offset. * *Unseekable*: Reads and discards needed bytes from stream to advance to target offset. +* **Driver Session Snapshot (`Driver#resume_handle`)**: + * Returns `nil` before upload session URL is established. + * Returns `ResumeHandle` snapshot during active upload progression. --- diff --git a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb index b7140fc..2b47567 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb @@ -157,6 +157,34 @@ def initialize phase:, bytes_uploaded:, total_bytes: nil # @return [Array] Progress::PHASES = [:initiating, :uploading, :recovering, :finalizing, :cancelling, :completed].freeze + ## + # Immutable handle containing parameters necessary to resume an in-progress upload session. + # These parameters are provided by the server and can be persisted to resume the upload + # at a later time. + # + # @!attribute [r] upload_url + # @return [String] Upload session URL provided by the server + # @!attribute [r] chunk_size + # @return [Integer] Effective chunk size in bytes + # + ResumeHandle = Data.define( + :upload_url, + :chunk_size + ) do + ## + # Initializes a new resume handle. + # + # @param upload_url [String] Upload session URL provided by the server + # @param chunk_size [Integer] Effective chunk size in bytes + # + def initialize upload_url:, chunk_size: + super( + upload_url: upload_url, + chunk_size: chunk_size + ) + end + end + ## # @private # Immutable state snapshot representing the current protocol progression. diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index 53bdee3..22cbc22 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -57,6 +57,15 @@ class Driver # @return [String, nil] Current upload session ID attr_reader :upload_id + ## + # Returns a {ResumeHandle} representing the current upload session parameters. + # Reading this property mid-run provides a best-effort snapshot of the current session state. + # + # @return [ResumeHandle, nil] Resume handle if upload URL is established, or nil + def resume_handle + Rules.resume_handle_from @core.state + end + ## # Initializes a new Resumable Upload Driver. # @@ -336,8 +345,10 @@ def realign_within_buffer server_offset # def realign_rewind_stream server_offset unless @config.stream.respond_to? :seek - raise UnseekableStreamError, - "Cannot rewind unseekable stream to offset #{server_offset} (buffered from #{@buffer_start_offset})" + raise UnseekableStreamError.new( + "Cannot rewind unseekable stream to offset #{server_offset} (buffered from #{@buffer_start_offset})", + resume_handle: resume_handle + ) end @config.stream.seek server_offset diff --git a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb index fe8c378..91ea986 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb @@ -141,6 +141,36 @@ def build_from_http_event event, prefix: end end + ## + # Mixin providing {ResumeHandle} access and uniform formatting for resumable errors. + # + # @!attribute [r] resume_handle + # @return [Gapic::Rest::ResumableUpload::ResumeHandle, nil] Associated upload session resume handle + # + module HasResumeHandle + # @return [Gapic::Rest::ResumableUpload::ResumeHandle, nil] + attr_reader :resume_handle + + ## + # Suffix appended to error message when a resume handle is present. + # @return [String] + RESUMABLE_SUFFIX = " (upload_session is resumable: see #resume_handle)" + + ## + # Appends the uniform resumable suffix if resume_handle is non-nil. + # + # @param message [String, nil] Error message + # @param resume_handle [Gapic::Rest::ResumableUpload::ResumeHandle, nil] Resume handle + # @return [String, nil] + def self.append_suffix message, resume_handle + return message if resume_handle.nil? + return RESUMABLE_SUFFIX.strip if message.nil? || message.to_s.strip.empty? + return message if message.end_with? RESUMABLE_SUFFIX + + "#{message}#{RESUMABLE_SUFFIX}" + end + end + ## # Raised when an invalid or unmatched event is dispatched for the current protocol state. # @@ -150,8 +180,12 @@ def build_from_http_event event, prefix: # @return [Symbol, nil] Current protocol state # @!attribute [r] event # @return [Object, nil] Received event + # @!attribute [r] resume_handle + # @return [Gapic::Rest::ResumableUpload::ResumeHandle, nil] Associated resume handle # class InvalidTransitionError < Gapic::Common::Error + include HasResumeHandle + # @return [Gapic::Rest::ResumableUpload::Event::HttpResponse, Object, nil] attr_reader :response @@ -168,18 +202,94 @@ class InvalidTransitionError < Gapic::Common::Error # @param state [Symbol, nil] Current protocol state # @param event [Object, nil] Received event # @param response [Gapic::Rest::ResumableUpload::Event::HttpResponse, Object, nil] Associated HTTP response - def initialize message, state: nil, event: nil, response: nil + # @param resume_handle [Gapic::Rest::ResumableUpload::ResumeHandle, nil] Associated resume handle + def initialize message, state: nil, event: nil, response: nil, resume_handle: nil @state = state @event = event @response = response || (event if defined?(Event::HttpResponse) && event.is_a?(Event::HttpResponse)) - super message + @resume_handle = resume_handle + super HasResumeHandle.append_suffix(message, resume_handle) + end + + ## + # Creates an InvalidTransitionError from an event. + # + # @param event [Object] Received event + # @param state [Symbol, nil] Current protocol state + # @param message [String, nil] Descriptive error message + # @param response [Object, nil] Associated HTTP response + # @param resume_handle [Gapic::Rest::ResumableUpload::ResumeHandle, nil] Associated resume handle + # @return [InvalidTransitionError] + def self.from event, state: nil, message: nil, response: nil, resume_handle: nil + new( + message || "Invalid transition for event #{event.inspect}", + state: state, + event: event, + response: response, + resume_handle: resume_handle + ) end end ## # Raised when stream rewinding is required but the stream does not support seeking. # + # @!attribute [r] resume_handle + # @return [Gapic::Rest::ResumableUpload::ResumeHandle, nil] Associated resume handle + # class UnseekableStreamError < Gapic::Common::Error + include HasResumeHandle + + ## + # Initializes a new UnseekableStreamError. + # + # @param message [String, nil] Descriptive error message + # @param resume_handle [Gapic::Rest::ResumableUpload::ResumeHandle, nil] Associated resume handle + def initialize message = nil, resume_handle: nil + @resume_handle = resume_handle + super HasResumeHandle.append_suffix(message, resume_handle) + end + + ## + # Creates an UnseekableStreamError with optional resume handle. + # + # @param message [String, nil] Descriptive error message + # @param resume_handle [Gapic::Rest::ResumableUpload::ResumeHandle, nil] Associated resume handle + # @return [UnseekableStreamError] + def self.from message = nil, resume_handle: nil + new message, resume_handle: resume_handle + end + end + + ## + # Raised when stream content or length does not match resumed upload specifications. + # + # @!attribute [r] resume_handle + # @return [Gapic::Rest::ResumableUpload::ResumeHandle, nil] Associated resume handle + # + class StreamMismatchError < Gapic::Common::Error + include HasResumeHandle + + ## + # Initializes a new StreamMismatchError. + # + # @param message [String] Error message + # @param resume_handle [Gapic::Rest::ResumableUpload::ResumeHandle, nil] Associated resume handle + def initialize message = "Stream content or length does not match resumed upload", resume_handle: nil + @resume_handle = resume_handle + super HasResumeHandle.append_suffix(message, resume_handle) + end + + ## + # Creates a StreamMismatchError with optional resume handle. + # + # @param message [String, nil] Error message + # @param resume_handle [Gapic::Rest::ResumableUpload::ResumeHandle, nil] Associated resume handle + # @return [StreamMismatchError] + def self.from message = nil, resume_handle: nil + msg = message || "Stream content or length does not match resumed upload" + new msg, resume_handle: resume_handle + end end ## @@ -187,8 +297,12 @@ class UnseekableStreamError < Gapic::Common::Error # # @!attribute [r] response_body # @return [String, nil] Response body from backend + # @!attribute [r] resume_handle + # @return [Gapic::Rest::ResumableUpload::ResumeHandle, nil] Associated resume handle # class BadResponseError < Gapic::Rest::Error + include HasResumeHandle + # @return [String, nil] Response body from backend attr_reader :response_body @@ -201,9 +315,13 @@ class BadResponseError < Gapic::Rest::Error # @param details [Object, nil] Error details # @param headers [Object, nil] Response headers # @param response_body [String, nil] Response body - def initialize message = nil, status_code = nil, status: nil, details: nil, headers: nil, response_body: nil + # @param resume_handle [Gapic::Rest::ResumableUpload::ResumeHandle, nil] Associated resume handle + def initialize message = nil, status_code = nil, status: nil, details: nil, headers: nil, + response_body: nil, resume_handle: nil @response_body = response_body - super message, status_code, status: status, details: details, headers: headers + @resume_handle = resume_handle + super HasResumeHandle.append_suffix(message, resume_handle), + status_code, status: status, details: details, headers: headers end ## @@ -211,11 +329,13 @@ def initialize message = nil, status_code = nil, status: nil, details: nil, head # # @param event [Object] HTTP response event # @param response_body [String, nil] Optional response body override + # @param resume_handle [Gapic::Rest::ResumableUpload::ResumeHandle, nil] Optional resume handle # @return [BadResponseError] - def self.from event, response_body: nil + def self.from event, response_body: nil, resume_handle: nil body = response_body || (event.respond_to?(:body) ? event.body : nil) message, status_code, status, details, headers = ErrorBuilder.build_attributes event - new message, status_code, status: status, details: details, headers: headers, response_body: body + new message, status_code, status: status, details: details, headers: headers, + response_body: body, resume_handle: resume_handle end end @@ -289,8 +409,12 @@ def self.from source = nil # # @!attribute [r] root_cause # @return [Object, nil] Root cause exception if deadline exceeded during a retry loop + # @!attribute [r] resume_handle + # @return [Gapic::Rest::ResumableUpload::ResumeHandle, nil] Associated resume handle # class DeadlineExceededError < Gapic::Common::Error + include HasResumeHandle + # @return [Object, nil] Root cause exception if deadline exceeded during a retry loop attr_reader :root_cause @@ -299,9 +423,22 @@ class DeadlineExceededError < Gapic::Common::Error # # @param message [String] Deadline exceeded message # @param root_cause [Object, nil] Root cause exception - def initialize message = "Upload deadline exceeded", root_cause: nil - super message + # @param resume_handle [Gapic::Rest::ResumableUpload::ResumeHandle, nil] Associated resume handle + def initialize message = "Upload deadline exceeded", root_cause: nil, resume_handle: nil + super HasResumeHandle.append_suffix(message, resume_handle) @root_cause = root_cause + @resume_handle = resume_handle + end + + ## + # Creates a DeadlineExceededError with optional resume handle. + # + # @param message [String, nil] Deadline exceeded message + # @param root_cause [Object, nil] Root cause exception + # @param resume_handle [Gapic::Rest::ResumableUpload::ResumeHandle, nil] Associated resume handle + # @return [DeadlineExceededError] + def self.from message = "Upload deadline exceeded", root_cause: nil, resume_handle: nil + new message, root_cause: root_cause, resume_handle: resume_handle end end end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb index 3978c2e..7eb3443 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb @@ -536,6 +536,17 @@ def self.cancel_session state, _event, config [next_state, instructions] end + ## + # Extracts a {ResumeHandle} from current protocol state. + # + # @param state [State] Protocol state + # @return [ResumeHandle, nil] Resume handle if upload URL is established, or nil + def self.resume_handle_from state + return nil if state.nil? || state.upload_url.nil? + + ResumeHandle.new upload_url: state.upload_url, chunk_size: state.chunk_size + end + ## # @private # Fails upload due to exceeded execution deadline. @@ -545,7 +556,8 @@ def self.cancel_session state, _event, config # @param _config [CompleteUploadConfig] Session configuration # @return [Array>] Tuple of [next_state, instructions] def self.fail_with_deadline_exceeded state, _event, _config - err = DeadlineExceededError.new + handle = resume_handle_from state + err = DeadlineExceededError.new resume_handle: handle next_state = state.with( status: :error, in_flight_length: 0, @@ -581,7 +593,8 @@ def self.fail_with_rejected state, event, _config # @param _config [CompleteUploadConfig] Session configuration # @return [Array>] Tuple of [next_state, instructions] def self.fail_with_bad_response state, event, _config - err = BadResponseError.from event + handle = resume_handle_from state + err = BadResponseError.from event, resume_handle: handle next_state = state.with( status: :error, in_flight_length: 0, @@ -622,7 +635,14 @@ def self.fail_with_unmatched_transition state, event, _config happened = describe_event event, shape message = "Resumable upload failed while #{action}: #{happened}." response = event.is_a?(Event::HttpResponse) ? event : nil - raise InvalidTransitionError.new(message, state: state.status, event: event, response: response) + handle = resume_handle_from state + raise InvalidTransitionError.new( + message, + state: state.status, + event: event, + response: response, + resume_handle: handle + ) end ## diff --git a/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb index 41ac8fb..82e7312 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb @@ -116,4 +116,18 @@ def test_progress_instantiation Progress.new phase: :invalid_phase, bytes_uploaded: 512, total_bytes: 2048 end end + + def test_resume_handle_instantiation + handle = ResumeHandle.new upload_url: "https://upload.example.com/session123", chunk_size: 1_048_576 + assert_equal "https://upload.example.com/session123", handle.upload_url + assert_equal 1_048_576, handle.chunk_size + + assert_raises ArgumentError do + ResumeHandle.new upload_url: "https://upload.example.com/session123" + end + + assert_raises NoMethodError do + handle.upload_url = "https://mutated.com" + end + end end diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb index 4930173..6101454 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb @@ -198,6 +198,45 @@ def test_realign_buffer_rewind_unseekable_stream_raises_error assert_includes err.message, "offset 500" assert_includes err.message, "buffered from 1000" + assert_nil err.resume_handle + refute_includes err.message, "(upload_session is resumable: see #resume_handle)" + end + + def test_realign_buffer_rewind_unseekable_stream_with_resume_handle + stream = UnseekableStream.new "0123456789" * 100 + driver = build_driver stream: stream + driver.core.instance_variable_set( + :@state, + driver.core.state.with(status: :recovery, upload_url: "https://upload.example.com/session_1", chunk_size: 256) + ) + driver.instance_variable_set :@buffer_start_offset, 1000 + driver.instance_variable_set :@buffer, "buffered".b + + err = assert_raises UnseekableStreamError do + driver.send :execute_realign_buffer, Instruction::RealignBuffer.new(server_offset: 500) + end + + refute_nil err.resume_handle + assert_equal "https://upload.example.com/session_1", err.resume_handle.upload_url + assert_equal 256, err.resume_handle.chunk_size + assert_includes err.message, "offset 500" + assert_includes err.message, "buffered from 1000" + assert_includes err.message, "(upload_session is resumable: see #resume_handle)" + end + + def test_driver_resume_handle_property + stream = StringIO.new "test" + driver = build_driver stream: stream + assert_nil driver.resume_handle + + driver.core.instance_variable_set( + :@state, + driver.core.state.with(upload_url: "https://upload.example.com/session_2", chunk_size: 512) + ) + handle = driver.resume_handle + refute_nil handle + assert_equal "https://upload.example.com/session_2", handle.upload_url + assert_equal 512, handle.chunk_size end # ============================================================================ diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb index 1e0ec74..246b9e6 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb @@ -282,5 +282,135 @@ def test_error_class_inheritance_hierarchy assert_operator UploadRejectedError, :<, Gapic::Rest::Error assert_operator BadResponseError, :<, Gapic::Rest::Error + + assert_operator StreamMismatchError, :<, Gapic::Common::Error + assert_operator HasResumeHandle, :===, BadResponseError.new + assert_operator HasResumeHandle, :===, DeadlineExceededError.new + assert_operator HasResumeHandle, :===, UnseekableStreamError.new + assert_operator HasResumeHandle, :===, InvalidTransitionError.new("invalid") + assert_operator HasResumeHandle, :===, StreamMismatchError.new + refute_operator HasResumeHandle, :===, UploadRejectedError.new + refute_operator HasResumeHandle, :===, UploadCancelledError.new + end + + def test_rules_resume_handle_from + assert_nil Rules.resume_handle_from(nil) + assert_nil Rules.resume_handle_from(State.new(status: :starting, upload_url: nil)) + + state = State.new status: :transmission_sending, upload_url: "https://upload.example.com/id123", chunk_size: 1024 + handle = Rules.resume_handle_from state + refute_nil handle + assert_equal "https://upload.example.com/id123", handle.upload_url + assert_equal 1024, handle.chunk_size + end + + def test_resume_handle_present_on_errors_when_upload_url_set + state = State.new( + status: :transmission_sending, + upload_url: "https://upload.example.com/session_abc", + chunk_size: 512 + ) + + # 1. Deadline exceeded + next_state, = Rules.step state, Event::GlobalDeadlineExceeded.new, @config + assert_equal :error, next_state.status + deadline_err = next_state.last_error + assert_instance_of DeadlineExceededError, deadline_err + refute_nil deadline_err.resume_handle + assert_equal "https://upload.example.com/session_abc", deadline_err.resume_handle.upload_url + assert_equal 512, deadline_err.resume_handle.chunk_size + assert_includes deadline_err.message, "(upload_session is resumable: see #resume_handle)" + + # 2. Bad response + resp = Event::HttpResponse.new status: 401, headers: {}, body: "Fatal 401" + next_state, = Rules.step state, resp, @config + assert_equal :error, next_state.status + bad_resp_err = next_state.last_error + assert_instance_of BadResponseError, bad_resp_err + refute_nil bad_resp_err.resume_handle + assert_equal "https://upload.example.com/session_abc", bad_resp_err.resume_handle.upload_url + assert_equal 512, bad_resp_err.resume_handle.chunk_size + assert_includes bad_resp_err.message, "(upload_session is resumable: see #resume_handle)" + + # 3. Unmatched transition + unmatched_err = assert_raises InvalidTransitionError do + Rules.step state, Object.new, @config + end + refute_nil unmatched_err.resume_handle + assert_equal "https://upload.example.com/session_abc", unmatched_err.resume_handle.upload_url + assert_equal 512, unmatched_err.resume_handle.chunk_size + assert_includes unmatched_err.message, "(upload_session is resumable: see #resume_handle)" + end + + def test_resume_handle_nil_on_errors_before_session_created + state = State.new status: :starting, upload_url: nil + + # 1. Deadline exceeded before session creation + next_state, = Rules.step state, Event::GlobalDeadlineExceeded.new, @config + assert_equal :error, next_state.status + deadline_err = next_state.last_error + assert_nil deadline_err.resume_handle + refute_includes deadline_err.message, "(upload_session is resumable: see #resume_handle)" + + # 2. Bad response before session creation + resp = Event::HttpResponse.new status: 503, headers: {}, body: "Init failed" + next_state, = Rules.step state, resp, @config + assert_equal :error, next_state.status + bad_resp_err = next_state.last_error + assert_nil bad_resp_err.resume_handle + refute_includes bad_resp_err.message, "(upload_session is resumable: see #resume_handle)" + + # 3. Unmatched transition before session creation + unmatched_err = assert_raises InvalidTransitionError do + Rules.step state, Object.new, @config + end + assert_nil unmatched_err.resume_handle + refute_includes unmatched_err.message, "(upload_session is resumable: see #resume_handle)" + end + + def test_resume_handle_absent_on_rejected_and_cancelled + state = State.new( + status: :transmission_sending, + upload_url: "https://upload.example.com/session_abc", + chunk_size: 512 + ) + + # 1. Rejected error + rejected_resp = Event::HttpResponse.new status: 403, headers: { "x-goog-upload-status" => "final" }, + body: "Access Denied" + next_state, = Rules.step state, rejected_resp, @config + assert_equal :rejected, next_state.status + rejected_err = next_state.last_error + assert_instance_of UploadRejectedError, rejected_err + refute_respond_to rejected_err, :resume_handle + refute_includes rejected_err.message, "(upload_session is resumable: see #resume_handle)" + + # 2. Cancelled error + cancelling_state = state.with status: :cancelling + cancelled_resp = Event::HttpResponse.new status: 200, headers: { "x-goog-upload-status" => "cancelled" }, + body: "" + next_state, = Rules.step cancelling_state, cancelled_resp, @config + assert_equal :cancelled, next_state.status + cancelled_err = next_state.last_error + assert_instance_of UploadCancelledError, cancelled_err + refute_respond_to cancelled_err, :resume_handle + refute_includes cancelled_err.message, "(upload_session is resumable: see #resume_handle)" + end + + def test_stream_mismatch_error_behavior + handle = ResumeHandle.new upload_url: "https://upload.example.com/resume", chunk_size: 256 + err_with_handle = StreamMismatchError.new "Stream too short", resume_handle: handle + + assert_instance_of StreamMismatchError, err_with_handle + assert_equal handle, err_with_handle.resume_handle + assert_equal "Stream too short (upload_session is resumable: see #resume_handle)", err_with_handle.message + + err_from = StreamMismatchError.from "Stream corrupted", resume_handle: handle + assert_equal handle, err_from.resume_handle + assert_equal "Stream corrupted (upload_session is resumable: see #resume_handle)", err_from.message + + err_without_handle = StreamMismatchError.new "No handle" + assert_nil err_without_handle.resume_handle + assert_equal "No handle", err_without_handle.message end end From b1b19928110867bde72ea2220a0f620ae3ffd3c0 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Thu, 10 Sep 2026 04:25:39 +0000 Subject: [PATCH 56/79] feat: adding ResumeUploadCOnfig --- gapic-common/design/implementation-guide.md | 49 ++++++++-- gapic-common/design/test-plan.md | 10 ++ .../gapic/rest/resumable_upload/data_types.rb | 97 +++++++++++++++++++ .../lib/gapic/rest/resumable_upload/driver.rb | 40 +++++++- .../resumable_upload/driver/upload_log.rb | 6 ++ .../lib/gapic/rest/resumable_upload/events.rb | 6 ++ .../lib/gapic/rest/resumable_upload/rules.rb | 35 +++++++ .../rest/resumable_upload/data_types_test.rb | 53 ++++++++++ .../resumable_upload/driver_buffer_test.rb | 79 ++++++++++++++- .../resumable_upload/driver_logging_test.rb | 39 ++++++++ .../rest/resumable_upload/driver_test.rb | 90 +++++++++++++++++ .../rules_classification_test.rb | 2 + .../resumable_upload/rules_decide_test.rb | 20 ++++ 13 files changed, 512 insertions(+), 14 deletions(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index ee9a5f4..b6bde4e 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -22,7 +22,7 @@ The `Driver` executes all operations with side-effects. It interacts with HTTP t Crucially, the Driver delegates all **Category 1 (Transient)** transport retries directly to `Gapic::Common::RetryPolicy`. Transient retries occur entirely within the Driver's network execution wrapper. The `Core` state machine is never exposed to transient noise, receiving only verified successful HTTP responses or terminal transport exceptions. -The Driver also exposes `Driver#resume_handle`, returning a `ResumeHandle` (or `nil` if initiation has not established an upload URL). Reading this property mid-run provides a best-effort snapshot of current session parameters. +The Driver also exposes `Driver#resume_handle`, returning a `ResumeHandle` (or `nil` if initiation has not established an upload URL). Reading this property mid-run provides a best-effort snapshot of current session parameters. In addition, `Driver#stream_position` returns the current absolute stream offset (`@buffer_start_offset + @buffer.bytesize`). ### 1.2 Core (State Container) The `Core` maintains the immutable `State` snapshot. When `Core#dispatch(event)` is invoked by the Driver, Core forwards `@state`, the event, and static configuration to `Rules.decide`. Core mutates `@state` to `decision.next_state`, records the decision in `@last_decision`, and returns `decision.instructions` back to the Driver. Core contains zero protocol branching logic and zero side effects. @@ -76,7 +76,31 @@ end * Terminal failures and completed cancellations do not emit `Progress` notifications; however, entering the `:cancelling` phase does. * Public phases (`Progress::PHASES`): `:initiating`, `:uploading`, `:recovering`, `:finalizing`, `:cancelling`, `:completed`. -### 2.2 Protocol State (`State`) & Decisions (`Decision`) +### 2.2 Resume Configuration (`ResumeUploadConfig`) +```ruby +module Gapic + module Rest + module ResumableUpload + ResumeUploadConfig = Data.define( + :upload_url, # [String] Upload session URL returned by Scotty backend + :chunk_size, # [Integer] Chunk size in bytes (> 0) + :stream, # [IO] Binary input stream to upload + :stream_offset, # [Integer] Starting byte offset in stream (default: 0) + :upload_size, # [Integer, nil] Total upload bytes if known upfront + :content_type, # [String, nil] MIME type of uploaded media + :timeout, # [Numeric, nil] Total upload timeout in seconds (zero/negative treated as nil) + :start_retry_policy, # [Gapic::Common::RetryPolicy, Hash, nil] Unused; retained for config parity + :control_plane_retry_policy, # [Gapic::Common::RetryPolicy, Hash, nil] Policy or hash override for query/cancel commands + :data_plane_retry_policy, # [Gapic::Common::RetryPolicy, Hash, nil] Policy or hash override for upload/finalize + :on_progress # [Proc, nil] Callback: ->(progress) with a Progress instance + ) + end + end +end +``` +`ResumeUploadConfig` allows resuming an existing session directly using the session URL (typically obtained from `ResumeHandle#upload_url` or an error's `#resume_handle`). + +### 2.3 Protocol State (`State`) & Decisions (`Decision`) ```ruby module Gapic module Rest @@ -106,7 +130,7 @@ module Gapic end ``` -### 2.3 Resume Handle (`ResumeHandle`) +### 2.4 Resume Handle (`ResumeHandle`) ```ruby module Gapic module Rest @@ -121,8 +145,9 @@ end ``` `ResumeHandle` captures server-provided parameters that can be persisted to resume the upload session at a later time. -### 2.4 Events Vocabulary (Driver -> Core) -* `Event::StartUpload`: Start the upload session. +### 2.5 Events Vocabulary (Driver -> Core) +* `Event::StartUpload`: Start a new upload session. +* `Event::ResumeUpload.new(upload_url:, chunk_size:, upload_size:)`: Resume an existing upload session with a known upload URL. * `Event::ChunkRead.new(bytes_buffered:, eof:)`: Binary data buffered in Driver memory; reports total bytes ready in buffer and whether the stream hit EOF. * `Event::HttpResponse.new(status:, headers:, body:, error: nil)`: Dispatched for any completed HTTP exchange over the wire (including 2xx, 4xx, 5xx, or responses with missing/unexpected headers). Carries optional parsed `error` (`Gapic::Rest::Error`) when rescued from transport errors. `Core` inspects status and headers to determine protocol progression or recovery. * `Event::RequestFailed.new(kind:, message:, source_error:)`: Dispatched when an HTTP request fails to produce a usable HTTP response (e.g., request timeout, transport connection errors, or `RetryPolicy` exhaustion). @@ -132,7 +157,7 @@ end * `Event::Cancel`: Caller requested session cancellation. * `Event::GlobalDeadlineExceeded`: Absolute monotonic clock exceeded the session deadline (`@deadline`) computed at the start of `Driver#run`. -### 2.5 Instructions Vocabulary (Core -> Driver) +### 2.6 Instructions Vocabulary (Core -> Driver) * `Instruction::SendStart.new(url:, headers:, body:)`: Execute initiation request to establish upload session. * `Instruction::SendChunk.new(url:, offset:, length:, finalize:)`: Transmit buffered chunk of specified `length` starting at `offset`. If `finalize` is true, sends command `upload, finalize`. * `Instruction::SendFinalize.new(url:)`: Send standalone `finalize` command when all data bytes were already acknowledged. @@ -170,13 +195,14 @@ When `Core` resolves a recovery query or offset realignment, the Driver executes 2. **Case 2: Server Offset Behind Buffer (`server_offset < buffer_start_offset`)** * Occurs if the server rolls back beyond the retained buffer window. * If `stream.respond_to?(:seek)`: Driver seeks the stream back to `server_offset`, resets `@buffer = "".b`, and sets `buffer_start_offset = server_offset`. - * If `stream` is unseekable (e.g. Socket, Pipe, STDIN): Driver raises a terminal `UnseekableStreamError` (Category 3 failure). + * If `stream` is unseekable (e.g. Socket, Pipe, STDIN): Driver raises a terminal `UnseekableStreamError` (Category 3 failure), attaching `resume_handle`. 3. **Case 3: Server Offset Ahead of Buffer (`server_offset > buffer_end_offset`)** * Occurs when resuming an existing session or when the server processed a previously timed-out request ahead of local state. + * If total `upload_size` is known and `server_offset > upload_size`, Driver raises a terminal `StreamMismatchError` with `resume_handle`. * Driver resets `@buffer = "".b`. * Driver advances the stream to `server_offset`: * If seekable: `stream.seek(server_offset)`. - * If unseekable: Driver reads and discards `server_offset - current_stream_pos` bytes from `stream`. + * If unseekable: Driver reads and discards `server_offset - current_stream_pos` bytes from `stream`. If the stream encounters an unexpected EOF before reaching `server_offset`, Driver raises a terminal `StreamMismatchError` with `resume_handle`. * Driver sets `buffer_start_offset = server_offset`. --- @@ -242,6 +268,7 @@ Full implementation: [reference-implementation.md#3-driver-class](reference-impl | From State | Event Shape | Event & Input Payload | State Mutations | To State | Emitted Instructions & Parameters | | :--- | :--- | :--- | :--- | :--- | :--- | | **`Initializing`** | `:start_upload` | `Event::StartUpload` | `status = :starting` | `Starting` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :initiating, bytes_uploaded: 0, total_bytes: config.upload_size))`
`Instruction::SendStart.new(url: config.initial_url, headers: config.initial_headers, body: config.initial_body)` | +| **`Initializing`** | `:resume_upload` | `Event::ResumeUpload` | `upload_url = event.upload_url`
`chunk_size = event.chunk_size`
`offset = 0`
`status = :recovery` | `Recovery` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :initiating, bytes_uploaded: 0, total_bytes: event.upload_size))`
`Instruction::SendQuery.new(url: event.upload_url)` | | **`Starting`** | `:response_active` | `Event::HttpResponse(200, headers, _)` with `Status: active` | `upload_url = headers['X-Goog-Upload-URL']`
`chunk_granularity = headers['...-Granularity']&.to_i`
`chunk_size = resolve(config, chunk_granularity)`
`offset = 0`
`status = :transmission_reading` | `Transmission \| Reading from stream` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :uploading, bytes_uploaded: 0, total_bytes: config.upload_size))`
`Instruction::FillBuffer.new(target_bytesize: state.chunk_size)` | | **`Starting`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, _)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Rest::ResumableUpload::UploadRejectedError.from(event))` | | **`Starting`** | `:response_cat2` / `:response_fatal_bad_response` | `Event::HttpResponse` (Non-200; see Section 6.1) | `last_error = Gapic::Rest::ResumableUpload::BadResponseError.from(event)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | @@ -286,6 +313,7 @@ Full implementation: [reference-implementation.md#3-driver-class](reference-impl stateDiagram-v2 [*] --> Initializing Initializing --> Starting : Event::StartUpload + Initializing --> Recovery : Event::ResumeUpload Starting --> Transmission_Reading : Event::HttpResponse(200, active) state Transmission { @@ -448,11 +476,12 @@ To realign the upload state, the `Driver` processes `Instruction::RealignBuffer( * Upon executing the accompanying `Instruction::FillBuffer(target_bytesize)`, the Driver reads `target_bytesize - @buffer.bytesize` bytes from `stream` to restore `@buffer` to full `chunk_size` before transmitting. 2. **Rewind Required (`server_offset < buffer_start_offset`)**: * If `stream.respond_to?(:seek)`: the Driver seeks to `server_offset`, clears `@buffer = "".b`, and sets `buffer_start_offset = server_offset`. - * If `stream` is unseekable (e.g. Socket, Pipe, STDIN): the Driver raises terminal `UnseekableStreamError` (Category 3). + * If `stream` is unseekable (e.g. Socket, Pipe, STDIN): the Driver raises terminal `UnseekableStreamError` (Category 3), attaching `resume_handle`. 3. **Fast-Forward Required (`server_offset > buffer_end_offset`)**: + * If total `upload_size` is known and `server_offset > upload_size`: Driver raises terminal `StreamMismatchError` with `resume_handle`. * The Driver clears `@buffer = "".b`. * If `stream.respond_to?(:seek)`: seeks to `server_offset`. - * If unseekable: reads and discards `server_offset - current_stream_pos` bytes from `stream`. + * If unseekable: reads and discards `server_offset - current_stream_pos` bytes from `stream`. If the stream encounters unexpected EOF before reaching `server_offset`, Driver raises terminal `StreamMismatchError` with `resume_handle`. * The Driver sets `buffer_start_offset = server_offset`. ### 6.3 Sensible Defaults for Global Deadline diff --git a/gapic-common/design/test-plan.md b/gapic-common/design/test-plan.md index 42ca655..28da3b2 100644 --- a/gapic-common/design/test-plan.md +++ b/gapic-common/design/test-plan.md @@ -178,6 +178,12 @@ flowchart TD * **Fast-forward stream**: * *Seekable*: Seeks stream forward and resets buffer to target offset. * *Unseekable*: Reads and discards needed bytes from stream to advance to target offset. +* **Stream mismatch errors (`StreamMismatchError`)**: + * *Fast-forward unexpected EOF*: Unexpected EOF while discarding bytes from an unseekable stream raises `StreamMismatchError` with `resume_handle` and the uniform resumable suffix. + * *Server offset exceeding upload size*: Server reporting an offset exceeding known `upload_size` raises `StreamMismatchError` with `resume_handle` and the uniform resumable suffix. +* **Driver Stream Position & Resume Offset**: + * `Driver#stream_position`: Returns `@buffer_start_offset + @buffer.bytesize`. + * `ResumeUploadConfig#stream_offset`: Initializes `Driver#instance_variable_get(:@buffer_start_offset)` and `Driver#stream_position`. * **Driver Session Snapshot (`Driver#resume_handle`)**: * Returns `nil` before upload session URL is established. * Returns `ResumeHandle` snapshot during active upload progression. @@ -228,6 +234,8 @@ flowchart TD * **Multi-chunk upload**: Multi-chunk stream uploads with active status headers succeed and return the final response body String. * **Protocol recovery during chunk upload**: Missing status header on chunk response triggers `query` recovery and resumes chunk transmission from the server-confirmed offset. +* **Scripted resume upload (`test_resume_upload_success`)**: Resuming an existing session via `ResumeUploadConfig` dispatches `Event::ResumeUpload`, executes `query` command, fast-forwards stream to server-reported offset, and transmits remaining chunks with accurate `Progress` notifications. +* **Resume recovery retry (`test_resume_upload_with_409_recovery_retry`)**: HTTP 409 active response to recovery query triggers `:retry_recovery` retry query and successfully resumes once query returns 200 active. --- @@ -296,6 +304,8 @@ flowchart TD * Verifies `Driver` passes explicit `method_name` strings (`"ResumableUpload.start"`, `"ResumableUpload.upload"`) to `ClientStub#make_post_request`. * **Multi-chunk upload lifecycle (`test_multi_chunk_upload_logs_lifecycle_entries`)**: * Confirms multi-chunk upload emits `INFO` lifecycle entries for `start_session`, `begin_transmission`, and completion while suppressing per-chunk `ack_chunk` at `INFO`. +* **Resume upload lifecycle (`test_resume_upload_logs_resume_session_entry`)**: + * Confirms resume upload emits `INFO` lifecycle entry for `resume_session` with message `"Resuming upload session"`, abridged `uploadUrl`, and `chunkSize`. * **Protocol recovery logging (`test_recovery_scenario_logs_enter_recovery_and_realign`)**: * Simulates HTTP 503 during chunk upload followed by recovery query; asserts `INFO` logs include both `enter_recovery` and `realign_from_recovery`. * **Terminal failure & unmatched transition logging (`test_fatal_failure_logs_warn_with_fail_with_recipe`, `test_unmatched_transition_logs_warn_and_reraises`, `test_lifecycle_warn_includes_response_body_for_rejected_error`, `test_lifecycle_warn_includes_response_body_for_bad_response_error`, `test_lifecycle_warn_omits_response_body_when_error_lacks_it`, `test_error_info_reason_in_details_survives_in_error_and_logs`)**: diff --git a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb index 2b47567..bcfe64c 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb @@ -113,6 +113,103 @@ def initialize initial_url:, end end + ## + # Immutable configuration for resuming an existing upload session. + # + # @!attribute [r] upload_url + # @return [String] Session upload URL returned by Scotty backend + # @!attribute [r] chunk_size + # @return [Integer] Explicit chunk size in bytes (must be a positive integer) + # @!attribute [r] stream + # @return [IO] Binary input stream to upload + # @!attribute [r] stream_offset + # @return [Integer] Absolute byte offset at which the stream is currently positioned (defaults to 0) + # @!attribute [r] upload_size + # @return [Integer, nil] Total upload bytes if known upfront + # @!attribute [r] content_type + # @return [String, nil] MIME type of uploaded media + # @!attribute [r] timeout + # @return [Numeric, nil] Total upload timeout in seconds (zero/negative values treated as nil) + # @!attribute [r] start_retry_policy + # @return [Gapic::Common::RetryPolicy, Hash, nil] Unused; preserved for interface parity with + # {CompleteUploadConfig}. + # @!attribute [r] control_plane_retry_policy + # @return [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for session control commands (query/cancel). + # Passing a {Gapic::Common::RetryPolicy} replaces the default policy. + # Passing a Hash overrides specified settings while preserving unspecified defaults. + # @!attribute [r] data_plane_retry_policy + # @return [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for data transmission commands (upload/finalize). + # Passing a {Gapic::Common::RetryPolicy} replaces the default policy. + # Passing a Hash overrides specified settings while preserving unspecified defaults + # (such as retry codes and predicates). + # @!attribute [r] on_progress + # @return [Proc, nil] Callback invoked as `->(progress)` with a {Progress} instance + # + ResumeUploadConfig = Data.define( + :upload_url, + :chunk_size, + :stream, + :stream_offset, + :upload_size, + :content_type, + :timeout, + :start_retry_policy, + :control_plane_retry_policy, + :data_plane_retry_policy, + :on_progress + ) do + ## + # Initializes a new upload resume configuration. + # + # @param upload_url [String] Session upload URL + # @param chunk_size [Integer] Explicit chunk size in bytes (must be a positive integer) + # @param stream [IO] Binary input stream to upload + # @param stream_offset [Integer] Current absolute byte offset of the stream (defaults to 0) + # @param upload_size [Integer, nil] Total upload bytes if known upfront + # @param content_type [String, nil] MIME type of uploaded media + # @param timeout [Numeric, nil] Total upload timeout in seconds (zero/negative values treated as nil) + # @param start_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Unused; preserved for parity + # @param control_plane_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for control commands + # @param data_plane_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for data commands + # @param on_progress [Proc, nil] Callback invoked as `->(progress)` with a {Progress} instance + # @raise [ArgumentError] If required arguments are missing or invalid + # + def initialize upload_url:, + chunk_size:, + stream:, + stream_offset: 0, + upload_size: nil, + content_type: nil, + timeout: nil, + start_retry_policy: nil, + control_plane_retry_policy: nil, + data_plane_retry_policy: nil, + on_progress: nil + raise ArgumentError, "upload_url is required" if upload_url.nil? || upload_url.to_s.strip.empty? + unless chunk_size.is_a?(Integer) && chunk_size.positive? + raise ArgumentError, "chunk_size must be a positive integer" + end + raise ArgumentError, "stream is required" if stream.nil? + if !stream_offset.is_a?(Integer) || stream_offset.negative? + raise ArgumentError, "stream_offset must be a non-negative integer" + end + + super( + upload_url: upload_url, + chunk_size: chunk_size, + stream: stream, + stream_offset: stream_offset, + upload_size: upload_size, + content_type: content_type, + timeout: timeout, + start_retry_policy: start_retry_policy, + control_plane_retry_policy: control_plane_retry_policy, + data_plane_retry_policy: data_plane_retry_policy, + on_progress: on_progress + ) + end + end + ## # Immutable progress snapshot passed to the `on_progress` callback. # diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index 22cbc22..c131c5b 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -66,6 +66,14 @@ def resume_handle Rules.resume_handle_from @core.state end + ## + # Returns the current absolute stream position represented by the Driver buffer window. + # + # @return [Integer] Current absolute byte offset + def stream_position + @buffer_start_offset + @buffer.bytesize + end + ## # Initializes a new Resumable Upload Driver. # @@ -78,7 +86,7 @@ def initialize client_stub:, config:, core: nil, logger: nil @config = config @core = core || Core.new(config) @buffer = "".b - @buffer_start_offset = 0 + @buffer_start_offset = config.respond_to?(:stream_offset) && config.stream_offset ? config.stream_offset : 0 endpoint = client_stub.respond_to?(:endpoint) ? client_stub.endpoint : nil setup_logging logger: logger || (client_stub.respond_to?(:logger) ? client_stub.logger : nil), @@ -128,7 +136,7 @@ def self.default_data_plane_retry_policy def run @upload_log = UploadLog.new stub_logger, upload_id: LoggingConcerns.random_uuid4 @deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + resolve_timeout - pending_event = Event::StartUpload.new + pending_event = initial_event loop do instructions = dispatch_event pending_event @@ -223,6 +231,19 @@ def resolve_retry_policy value, defaults end end + ## + # @private + # Determines the initial event to dispatch based on configuration class. + # + # @return [Event::StartUpload, Event::ResumeUpload] + def initial_event + if defined?(ResumeUploadConfig) && @config.is_a?(ResumeUploadConfig) + Event::ResumeUpload.new + else + Event::StartUpload.new + end + end + ## # @private # Resolves the total upload deadline timeout in seconds. @@ -299,6 +320,13 @@ def execute_notify_progress instruction # def execute_realign_buffer instruction server_offset = instruction.server_offset + if @config.upload_size && server_offset > @config.upload_size + raise StreamMismatchError.new( + "Server reported offset #{server_offset} exceeds total upload size #{@config.upload_size}", + resume_handle: resume_handle + ) + end + buffer_start = @buffer_start_offset buffer_end = @buffer_start_offset + @buffer.bytesize @@ -371,7 +399,13 @@ def realign_fast_forward_stream server_offset, buffer_end needed_discard = server_offset - buffer_end while needed_discard.positive? chunk = @config.stream.read [needed_discard, 65_536].min - break if chunk.nil? || chunk.empty? + if chunk.nil? || chunk.empty? + raise StreamMismatchError.new( + "Stream encountered unexpected EOF during fast-forward to offset #{server_offset} " \ + "(expected at least #{needed_discard} more bytes)", + resume_handle: resume_handle + ) + end needed_discard -= chunk.bytesize end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb index bc8707c..2dc0e13 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb @@ -42,6 +42,7 @@ class UploadLog # @return [Hash] LIFECYCLE = { start_session: [:info, "Initiating resumable upload"], + resume_session: [:info, "Resuming upload session"], begin_transmission: [:info, "Upload session established"], send_chunk: [:debug, "Sending upload chunk"], send_upload_finalize: [:info, "Sending final upload chunk"], @@ -258,6 +259,11 @@ def lifecycle_fields decision, config case decision.recipe when :start_session { uploadSize: config.upload_size, requestedChunkSize: config.chunk_size } + when :resume_session + { + uploadUrl: Abridge.url(state.upload_url), + chunkSize: state.chunk_size + } when :begin_transmission { effectiveChunkSize: state.chunk_size, diff --git a/gapic-common/lib/gapic/rest/resumable_upload/events.rb b/gapic-common/lib/gapic/rest/resumable_upload/events.rb index ac215a3..17c306b 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/events.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/events.rb @@ -30,6 +30,12 @@ module Event # StartUpload = Data.define + ## + # @private + # Signals the resumption of an existing upload session. + # + ResumeUpload = Data.define + ## # @private # Signals that binary data was read from the stream into the Driver's buffer. diff --git a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb index 7eb3443..852290a 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb @@ -73,6 +73,7 @@ module Rules # @return [Array] RECIPES = [ :start_session, + :resume_session, :begin_transmission, :send_chunk, :send_upload_finalize, @@ -99,6 +100,7 @@ module Rules # @return [Hash] RECIPE_PHASES = { start_session: :initiating, + resume_session: :initiating, begin_transmission: :uploading, ack_chunk: :uploading, realign_from_recovery: :uploading, @@ -136,6 +138,8 @@ def self.shape_of event case event when Event::StartUpload, Event::StartUpload.singleton_class :start_upload + when Event::ResumeUpload, Event::ResumeUpload.singleton_class + :resume_upload when Event::ChunkRead classify_chunk_read event when Event::Cancel, Event::Cancel.singleton_class @@ -169,6 +173,8 @@ def self.decide state, event, config recipe = case [state.status, shape] in [:initializing, :start_upload] :start_session + in [:initializing, :resume_upload] + :resume_session in [:starting, :response_active] :begin_transmission in [:transmission_reading, :chunk_read_full] @@ -262,6 +268,33 @@ def self.start_session state, _event, config [next_state, instructions] end + ## + # @private + # Resumes an existing upload session by transitioning to recovery and querying backend offset. + # + # @param state [State] Current state + # @param _event [Object] Dispatched event + # @param config [ResumeUploadConfig] Resume session configuration + # @return [Array>] Tuple of [next_state, instructions] + def self.resume_session state, _event, config + next_state = state.with( + status: :recovery, + upload_url: config.upload_url, + chunk_size: config.chunk_size, + offset: 0 + ) + progress = Progress.new( + phase: :initiating, + bytes_uploaded: 0, + total_bytes: config.upload_size + ) + instructions = [ + Instruction::NotifyProgress.new(progress: progress), + Instruction::SendQuery.new(url: config.upload_url) + ] + [next_state, instructions] + end + ## # @private # Processes initiation response and begins data reading. @@ -765,6 +798,8 @@ def self.classify_request_failed event def self.classify_event_class event_class if event_class == Event::StartUpload :start_upload + elsif event_class == Event::ResumeUpload + :resume_upload elsif event_class == Event::Cancel :user_cancel elsif event_class == Event::GlobalDeadlineExceeded diff --git a/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb index 82e7312..f8c3285 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb @@ -130,4 +130,57 @@ def test_resume_handle_instantiation handle.upload_url = "https://mutated.com" end end + + def test_resume_upload_config_defaults + stream = StringIO.new "content" + config = ResumeUploadConfig.new( + upload_url: "https://upload.example.com/session1", + chunk_size: 1024, + stream: stream + ) + + assert_equal "https://upload.example.com/session1", config.upload_url + assert_equal 1024, config.chunk_size + assert_same stream, config.stream + assert_equal 0, config.stream_offset + assert_nil config.upload_size + assert_nil config.content_type + assert_nil config.timeout + assert_nil config.start_retry_policy + assert_nil config.control_plane_retry_policy + assert_nil config.data_plane_retry_policy + assert_nil config.on_progress + end + + def test_resume_upload_config_validations + stream = StringIO.new "content" + + assert_raises ArgumentError do + ResumeUploadConfig.new upload_url: nil, chunk_size: 1024, stream: stream + end + + assert_raises ArgumentError do + ResumeUploadConfig.new upload_url: " ", chunk_size: 1024, stream: stream + end + + assert_raises ArgumentError do + ResumeUploadConfig.new upload_url: "https://example.com", chunk_size: 0, stream: stream + end + + assert_raises ArgumentError do + ResumeUploadConfig.new upload_url: "https://example.com", chunk_size: -10, stream: stream + end + + assert_raises ArgumentError do + ResumeUploadConfig.new upload_url: "https://example.com", chunk_size: "1024", stream: stream + end + + assert_raises ArgumentError do + ResumeUploadConfig.new upload_url: "https://example.com", chunk_size: 1024, stream: nil + end + + assert_raises ArgumentError do + ResumeUploadConfig.new upload_url: "https://example.com", chunk_size: 1024, stream: stream, stream_offset: -1 + end + end end diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb index 6101454..c1ccdcf 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb @@ -277,13 +277,90 @@ def test_realign_buffer_fast_forward_unseekable_stream assert_equal "0123456789", stream.read(10) end + def test_driver_stream_position_tracking + stream = StringIO.new "0123456789" * 10 + driver = build_driver stream: stream + assert_equal 0, driver.stream_position + + driver.instance_variable_set :@buffer_start_offset, 100 + driver.instance_variable_set :@buffer, "01234".b + assert_equal 105, driver.stream_position + end + + def test_driver_honors_stream_offset_from_resume_config + stream = StringIO.new "0123456789" + resume_config = ResumeUploadConfig.new( + upload_url: "https://upload.example.com/session_resume", + chunk_size: 256, + stream: stream, + stream_offset: 500 + ) + driver = Driver.new client_stub: @dummy_client, config: resume_config + + assert_equal 500, driver.instance_variable_get(:@buffer_start_offset) + assert_equal 500, driver.stream_position + assert_equal "".b, driver.instance_variable_get(:@buffer) + end + + def test_fast_forward_unseekable_stream_raises_stream_mismatch_on_unexpected_eof + # Stream has only 20 bytes total + stream = UnseekableStream.new "01234567890123456789" + resume_config = ResumeUploadConfig.new( + upload_url: "https://upload.example.com/session_resume", + chunk_size: 256, + stream: stream, + stream_offset: 0 + ) + driver = Driver.new client_stub: @dummy_client, config: resume_config + driver.core.instance_variable_set( + :@state, + driver.core.state.with(status: :recovery, upload_url: "https://upload.example.com/session_resume", chunk_size: 256) + ) + driver.instance_variable_set :@buffer, "".b + driver.instance_variable_set :@buffer_start_offset, 0 + + # Server offset is 50, but stream only has 20 bytes -> EOF hit during discard + err = assert_raises StreamMismatchError do + driver.send :execute_realign_buffer, Instruction::RealignBuffer.new(server_offset: 50) + end + + refute_nil err.resume_handle + assert_equal "https://upload.example.com/session_resume", err.resume_handle.upload_url + assert_includes err.message, "unexpected EOF during fast-forward" + assert_includes err.message, "(upload_session is resumable: see #resume_handle)" + end + + def test_realign_buffer_raises_stream_mismatch_when_server_offset_exceeds_upload_size + stream = StringIO.new "data" + resume_config = ResumeUploadConfig.new( + upload_url: "https://upload.example.com/session_resume", + chunk_size: 256, + stream: stream, + upload_size: 500 + ) + driver = Driver.new client_stub: @dummy_client, config: resume_config + driver.core.instance_variable_set( + :@state, + driver.core.state.with(status: :recovery, upload_url: "https://upload.example.com/session_resume", chunk_size: 256) + ) + + err = assert_raises StreamMismatchError do + driver.send :execute_realign_buffer, Instruction::RealignBuffer.new(server_offset: 600) + end + + refute_nil err.resume_handle + assert_equal "https://upload.example.com/session_resume", err.resume_handle.upload_url + assert_includes err.message, "Server reported offset 600 exceeds total upload size 500" + assert_includes err.message, "(upload_session is resumable: see #resume_handle)" + end + private def build_driver stream: config = CompleteUploadConfig.new( initial_url: "https://example.com/upload", stream: stream, - upload_size: 1000, + upload_size: 2000, chunk_size: 100 ) Driver.new client_stub: @dummy_client, config: config diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb index 1f539e0..e8fb66c 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb @@ -146,6 +146,45 @@ def test_recovery_scenario_logs_enter_recovery_and_realign assert_includes info_recipes, "realign_from_recovery" end + def test_resume_upload_logs_resume_session_entry + recording = RecordingLogger.new + responses = [ + FakeResponse.new( + 200, + { + "X-Goog-Upload-Status" => "active", + "X-Goog-Upload-Size-Received" => "0" + }, + "" + ), + FakeResponse.new( + 200, + { "X-Goog-Upload-Status" => "final" }, + "done" + ) + ] + + stub = FakeStub.new responses + config = ResumeUploadConfig.new( + upload_url: "https://storage.googleapis.com/session?id=123", + stream: StringIO.new("hello world"), + upload_size: 11, + chunk_size: 256 + ) + + driver = Driver.new client_stub: stub, config: config, logger: recording + driver.run + + resume_entry = recording.entries.find do |e| + e.severity == Logger::INFO && e.message.fields["recipe"] == "resume_session" + end + refute_nil resume_entry + assert_equal Logger::INFO, resume_entry.severity + assert_equal "Resuming upload session", resume_entry.message.message + assert_equal 256, resume_entry.message.fields["chunkSize"] + assert_includes resume_entry.message.fields["uploadUrl"], "session?id=" + end + def test_fatal_failure_logs_warn_with_fail_with_recipe recording = RecordingLogger.new responses = [ diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb index 20f9e64..c2c0e4e 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb @@ -108,6 +108,96 @@ def test_upload_recovers_when_chunk_response_lacks_status_header ], progress_records end + def test_resume_upload_success + progress_records = [] + responses = [ + FakeResponse.new( + status: 200, + headers: { + "X-Goog-Upload-Status" => "active", + "X-Goog-Upload-Size-Received" => "4" + }, + body: "" + ), + FakeResponse.new(status: 200, headers: { "X-Goog-Upload-Status" => "active" }, body: ""), + FakeResponse.new(status: 200, headers: { "X-Goog-Upload-Status" => "final" }, body: '{"done":true}') + ] + stub = FakeClientStub.new responses + config = ResumeUploadConfig.new( + upload_url: "https://example.com/session/1", + chunk_size: 4, + stream: StringIO.new("0123456789"), + stream_offset: 0, + upload_size: 10, + on_progress: ->(p) { progress_records << p } + ) + + driver = Driver.new client_stub: stub, config: config + result = driver.run + + assert_equal '{"done":true}', result + assert_equal 3, stub.requests.size + assert_query_request stub.requests[0] + assert_chunk_request stub.requests[1], offset: "4", length: "4", body: "4567", finalize: false + assert_chunk_request stub.requests[2], offset: "8", length: "2", body: "89", finalize: true + + assert_equal [ + Progress.new(phase: :initiating, bytes_uploaded: 0, total_bytes: 10), + Progress.new(phase: :uploading, bytes_uploaded: 4, total_bytes: 10), + Progress.new(phase: :uploading, bytes_uploaded: 8, total_bytes: 10), + Progress.new(phase: :finalizing, bytes_uploaded: 8, total_bytes: 10), + Progress.new(phase: :completed, bytes_uploaded: 10, total_bytes: 10) + ], progress_records + end + + def test_resume_upload_with_409_recovery_retry + progress_records = [] + responses = [ + FakeResponse.new( + status: 409, + headers: { "X-Goog-Upload-Status" => "active" }, + body: "Conflict" + ), + FakeResponse.new( + status: 200, + headers: { + "X-Goog-Upload-Status" => "active", + "X-Goog-Upload-Size-Received" => "4" + }, + body: "" + ), + FakeResponse.new(status: 200, headers: { "X-Goog-Upload-Status" => "active" }, body: ""), + FakeResponse.new(status: 200, headers: { "X-Goog-Upload-Status" => "final" }, body: '{"done":true}') + ] + stub = FakeClientStub.new responses + config = ResumeUploadConfig.new( + upload_url: "https://example.com/session/1", + chunk_size: 4, + stream: StringIO.new("0123456789"), + stream_offset: 0, + upload_size: 10, + on_progress: ->(p) { progress_records << p } + ) + + driver = Driver.new client_stub: stub, config: config + result = driver.run + + assert_equal '{"done":true}', result + assert_equal 4, stub.requests.size + assert_query_request stub.requests[0] + assert_query_request stub.requests[1] + assert_chunk_request stub.requests[2], offset: "4", length: "4", body: "4567", finalize: false + assert_chunk_request stub.requests[3], offset: "8", length: "2", body: "89", finalize: true + + assert_equal [ + Progress.new(phase: :initiating, bytes_uploaded: 0, total_bytes: 10), + Progress.new(phase: :uploading, bytes_uploaded: 4, total_bytes: 10), + Progress.new(phase: :uploading, bytes_uploaded: 8, total_bytes: 10), + Progress.new(phase: :finalizing, bytes_uploaded: 8, total_bytes: 10), + Progress.new(phase: :completed, bytes_uploaded: 10, total_bytes: 10) + ], progress_records + end + private def build_scripted_responses diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb index d3c95dc..e3284b7 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb @@ -152,6 +152,8 @@ def test_classify_http_response_header_key_casing def test_shape_of_control_events assert_equal :start_upload, Rules.shape_of(Event::StartUpload.new) assert_equal :start_upload, Rules.shape_of(Event::StartUpload) + assert_equal :resume_upload, Rules.shape_of(Event::ResumeUpload.new) + assert_equal :resume_upload, Rules.shape_of(Event::ResumeUpload) assert_equal :user_cancel, Rules.shape_of(Event::Cancel.new) assert_equal :user_cancel, Rules.shape_of(Event::Cancel) assert_equal :global_deadline_exceeded, Rules.shape_of(Event::GlobalDeadlineExceeded.new) diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb index 42ecb91..6b872bc 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb @@ -58,6 +58,26 @@ def test_row_initializing_start_upload assert_instance_of Instruction::SendStart, decision.instructions[1] end + def test_row_initializing_resume_upload + resume_config = ResumeUploadConfig.new( + upload_url: "https://example.com/upload/session1", + chunk_size: 512, + stream: StringIO.new("data"), + upload_size: 1024 + ) + decision = Rules.decide State.new(status: :initializing), Event::ResumeUpload.new, resume_config + assert_equal :initializing, decision.from_status + assert_equal :resume_upload, decision.shape + assert_equal :resume_session, decision.recipe + assert_equal :recovery, decision.next_state.status + assert_equal "https://example.com/upload/session1", decision.next_state.upload_url + assert_equal 512, decision.next_state.chunk_size + assert_equal 0, decision.next_state.offset + assert_recipe_progress_notification decision + assert_instance_of Instruction::SendQuery, decision.instructions[1] + assert_equal "https://example.com/upload/session1", decision.instructions[1].url + end + def test_row_starting_response_active active_resp = Event::HttpResponse.new( status: 200, From 549485e34a07cf20da8cd7881b6fd7bd73f2e554 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Thu, 10 Sep 2026 04:34:39 +0000 Subject: [PATCH 57/79] fix: RequestFailedError --- gapic-common/design/implementation-guide.md | 5 +- gapic-common/design/test-plan.md | 10 +- .../lib/gapic/rest/resumable_upload/errors.rb | 84 ++++++++++++++++- .../lib/gapic/rest/resumable_upload/rules.rb | 5 +- .../resumable_upload/driver_buffer_test.rb | 20 +++- .../resumable_upload/driver_retry_test.rb | 3 +- .../rest/resumable_upload/rules_error_test.rb | 91 +++++++++++++++---- 7 files changed, 186 insertions(+), 32 deletions(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index b6bde4e..28c92db 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -451,9 +451,10 @@ Terminal errors provide actionable context so downstream SDK callers can inspect * `UnseekableStreamError < Gapic::Common::Error`: Stream rewind required on an unseekable stream; includes `HasResumeHandle`. * `InvalidTransitionError < Gapic::Common::Error`: Unexpected event dispatched for state; includes `HasResumeHandle`. * `StreamMismatchError < Gapic::Common::Error`: Stream content or length does not match resumed upload specifications; includes `HasResumeHandle`. + * `RequestFailedError < Gapic::Common::Error`: Terminal HTTP request failure (e.g. transport connection failure, request timeout, or retries exhausted). Retains `attr_reader :cause` returning the underlying error, preserves REST error attributes (`status_code`, `status`, `details`, `headers`) when available, and includes `HasResumeHandle`. * **Resume Handle Propagation (`HasResumeHandle`)**: - * The `HasResumeHandle` mixin exposes `attr_reader :resume_handle` returning a `ResumeHandle` (or `nil` if session initiation was incomplete). - * Whenever `resume_handle` is non-nil, the uniform suffix `" (upload_session is resumable: see #resume_handle)"` is automatically appended to the error message. + * The `HasResumeHandle` mixin exposes `attr_reader :resume_handle` returning a `ResumeHandle` (or `nil` if session initiation was incomplete or if the session was `:rejected` or `:cancelled`). + * Whenever `resume_handle` is non-nil, the uniform suffix `" (upload session is resumable: see #resume_handle)"` is automatically appended to the error message. * **Metadata Sourcing & De-prefixing**: * When `event.error` is present (from `Gapic::Rest::Error.wrap_faraday_error`), factories source `status_code`, `status`, `details`/`status_details`, and `headers`/`header`. * The prefix literal `Gapic::Rest::Error::REST_ERROR_PREFIX` (`"An error has occurred when making a REST request"`) is stripped from `event.error.message` to avoid redundant prefixes. diff --git a/gapic-common/design/test-plan.md b/gapic-common/design/test-plan.md index 28da3b2..17d4095 100644 --- a/gapic-common/design/test-plan.md +++ b/gapic-common/design/test-plan.md @@ -149,13 +149,13 @@ flowchart TD * **Actionable description for non-HTTP unexpected events**: * Stream chunk read while in `:starting` raises message stating `"initiating upload session: received unexpected stream chunk read (512 bytes, eof: false)"` with `err.response == nil`. * **Resume Handle & Error Metadata (`HasResumeHandle`)**: - * `HasResumeHandle` mixin inclusion verified on `BadResponseError`, `DeadlineExceededError`, `UnseekableStreamError`, `InvalidTransitionError`, and `StreamMismatchError`. + * `HasResumeHandle` mixin inclusion verified on `BadResponseError`, `DeadlineExceededError`, `UnseekableStreamError`, `InvalidTransitionError`, `StreamMismatchError`, and `RequestFailedError`. * `HasResumeHandle` explicitly refuted on terminal dead-session errors (`UploadRejectedError`, `UploadCancelledError`). - * `Rules.resume_handle_from`: Returns `nil` when state is `nil` or `upload_url` is `nil`; returns populated `ResumeHandle` with `upload_url` and `chunk_size` when established. - * Resumable error suffix: When `resume_handle` is present, uniform suffix `" (upload_session is resumable: see #resume_handle)"` is appended to the message on `DeadlineExceededError`, `BadResponseError`, `InvalidTransitionError`, `UnseekableStreamError`, and `StreamMismatchError`. + * `Rules.resume_handle_from`: Returns `nil` when state is `nil`, `upload_url` is `nil`, or status is `:rejected` or `:cancelled`; returns populated `ResumeHandle` with `upload_url` and `chunk_size` when established. + * Resumable error suffix: When `resume_handle` is present, uniform suffix `" (upload session is resumable: see #resume_handle)"` is appended to the message on `DeadlineExceededError`, `BadResponseError`, `InvalidTransitionError`, `UnseekableStreamError`, `StreamMismatchError`, and `RequestFailedError`. * Suffix omission: When `resume_handle` is `nil` (e.g. before session creation), error message omits the resumable suffix. * Terminal dead sessions: `UploadRejectedError` and `UploadCancelledError` do not respond to `:resume_handle` and do not include the suffix. - * `StreamMismatchError`: Verified with `.new` and `.from`, ensuring `resume_handle` and formatted messages with/without handle. + * `StreamMismatchError` & `RequestFailedError`: Verified with `.new` and `.from`, ensuring `cause`, `resume_handle`, and formatted messages with/without handle. --- @@ -242,7 +242,7 @@ flowchart TD ### 3.8 Driver Initiation & Query Retries (`driver_retry_test.rb`) * **Session initiation retry loop**: Missing status header on HTTP 200 during `start` triggers `start_retry_policy` and succeeds upon header arrival. -* **Initiation retry exhaustion on 200**: Continuous missing status headers on HTTP 200 during `start` exhaust retries and dispatch `Event::RequestFailed(kind: :retries_exhausted)` with `"Missing X-Goog-Upload-Status header in start response"`. +* **Initiation retry exhaustion on 200**: Continuous missing status headers on HTTP 200 during `start` exhaust retries and dispatch `Event::RequestFailed(kind: :retries_exhausted)`, raising `RequestFailedError` with message `"Missing X-Goog-Upload-Status header in start response"` and root cause `BadResponseError`. * **Initiation retry exhaustion on non-200**: Continuous non-200 HTTP responses (e.g. 503) lacking status header exhaust retries and return `Event::HttpResponse` directly, allowing `Rules` to raise `BadResponseError` preserving the HTTP status code and message. * **Control plane non-retry**: Missing status header on `query` does not retry inside `execute_send_query`, returning `Event::HttpResponse` immediately to drive protocol recovery. diff --git a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb index 91ea986..c910d3a 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb @@ -154,7 +154,7 @@ module HasResumeHandle ## # Suffix appended to error message when a resume handle is present. # @return [String] - RESUMABLE_SUFFIX = " (upload_session is resumable: see #resume_handle)" + RESUMABLE_SUFFIX = " (upload session is resumable: see #resume_handle)" ## # Appends the uniform resumable suffix if resume_handle is non-nil. @@ -441,6 +441,88 @@ def self.from message = "Upload deadline exceeded", root_cause: nil, resume_hand new message, root_cause: root_cause, resume_handle: resume_handle end end + + ## + # Raised when an HTTP request fails (e.g. transport connection failure, request timeout, or retries exhausted). + # + # @!attribute [r] cause + # @return [StandardError, nil] Underlying cause exception + # @!attribute [r] resume_handle + # @return [Gapic::Rest::ResumableUpload::ResumeHandle, nil] Associated resume handle + # @!attribute [r] status_code + # @return [Integer, nil] HTTP status code if cause was a REST error + # @!attribute [r] status + # @return [String, nil] Status description if cause was a REST error + # @!attribute [r] details + # @return [Object, nil] Error details if cause was a REST error + # @!attribute [r] headers + # @return [Object, nil] Response headers if cause was a REST error + # + class RequestFailedError < Gapic::Common::Error + include HasResumeHandle + + # @return [Integer, nil] + attr_reader :status_code + + # @return [String, nil] + attr_reader :status + + # @return [Object, nil] + attr_reader :details + + # @return [Object, nil] + attr_reader :headers + + ## + # Initializes a new RequestFailedError. + # + # @param message [String, nil] Error message + # @param cause [StandardError, nil] Underlying cause exception + # @param resume_handle [Gapic::Rest::ResumableUpload::ResumeHandle, nil] Associated resume handle + # @param status_code [Integer, nil] HTTP status code + # @param status [String, nil] Status description + # @param details [Object, nil] Error details + # @param headers [Object, nil] Response headers + def initialize message = nil, cause: nil, resume_handle: nil, + status_code: nil, status: nil, details: nil, headers: nil + @cause = cause + @resume_handle = resume_handle + @status_code = status_code || (cause.respond_to?(:status_code) ? cause.status_code : nil) + @status = status || (cause.respond_to?(:status) ? cause.status : nil) + @details = details || (cause.respond_to?(:details) ? cause.details : nil) + @headers = headers || (cause.respond_to?(:headers) ? cause.headers : nil) + msg = message || cause&.message || "Request failed" + super HasResumeHandle.append_suffix(msg, resume_handle) + end + + ## + # Returns the underlying cause exception. + # + # @return [StandardError, nil] + def cause + @cause || super + end + + ## + # Creates a RequestFailedError from a failure event or error. + # + # @param event_or_error [Event::RequestFailed, StandardError] Source event or error + # @param message [String, nil] Optional message override + # @param resume_handle [Gapic::Rest::ResumableUpload::ResumeHandle, nil] Associated resume handle + # @return [RequestFailedError] + def self.from event_or_error, message: nil, resume_handle: nil + if event_or_error.respond_to? :source_error + cause = event_or_error.source_error + msg = message || event_or_error.message || cause&.message || "Request failed" + new msg, cause: cause, resume_handle: resume_handle + elsif event_or_error.is_a? Exception + msg = message || event_or_error.message || "Request failed" + new msg, cause: event_or_error, resume_handle: resume_handle + else + new message || event_or_error.to_s, resume_handle: resume_handle + end + end + end end end end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb index 852290a..4fa4b9e 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb @@ -575,7 +575,7 @@ def self.cancel_session state, _event, config # @param state [State] Protocol state # @return [ResumeHandle, nil] Resume handle if upload URL is established, or nil def self.resume_handle_from state - return nil if state.nil? || state.upload_url.nil? + return nil if state.nil? || state.upload_url.nil? || [:rejected, :cancelled].include?(state.status) ResumeHandle.new upload_url: state.upload_url, chunk_size: state.chunk_size end @@ -645,7 +645,8 @@ def self.fail_with_bad_response state, event, _config # @param _config [CompleteUploadConfig] Session configuration # @return [Array>] Tuple of [next_state, instructions] def self.fail_with_request_error state, event, _config - err = event.source_error || Gapic::Common::Error.new(event.message || "Request failed") + handle = resume_handle_from state + err = RequestFailedError.from event, resume_handle: handle next_state = state.with( status: :error, in_flight_length: 0, diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb index c1ccdcf..7dda4b5 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb @@ -199,7 +199,7 @@ def test_realign_buffer_rewind_unseekable_stream_raises_error assert_includes err.message, "offset 500" assert_includes err.message, "buffered from 1000" assert_nil err.resume_handle - refute_includes err.message, "(upload_session is resumable: see #resume_handle)" + refute_includes err.message, "(upload session is resumable: see #resume_handle)" end def test_realign_buffer_rewind_unseekable_stream_with_resume_handle @@ -221,7 +221,7 @@ def test_realign_buffer_rewind_unseekable_stream_with_resume_handle assert_equal 256, err.resume_handle.chunk_size assert_includes err.message, "offset 500" assert_includes err.message, "buffered from 1000" - assert_includes err.message, "(upload_session is resumable: see #resume_handle)" + assert_includes err.message, "(upload session is resumable: see #resume_handle)" end def test_driver_resume_handle_property @@ -237,6 +237,18 @@ def test_driver_resume_handle_property refute_nil handle assert_equal "https://upload.example.com/session_2", handle.upload_url assert_equal 512, handle.chunk_size + + driver.core.instance_variable_set( + :@state, + driver.core.state.with(status: :rejected, upload_url: "https://upload.example.com/session_2", chunk_size: 512) + ) + assert_nil driver.resume_handle + + driver.core.instance_variable_set( + :@state, + driver.core.state.with(status: :cancelled, upload_url: "https://upload.example.com/session_2", chunk_size: 512) + ) + assert_nil driver.resume_handle end # ============================================================================ @@ -327,7 +339,7 @@ def test_fast_forward_unseekable_stream_raises_stream_mismatch_on_unexpected_eof refute_nil err.resume_handle assert_equal "https://upload.example.com/session_resume", err.resume_handle.upload_url assert_includes err.message, "unexpected EOF during fast-forward" - assert_includes err.message, "(upload_session is resumable: see #resume_handle)" + assert_includes err.message, "(upload session is resumable: see #resume_handle)" end def test_realign_buffer_raises_stream_mismatch_when_server_offset_exceeds_upload_size @@ -351,7 +363,7 @@ def test_realign_buffer_raises_stream_mismatch_when_server_offset_exceeds_upload refute_nil err.resume_handle assert_equal "https://upload.example.com/session_resume", err.resume_handle.upload_url assert_includes err.message, "Server reported offset 600 exceeds total upload size 500" - assert_includes err.message, "(upload_session is resumable: see #resume_handle)" + assert_includes err.message, "(upload session is resumable: see #resume_handle)" end private diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb index 6e61616..5438685 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb @@ -88,12 +88,13 @@ def test_start_exhausts_retries_when_200_responses_continually_lack_status_heade ) driver = Driver.new client_stub: stub, config: config - err = assert_raises BadResponseError do + err = assert_raises RequestFailedError do driver.run end assert_match(/Missing X-Goog-Upload-Status/, err.message) assert_equal 200, err.status_code + assert_instance_of BadResponseError, err.cause assert stub.requests.size > 1 end diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb index 246b9e6..d236998 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb @@ -67,9 +67,11 @@ def test_transition_starting_request_failed next_state, instructions = Rules.step state, failed, @config assert_equal :error, next_state.status - assert_equal err, next_state.last_error + assert_instance_of RequestFailedError, next_state.last_error + assert_equal err, next_state.last_error.cause assert_equal 1, instructions.size assert_instance_of Instruction::TerminateFailure, instructions.first + assert_equal next_state.last_error, instructions.first.error end def test_transition_starting_timeout_terminates_failure @@ -80,10 +82,11 @@ def test_transition_starting_timeout_terminates_failure assert_equal :error, next_state.status assert_equal 0, next_state.in_flight_length - assert_equal err, next_state.last_error + assert_instance_of RequestFailedError, next_state.last_error + assert_equal err, next_state.last_error.cause assert_equal 1, instructions.size assert_instance_of Instruction::TerminateFailure, instructions.first - assert_equal err, instructions.first.error + assert_equal next_state.last_error, instructions.first.error end def test_transition_transmission_sending_retries_exhausted_terminates_failure @@ -95,10 +98,11 @@ def test_transition_transmission_sending_retries_exhausted_terminates_failure assert_equal :error, next_state.status assert_equal 0, next_state.in_flight_length - assert_equal err, next_state.last_error + assert_instance_of RequestFailedError, next_state.last_error + assert_equal err, next_state.last_error.cause assert_equal 1, instructions.size assert_instance_of Instruction::TerminateFailure, instructions.first - assert_equal err, instructions.first.error + assert_equal next_state.last_error, instructions.first.error end def test_transition_recovery_timeout_terminates_failure @@ -109,10 +113,11 @@ def test_transition_recovery_timeout_terminates_failure assert_equal :error, next_state.status assert_equal 0, next_state.in_flight_length - assert_equal err, next_state.last_error + assert_instance_of RequestFailedError, next_state.last_error + assert_equal err, next_state.last_error.cause assert_equal 1, instructions.size assert_instance_of Instruction::TerminateFailure, instructions.first - assert_equal err, instructions.first.error + assert_equal next_state.last_error, instructions.first.error end def test_transition_global_deadline_exceeded @@ -284,11 +289,13 @@ def test_error_class_inheritance_hierarchy assert_operator BadResponseError, :<, Gapic::Rest::Error assert_operator StreamMismatchError, :<, Gapic::Common::Error + assert_operator RequestFailedError, :<, Gapic::Common::Error assert_operator HasResumeHandle, :===, BadResponseError.new assert_operator HasResumeHandle, :===, DeadlineExceededError.new assert_operator HasResumeHandle, :===, UnseekableStreamError.new assert_operator HasResumeHandle, :===, InvalidTransitionError.new("invalid") assert_operator HasResumeHandle, :===, StreamMismatchError.new + assert_operator HasResumeHandle, :===, RequestFailedError.new("failed") refute_operator HasResumeHandle, :===, UploadRejectedError.new refute_operator HasResumeHandle, :===, UploadCancelledError.new end @@ -296,6 +303,8 @@ def test_error_class_inheritance_hierarchy def test_rules_resume_handle_from assert_nil Rules.resume_handle_from(nil) assert_nil Rules.resume_handle_from(State.new(status: :starting, upload_url: nil)) + assert_nil Rules.resume_handle_from(State.new(status: :rejected, upload_url: "https://upload.example.com/id123")) + assert_nil Rules.resume_handle_from(State.new(status: :cancelled, upload_url: "https://upload.example.com/id123")) state = State.new status: :transmission_sending, upload_url: "https://upload.example.com/id123", chunk_size: 1024 handle = Rules.resume_handle_from state @@ -319,7 +328,7 @@ def test_resume_handle_present_on_errors_when_upload_url_set refute_nil deadline_err.resume_handle assert_equal "https://upload.example.com/session_abc", deadline_err.resume_handle.upload_url assert_equal 512, deadline_err.resume_handle.chunk_size - assert_includes deadline_err.message, "(upload_session is resumable: see #resume_handle)" + assert_includes deadline_err.message, "(upload session is resumable: see #resume_handle)" # 2. Bad response resp = Event::HttpResponse.new status: 401, headers: {}, body: "Fatal 401" @@ -330,7 +339,7 @@ def test_resume_handle_present_on_errors_when_upload_url_set refute_nil bad_resp_err.resume_handle assert_equal "https://upload.example.com/session_abc", bad_resp_err.resume_handle.upload_url assert_equal 512, bad_resp_err.resume_handle.chunk_size - assert_includes bad_resp_err.message, "(upload_session is resumable: see #resume_handle)" + assert_includes bad_resp_err.message, "(upload session is resumable: see #resume_handle)" # 3. Unmatched transition unmatched_err = assert_raises InvalidTransitionError do @@ -339,7 +348,23 @@ def test_resume_handle_present_on_errors_when_upload_url_set refute_nil unmatched_err.resume_handle assert_equal "https://upload.example.com/session_abc", unmatched_err.resume_handle.upload_url assert_equal 512, unmatched_err.resume_handle.chunk_size - assert_includes unmatched_err.message, "(upload_session is resumable: see #resume_handle)" + assert_includes unmatched_err.message, "(upload session is resumable: see #resume_handle)" + + # 4. Request failed (retries exhausted) + req_failed = Event::RequestFailed.new( + kind: :retries_exhausted, + message: "Connection reset", + source_error: StandardError.new("reset") + ) + next_state, = Rules.step state, req_failed, @config + assert_equal :error, next_state.status + req_err = next_state.last_error + assert_instance_of RequestFailedError, req_err + refute_nil req_err.resume_handle + assert_equal "https://upload.example.com/session_abc", req_err.resume_handle.upload_url + assert_equal 512, req_err.resume_handle.chunk_size + assert_equal "reset", req_err.cause.message + assert_includes req_err.message, "(upload session is resumable: see #resume_handle)" end def test_resume_handle_nil_on_errors_before_session_created @@ -350,7 +375,7 @@ def test_resume_handle_nil_on_errors_before_session_created assert_equal :error, next_state.status deadline_err = next_state.last_error assert_nil deadline_err.resume_handle - refute_includes deadline_err.message, "(upload_session is resumable: see #resume_handle)" + refute_includes deadline_err.message, "(upload session is resumable: see #resume_handle)" # 2. Bad response before session creation resp = Event::HttpResponse.new status: 503, headers: {}, body: "Init failed" @@ -358,14 +383,27 @@ def test_resume_handle_nil_on_errors_before_session_created assert_equal :error, next_state.status bad_resp_err = next_state.last_error assert_nil bad_resp_err.resume_handle - refute_includes bad_resp_err.message, "(upload_session is resumable: see #resume_handle)" + refute_includes bad_resp_err.message, "(upload session is resumable: see #resume_handle)" # 3. Unmatched transition before session creation unmatched_err = assert_raises InvalidTransitionError do Rules.step state, Object.new, @config end assert_nil unmatched_err.resume_handle - refute_includes unmatched_err.message, "(upload_session is resumable: see #resume_handle)" + refute_includes unmatched_err.message, "(upload session is resumable: see #resume_handle)" + + # 4. Request failed before session creation + req_failed = Event::RequestFailed.new( + kind: :connection_failed, + message: "Connection reset", + source_error: StandardError.new("reset") + ) + next_state, = Rules.step state, req_failed, @config + assert_equal :error, next_state.status + req_err = next_state.last_error + assert_instance_of RequestFailedError, req_err + assert_nil req_err.resume_handle + refute_includes req_err.message, "(upload session is resumable: see #resume_handle)" end def test_resume_handle_absent_on_rejected_and_cancelled @@ -383,7 +421,7 @@ def test_resume_handle_absent_on_rejected_and_cancelled rejected_err = next_state.last_error assert_instance_of UploadRejectedError, rejected_err refute_respond_to rejected_err, :resume_handle - refute_includes rejected_err.message, "(upload_session is resumable: see #resume_handle)" + refute_includes rejected_err.message, "(upload session is resumable: see #resume_handle)" # 2. Cancelled error cancelling_state = state.with status: :cancelling @@ -394,7 +432,7 @@ def test_resume_handle_absent_on_rejected_and_cancelled cancelled_err = next_state.last_error assert_instance_of UploadCancelledError, cancelled_err refute_respond_to cancelled_err, :resume_handle - refute_includes cancelled_err.message, "(upload_session is resumable: see #resume_handle)" + refute_includes cancelled_err.message, "(upload session is resumable: see #resume_handle)" end def test_stream_mismatch_error_behavior @@ -403,14 +441,33 @@ def test_stream_mismatch_error_behavior assert_instance_of StreamMismatchError, err_with_handle assert_equal handle, err_with_handle.resume_handle - assert_equal "Stream too short (upload_session is resumable: see #resume_handle)", err_with_handle.message + assert_equal "Stream too short (upload session is resumable: see #resume_handle)", err_with_handle.message err_from = StreamMismatchError.from "Stream corrupted", resume_handle: handle assert_equal handle, err_from.resume_handle - assert_equal "Stream corrupted (upload_session is resumable: see #resume_handle)", err_from.message + assert_equal "Stream corrupted (upload session is resumable: see #resume_handle)", err_from.message err_without_handle = StreamMismatchError.new "No handle" assert_nil err_without_handle.resume_handle assert_equal "No handle", err_without_handle.message end + + def test_request_failed_error_behavior + handle = ResumeHandle.new upload_url: "https://upload.example.com/resume", chunk_size: 256 + cause = Gapic::Rest::Error.new "Underlying Faraday error", 500, status: "INTERNAL", details: ["foo"], headers: { "k" => "v" } + + err_with_handle = RequestFailedError.from cause, resume_handle: handle + assert_instance_of RequestFailedError, err_with_handle + assert_equal cause, err_with_handle.cause + assert_equal handle, err_with_handle.resume_handle + assert_equal 500, err_with_handle.status_code + assert_equal "INTERNAL", err_with_handle.status + assert_equal ["foo"], err_with_handle.details + assert_equal({ "k" => "v" }, err_with_handle.headers) + assert_equal "Underlying Faraday error (upload session is resumable: see #resume_handle)", err_with_handle.message + + err_without_handle = RequestFailedError.from cause + assert_nil err_without_handle.resume_handle + assert_equal "Underlying Faraday error", err_without_handle.message + end end From 8e89fef1c7670acb9ae40f2394ff3e8100571e60 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Thu, 10 Sep 2026 04:44:26 +0000 Subject: [PATCH 58/79] guard against over-forwarding the stream --- gapic-common/design/implementation-guide.md | 2 ++ gapic-common/design/test-plan.md | 1 + .../lib/gapic/rest/resumable_upload/driver.rb | 15 +++++++++++- .../resumable_upload/driver_buffer_test.rb | 24 +++++++++++++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index 28c92db..d2bacd9 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -199,6 +199,7 @@ When `Core` resolves a recovery query or offset realignment, the Driver executes 3. **Case 3: Server Offset Ahead of Buffer (`server_offset > buffer_end_offset`)** * Occurs when resuming an existing session or when the server processed a previously timed-out request ahead of local state. * If total `upload_size` is known and `server_offset > upload_size`, Driver raises a terminal `StreamMismatchError` with `resume_handle`. + * If `upload_size` is `nil` and `stream.respond_to?(:size)` and `server_offset > stream.size`, Driver raises a terminal `StreamMismatchError` with `resume_handle` (preventing seek past EOF from silently succeeding on seekable streams). * Driver resets `@buffer = "".b`. * Driver advances the stream to `server_offset`: * If seekable: `stream.seek(server_offset)`. @@ -480,6 +481,7 @@ To realign the upload state, the `Driver` processes `Instruction::RealignBuffer( * If `stream` is unseekable (e.g. Socket, Pipe, STDIN): the Driver raises terminal `UnseekableStreamError` (Category 3), attaching `resume_handle`. 3. **Fast-Forward Required (`server_offset > buffer_end_offset`)**: * If total `upload_size` is known and `server_offset > upload_size`: Driver raises terminal `StreamMismatchError` with `resume_handle`. + * If `upload_size` is `nil` and `stream.respond_to?(:size)` and `server_offset > stream.size`: Driver raises terminal `StreamMismatchError` with `resume_handle`. * The Driver clears `@buffer = "".b`. * If `stream.respond_to?(:seek)`: seeks to `server_offset`. * If unseekable: reads and discards `server_offset - current_stream_pos` bytes from `stream`. If the stream encounters unexpected EOF before reaching `server_offset`, Driver raises terminal `StreamMismatchError` with `resume_handle`. diff --git a/gapic-common/design/test-plan.md b/gapic-common/design/test-plan.md index 17d4095..9b80c29 100644 --- a/gapic-common/design/test-plan.md +++ b/gapic-common/design/test-plan.md @@ -181,6 +181,7 @@ flowchart TD * **Stream mismatch errors (`StreamMismatchError`)**: * *Fast-forward unexpected EOF*: Unexpected EOF while discarding bytes from an unseekable stream raises `StreamMismatchError` with `resume_handle` and the uniform resumable suffix. * *Server offset exceeding upload size*: Server reporting an offset exceeding known `upload_size` raises `StreamMismatchError` with `resume_handle` and the uniform resumable suffix. + * *Server offset exceeding stream size on seekable stream with unknown upload size*: When `upload_size` is `nil` and the seekable stream responds to `:size`, server offset exceeding `stream.size` raises `StreamMismatchError` with `resume_handle` and the uniform resumable suffix (guarding against Ruby's seek beyond EOF). * **Driver Stream Position & Resume Offset**: * `Driver#stream_position`: Returns `@buffer_start_offset + @buffer.bytesize`. * `ResumeUploadConfig#stream_offset`: Initializes `Driver#instance_variable_get(:@buffer_start_offset)` and `Driver#stream_position`. diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index c131c5b..e91a7c7 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -237,7 +237,7 @@ def resolve_retry_policy value, defaults # # @return [Event::StartUpload, Event::ResumeUpload] def initial_event - if defined?(ResumeUploadConfig) && @config.is_a?(ResumeUploadConfig) + if @config.is_a? ResumeUploadConfig Event::ResumeUpload.new else Event::StartUpload.new @@ -379,6 +379,13 @@ def realign_rewind_stream server_offset ) end + if @config.upload_size.nil? && @config.stream.respond_to?(:size) && server_offset > @config.stream.size + raise StreamMismatchError.new( + "Server reported offset #{server_offset} exceeds stream size #{@config.stream.size}", + resume_handle: resume_handle + ) + end + @config.stream.seek server_offset @buffer = "".b @buffer_start_offset = server_offset @@ -394,6 +401,12 @@ def realign_rewind_stream server_offset def realign_fast_forward_stream server_offset, buffer_end @buffer = "".b if @config.stream.respond_to? :seek + if @config.upload_size.nil? && @config.stream.respond_to?(:size) && server_offset > @config.stream.size + raise StreamMismatchError.new( + "Server reported offset #{server_offset} exceeds stream size #{@config.stream.size}", + resume_handle: resume_handle + ) + end @config.stream.seek server_offset else needed_discard = server_offset - buffer_end diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb index 7dda4b5..ac9a01c 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb @@ -366,6 +366,30 @@ def test_realign_buffer_raises_stream_mismatch_when_server_offset_exceeds_upload assert_includes err.message, "(upload session is resumable: see #resume_handle)" end + def test_realign_buffer_fast_forward_seekable_stream_raises_stream_mismatch_when_exceeding_stream_size + stream = StringIO.new "hello" + resume_config = ResumeUploadConfig.new( + upload_url: "https://upload.example.com/session_resume", + chunk_size: 256, + stream: stream, + upload_size: nil + ) + driver = Driver.new client_stub: @dummy_client, config: resume_config + driver.core.instance_variable_set( + :@state, + driver.core.state.with(status: :recovery, upload_url: "https://upload.example.com/session_resume", chunk_size: 256) + ) + + err = assert_raises StreamMismatchError do + driver.send :execute_realign_buffer, Instruction::RealignBuffer.new(server_offset: 1000) + end + + refute_nil err.resume_handle + assert_equal "https://upload.example.com/session_resume", err.resume_handle.upload_url + assert_includes err.message, "Server reported offset 1000 exceeds stream size 5" + assert_includes err.message, "(upload session is resumable: see #resume_handle)" + end + private def build_driver stream: From 2854ee6140ddc55d316b7da5334ee9f530867747 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Thu, 10 Sep 2026 05:11:31 +0000 Subject: [PATCH 59/79] upload session --- gapic-common/design/implementation-guide.md | 37 +- gapic-common/design/test-plan.md | 36 + .../lib/gapic/rest/resumable_upload.rb | 1 + .../lib/gapic/rest/resumable_upload/driver.rb | 8 + .../lib/gapic/rest/resumable_upload/errors.rb | 8 + .../lib/gapic/rest/resumable_upload/rules.rb | 2 +- .../gapic/rest/resumable_upload/session.rb | 362 +++++++++++ .../rest/resumable_upload/rules_error_test.rb | 1 + .../rest/resumable_upload/session_test.rb | 615 ++++++++++++++++++ 9 files changed, 1068 insertions(+), 2 deletions(-) create mode 100644 gapic-common/lib/gapic/rest/resumable_upload/session.rb create mode 100644 gapic-common/test/gapic/rest/resumable_upload/session_test.rb diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index d2bacd9..ab80fed 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -17,12 +17,22 @@ graph TD Driver -->|IO#read| Stream[Local Stream] ``` +### 1.0 Domain Vocabulary +* **Upload**: Server-side entity created by a successful session initiation (`start`), identified by `upload_url`. +* **Resume Handle (`ResumeHandle`)**: An immutable snapshot (`upload_url`, `chunk_size`) identifying an upload for resumption. +* **Session (`Session`)**: Client-side transfer coordinator; exactly 1 per logical transfer. It owns the input stream and configuration options, executing runs against exactly one upload. +* **Run**: One invocation of `Driver#run` (either a start or resume execution). +* **Bound**: The property that a session knows its upload (`!session.upload_url.nil?`). Set by `start`, or immediately by `resume`. A bound session never re-binds. + ### 1.1 Driver (Synchronous I/O Adapter) The `Driver` executes all operations with side-effects. It interacts with HTTP transport via `Gapic::Rest::ClientStub`, reads binary data from local input streams, tracks monotonic execution deadlines, and dispatches progress callbacks. Crucially, the Driver delegates all **Category 1 (Transient)** transport retries directly to `Gapic::Common::RetryPolicy`. Transient retries occur entirely within the Driver's network execution wrapper. The `Core` state machine is never exposed to transient noise, receiving only verified successful HTTP responses or terminal transport exceptions. -The Driver also exposes `Driver#resume_handle`, returning a `ResumeHandle` (or `nil` if initiation has not established an upload URL). Reading this property mid-run provides a best-effort snapshot of current session parameters. In addition, `Driver#stream_position` returns the current absolute stream offset (`@buffer_start_offset + @buffer.bytesize`). +The Driver exposes: +* `Driver#resume_handle`: Returns a `ResumeHandle` (or `nil` if initiation has not established an upload URL, or if the session is `:rejected`, `:cancelled`, or `:success`). Reading this property mid-run provides a best-effort snapshot of current session parameters. +* `Driver#upload_url`: Returns the raw protocol state upload URL under any status (`:active`, `:success`, `:rejected`, `:cancelled`). +* `Driver#stream_position`: Returns the current absolute stream offset (`@buffer_start_offset + @buffer.bytesize`). ### 1.2 Core (State Container) The `Core` maintains the immutable `State` snapshot. When `Core#dispatch(event)` is invoked by the Driver, Core forwards `@state`, the event, and static configuration to `Rules.decide`. Core mutates `@state` to `decision.next_state`, records the decision in `@last_decision`, and returns `decision.instructions` back to the Driver. Core contains zero protocol branching logic and zero side effects. @@ -33,6 +43,30 @@ The `Rules` module encapsulates the Resumable Upload Protocol state transitions ### 1.4 Stream Buffering Because arbitrary Ruby `IO` objects (network sockets, pipes, `STDIN`) do not support seeking (`#seek`), the Driver buffers the current in-flight chunk in memory (bounded by chunk size, default: 8MB). When `RetryPolicy` executes transport retries, or when `Core` triggers Category 2 recovery realignments within the buffered range, the Driver retransmits directly from memory. The buffer is discarded only after receiving a `200 OK` durably confirming receipt of the chunk. +### 1.5 Session (Transfer Coordinator) +The `Session` (`Gapic::Rest::ResumableUpload::Session`) provides a client-facing coordinator that encapsulates configuration, owns the input stream, and manages upload execution across successive runs. + +#### Observable States +1. **Unbound (`!session.bound?`)**: + * Initial state upon construction. The session does not yet know its upload. + * Permitted operations: `start` and explicit `resume` (with `upload_url:` + `chunk_size:` or `resume_handle:`). + * Bare `resume` raises `SessionStateError`. +2. **Bound and Alive (`session.bound? && session.resumable?`)**: + * An upload URL is known and `resume_handle` is non-nil (the session is actively in-flight or paused after a recoverable error). + * Permitted operations: bare `resume` and explicit `resume` with matching `upload_url`. + * `start` raises `SessionStateError` (a bound session never re-binds). +3. **Bound Dead (`session.bound? && !session.resumable?`)**: + * Finalized state (completed `200 OK`, rejected `4xx`, or cancelled). + * The session is permanently unusable for further uploads. + * Both `start` and `resume` raise `SessionStateError`. An end user wishing to upload must create a new session. + +#### Concurrency & Execution Model +* At most one run (`Driver#run`) may execute at any time. +* `@running` is checked and toggled exclusively inside a `Mutex`. +* Network execution (`driver.run`) occurs outside the mutex to prevent blocking reader threads. +* Invoking `start` or `resume` while `@running` is `true` raises `SessionStateError`. +* Errors propagate unchanged. The failed `Driver` remains referenced so `upload_url`, `resume_handle`, and `bound?` remain inspectable after an exception. + --- ## 2. Component Interfaces & Data Models @@ -453,6 +487,7 @@ Terminal errors provide actionable context so downstream SDK callers can inspect * `InvalidTransitionError < Gapic::Common::Error`: Unexpected event dispatched for state; includes `HasResumeHandle`. * `StreamMismatchError < Gapic::Common::Error`: Stream content or length does not match resumed upload specifications; includes `HasResumeHandle`. * `RequestFailedError < Gapic::Common::Error`: Terminal HTTP request failure (e.g. transport connection failure, request timeout, or retries exhausted). Retains `attr_reader :cause` returning the underlying error, preserves REST error attributes (`status_code`, `status`, `details`, `headers`) when available, and includes `HasResumeHandle`. + * `SessionStateError < Gapic::Common::Error`: Raised when an operation violates Session lifecycle rules (e.g. attempting to start an already-bound session, resuming an unbound session without a target upload, re-binding to a different upload, resuming a finalized/dead session, or concurrent run invocations). Distinguished from `ArgumentError`, which is raised strictly for invalid argument shapes. * **Resume Handle Propagation (`HasResumeHandle`)**: * The `HasResumeHandle` mixin exposes `attr_reader :resume_handle` returning a `ResumeHandle` (or `nil` if session initiation was incomplete or if the session was `:rejected` or `:cancelled`). * Whenever `resume_handle` is non-nil, the uniform suffix `" (upload session is resumable: see #resume_handle)"` is automatically appended to the error message. diff --git a/gapic-common/design/test-plan.md b/gapic-common/design/test-plan.md index 9b80c29..b78ef2c 100644 --- a/gapic-common/design/test-plan.md +++ b/gapic-common/design/test-plan.md @@ -316,3 +316,39 @@ flowchart TD * Asserts the sentinel string is completely absent across the entire serialized log corpus. * **Bounded log corpus size (`test_full_log_corpus_size_under_64kib`)**: * Asserts that the total serialized byte size of all log entries emitted across a 16 MiB multi-chunk upload run is strictly under 64 KiB (65,536 bytes). + +--- + +### 3.13 Resumable Upload Session (`session_test.rb`) + +* **Initialization & argument validation (`test_initialize_mandatory_arguments`, `test_initialize_defaults_and_size_alias`)**: + * Asserts missing any mandatory keyword (`client_stub`, `stream`, `initial_url`, `initial_body`) raises `ArgumentError`. + * Verifies defaults (`initial_headers: {}`, optional configs defaulting to `nil`) and confirms `size:` aliases `upload_size:`. +* **Observable states & lifecycle**: + * *Unbound (`test_initial_unbound_state`, `test_bare_resume_on_unbound_session_raises_session_state_error`)*: + * Verifies `bound?`, `resumable?`, `is_dead?`, and `running?` return `false`, and `upload_url` / `resume_handle` return `nil`. + * Asserts bare `session.resume` on an unbound session raises `SessionStateError`. + * *Bound and Alive (`test_resume_form1_bare_resume_on_bound_alive_session`, `test_resume_form1_bare_resume_without_arguments_when_stream_rewound`)*: + * Verifies that when a run fails with a recoverable error, `bound?` and `resumable?` are `true`, `is_dead?` is `false`, and `resume_handle` is present. + * Verifies bare `session.resume` successfully continues the bound upload. + * *Bound Dead (`test_start_successful_upload_transitions_to_bound_dead`, `test_resume_on_bound_dead_session_raises_session_state_error`)*: + * Verifies that after upload finalization (success, rejection, or cancel), `bound?` is `true`, `resumable?` is `false`, and `is_dead?` is `true`. + * Asserts resuming a dead session raises `SessionStateError` ("Session is dead and cannot be resumed"). + * *Start lifecycle violation (`test_start_when_already_bound_raises_session_state_error`)*: + * Asserts calling `session.start` on an already bound session raises `SessionStateError` ("Session is already bound to an upload"). +* **Resume mutually exclusive forms & argument shape**: + * *Form 2 (`test_resume_form2_explicit_url_and_chunk_size_binds_unbound_session`)*: + * Verifies `session.resume(upload_url:, chunk_size:)` immediately binds and executes. + * *Form 3 (`test_resume_form3_resume_handle_binds_unbound_session`)*: + * Verifies `session.resume(resume_handle)` immediately binds and executes. + * *Argument shape mixing (`test_resume_mixing_arguments_raises_argument_error`)*: + * Verifies mixing `resume_handle` with `upload_url` or `chunk_size`, or passing `upload_url` without `chunk_size`, raises `ArgumentError`. + * *Re-binding violation (`test_resume_rebinding_different_upload_url_raises_session_state_error`)*: + * Verifies calling `resume` with a different `upload_url` than the bound session raises `SessionStateError`. +* **Concurrency & running guard (`test_running_guard_prevents_concurrent_runs`)**: + * Blocks `client_stub.make_post_request` via synchronizing `Queue`s during `session.start`. + * Asserts `session.running?` is `true` while execution is blocked. + * Asserts concurrent invocations of `session.resume` and `session.start` from another thread raise `SessionStateError` ("A run is already in progress for this session"). + * Unblocks the worker thread, confirms run completes, and asserts `session.running?` transitions to `false`. +* **Driver `#upload_url` verification (`test_driver_upload_url_across_statuses`)**: + * Confirms `Driver#upload_url` returns the raw state upload URL across `:active`, `:success`, `:rejected`, and `:cancelled` states (while `Driver#resume_handle` correctly returns `nil` for `:rejected`, `:cancelled`, and `:success`). diff --git a/gapic-common/lib/gapic/rest/resumable_upload.rb b/gapic-common/lib/gapic/rest/resumable_upload.rb index e5462e6..d39d7a2 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload.rb @@ -24,6 +24,7 @@ require "gapic/rest/resumable_upload/rules" require "gapic/rest/resumable_upload/core" require "gapic/rest/resumable_upload/driver" +require "gapic/rest/resumable_upload/session" module Gapic module Rest diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index e91a7c7..a8145ea 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -66,6 +66,14 @@ def resume_handle Rules.resume_handle_from @core.state end + ## + # Returns the raw upload session URL from protocol state, regardless of lifecycle status. + # + # @return [String, nil] Session upload URL if established, or nil + def upload_url + @core.state.upload_url + end + ## # Returns the current absolute stream position represented by the Driver buffer window. # diff --git a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb index c910d3a..f03bc64 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb @@ -523,6 +523,14 @@ def self.from event_or_error, message: nil, resume_handle: nil end end end + + ## + # Raised when an operation violates the Session lifecycle rules (e.g. attempting to + # start an already-bound session, resuming an unbound session without a target upload, + # re-binding to a different upload, or concurrent run invocations). + # + class SessionStateError < Gapic::Common::Error + end end end end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb index 4fa4b9e..9691d0a 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb @@ -575,7 +575,7 @@ def self.cancel_session state, _event, config # @param state [State] Protocol state # @return [ResumeHandle, nil] Resume handle if upload URL is established, or nil def self.resume_handle_from state - return nil if state.nil? || state.upload_url.nil? || [:rejected, :cancelled].include?(state.status) + return nil if state.nil? || state.upload_url.nil? || [:rejected, :cancelled, :success].include?(state.status) ResumeHandle.new upload_url: state.upload_url, chunk_size: state.chunk_size end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/session.rb b/gapic-common/lib/gapic/rest/resumable_upload/session.rb new file mode 100644 index 0000000..b8bcd8c --- /dev/null +++ b/gapic-common/lib/gapic/rest/resumable_upload/session.rb @@ -0,0 +1,362 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "gapic/rest/resumable_upload/data_types" +require "gapic/rest/resumable_upload/driver" +require "gapic/rest/resumable_upload/errors" + +module Gapic + module Rest + module ResumableUpload + ## + # Coordinates a resumable upload across its entire lifecycle. + # + # A Session is 1 per logical transfer. It owns the input stream and configuration options, + # performing runs against exactly one upload. + # + # ### Observable States + # 1. **Unbound** (`!bound?`): Fresh session. `start` and explicit `resume` are allowed. + # Bare `resume` raises {SessionStateError}. + # 2. **Bound and Alive** (`bound? && resumable?`): Active or recoverable upload. + # Bare `resume` is allowed; `start` raises {SessionStateError}. + # 3. **Bound Dead** (`bound? && !resumable?`): Finalized upload (completed, rejected, or cancelled). + # The session is permanently unusable; both `start` and `resume` raise {SessionStateError}. + # + # rubocop:disable Metrics/ClassLength + class Session + # @return [Gapic::Rest::ClientStub] Underlying REST client stub + attr_reader :client_stub + + # @return [IO] Binary input stream to upload + attr_reader :stream + + # @return [String] Initial endpoint URI for session initiation + attr_reader :initial_url + + # @return [String, nil] Request payload for session initiation + attr_reader :initial_body + + # @return [Hash] Additional headers for initiation + attr_reader :initial_headers + + # @return [Integer, nil] Total upload bytes if known upfront + attr_reader :upload_size + + # @return [Integer, nil] Explicit chunk size in bytes + attr_reader :chunk_size + + # @return [String, nil] MIME type of uploaded media + attr_reader :content_type + + # @return [Numeric, nil] Total upload timeout in seconds + attr_reader :timeout + + # @return [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for session initiation + attr_reader :start_retry_policy + + # @return [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for control commands + attr_reader :control_plane_retry_policy + + # @return [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for data commands + attr_reader :data_plane_retry_policy + + # @return [Proc, nil] Callback invoked with Progress snapshots + attr_reader :on_progress + + # @return [Logger, nil] Logger instance + attr_reader :logger + + ## + # Initializes a new Resumable Upload Session. + # + # @param client_stub [Gapic::Rest::ClientStub] Underlying REST client stub + # @param stream [IO] Binary input stream to upload + # @param initial_url [String] Initial endpoint URI for session initiation + # @param initial_body [String, nil] Request payload for session initiation + # @param initial_headers [Hash] Additional headers for initiation + # @param upload_size [Integer, nil] Total upload bytes if known upfront + # @param chunk_size [Integer, nil] Explicit chunk size in bytes + # @param content_type [String, nil] MIME type of uploaded media + # @param size [Integer, nil] Alias for `upload_size` + # @param timeout [Numeric, nil] Total upload timeout in seconds + # @param start_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Initiation retry policy + # @param control_plane_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Control retry policy + # @param data_plane_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Data retry policy + # @param on_progress [Proc, nil] Progress callback + # @param logger [Logger, nil] Logger instance + # + def initialize client_stub:, + stream:, + initial_url:, + initial_body:, + initial_headers: {}, + upload_size: nil, + chunk_size: nil, + content_type: nil, + size: nil, + timeout: nil, + start_retry_policy: nil, + control_plane_retry_policy: nil, + data_plane_retry_policy: nil, + on_progress: nil, + logger: nil + @client_stub = client_stub + @stream = stream + @initial_url = initial_url + @initial_body = initial_body + @initial_headers = initial_headers || {} + @upload_size = upload_size || size + @chunk_size = chunk_size + @content_type = content_type + @timeout = timeout + @start_retry_policy = start_retry_policy + @control_plane_retry_policy = control_plane_retry_policy + @data_plane_retry_policy = data_plane_retry_policy + @on_progress = on_progress + @logger = logger + + @mutex = Mutex.new + @running = false + @upload_url = nil + @last_driver = nil + end + + ## + # Returns the raw upload session URL if established. + # + # @return [String, nil] + def upload_url + @mutex.synchronize { upload_url_internal } + end + + ## + # Returns whether the session is bound to a server-side upload. + # + # @return [Boolean] + def bound? + @mutex.synchronize { !upload_url_internal.nil? } + end + alias bound bound? + + ## + # Returns the current {ResumeHandle} if the session is alive and resumable. + # + # @return [ResumeHandle, nil] + def resume_handle + @mutex.synchronize { resume_handle_internal } + end + + ## + # Returns whether the session can be resumed. + # + # @return [Boolean] + def resumable? + @mutex.synchronize { !resume_handle_internal.nil? } + end + alias resumable resumable? + + ## + # Returns whether the session is permanently dead (finalized, rejected, or cancelled). + # + # @return [Boolean] + def is_dead? + @mutex.synchronize { bound_internal? && resume_handle_internal.nil? } + end + alias is_dead is_dead? + alias dead? is_dead? + + ## + # Returns whether a run is currently executing. + # + # @return [Boolean] + def running? + @mutex.synchronize { @running } + end + alias running running? + + ## + # Starts a new upload session on the server. + # + # @return [String, Object] Final response body upon completion + # @raise [SessionStateError] If already bound or if a run is currently in progress + def start + driver = nil + @mutex.synchronize do + raise SessionStateError, "A run is already in progress for this session" if @running + raise SessionStateError, "Session is already bound to an upload" if bound_internal? + + @running = true + config = build_start_config + driver = Driver.new client_stub: @client_stub, config: config, logger: @logger + end + + execute_run driver + end + + ## + # Resumes an upload session using one of three mutually exclusive forms: + # 1. Bare `resume(stream_offset: nil)`: Continues the bound upload. + # 2. `resume(upload_url:, chunk_size:, stream_offset: nil)`: Binds and resumes explicit URL and chunk size. + # 3. `resume(resume_handle:, stream_offset: nil)`: Binds and resumes via {ResumeHandle}. + # + # @param handle_or_upload_url [ResumeHandle, String, nil] Optional positional handle or upload URL + # @param upload_url [String, nil] Explicit upload URL + # @param chunk_size [Integer, nil] Explicit chunk size + # @param resume_handle [ResumeHandle, nil] Explicit resume handle + # @param stream_offset [Integer, nil] Current absolute byte offset of the stream + # @return [String, Object] Final response body upon completion + # @raise [ArgumentError] If argument shape is invalid or forms are mixed + # @raise [SessionStateError] If lifecycle rules are violated + def resume handle_or_upload_url = nil, + upload_url: nil, + chunk_size: nil, + resume_handle: nil, + stream_offset: nil + target_url, target_chunk_size = resolve_resume_args( + handle_or_upload_url, + upload_url: upload_url, + chunk_size: chunk_size, + resume_handle: resume_handle + ) + + driver = nil + @mutex.synchronize do + target_url, target_chunk_size = validate_and_bind_resume target_url, target_chunk_size + @running = true + config = build_resume_config target_url, target_chunk_size, stream_offset + driver = Driver.new client_stub: @client_stub, config: config, logger: @logger + end + + execute_run driver + end + + private + + def upload_url_internal + @upload_url || @last_driver&.upload_url + end + + def bound_internal? + !upload_url_internal.nil? + end + + def resume_handle_internal + @last_driver&.resume_handle + end + + def is_dead_internal? + bound_internal? && resume_handle_internal.nil? + end + + def build_start_config + CompleteUploadConfig.new( + initial_url: @initial_url, + initial_body: @initial_body, + initial_headers: @initial_headers, + stream: @stream, + upload_size: @upload_size, + chunk_size: @chunk_size, + content_type: @content_type, + timeout: @timeout, + start_retry_policy: @start_retry_policy, + control_plane_retry_policy: @control_plane_retry_policy, + data_plane_retry_policy: @data_plane_retry_policy, + on_progress: @on_progress + ) + end + + def build_resume_config target_url, target_chunk_size, stream_offset + ResumeUploadConfig.new( + upload_url: target_url, + chunk_size: target_chunk_size, + stream: @stream, + stream_offset: stream_offset || 0, + upload_size: @upload_size, + content_type: @content_type, + timeout: @timeout, + start_retry_policy: @start_retry_policy, + control_plane_retry_policy: @control_plane_retry_policy, + data_plane_retry_policy: @data_plane_retry_policy, + on_progress: @on_progress + ) + end + + def resolve_resume_args pos_arg, upload_url:, chunk_size:, resume_handle: + if pos_arg.is_a? ResumeHandle + resume_handle = pos_arg + elsif pos_arg.is_a? String + upload_url = pos_arg + elsif !pos_arg.nil? + raise ArgumentError, "Unexpected argument: #{pos_arg.inspect}" + end + + if resume_handle + raise ArgumentError, "Cannot pass both resume_handle and upload_url/chunk_size" if upload_url || chunk_size + [resume_handle.upload_url, resume_handle.chunk_size] + elsif upload_url + raise ArgumentError, "Must provide chunk_size with upload_url" if chunk_size.nil? + [upload_url, chunk_size] + elsif chunk_size + raise ArgumentError, "Cannot pass chunk_size without upload_url" + else + [nil, nil] + end + end + + def validate_and_bind_resume target_url, target_chunk_size + raise SessionStateError, "A run is already in progress for this session" if @running + + if target_url.nil? + unless bound_internal? + raise SessionStateError, "Cannot resume unbound session without resume_handle or upload_url" + end + raise SessionStateError, "Session is dead and cannot be resumed" if is_dead_internal? + + resolved_url = upload_url_internal + resolved_chunk = resume_handle_internal&.chunk_size || @chunk_size + raise SessionStateError, "No chunk_size available to resume session" if resolved_chunk.nil? + [resolved_url, resolved_chunk] + else + if bound_internal? && target_url != upload_url_internal + raise SessionStateError, "Session is already bound to a different upload: #{upload_url_internal}" + end + raise SessionStateError, "Session is dead and cannot be resumed" if is_dead_internal? + + @upload_url = target_url + [target_url, target_chunk_size] + end + end + + def execute_run driver + @mutex.synchronize { @last_driver = driver } + result = driver.run + @mutex.synchronize do + @upload_url ||= driver.upload_url + @running = false + end + result + rescue StandardError + @mutex.synchronize do + @upload_url ||= driver.upload_url + @running = false + end + raise + end + end + # rubocop:enable Metrics/ClassLength + end + end +end diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb index d236998..81df4bb 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb @@ -305,6 +305,7 @@ def test_rules_resume_handle_from assert_nil Rules.resume_handle_from(State.new(status: :starting, upload_url: nil)) assert_nil Rules.resume_handle_from(State.new(status: :rejected, upload_url: "https://upload.example.com/id123")) assert_nil Rules.resume_handle_from(State.new(status: :cancelled, upload_url: "https://upload.example.com/id123")) + assert_nil Rules.resume_handle_from(State.new(status: :success, upload_url: "https://upload.example.com/id123")) state = State.new status: :transmission_sending, upload_url: "https://upload.example.com/id123", chunk_size: 1024 handle = Rules.resume_handle_from state diff --git a/gapic-common/test/gapic/rest/resumable_upload/session_test.rb b/gapic-common/test/gapic/rest/resumable_upload/session_test.rb new file mode 100644 index 0000000..5da8153 --- /dev/null +++ b/gapic-common/test/gapic/rest/resumable_upload/session_test.rb @@ -0,0 +1,615 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "test_helper" +require "gapic/rest/resumable_upload" +require "stringio" + +class SessionTest < Minitest::Test + include Gapic::Rest::ResumableUpload + + FakeResponse = Struct.new :status, :headers, :body, keyword_init: true + + class ScriptedClientStub + attr_reader :requests + + def initialize responses = [] + @responses = responses.dup + @requests = [] + end + + def make_post_request uri:, body:, params:, options:, method_name: nil + @requests << { uri: uri, body: body, params: params, options: options, method_name: method_name } + raise "Unexpected request: no scripted response left" if @responses.empty? + + res = @responses.shift + if res.is_a? Proc + res.call + elsif res.is_a? Exception + raise res + else + res + end + end + end + + def build_session stub: nil, stream: nil, upload_size: 10, chunk_size: 4, **kwargs + stream ||= StringIO.new "0123456789" + stub ||= ScriptedClientStub.new + Session.new( + client_stub: stub, + stream: stream, + initial_url: "https://example.com/initiate", + initial_body: '{"name":"test.txt"}', + upload_size: upload_size, + chunk_size: chunk_size, + **kwargs + ) + end + + # ============================================================================ + # 1. Initialization and argument validation + # ============================================================================ + + def test_initialize_mandatory_arguments + assert_raises ArgumentError do + Session.new stream: StringIO.new, initial_url: "http://x", initial_body: "" + end + + assert_raises ArgumentError do + Session.new client_stub: ScriptedClientStub.new, initial_url: "http://x", initial_body: "" + end + + assert_raises ArgumentError do + Session.new client_stub: ScriptedClientStub.new, stream: StringIO.new, initial_body: "" + end + + assert_raises ArgumentError do + Session.new client_stub: ScriptedClientStub.new, stream: StringIO.new, initial_url: "http://x" + end + end + + def test_initialize_defaults_and_size_alias + session = Session.new( + client_stub: ScriptedClientStub.new, + stream: StringIO.new("abc"), + initial_url: "https://example.com/initiate", + initial_body: "", + size: 300 + ) + + assert_equal 300, session.upload_size + assert_equal({}, session.initial_headers) + assert_nil session.chunk_size + assert_nil session.content_type + assert_nil session.timeout + assert_nil session.start_retry_policy + assert_nil session.control_plane_retry_policy + assert_nil session.data_plane_retry_policy + assert_nil session.on_progress + assert_nil session.logger + end + + # ============================================================================ + # 2. Observable States: Unbound + # ============================================================================ + + def test_initial_unbound_state + session = build_session + refute session.bound? + refute session.bound + assert_nil session.upload_url + assert_nil session.resume_handle + refute session.resumable? + refute session.resumable + refute session.is_dead? + refute session.is_dead + refute session.dead? + refute session.running? + refute session.running + end + + def test_bare_resume_on_unbound_session_raises_session_state_error + session = build_session + err = assert_raises SessionStateError do + session.resume + end + assert_includes err.message, "Cannot resume unbound session without resume_handle or upload_url" + end + + # ============================================================================ + # 3. Start Lifecycle & Bound Transitions + # ============================================================================ + + def test_start_successful_upload_transitions_to_bound_dead + responses = [ + # Initiation response + FakeResponse.new( + status: 200, + headers: { + "x-goog-upload-status" => "active", + "x-goog-upload-url" => "https://upload.example.com/session_1", + "x-goog-upload-chunk-granularity" => "4" + }, + body: "" + ), + # Chunk 1 (0-3) + FakeResponse.new( + status: 200, + headers: { "x-goog-upload-status" => "active" }, + body: "" + ), + # Chunk 2 (4-7) + FakeResponse.new( + status: 200, + headers: { "x-goog-upload-status" => "active" }, + body: "" + ), + # Final Chunk (8-9) + FakeResponse.new( + status: 200, + headers: { "x-goog-upload-status" => "final" }, + body: '{"status":"completed"}' + ) + ] + + stub = ScriptedClientStub.new responses + session = build_session stub: stub, upload_size: 10, chunk_size: 4 + + result = session.start + + assert_equal '{"status":"completed"}', result + assert session.bound? + assert_equal "https://upload.example.com/session_1", session.upload_url + refute session.running? + refute session.resumable? + assert_nil session.resume_handle + # Finalized/completed state is Bound Dead (unusable for further uploads) + assert session.is_dead? + end + + def test_start_when_already_bound_raises_session_state_error + responses = [ + FakeResponse.new( + status: 200, + headers: { + "x-goog-upload-status" => "active", + "x-goog-upload-url" => "https://upload.example.com/session_1", + "x-goog-upload-chunk-granularity" => "4" + }, + body: "" + ), + FakeResponse.new( + status: 200, + headers: { "x-goog-upload-status" => "final" }, + body: '{"done":true}' + ) + ] + session = build_session( + stub: ScriptedClientStub.new(responses), + stream: StringIO.new("01"), + upload_size: 2, + chunk_size: 4 + ) + session.start + + assert session.bound? + + err = assert_raises SessionStateError do + session.start + end + assert_includes err.message, "Session is already bound to an upload" + end + + # ============================================================================ + # 4. Resume Forms: Three Mutually Exclusive Forms + # ============================================================================ + + def test_resume_form1_bare_resume_on_bound_alive_session + # First run fails during recovery query with connection failure + stub = ScriptedClientStub.new [ + # Initiation succeeds -> binds session to upload URL + FakeResponse.new( + status: 200, + headers: { + "x-goog-upload-status" => "active", + "x-goog-upload-url" => "https://upload.example.com/session_1", + "x-goog-upload-chunk-granularity" => "4" + }, + body: "" + ), + # Chunk 1 returns 503 -> triggers Category 2 recovery + FakeResponse.new(status: 503, headers: {}, body: "Service Unavailable"), + # Recovery query fails with connection error -> raises RequestFailedError + Faraday::ConnectionFailed.new("network connection failed") + ] + + session = build_session stub: stub, upload_size: 10, chunk_size: 4 + + raised = assert_raises RequestFailedError do + session.start + end + + assert_includes raised.message, "(upload session is resumable: see #resume_handle)" + refute_nil raised.resume_handle + # State: Bound and Alive + assert session.bound? + assert session.resumable? + refute session.is_dead? + refute session.running? + assert_equal "https://upload.example.com/session_1", session.upload_url + assert_equal "https://upload.example.com/session_1", session.resume_handle.upload_url + + # Second run: bare resume continues the bound upload + recovery_responses = [ + # Query response + FakeResponse.new( + status: 200, + headers: { + "x-goog-upload-status" => "active", + "x-goog-upload-size-received" => "0" + }, + body: "" + ), + # Chunk 1 + FakeResponse.new( + status: 200, + headers: { "x-goog-upload-status" => "active" }, + body: "" + ), + # Chunk 2 + FakeResponse.new( + status: 200, + headers: { "x-goog-upload-status" => "active" }, + body: "" + ), + # Chunk 3 (final) + FakeResponse.new( + status: 200, + headers: { "x-goog-upload-status" => "final" }, + body: '{"resumed":true}' + ) + ] + stub.instance_variable_set :@responses, recovery_responses + + result = session.resume stream_offset: session.stream.pos + assert_equal '{"resumed":true}', result + assert session.bound? + assert session.is_dead? + refute session.resumable? + end + + def test_resume_form1_bare_resume_without_arguments_when_stream_rewound + stub = ScriptedClientStub.new [ + FakeResponse.new( + status: 200, + headers: { + "x-goog-upload-status" => "active", + "x-goog-upload-url" => "https://upload.example.com/session_bare", + "x-goog-upload-chunk-granularity" => "4" + }, + body: "" + ), + FakeResponse.new(status: 503, headers: {}, body: "Service Unavailable"), + Faraday::ConnectionFailed.new("network connection failed") + ] + session = build_session stub: stub, stream: StringIO.new("01"), upload_size: 2, chunk_size: 4 + assert_raises RequestFailedError do + session.start + end + + assert session.bound? + assert session.resumable? + + session.stream.rewind + stub.instance_variable_set :@responses, [ + FakeResponse.new( + status: 200, + headers: { + "x-goog-upload-status" => "active", + "x-goog-upload-size-received" => "0" + }, + body: "" + ), + FakeResponse.new(status: 200, headers: { "x-goog-upload-status" => "final" }, body: '{"resumed_bare":true}') + ] + + result = session.resume + assert_equal '{"resumed_bare":true}', result + assert session.bound? + assert session.is_dead? + end + + def test_resume_form2_explicit_url_and_chunk_size_binds_unbound_session + responses = [ + FakeResponse.new( + status: 200, + headers: { + "x-goog-upload-status" => "active", + "x-goog-upload-size-received" => "0" + }, + body: "" + ), + FakeResponse.new( + status: 200, + headers: { "x-goog-upload-status" => "final" }, + body: '{"from_url":true}' + ) + ] + stub = ScriptedClientStub.new responses + session = build_session stub: stub, stream: StringIO.new("01"), upload_size: 2, chunk_size: 4 + + refute session.bound? + result = session.resume upload_url: "https://upload.example.com/direct", chunk_size: 4 + + assert_equal '{"from_url":true}', result + assert session.bound? + assert_equal "https://upload.example.com/direct", session.upload_url + end + + def test_resume_form3_resume_handle_binds_unbound_session + responses = [ + FakeResponse.new( + status: 200, + headers: { + "x-goog-upload-status" => "active", + "x-goog-upload-size-received" => "0" + }, + body: "" + ), + FakeResponse.new( + status: 200, + headers: { "x-goog-upload-status" => "final" }, + body: '{"from_handle":true}' + ) + ] + stub = ScriptedClientStub.new responses + session = build_session stub: stub, stream: StringIO.new("01"), upload_size: 2, chunk_size: 4 + + handle = ResumeHandle.new upload_url: "https://upload.example.com/from_handle", chunk_size: 4 + + refute session.bound? + result = session.resume handle + + assert_equal '{"from_handle":true}', result + assert session.bound? + assert_equal "https://upload.example.com/from_handle", session.upload_url + end + + # ============================================================================ + # 5. Resume Argument Shape & Lifecycle Violations + # ============================================================================ + + def test_resume_mixing_arguments_raises_argument_error + session = build_session + handle = ResumeHandle.new upload_url: "https://example.com", chunk_size: 4 + + # Mixing resume_handle with upload_url + assert_raises ArgumentError do + session.resume handle, upload_url: "https://example.com" + end + + assert_raises ArgumentError do + session.resume resume_handle: handle, upload_url: "https://example.com" + end + + # Mixing resume_handle with chunk_size + assert_raises ArgumentError do + session.resume handle, chunk_size: 4 + end + + # upload_url without chunk_size + assert_raises ArgumentError do + session.resume upload_url: "https://example.com" + end + + # chunk_size without upload_url + assert_raises ArgumentError do + session.resume chunk_size: 4 + end + + # Invalid positional argument type + assert_raises ArgumentError do + session.resume 12345 + end + end + + def test_resume_rebinding_different_upload_url_raises_session_state_error + responses = [ + FakeResponse.new( + status: 200, + headers: { + "x-goog-upload-status" => "active", + "x-goog-upload-size-received" => "0" + }, + body: "" + ), + FakeResponse.new( + status: 200, + headers: { "x-goog-upload-status" => "final" }, + body: '{"done":true}' + ) + ] + session = build_session( + stub: ScriptedClientStub.new(responses), + stream: StringIO.new("01"), + upload_size: 2, + chunk_size: 4 + ) + session.resume upload_url: "https://upload.example.com/session_a", chunk_size: 4 + + assert session.bound? + assert_equal "https://upload.example.com/session_a", session.upload_url + + # Attempting to resume with a different upload_url + err = assert_raises SessionStateError do + session.resume upload_url: "https://upload.example.com/session_b", chunk_size: 4 + end + assert_includes err.message, "Session is already bound to a different upload" + + handle_b = ResumeHandle.new upload_url: "https://upload.example.com/session_b", chunk_size: 4 + err2 = assert_raises SessionStateError do + session.resume handle_b + end + assert_includes err2.message, "Session is already bound to a different upload" + end + + def test_resume_on_bound_dead_session_raises_session_state_error + responses = [ + FakeResponse.new( + status: 200, + headers: { + "x-goog-upload-status" => "active", + "x-goog-upload-url" => "https://upload.example.com/session_dead", + "x-goog-upload-chunk-granularity" => "4" + }, + body: "" + ), + FakeResponse.new( + status: 200, + headers: { "x-goog-upload-status" => "final" }, + body: '{"completed":true}' + ) + ] + session = build_session( + stub: ScriptedClientStub.new(responses), + stream: StringIO.new("01"), + upload_size: 2, + chunk_size: 4 + ) + session.start + + assert session.bound? + assert session.is_dead? + + err = assert_raises SessionStateError do + session.resume + end + assert_includes err.message, "Session is dead and cannot be resumed" + end + + # ============================================================================ + # 6. Concurrency & Running Guard + # ============================================================================ + + def test_running_guard_prevents_concurrent_runs + started_q = Queue.new + unblock_q = Queue.new + + blocking_proc = proc do + started_q.push :started + unblock_q.pop # wait until test signals to proceed + FakeResponse.new( + status: 200, + headers: { + "x-goog-upload-status" => "active", + "x-goog-upload-url" => "https://upload.example.com/session_block", + "x-goog-upload-chunk-granularity" => "4" + }, + body: "" + ) + end + + stub = ScriptedClientStub.new [ + blocking_proc, + FakeResponse.new( + status: 200, + headers: { "x-goog-upload-status" => "final" }, + body: '{"done":true}' + ) + ] + session = build_session( + stub: stub, + stream: StringIO.new("01"), + upload_size: 2, + chunk_size: 4 + ) + + worker = Thread.new do + session.start + end + + started_q.pop # wait for worker thread to enter driver.run + assert session.running? + + # Concurrent call from another thread raises SessionStateError + err = assert_raises SessionStateError do + session.resume + end + assert_includes err.message, "A run is already in progress for this session" + + err_start = assert_raises SessionStateError do + session.start + end + assert_includes err_start.message, "A run is already in progress for this session" + + # Unblock worker thread + unblock_q.push :continue + result = worker.value + + assert_equal '{"done":true}', result + refute session.running? + end + + # ============================================================================ + # 7. Driver#upload_url Direct Verification + # ============================================================================ + + def test_driver_upload_url_across_statuses + dummy_client = ScriptedClientStub.new + config = CompleteUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("data"), + upload_size: 4, + chunk_size: 4 + ) + driver = Driver.new client_stub: dummy_client, config: config + + assert_nil driver.upload_url + + # Active + driver.core.instance_variable_set( + :@state, + driver.core.state.with(status: :transmission_sending, upload_url: "https://upload.example.com/sess1") + ) + assert_equal "https://upload.example.com/sess1", driver.upload_url + assert_equal "https://upload.example.com/sess1", driver.resume_handle.upload_url + + # Rejected (resume_handle is nil, but upload_url remains readable) + driver.core.instance_variable_set( + :@state, + driver.core.state.with(status: :rejected, upload_url: "https://upload.example.com/sess1") + ) + assert_equal "https://upload.example.com/sess1", driver.upload_url + assert_nil driver.resume_handle + + # Cancelled (resume_handle is nil, but upload_url remains readable) + driver.core.instance_variable_set( + :@state, + driver.core.state.with(status: :cancelled, upload_url: "https://upload.example.com/sess1") + ) + assert_equal "https://upload.example.com/sess1", driver.upload_url + assert_nil driver.resume_handle + + # Success (resume_handle is nil, but upload_url remains readable) + driver.core.instance_variable_set( + :@state, + driver.core.state.with(status: :success, upload_url: "https://upload.example.com/sess1") + ) + assert_equal "https://upload.example.com/sess1", driver.upload_url + assert_nil driver.resume_handle + end +end From cd7ae725105bab235624653699d93a2044350348 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Thu, 10 Sep 2026 05:37:29 +0000 Subject: [PATCH 60/79] test: fix minor --- gapic-common/design/implementation-guide.md | 11 +- gapic-common/design/test-plan.md | 24 ++-- .../lib/gapic/rest/resumable_upload/driver.rb | 4 +- .../lib/gapic/rest/resumable_upload/rules.rb | 4 +- .../gapic/rest/resumable_upload/session.rb | 107 +++++++++--------- .../rest/resumable_upload/session_test.rb | 104 +++++++++++++---- 6 files changed, 161 insertions(+), 93 deletions(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index ab80fed..906ec60 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -30,7 +30,7 @@ The `Driver` executes all operations with side-effects. It interacts with HTTP t Crucially, the Driver delegates all **Category 1 (Transient)** transport retries directly to `Gapic::Common::RetryPolicy`. Transient retries occur entirely within the Driver's network execution wrapper. The `Core` state machine is never exposed to transient noise, receiving only verified successful HTTP responses or terminal transport exceptions. The Driver exposes: -* `Driver#resume_handle`: Returns a `ResumeHandle` (or `nil` if initiation has not established an upload URL, or if the session is `:rejected`, `:cancelled`, or `:success`). Reading this property mid-run provides a best-effort snapshot of current session parameters. +* `Driver#resume_handle`: Returns a `ResumeHandle` (or `nil` if initiation has not established an upload URL, or if the session is `:rejected`, `:cancelled`, or `:success`; completed uploads are not resumable). Reading this property mid-run provides a best-effort snapshot of current session parameters. * `Driver#upload_url`: Returns the raw protocol state upload URL under any status (`:active`, `:success`, `:rejected`, `:cancelled`). * `Driver#stream_position`: Returns the current absolute stream offset (`@buffer_start_offset + @buffer.bytesize`). @@ -57,9 +57,18 @@ The `Session` (`Gapic::Rest::ResumableUpload::Session`) provides a client-facing * `start` raises `SessionStateError` (a bound session never re-binds). 3. **Bound Dead (`session.bound? && !session.resumable?`)**: * Finalized state (completed `200 OK`, rejected `4xx`, or cancelled). + * Completed uploads are not resumable: once a transfer succeeds, `resume_handle` returns `nil` and `resumable?` returns `false`. * The session is permanently unusable for further uploads. * Both `start` and `resume` raise `SessionStateError`. An end user wishing to upload must create a new session. +#### Resume Invocations & Forms +The `Session#resume` method accepts strictly keyword-only arguments (`upload_url: nil, chunk_size: nil, resume_handle: nil, stream_offset: nil`): +1. **Bare Resume (`session.resume(stream_offset: nil)`)**: Continues the bound upload. Derives `stream_offset`: + * If `stream.respond_to?(:seek)`: `0` (realign/rewind seeks the stream to match the server offset). + * Otherwise: `@last_driver.stream_position` (the byte offset to which the physical unseekable stream was advanced in the prior run). +2. **Explicit URL & Chunk Size (`session.resume(upload_url:, chunk_size:, stream_offset: nil)`)**: Binds and resumes with explicit upload URL and chunk size. +3. **Resume Handle (`session.resume(resume_handle:, stream_offset: nil)`)**: Binds and resumes using an existing `ResumeHandle`. + #### Concurrency & Execution Model * At most one run (`Driver#run`) may execute at any time. * `@running` is checked and toggled exclusively inside a `Mutex`. diff --git a/gapic-common/design/test-plan.md b/gapic-common/design/test-plan.md index b78ef2c..66453e6 100644 --- a/gapic-common/design/test-plan.md +++ b/gapic-common/design/test-plan.md @@ -151,7 +151,7 @@ flowchart TD * **Resume Handle & Error Metadata (`HasResumeHandle`)**: * `HasResumeHandle` mixin inclusion verified on `BadResponseError`, `DeadlineExceededError`, `UnseekableStreamError`, `InvalidTransitionError`, `StreamMismatchError`, and `RequestFailedError`. * `HasResumeHandle` explicitly refuted on terminal dead-session errors (`UploadRejectedError`, `UploadCancelledError`). - * `Rules.resume_handle_from`: Returns `nil` when state is `nil`, `upload_url` is `nil`, or status is `:rejected` or `:cancelled`; returns populated `ResumeHandle` with `upload_url` and `chunk_size` when established. + * `Rules.resume_handle_from`: Returns `nil` when state is `nil`, `upload_url` is `nil`, or status is `:rejected`, `:cancelled`, or `:success` (completed uploads are not resumable); returns populated `ResumeHandle` with `upload_url` and `chunk_size` when established. * Resumable error suffix: When `resume_handle` is present, uniform suffix `" (upload session is resumable: see #resume_handle)"` is appended to the message on `DeadlineExceededError`, `BadResponseError`, `InvalidTransitionError`, `UnseekableStreamError`, `StreamMismatchError`, and `RequestFailedError`. * Suffix omission: When `resume_handle` is `nil` (e.g. before session creation), error message omits the resumable suffix. * Terminal dead sessions: `UploadRejectedError` and `UploadCancelledError` do not respond to `:resume_handle` and do not include the suffix. @@ -321,28 +321,30 @@ flowchart TD ### 3.13 Resumable Upload Session (`session_test.rb`) -* **Initialization & argument validation (`test_initialize_mandatory_arguments`, `test_initialize_defaults_and_size_alias`)**: +* **Initialization & argument validation (`test_initialize_mandatory_arguments`, `test_initialize_defaults`)**: * Asserts missing any mandatory keyword (`client_stub`, `stream`, `initial_url`, `initial_body`) raises `ArgumentError`. - * Verifies defaults (`initial_headers: {}`, optional configs defaulting to `nil`) and confirms `size:` aliases `upload_size:`. + * Verifies defaults (`initial_headers: {}`, optional configs defaulting to `nil`, `upload_size:` explicit). * **Observable states & lifecycle**: * *Unbound (`test_initial_unbound_state`, `test_bare_resume_on_unbound_session_raises_session_state_error`)*: - * Verifies `bound?`, `resumable?`, `is_dead?`, and `running?` return `false`, and `upload_url` / `resume_handle` return `nil`. + * Verifies `bound?`, `resumable?`, and `running?` return `false`, and `upload_url` / `resume_handle` return `nil`. * Asserts bare `session.resume` on an unbound session raises `SessionStateError`. - * *Bound and Alive (`test_resume_form1_bare_resume_on_bound_alive_session`, `test_resume_form1_bare_resume_without_arguments_when_stream_rewound`)*: - * Verifies that when a run fails with a recoverable error, `bound?` and `resumable?` are `true`, `is_dead?` is `false`, and `resume_handle` is present. - * Verifies bare `session.resume` successfully continues the bound upload. + * *Bound and Alive (`test_resume_form1_bare_resume_on_bound_alive_session`, `test_resume_form1_bare_resume_without_arguments_when_seekable`)*: + * Verifies that when a run fails with a recoverable error, `bound?` and `resumable?` are `true`, and `resume_handle` is present. + * Verifies bare `session.resume` without arguments successfully continues the bound upload on a seekable stream. + * *Bound and Alive on unseekable streams (`test_resume_form1_bare_resume_on_unseekable_stream_derives_offset`)*: + * Verifies bare `session.resume` on an unseekable stream derives `stream_offset` from the prior run's `@last_driver.stream_position` and passes it to `ResumeUploadConfig`. * *Bound Dead (`test_start_successful_upload_transitions_to_bound_dead`, `test_resume_on_bound_dead_session_raises_session_state_error`)*: - * Verifies that after upload finalization (success, rejection, or cancel), `bound?` is `true`, `resumable?` is `false`, and `is_dead?` is `true`. - * Asserts resuming a dead session raises `SessionStateError` ("Session is dead and cannot be resumed"). + * Verifies completed uploads are not resumable: after upload success, `bound?` is `true`, `resumable?` is `false`, and `resume_handle` is `nil`. + * Asserts resuming a finalized dead session raises `SessionStateError` ("Session is dead and cannot be resumed"). * *Start lifecycle violation (`test_start_when_already_bound_raises_session_state_error`)*: * Asserts calling `session.start` on an already bound session raises `SessionStateError` ("Session is already bound to an upload"). * **Resume mutually exclusive forms & argument shape**: * *Form 2 (`test_resume_form2_explicit_url_and_chunk_size_binds_unbound_session`)*: * Verifies `session.resume(upload_url:, chunk_size:)` immediately binds and executes. * *Form 3 (`test_resume_form3_resume_handle_binds_unbound_session`)*: - * Verifies `session.resume(resume_handle)` immediately binds and executes. + * Verifies `session.resume(resume_handle: handle)` immediately binds and executes. * *Argument shape mixing (`test_resume_mixing_arguments_raises_argument_error`)*: - * Verifies mixing `resume_handle` with `upload_url` or `chunk_size`, or passing `upload_url` without `chunk_size`, raises `ArgumentError`. + * Verifies mixing `resume_handle` with `upload_url` or `chunk_size`, passing positional arguments, or passing `upload_url` without `chunk_size` raises `ArgumentError`. * *Re-binding violation (`test_resume_rebinding_different_upload_url_raises_session_state_error`)*: * Verifies calling `resume` with a different `upload_url` than the bound session raises `SessionStateError`. * **Concurrency & running guard (`test_running_guard_prevents_concurrent_runs`)**: diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index a8145ea..a603744 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -60,8 +60,10 @@ class Driver ## # Returns a {ResumeHandle} representing the current upload session parameters. # Reading this property mid-run provides a best-effort snapshot of the current session state. + # Completed uploads (`:success`), rejected uploads (`:rejected`), and cancelled uploads + # (`:cancelled`) are finalized and not resumable, returning `nil`. Completed uploads are not resumable. # - # @return [ResumeHandle, nil] Resume handle if upload URL is established, or nil + # @return [ResumeHandle, nil] Resume handle if upload URL is established and resumable, or nil def resume_handle Rules.resume_handle_from @core.state end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb index 9691d0a..4135592 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb @@ -571,9 +571,11 @@ def self.cancel_session state, _event, config ## # Extracts a {ResumeHandle} from current protocol state. + # Completed uploads (`:success`), rejected uploads (`:rejected`), and cancelled uploads + # (`:cancelled`) are finalized and not resumable, returning `nil`. Completed uploads are not resumable. # # @param state [State] Protocol state - # @return [ResumeHandle, nil] Resume handle if upload URL is established, or nil + # @return [ResumeHandle, nil] Resume handle if upload URL is established and resumable, or nil def self.resume_handle_from state return nil if state.nil? || state.upload_url.nil? || [:rejected, :cancelled, :success].include?(state.status) diff --git a/gapic-common/lib/gapic/rest/resumable_upload/session.rb b/gapic-common/lib/gapic/rest/resumable_upload/session.rb index b8bcd8c..72c6bdd 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/session.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/session.rb @@ -30,12 +30,13 @@ module ResumableUpload # ### Observable States # 1. **Unbound** (`!bound?`): Fresh session. `start` and explicit `resume` are allowed. # Bare `resume` raises {SessionStateError}. - # 2. **Bound and Alive** (`bound? && resumable?`): Active or recoverable upload. - # Bare `resume` is allowed; `start` raises {SessionStateError}. + # 2. **Bound and Alive** (`bound? && resumable?`): Active or paused with valid {resume_handle}. + # Bare `resume` and matching explicit `resume` are allowed; `start` raises {SessionStateError}. # 3. **Bound Dead** (`bound? && !resumable?`): Finalized upload (completed, rejected, or cancelled). - # The session is permanently unusable; both `start` and `resume` raise {SessionStateError}. + # Completed uploads are not resumable. The session is permanently unusable for further uploads; + # both `start` and `resume` raise {SessionStateError}. An end user wishing to upload must create + # a new session. # - # rubocop:disable Metrics/ClassLength class Session # @return [Gapic::Rest::ClientStub] Underlying REST client stub attr_reader :client_stub @@ -90,7 +91,6 @@ class Session # @param upload_size [Integer, nil] Total upload bytes if known upfront # @param chunk_size [Integer, nil] Explicit chunk size in bytes # @param content_type [String, nil] MIME type of uploaded media - # @param size [Integer, nil] Alias for `upload_size` # @param timeout [Numeric, nil] Total upload timeout in seconds # @param start_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Initiation retry policy # @param control_plane_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Control retry policy @@ -106,7 +106,6 @@ def initialize client_stub:, upload_size: nil, chunk_size: nil, content_type: nil, - size: nil, timeout: nil, start_retry_policy: nil, control_plane_retry_policy: nil, @@ -118,7 +117,7 @@ def initialize client_stub:, @initial_url = initial_url @initial_body = initial_body @initial_headers = initial_headers || {} - @upload_size = upload_size || size + @upload_size = upload_size @chunk_size = chunk_size @content_type = content_type @timeout = timeout @@ -149,10 +148,11 @@ def upload_url def bound? @mutex.synchronize { !upload_url_internal.nil? } end - alias bound bound? ## # Returns the current {ResumeHandle} if the session is alive and resumable. + # Completed uploads are not resumable (returns nil). Rejected uploads and + # cancelled uploads are also finalized and not resumable, returning nil. # # @return [ResumeHandle, nil] def resume_handle @@ -161,22 +161,13 @@ def resume_handle ## # Returns whether the session can be resumed. + # Completed uploads are not resumable (returns false). Rejected uploads and + # cancelled uploads are also finalized and not resumable (returns false). # # @return [Boolean] def resumable? @mutex.synchronize { !resume_handle_internal.nil? } end - alias resumable resumable? - - ## - # Returns whether the session is permanently dead (finalized, rejected, or cancelled). - # - # @return [Boolean] - def is_dead? - @mutex.synchronize { bound_internal? && resume_handle_internal.nil? } - end - alias is_dead is_dead? - alias dead? is_dead? ## # Returns whether a run is currently executing. @@ -185,7 +176,6 @@ def is_dead? def running? @mutex.synchronize { @running } end - alias running running? ## # Starts a new upload session on the server. @@ -208,11 +198,13 @@ def start ## # Resumes an upload session using one of three mutually exclusive forms: - # 1. Bare `resume(stream_offset: nil)`: Continues the bound upload. + # 1. Bare `resume(stream_offset: nil)`: Continues the bound upload. Derives stream_offset as 0 + # for seekable streams or the prior run stream_position for unseekable streams. # 2. `resume(upload_url:, chunk_size:, stream_offset: nil)`: Binds and resumes explicit URL and chunk size. # 3. `resume(resume_handle:, stream_offset: nil)`: Binds and resumes via {ResumeHandle}. # - # @param handle_or_upload_url [ResumeHandle, String, nil] Optional positional handle or upload URL + # Completed uploads are not resumable; attempting to resume a completed session raises {SessionStateError}. + # # @param upload_url [String, nil] Explicit upload URL # @param chunk_size [Integer, nil] Explicit chunk size # @param resume_handle [ResumeHandle, nil] Explicit resume handle @@ -220,13 +212,11 @@ def start # @return [String, Object] Final response body upon completion # @raise [ArgumentError] If argument shape is invalid or forms are mixed # @raise [SessionStateError] If lifecycle rules are violated - def resume handle_or_upload_url = nil, - upload_url: nil, + def resume upload_url: nil, chunk_size: nil, resume_handle: nil, stream_offset: nil target_url, target_chunk_size = resolve_resume_args( - handle_or_upload_url, upload_url: upload_url, chunk_size: chunk_size, resume_handle: resume_handle @@ -234,9 +224,11 @@ def resume handle_or_upload_url = nil, driver = nil @mutex.synchronize do - target_url, target_chunk_size = validate_and_bind_resume target_url, target_chunk_size + target_url, target_chunk_size, resolved_offset = validate_and_bind_resume( + target_url, target_chunk_size, stream_offset + ) @running = true - config = build_resume_config target_url, target_chunk_size, stream_offset + config = build_resume_config target_url, target_chunk_size, resolved_offset driver = Driver.new client_stub: @client_stub, config: config, logger: @logger end @@ -257,7 +249,7 @@ def resume_handle_internal @last_driver&.resume_handle end - def is_dead_internal? + def dead_internal? bound_internal? && resume_handle_internal.nil? end @@ -294,15 +286,7 @@ def build_resume_config target_url, target_chunk_size, stream_offset ) end - def resolve_resume_args pos_arg, upload_url:, chunk_size:, resume_handle: - if pos_arg.is_a? ResumeHandle - resume_handle = pos_arg - elsif pos_arg.is_a? String - upload_url = pos_arg - elsif !pos_arg.nil? - raise ArgumentError, "Unexpected argument: #{pos_arg.inspect}" - end - + def resolve_resume_args upload_url:, chunk_size:, resume_handle: if resume_handle raise ArgumentError, "Cannot pass both resume_handle and upload_url/chunk_size" if upload_url || chunk_size [resume_handle.upload_url, resume_handle.chunk_size] @@ -316,28 +300,44 @@ def resolve_resume_args pos_arg, upload_url:, chunk_size:, resume_handle: end end - def validate_and_bind_resume target_url, target_chunk_size + def validate_and_bind_resume target_url, target_chunk_size, stream_offset raise SessionStateError, "A run is already in progress for this session" if @running if target_url.nil? - unless bound_internal? - raise SessionStateError, "Cannot resume unbound session without resume_handle or upload_url" - end - raise SessionStateError, "Session is dead and cannot be resumed" if is_dead_internal? - - resolved_url = upload_url_internal - resolved_chunk = resume_handle_internal&.chunk_size || @chunk_size - raise SessionStateError, "No chunk_size available to resume session" if resolved_chunk.nil? - [resolved_url, resolved_chunk] + validate_bare_resume stream_offset else - if bound_internal? && target_url != upload_url_internal - raise SessionStateError, "Session is already bound to a different upload: #{upload_url_internal}" - end - raise SessionStateError, "Session is dead and cannot be resumed" if is_dead_internal? + validate_explicit_resume target_url, target_chunk_size, stream_offset + end + end - @upload_url = target_url - [target_url, target_chunk_size] + def validate_bare_resume stream_offset + unless bound_internal? + raise SessionStateError, "Cannot resume unbound session without resume_handle or upload_url" end + raise SessionStateError, "Session is dead and cannot be resumed" if dead_internal? + + resolved_url = upload_url_internal + resolved_chunk = resume_handle_internal&.chunk_size || @chunk_size + raise SessionStateError, "No chunk_size available to resume session" if resolved_chunk.nil? + + resolved_offset = stream_offset || ( + @stream.respond_to?(:seek) ? 0 : (@last_driver&.stream_position || 0) + ) + @stream.seek resolved_offset if @stream.respond_to? :seek + + [resolved_url, resolved_chunk, resolved_offset] + end + + def validate_explicit_resume target_url, target_chunk_size, stream_offset + if bound_internal? && target_url != upload_url_internal + raise SessionStateError, "Session is already bound to a different upload: #{upload_url_internal}" + end + raise SessionStateError, "Session is dead and cannot be resumed" if dead_internal? + + @upload_url = target_url + resolved_offset = stream_offset || 0 + @stream.seek resolved_offset if @stream.respond_to? :seek + [target_url, target_chunk_size, resolved_offset] end def execute_run driver @@ -356,7 +356,6 @@ def execute_run driver raise end end - # rubocop:enable Metrics/ClassLength end end end diff --git a/gapic-common/test/gapic/rest/resumable_upload/session_test.rb b/gapic-common/test/gapic/rest/resumable_upload/session_test.rb index 5da8153..bd11b35 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/session_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/session_test.rb @@ -46,6 +46,21 @@ def make_post_request uri:, body:, params:, options:, method_name: nil end end + class UnseekableStream + attr_reader :pos + + def initialize string + @io = StringIO.new string + @pos = 0 + end + + def read length = nil + chunk = @io.read length + @pos += chunk.bytesize if chunk + chunk + end + end + def build_session stub: nil, stream: nil, upload_size: 10, chunk_size: 4, **kwargs stream ||= StringIO.new "0123456789" stub ||= ScriptedClientStub.new @@ -82,13 +97,13 @@ def test_initialize_mandatory_arguments end end - def test_initialize_defaults_and_size_alias + def test_initialize_defaults session = Session.new( client_stub: ScriptedClientStub.new, stream: StringIO.new("abc"), initial_url: "https://example.com/initiate", initial_body: "", - size: 300 + upload_size: 300 ) assert_equal 300, session.upload_size @@ -110,16 +125,10 @@ def test_initialize_defaults_and_size_alias def test_initial_unbound_state session = build_session refute session.bound? - refute session.bound assert_nil session.upload_url assert_nil session.resume_handle refute session.resumable? - refute session.resumable - refute session.is_dead? - refute session.is_dead - refute session.dead? refute session.running? - refute session.running end def test_bare_resume_on_unbound_session_raises_session_state_error @@ -177,8 +186,6 @@ def test_start_successful_upload_transitions_to_bound_dead refute session.running? refute session.resumable? assert_nil session.resume_handle - # Finalized/completed state is Bound Dead (unusable for further uploads) - assert session.is_dead? end def test_start_when_already_bound_raises_session_state_error @@ -248,7 +255,6 @@ def test_resume_form1_bare_resume_on_bound_alive_session # State: Bound and Alive assert session.bound? assert session.resumable? - refute session.is_dead? refute session.running? assert_equal "https://upload.example.com/session_1", session.upload_url assert_equal "https://upload.example.com/session_1", session.resume_handle.upload_url @@ -285,14 +291,13 @@ def test_resume_form1_bare_resume_on_bound_alive_session ] stub.instance_variable_set :@responses, recovery_responses - result = session.resume stream_offset: session.stream.pos + result = session.resume assert_equal '{"resumed":true}', result assert session.bound? - assert session.is_dead? refute session.resumable? end - def test_resume_form1_bare_resume_without_arguments_when_stream_rewound + def test_resume_form1_bare_resume_on_seekable_stream_without_manual_rewind stub = ScriptedClientStub.new [ FakeResponse.new( status: 200, @@ -314,7 +319,6 @@ def test_resume_form1_bare_resume_without_arguments_when_stream_rewound assert session.bound? assert session.resumable? - session.stream.rewind stub.instance_variable_set :@responses, [ FakeResponse.new( status: 200, @@ -330,7 +334,61 @@ def test_resume_form1_bare_resume_without_arguments_when_stream_rewound result = session.resume assert_equal '{"resumed_bare":true}', result assert session.bound? - assert session.is_dead? + refute session.resumable? + end + + def test_resume_form1_bare_resume_on_unseekable_stream_derives_offset + stream = UnseekableStream.new "0123456789" + stub = ScriptedClientStub.new [ + # Initiation + FakeResponse.new( + status: 200, + headers: { + "x-goog-upload-status" => "active", + "x-goog-upload-url" => "https://upload.example.com/session_unseekable", + "x-goog-upload-chunk-granularity" => "4" + }, + body: "" + ), + # Chunk 1 (0-3) returns 503 -> recovery + FakeResponse.new(status: 503, headers: {}, body: "Service Unavailable"), + # Recovery query fails -> RequestFailedError + Faraday::ConnectionFailed.new("network connection failed") + ] + + session = build_session stub: stub, stream: stream, upload_size: 10, chunk_size: 4 + assert_raises RequestFailedError do + session.start + end + + assert session.bound? + assert session.resumable? + assert_equal 4, stream.pos + + # Script responses for bare resume + stub.instance_variable_set :@responses, [ + # Recovery query on resume: server acknowledges 4 bytes received + FakeResponse.new( + status: 200, + headers: { + "x-goog-upload-status" => "active", + "x-goog-upload-size-received" => "4" + }, + body: "" + ), + # Chunk 2 (4-7) + FakeResponse.new(status: 200, headers: { "x-goog-upload-status" => "active" }, body: ""), + # Final chunk (8-9) + FakeResponse.new(status: 200, headers: { "x-goog-upload-status" => "final" }, body: '{"unseekable_resumed":true}') + ] + + result = session.resume + assert_equal '{"unseekable_resumed":true}', result + assert_equal 10, stream.pos + assert session.bound? + refute session.resumable? + # Verify derived stream_offset was 4 in the resume driver config + assert_equal 4, session.instance_variable_get(:@last_driver).instance_variable_get(:@config).stream_offset end def test_resume_form2_explicit_url_and_chunk_size_binds_unbound_session @@ -382,7 +440,7 @@ def test_resume_form3_resume_handle_binds_unbound_session handle = ResumeHandle.new upload_url: "https://upload.example.com/from_handle", chunk_size: 4 refute session.bound? - result = session.resume handle + result = session.resume resume_handle: handle assert_equal '{"from_handle":true}', result assert session.bound? @@ -398,17 +456,13 @@ def test_resume_mixing_arguments_raises_argument_error handle = ResumeHandle.new upload_url: "https://example.com", chunk_size: 4 # Mixing resume_handle with upload_url - assert_raises ArgumentError do - session.resume handle, upload_url: "https://example.com" - end - assert_raises ArgumentError do session.resume resume_handle: handle, upload_url: "https://example.com" end # Mixing resume_handle with chunk_size assert_raises ArgumentError do - session.resume handle, chunk_size: 4 + session.resume resume_handle: handle, chunk_size: 4 end # upload_url without chunk_size @@ -423,7 +477,7 @@ def test_resume_mixing_arguments_raises_argument_error # Invalid positional argument type assert_raises ArgumentError do - session.resume 12345 + session.resume handle end end @@ -462,7 +516,7 @@ def test_resume_rebinding_different_upload_url_raises_session_state_error handle_b = ResumeHandle.new upload_url: "https://upload.example.com/session_b", chunk_size: 4 err2 = assert_raises SessionStateError do - session.resume handle_b + session.resume resume_handle: handle_b end assert_includes err2.message, "Session is already bound to a different upload" end @@ -493,7 +547,7 @@ def test_resume_on_bound_dead_session_raises_session_state_error session.start assert session.bound? - assert session.is_dead? + refute session.resumable? err = assert_raises SessionStateError do session.resume From 65f327b5fe93f5383f288abad5cd907559cbfa14 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Thu, 10 Sep 2026 06:35:59 +0000 Subject: [PATCH 61/79] chore: revert stream offset, only allow one run per session --- gapic-common/design/implementation-guide.md | 61 +-- gapic-common/design/test-plan.md | 52 +-- .../gapic/rest/resumable_upload/data_types.rb | 9 - .../lib/gapic/rest/resumable_upload/driver.rb | 10 +- .../gapic/rest/resumable_upload/session.rb | 126 ++---- .../rest/resumable_upload/data_types_test.rb | 5 - .../resumable_upload/driver_buffer_test.rb | 32 +- .../rest/resumable_upload/driver_test.rb | 22 +- .../rest/resumable_upload/session_test.rb | 393 ++++++++---------- 9 files changed, 289 insertions(+), 421 deletions(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index 906ec60..d483a55 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -20,9 +20,9 @@ graph TD ### 1.0 Domain Vocabulary * **Upload**: Server-side entity created by a successful session initiation (`start`), identified by `upload_url`. * **Resume Handle (`ResumeHandle`)**: An immutable snapshot (`upload_url`, `chunk_size`) identifying an upload for resumption. -* **Session (`Session`)**: Client-side transfer coordinator; exactly 1 per logical transfer. It owns the input stream and configuration options, executing runs against exactly one upload. +* **Session (`Session`)**: Client-side transfer coordinator; performs exactly one run (`start` or `resume`), never both, never twice. It owns the input stream and configuration options. * **Run**: One invocation of `Driver#run` (either a start or resume execution). -* **Bound**: The property that a session knows its upload (`!session.upload_url.nil?`). Set by `start`, or immediately by `resume`. A bound session never re-binds. +* **Bound**: The property that a session has executed a run or is bound to an upload (`session.bound?`). A session becomes bound when `start` or `resume` begins execution. A bound session never runs again. ### 1.1 Driver (Synchronous I/O Adapter) The `Driver` executes all operations with side-effects. It interacts with HTTP transport via `Gapic::Rest::ClientStub`, reads binary data from local input streams, tracks monotonic execution deadlines, and dispatches progress callbacks. @@ -32,7 +32,6 @@ Crucially, the Driver delegates all **Category 1 (Transient)** transport retries The Driver exposes: * `Driver#resume_handle`: Returns a `ResumeHandle` (or `nil` if initiation has not established an upload URL, or if the session is `:rejected`, `:cancelled`, or `:success`; completed uploads are not resumable). Reading this property mid-run provides a best-effort snapshot of current session parameters. * `Driver#upload_url`: Returns the raw protocol state upload URL under any status (`:active`, `:success`, `:rejected`, `:cancelled`). -* `Driver#stream_position`: Returns the current absolute stream offset (`@buffer_start_offset + @buffer.bytesize`). ### 1.2 Core (State Container) The `Core` maintains the immutable `State` snapshot. When `Core#dispatch(event)` is invoked by the Driver, Core forwards `@state`, the event, and static configuration to `Rules.decide`. Core mutates `@state` to `decision.next_state`, records the decision in `@last_decision`, and returns `decision.instructions` back to the Driver. Core contains zero protocol branching logic and zero side effects. @@ -44,30 +43,43 @@ The `Rules` module encapsulates the Resumable Upload Protocol state transitions Because arbitrary Ruby `IO` objects (network sockets, pipes, `STDIN`) do not support seeking (`#seek`), the Driver buffers the current in-flight chunk in memory (bounded by chunk size, default: 8MB). When `RetryPolicy` executes transport retries, or when `Core` triggers Category 2 recovery realignments within the buffered range, the Driver retransmits directly from memory. The buffer is discarded only after receiving a `200 OK` durably confirming receipt of the chunk. ### 1.5 Session (Transfer Coordinator) -The `Session` (`Gapic::Rest::ResumableUpload::Session`) provides a client-facing coordinator that encapsulates configuration, owns the input stream, and manages upload execution across successive runs. +The `Session` (`Gapic::Rest::ResumableUpload::Session`) provides a client-facing coordinator that encapsulates configuration, owns the input stream, and manages upload execution across a strict single-run lifecycle. + +#### Single-Run Contract & Two-State Model +A session adheres to a two-state model with a strict single-run contract: a session performs exactly one run (`start` or `resume`), never both, never twice. -#### Observable States 1. **Unbound (`!session.bound?`)**: - * Initial state upon construction. The session does not yet know its upload. - * Permitted operations: `start` and explicit `resume` (with `upload_url:` + `chunk_size:` or `resume_handle:`). - * Bare `resume` raises `SessionStateError`. -2. **Bound and Alive (`session.bound? && session.resumable?`)**: - * An upload URL is known and `resume_handle` is non-nil (the session is actively in-flight or paused after a recoverable error). - * Permitted operations: bare `resume` and explicit `resume` with matching `upload_url`. - * `start` raises `SessionStateError` (a bound session never re-binds). -3. **Bound Dead (`session.bound? && !session.resumable?`)**: - * Finalized state (completed `200 OK`, rejected `4xx`, or cancelled). - * Completed uploads are not resumable: once a transfer succeeds, `resume_handle` returns `nil` and `resumable?` returns `false`. - * The session is permanently unusable for further uploads. - * Both `start` and `resume` raise `SessionStateError`. An end user wishing to upload must create a new session. + * Initial state upon construction (`Session.new`). The session has not yet executed a run. + * Permitted operations: `start` or `resume(...)`. +2. **Bound (`session.bound?`)**: + * Transitions to bound as soon as `start` or `resume` begins execution. + * The session has executed its run and cannot be reused. + * Both `start` and `resume` raise `SessionStateError` ("Session has already executed a run"). + +#### Resumability (`session.resumable?`) +* Reports whether a *new* session can resume the transfer (`!session.resume_handle.nil?`). +* Completed uploads are not resumable: once a transfer succeeds, `resume_handle` returns `nil` and `resumable?` returns `false`. +* When a run fails with a recoverable error, `resume_handle` captures the upload parameters (`upload_url`, `chunk_size`) and `resumable?` returns `true`. + +#### Precondition on Stream Position for Resume +* Before executing `resume`, the caller must ensure the input stream is positioned at byte 0. +* If `stream.respond_to?(:pos) && !stream.pos.zero?`, `Session#resume` raises `ArgumentError` ("Input stream must be at byte 0 to resume; rewind the stream before resuming"). +* For unseekable streams without `:pos` (or streams at `pos == 0`), `Session` trusts the stream is at byte 0 and delegates to `Driver`, which fast-forwards to the server-confirmed offset by seeking or reading and discarding bytes. #### Resume Invocations & Forms -The `Session#resume` method accepts strictly keyword-only arguments (`upload_url: nil, chunk_size: nil, resume_handle: nil, stream_offset: nil`): -1. **Bare Resume (`session.resume(stream_offset: nil)`)**: Continues the bound upload. Derives `stream_offset`: - * If `stream.respond_to?(:seek)`: `0` (realign/rewind seeks the stream to match the server offset). - * Otherwise: `@last_driver.stream_position` (the byte offset to which the physical unseekable stream was advanced in the prior run). -2. **Explicit URL & Chunk Size (`session.resume(upload_url:, chunk_size:, stream_offset: nil)`)**: Binds and resumes with explicit upload URL and chunk size. -3. **Resume Handle (`session.resume(resume_handle:, stream_offset: nil)`)**: Binds and resumes using an existing `ResumeHandle`. +The `Session#resume` method accepts strictly keyword-only arguments: `upload_url: nil, chunk_size: nil, resume_handle: nil`. +Resumption always requires an unbound session with one of two mutually exclusive parameter forms: +1. **Explicit URL & Chunk Size**: `session.resume(upload_url: url, chunk_size: size)` +2. **Resume Handle**: `session.resume(resume_handle: handle)` + +Calling `resume` without arguments (bare resume), calling `resume` with `upload_url` but omitting `chunk_size`, or mixing `resume_handle` with other parameters raises `ArgumentError`. + +#### Cross-Session Resumption Flow +Because a session performs only a single run, resuming an interrupted upload requires instantiating a fresh session: +1. Session 1 encounters a recoverable error. +2. Caller extracts `resume_handle = session1.resume_handle` (or from the error's `#resume_handle`). +3. Caller rewinds the stream to byte 0 (if seekable, or provides an equivalent stream starting at byte 0). +4. Caller instantiates Session 2 and invokes `session2.resume(resume_handle: resume_handle)`. #### Concurrency & Execution Model * At most one run (`Driver#run`) may execute at any time. @@ -128,7 +140,6 @@ module Gapic :upload_url, # [String] Upload session URL returned by Scotty backend :chunk_size, # [Integer] Chunk size in bytes (> 0) :stream, # [IO] Binary input stream to upload - :stream_offset, # [Integer] Starting byte offset in stream (default: 0) :upload_size, # [Integer, nil] Total upload bytes if known upfront :content_type, # [String, nil] MIME type of uploaded media :timeout, # [Numeric, nil] Total upload timeout in seconds (zero/negative treated as nil) @@ -246,7 +257,7 @@ When `Core` resolves a recovery query or offset realignment, the Driver executes * Driver resets `@buffer = "".b`. * Driver advances the stream to `server_offset`: * If seekable: `stream.seek(server_offset)`. - * If unseekable: Driver reads and discards `server_offset - current_stream_pos` bytes from `stream`. If the stream encounters an unexpected EOF before reaching `server_offset`, Driver raises a terminal `StreamMismatchError` with `resume_handle`. + * If unseekable: Driver reads and discards bytes from `stream` until reaching `server_offset` (reading `server_offset - buffer_end` bytes). If the stream encounters an unexpected EOF before reaching `server_offset`, Driver raises a terminal `StreamMismatchError` with `resume_handle`. * Driver sets `buffer_start_offset = server_offset`. --- diff --git a/gapic-common/design/test-plan.md b/gapic-common/design/test-plan.md index 66453e6..36578e3 100644 --- a/gapic-common/design/test-plan.md +++ b/gapic-common/design/test-plan.md @@ -182,9 +182,6 @@ flowchart TD * *Fast-forward unexpected EOF*: Unexpected EOF while discarding bytes from an unseekable stream raises `StreamMismatchError` with `resume_handle` and the uniform resumable suffix. * *Server offset exceeding upload size*: Server reporting an offset exceeding known `upload_size` raises `StreamMismatchError` with `resume_handle` and the uniform resumable suffix. * *Server offset exceeding stream size on seekable stream with unknown upload size*: When `upload_size` is `nil` and the seekable stream responds to `:size`, server offset exceeding `stream.size` raises `StreamMismatchError` with `resume_handle` and the uniform resumable suffix (guarding against Ruby's seek beyond EOF). -* **Driver Stream Position & Resume Offset**: - * `Driver#stream_position`: Returns `@buffer_start_offset + @buffer.bytesize`. - * `ResumeUploadConfig#stream_offset`: Initializes `Driver#instance_variable_get(:@buffer_start_offset)` and `Driver#stream_position`. * **Driver Session Snapshot (`Driver#resume_handle`)**: * Returns `nil` before upload session URL is established. * Returns `ResumeHandle` snapshot during active upload progression. @@ -322,31 +319,34 @@ flowchart TD ### 3.13 Resumable Upload Session (`session_test.rb`) * **Initialization & argument validation (`test_initialize_mandatory_arguments`, `test_initialize_defaults`)**: - * Asserts missing any mandatory keyword (`client_stub`, `stream`, `initial_url`, `initial_body`) raises `ArgumentError`. - * Verifies defaults (`initial_headers: {}`, optional configs defaulting to `nil`, `upload_size:` explicit). -* **Observable states & lifecycle**: - * *Unbound (`test_initial_unbound_state`, `test_bare_resume_on_unbound_session_raises_session_state_error`)*: + * Asserts missing any mandatory keyword (`client_stub`, `stream`, `initial_url`) raises `ArgumentError`. + * Verifies defaults (`initial_body: nil`, `initial_headers: {}`, optional configs defaulting to `nil`, `upload_size:` explicit). +* **Observable states & lifecycle (Two-State Model)**: + * *Unbound (`test_initial_unbound_state`)*: * Verifies `bound?`, `resumable?`, and `running?` return `false`, and `upload_url` / `resume_handle` return `nil`. - * Asserts bare `session.resume` on an unbound session raises `SessionStateError`. - * *Bound and Alive (`test_resume_form1_bare_resume_on_bound_alive_session`, `test_resume_form1_bare_resume_without_arguments_when_seekable`)*: - * Verifies that when a run fails with a recoverable error, `bound?` and `resumable?` are `true`, and `resume_handle` is present. - * Verifies bare `session.resume` without arguments successfully continues the bound upload on a seekable stream. - * *Bound and Alive on unseekable streams (`test_resume_form1_bare_resume_on_unseekable_stream_derives_offset`)*: - * Verifies bare `session.resume` on an unseekable stream derives `stream_offset` from the prior run's `@last_driver.stream_position` and passes it to `ResumeUploadConfig`. - * *Bound Dead (`test_start_successful_upload_transitions_to_bound_dead`, `test_resume_on_bound_dead_session_raises_session_state_error`)*: + * *Single-run contract on start (`test_start_transitions_to_bound`, `test_start_when_already_bound_raises_session_state_error`, `test_resume_when_already_bound_after_start_raises_session_state_error`)*: + * Verifies calling `session.start` transitions session to `bound? == true`. + * Asserts calling `session.start` or `session.resume` again on an already bound session raises `SessionStateError` ("Session has already executed a run"). + * *Single-run contract on resume (`test_resume_transitions_to_bound`, `test_resume_when_already_bound_after_resume_raises_session_state_error`, `test_start_when_already_bound_after_resume_raises_session_state_error`)*: + * Verifies calling `session.resume` transitions session to `bound? == true`. + * Asserts subsequent calls to `resume` or `start` raise `SessionStateError` ("Session has already executed a run"). + * *Resumability & terminal states (`test_start_successful_upload_transitions_to_bound_not_resumable`, `test_failed_upload_remains_resumable`)*: * Verifies completed uploads are not resumable: after upload success, `bound?` is `true`, `resumable?` is `false`, and `resume_handle` is `nil`. - * Asserts resuming a finalized dead session raises `SessionStateError` ("Session is dead and cannot be resumed"). - * *Start lifecycle violation (`test_start_when_already_bound_raises_session_state_error`)*: - * Asserts calling `session.start` on an already bound session raises `SessionStateError` ("Session is already bound to an upload"). -* **Resume mutually exclusive forms & argument shape**: - * *Form 2 (`test_resume_form2_explicit_url_and_chunk_size_binds_unbound_session`)*: - * Verifies `session.resume(upload_url:, chunk_size:)` immediately binds and executes. - * *Form 3 (`test_resume_form3_resume_handle_binds_unbound_session`)*: - * Verifies `session.resume(resume_handle: handle)` immediately binds and executes. - * *Argument shape mixing (`test_resume_mixing_arguments_raises_argument_error`)*: - * Verifies mixing `resume_handle` with `upload_url` or `chunk_size`, passing positional arguments, or passing `upload_url` without `chunk_size` raises `ArgumentError`. - * *Re-binding violation (`test_resume_rebinding_different_upload_url_raises_session_state_error`)*: - * Verifies calling `resume` with a different `upload_url` than the bound session raises `SessionStateError`. + * Verifies that when a run fails with a recoverable error, `bound?` is `true`, `resumable?` is `true`, and `resume_handle` is present. +* **Resume precondition & argument shape**: + * *Stream byte-0 precondition (`test_resume_with_non_zero_stream_pos_raises_argument_error`, `test_resume_with_zero_stream_pos_succeeds`, `test_resume_with_unseekable_stream_trusts_caller`)*: + * Asserts calling `resume` when `stream.pos != 0` raises `ArgumentError` ("Input stream must be at byte 0 to resume; rewind the stream before resuming"). + * Verifies calling `resume` when `stream.pos == 0` or on unseekable streams without `:pos` succeeds. + * *Bare resume rejection (`test_bare_resume_raises_argument_error`)*: + * Asserts calling `session.resume` with no arguments raises `ArgumentError`. + * *Resume mutually exclusive forms (`test_resume_with_upload_url_and_chunk_size`, `test_resume_with_resume_handle`)*: + * Verifies `session.resume(upload_url:, chunk_size:)` and `session.resume(resume_handle:)` execute successfully. + * *Argument mixing & validation (`test_resume_mixing_arguments_raises_argument_error`, `test_resume_missing_chunk_size_with_upload_url_raises_argument_error`)*: + * Asserts mixing `resume_handle` with `upload_url` or `chunk_size`, or passing `upload_url` without `chunk_size`, raises `ArgumentError`. +* **Cross-session resumption (`test_cross_session_resumption_flow`)*: + * Simulates Session 1 failing with a recoverable error and capturing `resume_handle`. + * Rewinds stream to 0, creates Session 2, and invokes `session2.resume(resume_handle: handle)`. + * Verifies Session 2 successfully resumes and completes the upload. * **Concurrency & running guard (`test_running_guard_prevents_concurrent_runs`)**: * Blocks `client_stub.make_post_request` via synchronizing `Queue`s during `session.start`. * Asserts `session.running?` is `true` while execution is blocked. diff --git a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb index bcfe64c..2b74eab 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb @@ -122,8 +122,6 @@ def initialize initial_url:, # @return [Integer] Explicit chunk size in bytes (must be a positive integer) # @!attribute [r] stream # @return [IO] Binary input stream to upload - # @!attribute [r] stream_offset - # @return [Integer] Absolute byte offset at which the stream is currently positioned (defaults to 0) # @!attribute [r] upload_size # @return [Integer, nil] Total upload bytes if known upfront # @!attribute [r] content_type @@ -149,7 +147,6 @@ def initialize initial_url:, :upload_url, :chunk_size, :stream, - :stream_offset, :upload_size, :content_type, :timeout, @@ -164,7 +161,6 @@ def initialize initial_url:, # @param upload_url [String] Session upload URL # @param chunk_size [Integer] Explicit chunk size in bytes (must be a positive integer) # @param stream [IO] Binary input stream to upload - # @param stream_offset [Integer] Current absolute byte offset of the stream (defaults to 0) # @param upload_size [Integer, nil] Total upload bytes if known upfront # @param content_type [String, nil] MIME type of uploaded media # @param timeout [Numeric, nil] Total upload timeout in seconds (zero/negative values treated as nil) @@ -177,7 +173,6 @@ def initialize initial_url:, def initialize upload_url:, chunk_size:, stream:, - stream_offset: 0, upload_size: nil, content_type: nil, timeout: nil, @@ -190,15 +185,11 @@ def initialize upload_url:, raise ArgumentError, "chunk_size must be a positive integer" end raise ArgumentError, "stream is required" if stream.nil? - if !stream_offset.is_a?(Integer) || stream_offset.negative? - raise ArgumentError, "stream_offset must be a non-negative integer" - end super( upload_url: upload_url, chunk_size: chunk_size, stream: stream, - stream_offset: stream_offset, upload_size: upload_size, content_type: content_type, timeout: timeout, diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index a603744..3503a40 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -76,14 +76,6 @@ def upload_url @core.state.upload_url end - ## - # Returns the current absolute stream position represented by the Driver buffer window. - # - # @return [Integer] Current absolute byte offset - def stream_position - @buffer_start_offset + @buffer.bytesize - end - ## # Initializes a new Resumable Upload Driver. # @@ -96,7 +88,7 @@ def initialize client_stub:, config:, core: nil, logger: nil @config = config @core = core || Core.new(config) @buffer = "".b - @buffer_start_offset = config.respond_to?(:stream_offset) && config.stream_offset ? config.stream_offset : 0 + @buffer_start_offset = 0 endpoint = client_stub.respond_to?(:endpoint) ? client_stub.endpoint : nil setup_logging logger: logger || (client_stub.respond_to?(:logger) ? client_stub.logger : nil), diff --git a/gapic-common/lib/gapic/rest/resumable_upload/session.rb b/gapic-common/lib/gapic/rest/resumable_upload/session.rb index 72c6bdd..dca4276 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/session.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/session.rb @@ -22,20 +22,19 @@ module Gapic module Rest module ResumableUpload ## - # Coordinates a resumable upload across its entire lifecycle. + # Coordinates a resumable upload across its lifecycle. # - # A Session is 1 per logical transfer. It owns the input stream and configuration options, - # performing runs against exactly one upload. + # A Session performs exactly one run (`start` or `resume`), never both, never twice. # - # ### Observable States - # 1. **Unbound** (`!bound?`): Fresh session. `start` and explicit `resume` are allowed. - # Bare `resume` raises {SessionStateError}. - # 2. **Bound and Alive** (`bound? && resumable?`): Active or paused with valid {resume_handle}. - # Bare `resume` and matching explicit `resume` are allowed; `start` raises {SessionStateError}. - # 3. **Bound Dead** (`bound? && !resumable?`): Finalized upload (completed, rejected, or cancelled). - # Completed uploads are not resumable. The session is permanently unusable for further uploads; - # both `start` and `resume` raise {SessionStateError}. An end user wishing to upload must create - # a new session. + # ### Two-State Model + # 1. **Unbound** (`!bound?`): Fresh session prior to execution. Permitted operations: `start` + # or `resume(...)`. + # 2. **Bound** (`bound?`): Session has executed or bound to an upload URL. Permitted operations: + # none (`start` and `resume` both raise {SessionStateError}). + # + # Calling {resumable?} reports whether a new session can resume the upload (`!resume_handle.nil?`). + # Completed uploads (`:success`), rejected uploads, and cancelled uploads are finalized and not + # resumable (`resumable?` returns `false`, `resume_handle` returns `nil`). # class Session # @return [Gapic::Rest::ClientStub] Underlying REST client stub @@ -86,7 +85,7 @@ class Session # @param client_stub [Gapic::Rest::ClientStub] Underlying REST client stub # @param stream [IO] Binary input stream to upload # @param initial_url [String] Initial endpoint URI for session initiation - # @param initial_body [String, nil] Request payload for session initiation + # @param initial_body [String, nil] Request payload for session initiation (defaults to nil) # @param initial_headers [Hash] Additional headers for initiation # @param upload_size [Integer, nil] Total upload bytes if known upfront # @param chunk_size [Integer, nil] Explicit chunk size in bytes @@ -101,7 +100,7 @@ class Session def initialize client_stub:, stream:, initial_url:, - initial_body:, + initial_body: nil, initial_headers: {}, upload_size: nil, chunk_size: nil, @@ -129,6 +128,7 @@ def initialize client_stub:, @mutex = Mutex.new @running = false + @executed = false @upload_url = nil @last_driver = nil end @@ -146,7 +146,7 @@ def upload_url # # @return [Boolean] def bound? - @mutex.synchronize { !upload_url_internal.nil? } + @mutex.synchronize { bound_internal? } end ## @@ -160,7 +160,7 @@ def resume_handle end ## - # Returns whether the session can be resumed. + # Returns whether a new session can resume the upload. # Completed uploads are not resumable (returns false). Rejected uploads and # cancelled uploads are also finalized and not resumable (returns false). # @@ -180,14 +180,18 @@ def running? ## # Starts a new upload session on the server. # + # A session performs exactly one run (`start` or `resume`). Calling `start` on an already-bound + # or executed session raises {SessionStateError}. + # # @return [String, Object] Final response body upon completion - # @raise [SessionStateError] If already bound or if a run is currently in progress + # @raise [SessionStateError] If already bound/executed or if a run is currently in progress def start driver = nil @mutex.synchronize do raise SessionStateError, "A run is already in progress for this session" if @running - raise SessionStateError, "Session is already bound to an upload" if bound_internal? + raise SessionStateError, "Session has already executed a run" if bound_internal? + @executed = true @running = true config = build_start_config driver = Driver.new client_stub: @client_stub, config: config, logger: @logger @@ -197,25 +201,26 @@ def start end ## - # Resumes an upload session using one of three mutually exclusive forms: - # 1. Bare `resume(stream_offset: nil)`: Continues the bound upload. Derives stream_offset as 0 - # for seekable streams or the prior run stream_position for unseekable streams. - # 2. `resume(upload_url:, chunk_size:, stream_offset: nil)`: Binds and resumes explicit URL and chunk size. - # 3. `resume(resume_handle:, stream_offset: nil)`: Binds and resumes via {ResumeHandle}. + # Resumes an upload session using one of two explicit keyword forms: + # 1. `resume(upload_url:, chunk_size:)`: Resumes with explicit URL and chunk size. + # 2. `resume(resume_handle:)`: Resumes via {ResumeHandle}. + # + # A session performs exactly one run (`start` or `resume`). Resuming must be executed on a + # fresh, unexecuted session. Precondition: the stream must be positioned at byte 0. + # The Driver fast-forwards to the server's acknowledged offset (by seeking on seekable streams + # or reading and discarding on unseekable streams). # # Completed uploads are not resumable; attempting to resume a completed session raises {SessionStateError}. # # @param upload_url [String, nil] Explicit upload URL # @param chunk_size [Integer, nil] Explicit chunk size # @param resume_handle [ResumeHandle, nil] Explicit resume handle - # @param stream_offset [Integer, nil] Current absolute byte offset of the stream # @return [String, Object] Final response body upon completion - # @raise [ArgumentError] If argument shape is invalid or forms are mixed - # @raise [SessionStateError] If lifecycle rules are violated + # @raise [ArgumentError] If argument shape is invalid, target upload is missing, or stream.pos != 0 + # @raise [SessionStateError] If already bound/executed or if a run is currently in progress def resume upload_url: nil, chunk_size: nil, - resume_handle: nil, - stream_offset: nil + resume_handle: nil target_url, target_chunk_size = resolve_resume_args( upload_url: upload_url, chunk_size: chunk_size, @@ -224,11 +229,17 @@ def resume upload_url: nil, driver = nil @mutex.synchronize do - target_url, target_chunk_size, resolved_offset = validate_and_bind_resume( - target_url, target_chunk_size, stream_offset - ) + raise SessionStateError, "A run is already in progress for this session" if @running + raise SessionStateError, "Session has already executed a run" if bound_internal? + + if @stream.respond_to?(:pos) && !@stream.pos.zero? + raise ArgumentError, "Stream must be positioned at byte 0 to resume an upload (got pos #{@stream.pos})" + end + + @executed = true @running = true - config = build_resume_config target_url, target_chunk_size, resolved_offset + @upload_url = target_url + config = build_resume_config target_url, target_chunk_size driver = Driver.new client_stub: @client_stub, config: config, logger: @logger end @@ -242,17 +253,13 @@ def upload_url_internal end def bound_internal? - !upload_url_internal.nil? + @executed || !upload_url_internal.nil? end def resume_handle_internal @last_driver&.resume_handle end - def dead_internal? - bound_internal? && resume_handle_internal.nil? - end - def build_start_config CompleteUploadConfig.new( initial_url: @initial_url, @@ -270,12 +277,11 @@ def build_start_config ) end - def build_resume_config target_url, target_chunk_size, stream_offset + def build_resume_config target_url, target_chunk_size ResumeUploadConfig.new( upload_url: target_url, chunk_size: target_chunk_size, stream: @stream, - stream_offset: stream_offset || 0, upload_size: @upload_size, content_type: @content_type, timeout: @timeout, @@ -296,48 +302,8 @@ def resolve_resume_args upload_url:, chunk_size:, resume_handle: elsif chunk_size raise ArgumentError, "Cannot pass chunk_size without upload_url" else - [nil, nil] - end - end - - def validate_and_bind_resume target_url, target_chunk_size, stream_offset - raise SessionStateError, "A run is already in progress for this session" if @running - - if target_url.nil? - validate_bare_resume stream_offset - else - validate_explicit_resume target_url, target_chunk_size, stream_offset - end - end - - def validate_bare_resume stream_offset - unless bound_internal? - raise SessionStateError, "Cannot resume unbound session without resume_handle or upload_url" - end - raise SessionStateError, "Session is dead and cannot be resumed" if dead_internal? - - resolved_url = upload_url_internal - resolved_chunk = resume_handle_internal&.chunk_size || @chunk_size - raise SessionStateError, "No chunk_size available to resume session" if resolved_chunk.nil? - - resolved_offset = stream_offset || ( - @stream.respond_to?(:seek) ? 0 : (@last_driver&.stream_position || 0) - ) - @stream.seek resolved_offset if @stream.respond_to? :seek - - [resolved_url, resolved_chunk, resolved_offset] - end - - def validate_explicit_resume target_url, target_chunk_size, stream_offset - if bound_internal? && target_url != upload_url_internal - raise SessionStateError, "Session is already bound to a different upload: #{upload_url_internal}" + raise ArgumentError, "Must provide either resume_handle or upload_url and chunk_size" end - raise SessionStateError, "Session is dead and cannot be resumed" if dead_internal? - - @upload_url = target_url - resolved_offset = stream_offset || 0 - @stream.seek resolved_offset if @stream.respond_to? :seek - [target_url, target_chunk_size, resolved_offset] end def execute_run driver diff --git a/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb index f8c3285..2f8890b 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb @@ -142,7 +142,6 @@ def test_resume_upload_config_defaults assert_equal "https://upload.example.com/session1", config.upload_url assert_equal 1024, config.chunk_size assert_same stream, config.stream - assert_equal 0, config.stream_offset assert_nil config.upload_size assert_nil config.content_type assert_nil config.timeout @@ -178,9 +177,5 @@ def test_resume_upload_config_validations assert_raises ArgumentError do ResumeUploadConfig.new upload_url: "https://example.com", chunk_size: 1024, stream: nil end - - assert_raises ArgumentError do - ResumeUploadConfig.new upload_url: "https://example.com", chunk_size: 1024, stream: stream, stream_offset: -1 - end end end diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb index ac9a01c..9c3bf9e 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb @@ -289,39 +289,13 @@ def test_realign_buffer_fast_forward_unseekable_stream assert_equal "0123456789", stream.read(10) end - def test_driver_stream_position_tracking - stream = StringIO.new "0123456789" * 10 - driver = build_driver stream: stream - assert_equal 0, driver.stream_position - - driver.instance_variable_set :@buffer_start_offset, 100 - driver.instance_variable_set :@buffer, "01234".b - assert_equal 105, driver.stream_position - end - - def test_driver_honors_stream_offset_from_resume_config - stream = StringIO.new "0123456789" - resume_config = ResumeUploadConfig.new( - upload_url: "https://upload.example.com/session_resume", - chunk_size: 256, - stream: stream, - stream_offset: 500 - ) - driver = Driver.new client_stub: @dummy_client, config: resume_config - - assert_equal 500, driver.instance_variable_get(:@buffer_start_offset) - assert_equal 500, driver.stream_position - assert_equal "".b, driver.instance_variable_get(:@buffer) - end - def test_fast_forward_unseekable_stream_raises_stream_mismatch_on_unexpected_eof # Stream has only 20 bytes total stream = UnseekableStream.new "01234567890123456789" resume_config = ResumeUploadConfig.new( - upload_url: "https://upload.example.com/session_resume", - chunk_size: 256, - stream: stream, - stream_offset: 0 + upload_url: "https://upload.example.com/session_resume", + chunk_size: 256, + stream: stream ) driver = Driver.new client_stub: @dummy_client, config: resume_config driver.core.instance_variable_set( diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb index c2c0e4e..aaaa978 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb @@ -124,12 +124,11 @@ def test_resume_upload_success ] stub = FakeClientStub.new responses config = ResumeUploadConfig.new( - upload_url: "https://example.com/session/1", - chunk_size: 4, - stream: StringIO.new("0123456789"), - stream_offset: 0, - upload_size: 10, - on_progress: ->(p) { progress_records << p } + upload_url: "https://example.com/session/1", + chunk_size: 4, + stream: StringIO.new("0123456789"), + upload_size: 10, + on_progress: ->(p) { progress_records << p } ) driver = Driver.new client_stub: stub, config: config @@ -171,12 +170,11 @@ def test_resume_upload_with_409_recovery_retry ] stub = FakeClientStub.new responses config = ResumeUploadConfig.new( - upload_url: "https://example.com/session/1", - chunk_size: 4, - stream: StringIO.new("0123456789"), - stream_offset: 0, - upload_size: 10, - on_progress: ->(p) { progress_records << p } + upload_url: "https://example.com/session/1", + chunk_size: 4, + stream: StringIO.new("0123456789"), + upload_size: 10, + on_progress: ->(p) { progress_records << p } ) driver = Driver.new client_stub: stub, config: config diff --git a/gapic-common/test/gapic/rest/resumable_upload/session_test.rb b/gapic-common/test/gapic/rest/resumable_upload/session_test.rb index bd11b35..205ef24 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/session_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/session_test.rb @@ -61,6 +61,16 @@ def read length = nil end end + class StreamWithoutPos + def initialize string + @io = StringIO.new string + end + + def read length = nil + @io.read length + end + end + def build_session stub: nil, stream: nil, upload_size: 10, chunk_size: 4, **kwargs stream ||= StringIO.new "0123456789" stub ||= ScriptedClientStub.new @@ -81,32 +91,28 @@ def build_session stub: nil, stream: nil, upload_size: 10, chunk_size: 4, **kwar def test_initialize_mandatory_arguments assert_raises ArgumentError do - Session.new stream: StringIO.new, initial_url: "http://x", initial_body: "" + Session.new stream: StringIO.new, initial_url: "http://x" end assert_raises ArgumentError do - Session.new client_stub: ScriptedClientStub.new, initial_url: "http://x", initial_body: "" + Session.new client_stub: ScriptedClientStub.new, initial_url: "http://x" end assert_raises ArgumentError do - Session.new client_stub: ScriptedClientStub.new, stream: StringIO.new, initial_body: "" - end - - assert_raises ArgumentError do - Session.new client_stub: ScriptedClientStub.new, stream: StringIO.new, initial_url: "http://x" + Session.new client_stub: ScriptedClientStub.new, stream: StringIO.new end end def test_initialize_defaults session = Session.new( - client_stub: ScriptedClientStub.new, - stream: StringIO.new("abc"), - initial_url: "https://example.com/initiate", - initial_body: "", - upload_size: 300 + client_stub: ScriptedClientStub.new, + stream: StringIO.new("abc"), + initial_url: "https://example.com/initiate", + upload_size: 300 ) assert_equal 300, session.upload_size + assert_nil session.initial_body assert_equal({}, session.initial_headers) assert_nil session.chunk_size assert_nil session.content_type @@ -119,7 +125,7 @@ def test_initialize_defaults end # ============================================================================ - # 2. Observable States: Unbound + # 2. Observable States: Unbound & Bound # ============================================================================ def test_initial_unbound_state @@ -131,19 +137,11 @@ def test_initial_unbound_state refute session.running? end - def test_bare_resume_on_unbound_session_raises_session_state_error - session = build_session - err = assert_raises SessionStateError do - session.resume - end - assert_includes err.message, "Cannot resume unbound session without resume_handle or upload_url" - end - # ============================================================================ - # 3. Start Lifecycle & Bound Transitions + # 3. Start Lifecycle & Single-Run Contract # ============================================================================ - def test_start_successful_upload_transitions_to_bound_dead + def test_start_successful_upload_transitions_to_bound responses = [ # Initiation response FakeResponse.new( @@ -188,7 +186,7 @@ def test_start_successful_upload_transitions_to_bound_dead assert_nil session.resume_handle end - def test_start_when_already_bound_raises_session_state_error + def test_second_start_raises_session_state_error responses = [ FakeResponse.new( status: 200, @@ -218,17 +216,11 @@ def test_start_when_already_bound_raises_session_state_error err = assert_raises SessionStateError do session.start end - assert_includes err.message, "Session is already bound to an upload" + assert_includes err.message, "Session has already executed a run" end - # ============================================================================ - # 4. Resume Forms: Three Mutually Exclusive Forms - # ============================================================================ - - def test_resume_form1_bare_resume_on_bound_alive_session - # First run fails during recovery query with connection failure - stub = ScriptedClientStub.new [ - # Initiation succeeds -> binds session to upload URL + def test_resume_after_start_raises_session_state_error + responses = [ FakeResponse.new( status: 200, headers: { @@ -238,30 +230,33 @@ def test_resume_form1_bare_resume_on_bound_alive_session }, body: "" ), - # Chunk 1 returns 503 -> triggers Category 2 recovery - FakeResponse.new(status: 503, headers: {}, body: "Service Unavailable"), - # Recovery query fails with connection error -> raises RequestFailedError - Faraday::ConnectionFailed.new("network connection failed") + FakeResponse.new( + status: 200, + headers: { "x-goog-upload-status" => "final" }, + body: '{"done":true}' + ) ] + session = build_session( + stub: ScriptedClientStub.new(responses), + stream: StringIO.new("01"), + upload_size: 2, + chunk_size: 4 + ) + session.start - session = build_session stub: stub, upload_size: 10, chunk_size: 4 - - raised = assert_raises RequestFailedError do - session.start + handle = ResumeHandle.new upload_url: "https://upload.example.com/session_1", chunk_size: 4 + err = assert_raises SessionStateError do + session.resume resume_handle: handle end + assert_includes err.message, "Session has already executed a run" + end - assert_includes raised.message, "(upload session is resumable: see #resume_handle)" - refute_nil raised.resume_handle - # State: Bound and Alive - assert session.bound? - assert session.resumable? - refute session.running? - assert_equal "https://upload.example.com/session_1", session.upload_url - assert_equal "https://upload.example.com/session_1", session.resume_handle.upload_url + # ============================================================================ + # 4. Resume Forms: Explicit URL or ResumeHandle + # ============================================================================ - # Second run: bare resume continues the bound upload - recovery_responses = [ - # Query response + def test_resume_explicit_url_and_chunk_size_binds_and_executes + responses = [ FakeResponse.new( status: 200, headers: { @@ -270,56 +265,25 @@ def test_resume_form1_bare_resume_on_bound_alive_session }, body: "" ), - # Chunk 1 - FakeResponse.new( - status: 200, - headers: { "x-goog-upload-status" => "active" }, - body: "" - ), - # Chunk 2 - FakeResponse.new( - status: 200, - headers: { "x-goog-upload-status" => "active" }, - body: "" - ), - # Chunk 3 (final) FakeResponse.new( status: 200, headers: { "x-goog-upload-status" => "final" }, - body: '{"resumed":true}' + body: '{"from_url":true}' ) ] - stub.instance_variable_set :@responses, recovery_responses - - result = session.resume - assert_equal '{"resumed":true}', result - assert session.bound? - refute session.resumable? - end - - def test_resume_form1_bare_resume_on_seekable_stream_without_manual_rewind - stub = ScriptedClientStub.new [ - FakeResponse.new( - status: 200, - headers: { - "x-goog-upload-status" => "active", - "x-goog-upload-url" => "https://upload.example.com/session_bare", - "x-goog-upload-chunk-granularity" => "4" - }, - body: "" - ), - FakeResponse.new(status: 503, headers: {}, body: "Service Unavailable"), - Faraday::ConnectionFailed.new("network connection failed") - ] + stub = ScriptedClientStub.new responses session = build_session stub: stub, stream: StringIO.new("01"), upload_size: 2, chunk_size: 4 - assert_raises RequestFailedError do - session.start - end + refute session.bound? + result = session.resume upload_url: "https://upload.example.com/direct", chunk_size: 4 + + assert_equal '{"from_url":true}', result assert session.bound? - assert session.resumable? + assert_equal "https://upload.example.com/direct", session.upload_url + end - stub.instance_variable_set :@responses, [ + def test_resume_resume_handle_binds_and_executes + responses = [ FakeResponse.new( status: 200, headers: { @@ -328,70 +292,26 @@ def test_resume_form1_bare_resume_on_seekable_stream_without_manual_rewind }, body: "" ), - FakeResponse.new(status: 200, headers: { "x-goog-upload-status" => "final" }, body: '{"resumed_bare":true}') - ] - - result = session.resume - assert_equal '{"resumed_bare":true}', result - assert session.bound? - refute session.resumable? - end - - def test_resume_form1_bare_resume_on_unseekable_stream_derives_offset - stream = UnseekableStream.new "0123456789" - stub = ScriptedClientStub.new [ - # Initiation FakeResponse.new( status: 200, - headers: { - "x-goog-upload-status" => "active", - "x-goog-upload-url" => "https://upload.example.com/session_unseekable", - "x-goog-upload-chunk-granularity" => "4" - }, - body: "" - ), - # Chunk 1 (0-3) returns 503 -> recovery - FakeResponse.new(status: 503, headers: {}, body: "Service Unavailable"), - # Recovery query fails -> RequestFailedError - Faraday::ConnectionFailed.new("network connection failed") + headers: { "x-goog-upload-status" => "final" }, + body: '{"from_handle":true}' + ) ] + stub = ScriptedClientStub.new responses + session = build_session stub: stub, stream: StringIO.new("01"), upload_size: 2, chunk_size: 4 - session = build_session stub: stub, stream: stream, upload_size: 10, chunk_size: 4 - assert_raises RequestFailedError do - session.start - end - - assert session.bound? - assert session.resumable? - assert_equal 4, stream.pos + handle = ResumeHandle.new upload_url: "https://upload.example.com/from_handle", chunk_size: 4 - # Script responses for bare resume - stub.instance_variable_set :@responses, [ - # Recovery query on resume: server acknowledges 4 bytes received - FakeResponse.new( - status: 200, - headers: { - "x-goog-upload-status" => "active", - "x-goog-upload-size-received" => "4" - }, - body: "" - ), - # Chunk 2 (4-7) - FakeResponse.new(status: 200, headers: { "x-goog-upload-status" => "active" }, body: ""), - # Final chunk (8-9) - FakeResponse.new(status: 200, headers: { "x-goog-upload-status" => "final" }, body: '{"unseekable_resumed":true}') - ] + refute session.bound? + result = session.resume resume_handle: handle - result = session.resume - assert_equal '{"unseekable_resumed":true}', result - assert_equal 10, stream.pos + assert_equal '{"from_handle":true}', result assert session.bound? - refute session.resumable? - # Verify derived stream_offset was 4 in the resume driver config - assert_equal 4, session.instance_variable_get(:@last_driver).instance_variable_get(:@config).stream_offset + assert_equal "https://upload.example.com/from_handle", session.upload_url end - def test_resume_form2_explicit_url_and_chunk_size_binds_unbound_session + def test_start_after_resume_raises_session_state_error responses = [ FakeResponse.new( status: 200, @@ -401,24 +321,20 @@ def test_resume_form2_explicit_url_and_chunk_size_binds_unbound_session }, body: "" ), - FakeResponse.new( - status: 200, - headers: { "x-goog-upload-status" => "final" }, - body: '{"from_url":true}' - ) + FakeResponse.new(status: 200, headers: { "x-goog-upload-status" => "final" }, body: '{"ok":true}') ] stub = ScriptedClientStub.new responses session = build_session stub: stub, stream: StringIO.new("01"), upload_size: 2, chunk_size: 4 + session.resume upload_url: "https://upload.example.com/direct", chunk_size: 4 - refute session.bound? - result = session.resume upload_url: "https://upload.example.com/direct", chunk_size: 4 - - assert_equal '{"from_url":true}', result assert session.bound? - assert_equal "https://upload.example.com/direct", session.upload_url + err = assert_raises SessionStateError do + session.start + end + assert_includes err.message, "Session has already executed a run" end - def test_resume_form3_resume_handle_binds_unbound_session + def test_second_resume_raises_session_state_error responses = [ FakeResponse.new( status: 200, @@ -428,60 +344,71 @@ def test_resume_form3_resume_handle_binds_unbound_session }, body: "" ), - FakeResponse.new( - status: 200, - headers: { "x-goog-upload-status" => "final" }, - body: '{"from_handle":true}' - ) + FakeResponse.new(status: 200, headers: { "x-goog-upload-status" => "final" }, body: '{"ok":true}') ] stub = ScriptedClientStub.new responses session = build_session stub: stub, stream: StringIO.new("01"), upload_size: 2, chunk_size: 4 + session.resume upload_url: "https://upload.example.com/direct", chunk_size: 4 - handle = ResumeHandle.new upload_url: "https://upload.example.com/from_handle", chunk_size: 4 - - refute session.bound? - result = session.resume resume_handle: handle - - assert_equal '{"from_handle":true}', result assert session.bound? - assert_equal "https://upload.example.com/from_handle", session.upload_url + err = assert_raises SessionStateError do + session.resume upload_url: "https://upload.example.com/direct", chunk_size: 4 + end + assert_includes err.message, "Session has already executed a run" end # ============================================================================ - # 5. Resume Argument Shape & Lifecycle Violations + # 5. Argument Shape & Preconditions # ============================================================================ + def test_resume_without_arguments_raises_argument_error + session = build_session + err = assert_raises ArgumentError do + session.resume + end + assert_includes err.message, "Must provide either resume_handle or upload_url and chunk_size" + end + def test_resume_mixing_arguments_raises_argument_error session = build_session handle = ResumeHandle.new upload_url: "https://example.com", chunk_size: 4 - # Mixing resume_handle with upload_url assert_raises ArgumentError do session.resume resume_handle: handle, upload_url: "https://example.com" end - # Mixing resume_handle with chunk_size assert_raises ArgumentError do session.resume resume_handle: handle, chunk_size: 4 end - # upload_url without chunk_size assert_raises ArgumentError do session.resume upload_url: "https://example.com" end - # chunk_size without upload_url assert_raises ArgumentError do session.resume chunk_size: 4 end - # Invalid positional argument type assert_raises ArgumentError do session.resume handle end end - def test_resume_rebinding_different_upload_url_raises_session_state_error + def test_resume_with_non_zero_stream_pos_raises_argument_error + stream = StringIO.new "0123456789" + stream.seek 4 + + session = build_session stream: stream + handle = ResumeHandle.new upload_url: "https://upload.example.com/from_handle", chunk_size: 4 + + err = assert_raises ArgumentError do + session.resume resume_handle: handle + end + assert_includes err.message, "Stream must be positioned at byte 0 to resume an upload (got pos 4)" + end + + def test_resume_with_stream_without_pos_is_trusted + stream = StreamWithoutPos.new "01" responses = [ FakeResponse.new( status: 200, @@ -491,72 +418,85 @@ def test_resume_rebinding_different_upload_url_raises_session_state_error }, body: "" ), - FakeResponse.new( - status: 200, - headers: { "x-goog-upload-status" => "final" }, - body: '{"done":true}' - ) + FakeResponse.new(status: 200, headers: { "x-goog-upload-status" => "final" }, body: '{"ok":true}') ] - session = build_session( - stub: ScriptedClientStub.new(responses), - stream: StringIO.new("01"), - upload_size: 2, - chunk_size: 4 - ) - session.resume upload_url: "https://upload.example.com/session_a", chunk_size: 4 - - assert session.bound? - assert_equal "https://upload.example.com/session_a", session.upload_url - - # Attempting to resume with a different upload_url - err = assert_raises SessionStateError do - session.resume upload_url: "https://upload.example.com/session_b", chunk_size: 4 - end - assert_includes err.message, "Session is already bound to a different upload" + stub = ScriptedClientStub.new responses + session = build_session stub: stub, stream: stream, upload_size: 2, chunk_size: 4 - handle_b = ResumeHandle.new upload_url: "https://upload.example.com/session_b", chunk_size: 4 - err2 = assert_raises SessionStateError do - session.resume resume_handle: handle_b - end - assert_includes err2.message, "Session is already bound to a different upload" + result = session.resume upload_url: "https://upload.example.com/direct", chunk_size: 4 + assert_equal '{"ok":true}', result end - def test_resume_on_bound_dead_session_raises_session_state_error - responses = [ + # ============================================================================ + # 6. Cross-Session Resumption + # ============================================================================ + + def test_cross_session_resumption_from_failed_run + stream = StringIO.new "0123456789" + stub1 = ScriptedClientStub.new [ + # Initiation succeeds FakeResponse.new( status: 200, headers: { "x-goog-upload-status" => "active", - "x-goog-upload-url" => "https://upload.example.com/session_dead", + "x-goog-upload-url" => "https://upload.example.com/session_cross", "x-goog-upload-chunk-granularity" => "4" }, body: "" ), + # Chunk 1 returns 503 + FakeResponse.new(status: 503, headers: {}, body: "Service Unavailable"), + # Recovery query fails + Faraday::ConnectionFailed.new("network connection failed") + ] + + session1 = build_session stub: stub1, stream: stream, upload_size: 10, chunk_size: 4 + raised = assert_raises RequestFailedError do + session1.start + end + + assert session1.bound? + assert session1.resumable? + handle = session1.resume_handle + refute_nil handle + assert_equal handle, raised.resume_handle + assert_equal "https://upload.example.com/session_cross", handle.upload_url + assert_equal 4, handle.chunk_size + + # Prepare for session 2: rewind the stream to byte 0 + stream.rewind + assert_equal 0, stream.pos + + stub2 = ScriptedClientStub.new [ + # Recovery query on resume: server acknowledges 0 bytes received FakeResponse.new( status: 200, - headers: { "x-goog-upload-status" => "final" }, - body: '{"completed":true}' - ) + headers: { + "x-goog-upload-status" => "active", + "x-goog-upload-size-received" => "0" + }, + body: "" + ), + # Chunk 1 + FakeResponse.new(status: 200, headers: { "x-goog-upload-status" => "active" }, body: ""), + # Chunk 2 + FakeResponse.new(status: 200, headers: { "x-goog-upload-status" => "active" }, body: ""), + # Chunk 3 (final) + FakeResponse.new(status: 200, headers: { "x-goog-upload-status" => "final" }, body: '{"resumed":true}') ] - session = build_session( - stub: ScriptedClientStub.new(responses), - stream: StringIO.new("01"), - upload_size: 2, - chunk_size: 4 - ) - session.start - assert session.bound? - refute session.resumable? + session2 = build_session stub: stub2, stream: stream, upload_size: 10, chunk_size: 4 + refute session2.bound? - err = assert_raises SessionStateError do - session.resume - end - assert_includes err.message, "Session is dead and cannot be resumed" + result = session2.resume resume_handle: handle + assert_equal '{"resumed":true}', result + assert session2.bound? + refute session2.resumable? + assert_nil session2.resume_handle end # ============================================================================ - # 6. Concurrency & Running Guard + # 7. Concurrency & Running Guard # ============================================================================ def test_running_guard_prevents_concurrent_runs @@ -600,8 +540,9 @@ def test_running_guard_prevents_concurrent_runs assert session.running? # Concurrent call from another thread raises SessionStateError + handle = ResumeHandle.new upload_url: "https://upload.example.com/session_block", chunk_size: 4 err = assert_raises SessionStateError do - session.resume + session.resume resume_handle: handle end assert_includes err.message, "A run is already in progress for this session" @@ -619,7 +560,7 @@ def test_running_guard_prevents_concurrent_runs end # ============================================================================ - # 7. Driver#upload_url Direct Verification + # 8. Driver#upload_url Direct Verification # ============================================================================ def test_driver_upload_url_across_statuses From 47debccb444500fcc70e809f8085afab7ee64cf7 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Thu, 10 Sep 2026 06:49:45 +0000 Subject: [PATCH 62/79] integration helpers --- .../integration/integration_helper.rb | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/gapic-common/integration/integration_helper.rb b/gapic-common/integration/integration_helper.rb index 951eadc..4b363d0 100644 --- a/gapic-common/integration/integration_helper.rb +++ b/gapic-common/integration/integration_helper.rb @@ -122,4 +122,42 @@ def build_config scenario: nil, scenario_config: {}, **overrides Gapic::Rest::ResumableUpload::CompleteUploadConfig.new(**defaults, **overrides, initial_headers: headers) end + + def raw_start scenario: nil, scenario_config: {}, upload_size: nil, headers: {} + req_headers = { + "X-Goog-Upload-Protocol" => "resumable", + "X-Goog-Upload-Command" => "start" + } + req_headers["X-Goog-Upload-Header-Content-Length"] = upload_size.to_s if upload_size + if scenario + req_headers["X-Goog-Test-Scenario"] = scenario + req_headers["X-Goog-Test-Scenario-Config"] = JSON.generate( + { "client_uuid" => SecureRandom.uuid }.merge(scenario_config) + ) + end + req_headers.merge! headers + + response = showcase_client_stub.make_post_request( + uri: UPLOAD_PATH, + body: nil, + options: { metadata: req_headers } + ) + Gapic::Rest::ResumableUpload::Rules.header_value response.headers, "x-goog-upload-url" + end + + def raw_upload upload_url:, offset:, bytes:, finalize: false, headers: {} + req_headers = { + "X-Goog-Upload-Command" => finalize ? "upload, finalize" : "upload", + "X-Goog-Upload-Offset" => offset.to_s, + "Content-Type" => "application/octet-stream", + "Content-Length" => bytes.bytesize.to_s + } + req_headers.merge! headers + + showcase_client_stub.make_post_request( + uri: upload_url, + body: bytes, + options: { metadata: req_headers } + ) + end end From 2048e7e463c42e61ec158357bbbd51ec014c01a0 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Thu, 10 Sep 2026 07:01:33 +0000 Subject: [PATCH 63/79] test: integration tests for resume --- gapic-common/design/integration-test-plan.md | 30 +++ .../integration/integration_helper.rb | 33 +++ .../resumable_upload/resume_test.rb | 229 ++++++++++++++++++ 3 files changed, 292 insertions(+) create mode 100644 gapic-common/integration/resumable_upload/resume_test.rb diff --git a/gapic-common/design/integration-test-plan.md b/gapic-common/design/integration-test-plan.md index d483f87..5f4b89f 100644 --- a/gapic-common/design/integration-test-plan.md +++ b/gapic-common/design/integration-test-plan.md @@ -251,5 +251,35 @@ Tests non-fatal transient retries, missing status headers, retry exhaustion, fat * Both runs successfully complete uploading 100 bytes. * Demonstrates Showcase session state isolation across sequential client sessions. +--- + +### 2.5 Resumption Suite (`integration/resumable_upload/resume_test.rb`) + +Tests `Gapic::Rest::ResumableUpload::Session` resumption capabilities against Showcase. For full details, see [suite-d-resume-test-plan.md](./suite-d-resume-test-plan.md). + +#### Case 1. Resume in-progress upload on seekable stream (`test_resume_in_progress_upload`) +* Uploads chunk 1 via `raw_upload`, then resumes with a fresh session and full stream. +* Asserts `phases == [:initiating, :uploading, :uploading, :uploading, :finalizing, :completed]` and offsets align correctly. + +#### Case 2. Resume already finalized upload (`test_resume_finalized_upload`) +* Finalizes upload upfront via `raw_upload(finalize: true)`, then attempts to resume. +* Asserts direct completion with `phases == [:initiating, :completed]` and final payload parsed cleanly. + +#### Case 3. Non-fatal errors on query during resume +* **Case 3a (`test_resume_query_503_absorbed_by_retry`)**: 503 on resume query is absorbed transparently by `control_plane_retry_policy`. +* **Case 3b (`test_resume_query_409_triggers_retry_recovery`)**: 409 Category 2 error on query triggers `retry_recovery` re-querying without progress notification (`refute_includes phases, :recovering`). + +#### Case 4. Unseekable stream fast-forward on resume (`test_resume_unseekable_stream_fast_forwards`) +* Resumes using an `UnseekableStream` starting at byte 0. +* Verifies Driver fast-forwards by discarding bytes up to server offset, then uploads remaining chunks. + +#### Case 5. Stream mismatch errors on resume +* **Case 5a (`test_resume_wrong_stream_unseekable_mismatch`)**: Unseekable stream hitting unexpected EOF during discard raises `StreamMismatchError`. +* **Case 5b (`test_resume_wrong_stream_seekable_size_guard`)**: Seekable `StringIO` with `server_offset > stream.size` (and `upload_size: nil`) raises `StreamMismatchError` via stream size guard. +#### Case 6. Golden user-style resume (`test_golden_user_style_resume_seekable`, `test_golden_user_style_resume_unseekable`) +* User raises exception in `on_progress` carrying `session.resume_handle` on first upload ack. +* Fresh session resumes via `resume_handle: handle` and completes the transfer. Tested on both seekable and unseekable streams (rewound to 0). +#### Case 7. Lifecycle and contract violations (`test_lifecycle_violations`) +* Verifies second `start` and `resume` on bound session raise `SessionStateError`, and bare `resume` raises `ArgumentError`. diff --git a/gapic-common/integration/integration_helper.rb b/gapic-common/integration/integration_helper.rb index 4b363d0..b828487 100644 --- a/gapic-common/integration/integration_helper.rb +++ b/gapic-common/integration/integration_helper.rb @@ -50,6 +50,10 @@ def read length = nil def pos @io.pos end + + def rewind + @io.rewind + end end attr_reader :logger @@ -123,6 +127,35 @@ def build_config scenario: nil, scenario_config: {}, **overrides Gapic::Rest::ResumableUpload::CompleteUploadConfig.new(**defaults, **overrides, initial_headers: headers) end + def build_session scenario: nil, scenario_config: {}, **overrides + @progress_records = [] + headers = (overrides.delete(:initial_headers) || {}).dup + if scenario + headers["X-Goog-Test-Scenario"] = scenario + headers["X-Goog-Test-Scenario-Config"] = JSON.generate( + { "client_uuid" => SecureRandom.uuid }.merge(scenario_config) + ) + end + + defaults = { + client_stub: showcase_client_stub, + initial_url: UPLOAD_PATH, + initial_headers: headers, + start_retry_policy: FAST_RETRY, + control_plane_retry_policy: FAST_RETRY, + data_plane_retry_policy: FAST_RETRY, + timeout: 10, + chunk_size: DEFAULT_CHUNK_SIZE, + on_progress: ->(progress) { @progress_records << progress } + } + unless overrides.key? :stream + defaults[:stream] = StringIO.new payload(DEFAULT_PAYLOAD_SIZE) + defaults[:upload_size] = DEFAULT_PAYLOAD_SIZE + end + + Gapic::Rest::ResumableUpload::Session.new(**defaults, **overrides) + end + def raw_start scenario: nil, scenario_config: {}, upload_size: nil, headers: {} req_headers = { "X-Goog-Upload-Protocol" => "resumable", diff --git a/gapic-common/integration/resumable_upload/resume_test.rb b/gapic-common/integration/resumable_upload/resume_test.rb new file mode 100644 index 0000000..432ed5e --- /dev/null +++ b/gapic-common/integration/resumable_upload/resume_test.rb @@ -0,0 +1,229 @@ +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "integration_helper" +require "json" +require "stringio" + +## +# Suite D: Integration tests for Resumable Upload Session resumption against Showcase. +# +class ResumeTest < ShowcaseIntegrationTest + ## + # Custom error to simulate a user aborting an in-progress transfer from inside on_progress. + # + class UserPauseError < StandardError + attr_reader :resume_handle + + def initialize message, resume_handle + super message + @resume_handle = resume_handle + end + end + + # D1. Resume an in-progress upload on a seekable stream. + def test_resume_in_progress_upload + upload_url = raw_start upload_size: DEFAULT_PAYLOAD_SIZE + chunk1 = payload(DEFAULT_PAYLOAD_SIZE).byteslice 0, DEFAULT_CHUNK_SIZE + raw_upload upload_url: upload_url, offset: 0, bytes: chunk1, finalize: false + + session = build_session + result = session.resume upload_url: upload_url, chunk_size: DEFAULT_CHUNK_SIZE + parsed = JSON.parse result + + assert_equal DEFAULT_PAYLOAD_SIZE, parsed["size"] + assert_equal [:initiating, :uploading, :uploading, :uploading, :finalizing, :completed], phases + assert_equal [0, 262_144, 524_288, 786_432, 786_432, 786_432], offsets + end + + # D2. Resuming an already-finalized upload terminates cleanly and returns final body. + def test_resume_finalized_upload + upload_url = raw_start upload_size: DEFAULT_CHUNK_SIZE + raw_upload upload_url: upload_url, offset: 0, bytes: payload(DEFAULT_CHUNK_SIZE), finalize: true + + session = build_session stream: StringIO.new(payload(DEFAULT_CHUNK_SIZE)), upload_size: DEFAULT_CHUNK_SIZE + result = session.resume upload_url: upload_url, chunk_size: DEFAULT_CHUNK_SIZE + parsed = JSON.parse result + + assert_equal DEFAULT_CHUNK_SIZE, parsed["size"] + assert_equal [:initiating, :completed], phases + end + + # D3a. Non-fatal 503 error on query during resume is absorbed by control plane retry policy. + def test_resume_query_503_absorbed_by_retry + upload_url = raw_start( + scenario: "non_fatal_error_on_query", + scenario_config: { error_code: 503, failure_count: 1 }, + upload_size: DEFAULT_PAYLOAD_SIZE + ) + chunk1 = payload(DEFAULT_PAYLOAD_SIZE).byteslice 0, DEFAULT_CHUNK_SIZE + raw_upload upload_url: upload_url, offset: 0, bytes: chunk1, finalize: false + + session = build_session + result = session.resume upload_url: upload_url, chunk_size: DEFAULT_CHUNK_SIZE + parsed = JSON.parse result + + assert_equal DEFAULT_PAYLOAD_SIZE, parsed["size"] + assert_equal [:initiating, :uploading, :uploading, :uploading, :finalizing, :completed], phases + end + + # D3b. Non-fatal 409 error on query during resume triggers protocol retry_recovery without progress notification. + def test_resume_query_409_triggers_retry_recovery + upload_url = raw_start( + scenario: "non_fatal_error_on_query", + scenario_config: { error_code: 409, failure_count: 1 }, + upload_size: DEFAULT_PAYLOAD_SIZE + ) + chunk1 = payload(DEFAULT_PAYLOAD_SIZE).byteslice 0, DEFAULT_CHUNK_SIZE + raw_upload upload_url: upload_url, offset: 0, bytes: chunk1, finalize: false + + session = build_session + result = session.resume upload_url: upload_url, chunk_size: DEFAULT_CHUNK_SIZE + parsed = JSON.parse result + + assert_equal DEFAULT_PAYLOAD_SIZE, parsed["size"] + assert_equal [:initiating, :uploading, :uploading, :uploading, :finalizing, :completed], phases + refute_includes phases, :recovering + end + + # D4. Resume fast-forwards by discarding bytes on an unseekable stream starting at byte 0. + def test_resume_unseekable_stream_fast_forwards + upload_url = raw_start upload_size: DEFAULT_PAYLOAD_SIZE + chunk1 = payload(DEFAULT_PAYLOAD_SIZE).byteslice 0, DEFAULT_CHUNK_SIZE + raw_upload upload_url: upload_url, offset: 0, bytes: chunk1, finalize: false + + stream = UnseekableStream.new payload(DEFAULT_PAYLOAD_SIZE) + session = build_session stream: stream + result = session.resume upload_url: upload_url, chunk_size: DEFAULT_CHUNK_SIZE + parsed = JSON.parse result + + assert_equal DEFAULT_PAYLOAD_SIZE, parsed["size"] + assert_equal [:initiating, :uploading, :uploading, :uploading, :finalizing, :completed], phases + assert_equal [0, 262_144, 524_288, 786_432, 786_432, 786_432], offsets + end + + # D5a. Resume with unseekable stream shorter than acknowledged server offset raises StreamMismatchError. + def test_resume_wrong_stream_unseekable_mismatch + upload_url = raw_start upload_size: DEFAULT_CHUNK_SIZE + raw_upload upload_url: upload_url, offset: 0, bytes: payload(DEFAULT_CHUNK_SIZE), finalize: false + + stream = UnseekableStream.new payload(100) + session = build_session stream: stream, upload_size: nil + + assert_raises Gapic::Rest::ResumableUpload::StreamMismatchError do + session.resume upload_url: upload_url, chunk_size: DEFAULT_CHUNK_SIZE + end + refute_includes phases, :finalizing + end + + # D5b. Resume with seekable stream shorter than server offset raises StreamMismatchError via stream.size guard. + def test_resume_wrong_stream_seekable_size_guard + upload_url = raw_start upload_size: DEFAULT_CHUNK_SIZE + raw_upload upload_url: upload_url, offset: 0, bytes: payload(DEFAULT_CHUNK_SIZE), finalize: false + + stream = StringIO.new payload(100) + session = build_session stream: stream, upload_size: nil + + assert_raises Gapic::Rest::ResumableUpload::StreamMismatchError do + session.resume upload_url: upload_url, chunk_size: DEFAULT_CHUNK_SIZE + end + refute_includes phases, :finalizing + end + + # D6a. Golden user-style resume on a seekable stream after user abort in on_progress. + def test_golden_user_style_resume_seekable + stream = StringIO.new payload(DEFAULT_PAYLOAD_SIZE) + session1 = nil + on_progress = lambda do |progress| + if progress.phase == :uploading && progress.bytes_uploaded == DEFAULT_CHUNK_SIZE + raise UserPauseError.new("user paused", session1.resume_handle) + end + end + + session1 = build_session stream: stream, on_progress: on_progress + err = assert_raises UserPauseError do + session1.start + end + + assert session1.bound? + assert session1.resumable? + handle = err.resume_handle + refute_nil handle + + stream.rewind + session2 = build_session stream: stream + result = session2.resume resume_handle: handle + parsed = JSON.parse result + + assert_equal DEFAULT_PAYLOAD_SIZE, parsed["size"] + assert session2.bound? + refute session2.resumable? + end + + # D6b. Golden user-style resume on an unseekable stream rewound to 0 before resumption. + def test_golden_user_style_resume_unseekable + stream = UnseekableStream.new payload(DEFAULT_PAYLOAD_SIZE) + session1 = nil + on_progress = lambda do |progress| + if progress.phase == :uploading && progress.bytes_uploaded == DEFAULT_CHUNK_SIZE + raise UserPauseError.new("user paused", session1.resume_handle) + end + end + + session1 = build_session stream: stream, on_progress: on_progress + err = assert_raises UserPauseError do + session1.start + end + + assert session1.bound? + assert session1.resumable? + handle = err.resume_handle + refute_nil handle + + stream.rewind + session2 = build_session stream: stream + result = session2.resume resume_handle: handle + parsed = JSON.parse result + + assert_equal DEFAULT_PAYLOAD_SIZE, parsed["size"] + assert session2.bound? + refute session2.resumable? + end + + # D7. Lifecycle and contract violations on session runs. + def test_lifecycle_violations + session = build_session stream: StringIO.new(payload(100)), upload_size: 100 + session.start + + assert session.bound? + + # Second start on executed session raises SessionStateError + assert_raises Gapic::Rest::ResumableUpload::SessionStateError do + session.start + end + + # Resume on already bound/executed session raises SessionStateError + assert_raises Gapic::Rest::ResumableUpload::SessionStateError do + session.resume upload_url: "https://example.com/test", chunk_size: DEFAULT_CHUNK_SIZE + end + + # Resume without parameters on fresh session raises ArgumentError + fresh_session = build_session + assert_raises ArgumentError do + fresh_session.resume + end + end +end From 9ff09182963a285a96053ebe018c5ddc26e0a8cb Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Thu, 10 Sep 2026 07:17:55 +0000 Subject: [PATCH 64/79] test: small fixes, reference-implementation synced --- gapic-common/design/integration-test-plan.md | 4 +- .../design/reference-implementation.md | 894 ++++++++++++++---- .../integration/integration_helper.rb | 7 +- .../resumable_upload/resume_test.rb | 10 +- 4 files changed, 710 insertions(+), 205 deletions(-) diff --git a/gapic-common/design/integration-test-plan.md b/gapic-common/design/integration-test-plan.md index 5f4b89f..236d358 100644 --- a/gapic-common/design/integration-test-plan.md +++ b/gapic-common/design/integration-test-plan.md @@ -255,7 +255,7 @@ Tests non-fatal transient retries, missing status headers, retry exhaustion, fat ### 2.5 Resumption Suite (`integration/resumable_upload/resume_test.rb`) -Tests `Gapic::Rest::ResumableUpload::Session` resumption capabilities against Showcase. For full details, see [suite-d-resume-test-plan.md](./suite-d-resume-test-plan.md). +Tests `Gapic::Rest::ResumableUpload::Session` resumption capabilities against Showcase. #### Case 1. Resume in-progress upload on seekable stream (`test_resume_in_progress_upload`) * Uploads chunk 1 via `raw_upload`, then resumes with a fresh session and full stream. @@ -279,7 +279,7 @@ Tests `Gapic::Rest::ResumableUpload::Session` resumption capabilities against Sh #### Case 6. Golden user-style resume (`test_golden_user_style_resume_seekable`, `test_golden_user_style_resume_unseekable`) * User raises exception in `on_progress` carrying `session.resume_handle` on first upload ack. -* Fresh session resumes via `resume_handle: handle` and completes the transfer. Tested on both seekable and unseekable streams (rewound to 0). +* Fresh session resumes via `resume_handle: handle` and completes the transfer. Tested on both seekable streams and fresh unseekable streams starting at byte 0. #### Case 7. Lifecycle and contract violations (`test_lifecycle_violations`) * Verifies second `start` and `resume` on bound session raise `SessionStateError`, and bare `resume` raises `ArgumentError`. diff --git a/gapic-common/design/reference-implementation.md b/gapic-common/design/reference-implementation.md index d1fb029..21ebcae 100644 --- a/gapic-common/design/reference-implementation.md +++ b/gapic-common/design/reference-implementation.md @@ -4,6 +4,7 @@ This document provides the complete reference implementation code for the core c - [1. Rules Module (`Gapic::Rest::ResumableUpload::Rules`)](#1-rules-module) - [2. Core Class (`Gapic::Rest::ResumableUpload::Core`)](#2-core-class) - [3. Driver Class (`Gapic::Rest::ResumableUpload::Driver`)](#3-driver-class) +- [4. Session Class (`Gapic::Rest::ResumableUpload::Session`)](#4-session-class) For system architecture, data models, buffer invariants, and state transition specifications, see the [Implementation Guide](implementation-guide.md). @@ -16,9 +17,48 @@ module Gapic module Rest module ResumableUpload module Rules + DEFAULT_CHUNK_SIZE = 8_388_608 # 8 MB CAT2_STATUS_CODES = [400, 408, 409, 412, 416, 429, 499].freeze FATAL_STATUS_CODES = [401, 403, 404, 405, 410, 413, 415].freeze + STATE_DESCRIPTIONS = { + initializing: "initializing upload", + starting: "initiating upload session", + transmission_reading: "reading chunk from stream", + transmission_sending: "sending a chunk of data", + finalizing_sending_upload: "sending final data chunk", + finalizing_sending_finalize: "sending finalize command", + recovery: "querying upload offset for recovery", + cancelling: "cancelling upload session", + success: "in completed upload state", + cancelled: "in cancelled upload state", + error: "in error state", + rejected: "in rejected upload state" + }.freeze + + RECIPES = [ + :start_session, + :resume_session, + :begin_transmission, + :send_chunk, + :send_upload_finalize, + :send_finalize, + :ack_chunk, + :enter_recovery, + :complete_upload_with_data, + :complete_upload_finalized, + :realign_from_recovery, + :retry_recovery, + :complete_cancellation, + :ignore_duplicate_cancel, + :cancel_session, + :fail_with_deadline_exceeded, + :fail_with_rejected, + :fail_with_bad_response, + :fail_with_request_error, + :fail_with_unmatched_transition + ].freeze + # Classifies incoming event into a canonical shape symbol. # Pure function: takes ONLY event, zero state awareness. # @@ -26,39 +66,33 @@ module Gapic # @return [Symbol] Canonical event shape def self.shape_of(event) case event - when Event::StartUpload + when Event::StartUpload, Event::StartUpload.singleton_class :start_upload + when Event::ResumeUpload, Event::ResumeUpload.singleton_class + :resume_upload when Event::ChunkRead - if !event.eof - :chunk_read_full - elsif event.bytes_buffered.positive? - :chunk_read_eof_with_data - else - :chunk_read_eof_empty - end - when Event::Cancel + classify_chunk_read(event) + when Event::Cancel, Event::Cancel.singleton_class :user_cancel - when Event::GlobalDeadlineExceeded + when Event::GlobalDeadlineExceeded, Event::GlobalDeadlineExceeded.singleton_class :global_deadline_exceeded when Event::RequestFailed - case event.kind - when :timeout then :request_timeout - when :retries_exhausted then :request_retries_exhausted - when :connection_failed then :request_connection_failed - else :request_failed_unknown - end + classify_request_failed(event) when Event::HttpResponse classify_http_response(event) + when Class + classify_event_class(event) else :unknown end end # Top-level transition decision engine. Matches [state.status, shape]. + # Pure function: computes next immutable State and driver instructions. # # @param state [State] Current state # @param event [Object] Input event - # @param config [CompleteUploadConfig] Static configuration + # @param config [CompleteUploadConfig, ResumeUploadConfig] Static configuration # @return [Decision] Decision snapshot def self.decide(state, event, config) shape = shape_of(event) @@ -66,6 +100,8 @@ module Gapic recipe = case [state.status, shape] in [:initializing, :start_upload] :start_session + in [:initializing, :resume_upload] + :resume_session in [:starting, :response_active] :begin_transmission in [:transmission_reading, :chunk_read_full] @@ -113,10 +149,10 @@ module Gapic next_state, instructions = public_send(recipe, state, event, config) Decision.new( - from_status: state.status, - shape: shape, - recipe: recipe, - next_state: next_state, + from_status: state.status, + shape: shape, + recipe: recipe, + next_state: next_state, instructions: instructions ) end @@ -132,24 +168,45 @@ module Gapic instructions = [ Instruction::NotifyProgress.new(progress: progress), Instruction::SendStart.new( - url: config.initial_url, + url: config.initial_url, headers: config.initial_headers, - body: config.initial_body + body: config.initial_body ) ] [next_state, instructions] end + def self.resume_session(state, _event, config) + next_state = state.with( + status: :recovery, + upload_url: config.upload_url, + chunk_size: config.chunk_size, + offset: 0 + ) + progress = Progress.new( + phase: :initiating, + bytes_uploaded: 0, + total_bytes: config.upload_size + ) + instructions = [ + Instruction::NotifyProgress.new(progress: progress), + Instruction::SendQuery.new(url: config.upload_url) + ] + [next_state, instructions] + end + def self.begin_transmission(state, event, config) - granularity = event.headers["x-goog-upload-chunk-granularity"]&.to_i + granularity_str = header_value(event.headers, "x-goog-upload-chunk-granularity") + granularity = granularity_str&.to_i chunk_size = resolve_chunk_size(config.chunk_size, granularity) + upload_url = header_value(event.headers, "x-goog-upload-url") next_state = state.with( - status: :transmission_reading, - upload_url: event.headers["x-goog-upload-url"], + status: :transmission_reading, + upload_url: upload_url, chunk_granularity: granularity, - chunk_size: chunk_size, - offset: 0, - in_flight_length: 0 + chunk_size: chunk_size, + offset: 0, + in_flight_length: 0 ) progress = Progress.new(phase: :uploading, bytes_uploaded: next_state.offset, total_bytes: config.upload_size) instructions = [ @@ -161,14 +218,14 @@ module Gapic def self.send_chunk(state, event, _config) next_state = state.with( - status: :transmission_sending, + status: :transmission_sending, in_flight_length: event.bytes_buffered ) instructions = [ Instruction::SendChunk.new( - url: state.upload_url, - offset: state.offset, - length: event.bytes_buffered, + url: state.upload_url, + offset: state.offset, + length: event.bytes_buffered, finalize: false ) ] @@ -177,16 +234,16 @@ module Gapic def self.send_upload_finalize(state, event, config) next_state = state.with( - status: :finalizing_sending_upload, + status: :finalizing_sending_upload, in_flight_length: event.bytes_buffered ) progress = Progress.new(phase: :finalizing, bytes_uploaded: next_state.offset, total_bytes: config.upload_size) instructions = [ Instruction::NotifyProgress.new(progress: progress), Instruction::SendChunk.new( - url: state.upload_url, - offset: state.offset, - length: event.bytes_buffered, + url: state.upload_url, + offset: state.offset, + length: event.bytes_buffered, finalize: true ) ] @@ -195,7 +252,7 @@ module Gapic def self.send_finalize(state, _event, config) next_state = state.with( - status: :finalizing_sending_finalize, + status: :finalizing_sending_finalize, in_flight_length: 0 ) progress = Progress.new(phase: :finalizing, bytes_uploaded: next_state.offset, total_bytes: config.upload_size) @@ -209,8 +266,8 @@ module Gapic def self.ack_chunk(state, _event, config) new_offset = state.offset + state.in_flight_length next_state = state.with( - status: :transmission_reading, - offset: new_offset, + status: :transmission_reading, + offset: new_offset, in_flight_length: 0 ) progress = Progress.new(phase: :uploading, bytes_uploaded: new_offset, total_bytes: config.upload_size) @@ -224,7 +281,7 @@ module Gapic def self.enter_recovery(state, _event, config) next_state = state.with( - status: :recovery, + status: :recovery, in_flight_length: 0 ) progress = Progress.new(phase: :recovering, bytes_uploaded: next_state.offset, total_bytes: config.upload_size) @@ -237,7 +294,7 @@ module Gapic def self.retry_recovery(state, _event, _config) next_state = state.with( - status: :recovery, + status: :recovery, in_flight_length: 0 ) [next_state, [Instruction::SendQuery.new(url: state.upload_url)]] @@ -246,8 +303,8 @@ module Gapic def self.complete_upload_with_data(state, event, _config) new_offset = state.offset + state.in_flight_length next_state = state.with( - status: :success, - offset: new_offset, + status: :success, + offset: new_offset, in_flight_length: 0 ) progress = Progress.new(phase: :completed, bytes_uploaded: new_offset, total_bytes: new_offset) @@ -260,7 +317,7 @@ module Gapic def self.complete_upload_finalized(state, event, _config) next_state = state.with( - status: :success, + status: :success, in_flight_length: 0 ) progress = Progress.new(phase: :completed, bytes_uploaded: next_state.offset, total_bytes: next_state.offset) @@ -272,10 +329,11 @@ module Gapic end def self.realign_from_recovery(state, event, config) - server_offset = event.headers["x-goog-upload-size-received"].to_i + server_offset_str = header_value(event.headers, "x-goog-upload-size-received") + server_offset = server_offset_str.to_i next_state = state.with( - status: :transmission_reading, - offset: server_offset, + status: :transmission_reading, + offset: server_offset, in_flight_length: 0 ) progress = Progress.new(phase: :uploading, bytes_uploaded: server_offset, total_bytes: config.upload_size) @@ -307,99 +365,139 @@ module Gapic [next_state, instructions] end + def self.resume_handle_from(state) + return nil if state.nil? || state.upload_url.nil? || [:rejected, :cancelled, :success].include?(state.status) + + ResumeHandle.new(upload_url: state.upload_url, chunk_size: state.chunk_size) + end + def self.fail_with_deadline_exceeded(state, _event, _config) - err = DeadlineExceededError.new + handle = resume_handle_from(state) + err = DeadlineExceededError.new(resume_handle: handle) next_state = state.with( - status: :error, + status: :error, in_flight_length: 0, - last_error: err + last_error: err ) [next_state, [Instruction::TerminateFailure.new(error: err)]] end def self.fail_with_rejected(state, event, _config) - err = UploadRejectedError.from(event) + handle = resume_handle_from(state) + err = UploadRejectedError.from(event, resume_handle: handle) next_state = state.with( - status: :rejected, + status: :rejected, in_flight_length: 0, - last_error: err + last_error: err ) [next_state, [Instruction::TerminateFailure.new(error: err)]] end def self.fail_with_bad_response(state, event, _config) - err = BadResponseError.from(event) + handle = resume_handle_from(state) + msg = "Unexpected response from server while #{STATE_DESCRIPTIONS[state.status]}" + err = BadResponseError.new(msg, event.status, headers: event.headers, resume_handle: handle) next_state = state.with( - status: :error, + status: :error, in_flight_length: 0, - last_error: err + last_error: err ) [next_state, [Instruction::TerminateFailure.new(error: err)]] end def self.fail_with_request_error(state, event, _config) - err = event.source_error || Gapic::Common::Error.new(event.message || "Request failed") + handle = resume_handle_from(state) + msg = "Request failed while #{STATE_DESCRIPTIONS[state.status]}: #{event.message}" + err = RequestFailedError.new(msg, source_error: event.source_error, resume_handle: handle) next_state = state.with( - status: :error, + status: :error, in_flight_length: 0, - last_error: err + last_error: err ) [next_state, [Instruction::TerminateFailure.new(error: err)]] end def self.fail_with_unmatched_transition(state, event, _config) - shape = shape_of(event) - action = STATE_DESCRIPTIONS[state.status] || "processing #{state.status}" - happened = describe_event(event, shape) - message = "Resumable upload failed while #{action}: #{happened}." - response = event.is_a?(Event::HttpResponse) ? event : nil - raise InvalidTransitionError.new(message, state: state.status, event: event, response: response) + err = InvalidTransitionError.new(state: state, event: event) + next_state = state.with( + status: :error, + in_flight_length: 0, + last_error: err + ) + [next_state, [Instruction::TerminateFailure.new(error: err)]] end - def self.describe_event(event, shape) - case event - when Event::HttpResponse - upload_status = event.headers["x-goog-upload-status"] || event.headers["X-Goog-Upload-Status"] - status_desc = upload_status ? "'#{upload_status}'" : "missing" - "received an unexpected HTTP #{event.status} response (X-Goog-Upload-Status: #{status_desc})" - when Event::ChunkRead - "received unexpected stream chunk read (#{event.bytes_buffered} bytes, eof: #{event.eof})" - when Event::RequestFailed - "encountered unexpected request failure (#{event.kind}: #{event.message})" + def self.resolve_chunk_size(requested_size, granularity) + base_size = requested_size || DEFAULT_CHUNK_SIZE + return base_size if granularity.nil? || !granularity.positive? + + (base_size / granularity) * granularity + end + + def self.header_value(headers, key) + return nil unless headers.is_a?(Hash) + return headers[key] if headers.key?(key) + + target = key.downcase + _, val = headers.find { |k, _| k.to_s.downcase == target } + val + end + + def self.classify_chunk_read(event) + if !event.eof + :chunk_read_full + elsif event.bytes_buffered.positive? + :chunk_read_eof_with_data else - "received unexpected event #{shape} (#{event.class.name})" + :chunk_read_eof_empty end end - private + def self.classify_request_failed(event) + case event.kind + when :timeout then :request_timeout + when :retries_exhausted then :request_retries_exhausted + when :connection_failed then :request_connection_failed + else :request_failed_unknown + end + end - def self.classify_http_response(response) - status_header = response.headers["x-goog-upload-status"]&.downcase - - case status_header - when "active" - response.status == 200 ? :response_active : :response_cat2 - when "final" - response.status == 200 ? :response_final : :response_rejected - when "cancelled" - response.status == 200 ? :response_cancelled : :response_fatal_bad_response - when nil, "" - if FATAL_STATUS_CODES.include?(response.status) - :response_fatal_bad_response - else + def self.classify_http_response(event) + case event.status + when 200..299 + status_hdr = header_value(event.headers, "x-goog-upload-status") + case status_hdr + when "active" then :response_active + when "final" then :response_final + when "cancelled" then :response_cancelled + else :response_fatal_bad_response + end + when *CAT2_STATUS_CODES + status_hdr = header_value(event.headers, "x-goog-upload-status") + if status_hdr.nil? || status_hdr.empty? || status_hdr == "active" :response_cat2 + else + :response_fatal_bad_response end - else + when *FATAL_STATUS_CODES :response_fatal_bad_response + else + :response_rejected end end - def self.resolve_chunk_size(user_chunk_size, chunk_granularity) - base_size = user_chunk_size || DEFAULT_CHUNK_SIZE - return base_size if chunk_granularity.nil? || chunk_granularity <= 0 - return chunk_granularity if base_size <= chunk_granularity - - base_size - (base_size % chunk_granularity) + def self.classify_event_class(klass) + if klass <= Event::StartUpload + :start_upload + elsif klass <= Event::ResumeUpload + :resume_upload + elsif klass <= Event::Cancel + :user_cancel + elsif klass <= Event::GlobalDeadlineExceeded + :global_deadline_exceeded + else + :unknown + end end end end @@ -418,14 +516,18 @@ module Gapic class Core attr_reader :state, :last_decision - # @param config [CompleteUploadConfig] + # @param config [CompleteUploadConfig, ResumeUploadConfig] def initialize(config) @config = config @last_decision = nil @state = State.new( - status: :initializing, upload_url: nil, offset: 0, - chunk_size: config.chunk_size || 8_388_608, - chunk_granularity: nil, in_flight_length: 0, last_error: nil + status: :initializing, + upload_url: nil, + offset: 0, + chunk_size: config.chunk_size || Rules::DEFAULT_CHUNK_SIZE, + chunk_granularity: nil, + in_flight_length: 0, + last_error: nil ) end @@ -462,8 +564,10 @@ module Gapic # Default base timeout in seconds (1 hour) BASE_TIMEOUT = 3_600 + attr_reader :core + # @param client_stub [Gapic::Rest::ClientStub] - # @param config [CompleteUploadConfig] + # @param config [CompleteUploadConfig, ResumeUploadConfig] # @param core [Core, nil] Optional Core state machine (defaults to new Core with config) # @param logger [Logger, nil] Optional logger override def initialize(client_stub:, config:, core: nil, logger: nil) @@ -474,50 +578,59 @@ module Gapic @buffer_start_offset = 0 endpoint = client_stub.respond_to?(:endpoint) ? client_stub.endpoint : nil - setup_logging logger: logger || (client_stub.respond_to?(:logger) ? client_stub.logger : nil), - system_name: "gapic-common", - service: "ResumableUpload", - endpoint: endpoint, - client_id: client_stub.object_id + setup_logging( + logger: logger || (client_stub.respond_to?(:logger) ? client_stub.logger : nil), + system_name: "gapic-common", + service: "ResumableUpload", + endpoint: endpoint, + client_id: client_stub.object_id + ) @upload_log = UploadLog.new(stub_logger, upload_id: "unstarted") @start_retry_policy = resolve_retry_policy(config.start_retry_policy, RetryPolicies::START_DEFAULTS) - @control_plane_retry_policy = resolve_retry_policy(config.control_plane_retry_policy, RetryPolicies::CONTROL_PLANE_DEFAULTS) - @data_plane_retry_policy = resolve_retry_policy(config.data_plane_retry_policy, RetryPolicies::DATA_PLANE_DEFAULTS) + @control_plane_retry_policy = resolve_retry_policy( + config.control_plane_retry_policy, + RetryPolicies::CONTROL_PLANE_DEFAULTS + ) + @data_plane_retry_policy = resolve_retry_policy( + config.data_plane_retry_policy, + RetryPolicies::DATA_PLANE_DEFAULTS + ) end - # Default retry policy for session initiation requests (start). - # Missing X-Goog-Upload-Status header is retriable across any response code, including 200 (predicate returns true). - # - # @return [Gapic::Common::RetryPolicy] def self.default_start_retry_policy RetryPolicies.default_start end - # Default retry policy for session control requests (query, cancel). - # Does not retry on missing X-Goog-Upload-Status header. - # - # @return [Gapic::Common::RetryPolicy] def self.default_control_plane_retry_policy RetryPolicies.default_control_plane end - # Default retry policy for data plane requests (upload, finalize, upload_finalize). - # Missing X-Goog-Upload-Status header is unretriable (predicate returns false), - # causing Driver to yield Event::HttpResponse so Core initiates Recovery. - # - # @return [Gapic::Common::RetryPolicy] def self.default_data_plane_retry_policy RetryPolicies.default_data_plane end + # Returns current resume handle, or nil if initiation is pending or session is finalized. + # + # @return [ResumeHandle, nil] + def resume_handle + Rules.resume_handle_from(@core.state) + end + + # Returns raw session upload URL. + # + # @return [String, nil] + def upload_url + @core.state.upload_url + end + # Executes event loop until terminal state. # # @return [String, Object] Final response body def run @upload_log = UploadLog.new(stub_logger, upload_id: LoggingConcerns.random_uuid4) @deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + resolve_timeout - pending_event = Event::StartUpload.new + pending_event = initial_event loop do instructions = dispatch_event(pending_event) @@ -537,6 +650,36 @@ module Gapic private + def initial_event + if @config.is_a?(ResumeUploadConfig) + Event::ResumeUpload.new + else + Event::StartUpload.new + end + end + + def resolve_timeout + return @config.timeout if @config.timeout&.positive? + + if @config.upload_size + [@config.upload_size.fdiv(MIN_ASSUMED_THROUGHPUT), BASE_TIMEOUT].max + else + BASE_TIMEOUT + end + end + + def deadline_exceeded? + Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @deadline + end + + def remaining_time + [@deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0.0].max + end + + def terminal_instructions?(instructions) + instructions.any? { |i| i.is_a?(Instruction::TerminateSuccess) || i.is_a?(Instruction::TerminateFailure) } + end + def dispatch_event(event) instructions = begin @core.dispatch(event) @@ -549,117 +692,242 @@ module Gapic instructions end - def pending_event_type?(obj) - obj.is_a?(Event::ChunkRead) || obj.is_a?(Event::HttpResponse) || - obj.is_a?(Event::RequestFailed) || obj.is_a?(Event::GlobalDeadlineExceeded) - end - def dispatch_instruction(instruction) case instruction when Instruction::NotifyProgress then execute_notify_progress(instruction) - when Instruction::RealignBuffer then execute_realign_buffer(instruction) - when Instruction::FillBuffer then execute_fill_buffer(instruction) - when Instruction::SendStart then execute_send_start(instruction) - when Instruction::SendChunk then execute_send_chunk(instruction) - when Instruction::SendFinalize then execute_send_finalize(instruction) - when Instruction::SendQuery then execute_send_query(instruction) - when Instruction::SendCancel then execute_send_cancel(instruction) + when Instruction::RealignBuffer then execute_realign_buffer(instruction) + when Instruction::FillBuffer then execute_fill_buffer(instruction) + when Instruction::SendStart then execute_send_start(instruction) + when Instruction::SendChunk then execute_send_chunk(instruction) + when Instruction::SendFinalize then execute_send_finalize(instruction) + when Instruction::SendQuery then execute_send_query(instruction) + when Instruction::SendCancel then execute_send_cancel(instruction) when Instruction::TerminateSuccess instruction.response.respond_to?(:body) ? instruction.response.body : instruction.response when Instruction::TerminateFailure then raise instruction.error end end - def resolve_retry_policy(value, defaults) - case value - when Gapic::Common::RetryPolicy - value - when Hash - Gapic::Common::RetryPolicy.new(**value).apply_defaults(defaults) - when nil - Gapic::Common::RetryPolicy.new(**defaults) + def execute_notify_progress(instruction) + @config.on_progress&.call(instruction.progress) + end + + def execute_realign_buffer(instruction) + server_offset = instruction.server_offset + if @config.upload_size && server_offset > @config.upload_size + raise StreamMismatchError.new( + "Server reported offset #{server_offset} exceeds total upload size #{@config.upload_size}", + resume_handle: resume_handle + ) + end + + buffer_start = @buffer_start_offset + buffer_end = @buffer_start_offset + @buffer.bytesize + + realign_case = if server_offset >= buffer_start && server_offset <= buffer_end + "within_buffer" + elsif server_offset < buffer_start + "rewind" + else + "fast_forward" + end + + unseekable = realign_case == "rewind" && !@config.stream.respond_to?(:seek) + @upload_log.buffer_realign( + realign_case, server_offset: server_offset, + current_offset: buffer_start, + unseekable: unseekable + ) + + if server_offset >= buffer_start && server_offset <= buffer_end + realign_within_buffer(server_offset) + elsif server_offset < buffer_start + realign_rewind_stream(server_offset) else - raise ArgumentError, "Expected RetryPolicy, Hash, or nil, got #{value.class}" + realign_fast_forward_stream(server_offset, buffer_end) end end - def resolve_timeout - return @config.timeout if @config.timeout&.positive? + def realign_within_buffer(server_offset) + slice_index = server_offset - @buffer_start_offset + @buffer = @buffer.byteslice(slice_index..-1) || "".b + @buffer_start_offset = server_offset + end - if @config.upload_size - [@config.upload_size.fdiv(MIN_ASSUMED_THROUGHPUT), BASE_TIMEOUT].max + def realign_rewind_stream(server_offset) + unless @config.stream.respond_to?(:seek) + raise UnseekableStreamError.new( + "Cannot rewind unseekable stream to offset #{server_offset} (buffered from #{@buffer_start_offset})", + resume_handle: resume_handle + ) + end + + if @config.upload_size.nil? && @config.stream.respond_to?(:size) && server_offset > @config.stream.size + raise StreamMismatchError.new( + "Server reported offset #{server_offset} exceeds stream size #{@config.stream.size}", + resume_handle: resume_handle + ) + end + + @config.stream.seek(server_offset) + @buffer = "".b + @buffer_start_offset = server_offset + end + + def realign_fast_forward_stream(server_offset, buffer_end) + @buffer = "".b + if @config.stream.respond_to?(:seek) + if @config.upload_size.nil? && @config.stream.respond_to?(:size) && server_offset > @config.stream.size + raise StreamMismatchError.new( + "Server reported offset #{server_offset} exceeds stream size #{@config.stream.size}", + resume_handle: resume_handle + ) + end + @config.stream.seek(server_offset) else - BASE_TIMEOUT + needed_discard = server_offset - buffer_end + while needed_discard.positive? + chunk_len = [needed_discard, 65_536].min + discarded = @config.stream.read(chunk_len) + if discarded.nil? || discarded.empty? + raise StreamMismatchError.new( + "Stream ended prematurely at offset #{server_offset - needed_discard} before reaching server offset #{server_offset}", + resume_handle: resume_handle + ) + end + needed_discard -= discarded.bytesize + end end + @buffer_start_offset = server_offset end - def request_timeout(retry_policy) - remaining = if @deadline - [@deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max - else - resolve_timeout - end - return [remaining, retry_policy.timeout].min if retry_policy&.timeout + def execute_fill_buffer(instruction) + target = instruction.target_bytesize + eof = false + + while @buffer.bytesize < target + bytes_needed = target - @buffer.bytesize + chunk = @config.stream.read(bytes_needed) + if chunk.nil? || chunk.empty? + eof = true + break + end + @buffer << chunk.b + end - remaining + Event::ChunkRead.new(bytes_buffered: @buffer.bytesize, eof: eof) end - def deadline_exceeded? - return false unless @deadline + def execute_send_start(instruction) + policy = @start_retry_policy.dup.start! + headers = start_headers(instruction) + attempt = 1 - Process.clock_gettime(Process::CLOCK_MONOTONIC) > @deadline - end + loop do + return Event::GlobalDeadlineExceeded.new if deadline_exceeded? - def terminal_instructions?(instructions) - instructions.any? { |i| i.is_a?(Instruction::TerminateSuccess) || i.is_a?(Instruction::TerminateFailure) } + event = make_post_request( + instruction.url, headers: headers, body: instruction.body, + retry_policy: policy, method_name: "ResumableUpload.start", + start_attempt: attempt + ) + return event unless event.is_a?(Event::HttpResponse) + + status_hdr = Rules.header_value(event.headers, "x-goog-upload-status") + return event unless status_hdr.nil? || status_hdr.empty? + return event if Rules::FATAL_STATUS_CODES.include?(event.status) + + err = BadResponseError.new( + "Missing X-Goog-Upload-Status header in start response", + event.status, + headers: event.headers + ) + can_retry = policy.send(:retry_with_deadline?) && policy.call(event) + unless can_retry + if event.status == 200 + failed_event = Event::RequestFailed.new( + kind: :retries_exhausted, message: err.message, source_error: err + ) + @upload_log.wire_failure(failed_event) + return failed_event + end + return event + end + attempt += 1 + end end - # Synchronous side-effect: invokes user callback (exceptions propagate to caller) - def execute_notify_progress(instruction) - @config.on_progress&.call(instruction.progress) + def start_headers(instruction) + headers = { "X-Goog-Upload-Protocol" => "resumable", "X-Goog-Upload-Command" => "start" } + headers["X-Goog-Upload-Header-Content-Type"] = @config.content_type if @config.content_type + headers["X-Goog-Upload-Header-Content-Length"] = @config.upload_size.to_s if @config.upload_size + headers.merge(instruction.headers || {}) end - # Synchronous side-effect: adjusts in-memory buffer window and stream - def execute_realign_buffer(instruction) - # Logs buffer_realign via @upload_log (WARN on unseekable rewind, DEBUG otherwise) - # Implements Section 2.5 buffer alignment Cases 1, 2, and 3 + def execute_send_chunk(instruction) + headers = { + "X-Goog-Upload-Command" => instruction.finalize ? "upload, finalize" : "upload", + "X-Goog-Upload-Offset" => instruction.offset.to_s, + "Content-Type" => @config.content_type || "application/octet-stream", + "Content-Length" => instruction.length.to_s + } + slice_index = instruction.offset - @buffer_start_offset + body = @buffer.byteslice(slice_index, instruction.length) + + make_post_request( + instruction.url, headers: headers, body: body, + retry_policy: @data_plane_retry_policy.dup.start!, + method_name: "ResumableUpload.upload" + ) end - # I/O operation: fills buffer from unseekable/seekable stream - # @return [Event::ChunkRead] - def execute_fill_buffer(instruction) - # Reads from stream until @buffer.bytesize reaches instruction.target_bytesize or stream hits EOF + def execute_send_finalize(instruction) + headers = { + "X-Goog-Upload-Command" => "finalize", + "X-Goog-Upload-Offset" => @core.state.offset.to_s, + "Content-Length" => "0" + } + make_post_request( + instruction.url, headers: headers, body: "", + retry_policy: @data_plane_retry_policy.dup.start!, + method_name: "ResumableUpload.finalize" + ) end - # Network operation: wraps start HTTP request in start_retry_policy - # @return [Event::HttpResponse, Event::RequestFailed] - def execute_send_start(instruction) - # Executes POST initiation request via make_post_request(..., method_name: "ResumableUpload.start"). - # Retries missing X-Goog-Upload-Status header across any response code, including 200 OK. - # Logs @upload_log.wire_failure on retry exhaustion. + def execute_send_query(instruction) + headers = { "X-Goog-Upload-Command" => "query" } + make_post_request( + instruction.url, headers: headers, body: "", + retry_policy: @control_plane_retry_policy.dup.start!, + method_name: "ResumableUpload.query" + ) end - # Network operation: wraps HTTP request in data_plane_retry_policy - # @return [Event::HttpResponse, Event::RequestFailed] - def execute_send_chunk(instruction) - # Slices body from @buffer[instruction.offset - @buffer_start_offset, instruction.length] - # Executes POST request via make_post_request(..., method_name: "ResumableUpload.upload") + def execute_send_cancel(instruction) + headers = { "X-Goog-Upload-Command" => "cancel" } + make_post_request( + instruction.url, headers: headers, body: "", + retry_policy: @control_plane_retry_policy.dup.start!, + method_name: "ResumableUpload.cancel" + ) end def make_post_request(url, headers:, body:, retry_policy:, method_name: nil, start_attempt: 1) return Event::GlobalDeadlineExceeded.new if deadline_exceeded? options = { - metadata: headers, + metadata: headers, retry_policy: retry_policy, - timeout: request_timeout(retry_policy) + timeout: request_timeout(retry_policy) } @upload_log.wire_send( method: "POST", url: url, headers: headers, start_attempt: start_attempt, body_size: body.to_s.bytesize, body: body ) + response = @client_stub.make_post_request( - uri: url, body: body, params: {}, options: options, method_name: method_name + uri: url, body: body, params: {}, + options: options, method_name: method_name ) event = Event::HttpResponse.new(status: response.status, headers: response.headers || {}, body: response.body) @upload_log.wire_receive(event) @@ -676,6 +944,11 @@ module Gapic event end + def request_timeout(retry_policy) + policy_timeout = retry_policy.respond_to?(:timeout) ? retry_policy.timeout : nil + [remaining_time, policy_timeout].compact.min + end + def rescue_request_error(err) case err when Gapic::Rest::DeadlineExceededError @@ -683,10 +956,10 @@ module Gapic when Gapic::Rest::Error if err.status_code Event::HttpResponse.new( - status: err.status_code, + status: err.status_code, headers: err.headers || {}, - body: err.message, - error: err + body: err.message, + error: err ) else Event::RequestFailed.new(kind: :connection_failed, message: err.message, source_error: err) @@ -715,6 +988,241 @@ module Gapic Event::RequestFailed.new(kind: :retries_exhausted, message: err.message, source_error: err) end end + + def resolve_retry_policy(value, defaults) + case value + when Gapic::Common::RetryPolicy + value + when Hash + Gapic::Common::RetryPolicy.new(**value).apply_defaults(defaults) + when nil + Gapic::Common::RetryPolicy.new(**defaults) + else + raise ArgumentError, "Expected RetryPolicy, Hash, or nil, got #{value.class}" + end + end + end + end + end +end +``` + +--- + +## 4. Session Class + +```ruby +module Gapic + module Rest + module ResumableUpload + class Session + attr_reader :client_stub, :stream, :initial_url, :initial_body, :initial_headers, + :upload_size, :chunk_size, :content_type, :timeout, :start_retry_policy, + :control_plane_retry_policy, :data_plane_retry_policy, :on_progress, :logger + + def initialize(client_stub:, + stream:, + initial_url:, + initial_body: nil, + initial_headers: {}, + upload_size: nil, + chunk_size: nil, + content_type: nil, + timeout: nil, + start_retry_policy: nil, + control_plane_retry_policy: nil, + data_plane_retry_policy: nil, + on_progress: nil, + logger: nil) + @client_stub = client_stub + @stream = stream + @initial_url = initial_url + @initial_body = initial_body + @initial_headers = initial_headers || {} + @upload_size = upload_size + @chunk_size = chunk_size + @content_type = content_type + @timeout = timeout + @start_retry_policy = start_retry_policy + @control_plane_retry_policy = control_plane_retry_policy + @data_plane_retry_policy = data_plane_retry_policy + @on_progress = on_progress + @logger = logger + + @mutex = Mutex.new + @running = false + @executed = false + @upload_url = nil + @last_driver = nil + end + + # Returns the raw upload session URL if established. + # + # @return [String, nil] + def upload_url + @mutex.synchronize { upload_url_internal } + end + + # Returns whether the session is bound to a server-side upload. + # + # @return [Boolean] + def bound? + @mutex.synchronize { bound_internal? } + end + + # Returns the current ResumeHandle if the session is alive and resumable. + # + # @return [ResumeHandle, nil] + def resume_handle + @mutex.synchronize { resume_handle_internal } + end + + # Returns whether a new session can resume the upload. + # Completed uploads are not resumable (returns false). + # + # @return [Boolean] + def resumable? + !resume_handle.nil? + end + + # Returns whether a run is currently executing. + # + # @return [Boolean] + def running? + @mutex.synchronize { @running } + end + + # Starts a new upload session on the server. + # + # @return [String, Object] Final response body upon completion + # @raise [SessionStateError] If already bound/executed or if a run is currently in progress + def start + driver = nil + @mutex.synchronize do + raise SessionStateError, "A run is already in progress for this session" if @running + raise SessionStateError, "Session has already executed a run" if bound_internal? + + @executed = true + @running = true + config = build_start_config + driver = Driver.new(client_stub: @client_stub, config: config, logger: @logger) + end + + execute_run(driver) + end + + # Resumes an upload session using one of two explicit keyword forms: + # 1. `resume(upload_url:, chunk_size:)`: Resumes with explicit URL and chunk size. + # 2. `resume(resume_handle:)`: Resumes via ResumeHandle. + # + # Precondition: stream must be positioned at byte 0. + # + # @param upload_url [String, nil] Explicit upload URL + # @param chunk_size [Integer, nil] Explicit chunk size + # @param resume_handle [ResumeHandle, nil] Explicit resume handle + # @return [String, Object] Final response body upon completion + # @raise [ArgumentError] If argument shape is invalid, target upload is missing, or stream.pos != 0 + # @raise [SessionStateError] If already bound/executed or if a run is currently in progress + def resume(upload_url: nil, chunk_size: nil, resume_handle: nil) + target_url, target_chunk_size = resolve_resume_args( + upload_url: upload_url, + chunk_size: chunk_size, + resume_handle: resume_handle + ) + + driver = nil + @mutex.synchronize do + raise SessionStateError, "A run is already in progress for this session" if @running + raise SessionStateError, "Session has already executed a run" if bound_internal? + + if @stream.respond_to?(:pos) && !@stream.pos.zero? + raise ArgumentError, "Stream must be positioned at byte 0 to resume an upload (got pos #{@stream.pos})" + end + + @executed = true + @running = true + config = build_resume_config(target_url, target_chunk_size) + driver = Driver.new(client_stub: @client_stub, config: config, logger: @logger) + end + + execute_run(driver) + end + + private + + def upload_url_internal + @upload_url || @last_driver&.upload_url + end + + def bound_internal? + @executed || !upload_url_internal.nil? + end + + def resume_handle_internal + @last_driver&.resume_handle + end + + def build_start_config + CompleteUploadConfig.new( + initial_url: @initial_url, + initial_body: @initial_body, + initial_headers: @initial_headers, + stream: @stream, + upload_size: @upload_size, + chunk_size: @chunk_size, + content_type: @content_type, + timeout: @timeout, + start_retry_policy: @start_retry_policy, + control_plane_retry_policy: @control_plane_retry_policy, + data_plane_retry_policy: @data_plane_retry_policy, + on_progress: @on_progress + ) + end + + def build_resume_config(target_url, target_chunk_size) + ResumeUploadConfig.new( + upload_url: target_url, + chunk_size: target_chunk_size, + stream: @stream, + upload_size: @upload_size, + content_type: @content_type, + timeout: @timeout, + start_retry_policy: @start_retry_policy, + control_plane_retry_policy: @control_plane_retry_policy, + data_plane_retry_policy: @data_plane_retry_policy, + on_progress: @on_progress + ) + end + + def resolve_resume_args(upload_url:, chunk_size:, resume_handle:) + if resume_handle + raise ArgumentError, "Cannot pass both resume_handle and upload_url/chunk_size" if upload_url || chunk_size + [resume_handle.upload_url, resume_handle.chunk_size] + elsif upload_url + raise ArgumentError, "Must provide chunk_size with upload_url" if chunk_size.nil? + [upload_url, chunk_size] + elsif chunk_size + raise ArgumentError, "Cannot pass chunk_size without upload_url" + else + raise ArgumentError, "Must provide either resume_handle or upload_url and chunk_size" + end + end + + def execute_run(driver) + @mutex.synchronize { @last_driver = driver } + result = driver.run + @mutex.synchronize do + @upload_url ||= driver.upload_url + @running = false + end + result + rescue StandardError + @mutex.synchronize do + @upload_url ||= driver.upload_url + @running = false + end + raise + end end end end diff --git a/gapic-common/integration/integration_helper.rb b/gapic-common/integration/integration_helper.rb index b828487..6ab00fc 100644 --- a/gapic-common/integration/integration_helper.rb +++ b/gapic-common/integration/integration_helper.rb @@ -50,10 +50,6 @@ def read length = nil def pos @io.pos end - - def rewind - @io.rewind - end end attr_reader :logger @@ -146,7 +142,8 @@ def build_session scenario: nil, scenario_config: {}, **overrides data_plane_retry_policy: FAST_RETRY, timeout: 10, chunk_size: DEFAULT_CHUNK_SIZE, - on_progress: ->(progress) { @progress_records << progress } + on_progress: ->(progress) { @progress_records << progress }, + logger: @logger } unless overrides.key? :stream defaults[:stream] = StringIO.new payload(DEFAULT_PAYLOAD_SIZE) diff --git a/gapic-common/integration/resumable_upload/resume_test.rb b/gapic-common/integration/resumable_upload/resume_test.rb index 432ed5e..dc75ef0 100644 --- a/gapic-common/integration/resumable_upload/resume_test.rb +++ b/gapic-common/integration/resumable_upload/resume_test.rb @@ -78,6 +78,7 @@ def test_resume_query_503_absorbed_by_retry assert_equal DEFAULT_PAYLOAD_SIZE, parsed["size"] assert_equal [:initiating, :uploading, :uploading, :uploading, :finalizing, :completed], phases + refute_includes @log_output.string, "retry_recovery" end # D3b. Non-fatal 409 error on query during resume triggers protocol retry_recovery without progress notification. @@ -97,6 +98,7 @@ def test_resume_query_409_triggers_retry_recovery assert_equal DEFAULT_PAYLOAD_SIZE, parsed["size"] assert_equal [:initiating, :uploading, :uploading, :uploading, :finalizing, :completed], phases refute_includes phases, :recovering + assert_includes @log_output.string, "retry_recovery" end # D4. Resume fast-forwards by discarding bytes on an unseekable stream starting at byte 0. @@ -173,9 +175,8 @@ def test_golden_user_style_resume_seekable refute session2.resumable? end - # D6b. Golden user-style resume on an unseekable stream rewound to 0 before resumption. + # D6b. Golden user-style resume with a fresh unseekable stream starting at byte 0. def test_golden_user_style_resume_unseekable - stream = UnseekableStream.new payload(DEFAULT_PAYLOAD_SIZE) session1 = nil on_progress = lambda do |progress| if progress.phase == :uploading && progress.bytes_uploaded == DEFAULT_CHUNK_SIZE @@ -183,7 +184,7 @@ def test_golden_user_style_resume_unseekable end end - session1 = build_session stream: stream, on_progress: on_progress + session1 = build_session stream: UnseekableStream.new(payload(DEFAULT_PAYLOAD_SIZE)), on_progress: on_progress err = assert_raises UserPauseError do session1.start end @@ -193,8 +194,7 @@ def test_golden_user_style_resume_unseekable handle = err.resume_handle refute_nil handle - stream.rewind - session2 = build_session stream: stream + session2 = build_session stream: UnseekableStream.new(payload(DEFAULT_PAYLOAD_SIZE)) result = session2.resume resume_handle: handle parsed = JSON.parse result From 6edfd6bb27eaab8c81cc2e56ea26135a2eea66a5 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Mon, 14 Sep 2026 17:01:27 +0000 Subject: [PATCH 65/79] chore: update documentation --- .../lib/gapic/rest/resumable_upload.rb | 36 +++++++ .../gapic/rest/resumable_upload/data_types.rb | 10 +- .../lib/gapic/rest/resumable_upload/driver.rb | 16 +-- .../lib/gapic/rest/resumable_upload/errors.rb | 7 +- .../lib/gapic/rest/resumable_upload/rules.rb | 1 + .../gapic/rest/resumable_upload/session.rb | 101 ++++++++++++++++-- 6 files changed, 151 insertions(+), 20 deletions(-) diff --git a/gapic-common/lib/gapic/rest/resumable_upload.rb b/gapic-common/lib/gapic/rest/resumable_upload.rb index d39d7a2..c5fb204 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload.rb @@ -31,6 +31,42 @@ module Rest ## # Resumable Upload Protocol implementation for REST transport. # + # {Session} is the primary public entry point for initiating and resuming uploads. + # It manages session initiation, chunked streaming, automatic retries, progress + # callbacks via {Progress}, and cross-session resumption via {ResumeHandle}. + # + # ### Error Types + # * {RequestFailedError} - Transport connection failure, timeout, or retries exhausted (includes {HasResumeHandle}). + # * {DeadlineExceededError} - Global upload timeout exceeded (includes {HasResumeHandle}). + # * {BadResponseError} - Unexpected or malformed HTTP response (includes {HasResumeHandle}). + # * {UnseekableStreamError} - Stream rewinding required on an unseekable stream (includes {HasResumeHandle}). + # * {StreamMismatchError} - Stream content or length does not match resumed upload (includes {HasResumeHandle}). + # * {InvalidTransitionError} - Unmatched event for the current protocol state (includes {HasResumeHandle}). + # * {UploadRejectedError} - Server explicitly rejected the upload session (final). + # * {SessionStateError} - Session lifecycle rule violation, e.g., calling `#start` twice (final). + # + # @example Initiating an upload, rescuing an error, and resuming from a fresh session + # session = Gapic::Rest::ResumableUpload::Session.new( + # client_stub: client_stub, + # stream: stream, + # initial_url: "https://example.googleapis.com/resumable/upload/v1/example/upload:new" + # ) + # + # begin + # response = session.start + # rescue Gapic::Rest::ResumableUpload::HasResumeHandle => e + # handle = e.resume_handle + # raise unless handle + # + # stream.rewind + # resumed_session = Gapic::Rest::ResumableUpload::Session.new( + # client_stub: client_stub, + # stream: stream, + # initial_url: session.initial_url + # ) + # response = resumed_session.resume resume_handle: handle + # end + # module ResumableUpload end end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb index 2b74eab..a12404b 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb @@ -19,6 +19,7 @@ module Rest # rubocop:disable Metrics/ModuleLength module ResumableUpload ## + # @private # Immutable configuration for initiating and executing a resumable upload session. # # @!attribute [r] initial_url @@ -69,6 +70,7 @@ module ResumableUpload :on_progress ) do ## + # @private # Initializes a new upload configuration. # # @param initial_url [String] Initial endpoint URI for session initiation @@ -114,6 +116,7 @@ def initialize initial_url:, end ## + # @private # Immutable configuration for resuming an existing upload session. # # @!attribute [r] upload_url @@ -156,6 +159,7 @@ def initialize initial_url:, :on_progress ) do ## + # @private # Initializes a new upload resume configuration. # # @param upload_url [String] Session upload URL @@ -204,6 +208,10 @@ def initialize upload_url:, ## # Immutable progress snapshot passed to the `on_progress` callback. # + # The `on_progress` callback runs synchronously on the same thread as the upload protocol + # and must not block. Any exception raised inside the callback aborts the upload session + # and propagates out of {Session#start} or {Session#resume}. + # # @!attribute [r] phase # @return [Symbol] Current upload phase, one of {Progress::PHASES} # @!attribute [r] bytes_uploaded @@ -316,7 +324,7 @@ def initialize upload_url:, chunk_size: def initialize status: :initializing, upload_url: nil, offset: 0, - chunk_size: 8_388_608, + chunk_size: Rules::DEFAULT_CHUNK_SIZE, chunk_granularity: nil, in_flight_length: 0, last_error: nil diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index 3503a40..6bbc4cc 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -29,6 +29,7 @@ module Gapic module Rest module ResumableUpload ## + # @private # Synchronous execution engine for the Resumable Upload Protocol. # Coordinates HTTP network operations, stream buffering, monotonic deadlines, # and delegates state transitions to Core. @@ -53,11 +54,8 @@ class Driver # @return [Core] attr_reader :core - # @private - # @return [String, nil] Current upload session ID - attr_reader :upload_id - ## + # @private # Returns a {ResumeHandle} representing the current upload session parameters. # Reading this property mid-run provides a best-effort snapshot of the current session state. # Completed uploads (`:success`), rejected uploads (`:rejected`), and cancelled uploads @@ -69,6 +67,7 @@ def resume_handle end ## + # @private # Returns the raw upload session URL from protocol state, regardless of lifecycle status. # # @return [String, nil] Session upload URL if established, or nil @@ -77,10 +76,11 @@ def upload_url end ## + # @private # Initializes a new Resumable Upload Driver. # # @param client_stub [Gapic::Rest::ClientStub] Underlying REST client stub - # @param config [CompleteUploadConfig] Configuration for this upload session + # @param config [CompleteUploadConfig, ResumeUploadConfig] Configuration for this upload session # @param core [Core, nil] Optional Core state machine (defaults to new Core with config) # @param logger [Logger, nil] Optional logger override def initialize client_stub:, config:, core: nil, logger: nil @@ -106,6 +106,7 @@ def initialize client_stub:, config:, core: nil, logger: nil end ## + # @private # Default retry policy for session initiation requests (start). # # @return [Gapic::Common::RetryPolicy] @@ -114,6 +115,7 @@ def self.default_start_retry_policy end ## + # @private # Default retry policy for control plane requests (query, cancel). # # @return [Gapic::Common::RetryPolicy] @@ -122,6 +124,7 @@ def self.default_control_plane_retry_policy end ## + # @private # Default retry policy for data plane requests (upload, finalize). # # @return [Gapic::Common::RetryPolicy] @@ -130,9 +133,10 @@ def self.default_data_plane_retry_policy end ## + # @private # Executes event loop until terminal state. # Establishes a guaranteed monotonic deadline at the start of execution - # using {#resolve_timeout} so the upload cannot stall indefinitely. + # so the upload cannot stall indefinitely. # # @return [String, Object] Final response body def run diff --git a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb index f03bc64..c52898f 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb @@ -379,7 +379,9 @@ def self.from event, response_body: nil end ## + # @private # Raised when the upload session is cancelled. + # Cancellation is not public yet. # class UploadCancelledError < Gapic::Common::Error ## @@ -525,9 +527,8 @@ def self.from event_or_error, message: nil, resume_handle: nil end ## - # Raised when an operation violates the Session lifecycle rules (e.g. attempting to - # start an already-bound session, resuming an unbound session without a target upload, - # re-binding to a different upload, or concurrent run invocations). + # Raised when an operation violates the Session lifecycle rules + # (e.g. calling a `start` method more than once). # class SessionStateError < Gapic::Common::Error end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb index 4135592..607e59d 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb @@ -570,6 +570,7 @@ def self.cancel_session state, _event, config end ## + # @private # Extracts a {ResumeHandle} from current protocol state. # Completed uploads (`:success`), rejected uploads (`:rejected`), and cancelled uploads # (`:cancelled`) are finalized and not resumable, returning `nil`. Completed uploads are not resumable. diff --git a/gapic-common/lib/gapic/rest/resumable_upload/session.rb b/gapic-common/lib/gapic/rest/resumable_upload/session.rb index dca4276..744c710 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/session.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/session.rb @@ -40,7 +40,11 @@ class Session # @return [Gapic::Rest::ClientStub] Underlying REST client stub attr_reader :client_stub - # @return [IO] Binary input stream to upload + ## + # Binary input stream to upload. The stream is assumed to be positioned at byte 0 + # (it is not rewound prior to reading) and is not closed after use. + # + # @return [IO] attr_reader :stream # @return [String] Initial endpoint URI for session initiation @@ -55,13 +59,20 @@ class Session # @return [Integer, nil] Total upload bytes if known upfront attr_reader :upload_size - # @return [Integer, nil] Explicit chunk size in bytes + ## + # Explicit chunk size in bytes. If `nil`, the protocol implementation assigns a default value. + # The effective chunk size may be adjusted if the server specifies a required data granularity. + # + # @return [Integer, nil] attr_reader :chunk_size # @return [String, nil] MIME type of uploaded media attr_reader :content_type - # @return [Numeric, nil] Total upload timeout in seconds + ## + # Total upload timeout in seconds. If `nil`, the protocol implementation assigns a default value. + # + # @return [Numeric, nil] attr_reader :timeout # @return [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for session initiation @@ -73,7 +84,13 @@ class Session # @return [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for data commands attr_reader :data_plane_retry_policy - # @return [Proc, nil] Callback invoked with Progress snapshots + ## + # Callback invoked with {Progress} snapshots during upload execution. + # Executed synchronously on the thread running the upload protocol; it must not block. + # Exceptions raised inside the callback immediately abort the upload session and + # propagate out of {#start} or {#resume}. + # + # @return [Proc, nil] attr_reader :on_progress # @return [Logger, nil] Logger instance @@ -83,18 +100,24 @@ class Session # Initializes a new Resumable Upload Session. # # @param client_stub [Gapic::Rest::ClientStub] Underlying REST client stub - # @param stream [IO] Binary input stream to upload + # @param stream [IO] Binary input stream to upload. Precondition: assumed to be positioned at byte 0 + # (not rewound prior to reading) and not closed after use. # @param initial_url [String] Initial endpoint URI for session initiation # @param initial_body [String, nil] Request payload for session initiation (defaults to nil) # @param initial_headers [Hash] Additional headers for initiation # @param upload_size [Integer, nil] Total upload bytes if known upfront - # @param chunk_size [Integer, nil] Explicit chunk size in bytes + # @param chunk_size [Integer, nil] Explicit chunk size in bytes. If `nil`, the protocol implementation + # assigns a default value. The effective chunk size may be modified if the server specifies a required + # data granularity. # @param content_type [String, nil] MIME type of uploaded media - # @param timeout [Numeric, nil] Total upload timeout in seconds + # @param timeout [Numeric, nil] Total upload timeout in seconds. If `nil`, the protocol implementation + # assigns a default value. # @param start_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Initiation retry policy # @param control_plane_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Control retry policy # @param data_plane_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Data retry policy - # @param on_progress [Proc, nil] Progress callback + # @param on_progress [Proc, nil] Progress callback invoked as `->(progress)` with a {Progress} instance. + # Executed synchronously on the upload protocol thread; it must not block. + # Exceptions raised inside the callback abort the session and propagate out of {#start} or {#resume}. # @param logger [Logger, nil] Logger instance # def initialize client_stub:, @@ -181,10 +204,18 @@ def running? # Starts a new upload session on the server. # # A session performs exactly one run (`start` or `resume`). Calling `start` on an already-bound - # or executed session raises {SessionStateError}. + # or executed session raises {SessionStateError}. Precondition: the stream is assumed to be + # positioned at byte 0 (the session does not rewind it before reading) and is not closed after use. # # @return [String, Object] Final response body upon completion # @raise [SessionStateError] If already bound/executed or if a run is currently in progress + # @raise [RequestFailedError] If a transport error, timeout, or retry exhaustion occurs + # @raise [DeadlineExceededError] If the global upload timeout is exceeded + # @raise [BadResponseError] If an unexpected or malformed HTTP response is received + # @raise [UnseekableStreamError] If stream rewinding is required during recovery on an unseekable stream + # @raise [StreamMismatchError] If stream content or length does not match protocol expectations + # @raise [InvalidTransitionError] If an unmatched event occurs for the current protocol state + # @raise [UploadRejectedError] If the server explicitly rejects the upload session def start driver = nil @mutex.synchronize do @@ -206,7 +237,8 @@ def start # 2. `resume(resume_handle:)`: Resumes via {ResumeHandle}. # # A session performs exactly one run (`start` or `resume`). Resuming must be executed on a - # fresh, unexecuted session. Precondition: the stream must be positioned at byte 0. + # fresh, unexecuted session. Precondition: the stream must be positioned at byte 0 (it is not + # rewound prior to reading) and is not closed after use. # The Driver fast-forwards to the server's acknowledged offset (by seeking on seekable streams # or reading and discarding on unseekable streams). # @@ -218,6 +250,13 @@ def start # @return [String, Object] Final response body upon completion # @raise [ArgumentError] If argument shape is invalid, target upload is missing, or stream.pos != 0 # @raise [SessionStateError] If already bound/executed or if a run is currently in progress + # @raise [RequestFailedError] If a transport error, timeout, or retry exhaustion occurs + # @raise [DeadlineExceededError] If the global upload timeout is exceeded + # @raise [BadResponseError] If an unexpected or malformed HTTP response is received + # @raise [UnseekableStreamError] If stream rewinding is required during recovery on an unseekable stream + # @raise [StreamMismatchError] If stream content or length does not match the resumed upload + # @raise [InvalidTransitionError] If an unmatched event occurs for the current protocol state + # @raise [UploadRejectedError] If the server explicitly rejects the upload session def resume upload_url: nil, chunk_size: nil, resume_handle: nil @@ -248,18 +287,38 @@ def resume upload_url: nil, private + ## + # @private + # Returns the established upload URL without locking. + # + # @return [String, nil] def upload_url_internal @upload_url || @last_driver&.upload_url end + ## + # @private + # Returns whether the session is bound without locking. + # + # @return [Boolean] def bound_internal? @executed || !upload_url_internal.nil? end + ## + # @private + # Returns the current resume handle from the driver without locking. + # + # @return [ResumeHandle, nil] def resume_handle_internal @last_driver&.resume_handle end + ## + # @private + # Builds configuration for a new upload session. + # + # @return [CompleteUploadConfig] def build_start_config CompleteUploadConfig.new( initial_url: @initial_url, @@ -277,6 +336,13 @@ def build_start_config ) end + ## + # @private + # Builds configuration for resuming an upload session. + # + # @param target_url [String] Target upload session URL + # @param target_chunk_size [Integer] Effective chunk size in bytes + # @return [ResumeUploadConfig] def build_resume_config target_url, target_chunk_size ResumeUploadConfig.new( upload_url: target_url, @@ -292,6 +358,15 @@ def build_resume_config target_url, target_chunk_size ) end + ## + # @private + # Validates and extracts target upload URL and chunk size from resume keyword arguments. + # + # @param upload_url [String, nil] Explicit upload URL + # @param chunk_size [Integer, nil] Explicit chunk size + # @param resume_handle [ResumeHandle, nil] Explicit resume handle + # @return [Array] Tuple of [upload_url, chunk_size] + # @raise [ArgumentError] If arguments are missing or mutually exclusive def resolve_resume_args upload_url:, chunk_size:, resume_handle: if resume_handle raise ArgumentError, "Cannot pass both resume_handle and upload_url/chunk_size" if upload_url || chunk_size @@ -306,6 +381,12 @@ def resolve_resume_args upload_url:, chunk_size:, resume_handle: end end + ## + # @private + # Executes the driver run and records the final upload URL and state. + # + # @param driver [Driver] Driver instance to run + # @return [String, Object] Final response body upon completion def execute_run driver @mutex.synchronize { @last_driver = driver } result = driver.run From 5a643613578e4cc9c812ed164c3910e4ed1f665f Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Mon, 14 Sep 2026 18:08:11 +0000 Subject: [PATCH 66/79] docs: improve state machine documentation, reify event shapes --- gapic-common/design/implementation-guide.md | 14 +- .../lib/gapic/rest/resumable_upload/core.rb | 4 + .../gapic/rest/resumable_upload/data_types.rb | 8 +- .../lib/gapic/rest/resumable_upload/driver.rb | 6 + .../lib/gapic/rest/resumable_upload/errors.rb | 4 +- .../lib/gapic/rest/resumable_upload/rules.rb | 167 +++++++++++++++++- .../rules_classification_test.rb | 66 ++++++- 7 files changed, 254 insertions(+), 15 deletions(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index d483a55..154c739 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -1,4 +1,4 @@ -# Scotty Resumable Upload Protocol (RUP) Implementation Guide +# Resumable Upload Protocol (RUP) Implementation Guide ## 1. System Architecture @@ -13,7 +13,7 @@ graph TD Core -->|state, event| Rules[Rules
Pure Decision Function] Rules -->|next_state, instructions| Core end - Driver -->|RetryPolicy / Faraday| Server[Scotty / GCS Backend] + Driver -->|RetryPolicy / Faraday| Server[Upload Backend / GCS] Driver -->|IO#read| Stream[Local Stream] ``` @@ -137,7 +137,7 @@ module Gapic module Rest module ResumableUpload ResumeUploadConfig = Data.define( - :upload_url, # [String] Upload session URL returned by Scotty backend + :upload_url, # [String] Upload session URL returned by the upload backend :chunk_size, # [Integer] Chunk size in bytes (> 0) :stream, # [IO] Binary input stream to upload :upload_size, # [Integer, nil] Total upload bytes if known upfront @@ -163,7 +163,7 @@ module Gapic :status, # [Symbol] :initializing, :starting, :transmission_reading, :transmission_sending, # :finalizing_sending_upload, :finalizing_sending_finalize, # :recovery, :cancelling, :cancelled, :success, :error, :rejected - :upload_url, # [String, nil] Session upload URL returned by Scotty backend + :upload_url, # [String, nil] Session upload URL returned by the upload backend :offset, # [Integer] Contiguous bytes confirmed by server (protocol_state_offset) :chunk_size, # [Integer] Resolved effective chunk size :chunk_granularity, # [Integer, nil] Alignment modulus returned by server @@ -226,7 +226,7 @@ end ### 2.5 Driver Buffer Invariants & Stream Position Model The Driver coordinates stream reading and in-memory buffering using four explicit offset markers: -* `server_offset`: Contiguous byte count acknowledged by Scotty (extracted from `X-Goog-Upload-Size-Received`). +* `server_offset`: Contiguous byte count acknowledged by the server (extracted from `X-Goog-Upload-Size-Received`). * `protocol_state_offset`: Byte offset maintained in `State.offset`. * `buffer_start_offset`: Absolute stream offset corresponding to the first byte in the Driver's `@buffer`. * `buffer_end_offset`: `buffer_start_offset + @buffer.bytesize`. @@ -469,7 +469,7 @@ The implementation distinguishes three categories of network and protocol-level 2. **Missing or Empty `X-Goog-Upload-Status` Header**: Any response lacking `X-Goog-Upload-Status` (or empty) whose HTTP status is **not** in `FATAL_STATUS_CODES` (Section 6.1.3). This includes HTTP 200, 5xx server/gateway errors (`500`, `502`, `503`, `504`), and recoverable client errors (`400`, `408`, `409`, `412`, `416`, `429`, `499`). 3. **Unretried Data Plane Connection Drops or Request Timeouts**: `Event::RequestFailed(kind: :connection_failed)` or `Event::RequestFailed(kind: :timeout)` (`:request_connection_failed`, `:request_timeout`) occurring during `Transmission` or `Finalizing`. * **Missing Header Handling & Retry Policy Contract**: - * *Why Headers Go Missing*: Intermediate proxies, reverse-proxies, or Google Front End (GFE) edge proxies can strip Scotty response headers or return raw HTML/text error pages on failure. + * *Why Headers Go Missing*: Intermediate proxies, reverse-proxies, or Google Front End (GFE) edge proxies can strip the protocol response headers or return raw HTML/text error pages on failure. * *Session Initiation (`start`)*: Missing `X-Goog-Upload-Status` is treated as **retriable** by `start_retry_policy` (retry predicate returns `true`) across **any response code, including 200 OK**. Driver retries transparently to smooth over transient gateway noise. If retries exhaust, `Starting` transitions to `:error` via `fail_with_request_error` or `fail_with_bad_response` (cannot recover a session before an upload URL is obtained). * *Session Control (`query`, `cancel`)*: `control_plane_retry_policy` does **not** treat missing status headers as retriable, returning the completed `Event::HttpResponse` immediately to `Core` so it can manage protocol recovery or fail fast. * *Data Plane (`upload`, `upload, finalize`, standalone `finalize`)*: Missing `X-Goog-Upload-Status` is treated as **unretriable** by `data_plane_retry_policy` (retry predicate returns `false`). The Driver immediately returns `Event::HttpResponse` to `Core` so it classifies as `:response_cat2` and initiates Category 2 `Recovery` via `Instruction::SendQuery` rather than blindly re-transmitting data. @@ -478,7 +478,7 @@ The implementation distinguishes three categories of network and protocol-level #### 6.1.3 Category 3: Terminal Failures & Fatal Status Codes * **Definition**: Irrecoverable errors where either the request is structurally invalid, unauthorized, transport retry limits are exhausted, unseekable rewind is needed, or the server has explicitly aborted/rejected the session. * **Canonical Fatal Status Codes (`FATAL_STATUS_CODES`)**: - The following status codes indicate structural or authentication failures that cannot be resolved by querying the Scotty backend: + The following status codes indicate structural or authentication failures that cannot be resolved by querying the upload backend: * `401 Unauthorized`: Authentication token is expired, invalid, or missing. * `403 Forbidden`: Caller lacks required IAM permissions for the upload destination. * `404 Not Found`: Session upload URL does not exist or has expired. diff --git a/gapic-common/lib/gapic/rest/resumable_upload/core.rb b/gapic-common/lib/gapic/rest/resumable_upload/core.rb index aeb4919..c7b6de3 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/core.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/core.rb @@ -25,6 +25,10 @@ module ResumableUpload # State machine container holding the immutable State snapshot. # Contains zero protocol branching logic and zero side-effects. # + # The middle tier of the three-tier design: `Driver` executes side effects, {Rules} decides transitions, + # and Core holds the {State} between the two. See {Rules} for the protocol narrative and state graph, and + # `design/implementation-guide.md` section 1 for the tier boundaries. + # class Core # @private # @return [State] Current immutable state snapshot diff --git a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb index a12404b..bbba8f2 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb @@ -120,7 +120,7 @@ def initialize initial_url:, # Immutable configuration for resuming an existing upload session. # # @!attribute [r] upload_url - # @return [String] Session upload URL returned by Scotty backend + # @return [String] Session upload URL returned by the upload backend # @!attribute [r] chunk_size # @return [Integer] Explicit chunk size in bytes (must be a positive integer) # @!attribute [r] stream @@ -286,9 +286,9 @@ def initialize upload_url:, chunk_size: # Immutable state snapshot representing the current protocol progression. # # @!attribute [r] status - # @return [Symbol] Protocol lifecycle status symbol + # @return [Symbol] Protocol lifecycle status, one of {Rules::STATUSES} # @!attribute [r] upload_url - # @return [String, nil] Session upload URL returned by Scotty backend + # @return [String, nil] Session upload URL returned by the upload backend # @!attribute [r] offset # @return [Integer] Contiguous bytes acknowledged by server # @!attribute [r] chunk_size @@ -313,7 +313,7 @@ def initialize upload_url:, chunk_size: # @private # Initializes a protocol state snapshot. # - # @param status [Symbol] Protocol lifecycle status symbol + # @param status [Symbol] Protocol lifecycle status, one of {Rules::STATUSES} # @param upload_url [String, nil] Session upload URL # @param offset [Integer] Contiguous bytes acknowledged by server # @param chunk_size [Integer] Resolved effective chunk size in bytes diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index 6bbc4cc..fb30d06 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -34,6 +34,12 @@ module ResumableUpload # Coordinates HTTP network operations, stream buffering, monotonic deadlines, # and delegates state transitions to Core. # + # The outer tier of the three-tier design. All side effects live here; all protocol decisions live in + # {Rules}, which carries the state graph and the error category taxonomy. Category 1 transient retries + # are absorbed here by `Gapic::Common::RetryPolicy` and never reach {Core}. See + # `design/implementation-guide.md` section 2.5 for the buffer and stream position invariants, and + # section 6.3 for the deadline model. + # # rubocop:disable Metrics/ClassLength class Driver include Gapic::LoggingConcerns diff --git a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb index c52898f..30de050 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb @@ -340,8 +340,8 @@ def self.from event, response_body: nil, resume_handle: nil end ## - # Raised when Scotty backend explicitly rejects the upload session - # (returns non-2xx with X-Goog-Upload-Status: final). + # Raised when the resumable upload backend explicitly rejects the + # upload session (returns non-2xx with X-Goog-Upload-Status: final). # # @!attribute [r] response_body # @return [String, nil] Response body from backend diff --git a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb index 607e59d..a7a25ed 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb @@ -28,6 +28,69 @@ module ResumableUpload # Pure functional transition engine for the Resumable Upload Protocol. # Contains zero side-effects and zero persistent state. # + # ### Model + # + # {Rules.decide} is the protocol. It is a total function of `[state.status, shape_of(event)]` returning a + # {Decision} that carries the next {State} and the instructions for the Driver to execute. Three + # vocabularies define it, each published as a frozen constant: + # + # * {STATUSES} - protocol lifecycle statuses a {State} may hold. + # * {SHAPES} - canonical event shapes that {Rules.shape_of} reduces raw events to. + # * {RECIPES} - transition handlers that {Rules.decide} may select. + # + # Every router arm maps one (status, shape) pair to exactly one recipe, and every recipe returns + # `[next_state, instructions]`. Adding a protocol behaviour means adding a shape, a recipe and an arm. + # It never means adding branching to the Driver. + # + # ### State transition graph + # + # ```mermaid + # stateDiagram-v2 + # [*] --> initializing + # initializing --> starting : start_upload + # initializing --> recovery : resume_upload + # starting --> transmission_reading : response_active + # transmission_reading --> transmission_sending : chunk_read_full + # transmission_sending --> transmission_reading : response_active + # transmission_reading --> finalizing_sending_upload : chunk_read_eof_with_data + # transmission_reading --> finalizing_sending_finalize : chunk_read_eof_empty + # finalizing_sending_upload --> success : response_final + # finalizing_sending_finalize --> success : response_final + # transmission_sending --> recovery : response_cat2 / connection_failed / timeout + # finalizing_sending_upload --> recovery : response_cat2 / connection_failed / timeout + # finalizing_sending_finalize --> recovery : response_cat2 / connection_failed / timeout + # recovery --> recovery : response_cat2 + # recovery --> transmission_reading : response_active + # recovery --> success : response_final + # starting --> error : response_cat2 / response_fatal_bad_response / request_* + # recovery --> error : request_* + # transmission_sending --> rejected : response_rejected + # recovery --> rejected : response_rejected + # cancelling --> cancelled : response_cancelled + # success --> [*] + # rejected --> [*] + # cancelled --> [*] + # error --> [*] + # ``` + # + # Two families of edge are omitted above to keep the graph readable: every non-terminal status moves to + # `cancelling` on `:user_cancel` and to `error` on `:global_deadline_exceeded`. + # + # ### Router ordering + # + # Arms are evaluated top to bottom, so their order encodes precedence and is load-bearing: + # + # * The catch-all `[_, :global_deadline_exceeded]` and `[_, :user_cancel]` arms sit above the rejected, + # bad-response and request-error arms. Moving them below would let a late failure response win over an + # expired deadline in precisely the states where the deadline matters. + # * `[:starting, :response_cat2]` fails instead of recovering, unlike the same shape during transmission + # and finalizing. There is no upload to recover to until initiation yields an upload URL. + # * `recovery` re-queries on `:response_cat2` with no attempt cap. Termination is guaranteed only by the + # global deadline the Driver enforces, not by anything in this module. + # + # See `design/implementation-guide.md` section 4 for the transition specification and section 6.1 for the + # error category taxonomy this module implements. + # # rubocop:disable Metrics/ModuleLength module Rules ## @@ -36,15 +99,39 @@ module Rules # @return [Integer] DEFAULT_CHUNK_SIZE = 8_388_608 # 8 MB + # Failures are classified into three categories, which the rest of this module is written in terms of: + # + # * **Category 1 (transient transport)** - connection resets, DNS failures, load shedding. Handled + # entirely inside the Driver by `Gapic::Common::RetryPolicy`; Core never sees them. Only their + # exhaustion reaches this module, as `:request_retries_exhausted`. + # * **Category 2 (recoverable protocol)** - the client offset may be misaligned with the server, or a + # proxy stripped the protocol headers. Resolved by querying the server for its acknowledged offset + # and realigning, never by blindly retransmitting. Shape: `:response_cat2`. + # * **Category 3 (terminal)** - structurally invalid, unauthorized, rejected, or out of budget. + # Resolved by transitioning to `:error` or `:rejected` and emitting `Instruction::TerminateFailure`. + # + # See `design/implementation-guide.md` section 6.1 for the full classification. + ## # @private # HTTP status codes eligible for Category 2 (recovery) handling. + # + # Descriptive rather than load-bearing: {Rules.classify_http_response} routes any non-fatal status with a + # missing or empty `X-Goog-Upload-Status` to `:response_cat2`, so this list does not gate the decision. + # It records the codes the upload backend is expected to produce in that situation, and is asserted against + # {Rules.classify_http_response} by the classification tests. + # # @return [Array] CAT2_STATUS_CODES = [400, 408, 409, 412, 416, 429, 499].freeze ## # @private - # HTTP status codes that are immediately fatal and non-retriable. + # HTTP status codes that are immediately fatal and non-retriable (Category 3). + # + # Unlike {CAT2_STATUS_CODES} this list is load-bearing: {Rules.classify_http_response} consults it to decide + # between `:response_fatal_bad_response` and `:response_cat2` when the upload status header is absent, + # and {RetryPolicies::START_PREDICATE} consults it to refuse retries outright. + # # @return [Array] FATAL_STATUS_CODES = [401, 403, 404, 405, 410, 413, 415].freeze @@ -67,6 +154,76 @@ module Rules rejected: "in rejected upload state" }.freeze + ## + # @private + # Canonical list of protocol lifecycle statuses a {State} may hold. Derived from the keys of + # {STATE_DESCRIPTIONS} so the two cannot drift. + # + # * `:initializing` - nothing dispatched yet; awaits `:start_upload` or `:resume_upload`. + # * `:starting` - initiation request in flight; no upload URL yet. + # * `:transmission_reading` - filling the buffer from the stream. + # * `:transmission_sending` - a non-final chunk is in flight. + # * `:finalizing_sending_upload` - the last chunk is in flight, combined with the finalize command. + # * `:finalizing_sending_finalize` - a standalone finalize is in flight; all data bytes were already sent. + # * `:recovery` - offset query in flight, either after a recoverable failure or as the first step of a + # resume. + # * `:cancelling` - cancel command in flight. Not reachable from the public API. + # * `:success` - terminal; the upload finalized. + # * `:cancelled` - terminal; the server acknowledged cancellation. + # * `:rejected` - terminal; the server refused the upload. + # * `:error` - terminal for this run; `last_error` holds the exception. + # + # `:success`, `:cancelled` and `:rejected` are finalized and yield no {ResumeHandle}. `:error` ends the + # run but may still be resumable from a fresh session; see {Rules.resume_handle_from}. + # + # @return [Array] + STATUSES = STATE_DESCRIPTIONS.keys.freeze + + ## + # @private + # Canonical list of event shapes produced by {Rules.shape_of} and matched by {Rules.decide}, + # grouped by the event family each is reduced from. + # + # Lifecycle signals, one shape each from {Event::StartUpload}, {Event::ResumeUpload}, {Event::Cancel} + # and {Event::GlobalDeadlineExceeded}: `:start_upload`, `:resume_upload`, `:user_cancel`, + # `:global_deadline_exceeded`. + # + # Stream reads, from {Event::ChunkRead} split by EOF and buffer occupancy. The three-way split is what + # lets a zero-length tail finalize without sending an empty chunk: `:chunk_read_full`, + # `:chunk_read_eof_with_data`, `:chunk_read_eof_empty`. + # + # Request failures, from {Event::RequestFailed} split by `kind`: `:request_timeout`, + # `:request_retries_exhausted`, `:request_connection_failed`, `:request_failed_unknown`. + # + # HTTP responses, from {Event::HttpResponse} split by `X-Goog-Upload-Status` and HTTP status: + # `:response_active`, `:response_final`, `:response_cancelled`, `:response_rejected`, `:response_cat2`, + # `:response_fatal_bad_response`. + # + # `:unknown` is a live shape rather than an error sentinel. It is what {Rules.shape_of} returns for anything + # it does not recognise, and it routes to {Rules.fail_with_unmatched_transition}. + # + # @return [Array] + SHAPES = [ + :start_upload, + :resume_upload, + :user_cancel, + :global_deadline_exceeded, + :chunk_read_full, + :chunk_read_eof_with_data, + :chunk_read_eof_empty, + :request_timeout, + :request_retries_exhausted, + :request_connection_failed, + :request_failed_unknown, + :response_active, + :response_final, + :response_cancelled, + :response_rejected, + :response_cat2, + :response_fatal_bad_response, + :unknown + ].freeze + ## # @private # Canonical list of recipe symbols emitted by {Rules.decide}. @@ -169,6 +326,7 @@ def self.shape_of event # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength def self.decide state, event, config shape = shape_of event + raise ArgumentError, "unknown shape: #{shape}" unless SHAPES.include? shape recipe = case [state.status, shape] in [:initializing, :start_upload] @@ -194,12 +352,16 @@ def self.decide state, event, config :complete_upload_finalized in [:recovery, :response_active] :realign_from_recovery + # Re-query with no attempt cap. Only the Driver's global deadline guarantees termination. in [:recovery, :response_cat2] :retry_recovery in [:cancelling, :response_cancelled] :complete_cancellation in [:cancelling, :user_cancel] :ignore_duplicate_cancel + # Order matters from here down. These two catch-alls must stay above the failure arms below, + # so that an expired deadline or a cancellation wins over a late failure response arriving + # in the same states. in [_, :global_deadline_exceeded] :fail_with_deadline_exceeded in [_, :user_cancel] @@ -207,6 +369,9 @@ def self.decide state, event, config in [:starting | :transmission_sending | :finalizing_sending_upload | :finalizing_sending_finalize | :recovery | :cancelling, :response_rejected] :fail_with_rejected + # `:starting` fails on `:response_cat2` rather than entering recovery, unlike the + # transmission and finalizing states above: there is no upload to recover to until + # initiation has returned an upload URL. in [:starting | :cancelling, :response_cat2] | [:starting | :transmission_sending | :finalizing_sending_upload | :finalizing_sending_finalize | :recovery | :cancelling, :response_fatal_bad_response] diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb index e3284b7..7a8a4ee 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb @@ -196,6 +196,70 @@ def test_shape_of_unknown_event assert_equal :unknown, Rules.shape_of("unrecognized_event") end + ## + # One event per shape Rules can produce. Used to check SHAPES in both directions, so that a new shape must + # be added to the constant and a retired shape must be removed from it. + # + def shape_corpus + active = { "X-Goog-Upload-Status" => "active" } + final = { "X-Goog-Upload-Status" => "final" } + cancelled = { "X-Goog-Upload-Status" => "cancelled" } + { + start_upload: Event::StartUpload.new, + resume_upload: Event::ResumeUpload.new, + user_cancel: Event::Cancel.new, + global_deadline_exceeded: Event::GlobalDeadlineExceeded.new, + chunk_read_full: Event::ChunkRead.new(bytes_buffered: 4096, eof: false), + chunk_read_eof_with_data: Event::ChunkRead.new(bytes_buffered: 1024, eof: true), + chunk_read_eof_empty: Event::ChunkRead.new(bytes_buffered: 0, eof: true), + request_timeout: Event::RequestFailed.new(kind: :timeout), + request_retries_exhausted: Event::RequestFailed.new(kind: :retries_exhausted), + request_connection_failed: Event::RequestFailed.new(kind: :connection_failed), + request_failed_unknown: Event::RequestFailed.new(kind: :something_else), + response_active: Event::HttpResponse.new(status: 200, headers: active), + response_final: Event::HttpResponse.new(status: 200, headers: final), + response_cancelled: Event::HttpResponse.new(status: 200, headers: cancelled), + response_rejected: Event::HttpResponse.new(status: 400, headers: final), + response_cat2: Event::HttpResponse.new(status: 200, headers: {}), + response_fatal_bad_response: Event::HttpResponse.new(status: 401, headers: {}), + unknown: Object.new + } + end + + def test_shapes_constant_is_exhaustive_and_minimal + corpus = shape_corpus + + corpus.each do |expected_shape, event| + assert_equal expected_shape, Rules.shape_of(event), + "Corpus event for #{expected_shape} no longer classifies as that shape" + end + + assert_empty Rules::SHAPES - corpus.keys, + "SHAPES members that no corpus event produces (phantom or untested shapes)" + assert_empty corpus.keys - Rules::SHAPES, + "shape_of produces shapes that are missing from SHAPES" + assert_predicate Rules::SHAPES, :frozen? + end + + def test_statuses_tracks_state_descriptions + assert_equal Rules::STATE_DESCRIPTIONS.keys, Rules::STATUSES + assert_equal Rules::STATUSES.uniq, Rules::STATUSES + assert_predicate Rules::STATUSES, :frozen? + end + + def test_decide_rejects_a_shape_outside_the_vocabulary + state = State.new + config = CompleteUploadConfig.new initial_url: "https://example.com/upload", stream: StringIO.new("data") + + error = Rules.stub :shape_of, :not_a_real_shape do + assert_raises ArgumentError do + Rules.decide state, Event::StartUpload.new, config + end + end + + assert_match(/unknown shape: not_a_real_shape/, error.message) + end + def test_resolve_chunk_size_with_nil_or_non_positive_granularity # nil granularity assert_equal 1024, Rules.resolve_chunk_size(1024, nil) @@ -215,7 +279,7 @@ def test_resolve_chunk_size_when_divisible assert_equal 1024, Rules.resolve_chunk_size(1024, 256) assert_equal 1_048_576, Rules.resolve_chunk_size(1_048_576, 262_144) - # Default chunk size (8_388_608) evenly divides 256 KB standard Scotty granularity + # Default chunk size (8_388_608) evenly divides the standard 256 KB backend granularity assert_equal 8_388_608, Rules.resolve_chunk_size(nil, 262_144) assert_equal 8_388_608, Rules.resolve_chunk_size(nil, 524_288) end From b6eef11c9642e6fd46013d93a02014f4e4a4e98f Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Mon, 14 Sep 2026 21:06:36 +0000 Subject: [PATCH 67/79] chore: refactor the configuration structures and clarify documentation around resume --- .../integration/integration_helper.rb | 23 +- .../resumable_upload/resume_test.rb | 8 +- .../lib/gapic/rest/resumable_upload.rb | 10 +- .../lib/gapic/rest/resumable_upload/core.rb | 2 +- .../gapic/rest/resumable_upload/data_types.rb | 134 +++++----- .../lib/gapic/rest/resumable_upload/driver.rb | 7 +- .../resumable_upload/driver/upload_log.rb | 4 +- .../lib/gapic/rest/resumable_upload/errors.rb | 19 ++ .../lib/gapic/rest/resumable_upload/rules.rb | 42 ++-- .../gapic/rest/resumable_upload/session.rb | 235 ++++++++++++------ .../gapic/rest/resumable_upload/core_test.rb | 2 +- .../rest/resumable_upload/data_types_test.rb | 22 +- .../driver/upload_log_test.rb | 2 +- .../resumable_upload/driver_buffer_test.rb | 2 +- .../resumable_upload/driver_config_test.rb | 18 +- .../driver_error_mapping_test.rb | 2 +- .../resumable_upload/driver_logging_test.rb | 22 +- .../resumable_upload/driver_progress_test.rb | 4 +- .../driver_retry_policy_test.rb | 4 +- .../resumable_upload/driver_retry_test.rb | 8 +- .../rest/resumable_upload/driver_test.rb | 4 +- .../rules_classification_test.rb | 2 +- .../resumable_upload/rules_decide_test.rb | 2 +- .../rest/resumable_upload/rules_error_test.rb | 2 +- .../resumable_upload/rules_recovery_test.rb | 2 +- .../gapic/rest/resumable_upload/rules_test.rb | 2 +- .../rest/resumable_upload/session_test.rb | 125 ++++++++-- 27 files changed, 460 insertions(+), 249 deletions(-) diff --git a/gapic-common/integration/integration_helper.rb b/gapic-common/integration/integration_helper.rb index 6ab00fc..0af5c0b 100644 --- a/gapic-common/integration/integration_helper.rb +++ b/gapic-common/integration/integration_helper.rb @@ -120,9 +120,13 @@ def build_config scenario: nil, scenario_config: {}, **overrides defaults[:upload_size] = DEFAULT_PAYLOAD_SIZE end - Gapic::Rest::ResumableUpload::CompleteUploadConfig.new(**defaults, **overrides, initial_headers: headers) + Gapic::Rest::ResumableUpload::StartUploadConfig.new(**defaults, **overrides, initial_headers: headers) end + START_ONLY_KEYS = [:initial_url, :initial_body, :initial_headers, :chunk_size, :start_retry_policy].freeze + + # Builds a session from the shared arguments and remembers the per-run arguments that #start needs, + # so callers can run it with `start_session session`. def build_session scenario: nil, scenario_config: {}, **overrides @progress_records = [] headers = (overrides.delete(:initial_headers) || {}).dup @@ -133,15 +137,18 @@ def build_session scenario: nil, scenario_config: {}, **overrides ) end + @start_args = { + initial_url: UPLOAD_PATH, + initial_headers: headers, + start_retry_policy: FAST_RETRY, + chunk_size: DEFAULT_CHUNK_SIZE + }.merge(overrides.slice(*START_ONLY_KEYS)) + defaults = { client_stub: showcase_client_stub, - initial_url: UPLOAD_PATH, - initial_headers: headers, - start_retry_policy: FAST_RETRY, control_plane_retry_policy: FAST_RETRY, data_plane_retry_policy: FAST_RETRY, timeout: 10, - chunk_size: DEFAULT_CHUNK_SIZE, on_progress: ->(progress) { @progress_records << progress }, logger: @logger } @@ -150,7 +157,11 @@ def build_session scenario: nil, scenario_config: {}, **overrides defaults[:upload_size] = DEFAULT_PAYLOAD_SIZE end - Gapic::Rest::ResumableUpload::Session.new(**defaults, **overrides) + Gapic::Rest::ResumableUpload::Session.new(**defaults, **overrides.except(*START_ONLY_KEYS)) + end + + def start_session session, **overrides + session.start(**@start_args, **overrides) end def raw_start scenario: nil, scenario_config: {}, upload_size: nil, headers: {} diff --git a/gapic-common/integration/resumable_upload/resume_test.rb b/gapic-common/integration/resumable_upload/resume_test.rb index dc75ef0..5c4dc5f 100644 --- a/gapic-common/integration/resumable_upload/resume_test.rb +++ b/gapic-common/integration/resumable_upload/resume_test.rb @@ -157,7 +157,7 @@ def test_golden_user_style_resume_seekable session1 = build_session stream: stream, on_progress: on_progress err = assert_raises UserPauseError do - session1.start + start_session session1 end assert session1.bound? @@ -186,7 +186,7 @@ def test_golden_user_style_resume_unseekable session1 = build_session stream: UnseekableStream.new(payload(DEFAULT_PAYLOAD_SIZE)), on_progress: on_progress err = assert_raises UserPauseError do - session1.start + start_session session1 end assert session1.bound? @@ -206,13 +206,13 @@ def test_golden_user_style_resume_unseekable # D7. Lifecycle and contract violations on session runs. def test_lifecycle_violations session = build_session stream: StringIO.new(payload(100)), upload_size: 100 - session.start + start_session session assert session.bound? # Second start on executed session raises SessionStateError assert_raises Gapic::Rest::ResumableUpload::SessionStateError do - session.start + start_session session end # Resume on already bound/executed session raises SessionStateError diff --git a/gapic-common/lib/gapic/rest/resumable_upload.rb b/gapic-common/lib/gapic/rest/resumable_upload.rb index c5fb204..a7530e1 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload.rb @@ -48,12 +48,13 @@ module Rest # @example Initiating an upload, rescuing an error, and resuming from a fresh session # session = Gapic::Rest::ResumableUpload::Session.new( # client_stub: client_stub, - # stream: stream, - # initial_url: "https://example.googleapis.com/resumable/upload/v1/example/upload:new" + # stream: stream # ) # # begin - # response = session.start + # response = session.start( + # initial_url: "https://example.googleapis.com/resumable/upload/v1/example/upload:new" + # ) # rescue Gapic::Rest::ResumableUpload::HasResumeHandle => e # handle = e.resume_handle # raise unless handle @@ -61,8 +62,7 @@ module Rest # stream.rewind # resumed_session = Gapic::Rest::ResumableUpload::Session.new( # client_stub: client_stub, - # stream: stream, - # initial_url: session.initial_url + # stream: stream # ) # response = resumed_session.resume resume_handle: handle # end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/core.rb b/gapic-common/lib/gapic/rest/resumable_upload/core.rb index c7b6de3..bf82a26 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/core.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/core.rb @@ -42,7 +42,7 @@ class Core # @private # Initializes a Core state machine container. # - # @param config [CompleteUploadConfig] Upload session configuration + # @param config [StartUploadConfig, ResumeUploadConfig] Upload session configuration # def initialize config @config = config diff --git a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb index bbba8f2..d049c90 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb @@ -20,7 +20,43 @@ module Rest module ResumableUpload ## # @private - # Immutable configuration for initiating and executing a resumable upload session. + # Configuration members shared by {StartUploadConfig} and {ResumeUploadConfig}, in the order both + # definitions splat them. + # + # The two config types are deliberately *flat*: {Core}, {Rules} and {Driver} read every member + # straight off `config`. Nesting the shared members inside a common object would turn every + # `config.upload_size` into `config.common.upload_size` at some thirty call sites for no behavioural + # gain, so they are spliced into each `Data.define` instead. + # + # * `stream` [IO] Binary input stream to upload. Required. + # * `upload_size` [Integer, nil] Total upload bytes if known upfront. + # * `content_type` [String, nil] MIME type of uploaded media. + # * `timeout` [Numeric, nil] Total upload timeout in seconds (zero or negative is treated as nil). + # * `control_plane_retry_policy` [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for control + # commands (query, cancel). + # * `data_plane_retry_policy` [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for data commands + # (upload, finalize). + # * `on_progress` [Proc, nil] Callback invoked as `->(progress)` with a {Progress} instance. + # + # Every retry policy member, here and in the per-run configs, follows the same convention: a + # {Gapic::Common::RetryPolicy} replaces the default policy outright, while a Hash overrides only the + # settings it names and leaves the remaining defaults — including retry codes and predicates — in place. + # + COMMON_MEMBERS = [ + :stream, + :upload_size, + :content_type, + :timeout, + :control_plane_retry_policy, + :data_plane_retry_policy, + :on_progress + ].freeze + + ## + # @private + # Immutable configuration for a run that initiates a new upload session, i.e. {Session#start}. + # + # Carries {COMMON_MEMBERS} plus the members only an initiating run uses. # # @!attribute [r] initial_url # @return [String] Initial endpoint URI for session initiation @@ -28,46 +64,19 @@ module ResumableUpload # @return [String, nil] Request payload for session initiation # @!attribute [r] initial_headers # @return [Hash] Additional headers for initiation - # @!attribute [r] stream - # @return [IO] Binary input stream to upload - # @!attribute [r] upload_size - # @return [Integer, nil] Total upload bytes if known upfront # @!attribute [r] chunk_size - # @return [Integer, nil] Explicit chunk size in bytes - # @!attribute [r] content_type - # @return [String, nil] MIME type of uploaded media - # @!attribute [r] timeout - # @return [Numeric, nil] Total upload timeout in seconds (zero/negative values treated as nil) + # @return [Integer, nil] Requested chunk size in bytes, aligned to the granularity the server + # reports during initiation. A resumed run takes its chunk size from {ResumeUploadConfig}. # @!attribute [r] start_retry_policy - # @return [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for session initiation (start). - # Passing a {Gapic::Common::RetryPolicy} replaces the default policy. - # Passing a Hash overrides specified settings while preserving unspecified defaults - # (such as retry codes and predicates). - # @!attribute [r] control_plane_retry_policy - # @return [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for session control commands (query/cancel). - # Passing a {Gapic::Common::RetryPolicy} replaces the default policy. - # Passing a Hash overrides specified settings while preserving unspecified defaults. - # @!attribute [r] data_plane_retry_policy - # @return [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for data transmission commands (upload/finalize). - # Passing a {Gapic::Common::RetryPolicy} replaces the default policy. - # Passing a Hash overrides specified settings while preserving unspecified defaults - # (such as retry codes and predicates). - # @!attribute [r] on_progress - # @return [Proc, nil] Callback invoked as `->(progress)` with a {Progress} instance + # @return [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for session initiation # - CompleteUploadConfig = Data.define( + StartUploadConfig = Data.define( + *COMMON_MEMBERS, :initial_url, :initial_body, :initial_headers, - :stream, - :upload_size, :chunk_size, - :content_type, - :timeout, - :start_retry_policy, - :control_plane_retry_policy, - :data_plane_retry_policy, - :on_progress + :start_retry_policy ) do ## # @private @@ -78,13 +87,14 @@ module ResumableUpload # @param initial_body [String, nil] Request payload for session initiation # @param initial_headers [Hash] Additional headers for initiation # @param upload_size [Integer, nil] Total upload bytes if known upfront - # @param chunk_size [Integer, nil] Explicit chunk size in bytes + # @param chunk_size [Integer, nil] Requested chunk size in bytes # @param content_type [String, nil] MIME type of uploaded media # @param timeout [Numeric, nil] Total upload timeout in seconds (zero/negative values treated as nil) # @param start_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for session initiation # @param control_plane_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for control commands # @param data_plane_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for data commands # @param on_progress [Proc, nil] Callback invoked as `->(progress)` with a {Progress} instance + # @raise [ArgumentError] If required arguments are missing or invalid # def initialize initial_url:, stream:, @@ -98,6 +108,9 @@ def initialize initial_url:, control_plane_retry_policy: nil, data_plane_retry_policy: nil, on_progress: nil + raise ArgumentError, "initial_url is required" if initial_url.nil? || initial_url.to_s.strip.empty? + raise ArgumentError, "stream is required" if stream.nil? + super( initial_url: initial_url, initial_body: initial_body, @@ -117,46 +130,23 @@ def initialize initial_url:, ## # @private - # Immutable configuration for resuming an existing upload session. + # Immutable configuration for a run that resumes an existing upload session, i.e. {Session#resume}. + # + # Carries {COMMON_MEMBERS} plus the upload URL and chunk size the earlier run established. There is + # no `start_retry_policy` here: a resumed run issues no initiation request, so the member would + # always be dead. # # @!attribute [r] upload_url # @return [String] Session upload URL returned by the upload backend # @!attribute [r] chunk_size - # @return [Integer] Explicit chunk size in bytes (must be a positive integer) - # @!attribute [r] stream - # @return [IO] Binary input stream to upload - # @!attribute [r] upload_size - # @return [Integer, nil] Total upload bytes if known upfront - # @!attribute [r] content_type - # @return [String, nil] MIME type of uploaded media - # @!attribute [r] timeout - # @return [Numeric, nil] Total upload timeout in seconds (zero/negative values treated as nil) - # @!attribute [r] start_retry_policy - # @return [Gapic::Common::RetryPolicy, Hash, nil] Unused; preserved for interface parity with - # {CompleteUploadConfig}. - # @!attribute [r] control_plane_retry_policy - # @return [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for session control commands (query/cancel). - # Passing a {Gapic::Common::RetryPolicy} replaces the default policy. - # Passing a Hash overrides specified settings while preserving unspecified defaults. - # @!attribute [r] data_plane_retry_policy - # @return [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for data transmission commands (upload/finalize). - # Passing a {Gapic::Common::RetryPolicy} replaces the default policy. - # Passing a Hash overrides specified settings while preserving unspecified defaults - # (such as retry codes and predicates). - # @!attribute [r] on_progress - # @return [Proc, nil] Callback invoked as `->(progress)` with a {Progress} instance + # @return [Integer] Explicit chunk size in bytes (must be a positive integer). Server granularity is + # reported only during initiation, which a resumed run skips, so the size is carried forward from + # the earlier run rather than re-negotiated. # ResumeUploadConfig = Data.define( + *COMMON_MEMBERS, :upload_url, - :chunk_size, - :stream, - :upload_size, - :content_type, - :timeout, - :start_retry_policy, - :control_plane_retry_policy, - :data_plane_retry_policy, - :on_progress + :chunk_size ) do ## # @private @@ -168,7 +158,6 @@ def initialize initial_url:, # @param upload_size [Integer, nil] Total upload bytes if known upfront # @param content_type [String, nil] MIME type of uploaded media # @param timeout [Numeric, nil] Total upload timeout in seconds (zero/negative values treated as nil) - # @param start_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Unused; preserved for parity # @param control_plane_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for control commands # @param data_plane_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for data commands # @param on_progress [Proc, nil] Callback invoked as `->(progress)` with a {Progress} instance @@ -180,7 +169,6 @@ def initialize upload_url:, upload_size: nil, content_type: nil, timeout: nil, - start_retry_policy: nil, control_plane_retry_policy: nil, data_plane_retry_policy: nil, on_progress: nil @@ -197,7 +185,6 @@ def initialize upload_url:, upload_size: upload_size, content_type: content_type, timeout: timeout, - start_retry_policy: start_retry_policy, control_plane_retry_policy: control_plane_retry_policy, data_plane_retry_policy: data_plane_retry_policy, on_progress: on_progress @@ -250,6 +237,11 @@ def initialize phase:, bytes_uploaded:, total_bytes: nil ## # Allowed lifecycle phases for an upload session. + # + # A callback observes `:initiating`, `:uploading`, `:recovering`, `:finalizing` and `:completed`. + # `:cancelling` is reserved: cancellation is not exposed on {Session}, so no phase with that value is + # currently emitted. + # # @return [Array] Progress::PHASES = [:initiating, :uploading, :recovering, :finalizing, :cancelling, :completed].freeze diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index fb30d06..b3061ab 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -86,7 +86,7 @@ def upload_url # Initializes a new Resumable Upload Driver. # # @param client_stub [Gapic::Rest::ClientStub] Underlying REST client stub - # @param config [CompleteUploadConfig, ResumeUploadConfig] Configuration for this upload session + # @param config [StartUploadConfig, ResumeUploadConfig] Configuration for this upload session # @param core [Core, nil] Optional Core state machine (defaults to new Core with config) # @param logger [Logger, nil] Optional logger override def initialize client_stub:, config:, core: nil, logger: nil @@ -104,7 +104,10 @@ def initialize client_stub:, config:, core: nil, logger: nil client_id: client_stub.object_id @upload_log = UploadLog.new stub_logger, upload_id: "unstarted" - @start_retry_policy = resolve_retry_policy config.start_retry_policy, RetryPolicies::START_DEFAULTS + # Only an initiating run carries a start policy; a resumed run issues no initiation request. + configured_start_policy = config.is_a?(StartUploadConfig) ? config.start_retry_policy : nil + @start_retry_policy = resolve_retry_policy configured_start_policy, RetryPolicies::START_DEFAULTS + @control_plane_retry_policy = resolve_retry_policy config.control_plane_retry_policy, RetryPolicies::CONTROL_PLANE_DEFAULTS @data_plane_retry_policy = resolve_retry_policy config.data_plane_retry_policy, diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb index 2dc0e13..52dfe61 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb @@ -103,7 +103,7 @@ def decision decision # Logs high-level protocol lifecycle milestone if configured. # # @param decision [Decision] Decision snapshot - # @param config [CompleteUploadConfig] Upload configuration + # @param config [StartUploadConfig, ResumeUploadConfig] Upload configuration # def lifecycle decision, config return if SILENT_RECIPES.include? decision.recipe @@ -251,7 +251,7 @@ def wire_receive_body event, err # Extracts relevant state fields for lifecycle logging. # # @param decision [Decision] Decision snapshot - # @param config [CompleteUploadConfig] Upload configuration + # @param config [StartUploadConfig, ResumeUploadConfig] Upload configuration # @return [Hash] Metadata fields for log entry # def lifecycle_fields decision, config diff --git a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb index 30de050..945dd3a 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb @@ -144,6 +144,25 @@ def build_from_http_event event, prefix: ## # Mixin providing {ResumeHandle} access and uniform formatting for resumable errors. # + # Every error that may carry a resume handle includes this module, so it doubles as the rescue target + # for "this upload failed but can be retried from where it stopped": + # + # @example + # begin + # session.start initial_url: url + # rescue Gapic::Rest::ResumableUpload::HasResumeHandle => e + # retry_later e.resume_handle if e.resume_handle + # raise + # end + # + # Included by {RequestFailedError}, {DeadlineExceededError}, {BadResponseError}, + # {UnseekableStreamError}, {StreamMismatchError} and {InvalidTransitionError}. + # + # Deliberately **not** included by {UploadRejectedError} or {SessionStateError}: the first means the + # server refused the upload outright and the second is a caller misuse, so neither is retryable. Note + # also that `resume_handle` may still be `nil` on an including error, for instance when the failure + # happened before initiation established an upload URL. + # # @!attribute [r] resume_handle # @return [Gapic::Rest::ResumableUpload::ResumeHandle, nil] Associated upload session resume handle # diff --git a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb index a7a25ed..6a2f302 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb @@ -320,7 +320,7 @@ def self.shape_of event # # @param state [State] Current state # @param event [Object] Input event - # @param config [CompleteUploadConfig] Static configuration + # @param config [StartUploadConfig, ResumeUploadConfig] Static configuration # @return [Decision] Decision snapshot # # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength @@ -404,7 +404,7 @@ def self.decide state, event, config # # @param state [State] Current state # @param event [Object] Input event - # @param config [CompleteUploadConfig] Static configuration + # @param config [StartUploadConfig, ResumeUploadConfig] Static configuration # @return [Array>] Tuple of [next_state, instructions] def self.step state, event, config decision = decide state, event, config @@ -417,7 +417,7 @@ def self.step state, event, config # # @param state [State] Current state # @param _event [Object] Dispatched event - # @param config [CompleteUploadConfig] Session configuration + # @param config [StartUploadConfig] Session configuration; only an initiating run reaches this recipe # @return [Array>] Tuple of [next_state, instructions] def self.start_session state, _event, config next_state = state.with status: :starting @@ -466,7 +466,7 @@ def self.resume_session state, _event, config # # @param state [State] Current state # @param event [Event::HttpResponse] Initiation response - # @param config [CompleteUploadConfig] Session configuration + # @param config [StartUploadConfig, ResumeUploadConfig] Session configuration # @return [Array>] Tuple of [next_state, instructions] def self.begin_transmission state, event, config granularity_str = header_value event.headers, "x-goog-upload-chunk-granularity" @@ -495,7 +495,7 @@ def self.begin_transmission state, event, config # # @param state [State] Current state # @param event [Event::ChunkRead] Chunk read event - # @param _config [CompleteUploadConfig] Session configuration + # @param _config [StartUploadConfig, ResumeUploadConfig] Session configuration # @return [Array>] Tuple of [next_state, instructions] def self.send_chunk state, event, _config next_state = state.with( @@ -519,7 +519,7 @@ def self.send_chunk state, event, _config # # @param state [State] Current state # @param event [Event::ChunkRead] Chunk read event with EOF - # @param config [CompleteUploadConfig] Session configuration + # @param config [StartUploadConfig, ResumeUploadConfig] Session configuration # @return [Array>] Tuple of [next_state, instructions] def self.send_upload_finalize state, event, config next_state = state.with( @@ -545,7 +545,7 @@ def self.send_upload_finalize state, event, config # # @param state [State] Current state # @param _event [Object] Dispatched event - # @param config [CompleteUploadConfig] Session configuration + # @param config [StartUploadConfig, ResumeUploadConfig] Session configuration # @return [Array>] Tuple of [next_state, instructions] def self.send_finalize state, _event, config next_state = state.with( @@ -566,7 +566,7 @@ def self.send_finalize state, _event, config # # @param state [State] Current state # @param _event [Object] Dispatched event - # @param config [CompleteUploadConfig] Session configuration + # @param config [StartUploadConfig, ResumeUploadConfig] Session configuration # @return [Array>] Tuple of [next_state, instructions] def self.ack_chunk state, _event, config new_offset = state.offset + state.in_flight_length @@ -590,7 +590,7 @@ def self.ack_chunk state, _event, config # # @param state [State] Current state # @param _event [Object] Dispatched event - # @param config [CompleteUploadConfig] Session configuration + # @param config [StartUploadConfig, ResumeUploadConfig] Session configuration # @return [Array>] Tuple of [next_state, instructions] def self.enter_recovery state, _event, config next_state = state.with( @@ -611,7 +611,7 @@ def self.enter_recovery state, _event, config # # @param state [State] Current state # @param _event [Object] Dispatched event - # @param _config [CompleteUploadConfig] Session configuration + # @param _config [StartUploadConfig, ResumeUploadConfig] Session configuration # @return [Array>] Tuple of [next_state, instructions] def self.retry_recovery state, _event, _config next_state = state.with( @@ -627,7 +627,7 @@ def self.retry_recovery state, _event, _config # # @param state [State] Current state # @param event [Event::HttpResponse] Final HTTP response - # @param _config [CompleteUploadConfig] Session configuration + # @param _config [StartUploadConfig, ResumeUploadConfig] Session configuration # @return [Array>] Tuple of [next_state, instructions] def self.complete_upload_with_data state, event, _config new_offset = state.offset + state.in_flight_length @@ -650,7 +650,7 @@ def self.complete_upload_with_data state, event, _config # # @param state [State] Current state # @param event [Event::HttpResponse] Final HTTP response - # @param _config [CompleteUploadConfig] Session configuration + # @param _config [StartUploadConfig, ResumeUploadConfig] Session configuration # @return [Array>] Tuple of [next_state, instructions] def self.complete_upload_finalized state, event, _config next_state = state.with( @@ -671,7 +671,7 @@ def self.complete_upload_finalized state, event, _config # # @param state [State] Current state # @param event [Event::HttpResponse] Query response containing acknowledged offset - # @param config [CompleteUploadConfig] Session configuration + # @param config [StartUploadConfig, ResumeUploadConfig] Session configuration # @return [Array>] Tuple of [next_state, instructions] def self.realign_from_recovery state, event, config server_offset_str = header_value event.headers, "x-goog-upload-size-received" @@ -696,7 +696,7 @@ def self.realign_from_recovery state, event, config # # @param state [State] Current state # @param event [Object] Cancellation response event - # @param _config [CompleteUploadConfig] Session configuration + # @param _config [StartUploadConfig, ResumeUploadConfig] Session configuration # @return [Array>] Tuple of [next_state, instructions] def self.complete_cancellation state, event, _config err = UploadCancelledError.from event @@ -710,7 +710,7 @@ def self.complete_cancellation state, event, _config # # @param state [State] Current state # @param _event [Object] Dispatched event - # @param _config [CompleteUploadConfig] Session configuration + # @param _config [StartUploadConfig, ResumeUploadConfig] Session configuration # @return [Array>] Tuple of [next_state, instructions] def self.ignore_duplicate_cancel state, _event, _config [state, []] @@ -722,7 +722,7 @@ def self.ignore_duplicate_cancel state, _event, _config # # @param state [State] Current state # @param _event [Object] Dispatched event - # @param config [CompleteUploadConfig] Session configuration + # @param config [StartUploadConfig, ResumeUploadConfig] Session configuration # @return [Array>] Tuple of [next_state, instructions] def self.cancel_session state, _event, config next_state = state.with status: :cancelling @@ -754,7 +754,7 @@ def self.resume_handle_from state # # @param state [State] Current state # @param _event [Object] Dispatched event - # @param _config [CompleteUploadConfig] Session configuration + # @param _config [StartUploadConfig, ResumeUploadConfig] Session configuration # @return [Array>] Tuple of [next_state, instructions] def self.fail_with_deadline_exceeded state, _event, _config handle = resume_handle_from state @@ -773,7 +773,7 @@ def self.fail_with_deadline_exceeded state, _event, _config # # @param state [State] Current state # @param event [Event::HttpResponse] Rejected HTTP response - # @param _config [CompleteUploadConfig] Session configuration + # @param _config [StartUploadConfig, ResumeUploadConfig] Session configuration # @return [Array>] Tuple of [next_state, instructions] def self.fail_with_rejected state, event, _config err = UploadRejectedError.from event @@ -791,7 +791,7 @@ def self.fail_with_rejected state, event, _config # # @param state [State] Current state # @param event [Event::HttpResponse] Fatal HTTP response - # @param _config [CompleteUploadConfig] Session configuration + # @param _config [StartUploadConfig, ResumeUploadConfig] Session configuration # @return [Array>] Tuple of [next_state, instructions] def self.fail_with_bad_response state, event, _config handle = resume_handle_from state @@ -810,7 +810,7 @@ def self.fail_with_bad_response state, event, _config # # @param state [State] Current state # @param event [Event::RequestFailed] Request failure event - # @param _config [CompleteUploadConfig] Session configuration + # @param _config [StartUploadConfig, ResumeUploadConfig] Session configuration # @return [Array>] Tuple of [next_state, instructions] def self.fail_with_request_error state, event, _config handle = resume_handle_from state @@ -829,7 +829,7 @@ def self.fail_with_request_error state, event, _config # # @param state [State] Current state # @param event [Object] Dispatched event - # @param _config [CompleteUploadConfig] Session configuration + # @param _config [StartUploadConfig, ResumeUploadConfig] Session configuration # @raise [InvalidTransitionError] def self.fail_with_unmatched_transition state, event, _config shape = shape_of event diff --git a/gapic-common/lib/gapic/rest/resumable_upload/session.rb b/gapic-common/lib/gapic/rest/resumable_upload/session.rb index 744c710..3c89f36 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/session.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/session.rb @@ -32,9 +32,51 @@ module ResumableUpload # 2. **Bound** (`bound?`): Session has executed or bound to an upload URL. Permitted operations: # none (`start` and `resume` both raise {SessionStateError}). # - # Calling {resumable?} reports whether a new session can resume the upload (`!resume_handle.nil?`). - # Completed uploads (`:success`), rejected uploads, and cancelled uploads are finalized and not - # resumable (`resumable?` returns `false`, `resume_handle` returns `nil`). + # Calling {#resumable?} reports whether a new session can resume the upload (`!resume_handle.nil?`). + # Completed uploads (`:success`) and rejected uploads are finalized and not resumable + # (`resumable?` returns `false`, `resume_handle` returns `nil`). + # + # ### Execution Model + # + # {#start} and {#resume} are synchronous: they block the calling thread for the entire duration of the + # upload and return only on completion or failure. The `on_progress` callback runs on that same thread. + # + # The remaining readers ({#upload_url}, {#bound?}, {#resume_handle}, {#resumable?}, {#running?}) are + # guarded by an internal mutex and may be called from another thread while a run is in progress. Values + # read mid-run are a best-effort snapshot of a state the upload thread is still advancing. + # + # ### Where Arguments Live + # + # The constructor takes what both run types share: the client stub, the stream, `upload_size`, + # `content_type`, `timeout`, the control- and data-plane retry policies, `on_progress` and `logger`. + # Arguments that belong to one run live on the method performing it — `initial_url`, `initial_body`, + # `initial_headers`, `chunk_size` and `start_retry_policy` on {#start}; `upload_url` and `chunk_size`, + # or a {ResumeHandle}, on {#resume}. + # + # ### Recovering From a Failure + # + # A bound session never runs again, so recovery means constructing a new Session. Errors that carry a + # resume handle include the {HasResumeHandle} mixin, which can be rescued directly to catch all of them: + # + # @example Resuming after a recoverable failure + # begin + # session.start initial_url: url + # rescue Gapic::Rest::ResumableUpload::HasResumeHandle => e + # raise unless e.resume_handle + # Session.new(client_stub: client_stub, stream: File.open(path, "rb")) + # .resume(resume_handle: e.resume_handle) + # end + # + # The replacement session needs a stream positioned at byte 0 of the whole object, not at the server's + # acknowledged offset; {#resume} fast-forwards on its own. For an unseekable stream that means opening a + # fresh one, since it cannot be rewound. + # + # ### Defaults + # + # * `chunk_size` defaults to 8 MB, then rounds down to a multiple of any chunk granularity the server + # requires. + # * `timeout` defaults to `upload_size / 1 MB per second` when `upload_size` is known, floored at one + # hour, and to one hour flat when it is not. # class Session # @return [Gapic::Rest::ClientStub] Underlying REST client stub @@ -47,41 +89,32 @@ class Session # @return [IO] attr_reader :stream - # @return [String] Initial endpoint URI for session initiation - attr_reader :initial_url - - # @return [String, nil] Request payload for session initiation - attr_reader :initial_body - - # @return [Hash] Additional headers for initiation - attr_reader :initial_headers - # @return [Integer, nil] Total upload bytes if known upfront attr_reader :upload_size - ## - # Explicit chunk size in bytes. If `nil`, the protocol implementation assigns a default value. - # The effective chunk size may be adjusted if the server specifies a required data granularity. - # - # @return [Integer, nil] - attr_reader :chunk_size - # @return [String, nil] MIME type of uploaded media attr_reader :content_type ## - # Total upload timeout in seconds. If `nil`, the protocol implementation assigns a default value. + # Total upload timeout in seconds, covering the whole run rather than any single request. When `nil`, + # it resolves to `upload_size / 1 MB per second` floored at one hour if `upload_size` is known, and to + # one hour flat otherwise. Zero and negative values are treated as `nil`. # # @return [Numeric, nil] attr_reader :timeout - # @return [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for session initiation - attr_reader :start_retry_policy - - # @return [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for control commands + ## + # Retry policy for control commands (query, cancel). A {Gapic::Common::RetryPolicy} replaces the + # default policy outright; a Hash overrides only the settings it names. + # + # @return [Gapic::Common::RetryPolicy, Hash, nil] attr_reader :control_plane_retry_policy - # @return [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for data commands + ## + # Retry policy for data commands (upload, finalize). A {Gapic::Common::RetryPolicy} replaces the + # default policy outright; a Hash overrides only the settings it names. + # + # @return [Gapic::Common::RetryPolicy, Hash, nil] attr_reader :data_plane_retry_policy ## @@ -99,20 +132,18 @@ class Session ## # Initializes a new Resumable Upload Session. # + # The constructor takes only what both run types share. Arguments specific to a single run live on + # the method that performs it: initiation details on {#start}, the upload URL and chunk size on + # {#resume}. + # # @param client_stub [Gapic::Rest::ClientStub] Underlying REST client stub # @param stream [IO] Binary input stream to upload. Precondition: assumed to be positioned at byte 0 # (not rewound prior to reading) and not closed after use. - # @param initial_url [String] Initial endpoint URI for session initiation - # @param initial_body [String, nil] Request payload for session initiation (defaults to nil) - # @param initial_headers [Hash] Additional headers for initiation # @param upload_size [Integer, nil] Total upload bytes if known upfront - # @param chunk_size [Integer, nil] Explicit chunk size in bytes. If `nil`, the protocol implementation - # assigns a default value. The effective chunk size may be modified if the server specifies a required - # data granularity. # @param content_type [String, nil] MIME type of uploaded media - # @param timeout [Numeric, nil] Total upload timeout in seconds. If `nil`, the protocol implementation - # assigns a default value. - # @param start_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Initiation retry policy + # @param timeout [Numeric, nil] Total upload timeout in seconds covering the whole run. When `nil`, + # resolves to `upload_size / 1 MB per second` floored at one hour if `upload_size` is known, and to + # one hour flat otherwise. # @param control_plane_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Control retry policy # @param data_plane_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Data retry policy # @param on_progress [Proc, nil] Progress callback invoked as `->(progress)` with a {Progress} instance. @@ -122,28 +153,18 @@ class Session # def initialize client_stub:, stream:, - initial_url:, - initial_body: nil, - initial_headers: {}, upload_size: nil, - chunk_size: nil, content_type: nil, timeout: nil, - start_retry_policy: nil, control_plane_retry_policy: nil, data_plane_retry_policy: nil, on_progress: nil, logger: nil @client_stub = client_stub @stream = stream - @initial_url = initial_url - @initial_body = initial_body - @initial_headers = initial_headers || {} @upload_size = upload_size - @chunk_size = chunk_size @content_type = content_type @timeout = timeout - @start_retry_policy = start_retry_policy @control_plane_retry_policy = control_plane_retry_policy @data_plane_retry_policy = data_plane_retry_policy @on_progress = on_progress @@ -207,7 +228,33 @@ def running? # or executed session raises {SessionStateError}. Precondition: the stream is assumed to be # positioned at byte 0 (the session does not rewind it before reading) and is not closed after use. # + # Blocks the calling thread until the upload completes or fails. + # + # @example Uploading a file with progress reporting + # session = Gapic::Rest::ResumableUpload::Session.new( + # client_stub: client_stub, + # stream: File.open("movie.mp4", "rb"), + # upload_size: File.size("movie.mp4"), + # content_type: "video/mp4", + # on_progress: ->(progress) { puts "#{progress.phase}: #{progress.bytes_uploaded} bytes" } + # ) + # response = session.start initial_url: "https://example.googleapis.com/upload/v1/media" + # + # @param initial_url [String] Initial endpoint URI for session initiation + # @param initial_body [String, nil] Request payload for session initiation + # @param initial_headers [Hash] Additional headers for the initiation request. Merged + # last, so a key given here overrides the protocol header the session would otherwise send, + # regardless of its casing. + # @param chunk_size [Integer, nil] Requested chunk size in bytes, defaulting to 8 MB. The effective + # size is rounded down to a multiple of any chunk granularity the server requires, or raised to that + # granularity if it exceeds the requested size. + # @param start_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for the initiation + # request. A {Gapic::Common::RetryPolicy} replaces the default policy outright; a Hash overrides only + # the settings it names and leaves the remaining defaults, including retry codes and predicates, in + # place. # @return [String, Object] Final response body upon completion + # @raise [ArgumentError] If `initial_url` is missing or blank, or a retry policy argument is neither a + # {Gapic::Common::RetryPolicy}, a Hash, nor `nil` # @raise [SessionStateError] If already bound/executed or if a run is currently in progress # @raise [RequestFailedError] If a transport error, timeout, or retry exhaustion occurs # @raise [DeadlineExceededError] If the global upload timeout is exceeded @@ -216,16 +263,25 @@ def running? # @raise [StreamMismatchError] If stream content or length does not match protocol expectations # @raise [InvalidTransitionError] If an unmatched event occurs for the current protocol state # @raise [UploadRejectedError] If the server explicitly rejects the upload session - def start + def start initial_url:, + initial_body: nil, + initial_headers: {}, + chunk_size: nil, + start_retry_policy: nil + config = build_start_config initial_url: initial_url, + initial_body: initial_body, + initial_headers: initial_headers, + chunk_size: chunk_size, + start_retry_policy: start_retry_policy + driver = nil @mutex.synchronize do raise SessionStateError, "A run is already in progress for this session" if @running raise SessionStateError, "Session has already executed a run" if bound_internal? + driver = Driver.new client_stub: @client_stub, config: config, logger: @logger @executed = true @running = true - config = build_start_config - driver = Driver.new client_stub: @client_stub, config: config, logger: @logger end execute_run driver @@ -237,13 +293,38 @@ def start # 2. `resume(resume_handle:)`: Resumes via {ResumeHandle}. # # A session performs exactly one run (`start` or `resume`). Resuming must be executed on a - # fresh, unexecuted session. Precondition: the stream must be positioned at byte 0 (it is not - # rewound prior to reading) and is not closed after use. - # The Driver fast-forwards to the server's acknowledged offset (by seeking on seekable streams - # or reading and discarding on unseekable streams). + # fresh, unexecuted session. Blocks the calling thread until the upload completes or fails. + # + # A resumed run targets an upload the server has already created, so it takes no initiation + # arguments; everything it needs beyond the constructor is on this method. + # + # ### Chunk size + # + # A chunk size must be given explicitly because the server reports chunk granularity during + # initiation, which a resumed run skips. {ResumeHandle} carries the effective value from the original + # run for exactly this reason. + # + # ### Stream position + # + # The stream must be positioned at byte 0 of the whole object, not at the server's acknowledged + # offset, and is not closed after use. The Driver fast-forwards on its own, by seeking on seekable + # streams or by reading and discarding on unseekable ones. An unseekable stream therefore has to be + # freshly opened rather than rewound. # # Completed uploads are not resumable; attempting to resume a completed session raises {SessionStateError}. # + # @example Resuming from a handle persisted by an earlier process + # handle = Gapic::Rest::ResumableUpload::ResumeHandle.new( + # upload_url: row[:upload_url], + # chunk_size: row[:chunk_size] + # ) + # session = Gapic::Rest::ResumableUpload::Session.new( + # client_stub: client_stub, + # stream: File.open("movie.mp4", "rb"), + # upload_size: File.size("movie.mp4") + # ) + # response = session.resume resume_handle: handle + # # @param upload_url [String, nil] Explicit upload URL # @param chunk_size [Integer, nil] Explicit chunk size # @param resume_handle [ResumeHandle, nil] Explicit resume handle @@ -275,11 +356,11 @@ def resume upload_url: nil, raise ArgumentError, "Stream must be positioned at byte 0 to resume an upload (got pos #{@stream.pos})" end + config = build_resume_config target_url, target_chunk_size + driver = Driver.new client_stub: @client_stub, config: config, logger: @logger @executed = true @running = true @upload_url = target_url - config = build_resume_config target_url, target_chunk_size - driver = Driver.new client_stub: @client_stub, config: config, logger: @logger end execute_run driver @@ -316,23 +397,40 @@ def resume_handle_internal ## # @private - # Builds configuration for a new upload session. + # Returns the configuration members shared by both run types, mirroring + # {ResumableUpload::COMMON_MEMBERS}. # - # @return [CompleteUploadConfig] - def build_start_config - CompleteUploadConfig.new( - initial_url: @initial_url, - initial_body: @initial_body, - initial_headers: @initial_headers, + # @return [Hash{Symbol=>Object}] + def common_config_args + { stream: @stream, upload_size: @upload_size, - chunk_size: @chunk_size, content_type: @content_type, timeout: @timeout, - start_retry_policy: @start_retry_policy, control_plane_retry_policy: @control_plane_retry_policy, data_plane_retry_policy: @data_plane_retry_policy, on_progress: @on_progress + } + end + + ## + # @private + # Builds configuration for a new upload session. + # + # @param initial_url [String] Initial endpoint URI for session initiation + # @param initial_body [String, nil] Request payload for session initiation + # @param initial_headers [Hash, nil] Additional headers for initiation + # @param chunk_size [Integer, nil] Requested chunk size in bytes + # @param start_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Initiation retry policy + # @return [StartUploadConfig] + def build_start_config initial_url:, initial_body:, initial_headers:, chunk_size:, start_retry_policy: + StartUploadConfig.new( + initial_url: initial_url, + initial_body: initial_body, + initial_headers: initial_headers || {}, + chunk_size: chunk_size, + start_retry_policy: start_retry_policy, + **common_config_args ) end @@ -345,16 +443,9 @@ def build_start_config # @return [ResumeUploadConfig] def build_resume_config target_url, target_chunk_size ResumeUploadConfig.new( - upload_url: target_url, - chunk_size: target_chunk_size, - stream: @stream, - upload_size: @upload_size, - content_type: @content_type, - timeout: @timeout, - start_retry_policy: @start_retry_policy, - control_plane_retry_policy: @control_plane_retry_policy, - data_plane_retry_policy: @data_plane_retry_policy, - on_progress: @on_progress + upload_url: target_url, + chunk_size: target_chunk_size, + **common_config_args ) end diff --git a/gapic-common/test/gapic/rest/resumable_upload/core_test.rb b/gapic-common/test/gapic/rest/resumable_upload/core_test.rb index c36ddc0..7a7a205 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/core_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/core_test.rb @@ -22,7 +22,7 @@ class CoreTest < Minitest::Test include Gapic::Rest::ResumableUpload def setup - @config = CompleteUploadConfig.new( + @config = StartUploadConfig.new( initial_url: "https://example.com/upload", stream: StringIO.new("test content"), upload_size: 2048, diff --git a/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb index 2f8890b..1472490 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb @@ -24,9 +24,9 @@ class DataTypesTest < Minitest::Test include Gapic::Rest::ResumableUpload - def test_complete_upload_config_defaults + def test_start_upload_config_defaults stream = StringIO.new "content" - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://example.com", stream: stream ) @@ -45,6 +45,22 @@ def test_complete_upload_config_defaults assert_nil config.on_progress end + def test_start_upload_config_validations + stream = StringIO.new "content" + + assert_raises ArgumentError do + StartUploadConfig.new initial_url: nil, stream: stream + end + + assert_raises ArgumentError do + StartUploadConfig.new initial_url: " ", stream: stream + end + + assert_raises ArgumentError do + StartUploadConfig.new initial_url: "https://example.com", stream: nil + end + end + def test_state_defaults_and_with state = State.new @@ -145,7 +161,7 @@ def test_resume_upload_config_defaults assert_nil config.upload_size assert_nil config.content_type assert_nil config.timeout - assert_nil config.start_retry_policy + refute_respond_to config, :start_retry_policy assert_nil config.control_plane_retry_policy assert_nil config.data_plane_retry_policy assert_nil config.on_progress diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver/upload_log_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver/upload_log_test.rb index bde9e24..6d7af2e 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver/upload_log_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver/upload_log_test.rb @@ -27,7 +27,7 @@ def setup @recording = RecordingLogger.new stub_logger = Gapic::LoggingConcerns::StubLogger.new logger: @recording, service: "ResumableUpload" @upload_log = Driver::UploadLog.new stub_logger, upload_id: "test-upload-id" - @config = CompleteUploadConfig.new initial_url: "https://example.com/upload", + @config = StartUploadConfig.new initial_url: "https://example.com/upload", initial_body: nil, initial_headers: {}, stream: StringIO.new("data"), diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb index 9c3bf9e..a7e110d 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_buffer_test.rb @@ -367,7 +367,7 @@ def test_realign_buffer_fast_forward_seekable_stream_raises_stream_mismatch_when private def build_driver stream: - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://example.com/upload", stream: stream, upload_size: 2000, diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb index dc6fb1f..2bdab9d 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb @@ -50,7 +50,7 @@ def make_post_request uri:, body:, params:, options:, method_name: nil def test_resolve_timeout_prefers_positive_config_timeout stub = FakeClientStub.new - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://example.com/upload", stream: StringIO.new("0123"), upload_size: 10 * 1_048_576, @@ -63,7 +63,7 @@ def test_resolve_timeout_prefers_positive_config_timeout def test_resolve_timeout_treats_zero_timeout_same_as_nil stub = FakeClientStub.new - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://example.com/upload", stream: StringIO.new("0123"), timeout: 0 @@ -75,7 +75,7 @@ def test_resolve_timeout_treats_zero_timeout_same_as_nil def test_resolve_timeout_treats_negative_timeout_same_as_nil stub = FakeClientStub.new - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://example.com/upload", stream: StringIO.new("0123"), timeout: -10 @@ -88,7 +88,7 @@ def test_resolve_timeout_treats_negative_timeout_same_as_nil def test_resolve_timeout_calculates_from_upload_size_above_base_timeout stub = FakeClientStub.new large_size = 7_200 * Driver::MIN_ASSUMED_THROUGHPUT # 7200 seconds at 1MB/s - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://example.com/upload", stream: StringIO.new("0123"), upload_size: large_size @@ -100,7 +100,7 @@ def test_resolve_timeout_calculates_from_upload_size_above_base_timeout def test_resolve_timeout_uses_base_timeout_floor_for_small_upload_size stub = FakeClientStub.new - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://example.com/upload", stream: StringIO.new("0123"), upload_size: 1_048_576 # 1 second at 1MB/s < 3600 @@ -112,7 +112,7 @@ def test_resolve_timeout_uses_base_timeout_floor_for_small_upload_size def test_resolve_timeout_defaults_to_base_timeout_when_upload_size_nil stub = FakeClientStub.new - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://example.com/upload", stream: StringIO.new("0123") ) @@ -123,7 +123,7 @@ def test_resolve_timeout_defaults_to_base_timeout_when_upload_size_nil def test_run_raises_deadline_exceeded_when_timeout_expires stub = FakeClientStub.new - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://example.com/upload", stream: StringIO.new("0123"), upload_size: 4, @@ -156,7 +156,7 @@ def test_run_raises_deadline_exceeded_when_clock_advances_past_deadline_mid_batc on_progress = lambda do |progress| current_time = 110.0 if progress.phase == :finalizing end - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://example.com/upload", stream: StringIO.new("0123"), upload_size: 4, @@ -179,7 +179,7 @@ def test_run_raises_deadline_exceeded_when_clock_advances_past_deadline_mid_batc def test_make_post_request_passes_timeout_close_to_remaining_budget_and_decreases_across_calls current_time = 1000.0 stub = FakeClientStub.new(scripted_recovery_responses, on_request: -> { current_time += 10.0 }) - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://example.com/upload", stream: StringIO.new("0123"), upload_size: 4, diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_error_mapping_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_error_mapping_test.rb index 21bf5fe..b7b5e1b 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_error_mapping_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_error_mapping_test.rb @@ -40,7 +40,7 @@ def make_post_request uri:, body: nil, params: {}, options: {}, method_name: nil def setup @client_stub = FailingClientStub.new - @config = CompleteUploadConfig.new( + @config = StartUploadConfig.new( initial_url: "https://example.com/upload", stream: StringIO.new("0123456789"), upload_size: 10, diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb index e8fb66c..4cf93d2 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_logging_test.rb @@ -71,7 +71,7 @@ def test_all_entries_share_upload_id_and_pass_method_names ] stub = FakeStub.new responses - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://storage.googleapis.com/upload", stream: StringIO.new("hello world"), upload_size: 11, @@ -131,7 +131,7 @@ def test_recovery_scenario_logs_enter_recovery_and_realign ] stub = FakeStub.new responses - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://storage.googleapis.com/upload", stream: StringIO.new("hello world"), upload_size: 11, @@ -196,7 +196,7 @@ def test_fatal_failure_logs_warn_with_fail_with_recipe ] stub = FakeStub.new responses - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://storage.googleapis.com/upload", stream: StringIO.new("hello world"), upload_size: 11, @@ -216,7 +216,7 @@ def test_fatal_failure_logs_warn_with_fail_with_recipe def test_unmatched_transition_logs_warn_and_reraises recording = RecordingLogger.new stub = FakeStub.new [] - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://storage.googleapis.com/upload", stream: StringIO.new("hello"), upload_size: 5, @@ -316,7 +316,7 @@ def test_lifecycle_warn_carries_rich_message_when_driver_fails headers: { "x-goog-upload-status" => "final" } ) stub = FakeStub.new [wrapped_err] - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://storage.googleapis.com/upload", stream: StringIO.new("data"), upload_size: 4, @@ -345,7 +345,7 @@ def test_lifecycle_warn_carries_rich_message_when_driver_fails def test_driver_error_mapping_populates_error_on_rescue stub = FakeStub.new [] - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://storage.googleapis.com/upload", stream: StringIO.new("data"), upload_size: 4, @@ -379,7 +379,7 @@ def test_lifecycle_warn_includes_response_body_for_rejected_error body: raw_body } stub = FakeStub.new [faraday_err] - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://storage.googleapis.com/upload", stream: StringIO.new("data"), upload_size: 4, @@ -408,7 +408,7 @@ def test_lifecycle_warn_includes_response_body_for_bad_response_error body: raw_body } stub = FakeStub.new [faraday_err] - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://storage.googleapis.com/upload", stream: StringIO.new("data"), upload_size: 4, @@ -444,7 +444,7 @@ def test_lifecycle_warn_omits_response_body_when_error_lacks_it instructions: [] ) - upload_log.lifecycle decision, CompleteUploadConfig.new( + upload_log.lifecycle decision, StartUploadConfig.new( initial_url: "https://storage.googleapis.com/upload", stream: StringIO.new("data"), upload_size: 4, @@ -482,7 +482,7 @@ def test_error_info_reason_in_details_survives_in_error_and_logs body: raw_body } stub = FakeStub.new [faraday_err] - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://storage.googleapis.com/upload", stream: StringIO.new("data"), upload_size: 4, @@ -560,7 +560,7 @@ def run_two_chunk_upload_with_secret recording ] stub = FakeStub.new responses - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://storage.googleapis.com/upload?token=#{secret}", initial_headers: { "Authorization" => "Bearer #{secret}" }, stream: StringIO.new(stream_data), diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb index 5bddcde..66ac2d7 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb @@ -100,7 +100,7 @@ def test_driver_run_propagates_callback_error_end_to_end stub = ScriptedClientStub.new responses callback = ->(_progress) { raise CustomCallbackError, "Terminal failure in user progress handler" } - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://example.com/upload", stream: StringIO.new("0123456789"), upload_size: 10, @@ -118,7 +118,7 @@ def test_driver_run_propagates_callback_error_end_to_end private def build_driver on_progress: nil - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://example.com/upload", stream: StringIO.new("0123456789"), upload_size: 10, diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_retry_policy_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_retry_policy_test.rb index 50a75a3..76af4c0 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_retry_policy_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_retry_policy_test.rb @@ -25,7 +25,7 @@ class DriverRetryPolicyTest < Minitest::Test include Gapic::Rest::ResumableUpload def setup - @config = CompleteUploadConfig.new( + @config = StartUploadConfig.new( initial_url: "https://example.com/upload", stream: StringIO.new("0123") ) @@ -98,7 +98,7 @@ def test_resolve_retry_policy_with_invalid_type_raises_argument_error end def test_driver_initialize_resolves_hash_overrides_from_config - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://example.com/upload", stream: StringIO.new("0123"), start_retry_policy: { initial_delay: 0.1 }, diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb index 5438685..2fca882 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_retry_test.rb @@ -58,7 +58,7 @@ def test_start_retries_when_response_lacks_status_header_even_on_200 FakeResponse.new(status: 200, headers: { "X-Goog-Upload-Status" => "final" }, body: '{"done":true}') ] stub = FakeClientStub.new responses - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://example.com/upload", stream: StringIO.new("0123"), upload_size: 4, @@ -79,7 +79,7 @@ def test_start_retries_when_response_lacks_status_header_even_on_200 def test_start_exhausts_retries_when_200_responses_continually_lack_status_header responses = Array.new(10) { FakeResponse.new status: 200, headers: {}, body: "" } stub = FakeClientStub.new responses - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://example.com/upload", stream: StringIO.new("0123"), upload_size: 4, @@ -101,7 +101,7 @@ def test_start_exhausts_retries_when_200_responses_continually_lack_status_heade def test_start_exhausts_retries_when_non_200_responses_continually_lack_status_header responses = Array.new(10) { FakeResponse.new status: 503, headers: {}, body: "Service Unavailable" } stub = FakeClientStub.new responses - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://example.com/upload", stream: StringIO.new("0123"), upload_size: 4, @@ -143,7 +143,7 @@ def test_query_does_not_retry_on_missing_status_header_in_driver FakeResponse.new(status: 200, headers: { "X-Goog-Upload-Status" => "final" }, body: '{"done":true}') ] stub = FakeClientStub.new responses - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://example.com/upload", stream: StringIO.new("0123"), upload_size: 4, diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb index aaaa978..ecdb78b 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb @@ -46,7 +46,7 @@ def make_post_request uri:, body:, params:, options:, method_name: nil def test_multi_chunk_upload_with_active_responses progress_records = [] stub = FakeClientStub.new build_scripted_responses - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://example.com/upload", stream: StringIO.new("0123456789"), upload_size: 10, @@ -78,7 +78,7 @@ def test_upload_recovers_when_chunk_response_lacks_status_header progress_records = [] responses = build_recovery_responses stub = FakeClientStub.new responses - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://example.com/upload", stream: StringIO.new("0123456789"), upload_size: 10, diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb index 7a8a4ee..575a700 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb @@ -249,7 +249,7 @@ def test_statuses_tracks_state_descriptions def test_decide_rejects_a_shape_outside_the_vocabulary state = State.new - config = CompleteUploadConfig.new initial_url: "https://example.com/upload", stream: StringIO.new("data") + config = StartUploadConfig.new initial_url: "https://example.com/upload", stream: StringIO.new("data") error = Rules.stub :shape_of, :not_a_real_shape do assert_raises ArgumentError do diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb index 6b872bc..194c0d9 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb @@ -25,7 +25,7 @@ class RulesDecideTest < Minitest::Test include Gapic::Rest::ResumableUpload def setup - @config = CompleteUploadConfig.new( + @config = StartUploadConfig.new( initial_url: "https://example.com/upload", initial_headers: { "X-Custom" => "value" }, initial_body: '{"name":"obj"}', diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb index 81df4bb..212deec 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_error_test.rb @@ -25,7 +25,7 @@ class RulesErrorTest < Minitest::Test include Gapic::Rest::ResumableUpload def setup - @config = CompleteUploadConfig.new( + @config = StartUploadConfig.new( initial_url: "https://example.com/upload", initial_headers: { "X-Custom" => "value" }, initial_body: '{"name":"obj"}', diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_recovery_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_recovery_test.rb index f0f2b72..9c67f0d 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_recovery_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_recovery_test.rb @@ -25,7 +25,7 @@ class RulesRecoveryTest < Minitest::Test include Gapic::Rest::ResumableUpload def setup - @config = CompleteUploadConfig.new( + @config = StartUploadConfig.new( initial_url: "https://example.com/upload", initial_headers: { "X-Custom" => "value" }, initial_body: '{"name":"obj"}', diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb index 96922ba..d904e80 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb @@ -25,7 +25,7 @@ class RulesTest < Minitest::Test include Gapic::Rest::ResumableUpload def setup - @config = CompleteUploadConfig.new( + @config = StartUploadConfig.new( initial_url: "https://example.com/upload", initial_headers: { "X-Custom" => "value" }, initial_body: '{"name":"obj"}', diff --git a/gapic-common/test/gapic/rest/resumable_upload/session_test.rb b/gapic-common/test/gapic/rest/resumable_upload/session_test.rb index 205ef24..5423aae 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/session_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/session_test.rb @@ -71,35 +71,52 @@ def read length = nil end end + START_ONLY_KEYS = [:initial_url, :initial_body, :initial_headers, :chunk_size, :start_retry_policy].freeze + + # Builds a session from the shared arguments and remembers the per-run arguments that #start needs, + # so tests can keep calling `start_session session`. def build_session stub: nil, stream: nil, upload_size: 10, chunk_size: 4, **kwargs stream ||= StringIO.new "0123456789" stub ||= ScriptedClientStub.new - Session.new( - client_stub: stub, - stream: stream, + @start_args = { initial_url: "https://example.com/initiate", initial_body: '{"name":"test.txt"}', - upload_size: upload_size, - chunk_size: chunk_size, - **kwargs + chunk_size: chunk_size + }.merge(kwargs.slice(*START_ONLY_KEYS)) + + Session.new( + client_stub: stub, + stream: stream, + upload_size: upload_size, + **kwargs.except(*START_ONLY_KEYS) ) end + def start_session session, **overrides + session.start(**@start_args, **overrides) + end + # ============================================================================ # 1. Initialization and argument validation # ============================================================================ def test_initialize_mandatory_arguments assert_raises ArgumentError do - Session.new stream: StringIO.new, initial_url: "http://x" + Session.new stream: StringIO.new end assert_raises ArgumentError do - Session.new client_stub: ScriptedClientStub.new, initial_url: "http://x" + Session.new client_stub: ScriptedClientStub.new end + end + def test_initialize_rejects_per_run_arguments assert_raises ArgumentError do - Session.new client_stub: ScriptedClientStub.new, stream: StringIO.new + Session.new client_stub: ScriptedClientStub.new, stream: StringIO.new, initial_url: "http://x" + end + + assert_raises ArgumentError do + Session.new client_stub: ScriptedClientStub.new, stream: StringIO.new, chunk_size: 4 end end @@ -107,23 +124,85 @@ def test_initialize_defaults session = Session.new( client_stub: ScriptedClientStub.new, stream: StringIO.new("abc"), - initial_url: "https://example.com/initiate", upload_size: 300 ) assert_equal 300, session.upload_size - assert_nil session.initial_body - assert_equal({}, session.initial_headers) - assert_nil session.chunk_size assert_nil session.content_type assert_nil session.timeout - assert_nil session.start_retry_policy assert_nil session.control_plane_retry_policy assert_nil session.data_plane_retry_policy assert_nil session.on_progress assert_nil session.logger end + def test_start_without_initial_url_raises_argument_error + session = Session.new client_stub: ScriptedClientStub.new, stream: StringIO.new("abc"), upload_size: 3 + + error = assert_raises ArgumentError do + session.start + end + assert_match(/initial_url/, error.message) + end + + def test_start_with_blank_initial_url_raises_argument_error + session = Session.new client_stub: ScriptedClientStub.new, stream: StringIO.new("abc"), upload_size: 3 + + error = assert_raises ArgumentError do + session.start initial_url: " " + end + assert_match(/initial_url is required/, error.message) + end + + def test_start_with_malformed_retry_policy_raises_argument_error + session = build_session + + error = assert_raises ArgumentError do + start_session session, start_retry_policy: "nonsense" + end + assert_match(/Expected RetryPolicy, Hash, or nil/, error.message) + end + + def test_failed_start_leaves_session_reusable + session = build_session + + assert_raises ArgumentError do + start_session session, start_retry_policy: "nonsense" + end + + refute session.running? + refute session.bound? + end + + def test_resume_needs_no_initiation_arguments + responses = [ + FakeResponse.new( + status: 200, + headers: { + "x-goog-upload-status" => "active", + "x-goog-upload-size-received" => "0" + }, + body: "" + ), + FakeResponse.new( + status: 200, + headers: { "x-goog-upload-status" => "final" }, + body: '{"resumed":true}' + ) + ] + session = Session.new( + client_stub: ScriptedClientStub.new(responses), + stream: StringIO.new("01"), + upload_size: 2 + ) + handle = ResumeHandle.new upload_url: "https://upload.example.com/persisted", chunk_size: 4 + + result = session.resume resume_handle: handle + + assert_equal '{"resumed":true}', result + assert_equal "https://upload.example.com/persisted", session.upload_url + end + # ============================================================================ # 2. Observable States: Unbound & Bound # ============================================================================ @@ -176,7 +255,7 @@ def test_start_successful_upload_transitions_to_bound stub = ScriptedClientStub.new responses session = build_session stub: stub, upload_size: 10, chunk_size: 4 - result = session.start + result = start_session session assert_equal '{"status":"completed"}', result assert session.bound? @@ -209,12 +288,12 @@ def test_second_start_raises_session_state_error upload_size: 2, chunk_size: 4 ) - session.start + start_session session assert session.bound? err = assert_raises SessionStateError do - session.start + start_session session end assert_includes err.message, "Session has already executed a run" end @@ -242,7 +321,7 @@ def test_resume_after_start_raises_session_state_error upload_size: 2, chunk_size: 4 ) - session.start + start_session session handle = ResumeHandle.new upload_url: "https://upload.example.com/session_1", chunk_size: 4 err = assert_raises SessionStateError do @@ -329,7 +408,7 @@ def test_start_after_resume_raises_session_state_error assert session.bound? err = assert_raises SessionStateError do - session.start + start_session session end assert_includes err.message, "Session has already executed a run" end @@ -452,7 +531,7 @@ def test_cross_session_resumption_from_failed_run session1 = build_session stub: stub1, stream: stream, upload_size: 10, chunk_size: 4 raised = assert_raises RequestFailedError do - session1.start + start_session session1 end assert session1.bound? @@ -533,7 +612,7 @@ def test_running_guard_prevents_concurrent_runs ) worker = Thread.new do - session.start + start_session session end started_q.pop # wait for worker thread to enter driver.run @@ -547,7 +626,7 @@ def test_running_guard_prevents_concurrent_runs assert_includes err.message, "A run is already in progress for this session" err_start = assert_raises SessionStateError do - session.start + start_session session end assert_includes err_start.message, "A run is already in progress for this session" @@ -565,7 +644,7 @@ def test_running_guard_prevents_concurrent_runs def test_driver_upload_url_across_statuses dummy_client = ScriptedClientStub.new - config = CompleteUploadConfig.new( + config = StartUploadConfig.new( initial_url: "https://example.com/upload", stream: StringIO.new("data"), upload_size: 4, From c7985ab2bbb1d5bac1e59ee4cfa2fdd64dc5e7bd Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Mon, 14 Sep 2026 23:23:37 +0000 Subject: [PATCH 68/79] fix: improved header filtering --- gapic-common/design/implementation-guide.md | 57 ++++++++++++------- gapic-common/design/integration-test-plan.md | 3 +- .../gapic/rest/resumable_upload/data_types.rb | 25 +++++++- .../lib/gapic/rest/resumable_upload/driver.rb | 7 ++- .../gapic/rest/resumable_upload/session.rb | 10 ++-- .../rest/resumable_upload/data_types_test.rb | 27 +++++++++ .../resumable_upload/driver_config_test.rb | 25 ++++++++ .../rest/resumable_upload/session_test.rb | 12 ++++ 8 files changed, 137 insertions(+), 29 deletions(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index 154c739..eefd9d8 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -6,7 +6,7 @@ The Resumable Upload Protocol (RUP) implementation in `gapic-common` is structur ```mermaid graph TD - Client[Client Code] -->|CompleteUploadConfig| Driver + Client[Client Code] -->|StartUploadConfig| Driver subgraph Gapic::Rest::ResumableUpload Driver[Driver
Synchronous I/O Adapter] -->|Events| Core[Core
State Container] Core -->|Instructions| Driver @@ -43,19 +43,28 @@ The `Rules` module encapsulates the Resumable Upload Protocol state transitions Because arbitrary Ruby `IO` objects (network sockets, pipes, `STDIN`) do not support seeking (`#seek`), the Driver buffers the current in-flight chunk in memory (bounded by chunk size, default: 8MB). When `RetryPolicy` executes transport retries, or when `Core` triggers Category 2 recovery realignments within the buffered range, the Driver retransmits directly from memory. The buffer is discarded only after receiving a `200 OK` durably confirming receipt of the chunk. ### 1.5 Session (Transfer Coordinator) -The `Session` (`Gapic::Rest::ResumableUpload::Session`) provides a client-facing coordinator that encapsulates configuration, owns the input stream, and manages upload execution across a strict single-run lifecycle. +The `Session` (`Gapic::Rest::ResumableUpload::Session`) provides a client-facing coordinator that encapsulates shared transfer configuration, owns the input stream, and manages upload execution across a strict single-run lifecycle. #### Single-Run Contract & Two-State Model A session adheres to a two-state model with a strict single-run contract: a session performs exactly one run (`start` or `resume`), never both, never twice. 1. **Unbound (`!session.bound?`)**: * Initial state upon construction (`Session.new`). The session has not yet executed a run. - * Permitted operations: `start` or `resume(...)`. + * Permitted operations: `start(...)` or `resume(...)`. 2. **Bound (`session.bound?`)**: * Transitions to bound as soon as `start` or `resume` begins execution. * The session has executed its run and cannot be reused. * Both `start` and `resume` raise `SessionStateError` ("Session has already executed a run"). +#### Constructor & Initiation Signatures +Configuration is split between transfer-wide options passed to `Session.new` (`COMMON_MEMBERS` plus `client_stub` and `logger`) and initiation-only arguments passed to `Session#start`: +* **Constructor (`Session#initialize`)**: + `Session.new(client_stub:, stream:, upload_size: nil, content_type: nil, timeout: nil, control_plane_retry_policy: nil, data_plane_retry_policy: nil, on_progress: nil, logger: nil)` +* **Initiation (`Session#start`)**: + `session.start(initial_url:, initial_body: nil, initial_headers: {}, chunk_size: nil, start_retry_policy: nil)` + * `initial_url` is required (`ArgumentError` if missing or blank). + * `initial_headers` accepts caller-supplied HTTP headers for the initiation request, merged over the driver's headers. Any key with the `x-goog-upload-` prefix (in any casing) raises an `ArgumentError`; callers influence `X-Goog-Upload-Header-Content-Type` and `X-Goog-Upload-Header-Content-Length` through `content_type:` and `upload_size:` on the constructor. + #### Resumability (`session.resumable?`) * Reports whether a *new* session can resume the transfer (`!session.resume_handle.nil?`). * Completed uploads are not resumable: once a transfer succeeds, `resume_handle` returns `nil` and `resumable?` returns `false`. @@ -92,24 +101,30 @@ Because a session performs only a single run, resuming an interrupted upload req ## 2. Component Interfaces & Data Models -### 2.1 Client Configuration (`CompleteUploadConfig`) +### 2.1 Initiation Configuration (`StartUploadConfig`) ```ruby module Gapic module Rest module ResumableUpload - CompleteUploadConfig = Data.define( - :initial_url, # [String] Initial endpoint URI for session initiation - :initial_body, # [String] Request payload for session initiation - :initial_headers, # [Hash] Additional headers for initiation + COMMON_MEMBERS = [ :stream, # [IO] Binary input stream to upload :upload_size, # [Integer, nil] Total upload bytes if known upfront - :chunk_size, # [Integer, nil] Explicit chunk size in bytes - :content_type, # [String] MIME type of uploaded media + :content_type, # [String, nil] MIME type of uploaded media :timeout, # [Numeric, nil] Total upload timeout in seconds (zero/negative treated as nil) - :start_retry_policy, # [Gapic::Common::RetryPolicy, Hash, nil] Policy or hash override for start command :control_plane_retry_policy, # [Gapic::Common::RetryPolicy, Hash, nil] Policy or hash override for query/cancel commands :data_plane_retry_policy, # [Gapic::Common::RetryPolicy, Hash, nil] Policy or hash override for upload/finalize :on_progress # [Proc, nil] Callback: ->(progress) with a Progress instance + ].freeze + + RESERVED_INITIAL_HEADER_PREFIX = "x-goog-upload-" + + StartUploadConfig = Data.define( + *COMMON_MEMBERS, + :initial_url, # [String] Initial endpoint URI for session initiation + :initial_body, # [String, nil] Request payload for session initiation + :initial_headers, # [Hash] Additional headers for initiation (x-goog-upload-* rejected) + :chunk_size, # [Integer, nil] Explicit chunk size in bytes + :start_retry_policy # [Gapic::Common::RetryPolicy, Hash, nil] Policy or hash override for start command ) Progress = Data.define( @@ -125,6 +140,10 @@ module Gapic end ``` +**Reserved Header Prefix Rule (`RESERVED_INITIAL_HEADER_PREFIX`):** +* Every header under the `"x-goog-upload-"` prefix is protocol machinery owned by the driver (`X-Goog-Upload-Protocol`, `X-Goog-Upload-Command`, `X-Goog-Upload-Offset`, `X-Goog-Upload-Header-Content-Type`, `X-Goog-Upload-Header-Content-Length`). +* Any key in `initial_headers` beginning with `x-goog-upload-` (case-insensitively) is rejected at configuration construction with an `ArgumentError`. Callers shape media descriptors exclusively through `content_type` and `upload_size`. + **Progress Notification Contract (`on_progress`):** * `on_progress` fires whenever upload status or server-confirmed byte offset changes. Sequential callbacks may report the same `bytes_uploaded`. * `bytes_uploaded` represents the server-confirmed offset and is **not guaranteed to be monotonic** — a server rewind during recovery can decrease this value. @@ -137,22 +156,15 @@ module Gapic module Rest module ResumableUpload ResumeUploadConfig = Data.define( + *COMMON_MEMBERS, :upload_url, # [String] Upload session URL returned by the upload backend - :chunk_size, # [Integer] Chunk size in bytes (> 0) - :stream, # [IO] Binary input stream to upload - :upload_size, # [Integer, nil] Total upload bytes if known upfront - :content_type, # [String, nil] MIME type of uploaded media - :timeout, # [Numeric, nil] Total upload timeout in seconds (zero/negative treated as nil) - :start_retry_policy, # [Gapic::Common::RetryPolicy, Hash, nil] Unused; retained for config parity - :control_plane_retry_policy, # [Gapic::Common::RetryPolicy, Hash, nil] Policy or hash override for query/cancel commands - :data_plane_retry_policy, # [Gapic::Common::RetryPolicy, Hash, nil] Policy or hash override for upload/finalize - :on_progress # [Proc, nil] Callback: ->(progress) with a Progress instance + :chunk_size # [Integer] Chunk size in bytes (> 0) ) end end end ``` -`ResumeUploadConfig` allows resuming an existing session directly using the session URL (typically obtained from `ResumeHandle#upload_url` or an error's `#resume_handle`). +`ResumeUploadConfig` allows resuming an existing session directly using the session URL (typically obtained from `ResumeHandle#upload_url` or an error's `#resume_handle`). Because a resumed run skips session initiation, `start_retry_policy` and initiation headers/URL are absent. ### 2.3 Protocol State (`State`) & Decisions (`Decision`) ```ruby @@ -306,6 +318,7 @@ Full implementation: [reference-implementation.md#3-driver-class](reference-impl 1. **Logical Header Prefixing**: In the `start` request, logical headers describing the uploaded object must be prefixed with `X-Goog-Upload-Header-`. Specifically: * `X-Goog-Upload-Header-Content-Type: config.content_type` * `X-Goog-Upload-Header-Content-Length: config.upload_size` (if known upfront). + * Callers cannot supply these or any other `x-goog-upload-*` header via `initial_headers` (doing so raises an `ArgumentError`). 2. **Offset Extraction**: On `query` responses, the acknowledged byte count is extracted from `X-Goog-Upload-Size-Received` as an integer (`server_offset`). 3. **Request Modification on 4xx**: Retrying Category 2 errors requires querying the backend for `server_offset` first. 4. **Standard Retry Configuration & Distinct Policies**: The Driver manages distinct retry policy configurations for Category 1 transient errors: @@ -413,7 +426,7 @@ Upon receiving `200 OK` from the `start` request, `Core` inspects the response h ### 5.1 Variable Definitions * `DEFAULT_CHUNK_SIZE`: Default chunk size of `8_388_608` bytes (8 MB). -* `user_chunk_size`: Explicit chunk size specified in `CompleteUploadConfig.chunk_size` (or `nil` if unspecified). +* `user_chunk_size`: Explicit chunk size specified in `StartUploadConfig.chunk_size` (or `nil` if unspecified). * `chunk_granularity`: Required byte alignment modulus parsed from header `X-Goog-Upload-Chunk-Granularity` as an Integer (or `nil` if header is absent). * `effective_chunk_size`: Final calculated byte size used by Driver for in-memory buffering and chunk transmission. diff --git a/gapic-common/design/integration-test-plan.md b/gapic-common/design/integration-test-plan.md index 236d358..2a6e40d 100644 --- a/gapic-common/design/integration-test-plan.md +++ b/gapic-common/design/integration-test-plan.md @@ -46,7 +46,8 @@ flowchart TD ### 1.2 Test Harness (`integration/integration_helper.rb`) * **`ShowcaseIntegrationTest`**: Base class providing helper methods for test configuration: * `showcase_client_stub`: Instantiates a real `Gapic::Rest::ClientStub` targeting `SHOWCASE_ENDPOINT` with `raise_faraday_errors: false` and an attached `DEBUG` logger. - * `build_config(scenario: nil, scenario_config: {}, **overrides)`: Creates a `CompleteUploadConfig` targeting `/resumable/upload/v1beta1/files:upload`. When `scenario` is provided, injects `X-Goog-Test-Scenario` and `X-Goog-Test-Scenario-Config` (with a generated `client_uuid` merged with `scenario_config`) into `initial_headers`. Configures fast retry policies (`FAST_RETRY = { initial_delay: 0.01, max_delay: 0.05, multiplier: 1, timeout: 2 }`), a default 10-second session timeout, a default payload of `786_432` bytes (`3 * 262_144`), default chunk size of `262_144` bytes, and an `on_progress` callback appending each `Progress` struct to `@progress_records`. + * `build_config(scenario: nil, scenario_config: {}, **overrides)`: Creates a `StartUploadConfig` targeting `/resumable/upload/v1beta1/files:upload`. When `scenario` is provided, injects `X-Goog-Test-Scenario` and `X-Goog-Test-Scenario-Config` (with a generated `client_uuid` merged with `scenario_config`) into `initial_headers` (these test headers are unaffected by `RESERVED_INITIAL_HEADER_PREFIX` since they do not begin with `x-goog-upload-`). Configures fast retry policies (`FAST_RETRY = { initial_delay: 0.01, max_delay: 0.05, multiplier: 1, timeout: 2 }`), a default 10-second session timeout, a default payload of `786_432` bytes (`3 * 262_144`), default chunk size of `262_144` bytes, and an `on_progress` callback appending each `Progress` struct to `@progress_records`. + * `build_session(scenario: nil, scenario_config: {}, **overrides)` & `start_session(session, **overrides)`: Partitions overrides using `START_ONLY_KEYS` (`[:initial_url, :initial_body, :initial_headers, :chunk_size, :start_retry_policy]`). `build_session` instantiates a `Gapic::Rest::ResumableUpload::Session` with the common members (`client_stub`, `stream`, `upload_size`, `content_type`, `timeout`, `control_plane_retry_policy`, `data_plane_retry_policy`, `on_progress`, `logger`) and stores initiation arguments in `@start_args`, while `start_session` invokes `session.start(**@start_args, **overrides)`. * `phases` & `offsets`: Convenience accessors returning `@progress_records.map(&:phase)` and `@progress_records.map(&:bytes_uploaded)`. * `payload(size)`: Generates deterministic binary strings of arbitrary byte length for stream uploads. * `UnseekableStream`: Stream wrapper around `StringIO` that exposes `#read` and `#pos` while omitting `#seek` (`respond_to?(:seek)` is `false`). diff --git a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb index d049c90..a2a7b83 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb @@ -52,6 +52,19 @@ module ResumableUpload :on_progress ].freeze + ## + # @private + # Header-name prefix a caller may not use in `initial_headers`, lowercased for comparison. + # + # Every header under this prefix is protocol machinery the driver owns: the command verb, the + # byte offset, and the content descriptors derived from `content_type` and `upload_size`. A + # caller-supplied value competes with the driver's own bookkeeping, and the resulting failure + # never names the cause. Callers shape these through `content_type` and `upload_size` instead. + # + # See `Driver#start_headers`, which builds the headers this prefix protects. + # + RESERVED_INITIAL_HEADER_PREFIX = "x-goog-upload-" + ## # @private # Immutable configuration for a run that initiates a new upload session, i.e. {Session#start}. @@ -63,7 +76,9 @@ module ResumableUpload # @!attribute [r] initial_body # @return [String, nil] Request payload for session initiation # @!attribute [r] initial_headers - # @return [Hash] Additional headers for initiation + # @return [Hash] Additional headers for initiation, merged over the driver's + # own headers. Keys beginning with {RESERVED_INITIAL_HEADER_PREFIX} are rejected in any + # casing; use `content_type` and `upload_size` to shape those. # @!attribute [r] chunk_size # @return [Integer, nil] Requested chunk size in bytes, aligned to the granularity the server # reports during initiation. A resumed run takes its chunk size from {ResumeUploadConfig}. @@ -110,6 +125,14 @@ def initialize initial_url:, on_progress: nil raise ArgumentError, "initial_url is required" if initial_url.nil? || initial_url.to_s.strip.empty? raise ArgumentError, "stream is required" if stream.nil? + reserved = (initial_headers || {}).keys.find do |key| + key.to_s.downcase.start_with? RESERVED_INITIAL_HEADER_PREFIX + end + if reserved + raise ArgumentError, + "initial_headers must not set protocol header #{reserved.inspect}; " \ + "use content_type and upload_size instead" + end super( initial_url: initial_url, diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index b3061ab..bd8fce3 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -511,6 +511,11 @@ def execute_send_start instruction # @private # Builds initiation HTTP headers from instruction and config. # + # Every header derived here carries the `x-goog-upload-` prefix, and caller headers bearing + # that prefix are rejected when the config is built (see {RESERVED_INITIAL_HEADER_PREFIX}). + # The two sets are disjoint, so a plain merge cannot drop a driver header or duplicate one + # under a different casing. + # # @param instruction [Instruction::SendStart] Start instruction # @return [Hash] HTTP request headers # @@ -518,7 +523,7 @@ def start_headers instruction headers = { "X-Goog-Upload-Protocol" => "resumable", "X-Goog-Upload-Command" => "start" } headers["X-Goog-Upload-Header-Content-Type"] = @config.content_type if @config.content_type headers["X-Goog-Upload-Header-Content-Length"] = @config.upload_size.to_s if @config.upload_size - headers.merge(instruction.headers || {}) + headers.merge instruction.headers || {} end ## diff --git a/gapic-common/lib/gapic/rest/resumable_upload/session.rb b/gapic-common/lib/gapic/rest/resumable_upload/session.rb index 3c89f36..832d3bb 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/session.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/session.rb @@ -242,9 +242,10 @@ def running? # # @param initial_url [String] Initial endpoint URI for session initiation # @param initial_body [String, nil] Request payload for session initiation - # @param initial_headers [Hash] Additional headers for the initiation request. Merged - # last, so a key given here overrides the protocol header the session would otherwise send, - # regardless of its casing. + # @param initial_headers [Hash] Additional headers for the initiation request. + # Keys beginning with `x-goog-upload-` are rejected with an `ArgumentError` in any casing — + # they carry protocol mechanics the session owns. Use the constructor's `content_type` and + # `upload_size` to shape the media descriptors. # @param chunk_size [Integer, nil] Requested chunk size in bytes, defaulting to 8 MB. The effective # size is rounded down to a multiple of any chunk granularity the server requires, or raised to that # granularity if it exceeds the requested size. @@ -253,7 +254,8 @@ def running? # the settings it names and leaves the remaining defaults, including retry codes and predicates, in # place. # @return [String, Object] Final response body upon completion - # @raise [ArgumentError] If `initial_url` is missing or blank, or a retry policy argument is neither a + # @raise [ArgumentError] If `initial_url` is missing or blank, if `initial_headers` sets a + # reserved x-goog-upload-* header, or if a retry policy argument is neither a # {Gapic::Common::RetryPolicy}, a Hash, nor `nil` # @raise [SessionStateError] If already bound/executed or if a run is currently in progress # @raise [RequestFailedError] If a transport error, timeout, or retry exhaustion occurs diff --git a/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb index 1472490..453d525 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb @@ -61,6 +61,33 @@ def test_start_upload_config_validations end end + def test_start_upload_config_rejects_reserved_initial_headers + stream = StringIO.new "content" + reserved = ["X-Goog-Upload-Command", "x-goog-upload-command", "X-GOOG-UPLOAD-COMMAND", + "X-Goog-Upload-Protocol", "x-goog-upload-offset", + "X-Goog-Upload-Header-Content-Type", "x-goog-upload-header-content-length"] + + reserved.each do |header| + error = assert_raises ArgumentError do + StartUploadConfig.new initial_url: "https://example.com", stream: stream, initial_headers: { header => "x" } + end + assert_match(/must not set protocol header/, error.message) + assert_includes error.message, header + end + end + + def test_start_upload_config_allows_caller_owned_initial_headers + stream = StringIO.new "content" + headers = { + "Authorization" => "Bearer token", + "X-Custom" => "value" + } + + config = StartUploadConfig.new initial_url: "https://example.com", stream: stream, initial_headers: headers + + assert_equal headers, config.initial_headers + end + def test_state_defaults_and_with state = State.new diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb index 2bdab9d..50ef04b 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb @@ -200,6 +200,31 @@ def test_make_post_request_passes_timeout_close_to_remaining_budget_and_decrease end end + def test_start_headers_passes_unrelated_caller_headers_through + config = StartUploadConfig.new initial_url: "https://example.com/upload", stream: StringIO.new("0123") + driver = Driver.new client_stub: FakeClientStub.new, config: config + instruction = Instruction::SendStart.new( + url: "https://example.com/upload", + headers: { "X-Custom" => "value" } + ) + + headers = driver.send :start_headers, instruction + + assert_equal "value", headers["X-Custom"] + assert_equal "resumable", headers["X-Goog-Upload-Protocol"] + assert_equal "start", headers["X-Goog-Upload-Command"] + end + + def test_start_headers_without_caller_headers_is_unchanged + config = StartUploadConfig.new initial_url: "https://example.com/upload", stream: StringIO.new("0123") + driver = Driver.new client_stub: FakeClientStub.new, config: config + instruction = Instruction::SendStart.new url: "https://example.com/upload" + + headers = driver.send :start_headers, instruction + + assert_equal({ "X-Goog-Upload-Protocol" => "resumable", "X-Goog-Upload-Command" => "start" }, headers) + end + private def scripted_recovery_responses diff --git a/gapic-common/test/gapic/rest/resumable_upload/session_test.rb b/gapic-common/test/gapic/rest/resumable_upload/session_test.rb index 5423aae..a0055f5 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/session_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/session_test.rb @@ -163,6 +163,18 @@ def test_start_with_malformed_retry_policy_raises_argument_error assert_match(/Expected RetryPolicy, Hash, or nil/, error.message) end + def test_start_with_reserved_initial_header_raises_before_any_request + stub = ScriptedClientStub.new + session = build_session stub: stub + + error = assert_raises ArgumentError do + start_session session, initial_headers: { "X-Goog-Upload-Header-Content-Type" => "image/png" } + end + assert_match(/must not set protocol header/, error.message) + assert_empty stub.requests + refute session.bound? + end + def test_failed_start_leaves_session_reusable session = build_session From 116562b7cc40a218246861725653dd8fdff9f1f2 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Tue, 15 Sep 2026 01:00:31 +0000 Subject: [PATCH 69/79] fix: better testing for headers change --- .../gapic/rest/resumable_upload/data_types.rb | 4 +++- .../lib/gapic/rest/resumable_upload/session.rb | 2 +- .../rest/resumable_upload/data_types_test.rb | 4 ++-- .../rest/resumable_upload/driver_config_test.rb | 16 ++++++++++++++++ 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb index a2a7b83..7570750 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb @@ -100,7 +100,9 @@ module ResumableUpload # @param initial_url [String] Initial endpoint URI for session initiation # @param stream [IO] Binary input stream to upload # @param initial_body [String, nil] Request payload for session initiation - # @param initial_headers [Hash] Additional headers for initiation + # @param initial_headers [Hash] Additional headers for initiation. Keys beginning + # with {RESERVED_INITIAL_HEADER_PREFIX} are rejected in any casing; use `content_type` and + # `upload_size` to shape those. # @param upload_size [Integer, nil] Total upload bytes if known upfront # @param chunk_size [Integer, nil] Requested chunk size in bytes # @param content_type [String, nil] MIME type of uploaded media diff --git a/gapic-common/lib/gapic/rest/resumable_upload/session.rb b/gapic-common/lib/gapic/rest/resumable_upload/session.rb index 832d3bb..a0eafc5 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/session.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/session.rb @@ -255,7 +255,7 @@ def running? # place. # @return [String, Object] Final response body upon completion # @raise [ArgumentError] If `initial_url` is missing or blank, if `initial_headers` sets a - # reserved x-goog-upload-* header, or if a retry policy argument is neither a + # reserved `x-goog-upload-*` header, or if a retry policy argument is neither a # {Gapic::Common::RetryPolicy}, a Hash, nor `nil` # @raise [SessionStateError] If already bound/executed or if a run is currently in progress # @raise [RequestFailedError] If a transport error, timeout, or retry exhaustion occurs diff --git a/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb index 453d525..3231202 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb @@ -79,8 +79,8 @@ def test_start_upload_config_rejects_reserved_initial_headers def test_start_upload_config_allows_caller_owned_initial_headers stream = StringIO.new "content" headers = { - "Authorization" => "Bearer token", - "X-Custom" => "value" + "X-Goog-Test-Scenario" => "chunk_granularity", + "X-Custom" => "value" } config = StartUploadConfig.new initial_url: "https://example.com", stream: stream, initial_headers: headers diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb index 50ef04b..a6e0a13 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb @@ -200,6 +200,22 @@ def test_make_post_request_passes_timeout_close_to_remaining_budget_and_decrease end end + def test_start_headers_derives_content_descriptors_from_config + config = StartUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("0123"), + upload_size: 4, + content_type: "application/octet-stream" + ) + driver = Driver.new client_stub: FakeClientStub.new, config: config + instruction = Instruction::SendStart.new url: "https://example.com/upload" + + headers = driver.send :start_headers, instruction + + assert_equal "application/octet-stream", headers["X-Goog-Upload-Header-Content-Type"] + assert_equal "4", headers["X-Goog-Upload-Header-Content-Length"] + end + def test_start_headers_passes_unrelated_caller_headers_through config = StartUploadConfig.new initial_url: "https://example.com/upload", stream: StringIO.new("0123") driver = Driver.new client_stub: FakeClientStub.new, config: config From 7d9129f41e51a8ae8dce7fb1b23e1f45b03bdd5a Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Tue, 15 Sep 2026 01:51:19 +0000 Subject: [PATCH 70/79] docs: correct YARD type for the response body --- .../lib/gapic/rest/resumable_upload/driver.rb | 4 +-- .../lib/gapic/rest/resumable_upload/events.rb | 4 +-- .../rest/resumable_upload/instructions.rb | 4 +-- .../gapic/rest/resumable_upload/session.rb | 10 ++++-- .../rest/resumable_upload/driver_test.rb | 31 +++++++++++++++++++ 5 files changed, 44 insertions(+), 9 deletions(-) diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index bd8fce3..e32ac21 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -147,7 +147,7 @@ def self.default_data_plane_retry_policy # Establishes a guaranteed monotonic deadline at the start of execution # so the upload cannot stall indefinitely. # - # @return [String, Object] Final response body + # @return [String, nil] Final response body def run @upload_log = UploadLog.new stub_logger, upload_id: LoggingConcerns.random_uuid4 @deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + resolve_timeout @@ -220,7 +220,7 @@ def dispatch_instruction instruction when Instruction::SendQuery then execute_send_query instruction when Instruction::SendCancel then execute_send_cancel instruction when Instruction::TerminateSuccess - instruction.response.respond_to?(:body) ? instruction.response.body : instruction.response + instruction.response.body when Instruction::TerminateFailure then raise instruction.error end end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/events.rb b/gapic-common/lib/gapic/rest/resumable_upload/events.rb index 17c306b..6a8df2c 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/events.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/events.rb @@ -67,7 +67,7 @@ def initialize bytes_buffered: 0, eof: false # @!attribute [r] headers # @return [Hash] Response headers # @!attribute [r] body - # @return [String, Object, nil] Response body + # @return [String, nil] Response body # @!attribute [r] error # @return [Gapic::Rest::Error, nil] Wrapped REST error if status >= 400 # @@ -78,7 +78,7 @@ def initialize bytes_buffered: 0, eof: false # # @param status [Integer] HTTP status code # @param headers [Hash] Response headers - # @param body [String, Object, nil] Response body + # @param body [String, nil] Response body # @param error [Gapic::Rest::Error, nil] Wrapped REST error # def initialize status:, headers: {}, body: nil, error: nil diff --git a/gapic-common/lib/gapic/rest/resumable_upload/instructions.rb b/gapic-common/lib/gapic/rest/resumable_upload/instructions.rb index eb188f9..caed73d 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/instructions.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/instructions.rb @@ -194,14 +194,14 @@ def initialize progress: # Upload finalized cleanly; return response. # # @!attribute [r] response - # @return [Gapic::Rest::ResumableUpload::Event::HttpResponse, Object] Final response object + # @return [Gapic::Rest::ResumableUpload::Event::HttpResponse] Final response object # TerminateSuccess = Data.define :response do ## # @private # Initializes a TerminateSuccess instruction. # - # @param response [Gapic::Rest::ResumableUpload::Event::HttpResponse, Object] Final response object + # @param response [Gapic::Rest::ResumableUpload::Event::HttpResponse] Final response object # def initialize response: super response: response diff --git a/gapic-common/lib/gapic/rest/resumable_upload/session.rb b/gapic-common/lib/gapic/rest/resumable_upload/session.rb index a0eafc5..6d89ee2 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/session.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/session.rb @@ -253,7 +253,9 @@ def running? # request. A {Gapic::Common::RetryPolicy} replaces the default policy outright; a Hash overrides only # the settings it names and leaves the remaining defaults, including retry codes and predicates, in # place. - # @return [String, Object] Final response body upon completion + # @return [String, nil] Raw, undecoded body of the finalizing HTTP response (or `nil` if the + # response carried no body), typically the JSON resource the backend created that the caller + # parses. A client stub carrying response-decoding middleware is outside the contract. # @raise [ArgumentError] If `initial_url` is missing or blank, if `initial_headers` sets a # reserved `x-goog-upload-*` header, or if a retry policy argument is neither a # {Gapic::Common::RetryPolicy}, a Hash, nor `nil` @@ -330,7 +332,9 @@ def start initial_url:, # @param upload_url [String, nil] Explicit upload URL # @param chunk_size [Integer, nil] Explicit chunk size # @param resume_handle [ResumeHandle, nil] Explicit resume handle - # @return [String, Object] Final response body upon completion + # @return [String, nil] Raw, undecoded body of the finalizing HTTP response (or `nil` if the + # response carried no body), typically the JSON resource the backend created that the caller + # parses. A client stub carrying response-decoding middleware is outside the contract. # @raise [ArgumentError] If argument shape is invalid, target upload is missing, or stream.pos != 0 # @raise [SessionStateError] If already bound/executed or if a run is currently in progress # @raise [RequestFailedError] If a transport error, timeout, or retry exhaustion occurs @@ -479,7 +483,7 @@ def resolve_resume_args upload_url:, chunk_size:, resume_handle: # Executes the driver run and records the final upload URL and state. # # @param driver [Driver] Driver instance to run - # @return [String, Object] Final response body upon completion + # @return [String, nil] Final response body upon completion def execute_run driver @mutex.synchronize { @last_driver = driver } result = driver.run diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb index ecdb78b..9d58bb1 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb @@ -196,6 +196,37 @@ def test_resume_upload_with_409_recovery_retry ], progress_records end + def test_run_returns_nil_body_when_final_response_has_none + responses = [ + FakeResponse.new( + status: 200, + headers: { + "X-Goog-Upload-URL" => "https://example.com/session/1", + "X-Goog-Upload-Status" => "active" + }, + body: "" + ), + FakeResponse.new( + status: 200, + headers: { "X-Goog-Upload-Status" => "final" }, + body: nil + ) + ] + stub = FakeClientStub.new responses + config = StartUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("ab"), + upload_size: 2, + chunk_size: 4 + ) + + driver = Driver.new client_stub: stub, config: config + result = driver.run + + assert_nil result + assert_equal 2, stub.requests.size + end + private def build_scripted_responses From 9c6b28886d9eded5018327bc18932a3c08be41aa Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Tue, 15 Sep 2026 03:12:47 +0000 Subject: [PATCH 71/79] fix: remove duplicate cancel event, enforce invariants --- gapic-common/design/implementation-guide.md | 2 +- gapic-common/design/test-plan.md | 2 +- .../lib/gapic/rest/resumable_upload/driver.rb | 47 ++++++++-- .../resumable_upload/driver/upload_log.rb | 1 - .../lib/gapic/rest/resumable_upload/errors.rb | 8 ++ .../lib/gapic/rest/resumable_upload/rules.rb | 31 ++++--- .../driver/upload_log_test.rb | 7 +- .../rest/resumable_upload/driver_test.rb | 70 +++++++++++++++ .../resumable_upload/rules_decide_test.rb | 6 +- .../gapic/rest/resumable_upload/rules_test.rb | 86 ++++++++++++++++++- 10 files changed, 227 insertions(+), 33 deletions(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index eefd9d8..9e8f780 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -591,7 +591,7 @@ Because `Rules` is a pure decision engine and `Core` is a side-effect-free state Each invocation of `Driver#run` generates a fresh UUIDv4 session identifier (`uploadId`) that is attached to every log entry emitted during that run. Structured log entries are constructed using `Gapic::LoggingConcerns` (`StubLogger` yielding a `LogEntryBuilder` producing `Google::Logging::Message` instances). Machine-readable state and telemetry are stored in `Google::Logging::Message#fields`, allowing log message text to evolve independently without breaking structured queries. ### 7.2 Log Level & Recipe Mapping -The `Driver` emits structured logs across three severity levels (`INFO`, `DEBUG`, `WARN`). High-frequency per-chunk acknowledgements (`:ack_chunk`) and duplicate cancellation signals (`:ignore_duplicate_cancel`) are suppressed from `INFO` lifecycle logs to avoid log volume bloat on multi-gigabyte uploads. +The `Driver` emits structured logs across three severity levels (`INFO`, `DEBUG`, `WARN`). High-frequency per-chunk acknowledgements (`:ack_chunk`) are suppressed from `INFO` lifecycle logs to avoid log volume bloat on multi-gigabyte uploads. | Severity | Category | Trigger / Recipe | Message Summary | | :--- | :--- | :--- | :--- | diff --git a/gapic-common/design/test-plan.md b/gapic-common/design/test-plan.md index 36578e3..015324e 100644 --- a/gapic-common/design/test-plan.md +++ b/gapic-common/design/test-plan.md @@ -283,7 +283,7 @@ flowchart TD * Emits `DEBUG` entries containing `uploadId`, `fromStatus`, `shape`, `recipe`, `toStatus`, `offset`, `inFlightLength`, and abridged `instructions`. * **Lifecycle milestone logging (`UploadLog#lifecycle`)**: * Emits `INFO` entries for session milestones (`:start_session` with `uploadSize` and `requestedChunkSize`), `DEBUG` for per-chunk transmission (`:send_chunk`), and `WARN` for terminal failures (`:fail_with_rejected` with `error` field). - * Asserts silent recipes (`:ignore_duplicate_cancel`, `:ack_chunk`) emit no lifecycle log entries. + * Asserts silent recipes (`:ack_chunk`) emit no lifecycle log entries. * **Wire trace logging (`wire_send`, `wire_receive`, `wire_failure`)**: * `wire_send` logs `DEBUG` with HTTP verb, abridged URL, redacted headers, `startAttempt`, `bodySize`, and hex-encoded/abridged body. * `wire_receive` logs `DEBUG` with HTTP status code, parsed `uploadStatus`, optional `errorStatus` from `event.error.status`, `sizeReceived`, `granularity`, and abridged body (using `event.error.message` when HTTP $\ge 400$ and present). diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index e32ac21..9961ef4 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -147,6 +147,13 @@ def self.default_data_plane_retry_policy # Establishes a guaranteed monotonic deadline at the start of execution # so the upload cannot stall indefinitely. # + # Assumes the trampoline loop invariant: each dispatched instruction batch + # produces either a single continuation event or terminates the session + # (via {Instruction::TerminateSuccess} or {Instruction::TerminateFailure}). + # Note that {Instruction::RealignBuffer} returns an `Integer` stream offset + # rejected by {#pending_event_type?}, keeping `:ack_chunk` and + # `:realign_from_recovery` single-event batches. + # # @return [String, nil] Final response body def run @upload_log = UploadLog.new stub_logger, upload_id: LoggingConcerns.random_uuid4 @@ -155,22 +162,50 @@ def run loop do instructions = dispatch_event pending_event - pending_event = nil if deadline_exceeded? && !terminal_instructions?(instructions) instructions = dispatch_event Event::GlobalDeadlineExceeded.new end - instructions.each do |instruction| - result = dispatch_instruction instruction - pending_event = result if pending_event_type? result - return result if instruction.is_a? Instruction::TerminateSuccess - end + pending_event, terminal_result = execute_batch instructions + return terminal_result if pending_event.nil? end end private + ## + # @private + # Executes an instruction batch and enforces the single-continuation-event invariant. + # + # @param instructions [Array] Emitted instructions + # @return [Array] Tuple of [pending_event, terminal_result] + # + def execute_batch instructions + pending_event = nil + recipe = @core.last_decision&.recipe + + instructions.each do |instruction| + result = dispatch_instruction instruction + return [nil, result] if instruction.is_a? Instruction::TerminateSuccess + next unless pending_event_type? result + + if pending_event + raise InternalError, + "Resumable upload internal error: recipe :#{recipe} produced multiple continuation events" + end + pending_event = result + end + + if pending_event.nil? + raise InternalError, + "Resumable upload internal error: recipe :#{recipe} " \ + "produced no continuation event and did not terminate" + end + + [pending_event, nil] + end + ## # @private # Dispatches an event to Core, logging decisions and transitions. diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb index 52dfe61..d7864b6 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver/upload_log.rb @@ -32,7 +32,6 @@ class UploadLog # @return [Array] SILENT_RECIPES = [ :ack_chunk, # per-chunk transition, doesn't belong at INFO - :ignore_duplicate_cancel, # duplicate cancel signal, no state change :fail_with_unmatched_transition # raises before Decision exists, logged by #unmatched_transition ].freeze diff --git a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb index 945dd3a..f4df238 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb @@ -551,6 +551,14 @@ def self.from event_or_error, message: nil, resume_handle: nil # class SessionStateError < Gapic::Common::Error end + + ## + # Raised when an internal state machine or driver invariant is violated + # (e.g. a recipe batch producing zero continuation events without terminating, + # or producing multiple continuation events). + # + class InternalError < Gapic::Common::Error + end end end end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb index 6a2f302..292242a 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb @@ -42,6 +42,21 @@ module ResumableUpload # `[next_state, instructions]`. Adding a protocol behaviour means adding a shape, a recipe and an arm. # It never means adding branching to the Driver. # + # ### Trampoline invariant + # + # {Driver#run} executes as a synchronous trampoline loop, so every recipe in {RECIPES} must return an + # instruction batch that yields either: + # + # 1. **Exactly one** event-producing instruction (`FillBuffer` or `Send*`) and zero terminal instructions, or + # 2. **Exactly one** terminal instruction (`TerminateSuccess` or `TerminateFailure`) and zero event-producing + # instructions. + # + # A recipe returning zero event-producing instructions without terminating stalls the loop, and a recipe + # returning multiple event-producing instructions discards continuation events. Note that + # {Instruction::RealignBuffer} (emitted by `:ack_chunk` and `:realign_from_recovery`) returns an `Integer` + # stream offset from `execute_realign_buffer` rather than an event object; changing that return value to an + # event-shaped object would violate the single-continuation-event invariant. + # # ### State transition graph # # ```mermaid @@ -243,7 +258,6 @@ module Rules :complete_upload_finalized, :cancel_session, :complete_cancellation, - :ignore_duplicate_cancel, :fail_with_deadline_exceeded, :fail_with_rejected, :fail_with_bad_response, @@ -277,7 +291,6 @@ module Rules :send_chunk, :retry_recovery, :complete_cancellation, - :ignore_duplicate_cancel, :fail_with_deadline_exceeded, :fail_with_rejected, :fail_with_bad_response, @@ -357,8 +370,6 @@ def self.decide state, event, config :retry_recovery in [:cancelling, :response_cancelled] :complete_cancellation - in [:cancelling, :user_cancel] - :ignore_duplicate_cancel # Order matters from here down. These two catch-alls must stay above the failure arms below, # so that an expired deadline or a cancellation wins over a late failure response arriving # in the same states. @@ -704,18 +715,6 @@ def self.complete_cancellation state, event, _config [next_state, [Instruction::TerminateFailure.new(error: err)]] end - ## - # @private - # Ignores redundant cancel signal when cancellation is already in progress. - # - # @param state [State] Current state - # @param _event [Object] Dispatched event - # @param _config [StartUploadConfig, ResumeUploadConfig] Session configuration - # @return [Array>] Tuple of [next_state, instructions] - def self.ignore_duplicate_cancel state, _event, _config - [state, []] - end - ## # @private # Initiates session cancellation request. diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver/upload_log_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver/upload_log_test.rb index 6d7af2e..49a651b 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver/upload_log_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver/upload_log_test.rb @@ -106,9 +106,10 @@ def test_lifecycle_terminal_failure_logs_warn end def test_lifecycle_silent_recipes_emit_no_logs - state = State.new status: :cancelling - decision = Rules.decide state, Event::Cancel.new, @config - assert_equal :ignore_duplicate_cancel, decision.recipe + state = State.new status: :transmission_sending + active = Event::HttpResponse.new status: 200, headers: { "x-goog-upload-status" => "active" } + decision = Rules.decide state, active, @config + assert_equal :ack_chunk, decision.recipe @upload_log.lifecycle decision, @config diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb index 9d58bb1..e656586 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb @@ -227,6 +227,76 @@ def test_run_returns_nil_body_when_final_response_has_none assert_equal 2, stub.requests.size end + # Fake Core yielding a fixed Decision to test Driver#run invariant guards. + class FakeCore + attr_reader :state, :last_decision + + def initialize decision + @decision = decision + @state = decision.next_state + @last_decision = nil + end + + def dispatch _event + @last_decision = @decision + @decision.instructions + end + end + + def test_run_raises_internal_error_on_empty_batch + config = StartUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("data"), + upload_size: 4 + ) + decision = Decision.new( + from_status: :initializing, + shape: :start_upload, + recipe: :broken_empty, + next_state: State.new(status: :starting), + instructions: [] + ) + driver = Driver.new client_stub: FakeClientStub.new([]), config: config, core: FakeCore.new(decision) + + err = assert_raises InternalError do + driver.run + end + assert_equal "Resumable upload internal error: recipe :broken_empty " \ + "produced no continuation event and did not terminate", + err.message + end + + def test_run_raises_internal_error_on_multiple_continuation_events + config = StartUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("data"), + upload_size: 4 + ) + send_start = Instruction::SendStart.new url: "https://example.com/upload", headers: {}, body: "" + decision = Decision.new( + from_status: :initializing, + shape: :start_upload, + recipe: :broken_multi, + next_state: State.new(status: :starting), + instructions: [send_start, send_start] + ) + resp = FakeResponse.new( + status: 200, + headers: { + "X-Goog-Upload-URL" => "https://example.com/session/1", + "X-Goog-Upload-Status" => "active" + }, + body: "" + ) + driver = Driver.new client_stub: FakeClientStub.new([resp, resp]), config: config, core: FakeCore.new(decision) + + err = assert_raises InternalError do + driver.run + end + assert_equal "Resumable upload internal error: recipe :broken_multi produced multiple continuation events", + err.message + end + private def build_scripted_responses diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb index 194c0d9..267ce70 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb @@ -231,14 +231,14 @@ def test_row_cancelling_response_cancelled assert_instance_of Instruction::TerminateFailure, decision.instructions.first end - def test_row_cancelling_user_cancel + def test_row_cancelling_user_cancel_falls_through_to_wildcard decision = Rules.decide State.new(status: :cancelling), Event::Cancel.new, @config assert_equal :cancelling, decision.from_status assert_equal :user_cancel, decision.shape - assert_equal :ignore_duplicate_cancel, decision.recipe + assert_equal :cancel_session, decision.recipe assert_equal :cancelling, decision.next_state.status assert_recipe_progress_notification decision - assert_empty decision.instructions + assert_instance_of Instruction::SendCancel, decision.instructions[1] end def test_row_global_deadline_exceeded diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb index d904e80..a63ed15 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb @@ -173,10 +173,11 @@ def test_transition_cancellation_flow assert_equal Progress.new(phase: :cancelling, bytes_uploaded: 0, total_bytes: 1024), instructions[0].progress assert_instance_of Instruction::SendCancel, instructions[1] - # Duplicate cancel in cancelling state does nothing + # A duplicate cancel re-enters cancel_session through the wildcard arm and re-issues the command dup_state, dup_instructions = Rules.step next_state, Event::Cancel.new, @config assert_equal :cancelling, dup_state.status - assert_empty dup_instructions + assert_equal 2, dup_instructions.size + assert_instance_of Instruction::SendCancel, dup_instructions[1] # Cancellation confirmed resp = Event::HttpResponse.new status: 200, headers: { "x-goog-upload-status" => "cancelled" } @@ -192,4 +193,85 @@ def test_all_recipes_respond_to_rules_method assert_respond_to Rules, recipe end end + + def test_all_recipes_satisfy_trampoline_invariant + resume_config = ResumeUploadConfig.new( + upload_url: "https://example.com/session", + chunk_size: 256, + stream: StringIO.new("abcd") + ) + active_resp = Event::HttpResponse.new status: 200, headers: { + "x-goog-upload-url" => "https://example.com/session", + "x-goog-upload-status" => "active", + "x-goog-upload-size-received" => "256" + } + final_resp = Event::HttpResponse.new status: 200, headers: { "x-goog-upload-status" => "final" } + cancelled_resp = Event::HttpResponse.new status: 200, headers: { "x-goog-upload-status" => "cancelled" } + cat2_resp = Event::HttpResponse.new status: 503, headers: {} + rejected_resp = Event::HttpResponse.new status: 403, headers: { "x-goog-upload-status" => "final" } + bad_resp = Event::HttpResponse.new status: 401, headers: {} + req_err = Event::RequestFailed.new kind: :connection_failed, message: "connection lost" + chunk_full = Event::ChunkRead.new bytes_buffered: 256, eof: false + chunk_eof = Event::ChunkRead.new bytes_buffered: 256, eof: true + base_state = State.new( + status: :transmission_sending, + upload_url: "https://example.com/session", + chunk_size: 256, + in_flight_length: 256 + ) + + fixtures = { + start_session: [State.new(status: :initializing), Event::StartUpload.new, @config], + resume_session: [State.new(status: :initializing), Event::ResumeUpload.new, resume_config], + begin_transmission: [State.new(status: :starting), active_resp, @config], + send_chunk: [base_state.with(status: :transmission_reading), chunk_full, @config], + send_upload_finalize: [base_state.with(status: :transmission_reading), chunk_eof, @config], + send_finalize: [base_state.with(status: :transmission_reading), Event::ChunkRead.new(bytes_buffered: 0, eof: true), @config], + ack_chunk: [base_state, active_resp, @config], + enter_recovery: [base_state, cat2_resp, @config], + retry_recovery: [base_state.with(status: :recovery), cat2_resp, @config], + realign_from_recovery: [base_state.with(status: :recovery), active_resp, @config], + complete_upload_with_data: [base_state.with(status: :finalizing_sending_upload), final_resp, @config], + complete_upload_finalized: [base_state.with(status: :finalizing_sending_finalize), final_resp, @config], + cancel_session: [base_state, Event::Cancel.new, @config], + complete_cancellation: [base_state.with(status: :cancelling), cancelled_resp, @config], + fail_with_deadline_exceeded: [base_state, Event::GlobalDeadlineExceeded.new, @config], + fail_with_rejected: [base_state, rejected_resp, @config], + fail_with_bad_response: [base_state, bad_resp, @config], + fail_with_request_error: [base_state, req_err, @config], + fail_with_unmatched_transition: [State.new(status: :success), Event::StartUpload.new, @config] + } + + assert_equal Rules::RECIPES.sort, fixtures.keys.sort + + event_producing_types = [ + Instruction::FillBuffer, + Instruction::SendStart, + Instruction::SendChunk, + Instruction::SendFinalize, + Instruction::SendQuery, + Instruction::SendCancel + ].freeze + terminal_types = [ + Instruction::TerminateSuccess, + Instruction::TerminateFailure + ].freeze + + fixtures.each do |recipe, (state, event, cfg)| + if recipe == :fail_with_unmatched_transition + assert_raises InvalidTransitionError do + Rules.public_send recipe, state, event, cfg + end + next + end + + _next_state, instructions = Rules.public_send recipe, state, event, cfg + event_producing_count = instructions.count { |inst| event_producing_types.include? inst.class } + terminal_count = instructions.count { |inst| terminal_types.include? inst.class } + + valid = (event_producing_count == 1 && terminal_count.zero?) || + (event_producing_count.zero? && terminal_count == 1) + assert valid, "Recipe :#{recipe} produced #{event_producing_count} event-producing and #{terminal_count} terminal instructions" + end + end end From be3d34433793aa985b75b10fdb34585d8ba36ea3 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Tue, 15 Sep 2026 03:22:23 +0000 Subject: [PATCH 72/79] doc: update documentation on new exception --- .../lib/gapic/rest/resumable_upload/driver.rb | 10 +++++++--- .../lib/gapic/rest/resumable_upload/errors.rb | 1 + .../lib/gapic/rest/resumable_upload/rules.rb | 7 +++---- .../gapic/rest/resumable_upload/driver_test.rb | 16 ++++++++++++++++ 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index 9961ef4..a3a25da 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -150,9 +150,10 @@ def self.default_data_plane_retry_policy # Assumes the trampoline loop invariant: each dispatched instruction batch # produces either a single continuation event or terminates the session # (via {Instruction::TerminateSuccess} or {Instruction::TerminateFailure}). - # Note that {Instruction::RealignBuffer} returns an `Integer` stream offset - # rejected by {#pending_event_type?}, keeping `:ack_chunk` and - # `:realign_from_recovery` single-event batches. + # Side-effect instructions ({Instruction::NotifyProgress}, + # {Instruction::RealignBuffer}) explicitly return `nil` by construction, + # so only {Instruction::FillBuffer} and `Send*` instructions produce + # continuation events. # # @return [String, nil] Final response body def run @@ -360,6 +361,7 @@ def terminal_instructions? instructions # def execute_notify_progress instruction @config.on_progress&.call instruction.progress + nil end ## @@ -400,6 +402,8 @@ def execute_realign_buffer instruction else realign_fast_forward_stream server_offset, buffer_end end + + nil end ## diff --git a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb index f4df238..d5dbf41 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb @@ -553,6 +553,7 @@ class SessionStateError < Gapic::Common::Error end ## + # @private # Raised when an internal state machine or driver invariant is violated # (e.g. a recipe batch producing zero continuation events without terminating, # or producing multiple continuation events). diff --git a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb index 292242a..65fc6dd 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb @@ -52,10 +52,9 @@ module ResumableUpload # instructions. # # A recipe returning zero event-producing instructions without terminating stalls the loop, and a recipe - # returning multiple event-producing instructions discards continuation events. Note that - # {Instruction::RealignBuffer} (emitted by `:ack_chunk` and `:realign_from_recovery`) returns an `Integer` - # stream offset from `execute_realign_buffer` rather than an event object; changing that return value to an - # event-shaped object would violate the single-continuation-event invariant. + # returning multiple event-producing instructions discards continuation events. Side-effect instructions + # ({Instruction::NotifyProgress}, {Instruction::RealignBuffer}) explicitly return `nil` in the Driver by + # construction, so only {Instruction::FillBuffer} and `Send*` instructions produce continuation events. # # ### State transition graph # diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb index e656586..7aafdef 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb @@ -297,6 +297,22 @@ def test_run_raises_internal_error_on_multiple_continuation_events err.message end + def test_on_progress_return_value_does_not_leak_into_trampoline_invariant + stub = FakeClientStub.new build_scripted_responses + config = StartUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("0123456789"), + upload_size: 10, + chunk_size: 4, + on_progress: ->(_p) { Event::HttpResponse.new status: 200, headers: {} } + ) + + driver = Driver.new client_stub: stub, config: config + result = driver.run + + assert_equal '{"done":true}', result + end + private def build_scripted_responses From 68603d709c96f3a9c0f6f46f02b87d3b7aff6a30 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Tue, 15 Sep 2026 03:40:53 +0000 Subject: [PATCH 73/79] docs: retry policies --- .../gapic/rest/resumable_upload/data_types.rb | 4 +- .../lib/gapic/rest/resumable_upload/driver.rb | 7 ++- .../rest/resumable_upload/retry_policies.rb | 3 ++ .../lib/gapic/rest/resumable_upload/rules.rb | 5 +- .../gapic/rest/resumable_upload/session.rb | 40 ++++++++++++++-- .../resumable_upload/driver_progress_test.rb | 48 +++++++++++++++++++ 6 files changed, 98 insertions(+), 9 deletions(-) diff --git a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb index 7570750..67fc406 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb @@ -231,7 +231,9 @@ def initialize upload_url:, # server-confirmed offset and is not guaranteed to be monotonic — a server rewind during # recovery can decrease this value. # @!attribute [r] total_bytes - # @return [Integer, nil] Total upload size in bytes if known, or nil + # @return [Integer, nil] Total upload size in bytes if known, or `nil`. Always set on the + # `:completed` phase — the total is known once the transfer finishes, even when `upload_size` + # was not supplied upfront. # Progress = Data.define( :phase, diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index a3a25da..42de8de 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -65,7 +65,7 @@ class Driver # Returns a {ResumeHandle} representing the current upload session parameters. # Reading this property mid-run provides a best-effort snapshot of the current session state. # Completed uploads (`:success`), rejected uploads (`:rejected`), and cancelled uploads - # (`:cancelled`) are finalized and not resumable, returning `nil`. Completed uploads are not resumable. + # (`:cancelled`) are finalized and not resumable, returning `nil`. # # @return [ResumeHandle, nil] Resume handle if upload URL is established and resumable, or nil def resume_handle @@ -304,6 +304,8 @@ def initial_event def resolve_timeout return @config.timeout if @config.timeout&.positive? + # When timeout is unset, BASE_TIMEOUT (1 hour) acts as a floor so small uploads still get + # a full hour while large uploads scale past it at MIN_ASSUMED_THROUGHPUT (1 MiB/s). if @config.upload_size [@config.upload_size.fdiv(MIN_ASSUMED_THROUGHPUT), BASE_TIMEOUT].max else @@ -531,7 +533,8 @@ def execute_send_start instruction err = BadResponseError.new "Missing X-Goog-Upload-Status header in start response", event.status, headers: event.headers - can_retry = policy.send(:retry_with_deadline?) && policy.call(event) + # `retry_with_deadline?` is public; its `@private` tag hides it from docs, not from callers. + can_retry = policy.retry_with_deadline? && policy.call(event) unless can_retry if event.status == 200 failed_event = Event::RequestFailed.new( diff --git a/gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb b/gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb index cc125ee..deb92bf 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/retry_policies.rb @@ -59,6 +59,7 @@ module RetryPolicies ## # @private # Default options for start command retry policy. + # Keep in sync with the "Retry Policies" section of Session's class doc. # @return [Hash] START_DEFAULTS = { retry_codes: ["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"].freeze, @@ -71,6 +72,7 @@ module RetryPolicies ## # @private # Default options for query and cancel commands retry policy. + # Keep in sync with the "Retry Policies" section of Session's class doc. # @return [Hash] CONTROL_PLANE_DEFAULTS = { retry_codes: ["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"].freeze, @@ -82,6 +84,7 @@ module RetryPolicies ## # @private # Default options for upload and finalize commands retry policy. + # Keep in sync with the "Retry Policies" section of Session's class doc. # @return [Hash] DATA_PLANE_DEFAULTS = { retry_codes: ["UNAVAILABLE", "DEADLINE_EXCEEDED", "RESOURCE_EXHAUSTED", "INTERNAL"].freeze, diff --git a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb index 65fc6dd..168cfe9 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb @@ -646,6 +646,7 @@ def self.complete_upload_with_data state, event, _config offset: new_offset, in_flight_length: 0 ) + # `total_bytes` is set from `new_offset` even when `config.upload_size` is nil (see `Progress#total_bytes`). progress = Progress.new phase: :completed, bytes_uploaded: new_offset, total_bytes: new_offset instructions = [ Instruction::NotifyProgress.new(progress: progress), @@ -667,6 +668,8 @@ def self.complete_upload_finalized state, event, _config status: :success, in_flight_length: 0 ) + # `total_bytes` is set from `next_state.offset` even when `config.upload_size` is nil + # (see `Progress#total_bytes`). progress = Progress.new phase: :completed, bytes_uploaded: next_state.offset, total_bytes: next_state.offset instructions = [ Instruction::NotifyProgress.new(progress: progress), @@ -736,7 +739,7 @@ def self.cancel_session state, _event, config # @private # Extracts a {ResumeHandle} from current protocol state. # Completed uploads (`:success`), rejected uploads (`:rejected`), and cancelled uploads - # (`:cancelled`) are finalized and not resumable, returning `nil`. Completed uploads are not resumable. + # (`:cancelled`) are finalized and not resumable, returning `nil`. # # @param state [State] Protocol state # @return [ResumeHandle, nil] Resume handle if upload URL is established and resumable, or nil diff --git a/gapic-common/lib/gapic/rest/resumable_upload/session.rb b/gapic-common/lib/gapic/rest/resumable_upload/session.rb index 6d89ee2..947de3f 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/session.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/session.rb @@ -78,6 +78,36 @@ module ResumableUpload # * `timeout` defaults to `upload_size / 1 MB per second` when `upload_size` is known, floored at one # hour, and to one hour flat when it is not. # + # ### Retry Policies + # + # Retry behavior is partitioned across three policies: `start_retry_policy` on {#start}, and + # `control_plane_retry_policy` and `data_plane_retry_policy` on {#initialize}. + # + # Passing a {Gapic::Common::RetryPolicy} replaces the corresponding default policy outright. Passing a + # Hash overrides only the keys it names and leaves the remaining defaults — including `retry_codes` and + # any status-header predicates — in place. + # + # All three policies share the same default retry codes and exponential backoff settings: + # + # | Setting | Default | + # |---|---| + # | `retry_codes` | `UNAVAILABLE`, `DEADLINE_EXCEEDED`, `RESOURCE_EXHAUSTED`, `INTERNAL` | + # | `initial_delay` | `1.0` s | + # | `max_delay` | `15.0` s | + # | `multiplier` | `1.3` | + # + # They differ in which requests they govern and how a missing or empty `X-Goog-Upload-Status` response + # header is treated: + # + # | Policy | Governs | Missing status header | + # |---|---|---| + # | `start_retry_policy` | session initiation | **Retriable** on any status (incl. `200`), unless fatal | + # | `control_plane_retry_policy` | `query` and `cancel` | No predicate; decided on `retry_codes` alone | + # | `data_plane_retry_policy` | `upload` and `finalize` | **Not** retriable | + # + # Initiation treats a response missing `X-Goog-Upload-Status` as gateway noise worth retrying; the data + # plane treats it as a response it cannot interpret and refuses to replay bytes against it. + # class Session # @return [Gapic::Rest::ClientStub] Underlying REST client stub attr_reader :client_stub @@ -144,8 +174,10 @@ class Session # @param timeout [Numeric, nil] Total upload timeout in seconds covering the whole run. When `nil`, # resolves to `upload_size / 1 MB per second` floored at one hour if `upload_size` is known, and to # one hour flat otherwise. - # @param control_plane_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Control retry policy - # @param data_plane_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Data retry policy + # @param control_plane_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for control + # commands (`query` and `cancel`). See the "Retry Policies" section in the class documentation. + # @param data_plane_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for data + # commands (`upload` and `finalize`). See the "Retry Policies" section in the class documentation. # @param on_progress [Proc, nil] Progress callback invoked as `->(progress)` with a {Progress} instance. # Executed synchronously on the upload protocol thread; it must not block. # Exceptions raised inside the callback abort the session and propagate out of {#start} or {#resume}. @@ -250,9 +282,7 @@ def running? # size is rounded down to a multiple of any chunk granularity the server requires, or raised to that # granularity if it exceeds the requested size. # @param start_retry_policy [Gapic::Common::RetryPolicy, Hash, nil] Retry policy for the initiation - # request. A {Gapic::Common::RetryPolicy} replaces the default policy outright; a Hash overrides only - # the settings it names and leaves the remaining defaults, including retry codes and predicates, in - # place. + # request (`start`). See the "Retry Policies" section in the class documentation. # @return [String, nil] Raw, undecoded body of the finalizing HTTP response (or `nil` if the # response carried no body), typically the JSON resource the backend created that the caller # parses. A client stub carrying response-decoding middleware is outside the contract. diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb index 66ac2d7..54cf579 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_progress_test.rb @@ -115,6 +115,54 @@ def test_driver_run_propagates_callback_error_end_to_end assert_equal "Terminal failure in user progress handler", err.message end + def test_completed_progress_reports_total_bytes_when_upload_size_unknown + responses = [ + FakeResponse.new( + status: 200, + headers: { "x-goog-upload-url" => "https://example.com/upload/123", "x-goog-upload-status" => "active" }, + body: "" + ), + FakeResponse.new( + status: 200, + headers: { "x-goog-upload-status" => "active" }, + body: "" + ), + FakeResponse.new( + status: 200, + headers: { "x-goog-upload-status" => "active" }, + body: "" + ), + FakeResponse.new( + status: 200, + headers: { "x-goog-upload-status" => "final" }, + body: "{\"done\":true}" + ) + ] + stub = ScriptedClientStub.new responses + + progress_events = [] + config = StartUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("0123456789"), + upload_size: nil, + chunk_size: 4, + on_progress: ->(progress) { progress_events << progress } + ) + driver = Driver.new client_stub: stub, config: config + + result = driver.run + assert_equal "{\"done\":true}", result + + uploading_snapshots = progress_events.select { |p| p.phase == :uploading } + refute_empty uploading_snapshots + assert uploading_snapshots.all? { |p| p.total_bytes.nil? } + + completed_snapshot = progress_events.last + assert_equal :completed, completed_snapshot.phase + assert_equal 10, completed_snapshot.bytes_uploaded + assert_equal 10, completed_snapshot.total_bytes + end + private def build_driver on_progress: nil From 04271c6c8b66fa00db7c197c9743817e2a84b4cb Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Tue, 15 Sep 2026 05:39:40 +0000 Subject: [PATCH 74/79] fix: minor cleanup --- gapic-common/design/implementation-guide.md | 22 ++++-- gapic-common/design/integration-test-plan.md | 2 +- gapic-common/design/test-plan.md | 2 +- .../gapic/rest/resumable_upload/data_types.rb | 34 +++++---- .../lib/gapic/rest/resumable_upload/driver.rb | 73 +++++++++++++++---- .../lib/gapic/rest/resumable_upload/errors.rb | 6 +- .../rest/resumable_upload/instructions.rb | 33 +++++++++ .../lib/gapic/rest/resumable_upload/rules.rb | 56 ++++++++++---- .../gapic/rest/resumable_upload/session.rb | 19 ++++- .../rest/resumable_upload/data_types_test.rb | 28 ++++++- .../rest/resumable_upload/driver_test.rb | 55 +++++++++++++- .../rules_classification_test.rb | 46 +++++++++++- .../resumable_upload/rules_decide_test.rb | 37 ++++++++-- .../gapic/rest/resumable_upload/rules_test.rb | 42 ++++++----- .../rest/resumable_upload/session_test.rb | 3 + 15 files changed, 365 insertions(+), 93 deletions(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index 9e8f780..ed5dbfb 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -63,11 +63,11 @@ Configuration is split between transfer-wide options passed to `Session.new` (`C * **Initiation (`Session#start`)**: `session.start(initial_url:, initial_body: nil, initial_headers: {}, chunk_size: nil, start_retry_policy: nil)` * `initial_url` is required (`ArgumentError` if missing or blank). - * `initial_headers` accepts caller-supplied HTTP headers for the initiation request, merged over the driver's headers. Any key with the `x-goog-upload-` prefix (in any casing) raises an `ArgumentError`; callers influence `X-Goog-Upload-Header-Content-Type` and `X-Goog-Upload-Header-Content-Length` through `content_type:` and `upload_size:` on the constructor. + * `initial_headers` accepts caller-supplied HTTP headers for the initiation request, merged over the driver's headers. Any key in `RESERVED_INITIAL_HEADERS` (`X-Goog-Upload-Protocol`, `X-Goog-Upload-Command`, `X-Goog-Upload-Offset`, `X-Goog-Upload-Header-Content-Type`, `X-Goog-Upload-Header-Content-Length`, in any casing) raises an `ArgumentError`; callers influence `X-Goog-Upload-Header-Content-Type` and `X-Goog-Upload-Header-Content-Length` through `content_type:` and `upload_size:` on the constructor. Pass-through headers such as `X-Goog-Upload-Header-Content-Disposition` remain permitted. #### Resumability (`session.resumable?`) * Reports whether a *new* session can resume the transfer (`!session.resume_handle.nil?`). -* Completed uploads are not resumable: once a transfer succeeds, `resume_handle` returns `nil` and `resumable?` returns `false`. +* Completed uploads are finalized: once a transfer succeeds, `resume_handle` returns `nil` and `resumable?` returns `false`, so there is no handle to resume from. Calling `#resume` on the session that completed the run raises `SessionStateError`. Resuming a *fresh* session against a finalized `upload_url` is undefined behavior: it queries the server and might return the response body or raise an error, depending on the server response. * When a run fails with a recoverable error, `resume_handle` captures the upload parameters (`upload_url`, `chunk_size`) and `resumable?` returns `true`. #### Precondition on Stream Position for Resume @@ -116,13 +116,19 @@ module Gapic :on_progress # [Proc, nil] Callback: ->(progress) with a Progress instance ].freeze - RESERVED_INITIAL_HEADER_PREFIX = "x-goog-upload-" + RESERVED_INITIAL_HEADERS = [ + "x-goog-upload-protocol", + "x-goog-upload-command", + "x-goog-upload-offset", + "x-goog-upload-header-content-type", + "x-goog-upload-header-content-length" + ].freeze StartUploadConfig = Data.define( *COMMON_MEMBERS, :initial_url, # [String] Initial endpoint URI for session initiation :initial_body, # [String, nil] Request payload for session initiation - :initial_headers, # [Hash] Additional headers for initiation (x-goog-upload-* rejected) + :initial_headers, # [Hash] Additional headers for initiation (RESERVED_INITIAL_HEADERS rejected) :chunk_size, # [Integer, nil] Explicit chunk size in bytes :start_retry_policy # [Gapic::Common::RetryPolicy, Hash, nil] Policy or hash override for start command ) @@ -140,9 +146,9 @@ module Gapic end ``` -**Reserved Header Prefix Rule (`RESERVED_INITIAL_HEADER_PREFIX`):** -* Every header under the `"x-goog-upload-"` prefix is protocol machinery owned by the driver (`X-Goog-Upload-Protocol`, `X-Goog-Upload-Command`, `X-Goog-Upload-Offset`, `X-Goog-Upload-Header-Content-Type`, `X-Goog-Upload-Header-Content-Length`). -* Any key in `initial_headers` beginning with `x-goog-upload-` (case-insensitively) is rejected at configuration construction with an `ArgumentError`. Callers shape media descriptors exclusively through `content_type` and `upload_size`. +**Reserved Initial Headers Rule (`RESERVED_INITIAL_HEADERS`):** +* The five headers in `RESERVED_INITIAL_HEADERS` (`X-Goog-Upload-Protocol`, `X-Goog-Upload-Command`, `X-Goog-Upload-Offset`, `X-Goog-Upload-Header-Content-Type`, `X-Goog-Upload-Header-Content-Length`) are protocol machinery owned by the driver. +* Any key in `initial_headers` matching those five names (case-insensitively) is rejected at configuration construction with an `ArgumentError`. Callers shape media descriptors exclusively through `content_type` and `upload_size`. Pass-through headers under the prefix such as `X-Goog-Upload-Header-Content-Disposition` remain permitted. **Progress Notification Contract (`on_progress`):** * `on_progress` fires whenever upload status or server-confirmed byte offset changes. Sequential callbacks may report the same `bytes_uploaded`. @@ -368,7 +374,7 @@ Full implementation: [reference-implementation.md#3-driver-class](reference-impl | **`Recovery`** | `:request_retries_exhausted` / `:request_connection_failed` / `:request_timeout` | `Event::RequestFailed(kind:, ...)` | `last_error = event.source_error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: event.source_error)` | | **`Recovery`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, body)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Rest::ResumableUpload::UploadRejectedError.from(event))` | | **`Recovery`** | `:response_fatal_bad_response` | `Event::HttpResponse` (Fatal status; see Section 6.1.3) | `last_error = Gapic::Rest::ResumableUpload::BadResponseError.from(event)`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | -| **Any Non-Terminal** | `:user_cancel` | `Event::Cancel` | `status = :cancelling` | `Cancelling` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :cancelling, bytes_uploaded: state.offset, total_bytes: config.upload_size))`
`Instruction::SendCancel.new(url: state.upload_url)` | +| **`Transmission \| Reading from stream` / `Transmission \| Sending chunk` / `Finalizing \| Sending with upload` / `Finalizing \| Sending finalize` / `Recovery`** | `:user_cancel` | `Event::Cancel` | `status = :cancelling` | `Cancelling` | `Instruction::NotifyProgress.new(progress: Progress.new(phase: :cancelling, bytes_uploaded: state.offset, total_bytes: config.upload_size))`
`Instruction::SendCancel.new(url: state.upload_url)` | | **`Cancelling`** | `:response_cancelled` | `Event::HttpResponse(200, headers, _)` with `Status: cancelled` | `status = :cancelled` | `Cancelled` | `Instruction::TerminateFailure.new(error: Gapic::Rest::ResumableUpload::UploadCancelledError.from(event))` | | **`Cancelling`** | `:response_rejected` | `Event::HttpResponse(non-200, headers, _)` with `Status: final` | `status = :rejected` | `Rejected` | `Instruction::TerminateFailure.new(error: Gapic::Rest::ResumableUpload::UploadRejectedError.from(event))` | | **`Cancelling`** | `:request_retries_exhausted` / `:request_connection_failed` / `:request_timeout` / `:response_fatal_bad_response` | `Event::RequestFailed` or HTTP failure | `last_error = error`
`status = :error` | `Error` | `Instruction::TerminateFailure.new(error: state.last_error)` | diff --git a/gapic-common/design/integration-test-plan.md b/gapic-common/design/integration-test-plan.md index 2a6e40d..47626a1 100644 --- a/gapic-common/design/integration-test-plan.md +++ b/gapic-common/design/integration-test-plan.md @@ -46,7 +46,7 @@ flowchart TD ### 1.2 Test Harness (`integration/integration_helper.rb`) * **`ShowcaseIntegrationTest`**: Base class providing helper methods for test configuration: * `showcase_client_stub`: Instantiates a real `Gapic::Rest::ClientStub` targeting `SHOWCASE_ENDPOINT` with `raise_faraday_errors: false` and an attached `DEBUG` logger. - * `build_config(scenario: nil, scenario_config: {}, **overrides)`: Creates a `StartUploadConfig` targeting `/resumable/upload/v1beta1/files:upload`. When `scenario` is provided, injects `X-Goog-Test-Scenario` and `X-Goog-Test-Scenario-Config` (with a generated `client_uuid` merged with `scenario_config`) into `initial_headers` (these test headers are unaffected by `RESERVED_INITIAL_HEADER_PREFIX` since they do not begin with `x-goog-upload-`). Configures fast retry policies (`FAST_RETRY = { initial_delay: 0.01, max_delay: 0.05, multiplier: 1, timeout: 2 }`), a default 10-second session timeout, a default payload of `786_432` bytes (`3 * 262_144`), default chunk size of `262_144` bytes, and an `on_progress` callback appending each `Progress` struct to `@progress_records`. + * `build_config(scenario: nil, scenario_config: {}, **overrides)`: Creates a `StartUploadConfig` targeting `/resumable/upload/v1beta1/files:upload`. When `scenario` is provided, injects `X-Goog-Test-Scenario` and `X-Goog-Test-Scenario-Config` (with a generated `client_uuid` merged with `scenario_config`) into `initial_headers` (these test headers are unaffected by `RESERVED_INITIAL_HEADERS` since they are not in the five reserved protocol headers). Configures fast retry policies (`FAST_RETRY = { initial_delay: 0.01, max_delay: 0.05, multiplier: 1, timeout: 2 }`), a default 10-second session timeout, a default payload of `786_432` bytes (`3 * 262_144`), default chunk size of `262_144` bytes, and an `on_progress` callback appending each `Progress` struct to `@progress_records`. * `build_session(scenario: nil, scenario_config: {}, **overrides)` & `start_session(session, **overrides)`: Partitions overrides using `START_ONLY_KEYS` (`[:initial_url, :initial_body, :initial_headers, :chunk_size, :start_retry_policy]`). `build_session` instantiates a `Gapic::Rest::ResumableUpload::Session` with the common members (`client_stub`, `stream`, `upload_size`, `content_type`, `timeout`, `control_plane_retry_policy`, `data_plane_retry_policy`, `on_progress`, `logger`) and stores initiation arguments in `@start_args`, while `start_session` invokes `session.start(**@start_args, **overrides)`. * `phases` & `offsets`: Convenience accessors returning `@progress_records.map(&:phase)` and `@progress_records.map(&:bytes_uploaded)`. * `payload(size)`: Generates deterministic binary strings of arbitrary byte length for stream uploads. diff --git a/gapic-common/design/test-plan.md b/gapic-common/design/test-plan.md index 015324e..18a158a 100644 --- a/gapic-common/design/test-plan.md +++ b/gapic-common/design/test-plan.md @@ -122,7 +122,7 @@ flowchart TD * **Transmission start**: `:starting` on `:response_active` resolves chunk size and granularity, transitions to `:transmission_reading`, and emits `Instruction::FillBuffer`. * **Chunk transmission & finalization**: `:transmission_reading` dispatches `SendChunk` (with or without `finalize: true`) or standalone `SendFinalize` depending on stream EOF and buffered bytes. * **Chunk acknowledgment**: `:transmission_sending` on `:response_active` advances offset, emits `NotifyProgress`, `RealignBuffer`, and `FillBuffer`. -* **Cancellation flow**: `:user_cancel` transitions to `:cancelling` and emits `SendCancel`; `:response_cancelled` transitions to `:cancelled`. +* **Cancellation flow**: `:user_cancel` transitions to `:cancelling` and emits `SendCancel` in the five statuses with an established upload URL (`:transmission_reading`, `:transmission_sending`, `:finalizing_sending_upload`, `:finalizing_sending_finalize`, `:recovery`), and raises `InvalidTransitionError` in `:cancelling`, `:initializing`, `:starting`, and terminal statuses; `:response_cancelled` transitions to `:cancelled`. #### B. Protocol Recovery Transitions (`rules_recovery_test.rb`) * **Entering recovery**: `:transmission_sending` (on `:response_cat2`, `:request_connection_failed`, `:request_timeout`) and `:finalizing_sending_upload` (on `:request_timeout`) transition to `:recovery` and emit `Instruction::SendQuery`. diff --git a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb index 67fc406..d08c00d 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/data_types.rb @@ -54,16 +54,24 @@ module ResumableUpload ## # @private - # Header-name prefix a caller may not use in `initial_headers`, lowercased for comparison. + # Header names a caller may not use in `initial_headers`, lowercased for comparison. # - # Every header under this prefix is protocol machinery the driver owns: the command verb, the - # byte offset, and the content descriptors derived from `content_type` and `upload_size`. A - # caller-supplied value competes with the driver's own bookkeeping, and the resulting failure - # never names the cause. Callers shape these through `content_type` and `upload_size` instead. + # These five headers are protocol machinery the driver owns: the protocol identifier, the command + # verb, the byte offset, and the content descriptors derived from `content_type` and `upload_size`. + # `x-goog-upload-offset` is included for completeness even though initiation never sets an offset: + # supplying an offset at initiation is meaningless and indicates a confused caller. Pass-through + # headers such as `X-Goog-Upload-Header-Content-Disposition` remain permitted. # - # See `Driver#start_headers`, which builds the headers this prefix protects. + # See `Driver#start_headers`, which builds the initiation headers this list protects. # - RESERVED_INITIAL_HEADER_PREFIX = "x-goog-upload-" + # @return [Array] + RESERVED_INITIAL_HEADERS = [ + "x-goog-upload-protocol", + "x-goog-upload-command", + "x-goog-upload-offset", + "x-goog-upload-header-content-type", + "x-goog-upload-header-content-length" + ].freeze ## # @private @@ -77,8 +85,8 @@ module ResumableUpload # @return [String, nil] Request payload for session initiation # @!attribute [r] initial_headers # @return [Hash] Additional headers for initiation, merged over the driver's - # own headers. Keys beginning with {RESERVED_INITIAL_HEADER_PREFIX} are rejected in any - # casing; use `content_type` and `upload_size` to shape those. + # own headers. Keys in {RESERVED_INITIAL_HEADERS} are rejected in any casing; use + # `content_type` and `upload_size` to shape those. # @!attribute [r] chunk_size # @return [Integer, nil] Requested chunk size in bytes, aligned to the granularity the server # reports during initiation. A resumed run takes its chunk size from {ResumeUploadConfig}. @@ -100,9 +108,9 @@ module ResumableUpload # @param initial_url [String] Initial endpoint URI for session initiation # @param stream [IO] Binary input stream to upload # @param initial_body [String, nil] Request payload for session initiation - # @param initial_headers [Hash] Additional headers for initiation. Keys beginning - # with {RESERVED_INITIAL_HEADER_PREFIX} are rejected in any casing; use `content_type` and - # `upload_size` to shape those. + # @param initial_headers [Hash] Additional headers for initiation. Keys in + # {RESERVED_INITIAL_HEADERS} are rejected in any casing; use `content_type` and `upload_size` + # to shape those. # @param upload_size [Integer, nil] Total upload bytes if known upfront # @param chunk_size [Integer, nil] Requested chunk size in bytes # @param content_type [String, nil] MIME type of uploaded media @@ -128,7 +136,7 @@ def initialize initial_url:, raise ArgumentError, "initial_url is required" if initial_url.nil? || initial_url.to_s.strip.empty? raise ArgumentError, "stream is required" if stream.nil? reserved = (initial_headers || {}).keys.find do |key| - key.to_s.downcase.start_with? RESERVED_INITIAL_HEADER_PREFIX + RESERVED_INITIAL_HEADERS.include? key.to_s.downcase end if reserved raise ArgumentError, diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index 42de8de..74175ab 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -183,28 +183,72 @@ def run # @return [Array] Tuple of [pending_event, terminal_result] # def execute_batch instructions - pending_event = nil recipe = @core.last_decision&.recipe + validate_batch instructions, recipe + pending_event = nil instructions.each do |instruction| result = dispatch_instruction instruction return [nil, result] if instruction.is_a? Instruction::TerminateSuccess - next unless pending_event_type? result + pending_event = result if Instruction::CONTINUATION.any? { |klass| instruction.is_a? klass } + end + + unless pending_event_type? pending_event + raise InternalError, + "Resumable upload internal error: recipe :#{recipe} continuation instruction " \ + "returned #{pending_event.class} instead of an event" + end + [pending_event, nil] + end - if pending_event + ## + # @private + # Validates that an instruction batch satisfies the trampoline invariant before execution. + # + # @param instructions [Array] Emitted instructions + # @param recipe [Symbol, nil] Recipe symbol from last decision + # @return [void] + # @raise [InternalError] If the batch is malformed or contains an unclassified instruction + # + def validate_batch instructions, recipe + continuation = 0 + terminal = 0 + instructions.each do |instruction| + case instruction + when *Instruction::CONTINUATION then continuation += 1 + when *Instruction::TERMINAL then terminal += 1 + when *Instruction::SIDE_EFFECT then nil + else raise InternalError, - "Resumable upload internal error: recipe :#{recipe} produced multiple continuation events" + "Resumable upload internal error: recipe :#{recipe} emitted " \ + "unclassified instruction #{instruction.class}" end - pending_event = result end + return if continuation + terminal == 1 - if pending_event.nil? - raise InternalError, - "Resumable upload internal error: recipe :#{recipe} " \ - "produced no continuation event and did not terminate" - end + raise InternalError, batch_shape_message(recipe, continuation, terminal) + end - [pending_event, nil] + ## + # @private + # Formats diagnostic error message for a malformed instruction batch. + # + # @param recipe [Symbol, nil] Recipe symbol from last decision + # @param continuation [Integer] Number of continuation instructions + # @param terminal [Integer] Number of terminal instructions + # @return [String] Error message + # + def batch_shape_message recipe, continuation, terminal + reason = if continuation.zero? && terminal.zero? + "produced no continuation event and did not terminate" + elsif continuation > 1 && terminal.zero? + "produced multiple continuation events" + elsif continuation.zero? && terminal > 1 + "produced multiple terminal instructions" + else + "produced both a continuation event and a terminal instruction" + end + "Resumable upload internal error: recipe :#{recipe} #{reason}" end ## @@ -553,10 +597,9 @@ def execute_send_start instruction # @private # Builds initiation HTTP headers from instruction and config. # - # Every header derived here carries the `x-goog-upload-` prefix, and caller headers bearing - # that prefix are rejected when the config is built (see {RESERVED_INITIAL_HEADER_PREFIX}). - # The two sets are disjoint, so a plain merge cannot drop a driver header or duplicate one - # under a different casing. + # Every header derived here is listed in {RESERVED_INITIAL_HEADERS}, and caller headers in + # that list are rejected when the config is built. The two sets are disjoint, so a plain merge + # cannot drop a driver header or duplicate one under a different casing. # # @param instruction [Instruction::SendStart] Start instruction # @return [Hash] HTTP request headers diff --git a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb index d5dbf41..b54e114 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/errors.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/errors.rb @@ -554,9 +554,9 @@ class SessionStateError < Gapic::Common::Error ## # @private - # Raised when an internal state machine or driver invariant is violated - # (e.g. a recipe batch producing zero continuation events without terminating, - # or producing multiple continuation events). + # Raised when an internal state machine or driver invariant is violated. + # Produced by {Rules} when an unlisted shape or recipe is encountered, and by + # {Driver} when a recipe emits a malformed instruction batch. # class InternalError < Gapic::Common::Error end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/instructions.rb b/gapic-common/lib/gapic/rest/resumable_upload/instructions.rb index caed73d..57d02bd 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/instructions.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/instructions.rb @@ -20,6 +20,8 @@ module ResumableUpload ## # @private # Instruction vocabulary emitted by Rules/Core to be executed by Driver. + # The vocabulary is partitioned three ways ({CONTINUATION}, {TERMINAL}, {SIDE_EFFECT}), + # and every instruction class must join exactly one list. # module Instruction ## @@ -226,6 +228,37 @@ def initialize error: super error: error end end + + ## + # @private + # Instruction classes that produce a continuation event for the next step of the trampoline loop. + # @return [Array] + CONTINUATION = [ + FillBuffer, + SendStart, + SendChunk, + SendFinalize, + SendQuery, + SendCancel + ].freeze + + ## + # @private + # Instruction classes that terminate the upload run. + # @return [Array] + TERMINAL = [ + TerminateSuccess, + TerminateFailure + ].freeze + + ## + # @private + # Instruction classes that perform side effects without producing continuation events or terminating. + # @return [Array] + SIDE_EFFECT = [ + NotifyProgress, + RealignBuffer + ].freeze end end end diff --git a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb index 168cfe9..293015e 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb @@ -52,7 +52,9 @@ module ResumableUpload # instructions. # # A recipe returning zero event-producing instructions without terminating stalls the loop, and a recipe - # returning multiple event-producing instructions discards continuation events. Side-effect instructions + # returning multiple event-producing instructions discards continuation events. The Driver validates each + # batch against {Instruction::CONTINUATION}, {Instruction::TERMINAL} and {Instruction::SIDE_EFFECT} before + # executing any instruction, rejecting malformed batches up front. Side-effect instructions # ({Instruction::NotifyProgress}, {Instruction::RealignBuffer}) explicitly return `nil` in the Driver by # construction, so only {Instruction::FillBuffer} and `Send*` instructions produce continuation events. # @@ -87,14 +89,18 @@ module ResumableUpload # error --> [*] # ``` # - # Two families of edge are omitted above to keep the graph readable: every non-terminal status moves to - # `cancelling` on `:user_cancel` and to `error` on `:global_deadline_exceeded`. + # The graph shows the protocol's intended path and its recoverable detours. Failure edges are largely omitted + # to keep it readable: every non-terminal status can also reach `error` (on `:global_deadline_exceeded`, on an + # unretriable request failure, on a fatally bad response, or on any unmatched event) and `rejected` (on + # `:response_rejected`), and every status listed in the `:user_cancel` arm can reach `cancelling`. `cancelling` + # in particular has only its success edge drawn; it fails like any other in-flight state. {Rules.decide} is the + # authoritative enumeration. # # ### Router ordering # # Arms are evaluated top to bottom, so their order encodes precedence and is load-bearing: # - # * The catch-all `[_, :global_deadline_exceeded]` and `[_, :user_cancel]` arms sit above the rejected, + # * The catch-all `[_, :global_deadline_exceeded]` and status-scoped `:user_cancel` arms sit above the rejected, # bad-response and request-error arms. Moving them below would let a late failure response win over an # expired deadline in precisely the states where the deadline matters. # * `[:starting, :response_cat2]` fails instead of recovering, unlike the same shape during transmission @@ -170,8 +176,8 @@ module Rules ## # @private - # Canonical list of protocol lifecycle statuses a {State} may hold. Derived from the keys of - # {STATE_DESCRIPTIONS} so the two cannot drift. + # Canonical list of protocol lifecycle statuses a {State} may hold. Guaranteed to match the keys of + # {STATE_DESCRIPTIONS} by the classification test suite. # # * `:initializing` - nothing dispatched yet; awaits `:start_upload` or `:resume_upload`. # * `:starting` - initiation request in flight; no upload URL yet. @@ -191,7 +197,26 @@ module Rules # run but may still be resumable from a fresh session; see {Rules.resume_handle_from}. # # @return [Array] - STATUSES = STATE_DESCRIPTIONS.keys.freeze + STATUSES = [ + :initializing, + :starting, + :transmission_reading, + :transmission_sending, + :finalizing_sending_upload, + :finalizing_sending_finalize, + :recovery, + :cancelling, + :success, + :cancelled, + :rejected, + :error + ].freeze + + ## + # @private + # Terminal protocol lifecycle statuses in {STATUSES}. + # @return [Array] + TERMINAL_STATUSES = [:success, :cancelled, :rejected, :error].freeze ## # @private @@ -338,7 +363,9 @@ def self.shape_of event # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength def self.decide state, event, config shape = shape_of event - raise ArgumentError, "unknown shape: #{shape}" unless SHAPES.include? shape + unless SHAPES.include? shape + raise InternalError, "Resumable upload internal error: shape_of returned unknown shape #{shape.inspect}" + end recipe = case [state.status, shape] in [:initializing, :start_upload] @@ -369,12 +396,13 @@ def self.decide state, event, config :retry_recovery in [:cancelling, :response_cancelled] :complete_cancellation - # Order matters from here down. These two catch-alls must stay above the failure arms below, - # so that an expired deadline or a cancellation wins over a late failure response arriving - # in the same states. + # Order matters from here down. The catch-all deadline arm and the status-scoped cancellation arm + # must stay above the failure arms below, so that an expired deadline or a cancellation wins over a + # late failure response arriving in the same states. in [_, :global_deadline_exceeded] :fail_with_deadline_exceeded - in [_, :user_cancel] + in [:transmission_reading | :transmission_sending | :finalizing_sending_upload | + :finalizing_sending_finalize | :recovery, :user_cancel] :cancel_session in [:starting | :transmission_sending | :finalizing_sending_upload | :finalizing_sending_finalize | :recovery | :cancelling, :response_rejected] @@ -395,7 +423,9 @@ def self.decide state, event, config :fail_with_unmatched_transition end - raise ArgumentError, "unknown recipe: #{recipe}" unless RECIPES.include? recipe + unless RECIPES.include? recipe + raise InternalError, "Resumable upload internal error: decide selected unknown recipe #{recipe.inspect}" + end next_state, instructions = public_send recipe, state, event, config Decision.new( diff --git a/gapic-common/lib/gapic/rest/resumable_upload/session.rb b/gapic-common/lib/gapic/rest/resumable_upload/session.rb index 947de3f..ac0e431 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/session.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/session.rb @@ -275,9 +275,12 @@ def running? # @param initial_url [String] Initial endpoint URI for session initiation # @param initial_body [String, nil] Request payload for session initiation # @param initial_headers [Hash] Additional headers for the initiation request. - # Keys beginning with `x-goog-upload-` are rejected with an `ArgumentError` in any casing — + # The five reserved protocol headers (`X-Goog-Upload-Protocol`, `X-Goog-Upload-Command`, + # `X-Goog-Upload-Offset`, `X-Goog-Upload-Header-Content-Type`, + # `X-Goog-Upload-Header-Content-Length`) are rejected with an `ArgumentError` in any casing — # they carry protocol mechanics the session owns. Use the constructor's `content_type` and - # `upload_size` to shape the media descriptors. + # `upload_size` to shape the media descriptors. Pass-through headers such as + # `X-Goog-Upload-Header-Content-Disposition` are permitted. # @param chunk_size [Integer, nil] Requested chunk size in bytes, defaulting to 8 MB. The effective # size is rounded down to a multiple of any chunk granularity the server requires, or raised to that # granularity if it exceeds the requested size. @@ -287,7 +290,7 @@ def running? # response carried no body), typically the JSON resource the backend created that the caller # parses. A client stub carrying response-decoding middleware is outside the contract. # @raise [ArgumentError] If `initial_url` is missing or blank, if `initial_headers` sets a - # reserved `x-goog-upload-*` header, or if a retry policy argument is neither a + # reserved protocol header, or if a retry policy argument is neither a # {Gapic::Common::RetryPolicy}, a Hash, nor `nil` # @raise [SessionStateError] If already bound/executed or if a run is currently in progress # @raise [RequestFailedError] If a transport error, timeout, or retry exhaustion occurs @@ -297,6 +300,8 @@ def running? # @raise [StreamMismatchError] If stream content or length does not match protocol expectations # @raise [InvalidTransitionError] If an unmatched event occurs for the current protocol state # @raise [UploadRejectedError] If the server explicitly rejects the upload session + # @raise [InternalError] If the library detects an internal invariant breach; this signals a bug + # in this library rather than a caller or server error def start initial_url:, initial_body: nil, initial_headers: {}, @@ -345,7 +350,11 @@ def start initial_url:, # streams or by reading and discarding on unseekable ones. An unseekable stream therefore has to be # freshly opened rather than rewound. # - # Completed uploads are not resumable; attempting to resume a completed session raises {SessionStateError}. + # A completed upload is finalized: {#resume_handle} returns `nil` and {#resumable?} returns + # `false`, so there is no handle to resume from. Calling `#resume` on the session that completed + # the run raises {SessionStateError}, as it would after any run. Resuming a *fresh* session + # against a finalized `upload_url` is undefined behavior: it queries the server and might return + # the response body or raise an error, depending on the server response. # # @example Resuming from a handle persisted by an earlier process # handle = Gapic::Rest::ResumableUpload::ResumeHandle.new( @@ -374,6 +383,8 @@ def start initial_url:, # @raise [StreamMismatchError] If stream content or length does not match the resumed upload # @raise [InvalidTransitionError] If an unmatched event occurs for the current protocol state # @raise [UploadRejectedError] If the server explicitly rejects the upload session + # @raise [InternalError] If the library detects an internal invariant breach; this signals a bug + # in this library rather than a caller or server error def resume upload_url: nil, chunk_size: nil, resume_handle: nil diff --git a/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb index 3231202..c7bffe7 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb @@ -76,16 +76,38 @@ def test_start_upload_config_rejects_reserved_initial_headers end end + def test_reserved_initial_headers_constant_is_lowercase_and_duplicate_free + assert_equal RESERVED_INITIAL_HEADERS.map(&:downcase), RESERVED_INITIAL_HEADERS + assert_equal RESERVED_INITIAL_HEADERS.uniq, RESERVED_INITIAL_HEADERS + assert_predicate RESERVED_INITIAL_HEADERS, :frozen? + end + def test_start_upload_config_allows_caller_owned_initial_headers stream = StringIO.new "content" headers = { - "X-Goog-Test-Scenario" => "chunk_granularity", - "X-Custom" => "value" + "X-Goog-Test-Scenario" => "chunk_granularity", + "X-Goog-Upload-Header-Content-Disposition" => 'attachment; filename="movie.mp4"', + "X-Custom" => "value" } - config = StartUploadConfig.new initial_url: "https://example.com", stream: stream, initial_headers: headers + config = StartUploadConfig.new( + initial_url: "https://example.com", + stream: stream, + initial_headers: headers, + content_type: "video/mp4", + upload_size: 7 + ) assert_equal headers, config.initial_headers + + driver = Driver.new client_stub: Object.new, config: config + merged = driver.send :start_headers, Instruction::SendStart.new(url: "https://example.com", headers: headers) + + assert_equal 'attachment; filename="movie.mp4"', merged["X-Goog-Upload-Header-Content-Disposition"] + assert_equal "resumable", merged["X-Goog-Upload-Protocol"] + assert_equal "start", merged["X-Goog-Upload-Command"] + assert_equal "video/mp4", merged["X-Goog-Upload-Header-Content-Type"] + assert_equal "7", merged["X-Goog-Upload-Header-Content-Length"] end def test_state_defaults_and_with diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb index 7aafdef..b5d73df 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_test.rb @@ -288,13 +288,66 @@ def test_run_raises_internal_error_on_multiple_continuation_events }, body: "" ) - driver = Driver.new client_stub: FakeClientStub.new([resp, resp]), config: config, core: FakeCore.new(decision) + stub = FakeClientStub.new [resp, resp] + driver = Driver.new client_stub: stub, config: config, core: FakeCore.new(decision) err = assert_raises InternalError do driver.run end assert_equal "Resumable upload internal error: recipe :broken_multi produced multiple continuation events", err.message + assert_empty stub.requests + end + + def test_run_raises_internal_error_on_mixed_continuation_and_terminal + config = StartUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("data"), + upload_size: 4 + ) + send_start = Instruction::SendStart.new url: "https://example.com/upload", headers: {}, body: "" + term_success = Instruction::TerminateSuccess.new response: Event::HttpResponse.new(status: 200, headers: {}, body: "") + decision = Decision.new( + from_status: :initializing, + shape: :start_upload, + recipe: :broken_mixed, + next_state: State.new(status: :starting), + instructions: [send_start, term_success] + ) + stub = FakeClientStub.new [] + driver = Driver.new client_stub: stub, config: config, core: FakeCore.new(decision) + + err = assert_raises InternalError do + driver.run + end + assert_equal "Resumable upload internal error: recipe :broken_mixed " \ + "produced both a continuation event and a terminal instruction", + err.message + assert_empty stub.requests + end + + def test_run_raises_internal_error_on_unclassified_instruction + config = StartUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("data"), + upload_size: 4 + ) + decision = Decision.new( + from_status: :initializing, + shape: :start_upload, + recipe: :broken_unclassified, + next_state: State.new(status: :starting), + instructions: [Object.new] + ) + stub = FakeClientStub.new [] + driver = Driver.new client_stub: stub, config: config, core: FakeCore.new(decision) + + err = assert_raises InternalError do + driver.run + end + assert_equal "Resumable upload internal error: recipe :broken_unclassified emitted unclassified instruction Object", + err.message + assert_empty stub.requests end def test_on_progress_return_value_does_not_leak_into_trampoline_invariant diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb index 575a700..d2b7c3d 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_classification_test.rb @@ -242,9 +242,30 @@ def test_shapes_constant_is_exhaustive_and_minimal end def test_statuses_tracks_state_descriptions - assert_equal Rules::STATE_DESCRIPTIONS.keys, Rules::STATUSES + assert_empty Rules::STATUSES - Rules::STATE_DESCRIPTIONS.keys, + "status missing a description" + assert_empty Rules::STATE_DESCRIPTIONS.keys - Rules::STATUSES, + "description for unknown status" assert_equal Rules::STATUSES.uniq, Rules::STATUSES assert_predicate Rules::STATUSES, :frozen? + + assert_empty Rules::TERMINAL_STATUSES - Rules::STATUSES, + "TERMINAL_STATUSES contains statuses outside STATUSES" + assert_predicate Rules::TERMINAL_STATUSES, :frozen? + end + + def test_resume_handle_from_returns_nil_for_all_terminal_statuses_except_error + base_state = State.new upload_url: "https://example.com/session/123", chunk_size: 262_144 + + Rules::TERMINAL_STATUSES.each do |terminal_status| + state = base_state.with status: terminal_status + handle = Rules.resume_handle_from state + if terminal_status == :error + refute_nil handle, "Expected resume_handle_from to return a ResumeHandle for :error status" + else + assert_nil handle, "Expected resume_handle_from to return nil for terminal status #{terminal_status.inspect}" + end + end end def test_decide_rejects_a_shape_outside_the_vocabulary @@ -252,12 +273,31 @@ def test_decide_rejects_a_shape_outside_the_vocabulary config = StartUploadConfig.new initial_url: "https://example.com/upload", stream: StringIO.new("data") error = Rules.stub :shape_of, :not_a_real_shape do - assert_raises ArgumentError do + assert_raises InternalError do + Rules.decide state, Event::StartUpload.new, config + end + end + + assert_match(/Resumable upload internal error: shape_of returned unknown shape :not_a_real_shape/, error.message) + end + + def test_decide_rejects_a_recipe_outside_the_vocabulary + state = State.new + config = StartUploadConfig.new initial_url: "https://example.com/upload", stream: StringIO.new("data") + original_recipes = Rules::RECIPES + + error = begin + Rules.send :remove_const, :RECIPES + Rules.const_set :RECIPES, [].freeze + assert_raises InternalError do Rules.decide state, Event::StartUpload.new, config end + ensure + Rules.send :remove_const, :RECIPES + Rules.const_set :RECIPES, original_recipes end - assert_match(/unknown shape: not_a_real_shape/, error.message) + assert_match(/Resumable upload internal error: decide selected unknown recipe :start_session/, error.message) end def test_resolve_chunk_size_with_nil_or_non_positive_granularity diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb index 267ce70..9b3edf1 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_decide_test.rb @@ -231,14 +231,11 @@ def test_row_cancelling_response_cancelled assert_instance_of Instruction::TerminateFailure, decision.instructions.first end - def test_row_cancelling_user_cancel_falls_through_to_wildcard - decision = Rules.decide State.new(status: :cancelling), Event::Cancel.new, @config - assert_equal :cancelling, decision.from_status - assert_equal :user_cancel, decision.shape - assert_equal :cancel_session, decision.recipe - assert_equal :cancelling, decision.next_state.status - assert_recipe_progress_notification decision - assert_instance_of Instruction::SendCancel, decision.instructions[1] + def test_row_cancelling_user_cancel_raises_invalid_transition + err = assert_raises InvalidTransitionError do + Rules.decide State.new(status: :cancelling), Event::Cancel.new, @config + end + assert_equal :cancelling, err.state end def test_row_global_deadline_exceeded @@ -261,6 +258,30 @@ def test_row_user_cancel assert_instance_of Instruction::SendCancel, decision.instructions[1] end + def test_user_cancel_across_all_statuses + cancellable = [ + :transmission_reading, + :transmission_sending, + :finalizing_sending_upload, + :finalizing_sending_finalize, + :recovery + ] + + Rules::STATUSES.each do |status| + state = State.new status: status, upload_url: "https://example.com/upload/session-1" + if cancellable.include? status + decision = Rules.decide state, Event::Cancel.new, @config + assert_equal :cancel_session, decision.recipe, "Expected :cancel_session for status #{status.inspect}" + assert_equal :cancelling, decision.next_state.status + else + err = assert_raises InvalidTransitionError, "Expected InvalidTransitionError for status #{status.inspect}" do + Rules.decide state, Event::Cancel.new, @config + end + assert_equal status, err.state + end + end + end + def test_row_response_rejected rejected_resp = Event::HttpResponse.new status: 403, headers: { "x-goog-upload-status" => "final" }, body: "Rejected" decision = Rules.decide State.new(status: :starting), rejected_resp, @config diff --git a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb index a63ed15..0c8031f 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/rules_test.rb @@ -173,11 +173,10 @@ def test_transition_cancellation_flow assert_equal Progress.new(phase: :cancelling, bytes_uploaded: 0, total_bytes: 1024), instructions[0].progress assert_instance_of Instruction::SendCancel, instructions[1] - # A duplicate cancel re-enters cancel_session through the wildcard arm and re-issues the command - dup_state, dup_instructions = Rules.step next_state, Event::Cancel.new, @config - assert_equal :cancelling, dup_state.status - assert_equal 2, dup_instructions.size - assert_instance_of Instruction::SendCancel, dup_instructions[1] + # A duplicate cancel in :cancelling raises InvalidTransitionError + assert_raises InvalidTransitionError do + Rules.step next_state, Event::Cancel.new, @config + end # Cancellation confirmed resp = Event::HttpResponse.new status: 200, headers: { "x-goog-upload-status" => "cancelled" } @@ -244,19 +243,6 @@ def test_all_recipes_satisfy_trampoline_invariant assert_equal Rules::RECIPES.sort, fixtures.keys.sort - event_producing_types = [ - Instruction::FillBuffer, - Instruction::SendStart, - Instruction::SendChunk, - Instruction::SendFinalize, - Instruction::SendQuery, - Instruction::SendCancel - ].freeze - terminal_types = [ - Instruction::TerminateSuccess, - Instruction::TerminateFailure - ].freeze - fixtures.each do |recipe, (state, event, cfg)| if recipe == :fail_with_unmatched_transition assert_raises InvalidTransitionError do @@ -266,12 +252,28 @@ def test_all_recipes_satisfy_trampoline_invariant end _next_state, instructions = Rules.public_send recipe, state, event, cfg - event_producing_count = instructions.count { |inst| event_producing_types.include? inst.class } - terminal_count = instructions.count { |inst| terminal_types.include? inst.class } + event_producing_count = instructions.count { |inst| Instruction::CONTINUATION.include? inst.class } + terminal_count = instructions.count { |inst| Instruction::TERMINAL.include? inst.class } valid = (event_producing_count == 1 && terminal_count.zero?) || (event_producing_count.zero? && terminal_count == 1) assert valid, "Recipe :#{recipe} produced #{event_producing_count} event-producing and #{terminal_count} terminal instructions" end end + + def test_instruction_constants_partition_all_instruction_classes + all_classes = Instruction.constants(false).map { |name| Instruction.const_get name }.grep(Class) + partition_union = Instruction::CONTINUATION + Instruction::TERMINAL + Instruction::SIDE_EFFECT + + assert_empty all_classes - partition_union, + "Instruction classes missing from CONTINUATION/TERMINAL/SIDE_EFFECT partition" + assert_empty partition_union - all_classes, + "Partition contains classes that are not Instruction classes" + assert_empty Instruction::CONTINUATION & Instruction::TERMINAL + assert_empty Instruction::CONTINUATION & Instruction::SIDE_EFFECT + assert_empty Instruction::TERMINAL & Instruction::SIDE_EFFECT + assert_predicate Instruction::CONTINUATION, :frozen? + assert_predicate Instruction::TERMINAL, :frozen? + assert_predicate Instruction::SIDE_EFFECT, :frozen? + end end diff --git a/gapic-common/test/gapic/rest/resumable_upload/session_test.rb b/gapic-common/test/gapic/rest/resumable_upload/session_test.rb index a0055f5..fe6e782 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/session_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/session_test.rb @@ -335,6 +335,9 @@ def test_resume_after_start_raises_session_state_error ) start_session session + assert_nil session.resume_handle + refute session.resumable? + handle = ResumeHandle.new upload_url: "https://upload.example.com/session_1", chunk_size: 4 err = assert_raises SessionStateError do session.resume resume_handle: handle From a7e0fb8468be36b9989f64d413a4654180739d4e Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Tue, 15 Sep 2026 05:49:06 +0000 Subject: [PATCH 75/79] fix: minor doc fixes and test ajustment around header verification --- gapic-common/design/implementation-guide.md | 2 +- .../lib/gapic/rest/resumable_upload/driver.rb | 3 ++- .../lib/gapic/rest/resumable_upload/rules.rb | 13 +++++----- .../rest/resumable_upload/data_types_test.rb | 21 +++------------- .../resumable_upload/driver_config_test.rb | 25 +++++++++++++++++++ 5 files changed, 38 insertions(+), 26 deletions(-) diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/implementation-guide.md index ed5dbfb..46bd9f4 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/implementation-guide.md @@ -324,7 +324,7 @@ Full implementation: [reference-implementation.md#3-driver-class](reference-impl 1. **Logical Header Prefixing**: In the `start` request, logical headers describing the uploaded object must be prefixed with `X-Goog-Upload-Header-`. Specifically: * `X-Goog-Upload-Header-Content-Type: config.content_type` * `X-Goog-Upload-Header-Content-Length: config.upload_size` (if known upfront). - * Callers cannot supply these or any other `x-goog-upload-*` header via `initial_headers` (doing so raises an `ArgumentError`). + * Callers cannot supply either of these two headers via `initial_headers`; see the reserved-headers rule in Section 2 (doing so raises an `ArgumentError`). Other `X-Goog-Upload-Header-*` pass-through headers are permitted. 2. **Offset Extraction**: On `query` responses, the acknowledged byte count is extracted from `X-Goog-Upload-Size-Received` as an integer (`server_offset`). 3. **Request Modification on 4xx**: Retrying Category 2 errors requires querying the backend for `server_offset` first. 4. **Standard Retry Configuration & Distinct Policies**: The Driver manages distinct retry policy configurations for Category 1 transient errors: diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index 74175ab..30983d0 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -147,7 +147,8 @@ def self.default_data_plane_retry_policy # Establishes a guaranteed monotonic deadline at the start of execution # so the upload cannot stall indefinitely. # - # Assumes the trampoline loop invariant: each dispatched instruction batch + # Enforces the trampoline loop invariant: each dispatched instruction batch + # is validated by {#validate_batch} before any instruction executes, ensuring it # produces either a single continuation event or terminates the session # (via {Instruction::TerminateSuccess} or {Instruction::TerminateFailure}). # Side-effect instructions ({Instruction::NotifyProgress}, diff --git a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb index 293015e..bb0f8fb 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb @@ -100,9 +100,10 @@ module ResumableUpload # # Arms are evaluated top to bottom, so their order encodes precedence and is load-bearing: # - # * The catch-all `[_, :global_deadline_exceeded]` and status-scoped `:user_cancel` arms sit above the rejected, - # bad-response and request-error arms. Moving them below would let a late failure response win over an - # expired deadline in precisely the states where the deadline matters. + # * `enter_recovery` and `fail_with_request_error` both match + # `[:transmission_sending | :finalizing_sending_upload | :finalizing_sending_finalize,` + # `:request_connection_failed | :request_timeout]`. Recovery wins purely because its arm precedes + # `fail_with_request_error`. # * `[:starting, :response_cat2]` fails instead of recovering, unlike the same shape during transmission # and finalizing. There is no upload to recover to until initiation yields an upload URL. # * `recovery` re-queries on `:response_cat2` with no attempt cap. Termination is guaranteed only by the @@ -382,6 +383,9 @@ def self.decide state, event, config :send_finalize in [:transmission_sending, :response_active] :ack_chunk + # Order matters: `enter_recovery` and `fail_with_request_error` below both match + # `[:transmission_sending | :finalizing_sending_upload | :finalizing_sending_finalize, + # :request_connection_failed | :request_timeout]`. Recovery wins purely because this arm comes first. in [:transmission_sending | :finalizing_sending_upload | :finalizing_sending_finalize, :response_cat2 | :request_connection_failed | :request_timeout] :enter_recovery @@ -396,9 +400,6 @@ def self.decide state, event, config :retry_recovery in [:cancelling, :response_cancelled] :complete_cancellation - # Order matters from here down. The catch-all deadline arm and the status-scoped cancellation arm - # must stay above the failure arms below, so that an expired deadline or a cancellation wins over a - # late failure response arriving in the same states. in [_, :global_deadline_exceeded] :fail_with_deadline_exceeded in [:transmission_reading | :transmission_sending | :finalizing_sending_upload | diff --git a/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb index c7bffe7..e78d849 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/data_types_test.rb @@ -85,29 +85,14 @@ def test_reserved_initial_headers_constant_is_lowercase_and_duplicate_free def test_start_upload_config_allows_caller_owned_initial_headers stream = StringIO.new "content" headers = { - "X-Goog-Test-Scenario" => "chunk_granularity", + "X-Goog-Test-Scenario" => "chunk_granularity", "X-Goog-Upload-Header-Content-Disposition" => 'attachment; filename="movie.mp4"', - "X-Custom" => "value" + "X-Custom" => "value" } - config = StartUploadConfig.new( - initial_url: "https://example.com", - stream: stream, - initial_headers: headers, - content_type: "video/mp4", - upload_size: 7 - ) + config = StartUploadConfig.new initial_url: "https://example.com", stream: stream, initial_headers: headers assert_equal headers, config.initial_headers - - driver = Driver.new client_stub: Object.new, config: config - merged = driver.send :start_headers, Instruction::SendStart.new(url: "https://example.com", headers: headers) - - assert_equal 'attachment; filename="movie.mp4"', merged["X-Goog-Upload-Header-Content-Disposition"] - assert_equal "resumable", merged["X-Goog-Upload-Protocol"] - assert_equal "start", merged["X-Goog-Upload-Command"] - assert_equal "video/mp4", merged["X-Goog-Upload-Header-Content-Type"] - assert_equal "7", merged["X-Goog-Upload-Header-Content-Length"] end def test_state_defaults_and_with diff --git a/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb b/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb index a6e0a13..64f859e 100644 --- a/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb +++ b/gapic-common/test/gapic/rest/resumable_upload/driver_config_test.rb @@ -241,6 +241,31 @@ def test_start_headers_without_caller_headers_is_unchanged assert_equal({ "X-Goog-Upload-Protocol" => "resumable", "X-Goog-Upload-Command" => "start" }, headers) end + def test_start_headers_merges_caller_pass_through_upload_header + caller_headers = { + "X-Goog-Upload-Header-Content-Disposition" => 'attachment; filename="movie.mp4"', + "X-Custom" => "value" + } + config = StartUploadConfig.new( + initial_url: "https://example.com/upload", + stream: StringIO.new("content"), + initial_headers: caller_headers, + content_type: "video/mp4", + upload_size: 7 + ) + driver = Driver.new client_stub: FakeClientStub.new, config: config + instruction = Instruction::SendStart.new url: "https://example.com/upload", headers: caller_headers + + headers = driver.send :start_headers, instruction + + assert_equal 'attachment; filename="movie.mp4"', headers["X-Goog-Upload-Header-Content-Disposition"] + assert_equal "value", headers["X-Custom"] + assert_equal "resumable", headers["X-Goog-Upload-Protocol"] + assert_equal "start", headers["X-Goog-Upload-Command"] + assert_equal "video/mp4", headers["X-Goog-Upload-Header-Content-Type"] + assert_equal "7", headers["X-Goog-Upload-Header-Content-Length"] + end + private def scripted_recovery_responses From b0336f3eeb4d11ac4da72a689e7d71d9cd0a56db Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Tue, 15 Sep 2026 06:09:29 +0000 Subject: [PATCH 76/79] docs: remove stale, move actual, adjust links --- .../design/reference-implementation.md | 1230 ----------------- .../implementation-guide.md | 10 +- .../integration-test-plan.md | 2 +- gapic-common/design/test-plan.md | 356 ----- .../lib/gapic/rest/resumable_upload/core.rb | 2 +- .../lib/gapic/rest/resumable_upload/driver.rb | 4 +- .../lib/gapic/rest/resumable_upload/rules.rb | 6 +- 7 files changed, 12 insertions(+), 1598 deletions(-) delete mode 100644 gapic-common/design/reference-implementation.md rename gapic-common/design/{ => resumable_upload}/implementation-guide.md (99%) rename gapic-common/design/{ => resumable_upload}/integration-test-plan.md (98%) delete mode 100644 gapic-common/design/test-plan.md diff --git a/gapic-common/design/reference-implementation.md b/gapic-common/design/reference-implementation.md deleted file mode 100644 index 21ebcae..0000000 --- a/gapic-common/design/reference-implementation.md +++ /dev/null @@ -1,1230 +0,0 @@ -# Resumable Upload Protocol Reference Implementation - -This document provides the complete reference implementation code for the core components of the Resumable Upload Protocol in `gapic-common`: -- [1. Rules Module (`Gapic::Rest::ResumableUpload::Rules`)](#1-rules-module) -- [2. Core Class (`Gapic::Rest::ResumableUpload::Core`)](#2-core-class) -- [3. Driver Class (`Gapic::Rest::ResumableUpload::Driver`)](#3-driver-class) -- [4. Session Class (`Gapic::Rest::ResumableUpload::Session`)](#4-session-class) - -For system architecture, data models, buffer invariants, and state transition specifications, see the [Implementation Guide](implementation-guide.md). - ---- - -## 1. Rules Module - -```ruby -module Gapic - module Rest - module ResumableUpload - module Rules - DEFAULT_CHUNK_SIZE = 8_388_608 # 8 MB - CAT2_STATUS_CODES = [400, 408, 409, 412, 416, 429, 499].freeze - FATAL_STATUS_CODES = [401, 403, 404, 405, 410, 413, 415].freeze - - STATE_DESCRIPTIONS = { - initializing: "initializing upload", - starting: "initiating upload session", - transmission_reading: "reading chunk from stream", - transmission_sending: "sending a chunk of data", - finalizing_sending_upload: "sending final data chunk", - finalizing_sending_finalize: "sending finalize command", - recovery: "querying upload offset for recovery", - cancelling: "cancelling upload session", - success: "in completed upload state", - cancelled: "in cancelled upload state", - error: "in error state", - rejected: "in rejected upload state" - }.freeze - - RECIPES = [ - :start_session, - :resume_session, - :begin_transmission, - :send_chunk, - :send_upload_finalize, - :send_finalize, - :ack_chunk, - :enter_recovery, - :complete_upload_with_data, - :complete_upload_finalized, - :realign_from_recovery, - :retry_recovery, - :complete_cancellation, - :ignore_duplicate_cancel, - :cancel_session, - :fail_with_deadline_exceeded, - :fail_with_rejected, - :fail_with_bad_response, - :fail_with_request_error, - :fail_with_unmatched_transition - ].freeze - - # Classifies incoming event into a canonical shape symbol. - # Pure function: takes ONLY event, zero state awareness. - # - # @param event [Object] Input event - # @return [Symbol] Canonical event shape - def self.shape_of(event) - case event - when Event::StartUpload, Event::StartUpload.singleton_class - :start_upload - when Event::ResumeUpload, Event::ResumeUpload.singleton_class - :resume_upload - when Event::ChunkRead - classify_chunk_read(event) - when Event::Cancel, Event::Cancel.singleton_class - :user_cancel - when Event::GlobalDeadlineExceeded, Event::GlobalDeadlineExceeded.singleton_class - :global_deadline_exceeded - when Event::RequestFailed - classify_request_failed(event) - when Event::HttpResponse - classify_http_response(event) - when Class - classify_event_class(event) - else - :unknown - end - end - - # Top-level transition decision engine. Matches [state.status, shape]. - # Pure function: computes next immutable State and driver instructions. - # - # @param state [State] Current state - # @param event [Object] Input event - # @param config [CompleteUploadConfig, ResumeUploadConfig] Static configuration - # @return [Decision] Decision snapshot - def self.decide(state, event, config) - shape = shape_of(event) - - recipe = case [state.status, shape] - in [:initializing, :start_upload] - :start_session - in [:initializing, :resume_upload] - :resume_session - in [:starting, :response_active] - :begin_transmission - in [:transmission_reading, :chunk_read_full] - :send_chunk - in [:transmission_reading, :chunk_read_eof_with_data] - :send_upload_finalize - in [:transmission_reading, :chunk_read_eof_empty] - :send_finalize - in [:transmission_sending, :response_active] - :ack_chunk - in [:transmission_sending | :finalizing_sending_upload | :finalizing_sending_finalize, - :response_cat2 | :request_connection_failed | :request_timeout] - :enter_recovery - in [:finalizing_sending_upload, :response_final] - :complete_upload_with_data - in [:finalizing_sending_finalize | :recovery, :response_final] - :complete_upload_finalized - in [:recovery, :response_active] - :realign_from_recovery - in [:recovery, :response_cat2] - :retry_recovery - in [:cancelling, :response_cancelled] - :complete_cancellation - in [:cancelling, :user_cancel] - :ignore_duplicate_cancel - in [_, :global_deadline_exceeded] - :fail_with_deadline_exceeded - in [_, :user_cancel] - :cancel_session - in [:starting | :transmission_sending | :finalizing_sending_upload | - :finalizing_sending_finalize | :recovery | :cancelling, :response_rejected] - :fail_with_rejected - in [:starting | :cancelling, :response_cat2] | - [:starting | :transmission_sending | :finalizing_sending_upload | - :finalizing_sending_finalize | :recovery | :cancelling, :response_fatal_bad_response] - :fail_with_bad_response - in [:starting | :transmission_sending | :finalizing_sending_upload | - :finalizing_sending_finalize | :recovery | :cancelling, - :request_retries_exhausted | :request_connection_failed | :request_timeout | - :request_failed_unknown] - :fail_with_request_error - else - :fail_with_unmatched_transition - end - - next_state, instructions = public_send(recipe, state, event, config) - Decision.new( - from_status: state.status, - shape: shape, - recipe: recipe, - next_state: next_state, - instructions: instructions - ) - end - - def self.step(state, event, config) - decision = decide(state, event, config) - [decision.next_state, decision.instructions] - end - - def self.start_session(state, _event, config) - next_state = state.with(status: :starting) - progress = Progress.new(phase: :initiating, bytes_uploaded: next_state.offset, total_bytes: config.upload_size) - instructions = [ - Instruction::NotifyProgress.new(progress: progress), - Instruction::SendStart.new( - url: config.initial_url, - headers: config.initial_headers, - body: config.initial_body - ) - ] - [next_state, instructions] - end - - def self.resume_session(state, _event, config) - next_state = state.with( - status: :recovery, - upload_url: config.upload_url, - chunk_size: config.chunk_size, - offset: 0 - ) - progress = Progress.new( - phase: :initiating, - bytes_uploaded: 0, - total_bytes: config.upload_size - ) - instructions = [ - Instruction::NotifyProgress.new(progress: progress), - Instruction::SendQuery.new(url: config.upload_url) - ] - [next_state, instructions] - end - - def self.begin_transmission(state, event, config) - granularity_str = header_value(event.headers, "x-goog-upload-chunk-granularity") - granularity = granularity_str&.to_i - chunk_size = resolve_chunk_size(config.chunk_size, granularity) - upload_url = header_value(event.headers, "x-goog-upload-url") - next_state = state.with( - status: :transmission_reading, - upload_url: upload_url, - chunk_granularity: granularity, - chunk_size: chunk_size, - offset: 0, - in_flight_length: 0 - ) - progress = Progress.new(phase: :uploading, bytes_uploaded: next_state.offset, total_bytes: config.upload_size) - instructions = [ - Instruction::NotifyProgress.new(progress: progress), - Instruction::FillBuffer.new(target_bytesize: chunk_size) - ] - [next_state, instructions] - end - - def self.send_chunk(state, event, _config) - next_state = state.with( - status: :transmission_sending, - in_flight_length: event.bytes_buffered - ) - instructions = [ - Instruction::SendChunk.new( - url: state.upload_url, - offset: state.offset, - length: event.bytes_buffered, - finalize: false - ) - ] - [next_state, instructions] - end - - def self.send_upload_finalize(state, event, config) - next_state = state.with( - status: :finalizing_sending_upload, - in_flight_length: event.bytes_buffered - ) - progress = Progress.new(phase: :finalizing, bytes_uploaded: next_state.offset, total_bytes: config.upload_size) - instructions = [ - Instruction::NotifyProgress.new(progress: progress), - Instruction::SendChunk.new( - url: state.upload_url, - offset: state.offset, - length: event.bytes_buffered, - finalize: true - ) - ] - [next_state, instructions] - end - - def self.send_finalize(state, _event, config) - next_state = state.with( - status: :finalizing_sending_finalize, - in_flight_length: 0 - ) - progress = Progress.new(phase: :finalizing, bytes_uploaded: next_state.offset, total_bytes: config.upload_size) - instructions = [ - Instruction::NotifyProgress.new(progress: progress), - Instruction::SendFinalize.new(url: state.upload_url) - ] - [next_state, instructions] - end - - def self.ack_chunk(state, _event, config) - new_offset = state.offset + state.in_flight_length - next_state = state.with( - status: :transmission_reading, - offset: new_offset, - in_flight_length: 0 - ) - progress = Progress.new(phase: :uploading, bytes_uploaded: new_offset, total_bytes: config.upload_size) - instructions = [ - Instruction::NotifyProgress.new(progress: progress), - Instruction::RealignBuffer.new(server_offset: new_offset), - Instruction::FillBuffer.new(target_bytesize: state.chunk_size) - ] - [next_state, instructions] - end - - def self.enter_recovery(state, _event, config) - next_state = state.with( - status: :recovery, - in_flight_length: 0 - ) - progress = Progress.new(phase: :recovering, bytes_uploaded: next_state.offset, total_bytes: config.upload_size) - instructions = [ - Instruction::NotifyProgress.new(progress: progress), - Instruction::SendQuery.new(url: state.upload_url) - ] - [next_state, instructions] - end - - def self.retry_recovery(state, _event, _config) - next_state = state.with( - status: :recovery, - in_flight_length: 0 - ) - [next_state, [Instruction::SendQuery.new(url: state.upload_url)]] - end - - def self.complete_upload_with_data(state, event, _config) - new_offset = state.offset + state.in_flight_length - next_state = state.with( - status: :success, - offset: new_offset, - in_flight_length: 0 - ) - progress = Progress.new(phase: :completed, bytes_uploaded: new_offset, total_bytes: new_offset) - instructions = [ - Instruction::NotifyProgress.new(progress: progress), - Instruction::TerminateSuccess.new(response: event) - ] - [next_state, instructions] - end - - def self.complete_upload_finalized(state, event, _config) - next_state = state.with( - status: :success, - in_flight_length: 0 - ) - progress = Progress.new(phase: :completed, bytes_uploaded: next_state.offset, total_bytes: next_state.offset) - instructions = [ - Instruction::NotifyProgress.new(progress: progress), - Instruction::TerminateSuccess.new(response: event) - ] - [next_state, instructions] - end - - def self.realign_from_recovery(state, event, config) - server_offset_str = header_value(event.headers, "x-goog-upload-size-received") - server_offset = server_offset_str.to_i - next_state = state.with( - status: :transmission_reading, - offset: server_offset, - in_flight_length: 0 - ) - progress = Progress.new(phase: :uploading, bytes_uploaded: server_offset, total_bytes: config.upload_size) - instructions = [ - Instruction::NotifyProgress.new(progress: progress), - Instruction::RealignBuffer.new(server_offset: server_offset), - Instruction::FillBuffer.new(target_bytesize: state.chunk_size) - ] - [next_state, instructions] - end - - def self.complete_cancellation(state, event, _config) - err = UploadCancelledError.from(event) - next_state = state.with(status: :cancelled, in_flight_length: 0, last_error: err) - [next_state, [Instruction::TerminateFailure.new(error: err)]] - end - - def self.ignore_duplicate_cancel(state, _event, _config) - [state, []] - end - - def self.cancel_session(state, _event, config) - next_state = state.with(status: :cancelling) - progress = Progress.new(phase: :cancelling, bytes_uploaded: next_state.offset, total_bytes: config.upload_size) - instructions = [ - Instruction::NotifyProgress.new(progress: progress), - Instruction::SendCancel.new(url: state.upload_url) - ] - [next_state, instructions] - end - - def self.resume_handle_from(state) - return nil if state.nil? || state.upload_url.nil? || [:rejected, :cancelled, :success].include?(state.status) - - ResumeHandle.new(upload_url: state.upload_url, chunk_size: state.chunk_size) - end - - def self.fail_with_deadline_exceeded(state, _event, _config) - handle = resume_handle_from(state) - err = DeadlineExceededError.new(resume_handle: handle) - next_state = state.with( - status: :error, - in_flight_length: 0, - last_error: err - ) - [next_state, [Instruction::TerminateFailure.new(error: err)]] - end - - def self.fail_with_rejected(state, event, _config) - handle = resume_handle_from(state) - err = UploadRejectedError.from(event, resume_handle: handle) - next_state = state.with( - status: :rejected, - in_flight_length: 0, - last_error: err - ) - [next_state, [Instruction::TerminateFailure.new(error: err)]] - end - - def self.fail_with_bad_response(state, event, _config) - handle = resume_handle_from(state) - msg = "Unexpected response from server while #{STATE_DESCRIPTIONS[state.status]}" - err = BadResponseError.new(msg, event.status, headers: event.headers, resume_handle: handle) - next_state = state.with( - status: :error, - in_flight_length: 0, - last_error: err - ) - [next_state, [Instruction::TerminateFailure.new(error: err)]] - end - - def self.fail_with_request_error(state, event, _config) - handle = resume_handle_from(state) - msg = "Request failed while #{STATE_DESCRIPTIONS[state.status]}: #{event.message}" - err = RequestFailedError.new(msg, source_error: event.source_error, resume_handle: handle) - next_state = state.with( - status: :error, - in_flight_length: 0, - last_error: err - ) - [next_state, [Instruction::TerminateFailure.new(error: err)]] - end - - def self.fail_with_unmatched_transition(state, event, _config) - err = InvalidTransitionError.new(state: state, event: event) - next_state = state.with( - status: :error, - in_flight_length: 0, - last_error: err - ) - [next_state, [Instruction::TerminateFailure.new(error: err)]] - end - - def self.resolve_chunk_size(requested_size, granularity) - base_size = requested_size || DEFAULT_CHUNK_SIZE - return base_size if granularity.nil? || !granularity.positive? - - (base_size / granularity) * granularity - end - - def self.header_value(headers, key) - return nil unless headers.is_a?(Hash) - return headers[key] if headers.key?(key) - - target = key.downcase - _, val = headers.find { |k, _| k.to_s.downcase == target } - val - end - - def self.classify_chunk_read(event) - if !event.eof - :chunk_read_full - elsif event.bytes_buffered.positive? - :chunk_read_eof_with_data - else - :chunk_read_eof_empty - end - end - - def self.classify_request_failed(event) - case event.kind - when :timeout then :request_timeout - when :retries_exhausted then :request_retries_exhausted - when :connection_failed then :request_connection_failed - else :request_failed_unknown - end - end - - def self.classify_http_response(event) - case event.status - when 200..299 - status_hdr = header_value(event.headers, "x-goog-upload-status") - case status_hdr - when "active" then :response_active - when "final" then :response_final - when "cancelled" then :response_cancelled - else :response_fatal_bad_response - end - when *CAT2_STATUS_CODES - status_hdr = header_value(event.headers, "x-goog-upload-status") - if status_hdr.nil? || status_hdr.empty? || status_hdr == "active" - :response_cat2 - else - :response_fatal_bad_response - end - when *FATAL_STATUS_CODES - :response_fatal_bad_response - else - :response_rejected - end - end - - def self.classify_event_class(klass) - if klass <= Event::StartUpload - :start_upload - elsif klass <= Event::ResumeUpload - :resume_upload - elsif klass <= Event::Cancel - :user_cancel - elsif klass <= Event::GlobalDeadlineExceeded - :global_deadline_exceeded - else - :unknown - end - end - end - end - end -end -``` - ---- - -## 2. Core Class - -```ruby -module Gapic - module Rest - module ResumableUpload - class Core - attr_reader :state, :last_decision - - # @param config [CompleteUploadConfig, ResumeUploadConfig] - def initialize(config) - @config = config - @last_decision = nil - @state = State.new( - status: :initializing, - upload_url: nil, - offset: 0, - chunk_size: config.chunk_size || Rules::DEFAULT_CHUNK_SIZE, - chunk_granularity: nil, - in_flight_length: 0, - last_error: nil - ) - end - - # Dispatches event to Rules and updates state. - # - # @param event [Object] Input event - # @return [Array] Driver instructions - def dispatch(event) - decision = Rules.decide(@state, event, @config) - @state = decision.next_state - @last_decision = decision - decision.instructions - end - end - end - end -end -``` - ---- - -## 3. Driver Class - -```ruby -module Gapic - module Rest - module ResumableUpload - class Driver - include Gapic::LoggingConcerns - - # Minimum assumed upload throughput in bytes per second (1 MB/s) - MIN_ASSUMED_THROUGHPUT = 1_048_576 - - # Default base timeout in seconds (1 hour) - BASE_TIMEOUT = 3_600 - - attr_reader :core - - # @param client_stub [Gapic::Rest::ClientStub] - # @param config [CompleteUploadConfig, ResumeUploadConfig] - # @param core [Core, nil] Optional Core state machine (defaults to new Core with config) - # @param logger [Logger, nil] Optional logger override - def initialize(client_stub:, config:, core: nil, logger: nil) - @client_stub = client_stub - @config = config - @core = core || Core.new(config) - @buffer = "".b - @buffer_start_offset = 0 - - endpoint = client_stub.respond_to?(:endpoint) ? client_stub.endpoint : nil - setup_logging( - logger: logger || (client_stub.respond_to?(:logger) ? client_stub.logger : nil), - system_name: "gapic-common", - service: "ResumableUpload", - endpoint: endpoint, - client_id: client_stub.object_id - ) - @upload_log = UploadLog.new(stub_logger, upload_id: "unstarted") - - @start_retry_policy = resolve_retry_policy(config.start_retry_policy, RetryPolicies::START_DEFAULTS) - @control_plane_retry_policy = resolve_retry_policy( - config.control_plane_retry_policy, - RetryPolicies::CONTROL_PLANE_DEFAULTS - ) - @data_plane_retry_policy = resolve_retry_policy( - config.data_plane_retry_policy, - RetryPolicies::DATA_PLANE_DEFAULTS - ) - end - - def self.default_start_retry_policy - RetryPolicies.default_start - end - - def self.default_control_plane_retry_policy - RetryPolicies.default_control_plane - end - - def self.default_data_plane_retry_policy - RetryPolicies.default_data_plane - end - - # Returns current resume handle, or nil if initiation is pending or session is finalized. - # - # @return [ResumeHandle, nil] - def resume_handle - Rules.resume_handle_from(@core.state) - end - - # Returns raw session upload URL. - # - # @return [String, nil] - def upload_url - @core.state.upload_url - end - - # Executes event loop until terminal state. - # - # @return [String, Object] Final response body - def run - @upload_log = UploadLog.new(stub_logger, upload_id: LoggingConcerns.random_uuid4) - @deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + resolve_timeout - pending_event = initial_event - - loop do - instructions = dispatch_event(pending_event) - pending_event = nil - - if deadline_exceeded? && !terminal_instructions?(instructions) - instructions = dispatch_event(Event::GlobalDeadlineExceeded.new) - end - - instructions.each do |instruction| - result = dispatch_instruction(instruction) - pending_event = result if pending_event_type?(result) - return result if instruction.is_a?(Instruction::TerminateSuccess) - end - end - end - - private - - def initial_event - if @config.is_a?(ResumeUploadConfig) - Event::ResumeUpload.new - else - Event::StartUpload.new - end - end - - def resolve_timeout - return @config.timeout if @config.timeout&.positive? - - if @config.upload_size - [@config.upload_size.fdiv(MIN_ASSUMED_THROUGHPUT), BASE_TIMEOUT].max - else - BASE_TIMEOUT - end - end - - def deadline_exceeded? - Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @deadline - end - - def remaining_time - [@deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0.0].max - end - - def terminal_instructions?(instructions) - instructions.any? { |i| i.is_a?(Instruction::TerminateSuccess) || i.is_a?(Instruction::TerminateFailure) } - end - - def dispatch_event(event) - instructions = begin - @core.dispatch(event) - rescue InvalidTransitionError => e - @upload_log.unmatched_transition(@core.state, event, e) - raise - end - @upload_log.decision(@core.last_decision) - @upload_log.lifecycle(@core.last_decision, @config) - instructions - end - - def dispatch_instruction(instruction) - case instruction - when Instruction::NotifyProgress then execute_notify_progress(instruction) - when Instruction::RealignBuffer then execute_realign_buffer(instruction) - when Instruction::FillBuffer then execute_fill_buffer(instruction) - when Instruction::SendStart then execute_send_start(instruction) - when Instruction::SendChunk then execute_send_chunk(instruction) - when Instruction::SendFinalize then execute_send_finalize(instruction) - when Instruction::SendQuery then execute_send_query(instruction) - when Instruction::SendCancel then execute_send_cancel(instruction) - when Instruction::TerminateSuccess - instruction.response.respond_to?(:body) ? instruction.response.body : instruction.response - when Instruction::TerminateFailure then raise instruction.error - end - end - - def execute_notify_progress(instruction) - @config.on_progress&.call(instruction.progress) - end - - def execute_realign_buffer(instruction) - server_offset = instruction.server_offset - if @config.upload_size && server_offset > @config.upload_size - raise StreamMismatchError.new( - "Server reported offset #{server_offset} exceeds total upload size #{@config.upload_size}", - resume_handle: resume_handle - ) - end - - buffer_start = @buffer_start_offset - buffer_end = @buffer_start_offset + @buffer.bytesize - - realign_case = if server_offset >= buffer_start && server_offset <= buffer_end - "within_buffer" - elsif server_offset < buffer_start - "rewind" - else - "fast_forward" - end - - unseekable = realign_case == "rewind" && !@config.stream.respond_to?(:seek) - @upload_log.buffer_realign( - realign_case, server_offset: server_offset, - current_offset: buffer_start, - unseekable: unseekable - ) - - if server_offset >= buffer_start && server_offset <= buffer_end - realign_within_buffer(server_offset) - elsif server_offset < buffer_start - realign_rewind_stream(server_offset) - else - realign_fast_forward_stream(server_offset, buffer_end) - end - end - - def realign_within_buffer(server_offset) - slice_index = server_offset - @buffer_start_offset - @buffer = @buffer.byteslice(slice_index..-1) || "".b - @buffer_start_offset = server_offset - end - - def realign_rewind_stream(server_offset) - unless @config.stream.respond_to?(:seek) - raise UnseekableStreamError.new( - "Cannot rewind unseekable stream to offset #{server_offset} (buffered from #{@buffer_start_offset})", - resume_handle: resume_handle - ) - end - - if @config.upload_size.nil? && @config.stream.respond_to?(:size) && server_offset > @config.stream.size - raise StreamMismatchError.new( - "Server reported offset #{server_offset} exceeds stream size #{@config.stream.size}", - resume_handle: resume_handle - ) - end - - @config.stream.seek(server_offset) - @buffer = "".b - @buffer_start_offset = server_offset - end - - def realign_fast_forward_stream(server_offset, buffer_end) - @buffer = "".b - if @config.stream.respond_to?(:seek) - if @config.upload_size.nil? && @config.stream.respond_to?(:size) && server_offset > @config.stream.size - raise StreamMismatchError.new( - "Server reported offset #{server_offset} exceeds stream size #{@config.stream.size}", - resume_handle: resume_handle - ) - end - @config.stream.seek(server_offset) - else - needed_discard = server_offset - buffer_end - while needed_discard.positive? - chunk_len = [needed_discard, 65_536].min - discarded = @config.stream.read(chunk_len) - if discarded.nil? || discarded.empty? - raise StreamMismatchError.new( - "Stream ended prematurely at offset #{server_offset - needed_discard} before reaching server offset #{server_offset}", - resume_handle: resume_handle - ) - end - needed_discard -= discarded.bytesize - end - end - @buffer_start_offset = server_offset - end - - def execute_fill_buffer(instruction) - target = instruction.target_bytesize - eof = false - - while @buffer.bytesize < target - bytes_needed = target - @buffer.bytesize - chunk = @config.stream.read(bytes_needed) - if chunk.nil? || chunk.empty? - eof = true - break - end - @buffer << chunk.b - end - - Event::ChunkRead.new(bytes_buffered: @buffer.bytesize, eof: eof) - end - - def execute_send_start(instruction) - policy = @start_retry_policy.dup.start! - headers = start_headers(instruction) - attempt = 1 - - loop do - return Event::GlobalDeadlineExceeded.new if deadline_exceeded? - - event = make_post_request( - instruction.url, headers: headers, body: instruction.body, - retry_policy: policy, method_name: "ResumableUpload.start", - start_attempt: attempt - ) - return event unless event.is_a?(Event::HttpResponse) - - status_hdr = Rules.header_value(event.headers, "x-goog-upload-status") - return event unless status_hdr.nil? || status_hdr.empty? - return event if Rules::FATAL_STATUS_CODES.include?(event.status) - - err = BadResponseError.new( - "Missing X-Goog-Upload-Status header in start response", - event.status, - headers: event.headers - ) - can_retry = policy.send(:retry_with_deadline?) && policy.call(event) - unless can_retry - if event.status == 200 - failed_event = Event::RequestFailed.new( - kind: :retries_exhausted, message: err.message, source_error: err - ) - @upload_log.wire_failure(failed_event) - return failed_event - end - return event - end - attempt += 1 - end - end - - def start_headers(instruction) - headers = { "X-Goog-Upload-Protocol" => "resumable", "X-Goog-Upload-Command" => "start" } - headers["X-Goog-Upload-Header-Content-Type"] = @config.content_type if @config.content_type - headers["X-Goog-Upload-Header-Content-Length"] = @config.upload_size.to_s if @config.upload_size - headers.merge(instruction.headers || {}) - end - - def execute_send_chunk(instruction) - headers = { - "X-Goog-Upload-Command" => instruction.finalize ? "upload, finalize" : "upload", - "X-Goog-Upload-Offset" => instruction.offset.to_s, - "Content-Type" => @config.content_type || "application/octet-stream", - "Content-Length" => instruction.length.to_s - } - slice_index = instruction.offset - @buffer_start_offset - body = @buffer.byteslice(slice_index, instruction.length) - - make_post_request( - instruction.url, headers: headers, body: body, - retry_policy: @data_plane_retry_policy.dup.start!, - method_name: "ResumableUpload.upload" - ) - end - - def execute_send_finalize(instruction) - headers = { - "X-Goog-Upload-Command" => "finalize", - "X-Goog-Upload-Offset" => @core.state.offset.to_s, - "Content-Length" => "0" - } - make_post_request( - instruction.url, headers: headers, body: "", - retry_policy: @data_plane_retry_policy.dup.start!, - method_name: "ResumableUpload.finalize" - ) - end - - def execute_send_query(instruction) - headers = { "X-Goog-Upload-Command" => "query" } - make_post_request( - instruction.url, headers: headers, body: "", - retry_policy: @control_plane_retry_policy.dup.start!, - method_name: "ResumableUpload.query" - ) - end - - def execute_send_cancel(instruction) - headers = { "X-Goog-Upload-Command" => "cancel" } - make_post_request( - instruction.url, headers: headers, body: "", - retry_policy: @control_plane_retry_policy.dup.start!, - method_name: "ResumableUpload.cancel" - ) - end - - def make_post_request(url, headers:, body:, retry_policy:, method_name: nil, start_attempt: 1) - return Event::GlobalDeadlineExceeded.new if deadline_exceeded? - - options = { - metadata: headers, - retry_policy: retry_policy, - timeout: request_timeout(retry_policy) - } - @upload_log.wire_send( - method: "POST", url: url, headers: headers, - start_attempt: start_attempt, body_size: body.to_s.bytesize, body: body - ) - - response = @client_stub.make_post_request( - uri: url, body: body, params: {}, - options: options, method_name: method_name - ) - event = Event::HttpResponse.new(status: response.status, headers: response.headers || {}, body: response.body) - @upload_log.wire_receive(event) - event - rescue StandardError => e - return Event::GlobalDeadlineExceeded.new if deadline_exceeded? - - event = rescue_request_error(e) - if event.is_a?(Event::HttpResponse) - @upload_log.wire_receive(event) - else - @upload_log.wire_failure(event) - end - event - end - - def request_timeout(retry_policy) - policy_timeout = retry_policy.respond_to?(:timeout) ? retry_policy.timeout : nil - [remaining_time, policy_timeout].compact.min - end - - def rescue_request_error(err) - case err - when Gapic::Rest::DeadlineExceededError - Event::RequestFailed.new(kind: :timeout, message: err.message, source_error: err) - when Gapic::Rest::Error - if err.status_code - Event::HttpResponse.new( - status: err.status_code, - headers: err.headers || {}, - body: err.message, - error: err - ) - else - Event::RequestFailed.new(kind: :connection_failed, message: err.message, source_error: err) - end - when Faraday::Error - rescue_faraday_error(err) - else - Event::RequestFailed.new(kind: :connection_failed, message: err.message, source_error: err) - end - end - - def rescue_faraday_error(err) - if err.response && err.response[:status] - rest_err = Gapic::Rest::Error.wrap_faraday_error(err) - Event::HttpResponse.new( - status: err.response[:status], - headers: err.response[:headers] || {}, - body: err.response[:body], - error: rest_err - ) - elsif err.is_a?(Faraday::TimeoutError) - Event::RequestFailed.new(kind: :timeout, message: err.message, source_error: err) - elsif err.is_a?(Faraday::ConnectionFailed) - Event::RequestFailed.new(kind: :connection_failed, message: err.message, source_error: err) - else - Event::RequestFailed.new(kind: :retries_exhausted, message: err.message, source_error: err) - end - end - - def resolve_retry_policy(value, defaults) - case value - when Gapic::Common::RetryPolicy - value - when Hash - Gapic::Common::RetryPolicy.new(**value).apply_defaults(defaults) - when nil - Gapic::Common::RetryPolicy.new(**defaults) - else - raise ArgumentError, "Expected RetryPolicy, Hash, or nil, got #{value.class}" - end - end - end - end - end -end -``` - ---- - -## 4. Session Class - -```ruby -module Gapic - module Rest - module ResumableUpload - class Session - attr_reader :client_stub, :stream, :initial_url, :initial_body, :initial_headers, - :upload_size, :chunk_size, :content_type, :timeout, :start_retry_policy, - :control_plane_retry_policy, :data_plane_retry_policy, :on_progress, :logger - - def initialize(client_stub:, - stream:, - initial_url:, - initial_body: nil, - initial_headers: {}, - upload_size: nil, - chunk_size: nil, - content_type: nil, - timeout: nil, - start_retry_policy: nil, - control_plane_retry_policy: nil, - data_plane_retry_policy: nil, - on_progress: nil, - logger: nil) - @client_stub = client_stub - @stream = stream - @initial_url = initial_url - @initial_body = initial_body - @initial_headers = initial_headers || {} - @upload_size = upload_size - @chunk_size = chunk_size - @content_type = content_type - @timeout = timeout - @start_retry_policy = start_retry_policy - @control_plane_retry_policy = control_plane_retry_policy - @data_plane_retry_policy = data_plane_retry_policy - @on_progress = on_progress - @logger = logger - - @mutex = Mutex.new - @running = false - @executed = false - @upload_url = nil - @last_driver = nil - end - - # Returns the raw upload session URL if established. - # - # @return [String, nil] - def upload_url - @mutex.synchronize { upload_url_internal } - end - - # Returns whether the session is bound to a server-side upload. - # - # @return [Boolean] - def bound? - @mutex.synchronize { bound_internal? } - end - - # Returns the current ResumeHandle if the session is alive and resumable. - # - # @return [ResumeHandle, nil] - def resume_handle - @mutex.synchronize { resume_handle_internal } - end - - # Returns whether a new session can resume the upload. - # Completed uploads are not resumable (returns false). - # - # @return [Boolean] - def resumable? - !resume_handle.nil? - end - - # Returns whether a run is currently executing. - # - # @return [Boolean] - def running? - @mutex.synchronize { @running } - end - - # Starts a new upload session on the server. - # - # @return [String, Object] Final response body upon completion - # @raise [SessionStateError] If already bound/executed or if a run is currently in progress - def start - driver = nil - @mutex.synchronize do - raise SessionStateError, "A run is already in progress for this session" if @running - raise SessionStateError, "Session has already executed a run" if bound_internal? - - @executed = true - @running = true - config = build_start_config - driver = Driver.new(client_stub: @client_stub, config: config, logger: @logger) - end - - execute_run(driver) - end - - # Resumes an upload session using one of two explicit keyword forms: - # 1. `resume(upload_url:, chunk_size:)`: Resumes with explicit URL and chunk size. - # 2. `resume(resume_handle:)`: Resumes via ResumeHandle. - # - # Precondition: stream must be positioned at byte 0. - # - # @param upload_url [String, nil] Explicit upload URL - # @param chunk_size [Integer, nil] Explicit chunk size - # @param resume_handle [ResumeHandle, nil] Explicit resume handle - # @return [String, Object] Final response body upon completion - # @raise [ArgumentError] If argument shape is invalid, target upload is missing, or stream.pos != 0 - # @raise [SessionStateError] If already bound/executed or if a run is currently in progress - def resume(upload_url: nil, chunk_size: nil, resume_handle: nil) - target_url, target_chunk_size = resolve_resume_args( - upload_url: upload_url, - chunk_size: chunk_size, - resume_handle: resume_handle - ) - - driver = nil - @mutex.synchronize do - raise SessionStateError, "A run is already in progress for this session" if @running - raise SessionStateError, "Session has already executed a run" if bound_internal? - - if @stream.respond_to?(:pos) && !@stream.pos.zero? - raise ArgumentError, "Stream must be positioned at byte 0 to resume an upload (got pos #{@stream.pos})" - end - - @executed = true - @running = true - config = build_resume_config(target_url, target_chunk_size) - driver = Driver.new(client_stub: @client_stub, config: config, logger: @logger) - end - - execute_run(driver) - end - - private - - def upload_url_internal - @upload_url || @last_driver&.upload_url - end - - def bound_internal? - @executed || !upload_url_internal.nil? - end - - def resume_handle_internal - @last_driver&.resume_handle - end - - def build_start_config - CompleteUploadConfig.new( - initial_url: @initial_url, - initial_body: @initial_body, - initial_headers: @initial_headers, - stream: @stream, - upload_size: @upload_size, - chunk_size: @chunk_size, - content_type: @content_type, - timeout: @timeout, - start_retry_policy: @start_retry_policy, - control_plane_retry_policy: @control_plane_retry_policy, - data_plane_retry_policy: @data_plane_retry_policy, - on_progress: @on_progress - ) - end - - def build_resume_config(target_url, target_chunk_size) - ResumeUploadConfig.new( - upload_url: target_url, - chunk_size: target_chunk_size, - stream: @stream, - upload_size: @upload_size, - content_type: @content_type, - timeout: @timeout, - start_retry_policy: @start_retry_policy, - control_plane_retry_policy: @control_plane_retry_policy, - data_plane_retry_policy: @data_plane_retry_policy, - on_progress: @on_progress - ) - end - - def resolve_resume_args(upload_url:, chunk_size:, resume_handle:) - if resume_handle - raise ArgumentError, "Cannot pass both resume_handle and upload_url/chunk_size" if upload_url || chunk_size - [resume_handle.upload_url, resume_handle.chunk_size] - elsif upload_url - raise ArgumentError, "Must provide chunk_size with upload_url" if chunk_size.nil? - [upload_url, chunk_size] - elsif chunk_size - raise ArgumentError, "Cannot pass chunk_size without upload_url" - else - raise ArgumentError, "Must provide either resume_handle or upload_url and chunk_size" - end - end - - def execute_run(driver) - @mutex.synchronize { @last_driver = driver } - result = driver.run - @mutex.synchronize do - @upload_url ||= driver.upload_url - @running = false - end - result - rescue StandardError - @mutex.synchronize do - @upload_url ||= driver.upload_url - @running = false - end - raise - end - end - end - end -end -``` diff --git a/gapic-common/design/implementation-guide.md b/gapic-common/design/resumable_upload/implementation-guide.md similarity index 99% rename from gapic-common/design/implementation-guide.md rename to gapic-common/design/resumable_upload/implementation-guide.md index 46bd9f4..3a561a9 100644 --- a/gapic-common/design/implementation-guide.md +++ b/gapic-common/design/resumable_upload/implementation-guide.md @@ -280,9 +280,9 @@ When `Core` resolves a recovery query or offset realignment, the Driver executes --- -## 3. Component Architecture & Reference Implementation +## 3. Component Architecture -The complete reference implementation for `Rules`, `Core`, and `Driver` is located in [reference-implementation.md](reference-implementation.md). +The authoritative implementation is the source itself, under `lib/gapic/rest/resumable_upload/`. This section describes the contract each component honours; the code is normative where the two disagree. ### 3.1 Rules Module (`Gapic::Rest::ResumableUpload::Rules`) The `Rules` module is a pure functional transition engine with zero state awareness and zero side effects. It provides two primary entry points: @@ -290,7 +290,7 @@ The `Rules` module is a pure functional transition engine with zero state awaren * `Rules.decide(state, event, config)`: Evaluates `case [state.status, shape]` pattern matching to select a transition recipe symbol, dispatches via `public_send(recipe, state, event, config)`, and returns a `Decision` snapshot (`from_status`, `shape`, `recipe`, `next_state`, `instructions`). * `Rules.step(state, event, config)`: Convenience tuple wrapper around `Rules.decide` returning `[decision.next_state, decision.instructions]`. -Full implementation: [reference-implementation.md#1-rules-module](reference-implementation.md#1-rules-module) +Source: `lib/gapic/rest/resumable_upload/rules.rb` ### 3.2 Core Class (`Gapic::Rest::ResumableUpload::Core`) The `Core` class is the state container holding the immutable `State` snapshot. It exposes: @@ -298,7 +298,7 @@ The `Core` class is the state container holding the immutable `State` snapshot. * `#last_decision`: Reader for the `Decision` recorded during the most recent `#dispatch` (or `nil`). * `#dispatch(event)`: Invokes `Rules.decide(@state, event, @config)`, updates `@state = decision.next_state` and `@last_decision = decision`, and returns `decision.instructions` to the Driver. -Full implementation: [reference-implementation.md#2-core-class](reference-implementation.md#2-core-class) +Source: `lib/gapic/rest/resumable_upload/core.rb` ### 3.3 Driver Class (`Gapic::Rest::ResumableUpload::Driver`) The `Driver` is the synchronous execution engine for the pure protocol state machine. When `Core#dispatch(event)` is invoked, it returns an ordered list (`Array`) of commands that the Driver executes in sequence. @@ -314,7 +314,7 @@ The Driver categorizes instructions into three execution types: 3. **Terminal Handlers** (`TerminateSuccess`, `TerminateFailure`): * Break the event loop and return the final response body string (`response.body`) or raise the terminal exception. -Full implementation: [reference-implementation.md#3-driver-class](reference-implementation.md#3-driver-class) +Source: `lib/gapic/rest/resumable_upload/driver.rb` --- diff --git a/gapic-common/design/integration-test-plan.md b/gapic-common/design/resumable_upload/integration-test-plan.md similarity index 98% rename from gapic-common/design/integration-test-plan.md rename to gapic-common/design/resumable_upload/integration-test-plan.md index 47626a1..46a5750 100644 --- a/gapic-common/design/integration-test-plan.md +++ b/gapic-common/design/resumable_upload/integration-test-plan.md @@ -1,6 +1,6 @@ # Resumable Upload Integration Test Plan -This document outlines the integration test architecture and test suites for the Resumable Upload protocol implementation in `gapic-common`. Unlike the unit test suite ([test-plan.md](./test-plan.md)), which isolates protocol state transitions and driver components against test doubles, the integration test suite exercises the full stack end-to-end over real HTTP/REST connections against a live `gapic-showcase` server. +This document outlines the integration test architecture and test suites for the Resumable Upload protocol implementation in `gapic-common`. Unlike the unit test suite under `test/gapic/rest/resumable_upload/`, which isolates protocol state transitions and driver components against test doubles, the integration test suite exercises the full stack end-to-end over real HTTP/REST connections against a live `gapic-showcase` server. --- diff --git a/gapic-common/design/test-plan.md b/gapic-common/design/test-plan.md deleted file mode 100644 index 18a158a..0000000 --- a/gapic-common/design/test-plan.md +++ /dev/null @@ -1,356 +0,0 @@ -# Resumable Upload Unit Test Plan - -This document outlines the complete unit test plan for the Resumable Upload protocol implementation in `gapic-common`. It details all unit test suites, systems under test (SUT), test doubles, test cases, and behavioral assertions across the protocol layers. For end-to-end integration tests against `gapic-showcase`, see [integration-test-plan.md](./integration-test-plan.md). - ---- - -## 1. Test Architecture Overview - -```mermaid -flowchart TD - subgraph TestSuites["Test Suites"] - RC["rules_classification_test.rb
(Rules Classification & Utility)"] - RT["rules_test.rb
(Rules Progression & Lifecycle)"] - RR["rules_recovery_test.rb
(Rules Recovery Transitions)"] - RE["rules_error_test.rb
(Rules Terminal Errors & Formatting)"] - RP["retry_policies_test.rb
(Retry Policies & Header Extraction)"] - DB["driver_buffer_test.rb
(Stream Buffering & Realignment)"] - DE["driver_error_mapping_test.rb
(Network Error Mapping)"] - DP["driver_progress_test.rb
(Progress Dispatch & Error Propagation)"] - DT["driver_test.rb
(Driver Upload Execution Loop)"] - DR["driver_retry_test.rb
(Driver Initiation & Query Retries)"] - DC["driver_config_test.rb
(Driver Configuration & Deadlines)"] - AB["driver/abridge_test.rb
(Payload & Header Redaction)"] - UL["driver/upload_log_test.rb
(UploadLog Structured Entries)"] - DL["driver_logging_test.rb
(Driver Logging & Corpus Invariants)"] - end - - subgraph SUT["Systems Under Test"] - RulesClassify["Rules.shape_of
Rules.classify_http_response
Rules.header_value
Rules.resolve_chunk_size"] - RulesStep["Rules.step
Rules.fail_with_unmatched_transition"] - Policies["RetryPolicies.default_start
RetryPolicies.default_control_plane
RetryPolicies.default_data_plane
RetryPolicies.extract_headers"] - DriverIO["Driver#execute_fill_buffer
Driver#execute_realign_buffer"] - DriverErr["Driver#rescue_request_error
Driver#rescue_faraday_error"] - DriverProg["Driver#execute_notify_progress"] - DriverRun["Driver#run
Driver#execute_send_chunk"] - DriverRetry["Driver#execute_send_start
Driver#execute_send_query"] - DriverConfig["Driver#resolve_timeout
Driver#deadline_exceeded?"] - DriverLog["Driver::Abridge
Driver::UploadLog
Driver#run Logging"] - end - - RC --> RulesClassify - RT --> RulesStep - RR --> RulesStep - RE --> RulesStep - RP --> Policies - DB --> DriverIO - DE --> DriverErr - DP --> DriverProg - DT --> DriverRun - DR --> DriverRetry - DC --> DriverConfig - AB --> DriverLog - UL --> DriverLog - DL --> DriverLog -``` - ---- - -## 2. Test Doubles & Fixtures - -| Double Name | Location | Description & Behavior | -| :--- | :--- | :--- | -| `ChunkedStream` | `driver_buffer_test.rb` | Wraps `StringIO`; caps returned bytes per `#read(length)` call to simulate socket/pipe short reads. | -| `UnseekableStream` | `driver_buffer_test.rb` | Wraps `StringIO` with `#read` but explicitly omits `#seek` (`respond_to?(:seek)` is `false`). | -| `FailingClientStub` | `driver_error_mapping_test.rb` | Integration fake client stub configured with `@error_to_raise` to verify exception rescue in `Driver#make_post_request`. | -| `ScriptedClientStub` | `driver_progress_test.rb` / `driver_test.rb` | Yields a deterministic sequence of HTTP response structs and records dispatched requests. | -| `RecordingLogger` | `test_helper.rb` | Real `Logger` at `DEBUG` level whose formatter appends every emitted `Google::Logging::Message` and severity to an in-memory array. Ensures log blocks execute completely rather than being stubbed out (catching any exceptions inside log blocks that `StubLogger#log` would otherwise rescue). | -| `FakeStub` | `driver_logging_test.rb` | Returns scripted HTTP responses and records `method_name` arguments passed to `make_post_request`. | - ---- - -## 3. Detailed Test Suites & Cases - -### 3.1 Rules Classification & Utility (`rules_classification_test.rb`) - -#### A. Case-Insensitive Header Extraction (`Rules.header_value`) -* **Exact match**: Exact key casing (`"X-Goog-Upload-Status"` $\rightarrow$ `"active"`). -* **Key case-insensitivity**: Resolves lowercase (`"x-goog-upload-status"`), uppercase (`"X-GOOG-UPLOAD-STATUS"`), and mixed-case (`"x-Goog-UpLoad-Status"`). -* **Symbol keys**: Resolves symbols in headers hash (`:"x-goog-upload-status"`). -* **Missing or non-hash input**: Returns `nil` when key is absent, or when headers object is `nil`, `[]`, or a String. - -#### B. HTTP Response Classification (`Rules.classify_http_response`) -* **`active` status header**: - * HTTP 200 + `active` (and casing variants `"Active"`, `"ACTIVE"`, `"aCtIvE"`) $\rightarrow$ `:response_active`. - * Non-200 (HTTP 503, 500, 400, 408) + `active` $\rightarrow$ `:response_cat2`. -* **`final` status header**: - * HTTP 200 + `final` (and casing variants `"Final"`, `"FINAL"`) $\rightarrow$ `:response_final`. - * Non-200 (HTTP 400, 404, 500) + `final` $\rightarrow$ `:response_rejected`. -* **`cancelled` status header**: - * HTTP 200 + `cancelled` (and casing variants `"Cancelled"`, `"CANCELLED"`) $\rightarrow$ `:response_cancelled`. - * Non-200 (HTTP 400, 500) + `cancelled` $\rightarrow$ `:response_fatal_bad_response`. -* **Missing or empty status header (`nil`, `""`)**: - * HTTP 200 without status header $\rightarrow$ `:response_cat2`. - * All 7 recoverable status codes (`400, 408, 409, 412, 416, 429, 499`) without status header $\rightarrow$ `:response_cat2`. - * 5xx server/gateway errors (`500, 502, 503, 504`) without status header $\rightarrow$ `:response_cat2`. - * All 7 fatal status codes (`401, 403, 404, 405, 410, 413, 415`) without status header or with empty status header $\rightarrow$ `:response_fatal_bad_response`. -* **Unknown status header values**: - * Non-standard status strings (`"absconded"`, `"pending"`, `"in_progress"`, `"error"`, `"unknown"`) with HTTP 200 or 400 $\rightarrow$ `:response_fatal_bad_response`. -* **Header key variations**: - * Correct classification regardless of key casing (`"x-goog-upload-status"`, `"X-GOOG-UPLOAD-STATUS"`, `:"x-goog-upload-status"`). - -#### C. Event Shape Classification (`Rules.shape_of`) -* **Control events**: `Event::StartUpload`, `Event::Cancel`, `Event::GlobalDeadlineExceeded` as instances and class singletons. -* **Stream chunk reads**: Full chunks (`:chunk_read_full`), EOF with remaining data (`:chunk_read_eof_with_data`), EOF empty (`:chunk_read_eof_empty`). -* **Transport failures**: Retries exhausted (`:request_retries_exhausted`), connection failures (`:request_connection_failed`), unknown/other kinds (`:request_failed_unknown`). -* **HTTP responses**: Delegates cleanly to `classify_http_response`. -* **Unrecognized objects**: Arbitrary objects (`Object.new`, `nil`, `"string"`) $\rightarrow$ `:unknown`. - -#### D. Chunk Size Negotiation (`Rules.resolve_chunk_size`) -* **`nil`, `0`, or negative granularity**: Preserves user-specified chunk size or falls back to `DEFAULT_CHUNK_SIZE` (`8_388_608`) without modulo errors. -* **Evenly divisible**: Preserves chunk size when user size or default size divides server granularity evenly. -* **Not divisible (downward alignment)**: Rounds down to the nearest multiple of granularity (e.g. 1000 with granularity 256 $\rightarrow$ 768; 10 MB with 256 KB $\rightarrow$ 9,961,472 bytes; default 8 MB with 500 KB $\rightarrow$ 8 MB). -* **Equal size**: Preserves chunk size when size equals granularity (256 and 256 $\rightarrow$ 256). -* **Granularity strictly greater than chunk size**: Promotes chunk size to granularity to avoid rounding down to 0 (e.g. 100 with granularity 256 $\rightarrow$ 256; 1 with 256 KB $\rightarrow$ 256 KB; default 8 MB with 16 MB $\rightarrow$ 16 MB). - ---- - -### 3.2 Rules State Machine (`rules_test.rb`, `rules_recovery_test.rb`, `rules_error_test.rb`) - -#### A. Normal Progression & Session Lifecycle (`rules_test.rb`) -* **Session initiation**: `:initializing` on `:start_upload` transitions to `:starting` and emits `Instruction::SendStart`. -* **Transmission start**: `:starting` on `:response_active` resolves chunk size and granularity, transitions to `:transmission_reading`, and emits `Instruction::FillBuffer`. -* **Chunk transmission & finalization**: `:transmission_reading` dispatches `SendChunk` (with or without `finalize: true`) or standalone `SendFinalize` depending on stream EOF and buffered bytes. -* **Chunk acknowledgment**: `:transmission_sending` on `:response_active` advances offset, emits `NotifyProgress`, `RealignBuffer`, and `FillBuffer`. -* **Cancellation flow**: `:user_cancel` transitions to `:cancelling` and emits `SendCancel` in the five statuses with an established upload URL (`:transmission_reading`, `:transmission_sending`, `:finalizing_sending_upload`, `:finalizing_sending_finalize`, `:recovery`), and raises `InvalidTransitionError` in `:cancelling`, `:initializing`, `:starting`, and terminal statuses; `:response_cancelled` transitions to `:cancelled`. - -#### B. Protocol Recovery Transitions (`rules_recovery_test.rb`) -* **Entering recovery**: `:transmission_sending` (on `:response_cat2`, `:request_connection_failed`, `:request_timeout`) and `:finalizing_sending_upload` (on `:request_timeout`) transition to `:recovery` and emit `Instruction::SendQuery`. -* **Realignment from recovery**: `:recovery` on `:response_active` updates offset from `X-Goog-Upload-Size-Received`, emits `RealignBuffer` and `FillBuffer`, and transitions to `:transmission_reading`. -* **Finalized in recovery**: `:recovery` on `:response_final` transitions to `:success` and emits `TerminateSuccess`. -* **Retrying recovery query**: `:recovery` on `:response_cat2` remains in `:recovery` and re-emits `SendQuery`. - -#### C. Terminal Failures & Actionable Error Formatting (`rules_error_test.rb`) -* **Terminal failures (`:rejected` / `:error`)**: - * Session rejection (`:response_rejected` $\rightarrow$ `UploadRejectedError`), fatal bad responses (`:response_fatal_bad_response` $\rightarrow$ `BadResponseError`). - * Non-recoverable request failures: `:request_retries_exhausted` in `:transmission_sending`, and `:request_timeout` in `:starting` or `:recovery`. - * Global deadline expiration: `:global_deadline_exceeded` $\rightarrow$ `DeadlineExceededError < Gapic::Common::Error`. - * Session cancellation: `:complete_cancellation` $\rightarrow$ `UploadCancelledError < Gapic::Common::Error`. -* **Actionable description and metadata propagation**: - * Wrapped errors (`event.error`) de-prefix `Gapic::Rest::Error::REST_ERROR_PREFIX` and format with appropriate prefixes: - * `UploadRejectedError`: `"Upload rejected by server with HTTP #{status_code} #{status_name}: #{inner_message}"`. - * `BadResponseError`: `"Resumable upload failed with HTTP #{status_code} #{status_name}: #{inner_message}"`. - * Preserves `status_code`, `status`, `details`/`status_details`, `headers`/`header`, and `response_body` (on `UploadRejectedError` and `BadResponseError`) end-to-end. - * Fallback without `event.error` names the HTTP status code and status, appending detailed header `(X-Goog-Upload-Status: '...')`. -* **Actionable description on unexpected HTTP response**: - * Unmatched event (HTTP 200 `Status: final` while in `:transmission_sending`) raises `InvalidTransitionError` with human phrasing (`"Resumable upload failed while sending a chunk of data: received an unexpected HTTP 200 response (X-Goog-Upload-Status: 'final')."`) and attaches `err.response`, `err.event`, `err.state`. -* **Missing status header formatting in error**: - * Unexpected response lacking `X-Goog-Upload-Status` formats header description as `(X-Goog-Upload-Status: missing)`. -* **Actionable description for non-HTTP unexpected events**: - * Stream chunk read while in `:starting` raises message stating `"initiating upload session: received unexpected stream chunk read (512 bytes, eof: false)"` with `err.response == nil`. -* **Resume Handle & Error Metadata (`HasResumeHandle`)**: - * `HasResumeHandle` mixin inclusion verified on `BadResponseError`, `DeadlineExceededError`, `UnseekableStreamError`, `InvalidTransitionError`, `StreamMismatchError`, and `RequestFailedError`. - * `HasResumeHandle` explicitly refuted on terminal dead-session errors (`UploadRejectedError`, `UploadCancelledError`). - * `Rules.resume_handle_from`: Returns `nil` when state is `nil`, `upload_url` is `nil`, or status is `:rejected`, `:cancelled`, or `:success` (completed uploads are not resumable); returns populated `ResumeHandle` with `upload_url` and `chunk_size` when established. - * Resumable error suffix: When `resume_handle` is present, uniform suffix `" (upload session is resumable: see #resume_handle)"` is appended to the message on `DeadlineExceededError`, `BadResponseError`, `InvalidTransitionError`, `UnseekableStreamError`, `StreamMismatchError`, and `RequestFailedError`. - * Suffix omission: When `resume_handle` is `nil` (e.g. before session creation), error message omits the resumable suffix. - * Terminal dead sessions: `UploadRejectedError` and `UploadCancelledError` do not respond to `:resume_handle` and do not include the suffix. - * `StreamMismatchError` & `RequestFailedError`: Verified with `.new` and `.from`, ensuring `cause`, `resume_handle`, and formatted messages with/without handle. - ---- - -### 3.3 Driver Stream Buffering & Realignment (`driver_buffer_test.rb`) - -#### A. Stream Reading (`Driver#execute_fill_buffer`) -* **Short reads**: `ChunkedStream` returning at most 20 bytes per read call repeatedly accumulates until buffer hits target 100 bytes (`bytes_buffered: 100, eof: false`). -* **EOF at target boundary**: Stream with exactly 100 bytes for a 100-byte target stops reading once target is satisfied; `eof` remains `false` until subsequent read. -* **EOF mid-fill**: Stream with 45 bytes for a 100-byte target detects EOF, returns `bytes_buffered: 45, eof: true`, and stores 45 bytes in buffer. -* **Empty stream**: 0-byte stream returns `bytes_buffered: 0, eof: true` with empty buffer. - -#### B. Buffer Realignment (`Driver#execute_realign_buffer`) -* **Trim within buffer**: - * *Exact beginning*: `server_offset` matching buffer start leaves buffer intact. - * *Middle*: `server_offset` in middle slices buffer and updates start offset. - * *Exact end*: `server_offset` at end empties buffer and updates start offset. -* **Rewind stream**: - * *Seekable*: Rewinds stream position and resets buffer to target offset. - * *Unseekable*: Raises `UnseekableStreamError` with target and current buffer offsets in message. If `upload_url` is established in `Driver#resume_handle`, attaches `resume_handle` and appends the uniform resumable suffix. -* **Fast-forward stream**: - * *Seekable*: Seeks stream forward and resets buffer to target offset. - * *Unseekable*: Reads and discards needed bytes from stream to advance to target offset. -* **Stream mismatch errors (`StreamMismatchError`)**: - * *Fast-forward unexpected EOF*: Unexpected EOF while discarding bytes from an unseekable stream raises `StreamMismatchError` with `resume_handle` and the uniform resumable suffix. - * *Server offset exceeding upload size*: Server reporting an offset exceeding known `upload_size` raises `StreamMismatchError` with `resume_handle` and the uniform resumable suffix. - * *Server offset exceeding stream size on seekable stream with unknown upload size*: When `upload_size` is `nil` and the seekable stream responds to `:size`, server offset exceeding `stream.size` raises `StreamMismatchError` with `resume_handle` and the uniform resumable suffix (guarding against Ruby's seek beyond EOF). -* **Driver Session Snapshot (`Driver#resume_handle`)**: - * Returns `nil` before upload session URL is established. - * Returns `ResumeHandle` snapshot during active upload progression. - ---- - -### 3.4 Driver Network Error Mapping (`driver_error_mapping_test.rb`) - -* **`Driver#rescue_request_error`**: - * `Gapic::Rest::DeadlineExceededError` $\rightarrow$ `Event::RequestFailed(kind: :timeout)` preserving error message and `source_error`. - * `Gapic::Rest::Error` with HTTP status code $\rightarrow$ `Event::HttpResponse(status:, headers:, body:)`. - * `Gapic::Rest::Error` without status code $\rightarrow$ `Event::RequestFailed(kind: :connection_failed)`. - * `StandardError` (`RuntimeError`) $\rightarrow$ `Event::RequestFailed(kind: :connection_failed)`. -* **`Driver#rescue_faraday_error`**: - * `Faraday::Error` with response hash $\rightarrow$ `Event::HttpResponse(status: 400, headers:, body:)`. - * `Faraday::TimeoutError` $\rightarrow$ `Event::RequestFailed(kind: :timeout)`. - * `Faraday::ConnectionFailed` $\rightarrow$ `Event::RequestFailed(kind: :connection_failed)`. - * Generic `Faraday::Error` without response $\rightarrow$ `Event::RequestFailed(kind: :retries_exhausted)`. -* **Integration verification**: All mappings verified both directly and end-to-end through `Driver#make_post_request` via `FailingClientStub`. - ---- - -### 3.5 Retry Policies & Extraction (`retry_policies_test.rb`, `driver_retry_policy_test.rb`) - -#### A. Header & Status Extraction (`RetryPolicies.extract_headers`, `RetryPolicies.extract_status_code`) -* `extract_headers`: Extracts from `#headers`, `#response_headers`, and Faraday `#response[:headers]`. Returns `nil` when no headers present. -* `extract_status_code`: Extracts integer status from `#status_code`, Faraday `#response[:status]`, `#response_status`, and `#status`. - -#### B. $3 \times 3$ Policy Matrix (`policy.retry_error?`) -| Policy | Headers Present, NO Upload-Status | Headers Present, WITH Upload-Status | NO Headers | -| :--- | :--- | :--- | :--- | -| **`default_start`** | **Retried for non-fatal** (`true` across 503, 400, 200, empty string header); **Refuted** (`false`) for fatal status codes (401, 403, 404, 405, 410, 413, 415) across response doubles and `Gapic::Rest::Error`. | **Falls back to codes**: retries 503; refutes 400 and no code. | **Falls back to codes**: retries 503; refutes 400 and no code. | -| **`default_control_plane`** | **Falls back to codes**: retries 503; refutes 400 and no code. | **Falls back to codes**: retries 503; refutes 400 and no code. | **Falls back to codes**: retries 503; refutes 400 and no code. | -| **`default_data_plane`** | **Unretriable** (`false`) across 503, 400, empty string header, and no code (triggers Cat 2 recovery). | **Falls back to codes**: retries 503; refutes 400 and no code. | **Falls back to codes**: retries 503; refutes 400 and no code. | - ---- - -### 3.6 Progress Notification Dispatching (`driver_progress_test.rb`) - -* **Safe no-op without callback**: `on_progress: nil` executes without raising. -* **Happy path**: Callback receives a `Progress` instance containing `bytes_uploaded` and `total_bytes` once per instruction. -* **Pass-through of `total_bytes: nil`**: `total_bytes` passed as `nil` when upload size is unspecified. -* **Unswallowed callback error propagation**: Exceptions raised within `on_progress` are not swallowed or caught; they immediately propagate to the caller in both `execute_notify_progress` and `Driver#run`. - ---- - -### 3.7 Driver Upload Execution Loop (`driver_test.rb`) - -* **Multi-chunk upload**: Multi-chunk stream uploads with active status headers succeed and return the final response body String. -* **Protocol recovery during chunk upload**: Missing status header on chunk response triggers `query` recovery and resumes chunk transmission from the server-confirmed offset. -* **Scripted resume upload (`test_resume_upload_success`)**: Resuming an existing session via `ResumeUploadConfig` dispatches `Event::ResumeUpload`, executes `query` command, fast-forwards stream to server-reported offset, and transmits remaining chunks with accurate `Progress` notifications. -* **Resume recovery retry (`test_resume_upload_with_409_recovery_retry`)**: HTTP 409 active response to recovery query triggers `:retry_recovery` retry query and successfully resumes once query returns 200 active. - ---- - -### 3.8 Driver Initiation & Query Retries (`driver_retry_test.rb`) - -* **Session initiation retry loop**: Missing status header on HTTP 200 during `start` triggers `start_retry_policy` and succeeds upon header arrival. -* **Initiation retry exhaustion on 200**: Continuous missing status headers on HTTP 200 during `start` exhaust retries and dispatch `Event::RequestFailed(kind: :retries_exhausted)`, raising `RequestFailedError` with message `"Missing X-Goog-Upload-Status header in start response"` and root cause `BadResponseError`. -* **Initiation retry exhaustion on non-200**: Continuous non-200 HTTP responses (e.g. 503) lacking status header exhaust retries and return `Event::HttpResponse` directly, allowing `Rules` to raise `BadResponseError` preserving the HTTP status code and message. -* **Control plane non-retry**: Missing status header on `query` does not retry inside `execute_send_query`, returning `Event::HttpResponse` immediately to drive protocol recovery. - ---- - -### 3.9 Driver Configuration & Deadlines (`driver_config_test.rb`) - -* **Explicit positive timeout precedence**: `resolve_timeout` returns `config.timeout` when strictly positive. -* **Zero or negative timeout handling**: Zero or negative `config.timeout` is treated the same as `nil` (unset), falling back to size-based or `BASE_TIMEOUT` resolution. -* **Size-proportional timeout above base floor**: Large `upload_size` computes timeout as `upload_size.fdiv(MIN_ASSUMED_THROUGHPUT)`. -* **Base timeout floor for small uploads**: Small `upload_size` floors at `BASE_TIMEOUT` (`3_600` seconds). -* **Default base timeout when size is nil**: Unspecified `upload_size` defaults to `BASE_TIMEOUT`. -* **Deadline expiration enforcement**: Monotonic clock exceeding `@deadline` during `Driver#run` triggers `Event::GlobalDeadlineExceeded` and raises `Gapic::Rest::ResumableUpload::DeadlineExceededError`. - ---- - -### 3.10 Payload & Header Abridgement (`driver/abridge_test.rb`) - -* **Binary payload hex encoding & abridgement (`Driver::Abridge.bytes`)**: - * `nil` returns `nil`; short payloads (< 64 bytes, including 63-byte boundary) are full-hex-encoded via `unpack1("H*")`. - * Payloads $\ge 64$ bytes are abridged to the first 32 bytes in hex followed by total byte size (`"<32-byte hex>... <100 bytes>"`). -* **Error body sanitization (`Driver::Abridge.error_body`)**: - * Truncates error response strings to at most 512 bytes. - * Forces UTF-8 encoding and scrubs invalid byte sequences (`\xFF\xFE`) so malformed error payloads never raise encoding errors during log serialization. -* **URL query parameter elision (`Driver::Abridge.url`)**: - * Parses URIs and replaces every query parameter value with `<...>` (`uploadType=<...>&sid=<...>`) so capability session IDs never leak into logs. -* **Header allowlisting (`Driver::Abridge.headers`)**: - * Preserves values for headers prefixed with `x-goog-upload-` (case-insensitive) and abridges URLs in `x-goog-upload-url`. - * Redacts all other headers (`Authorization`, `Content-Type`, custom metadata) to `"<...>"`. -* **Instruction summarization (`Driver::Abridge.instructions`)**: - * Summarizes emitted instruction structs (`SendStart`, `SendChunk`) into hashes with abridged URLs and metadata while omitting raw request body payloads. - ---- - -### 3.11 Structured Upload Log Helper (`driver/upload_log_test.rb`) - -* **Bijective recipe coverage (`test_lifecycle_table_matches_rules_recipes`)**: - * Verifies that `UploadLog::LIFECYCLE.keys + UploadLog::SILENT_RECIPES` equals `Rules::RECIPES` with zero unmapped recipes and zero overlap between active and silent lists. -* **State machine decision logging (`UploadLog#decision`)**: - * Emits `DEBUG` entries containing `uploadId`, `fromStatus`, `shape`, `recipe`, `toStatus`, `offset`, `inFlightLength`, and abridged `instructions`. -* **Lifecycle milestone logging (`UploadLog#lifecycle`)**: - * Emits `INFO` entries for session milestones (`:start_session` with `uploadSize` and `requestedChunkSize`), `DEBUG` for per-chunk transmission (`:send_chunk`), and `WARN` for terminal failures (`:fail_with_rejected` with `error` field). - * Asserts silent recipes (`:ack_chunk`) emit no lifecycle log entries. -* **Wire trace logging (`wire_send`, `wire_receive`, `wire_failure`)**: - * `wire_send` logs `DEBUG` with HTTP verb, abridged URL, redacted headers, `startAttempt`, `bodySize`, and hex-encoded/abridged body. - * `wire_receive` logs `DEBUG` with HTTP status code, parsed `uploadStatus`, optional `errorStatus` from `event.error.status`, `sizeReceived`, `granularity`, and abridged body (using `event.error.message` when HTTP $\ge 400$ and present). - * `wire_failure` logs `DEBUG` with failure classification `kind` and exception message. -* **Buffer realignment logging (`UploadLog#buffer_realign`)**: - * Logs `DEBUG` on normal realignment and additionally emits a `WARN` entry (`"Server offset rewind on unseekable stream"`) with `action`, `serverOffset`, and `currentOffset` when rewinding an unseekable stream. -* **Unmatched state transition logging (`UploadLog#unmatched_transition`)**: - * Emits a `WARN` entry capturing current `status`, event `shape`, and exception `error` message before `InvalidTransitionError` propagates. - ---- - -### 3.12 End-to-End Driver Logging & Corpus Invariants (`driver_logging_test.rb`) - -* **Shared session correlation & RPC method names (`test_all_entries_share_upload_id_and_pass_method_names`)**: - * Verifies every log entry emitted during `Driver#run` shares a single non-nil UUIDv4 `uploadId`. - * Verifies `Driver` passes explicit `method_name` strings (`"ResumableUpload.start"`, `"ResumableUpload.upload"`) to `ClientStub#make_post_request`. -* **Multi-chunk upload lifecycle (`test_multi_chunk_upload_logs_lifecycle_entries`)**: - * Confirms multi-chunk upload emits `INFO` lifecycle entries for `start_session`, `begin_transmission`, and completion while suppressing per-chunk `ack_chunk` at `INFO`. -* **Resume upload lifecycle (`test_resume_upload_logs_resume_session_entry`)**: - * Confirms resume upload emits `INFO` lifecycle entry for `resume_session` with message `"Resuming upload session"`, abridged `uploadUrl`, and `chunkSize`. -* **Protocol recovery logging (`test_recovery_scenario_logs_enter_recovery_and_realign`)**: - * Simulates HTTP 503 during chunk upload followed by recovery query; asserts `INFO` logs include both `enter_recovery` and `realign_from_recovery`. -* **Terminal failure & unmatched transition logging (`test_fatal_failure_logs_warn_with_fail_with_recipe`, `test_unmatched_transition_logs_warn_and_reraises`, `test_lifecycle_warn_includes_response_body_for_rejected_error`, `test_lifecycle_warn_includes_response_body_for_bad_response_error`, `test_lifecycle_warn_omits_response_body_when_error_lacks_it`, `test_error_info_reason_in_details_survives_in_error_and_logs`)**: - * Confirms fatal HTTP 403 rejection emits `WARN` with a `fail_with_*` recipe, unexpected Core state transitions emit `WARN` prior to raising `InvalidTransitionError`, terminal failure entries capture abridged `responseBody` when present on `last_error` (or omit it when absent), and unpacked `Google::Rpc::ErrorInfo` reason in `details` survives through the raised error and lifecycle warning logs. -* **End-to-end secret redaction (`test_full_log_corpus_redacts_secrets`)**: - * Executes a 16 MiB two-chunk upload containing a sentinel secret (`"SECRET-123456"`) in the stream payload, session query URL (`sid=SECRET-123456`), initiation query token (`token=SECRET-123456`), and `Authorization: Bearer SECRET-123456` header. - * Asserts the sentinel string is completely absent across the entire serialized log corpus. -* **Bounded log corpus size (`test_full_log_corpus_size_under_64kib`)**: - * Asserts that the total serialized byte size of all log entries emitted across a 16 MiB multi-chunk upload run is strictly under 64 KiB (65,536 bytes). - ---- - -### 3.13 Resumable Upload Session (`session_test.rb`) - -* **Initialization & argument validation (`test_initialize_mandatory_arguments`, `test_initialize_defaults`)**: - * Asserts missing any mandatory keyword (`client_stub`, `stream`, `initial_url`) raises `ArgumentError`. - * Verifies defaults (`initial_body: nil`, `initial_headers: {}`, optional configs defaulting to `nil`, `upload_size:` explicit). -* **Observable states & lifecycle (Two-State Model)**: - * *Unbound (`test_initial_unbound_state`)*: - * Verifies `bound?`, `resumable?`, and `running?` return `false`, and `upload_url` / `resume_handle` return `nil`. - * *Single-run contract on start (`test_start_transitions_to_bound`, `test_start_when_already_bound_raises_session_state_error`, `test_resume_when_already_bound_after_start_raises_session_state_error`)*: - * Verifies calling `session.start` transitions session to `bound? == true`. - * Asserts calling `session.start` or `session.resume` again on an already bound session raises `SessionStateError` ("Session has already executed a run"). - * *Single-run contract on resume (`test_resume_transitions_to_bound`, `test_resume_when_already_bound_after_resume_raises_session_state_error`, `test_start_when_already_bound_after_resume_raises_session_state_error`)*: - * Verifies calling `session.resume` transitions session to `bound? == true`. - * Asserts subsequent calls to `resume` or `start` raise `SessionStateError` ("Session has already executed a run"). - * *Resumability & terminal states (`test_start_successful_upload_transitions_to_bound_not_resumable`, `test_failed_upload_remains_resumable`)*: - * Verifies completed uploads are not resumable: after upload success, `bound?` is `true`, `resumable?` is `false`, and `resume_handle` is `nil`. - * Verifies that when a run fails with a recoverable error, `bound?` is `true`, `resumable?` is `true`, and `resume_handle` is present. -* **Resume precondition & argument shape**: - * *Stream byte-0 precondition (`test_resume_with_non_zero_stream_pos_raises_argument_error`, `test_resume_with_zero_stream_pos_succeeds`, `test_resume_with_unseekable_stream_trusts_caller`)*: - * Asserts calling `resume` when `stream.pos != 0` raises `ArgumentError` ("Input stream must be at byte 0 to resume; rewind the stream before resuming"). - * Verifies calling `resume` when `stream.pos == 0` or on unseekable streams without `:pos` succeeds. - * *Bare resume rejection (`test_bare_resume_raises_argument_error`)*: - * Asserts calling `session.resume` with no arguments raises `ArgumentError`. - * *Resume mutually exclusive forms (`test_resume_with_upload_url_and_chunk_size`, `test_resume_with_resume_handle`)*: - * Verifies `session.resume(upload_url:, chunk_size:)` and `session.resume(resume_handle:)` execute successfully. - * *Argument mixing & validation (`test_resume_mixing_arguments_raises_argument_error`, `test_resume_missing_chunk_size_with_upload_url_raises_argument_error`)*: - * Asserts mixing `resume_handle` with `upload_url` or `chunk_size`, or passing `upload_url` without `chunk_size`, raises `ArgumentError`. -* **Cross-session resumption (`test_cross_session_resumption_flow`)*: - * Simulates Session 1 failing with a recoverable error and capturing `resume_handle`. - * Rewinds stream to 0, creates Session 2, and invokes `session2.resume(resume_handle: handle)`. - * Verifies Session 2 successfully resumes and completes the upload. -* **Concurrency & running guard (`test_running_guard_prevents_concurrent_runs`)**: - * Blocks `client_stub.make_post_request` via synchronizing `Queue`s during `session.start`. - * Asserts `session.running?` is `true` while execution is blocked. - * Asserts concurrent invocations of `session.resume` and `session.start` from another thread raise `SessionStateError` ("A run is already in progress for this session"). - * Unblocks the worker thread, confirms run completes, and asserts `session.running?` transitions to `false`. -* **Driver `#upload_url` verification (`test_driver_upload_url_across_statuses`)**: - * Confirms `Driver#upload_url` returns the raw state upload URL across `:active`, `:success`, `:rejected`, and `:cancelled` states (while `Driver#resume_handle` correctly returns `nil` for `:rejected`, `:cancelled`, and `:success`). diff --git a/gapic-common/lib/gapic/rest/resumable_upload/core.rb b/gapic-common/lib/gapic/rest/resumable_upload/core.rb index bf82a26..24b010a 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/core.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/core.rb @@ -27,7 +27,7 @@ module ResumableUpload # # The middle tier of the three-tier design: `Driver` executes side effects, {Rules} decides transitions, # and Core holds the {State} between the two. See {Rules} for the protocol narrative and state graph, and - # `design/implementation-guide.md` section 1 for the tier boundaries. + # `design/resumable_upload/implementation-guide.md` section 1 for the tier boundaries. # class Core # @private diff --git a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb index 30983d0..563171d 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/driver.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/driver.rb @@ -37,8 +37,8 @@ module ResumableUpload # The outer tier of the three-tier design. All side effects live here; all protocol decisions live in # {Rules}, which carries the state graph and the error category taxonomy. Category 1 transient retries # are absorbed here by `Gapic::Common::RetryPolicy` and never reach {Core}. See - # `design/implementation-guide.md` section 2.5 for the buffer and stream position invariants, and - # section 6.3 for the deadline model. + # `design/resumable_upload/implementation-guide.md` section 2.5 for the buffer and stream position + # invariants, and section 6.3 for the deadline model. # # rubocop:disable Metrics/ClassLength class Driver diff --git a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb index bb0f8fb..90cc14f 100644 --- a/gapic-common/lib/gapic/rest/resumable_upload/rules.rb +++ b/gapic-common/lib/gapic/rest/resumable_upload/rules.rb @@ -109,8 +109,8 @@ module ResumableUpload # * `recovery` re-queries on `:response_cat2` with no attempt cap. Termination is guaranteed only by the # global deadline the Driver enforces, not by anything in this module. # - # See `design/implementation-guide.md` section 4 for the transition specification and section 6.1 for the - # error category taxonomy this module implements. + # See `design/resumable_upload/implementation-guide.md` section 4 for the transition specification and + # section 6.1 for the error category taxonomy this module implements. # # rubocop:disable Metrics/ModuleLength module Rules @@ -131,7 +131,7 @@ module Rules # * **Category 3 (terminal)** - structurally invalid, unauthorized, rejected, or out of budget. # Resolved by transitioning to `:error` or `:rejected` and emitting `Instruction::TerminateFailure`. # - # See `design/implementation-guide.md` section 6.1 for the full classification. + # See `design/resumable_upload/implementation-guide.md` section 6.1 for the full classification. ## # @private From 076ab0bd6ad400ac5e63c8c3513fa6d104910c10 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Tue, 15 Sep 2026 06:14:09 +0000 Subject: [PATCH 77/79] chore:undo year change --- gapic-common/lib/gapic/common/error.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gapic-common/lib/gapic/common/error.rb b/gapic-common/lib/gapic/common/error.rb index 6d2646c..26bd430 100644 --- a/gapic-common/lib/gapic/common/error.rb +++ b/gapic-common/lib/gapic/common/error.rb @@ -1,4 +1,4 @@ -# Copyright 2026 Google LLC +# Copyright 2022 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. From c09848788d92e15787494970985d7185eb3d9752 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Tue, 15 Sep 2026 06:15:46 +0000 Subject: [PATCH 78/79] chore: mark a constant private --- gapic-common/lib/gapic/rest/error.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/gapic-common/lib/gapic/rest/error.rb b/gapic-common/lib/gapic/rest/error.rb index 357b3b3..278ba66 100644 --- a/gapic-common/lib/gapic/rest/error.rb +++ b/gapic-common/lib/gapic/rest/error.rb @@ -22,6 +22,7 @@ module Gapic module Rest # Gapic REST exception class class Error < ::Gapic::Common::Error + # @private REST_ERROR_PREFIX = "An error has occurred when making a REST request".freeze # @return [Integer, nil] the http status code for the error From e8a5a65bea34ad2c8935b3da872d94354f1078b1 Mon Sep 17 00:00:00 2001 From: Viacheslav Rostovtsev Date: Tue, 15 Sep 2026 06:30:28 +0000 Subject: [PATCH 79/79] fix: toys --- gapic-common/.toys.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gapic-common/.toys.rb b/gapic-common/.toys.rb index 0b16a3f..142795f 100644 --- a/gapic-common/.toys.rb +++ b/gapic-common/.toys.rb @@ -28,7 +28,7 @@ t.fail_on_undocumented_objects = false # TODO: Fix so this can be enabled t.bundler = true end -alias_tool :yard, :yardoc +tool :yard, delegate_relative: :yardoc expand :gem_build