Skip to content

fix(gax): enforce the call deadline on the REST transport - #9334

Open
shivanee-p wants to merge 4 commits into
shivaneep-o11y-tracer-helper-updatesfrom
shivaneep-o11y-rest-timeout
Open

shivanee-p wants to merge 4 commits into
shivaneep-o11y-tracer-helper-updatesfrom
shivaneep-o11y-rest-timeout

Conversation

@shivanee-p

@shivanee-p shivanee-p commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Update bug where REST calls didn't enforce deadlines. Since oTel spans were bound to the lifetime of calls, it's discovered that when the fallback service stub is used, it's not verified that the call is completed
Fixes:

  • enforce deadline from callOptions.deadline
  • record why the request was aborted
  • prevent metadata from being dropped from wrong type definition

`addTimeoutArg` computes `options.deadline` for every call and
`CallSettings.timeout` defaults to 30s, but the fallback stub never read it.
gRPC enforces its own deadline and cancels with DEADLINE_EXCEEDED; REST did
not, so an endpoint that accepted the connection and then went quiet left the
request outstanding forever, stranding the promise or callback waiting on it,
and any span bound to that callback with it.

The deadline was already being passed in. The stub's parameter names were
inverted relative to gax's `UnaryCall` order: the third argument is what gRPC
calls `options` and is the one carrying the deadline, but it was named
`_metadata` and discarded, while the second carries the metadata that becomes
request headers and was named `options`. Rename both so the argument that
matters is identifiable.

Forward the remaining time to gaxios as `timeout`. gaxios v7 arms an
`AbortSignal.timeout` and merges it with the existing cancel signal through
`AbortSignal.any`, so `cancel()` is unaffected. An already-expired deadline
clamps to 1ms rather than 0, because gaxios reads `timeout: 0` as "no timeout"
and would otherwise silently drop the bound in the case that most needs it.

Translate the resulting abort into a GoogleError carrying
Status.DEADLINE_EXCEEDED. Nothing downstream understands a DOMException named
TimeoutError: `retryCodes` matching, caller `err.code` checks and the tracer's
`error.type` all key off the numeric gRPC status. The translation is skipped
when no deadline was forwarded, so an unrelated timeout is never relabelled,
and the AbortError from `cancel()` keeps its existing handling.

Server-streaming RPCs are excluded. The signal stays armed once the response
body starts flowing, so forwarding the deadline would abort a healthy
long-lived stream mid-read. That leaves REST streams unbounded where gRPC
bounds them; closing that gap is user-visible and belongs in its own change.

Six tests cover the forwarding, both cases where no timeout should be set, the
expired-deadline clamp and the error translation. Each was mutation-tested by
deliberately breaking the corresponding behavior and confirming the matching
assertion fails.
@shivanee-p
shivanee-p requested a review from a team as a code owner September 15, 2026 01:50
@github-actions
github-actions Bot requested a review from feywind September 15, 2026 01:50
@shivanee-p
shivanee-p added this pull request to stack #9335 September 15, 2026 01:51
@shivanee-p
shivanee-p requested review from quirogas and removed request for a team and feywind September 15, 2026 01:52

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request implements call deadline forwarding for fallback service stubs in gax, translating timed-out requests into DEADLINE_EXCEEDED errors to align with gRPC behavior, and adds comprehensive unit tests. The review feedback suggests making the error type-checking more robust to handle different execution contexts, and correcting a type mismatch and potential runtime bug where metadata values are accessed as arrays but typed as strings, which could lead to truncated header values.

Comment thread core/packages/gax/src/fallbackServiceStub.ts Outdated
Comment thread core/packages/gax/src/fallbackServiceStub.ts Outdated
Comment thread core/packages/gax/src/fallbackServiceStub.ts
@shivanee-p
shivanee-p marked this pull request as draft September 15, 2026 01:53
The previous commit renamed the parameters on the exported
`FallbackServiceStub` interface and narrowed the third one from `{}` to
`{deadline?: Date}`. That is a public type change: an object literal passed as
the third argument by downstream code would now trip excess-property checks,
and implementors of the interface would see a narrower contract. Neither is
needed to enforce the deadline.

Restore the interface to its original shape. The accurate parameter names stay
inside `generateServiceStub`, where they are an implementation detail, with a
comment recording that the interface declares the middle two arguments the
other way round. That inversion is why the deadline-bearing argument read as
metadata and went unused for so long.

Also add a test that drives the call through `createApiCall` rather than
handing the stub a deadline directly. The existing tests all fabricate
`{deadline}` themselves, so they cover the stub but not the hand-off from
`addTimeoutArg`, which is the seam where the deadline was actually being
dropped. Commenting out the assignment in `addTimeoutArg` leaves all six of
them passing and fails only the new one.
The DEADLINE_EXCEEDED translation added in cd42784 never ran. It
decided whether a deadline had expired by inspecting the error, and the
error it was written against does not occur.

Measured end to end against a server that accepts the connection and
then never replies:

  rejected after 1513ms (timeout was 1500ms)
    constructor : GaxiosError
    name        : Error
    code        : undefined
    message     : The operation was aborted.
    cause.name  : AbortError

node-fetch discards signal.reason and throws its own AbortError. gaxios
wraps that in a GaxiosError, which never sets its own `name` (so it
stays the inherited 'Error') and copies `code` only from a DOMException
cause, which this is not. So the predicate's `instanceof Error` guard
was not merely fragile across realms or for serialized errors, it was
gating on evidence that never arrives. Every timeout returned the raw
transport error.

Worse, the evidence cannot be made to arrive. A deadline expiry and a
caller's cancel() produce byte-identical errors: same constructor, same
name, same undefined code, same cause.name. No amount of sniffing can
separate them, because the distinction does not exist in the error. It
exists only in the caller, which armed the timer.

So arm it explicitly. Rather than hand `timeout` to gaxios and let it
build the AbortSignal internally, build the same signal here, set a
flag when it fires, and merge it with the cancel signal. The request
behaves identically; the difference is that we now know which of the
two aborts happened. The predicate is gone, along with the error
inspection it existed to do.

The clamp moves from Math.max(1, ...) to Math.max(0, ...). Passing 0 to
gaxios meant "no timeout", but AbortSignal.timeout(0) aborts on the
next tick, which is the right answer for a deadline that has already
passed. Negative values still have to be clamped: AbortSignal.timeout
throws RangeError on them.

This also fixes the cancel guard in the body-read handler, which tested
`err.name !== 'AbortError'`. For the reason above that name is 'Error',
so the check never matched and a cancelled call still reported an
error. It now uses the recorded state.

The outer handler deliberately keeps no cancel suppression, matching
its previous behavior; whether a cancelled call should report CANCELLED
the way gRPC does is a separate, user-visible question.

Tests: the fixtures fabricated a TimeoutError that production never
produces, which is exactly why mutation-tested unit tests still passed
against dead code. They now assert on the signal the stub owns and
reject with the measured error shape. Two cases were added that the old
approach could not have satisfied: cancelling a call that has a
deadline armed must not report DEADLINE_EXCEEDED, and a cancelled call
must not report an error at all.

Each of the six behaviors was mutation-tested by breaking it and
confirming the corresponding assertion fails: the timeout flag never
set, the old err.name guard restored, the timeout signal not attached,
server streams bounded, the expired-deadline clamp removed, and the
translation applied without checking the flag. All six were caught.

Verified against a real silent server through the real stub: a direct
call with a 1500ms deadline settles at 1507ms with code 4, the same
call through createApiCall with CallSettings.timeout 1200ms settles at
1203ms with code 4, and a cancel() with a 60s deadline armed settles at
53ms and is left untranslated.
The metadata parameter was typed `{[name: string]: string}` while the
code read it as `metadata[key][0]`. Both could not be right, and the
disagreement was hiding two silent data-loss bugs.

gRPC metadata is multi-valued. `buildMetadata` normalizes every value
to an array for exactly that reason, with the comment "Since gRPC
expects each header to be an array, we are doing the same for fallback
here", and it appends when a header appears more than once. Reading
index 0 discarded everything after the first value.

Measured through the real stub with a recording transport:

  buildMetadata output          what the stub sent
    x-multi  = ["a","b","c"]      x-multi  = "a"
    x-plain  = "hello"            x-plain  = "h"

The second row is the type error made visible: indexing a string
yields its first character, so a caller who passed a plain string, as
the declared type invited, silently sent one character of it.

Widen the type to `string | string[]` and handle both. Arrays are
appended so all values survive; the Headers API joins them with ', '.
The delete before appending preserves the previous semantics, where
`set` replaced whatever the request encoder had put there rather than
accumulating onto it.

The exported `FallbackServiceStub` interface is unchanged. It types
this parameter as `{}`, which already permits both shapes.

Four mutation tests, all caught: restoring the original single-value
read fails the multi-valued and plain-string assertions, appending
without clearing fails the override assertion, sending only element
zero fails the multi-valued assertion, and indexing a plain string
fails the plain-string assertion.
@shivanee-p
shivanee-p marked this pull request as ready for review September 15, 2026 02:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant