Skip to content

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

@letiesperon

Description

@letiesperon

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 #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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions