Errors

execute returns a typed Response envelope rather than raising on GraphQL errors — so partial data and top-level extensions (cost, throttle) survive. execute! is the shortcut when you just want the result:

PersonQuery.execute!(id: "1")   # => Result, or raises QueryError  (== execute(...).data!)

response = PersonQuery.execute(id: "1")   # => GraphWeaver::Response[Result]
response.data           # T.nilable(Result) — typed, present even on partial success
response.errors         # Array[GraphWeaver::GraphQLError]
response.errors?        # any top-level errors?
response.extensions     # { "cost" => … } — rides on success too
response.data!          # the Result, or raise GraphWeaver::QueryError

The envelope is a single generic GraphWeaver::Response[Result]response.data stays fully typed to this query's result, no per-query wrapper class.

Every GraphQLError exposes #message, #locations, #path, #extensions, and #code (extensions["code"]) — match on the code, not the message string (response.errors.first.code == "THROTTLED").

Everything GraphWeaver raises descends from GraphWeaver::Error, split by where it failed:

Class When
TransportError never reached the server — DNS, connection refused, TLS, timeout
ServerError reached it, non-2xx HTTP — #status, #body
QueryError 200 body with top-level GraphQL errors — #errors, #data, #extensions, #codes
TypeError the response wouldn't cast into the generated structs — #struct, #cause
ValidationError build time: the query didn't validate against the schema
begin
  person = PersonQuery.execute!(id: "1").person
rescue GraphWeaver::TransportError
  retry                                   # network blip
rescue GraphWeaver::ServerError => e
  e.status >= 500 ? backoff : raise       # retry 5xx; a 4xx is our bug
rescue GraphWeaver::QueryError => e
  e.codes.include?("THROTTLED") ? backoff : raise
end

Or skip the hand-rolling — Retry wraps any transport with configurable retries:

transport = GraphWeaver::Retry.new(
  GraphWeaver::Transport::HTTP.new(url),
  tries: 5,                        # total attempts
  backoff: :exponential,           # or :linear, or ->(attempt) { seconds }
  base: 0.5, max: 30,              # seconds, clamped at max:
  jitter: true,                    # randomize each delay by 50-100%
  retry_codes: ["THROTTLED"],      # also retry GraphQL errors by code
)

Defaults match the rescue block above: transport failures always retry, ServerError only on 5xx (a 4xx is your bug — retrying won't fix it; override with retry_if:), and GraphQL-level codes only when listed in retry_codes:. Exhausting tries: re-raises the last error.

Two deliberate exceptions live outside the hierarchy, because typed kwargs should fail like any Ruby method call: a wrong-typed variable raises sorbet-runtime's TypeError ("Parameter 'page': Expected type T.nilable(Integer), got type String"), and a missing required variable raises a plain ArgumentError ("missing keyword: :id").

Business/validation failures returned as data (Shopify-style userErrors { field message code }) aren't errors here — they're just fields you selected, so they deserialize onto response.data like anything else and you inspect them there.

The one-shot GraphWeaver.execute / execute! mirror this: execute returns the envelope, execute! the result-or-raise.

Extending TransportError

What counts as a TransportError is an extensible set — each transport seeds its own network exceptions (Errno::*, SocketError, timeouts, TLS; the Faraday transport adds its own), and you can register more so a custom adapter's or connection pool's failure gets the same treatment:

GraphWeaver.register_transport_error(ConnectionPool::TimeoutError)
GraphWeaver.transport_errors << MyAdapter::ResetError   # it's just a Set

Programmatic surfacing

Every error is dual-surface: #message for humans, #to_h for machines — a JSON-ready hash (error class, per-error path/code/locations/extensions) you can nest straight into a log line or an API response.

Field-level tooling lives on both Response and QueryError:

response.errors_at("person.email")      # errors touching a path (prefix match)
response.each_error do |field, errors|  # grouped by index-stripped field
  form.add_error(field, errors.map(&:message))
end

response.report
# { "person.pets.name" => {
#     "messages" => ["name hidden"], "codes" => ["PRIVATE"],
#     "entity_ids" => ["7", "9"],    # resolved by walking paths through partial data
#     "errors" => [ ...full to_h detail... ] },
#   nil => { "codes" => ["DOWN"], ... } }   # global errors under nil

GraphQLError#field strips list indices (people.3.emailpeople.email) — the stable grouping key; the raw #path keeps indices for exact location.

Stale schemas

GraphQL has no schema-version signal, so a schema change surfaces as the server rejecting your query's shape. response.schema_stale? / QueryError#schema_stale? detect validation-shaped rejections (Apollo's GRAPHQL_VALIDATION_FAILED code, or the message patterns graphql-ruby and GitHub use), and the raised message says what to do: regenerate modules and/or refresh the schema cache.

Cast failures

When wire data disagrees with the types the schema promised at generation time (a nil where non-null was declared, a malformed scalar, an unknown enum value), casting raises GraphWeaver::TypeError naming the failing generated struct, with the original exception as #cause. Simulate one in tests with GraphWeaver::Testing::FakeClient.new(schema:, corrupt: "Person.birthday") — see testing.