Come posso creare interfacce di errore idiomatiche in Ruby?

7

Sto sviluppando un'app Ruby on Rails. L'app contiene un servizio che racchiude un'API REST esterna, chiamata da un controller, con diversi possibili stati di errore. L'implementazione corrente restituisce il corpo della risposta in caso di successo e solleva eccezionalmente un'eccezione catch-all specifica del servizio.

Voglio refactoring per essere in grado di distinguere tra errori di rete e altri errori di autorizzazione causati dal client che fornisce parametri non validi. Qual è il modo più idiomatico per farlo? Che cosa ha prodotto il codice più gestibile nella tua esperienza?

Di seguito sono riportate alcune alternative che ho considerato.

Eccezioni in tutto

It is recommended that a library should have one subclass of StandardError or RuntimeError and have specific exception types inherit from it. This allows the user to rescue a generic exception type to catch all exceptions the library may raise even if future versions of the library add new exception subclasses.

Dalla documentazione ufficiale di Ruby .

Il codice completo per il mio servizio è attualmente 39 linee e non può essere considerato una libreria, ma questa strategia potrebbe essere ancora applicabile. Una possibile implementazione è elencata di seguito.

class MyService
  def self.call(input)
    res = do_http_call input

    if res and res.code == 200
      res.body
    elsif res and res.code == 401
      fail MyServiceAuthenticationError
    else
      fail MyServiceError
    end
  end
end

class MyServiceError < StandardError
end

class MyServiceAuthenticationError < MyServiceError
end

Venendo da altre lingue questo approccio non sembra giusto. Ho spesso sentito il mantra "riservare eccezioni per casi eccezionali", ad esempio in Code Complete (Steve McConnell, 2a edizione, pagina 199):

Throw an exception on for conditions that are truly exceptional

Exceptions should be reserved for conditions that are truly exceptional – in other words, for conditions that cannot be addressed by other coding practices. Exceptions are used in similar circumstances to assertions – for events that are not just infrequent but for events that should never occur.

Le eccezioni sono davvero per errori eccezionali? è una discussione di questo argomento. Le risposte forniscono vari consigli e la risposta di S.Lott afferma esplicitamente "Non utilizzare le eccezioni per convalidare l'input dell'utente", che Penso che sia approssimativamente ciò che la strategia delineata sopra equivale a.

Simboli per errori "non eccezionali"

La mia prima intuizione è usare le eccezioni per gli errori che voglio far esplodere lo stack e simboli per i risultati che il chiamante può aspettarsi e vuole gestire.

class MyService
  def self.call(input)
    res = do_http_call input

    if res.code == 200
      res.body
    elsif res.code == 401
      :invalid_authentication
    else
      fail MyServiceError
    end
  end
end

class MyServiceError < StandardError
end

Proprio come le eccezioni in tutto, questo è facile da estendere con errori aggiuntivi.

Potrebbe tuttavia portare a problemi di manutenibilità. Se viene aggiunto un nuovo valore di ritorno di un simbolo e un chiamante non viene modificato, il simbolo di errore può essere interpretato silenziosamente come risultato positivo poiché il valore di ritorno positivo è una stringa. Non so quanto sia realistico in pratica però.

Inoltre, questo approccio può essere considerato più strong insieme al suo chiamante. Se un errore debba insinuarsi nello stack delle chiamate o essere gestito dal chiamante immediato, probabilmente non si tratta di qualcosa con cui il destinatario dovrebbe preoccuparsi.

Falso in caso di errore

Un esempio di questo approccio è ActiveRecord::Base#save .

  • Se l'operazione ha esito positivo, restituisce il risultato o true nel caso di #save .
  • Se le convalide falliscono, restituisce false.
  • Se si verificano alcuni tipi di errori imprevisti, come i campi di codifica con UTF-8 in #save , viene generata un'eccezione.
class MyService
  def self.call(input)
    res = do_http_call input

    if res.code == 200
      res.body
    elsif res.code == 401
      false
    else
      fail MyServiceError
    end
  end
end

class MyServiceError < StandardError
end

In genere non mi piace questa strategia poiché false non ha alcun significato semantico ed è impossibile distinguere tra errori.

Un altro modo

C'è un altro modo migliore?

    
posta jacwah 08.01.2017 - 00:09
fonte

2 risposte

1

Forse stai cercando qualcosa come un oggetto status.

Ad esempio:

class MyService
  def self.call(input)
    Client.call(input) do |status|
      status.on_success do |response|
        response.body
      end

      status.on_not_authorized do
        # do something when not authorized
      end

      status.on_error do
        # do something then there's an error
      end
    end
  end
end

class Client
  def self.call(input)
    res = do_http_call(input)

    if res.code == 200
      yield RequestStatus.success(res)
    elsif res.code == 401
      yield RequestStatus.not_authorized
    else
      yield RequestStatus.error
    end
  end
end

class RequestStatus
  def self.success(response)
    new(:success, response)
  end

  def self.not_authorized
    new(:not_authorized)
  end

  def self.error
    new(:error)
  end

  def initialize(status, response = nil)
    @status = status
    @response = response
  end

  def on_success
    yield(@response) if @status == :success
  end

  def on_not_authorized
    yield if @status == :not_authorized
  end

  def on_error
    yield if @status == :error
  end
end

Non so se questo modello è migliore di per sé, ma se vuoi evitare di generare eccezioni o di passare simboli, questa potrebbe essere una buona alternativa.

    
risposta data 08.01.2017 - 02:24
fonte
1

Un modo davvero eccellente che ho visto per l'organizzazione delle eccezioni in una libreria Ruby è nella libreria twitter di sferik.

link

Utilizzando il meccanismo di creazione di classi dinamiche di Ruby con Class.new(ParentClass) Rende molto facile ragionare sulla gerarchia delle classi.

Eccezioni relative ai client ereditate da ClientError. Le eccezioni relative al server ereditano da ServerError. ClientError e ServerError ereditano entrambi da Twitter :: Errore

I codici di risposta HTTP sono mappati alle classi di errore e sollevati dopo aver ricevuto la risposta HTTP:

@parser = Http::Parser.new(http_response)
error = Twitter::Error::ERRORS[@parser.status_code]
raise error if error

Ho abrevato il codice per mostrare solo le parti importanti:

module Twitter
  class Error < StandardError
    ...    
    ClientError = Class.new(self)
    BadRequest = Class.new(ClientError)
    Unauthorized = Class.new(ClientError)
    RequestEntityTooLarge = Class.new(ClientError)
    NotFound = Class.new(ClientError)
    NotAcceptable = Class.new(ClientError)
    UnprocessableEntity = Class.new(ClientError)
    TooManyRequests = Class.new(ClientError)

    Forbidden = Class.new(ClientError)
    AlreadyFavorited = Class.new(Forbidden)
    AlreadyRetweeted = Class.new(Forbidden)
    DuplicateStatus = Class.new(Forbidden)


    ServerError = Class.new(self)
    InternalServerError = Class.new(ServerError)
    BadGateway = Class.new(ServerError)
    ServiceUnavailable = Class.new(ServerError)
    GatewayTimeout = Class.new(ServerError)

    ERRORS = {
      400 => Twitter::Error::BadRequest,
      401 => Twitter::Error::Unauthorized,
      403 => Twitter::Error::Forbidden,
      404 => Twitter::Error::NotFound,
      406 => Twitter::Error::NotAcceptable,
      413 => Twitter::Error::RequestEntityTooLarge,
      422 => Twitter::Error::UnprocessableEntity,
      429 => Twitter::Error::TooManyRequests,
      500 => Twitter::Error::InternalServerError,
      502 => Twitter::Error::BadGateway,
      503 => Twitter::Error::ServiceUnavailable,
      504 => Twitter::Error::GatewayTimeout,
    }.freeze
    ...
  end
end
    
risposta data 19.02.2017 - 06:49
fonte

Leggi altre domande sui tag