graph_weaver 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -0
  3. data/CHANGELOG.md +279 -0
  4. data/Gemfile.lock +75 -17
  5. data/Makefile +11 -1
  6. data/NOTES.md +1 -1
  7. data/PLAN.md +85 -22
  8. data/README.md +86 -26
  9. data/docs/cassettes.md +76 -0
  10. data/docs/errors.md +134 -0
  11. data/docs/generated_modules.md +229 -0
  12. data/docs/getting_started.md +134 -0
  13. data/docs/logging.md +33 -0
  14. data/docs/real_world.md +53 -0
  15. data/docs/scalars.md +183 -0
  16. data/docs/testing.md +103 -0
  17. data/docs/transports.md +124 -0
  18. data/graph_weaver.gemspec +10 -1
  19. data/lib/graph_weaver/client.rb +200 -0
  20. data/lib/graph_weaver/codegen/emit.rb +286 -0
  21. data/lib/graph_weaver/codegen/enum_type.rb +149 -0
  22. data/lib/graph_weaver/codegen/nodes.rb +356 -0
  23. data/lib/graph_weaver/codegen/scalar_type.rb +256 -0
  24. data/lib/graph_weaver/codegen.rb +396 -401
  25. data/lib/graph_weaver/errors.rb +322 -0
  26. data/lib/graph_weaver/hints.rb +63 -0
  27. data/lib/graph_weaver/inflect.rb +19 -0
  28. data/lib/graph_weaver/logging.rb +41 -0
  29. data/lib/graph_weaver/railtie.rb +29 -0
  30. data/lib/graph_weaver/response.rb +55 -0
  31. data/lib/graph_weaver/retry.rb +97 -0
  32. data/lib/graph_weaver/rspec.rb +59 -0
  33. data/lib/graph_weaver/schema_loader.rb +208 -8
  34. data/lib/graph_weaver/selection.rb +68 -0
  35. data/lib/graph_weaver/tasks.rb +71 -0
  36. data/lib/graph_weaver/testing/cassette.rb +224 -0
  37. data/lib/graph_weaver/testing/failure.rb +109 -0
  38. data/lib/graph_weaver/testing/fake_client.rb +228 -0
  39. data/lib/graph_weaver/testing/values.rb +98 -0
  40. data/lib/graph_weaver/testing.rb +106 -0
  41. data/lib/graph_weaver/transport/faraday.rb +56 -0
  42. data/lib/graph_weaver/transport/http.rb +87 -0
  43. data/lib/graph_weaver/transport.rb +120 -0
  44. data/lib/graph_weaver/version.rb +1 -1
  45. data/lib/graph_weaver.rb +268 -4
  46. metadata +132 -2
  47. data/lib/graph_weaver/http_executor.rb +0 -31
data/PLAN.md CHANGED
@@ -1,7 +1,7 @@
1
- # Project Plan — typed GraphQL codegen for Ruby/Sorbet
1
+ # Project Plan — GraphWeaver, typed GraphQL client for Ruby/Sorbet
2
2
 
3
- _Resume-from-here notes. The README is the lab notebook (findings); this is
4
- the plan. Update both when state changes._
3
+ _Resume-from-here notes. README documents the product, NOTES.md is the
4
+ research notebook this grew out of; this is the plan. Update on change._
5
5
 
6
6
  ## Vision
7
7
 
@@ -12,21 +12,40 @@ shape of every query result. Dynamic (eval) mode for development, build
12
12
  step for CI/static checking. Runtime deps: graphql + sorbet-runtime only
13
13
  (graphql-client is NOT a dependency; the exploration outgrew it).
14
14
 
15
- ## State: working prototype, all green
15
+ ## State: v0.0.1 on rubygems; v0.0.2 accumulating on main
16
16
 
17
- `bundle exec rspec` (35 examples) + `bundle exec srb tc` + `bin/generate`
18
- (regenerates lib/generated/ from queries/; parity specs enforce freshness).
17
+ `make check` = bin/generate (spec fixture regeneration; parity specs
18
+ enforce freshness) + rspec + srb tc.
19
19
 
20
- Language coverage: queries, mutations, typed variables (kwargs on execute,
21
- optional-when-defaulted, enum/scalar serialization), fragments (inline,
22
- named, interface conditions), union- AND interface-typed fields
23
- (__typename dispatch, required at generation time), enums (T::Enum),
24
- custom scalars (name-keyed SCALAR_CASTS / SCALAR_SERIALIZERS registries).
20
+ Language coverage: queries, mutations, typed variables (kwargs on
21
+ execute, optional-when-defaulted), fragments (inline, named, interface
22
+ conditions), union- AND interface-typed fields (__typename dispatch,
23
+ required at generation time), enums (T::Enum), custom scalars via the
24
+ ScalarType registry (register_scalar: codec inference off .parse/.load,
25
+ requires:, opt-in input coercion incl. built-ins).
25
26
  Sources: live schema / introspection JSON / SDL — byte-identical output
26
27
  (enum values + abstract-type members sorted for determinism).
27
- Transport: executor: kwarg in-process schema or HttpExecutor (e2e spec
28
- against WEBrick). Federation supergraph SDL loads transparently (needs
28
+ Transport: executor precedence per call per module baked const →
29
+ GraphWeaver.executor; Transport::HTTP (zero-dep) + opt-in Transport::Faraday
30
+ (url / block middleware / ready connection), e2e specs against WEBrick.
31
+ Errors: typed Response envelope (partial data + extensions survive) and
32
+ a GraphWeaver::Error hierarchy (Transport/Server/Query/Validation/Type)
33
+ with extensible transport-error classification, schema_stale? staleness
34
+ detection, errors_at/each_error/report field-level surfacing (entity
35
+ ids resolved from partial data), and #to_h machine output throughout.
36
+ Dynamic mode: GraphWeaver.parse (paths or raw strings, derived names,
37
+ container-scoped constants) and GraphWeaver.execute one-shots.
38
+ Schema fetching: SchemaLoader.introspect(executor, cache:, ttl:) off
39
+ live endpoints; load takes paths, content, or Hashes.
40
+ Testing (graph_weaver/testing + graph_weaver/rspec): FakeExecutor
41
+ (schema-correct castable fakes, faker semantics, GraphQL-name
42
+ overrides, corrupt:, fail_at: with null propagation), Failure canned
43
+ executors + SequenceExecutor, cassettes with shape-preserving
44
+ anonymization, rspec seed + auto_fake integration. Selection module is
45
+ the single shared query-walk (codegen/fake/anonymizer).
46
+ Federation supergraph SDL loads transparently (needs
29
47
  directive_defaults_patch until upstream fix ships).
48
+ Live validation: make integration (GitHub + Countries APIs).
30
49
 
31
50
  ## Next steps (in rough order)
32
51
 
@@ -38,21 +57,32 @@ NOTES.md. Prior-art check partially answered: graphql-client PR #7
38
57
  Jan 2024 with users asking; schema-wide typing can't catch
39
58
  unfetched-field bugs or type unions/interfaces — the niche looks open.
40
59
 
60
+ ~~Input objects~~ DONE 2026-07-11: module-level T::Structs, serialize/
61
+ to_h, hash coercion at the execute boundary.
62
+ ~~Release~~ 0.1.0 cut 2026-07-11 (breaking: execute returns the
63
+ Response envelope; execute! for raise-or-result).
64
+
41
65
  1. Stable class naming design — names come from GraphQL type names per
42
66
  selection site; must not shift when unrelated selections are added
43
67
  (generated code is app-code API). Current: one-level field-name
44
- disambiguation, then raise.
45
- 2. Input objects as variables (raise NotImplementedError today) — likely
46
- generated T::Structs with serialize.
47
- 3. CLI entrypoint (graph_weaver generate --schema X --queries dir) —
68
+ disambiguation, then raise. Shipped in 0.1.0 as-is — a naming change
69
+ is fair game pre-1.0 but should land early.
70
+ 2. CLI entrypoint (graph_weaver generate --schema X --queries dir) —
48
71
  bin/generate is spec-fixture tooling, not shipped.
49
- 4. Subscriptions (unsupported; raise).
50
- 5. First release: 0.1.0 to rubygems once naming design settles.
51
- 6. Nice-to-haves: __typename auto-injection (currently required manually
72
+ 3. Subscriptions (unsupported; raise). Recursive input types (raise).
73
+ 6. Parse/execute memoization: repeated GraphWeaver.parse/execute of the
74
+ same [schema, query] re-generates and re-evals every call (~3x the
75
+ cost of a cached module; benchmarked 2026-07-09) — memo keyed on
76
+ schema/query/name/executor, minding shared executor= mutation.
77
+ (Schema-side caching landed: SchemaLoader.introspect cache:/ttl: +
78
+ introspection_result primitive for Rails.cache et al. Possible
79
+ follow-up: re-introspect + retry once on validation-shaped
80
+ QueryErrors, since GraphQL has no standard schema-version signal.)
81
+ 7. Nice-to-haves: __typename auto-injection (currently required manually
52
82
  on abstract selections), fragment reuse across queries, directives on
53
83
  selections (@skip/@include make non-null fields nullable).
54
- 7. Tapioca DSL compiler over dynamic mode (idea from graphql-client
55
- PR #7): RBI the GraphWeaver::Codegen.load-eval'd modules so development mode
84
+ 8. Tapioca DSL compiler over dynamic mode (idea from graphql-client
85
+ PR #7): RBI the GraphWeaver.parse-eval'd modules so development mode
56
86
  gets static types without the bin/generate build step — tapioca is
57
87
  already in every Sorbet shop's workflow. Upstream's
58
88
  Tapioca::Dsl::Helpers::GraphqlTypeHelper is prior art for type mapping.
@@ -72,3 +102,36 @@ unfetched-field bugs or type unions/interfaces — the niche looks open.
72
102
  resolvers — codegen must stay name-keyed, never call schema runtime hooks
73
103
  - graphql-client (the gem) casts scalars via coerce_isolated_input and
74
104
  only with a live schema class — documented in the early specs
105
+
106
+ ## From the field-test experiments (2026-07-12)
107
+
108
+ A junior + senior agent pair exercised the repo cold (clone, examples,
109
+ extensions, a Pokedex app against Hasura's 4,441-type PokeAPI schema).
110
+ Fixed same-day: snake_case type names generated invalid constants
111
+ (camelize), GraphQL::ParseError/NotImplementedError escaping the Error
112
+ umbrella, HttpExecutor timeouts, GitHub's top-level "type" error codes,
113
+ typo'd client registrations silently no-oping. Still open:
114
+
115
+ ~~Recursive input types~~ DONE 2026-07-12: register-before-walk breaks
116
+ the recursion; emission dependency-orders the structs and, for cycles,
117
+ forward-declares the classes in an eval (srb rejects reopening a
118
+ T::Struct statically, but adding props at runtime works — the full
119
+ bodies below the eval are all srb sees). Verified live against Hasura
120
+ bool_exp.
121
+
122
+ ~~Connection reuse in Transport::HTTP~~ DONE 2026-07-12: persistent
123
+ keep-alive connection behind a mutex, dropped on any failure; net/http's
124
+ keep_alive_timeout handles idle expiry. Faraday remains the pooling
125
+ answer.
126
+
127
+ - Subscriptions; @defer/@stream (routers send multipart responses — the
128
+ transport classifies them as ServerError today, no incremental support).
129
+ - Shared input-type structs across generated modules: one Hasura
130
+ bool_exp variable pulls its whole recursive closure into EVERY module
131
+ (~28k lines each) — correct but heavy in PRs; needs cross-module
132
+ sharing or selection-based pruning (field-test round 2).
133
+ - Structured logging: log_tag pairs lines and names operations now, but
134
+ events are prose — an optional {event:, url:, ms:} payload contract, a
135
+ scrub_variables hook, and cache-age on hit lines (field-test round 2).
136
+ - Cut 0.1.1 — RubyGems 0.1.0 is materially behind main (snake_case fix,
137
+ recursive inputs, keep-alive, logging, error umbrella).
data/README.md CHANGED
@@ -19,49 +19,113 @@ query($id: ID!) {
19
19
  ```
20
20
 
21
21
  ```ruby
22
- result = PersonQuery.execute(id: "1")
22
+ result = PersonQuery.execute!(id: "1") # typed result, or raises on errors (execute returns an envelope)
23
23
 
24
24
  result.person&.name # => "Daniel" (typed String)
25
25
  result.person&.birthday # => Date (custom scalars deserialize)
26
26
  result.person&.nmae # => srb tc: Method `nmae` does not exist
27
27
  ```
28
28
 
29
- #### Features
29
+ New here? The **[getting started](docs/getting_started.md)** guide walks the
30
+ production setup end to end — initializer, codegen, fakes, CI. Or run the
31
+ **[examples](examples/)**, smallest first: `examples/countries.rb` (public
32
+ API, no auth, all dynamic), `examples/rick_and_morty.rb` (filtering,
33
+ pagination, a block-built type helper), and `examples/github/run.rb`
34
+ (auth + checked-in generated modules; it stars this repo ⭐ and introduces
35
+ you to your fellow stargazers).
30
36
 
31
- - **Queries and mutations** with typed variable kwargs — required vs optional falls out of nullability and defaults
32
- - **Fragments** (inline, named, interface conditions), **unions and interfaces** (member structs, `__typename` dispatch), **enums** (`T::Enum`), **custom scalars** (pluggable registry)
33
- - **Any schema source**: live schema class, introspection JSON, or SDL — including Apollo Federation supergraph SDL
34
- - **Any transport**: in-process schema execution (perfect for tests) or HTTP via the bundled executor — swap per call with `executor:`
35
- - **Dynamic mode** for development: `GraphWeaver::Codegen.load(...)` generates and evals on the fly, no build step
37
+ #### Features
36
38
 
37
- #### Usage
39
+ - **Queries and mutations** with typed variable kwargs — enums as `T::Enum`s, input objects as `T::Struct`s, required vs optional falling out of nullability and defaults
40
+ - **Fragments** (inline, named, type conditions), **unions and interfaces** (member structs, `__typename` dispatch), **custom scalars** (pluggable registry), `@skip`/`@include` nullability
41
+ - **Any schema source**: live schema class, introspection JSON, or SDL — including Apollo Federation supergraph SDL; introspect live endpoints with caching
42
+ - **Any transport**: in-process schema execution, the zero-dependency HTTP executor, or Faraday with your own middleware — plus a composable `Retry` (exponential/linear/custom backoff, jitter, retry-by-error-class or GraphQL code) — swap per call with `executor:`
43
+ - **Structured errors**: a typed response envelope (partial data + extensions survive), an error hierarchy split by failure site, field-level reports with entity ids, and `schema_stale?` detection — every error dual-surfaced as a human message plus JSON-ready `#to_h`
44
+ - **Testing built in**: schema-correct fakes, failure simulation, record/replay cassettes with anonymization, rspec integration
45
+ - **Dynamic mode** for development: `GraphWeaver.parse(...)` generates and evals on the fly, no build step
46
+
47
+ #### Usage
48
+
49
+ Three ways to run a query — pick by context:
50
+
51
+ | Context | Use |
52
+ |---------|-----|
53
+ | Production | checked-in codegen (`rake graph_weaver:generate`) — reviewed, `srb tc`-checked |
54
+ | Development, consoles | `client.parse` / `client.load_queries!` — no build step |
55
+ | Scripts, one-offs | `client.execute!` — no module at all |
56
+
57
+ The production path assembled is the [getting started](docs/getting_started.md);
58
+ the pieces:
38
59
 
39
60
  ```ruby
40
61
  require "graph_weaver"
41
62
 
42
- # generate from any schema source
43
- schema = GraphWeaver::SchemaLoader.load("schema.json") # or .graphql SDL, or a live class
63
+ # a client for one server: transport (Faraday when loaded), auth, and a
64
+ # lazily introspected schema. The first argument is a url or any schema
65
+ # source — a live schema class, or a .json/.graphql dump
66
+ api = GraphWeaver.new("https://api.example.com/graphql", auth: ENV["API_TOKEN"], cache: true)
44
67
 
45
- source = GraphWeaver::Codegen.new(
46
- schema:,
47
- executor_const: "MyApi::Executor",
68
+ # make it the app default — generated modules execute through it
69
+ GraphWeaver.client = api
70
+
71
+ # generate checked-in typed modules (rake graph_weaver:generate, or directly)
72
+ source = GraphWeaver::Codegen.generate(
73
+ schema: api.schema,
48
74
  query: File.read("queries/person.graphql"),
49
75
  module_name: "PersonQuery",
50
- ).generate
51
-
76
+ )
52
77
  File.write("app/queries/person_query.rb", source)
53
78
 
54
79
  # at runtime
55
- executor = GraphWeaver::HttpExecutor.new("https://api.example.com/graphql")
56
- PersonQuery.execute(id: "1", executor:)
80
+ PersonQuery.execute(id: "1") # via GraphWeaver.client
81
+ PersonQuery.execute(other_client, id: "1") # or per call
57
82
  ```
58
83
 
59
- In development, skip the build step:
84
+ Module names derive from the operation name (`query GetPerson` →
85
+ `GetPerson`) or, for `parse` on a `.graphql` file, from the file name;
86
+ pass `module_name:`/`name:` to override. Pass `client:` (a constant) to
87
+ bake a default client into the generated module. Prefer Faraday? It's
88
+ opt-in (`gem "faraday"`), and the client picks it up when loaded —
89
+ middleware blocks and ready connections in [transports](docs/transports.md).
90
+
91
+ In development, skip the build step entirely — modules from `client.parse`
92
+ carry the client's transport, no global wiring needed:
60
93
 
61
94
  ```ruby
62
- PersonQuery = GraphWeaver::Codegen.load(schema:, executor_const: "...", query:, module_name: "PersonQuery")
95
+ # parse a query into a typed module on the fly — a .graphql path or a raw string
96
+ PersonQuery = api.parse("queries/person.graphql")
97
+ PersonQuery.execute(id: "1")
98
+
99
+ # or every query file at once (queries_path convention), named like generation would
100
+ api.load_queries!
101
+
102
+ # or one-shot, no module at all — variables are plain kwargs
103
+ api.execute!("query($id: ID!) { person(id: $id) { name } }", id: "1")
63
104
  ```
64
105
 
106
+
107
+ #### Dig deeper
108
+
109
+ - **[Getting started](docs/getting_started.md)** — the production path in Rails,
110
+ step by step: initializer, rake tasks, fakes, CI, Sorbet or not
111
+ - **[Generated modules](docs/generated_modules.md)** — module anatomy, typed
112
+ variables (enums, input objects), fragments/unions/interfaces,
113
+ `@skip`/`@include`, naming, clients, dynamic mode
114
+ - **[Against a real API](docs/real_world.md)** — the exploratory tour:
115
+ introspect a live endpoint (GitHub end to end), dynamic mode, schema caching
116
+ - **[Transports](docs/transports.md)** — clients, the execute contract,
117
+ Faraday, retries and backoff
118
+ - **[Custom scalars](docs/scalars.md)** — the registry: codec inference,
119
+ requires, input coercion
120
+ - **[Errors](docs/errors.md)** — the Response envelope, the error hierarchy,
121
+ field-level reports with entity ids, stale-schema detection
122
+ - **[Logging](docs/logging.md)** — point `GraphWeaver.logger` at any Logger:
123
+ wire traffic at debug, introspection/cache/codegen at info, errors at warn
124
+ - **[Testing](docs/testing.md)** — schema-correct fakes, failure simulation,
125
+ rspec integration
126
+ - **[Cassettes](docs/cassettes.md)** — capture and replay real API
127
+ responses; anonymized recording (`GRAPHWEAVER_RECORD=1`, rake tasks)
128
+
65
129
  ----
66
130
  ## Installation
67
131
 
@@ -72,16 +136,12 @@ gem "graph_weaver"
72
136
 
73
137
  or
74
138
 
75
- ```
139
+ ```sh
76
140
  gem install graph_weaver
77
141
  ```
78
142
 
79
143
  ----
80
144
  ## Development
81
145
 
82
- `make check` — regenerate spec fixtures, run specs, typecheck.
83
-
84
- See `PLAN.md` for roadmap and `NOTES.md` for the research notebook this
85
- gem grew out of (an exploration of graphql-client internals — GraphWeaver
86
- is a standalone client, not an extension; it depends only on `graphql`
87
- and `sorbet-runtime`).
146
+ - `make check` — regenerate spec fixtures, run specs, typecheck
147
+ - `make integration` — one-off checks against the live GitHub and Countries APIs
data/docs/cassettes.md ADDED
@@ -0,0 +1,76 @@
1
+ # Cassettes: capture and replay
2
+
3
+ Cassettes record real API responses and replay them in tests — above the
4
+ transport (a client wrapping a client), so there's no HTTP
5
+ interception and they work identically over HTTP, Faraday, or in-process
6
+ execution. A cassette is a YAML file of `{query, variables, response}`
7
+ entries, matched on the normalized query + variables.
8
+
9
+ ## The workflow
10
+
11
+ ```ruby
12
+ # spec: replay when the cassette exists, record against `live` when not
13
+ cassette = GraphWeaver::Testing::Cassette.use("github", client: live)
14
+ result = RepoQuery.execute!(cassette, owner: "dpep", name: "graph_weaver")
15
+ ```
16
+
17
+ 1. **Record** — first run hits the live API and writes
18
+ `spec/cassettes/github.yml` (`Testing.config.cassette_dir` resolves bare
19
+ names).
20
+ 2. **Anonymize** — cassettes hold real data; scrub before committing (below).
21
+ 3. **Commit** — tests now run offline, fast, deterministic.
22
+ 4. **Re-record** when the API's real behavior changes:
23
+
24
+ ```sh
25
+ GRAPHWEAVER_RECORD=1 bundle exec rspec # every Cassette.use records afresh
26
+ ```
27
+
28
+ (`Testing.config.record = true` is the programmatic equivalent.)
29
+
30
+ Replaying an unrecorded request raises `MissingRecording` with the query
31
+ and the path — no silent fabrication.
32
+
33
+ ## Anonymization
34
+
35
+ Anonymizing rewrites recorded values through the same engine
36
+ [FakeClient](testing.md) uses, while preserving everything that makes
37
+ the recording faithful:
38
+
39
+ | preserved | replaced |
40
+ |-----------|----------|
41
+ | shape: keys, list lengths, null positions | strings (semantically: emails look like emails) |
42
+ | enums, booleans, `__typename` | numbers, dates |
43
+ | id *relationships* (same original id → same fake id) | the id values themselves |
44
+
45
+ Three ways to run it:
46
+
47
+ ```ruby
48
+ # 1. as recordings happen — assertions you write against the recording
49
+ # run hold on replay, and real data never touches disk
50
+ GraphWeaver::Testing.configure do |config|
51
+ config.schema = MySchema
52
+ config.anonymize = true
53
+ end
54
+
55
+ # 2. after the fact, per cassette
56
+ GraphWeaver::Testing::Cassette.new("spec/cassettes/github.yml").anonymize!(schema:)
57
+ ```
58
+
59
+ ```sh
60
+ # 3. the whole cassette_dir at once
61
+ rake graph_weaver:cassettes:anonymize
62
+ ```
63
+
64
+ Anonymization needs the schema (it walks each recorded query's selections
65
+ to know which values are enums, dates, ids...). Variables are NOT
66
+ anonymized — they're the replay matching key; don't record with secret
67
+ variables.
68
+
69
+ ## When to use what
70
+
71
+ - **FakeClient** — no recording needed; schema-correct random data.
72
+ Best default for unit tests.
73
+ - **Cassettes** — real response *shapes* from a real API (pagination
74
+ quirks, actual union members, servers' null habits). Best for
75
+ integration-ish tests and regression pinning.
76
+ - **Anonymized cassettes** — cassette fidelity, committable without PII.
data/docs/errors.md ADDED
@@ -0,0 +1,134 @@
1
+ # Errors
2
+
3
+ `execute` returns a typed **`Response` envelope** rather than raising on GraphQL
4
+ errors — so partial data and top-level `extensions` (cost, throttle) survive.
5
+ `execute!` is the shortcut when you just want the result:
6
+
7
+ ```ruby
8
+ PersonQuery.execute!(id: "1") # => Result, or raises QueryError (== execute(...).data!)
9
+
10
+ response = PersonQuery.execute(id: "1") # => GraphWeaver::Response[Result]
11
+ response.data # T.nilable(Result) — typed, present even on partial success
12
+ response.errors # Array[GraphWeaver::GraphQLError]
13
+ response.errors? # any top-level errors?
14
+ response.extensions # { "cost" => … } — rides on success too
15
+ response.data! # the Result, or raise GraphWeaver::QueryError
16
+ ```
17
+
18
+ The envelope is a single generic `GraphWeaver::Response[Result]` — `response.data`
19
+ stays fully typed to *this* query's result, no per-query wrapper class.
20
+
21
+ Every `GraphQLError` exposes `#message`, `#locations`, `#path`, `#extensions`,
22
+ and `#code` (`extensions["code"]`) — match on the **code**, not the message
23
+ string (`response.errors.first.code == "THROTTLED"`).
24
+
25
+ Everything GraphWeaver raises descends from `GraphWeaver::Error`, split by where
26
+ it failed:
27
+
28
+ | Class | When |
29
+ |-------|------|
30
+ | `TransportError` | never reached the server — DNS, connection refused, TLS, timeout |
31
+ | `ServerError` | reached it, non-2xx HTTP — `#status`, `#body` |
32
+ | `QueryError` | 200 body with top-level GraphQL errors — `#errors`, `#data`, `#extensions`, `#codes` |
33
+ | `TypeError` | the response wouldn't cast into the generated structs — `#struct`, `#cause` |
34
+ | `ValidationError` | build time: the query didn't validate against the schema |
35
+
36
+ ```ruby
37
+ begin
38
+ person = PersonQuery.execute!(id: "1").person
39
+ rescue GraphWeaver::TransportError
40
+ retry # network blip
41
+ rescue GraphWeaver::ServerError => e
42
+ e.status >= 500 ? backoff : raise # retry 5xx; a 4xx is our bug
43
+ rescue GraphWeaver::QueryError => e
44
+ e.codes.include?("THROTTLED") ? backoff : raise
45
+ end
46
+ ```
47
+
48
+ Or skip the hand-rolling — `Retry` wraps any transport with
49
+ configurable retries:
50
+
51
+ ```ruby
52
+ transport = GraphWeaver::Retry.new(
53
+ GraphWeaver::Transport::HTTP.new(url),
54
+ tries: 5, # total attempts
55
+ backoff: :exponential, # or :linear, or ->(attempt) { seconds }
56
+ base: 0.5, max: 30, # seconds, clamped at max:
57
+ jitter: true, # randomize each delay by 50-100%
58
+ retry_codes: ["THROTTLED"], # also retry GraphQL errors by code
59
+ )
60
+ ```
61
+
62
+ Defaults match the rescue block above: transport failures always retry,
63
+ `ServerError` only on 5xx (a 4xx is your bug — retrying won't fix it;
64
+ override with `retry_if:`), and GraphQL-level codes only when listed in
65
+ `retry_codes:`. Exhausting `tries:` re-raises the last error.
66
+
67
+ Two deliberate exceptions live *outside* the hierarchy, because typed
68
+ kwargs should fail like any Ruby method call: a wrong-typed variable
69
+ raises sorbet-runtime's `TypeError` ("Parameter 'page': Expected type
70
+ T.nilable(Integer), got type String"), and a missing required variable
71
+ raises a plain `ArgumentError` ("missing keyword: :id").
72
+
73
+ Business/validation failures returned *as data* (Shopify-style `userErrors { field
74
+ message code }`) aren't errors here — they're just fields you selected, so they
75
+ deserialize onto `response.data` like anything else and you inspect them there.
76
+
77
+ The one-shot `GraphWeaver.execute` / `execute!` mirror this: `execute` returns
78
+ the envelope, `execute!` the result-or-raise.
79
+
80
+ ## Extending TransportError
81
+
82
+ What counts as a `TransportError` is an **extensible set** — each transport
83
+ seeds its own network exceptions (`Errno::*`, `SocketError`, timeouts, TLS; the
84
+ Faraday transport adds its own), and you can register more so a custom adapter's
85
+ or connection pool's failure gets the same treatment:
86
+
87
+ ```ruby
88
+ GraphWeaver.register_transport_error(ConnectionPool::TimeoutError)
89
+ GraphWeaver.transport_errors << MyAdapter::ResetError # it's just a Set
90
+ ```
91
+
92
+
93
+ ## Programmatic surfacing
94
+
95
+ Every error is dual-surface: `#message` for humans, `#to_h` for machines — a
96
+ JSON-ready hash (error class, per-error `path`/`code`/`locations`/`extensions`)
97
+ you can nest straight into a log line or an API response.
98
+
99
+ Field-level tooling lives on both `Response` and `QueryError`:
100
+
101
+ ```ruby
102
+ response.errors_at("person.email") # errors touching a path (prefix match)
103
+ response.each_error do |field, errors| # grouped by index-stripped field
104
+ form.add_error(field, errors.map(&:message))
105
+ end
106
+
107
+ response.report
108
+ # { "person.pets.name" => {
109
+ # "messages" => ["name hidden"], "codes" => ["PRIVATE"],
110
+ # "entity_ids" => ["7", "9"], # resolved by walking paths through partial data
111
+ # "errors" => [ ...full to_h detail... ] },
112
+ # nil => { "codes" => ["DOWN"], ... } } # global errors under nil
113
+ ```
114
+
115
+ `GraphQLError#field` strips list indices (`people.3.email` → `people.email`) —
116
+ the stable grouping key; the raw `#path` keeps indices for exact location.
117
+
118
+ ## Stale schemas
119
+
120
+ GraphQL has no schema-version signal, so a schema change surfaces as the
121
+ server rejecting your query's shape. `response.schema_stale?` /
122
+ `QueryError#schema_stale?` detect validation-shaped rejections (Apollo's
123
+ `GRAPHQL_VALIDATION_FAILED` code, or the message patterns graphql-ruby and
124
+ GitHub use), and the raised message says what to do: regenerate modules and/or
125
+ refresh the schema cache.
126
+
127
+ ## Cast failures
128
+
129
+ When wire data disagrees with the types the schema promised at generation time
130
+ (a nil where non-null was declared, a malformed scalar, an unknown enum value),
131
+ casting raises `GraphWeaver::TypeError` naming the failing generated struct,
132
+ with the original exception as `#cause`. Simulate one in tests with
133
+ `GraphWeaver::Testing::FakeClient.new(schema:, corrupt: "Person.birthday")` — see
134
+ [testing](testing.md).