rmosolgo / rmosolgo/graphql-ruby

Returning ALL Invalid ID Errors for `loads:` Arguments in JSON Response

Open
#5,427 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Ruby
Stars
5.4k
Forks
1.4k
Avg merge
23h 19m
Merged PRs (30d)
28

Description

Return ALL Invalid ID Errors for loads: Arguments in JSON Response

DISCLAIMER: This is more of a question of "how to patch this" more than a feature request, since from what I've seen and read it's not straightforward to achieve neither a priority.

Use Case

When GraphQL queries passes multiple invalid IDs, on an array arguments that uses autoloaded, we want to return errors for ALL invalid IDs in a single response, rather than just the first one. This would ease some scenarios where our frontend has to parse the error message to identify which ID was invalid, and then re-submit the query without that ID, so the error is gracefully handled for the user. This can lead to multiple round-trips if there are several invalid IDs.
(Example: they accessed a bookmarked URL to our programs marketplace, where a filter by label is applied, but some of those labels have been deleted since).

Example Query object:

class Queries::Programs < BaseQuery
  description 'Fetch a list of programs by IDs'

  type [Types::Programs::ProgramType], null: false

  argument :program_ids, [ID], required: true, loads: Types::Programs::ProgramType

  def resolve(programs:)
    programs
  end
end

Example request with multiple invalid IDs:

query {
  programs(programIds: ["valid-id", "invalid-1", "invalid-2", "invalid-3"]) {
    id
    name
  }
}

Default response (only returns first invalid id error):

{
  "errors": [
    {
      "message": "No object found for `programIds: \"invalid\"`",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": [
        "programs"
      ]
    }
  ],
  "data": null
}

Example of desired response (all invalid IDS returned, with a structured format):

{
  "errors": [
    {
      "message": "Record not found: invalid-1",
      "extensions": { "code": "INVALID_ID", "program_id": "invalid-1" }
    },
    {
      "message": "Record not found: invalid-2",
      "extensions": { "code": "INVALID_ID", "program_id": "invalid-2" }
    }
  ],
  "data": null
}

Attempted Solution

On our app, we tried overriding load_application_object_failed to collect errors instead of raising immediately, on all BaseMutation, BaseQuery, and InputObject levels:

def load_application_object_failed(error)
  puts "1) Inside load_application_object_failed"

  ctx[:failed_loads] ||= []
  ctx[:failed_loads] << {
    path: error.path,
    argument: error.argument.graphql_name,
    invalid_id: error.id
  }

  nil # Return nil instead of raising
end

Then, on BaseField, we tried overriding the resolve so that it first checks for any collected errors and raises them all at once before proceeding with the resolver:

def resolve(obj, args, ctx)
  puts "2) Inside BaseField resolve. load errors in context: #{ctx[:failed_loads].size}"

  failed_loads = ctx[:failed_loads]
  return if failed_loads.blank?

  failed_loads.each_with_index do |failed_load, index|
    error = GraphQL::ExecutionError.new("Record not found: #{failed_load[:invalid_id]}")
    error.path = failed_load[:path]
    error.extensions = {
      'code' => 'INVALID_ID',
      'argument' => failed_load[:argument],
      'invalid_id' => failed_load[:invalid_id]
    }

    is_last_error = index == failed_loads.size - 1

    raise error if is_last_error # Halt execution on last error to avoid running resolver
    ctx.add_error(error)
  end

  super
end

The Problem: Inconsistent Timing Behavior

Our solution works for Input Objects but fails for Direct Arguments due to different execution timing:

✅ Works: Input Objects with loads:
class MyInputObject < GraphQL::Schema::InputObject
  argument :program_ids, [ID], loads: ProgramType
end

class MyQuery < GraphQL::Schema::Resolver
  argument :filters, MyInputObject

  def resolve(filters:)
    raise "Should not reach here if there are invalid IDs"
  end
end

Execution Order:

  1. load_application_object_failed called (prepare phase)
  2. BaseField resolve runs with collected errors on context, so adds them to context and raises the last one ✅
  3. Query resolver never runs ✅
❌ Fails: Direct Arguments with loads:
class MyQuery < GraphQL::Schema::Resolver
  argument :program_ids, [ID], loads: ProgramType

  def resolve(filters:)
    raise "Should not reach here if there are invalid IDs" # ❌ This runs anyway
  end
end

Execution Order:

  1. BaseField resolve runs, but no errors yet in context so does nothing
  2. load_application_object_failed called (from resolve phase)
  3. Query resolver runs ❌

Root Cause Analysis

The issue stems from when loads: processing occurs:

  • Input Objects: loads: processed during prepare phase (early)
  • Direct Arguments: loads: processed during resolve phase (late)

This timing difference makes me unsure of how to create a global solution that works for both cases.

This appears related to https://github.com/rmosolgo/graphql-ruby/issues/2966 which discusses similar challenges with loads: error handling timing.

Questions

Recommended patterns? What's the recommended way to handle batch invalid ID scenarios in GraphQL-Ruby? Do you personally recommend not using loads: on array arguments at all? Or is there a better way to patch this globally on my application that I'm missing?

Thank you so much in advance!

Environment

  • Ruby: 3.1.0
  • GraphQL-Ruby: 2.0.x
  • Rails: 7.2.x

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by tracing load_application_object_failed and BaseField#resolve for direct arguments, then compare that execution path with GraphQL::Schema::InputObject and the related issue #2966. Done means the behavior for multiple invalid IDs is clearly established for both argument forms, with a documented recommended pattern or a focused change and regression coverage.

Written by the indexing model from the issue text.

Assessment

Tech stack
graphql, ruby
Domain
api, backend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Needs clarification
Newbie friendliness
30/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.