2

I've got a GRPC API that I'm designing. It includes an RPC method that requires the caller follow-up with another API call. Some pseudo-code a client might write:

val apiInstsance = makeAPIInstance()

val questionFromMyAPI = apiInstance.computeQuestion(clientInformation)

val clientResponse = doBusinessLogic(questionFromMyAPI)

apiInstance.offerResult(questionFromMyAPI, clientResponse)

The key thing I want to impart is that computeQuestion demands a response as per the protocol, IE it is not valid for a client to simply return after calling computeQuestion, they must respond with an offerResult to any computeQuestion calls.

If an API user fails to call offerResult the likely result is a kind of live-lock; nothing will be wrong or be waiting on stack, but it will likely be implemented as an infinite spinner or beach-ball. This is tough for a developer because they aren't given any traction by the design of the API to figure out what's wrong.

I want to change my API such that user can more idiomatically write something like:

val apiInstance = makeAPIInstance()

val questionFromMyAPI = apiInstance.computeQuestion(clientInformation)

AlternativeThreadingContext.runAppropriately(() -> {
    // with C#'s disposable syntax --you can imagine similar for RAII or try-finally
    using(questionFromOurAPI) {
        doComplexBuinessLogicAndSendResultTo(apiInstance)      
    }
})

...With this latter setup where we express protocol semantics through a kind of acquired resource. If the acquired resource is disposed (or the finally block reached) before a call to offerResult, then we can raise an exception. In this way I can convert from a likely-hanging process to a raised exception, but this requires I throw an exception in the RAII-destructor/finally-block/dispose-method, which seems like its against best-practices.

There is also the problem of exception hiding. If an exception is raised by doBusinessLogic, then any protocol check I do in the dispose/finally block is likely to find a protocol error and raise another exception effectively hiding the first exception, which is the "real" exception.

In future I hope to write language specific code in each of the major users of our API that will navigate some of these subtleties for you, but for now I'm not sure what to do.


Am I missing something? Is it best to simply document the behaviour and let API callers hang their processes as the failure mode for writing bad code here? Is there some obvious way I can reframe my question such that its expressed as a transaction or other paradigm that GPRC can clearly and easily handle with such-and-such a pattern I'm not aware of? Any help appreciated.

2 Answers 2

1

With the trend for stateless calls no-one will expect a method call to require closing. But a "Connection" is expected to have state, open, close etc

I would rename stuff to lean into that and instead of raising an exception on an early close, just release the server side lock. I assume you'll need to handle this situation anyway, so why make it hard.

using QuestionConn q as api.GetQuestionConnection();
q.ComputeQuestion(info);
q.OfferAnswer(answer);

class QuestionConn 
{
    private api api;
    private string state
    public Question ComputeQuestion(info) {
       //make calls to api
       return api.ComputeQuestion(info) 
       //update state
    }
    public bool OfferAnswer(answer) {
       //make api call
       return api.OfferAnswer(answer) 
       //update state
    }
    public bool Dispose()
    {
       if(state=="noanswer") { 
           api.ClosingWithNoAnswer();
           Invoke(OnCloseWithNoAnswer) //maybe??
        }
    }
}
1
  • the API does have a meaning for offerResult(question, NoResultsAvailable), so I can implement Invoker(OnCloseWithNoAnswer) without issue --and the server will have little trouble handling a call trace that contains offerAnswer(question, validResults) followed by an offerAnswer(question, OnCloseWithNoAnswer). But this is exactly the code I dont have time to write; I would have to write it in every client language. I want something I can do to my *.proto file to solve this problem.
    – Groostav
    Jul 26, 2022 at 20:20
1

The offerResult method seems like an implementation detail that should be hidden from clients of the API. Your API needs control of the compute-logic-offer result workflow. You only need to temporarily delegate control to the client to doBusinessLogic and automatically return control back to the API after the client is finished. Basically this is where the Command Pattern becomes beneficial.

I'll use the term "command pattern" in a general sense. You don't need a full-blown class hierarchy with an interface, like the Command Pattern typically requires. Modern languages have a number of options that allow you to write more expressive code. The two that immediately come to mind are:

  • Lambda expressions/anonymous functions
  • Function pointers/delegates (for C# developers)

The computeQuestion function could require a parameter that accepts either of the constructs above. These are more generally known as a "callback". It would pass the question from your API to the callback function, and expect the callback to return a "client response" before automatically calling offerResult. In this way the API is in full control of the protocol, leaving the client to fill in their specific details.

I'll use C# as an example:

public class YourApi
{
    public void AnswerQuestion(QuestionRequest request, Func<QuestionResponse, AnswerRequest> processQuestion)
    {
        QuestionResponse question = ComputeQuestion(request);
        
        try
        {
            AnswerRequest answer = processQuestion(question);
            
            OfferResult(answer);
        }
        catch (Exception error)
        {
            // log error
            throw new ApiException("Some relevant message", error);
        }
        finally
        {
            // cleanup work
        }
    }
    
    private void ComputeQuestion(QuestionRequest request)
    {
        ...
    }
    
    private void OfferResponse(AnswerRequest request)
    {
        ...
    }
}

Consumers of your API end up with one clean method call:

var request = new QuestionRequest(...);

api.AnswerQuestion(request, response =>
{
    // Do business logic using 'response'
    return new AnswerRequest(...);
});

The messiness of calling OfferResult and ComputeQuestion become unnecessary to the client.

Java has Function<T1, T2> for the processQuestion parameter. Languages that get you close to bare metal will probably support passing a function pointer. Failing that, the old school implementation of the Command Pattern would work just fine in place of processQuestion.

1
  • Threading is the problem here. How could/would such a callback interact with the runAppropriately function? I could force a suspendable coroutine on kotlin callers, an async lambda on C# callers, presumably some std::async-ish type in C++ 17-ish that I need to read-up on, But I know of no method for async-lambdas for python or java (without project loom).
    – Groostav
    Jul 26, 2022 at 20:18

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.