429 - Too Many Requests: Retry-Mechanismen mit Azure Functions

post-thumb

Die Entwicklung von Web-APIs beinhaltet meist die Nutzung von Drittanbieter-APIs zu den entsprechenden Quellsystemen. Um eine gewisse Servicequalität zu gewährleisten, verwenden Drittanbieter-APIs technische Limitierungen. Meist betreffen diese die Summe der Aufrufe auf einzelne APIs innerhalb einer gewissen Zeitspanne (Burst) oder des gesamten Kontingents von Aufrufen für einen Tag. Wenn nun ein Aufrufer diese Limitierungen überschreitet, erhält dieser die HTTP-Antwort 429 Too Many Requests. Zum Teil ist dies Bestandteil des Geschäftsmodells der Drittanbieter: Durch entsprechende Tarife können diese Limitierungen erhöht oder sogar aufgehoben werden.

In diesem Blogbeitrag möchte ich auf die Möglichkeiten eingehen, welche bei der Entwicklung von Web-APIs mit Azure Functions auf NET-Basis bestehen. Hierbei möchte ich insbesondere auf die zeitliche Begrenzung von Aufrufen innerhalb einer Zeitspanne (Burst) eingehen.

Szenario und Umgang mit 429 - Too Many Requests

Im definierten Szenario ruft der eigenentwickelte wiederkehrend eine API des Drittanbieters auf. Beispielsweise können das Endpunkte des Drittanbieters sein, welche größere Datenmengen durch Paging bereitstellt. Diese API muss also wiederkehrend aufgerufen werden. Bei einer großen Datenmenge kann dies schnell die definierten Limits übersteigen, da die abzurufende Menge unklar ist. Dies kann also schnell zu einer 429 Too Many Requests-Antwort führen.

Um die Resilienz unserer Implementierung zu steigern, muss bei einer entsprechenden Antwort ein Retry-Mechanismus ausgeführt werden, sodass unser Service nicht direkt mit einer Exception den Dienst quittiert.

Polly

Mit Polly kann ein Fehlerhandling für die verwendeten HTTP-Clients zentral implementiert werden. So kann mithilfe von Polly, z.B. auf Timeout-Fehler, reagiert oder Retry-Mechanismen implementiert werden.

Nach der Installation des NuGet-Packages, kann in der Startup-Klasse der HTTP-Client über Dependency Injection konfiguriert werden.

Neben der grundsätzlichen Konfiguration des Clients (via AddHttpClient) kann auch auf bestimmte Fehlerantworten reagiert werden (via HandleTransientHttpError).

Im folgenden Code-Snippet wird ein Retry-Mechanismus für eine 429 Too Many Requests-Antwort des HTTP-Clients implementiert. Nach einer 429-Antwort wird jeweils 10 Sekunden gewartet und insgesamt 3 Retrys durchgeführt.

services.AddHttpClient("myCustomHttpClient", (sp, client) =>
{
    client.BaseAddress = new Uri(Environment.GetEnvironmentVariable("BaseAddressThirdParty")!);
})
.AddPolicyHandler((sp, req) =>
{
    return HttpPolicyExtensions
        .HandleTransientHttpError()
        .OrResult(response => response.StatusCode == HttpStatusCode.TooManyRequests)
        .WaitAndRetryAsync(
            retryCount: 3,
            sleepDurationProvider: retryAttempt => TimeSpan.FromSeconds(10 * retryAttempt),
            onRetry: (exception, timeSpan, attemptNumber, context) =>
            {
                sp.GetService<ILogger<HttpClient>>()
                    .LogWarning(
                        $"Http-Error: 429-TooManyRequests; Delay: {timeSpan.TotalMilliseconds}ms; Retry-Attempt: {attemptNumber}; ResponseMessageContent: {exception.Result?.Content.ReadAsStringAsync()?.Result}; Exception-Message: {exception.Exception?.Message}");
            });
});

Bei der Dependency Injection des implementierten Services muss nur noch der konfigurierte HttpClient übergeben werden:

services.AddTransient<IMyService, MyService>(
  c =>
  {
      return new MyService(
          "path-url/",
          c.GetService<IHttpClientFactory>().CreateClient("myCustomHttpClient")
          );
  });

Allerdings muss hierbei berücksichtigt werden, dass die Zeit jedes Aufrufs innerhalb der Function zur Gesamtausführungszeit addiert wird. Dies bedeutet, dass die Gesamtdauer von 230 Sekunden einer HTTP-Trigger-Function nicht überschritten werden darf, da sonst ein allgemeiner Function-Timeout der Azure Function geworfen wird.

Durable Function

Nun bringen Durable Functions durch ihre Funktionsweise mit einer Orchestrierung und dazugehörende Activities eine neue Herangehensweise, um langläufige asynchrone Ausführungen abzubilden. Dankenswerterweise liefern Durable Functions bereits Retry-Mechanismen out-of-the-box mit.

Um nun in ähnlicher Weise, wie bei unserem Use-Case mit Polly, lediglich auf 429 Too Many Requests-Antworten reagieren zu können, sind einige Anpassungen der Durable Function vonnöten.

Zunächst benötigen wir eine Klasse, die alle notwendigen Attribute wie den HTTP-Statuscode und die Dauer für den Retry vereint. Hiermit werden die notwendigen Daten gehalten, um in der Druable Function die Wartedauer und den Statuscode der Serviceantwort der Drittanbieter-API abzurufen.

using System;
using System.Net;

public class DurableFunctionsException : Exception
{
    public DurableFunctionsException(HttpStatusCode httpStatusCode, TimeSpan? retryAfterTimeSpan)
    {
        this.HttpStatusCode = httpStatusCode;
        this.RetryAfterTimeSpan = retryAfterTimeSpan;
    }

    public HttpStatusCode HttpStatusCode { get; set; }

    public TimeSpan? RetryAfterTimeSpan { get; set; }
}

In der eigentlichen Implementierung und Verwendung eines HttpClients, z.B. in einer Activity, muss nur noch geprüft werden, ob der Aufruf eine 429-Antwort erzeugt und unsere DurableFunctionsException geworfen wird.

// Some Code of a Service ...

var response = await this.httpClient.GetAsync($"{this.pathUrl}");

if(!response.IsSuccessStatusCode)
{
    throw new DurableFunctionsException(response.StatusCode, response.Headers.RetryAfter.Delta);
}

// More Code of an Service ...

In der Orchestrator-Function wird uns mithilfe der Property RetryOptions ein Callback bereitgestellt, welcher definiert, ob eine Activity einen Retry durchführen soll oder nicht. Die Handle-Property prüft den Statuscode unserer DurableFunctionsException und gibt einen entsprechenden Boolean-Wert zurück.

// Some Code of a the Orchestration ...

var retryOptions = new RetryOptions(TimeSpan.FromSeconds(10), 3)
{
    Handle = (Exception ex) =>
    {
        var durableFunctionException = ex.InnerException as DurableFunctionsException;

        return durableFunctionException != null && durableFunctionException.HttpStatusCode == HttpStatusCode.TooManyRequests;
    },
};

// More Code of a the Orchestration ...

Mithilfe der vorher ermittelten RetryAfter-TimeSpan können wir nun in der Orchstrator-Function auf eine 429-Antwort reagieren. Im Catch-Block wird ein Wait-Timer auf Orchestrator-Ebene erzeugt, mit der ermittelten RetryAfter-TimeSpan. Mithilfe der ContinueAsNew-Methode wird der Orchestrator neu gestartet. Der Orchestrator führt hierdurch nur die ausstehenden Activities aus, bereits ausgeführte Activities und ermittelten Ergebnisse werden somit wiederverwendet. Somit wird nach dem Wait bei der letzten Activity angeknüpft, welche unsere Fehler-Activity mit der 429-Antwort war.

// Some Code of a the Orchestration ...

try
{
  var result = orchestrationContext.CallActivityWithRetryAsync<MyEntity>(nameof(GetMyEntityActivity),
  retryOptions,
  myEntity);

  return result;
}
catch(Exception ex)
{
  var durableFunctionsException = ex.InnerException as DurableFunctionsException;

  if(durableFunctionsException != null &&
      durableFunctionsException.HttpStatusCode == HttpStatusCode.TooManyRequests &&
      durableFunctionsException.RetryAfterTimeSpan.HasValue)
  {
      var retryAtDateTime = orchestrationContext.CurrentUtcDateTime.Add((TimeSpan)durableFunctionsException.RetryAfterTimeSpan);
      await orchestrationContext.CreateTimer(retryAtDateTime, CancellationToken.None);

      orchestrationContext.ContinueAsNew(null);
  }
}
// More Code of a the Orchestration ...

Fazit

Unabhängig davon, ob man sich nun für Polly oder die Retry-Mechanismen der Durable Functions entscheidet: Man sollte dies immer mit einer Prise Vorsicht tun. Es hängt immer vom Anwendungsfall ab (z.B. im Durable-Functions-Kontext, sollte man immer auf die mitgelieferte Funktionalität setzen). Auch sollte man sich beim Entwickeln den jeweiligen Konsequenzen bewusst sein. Grundsätzlich erhält man aber durch die in diesem Blogbeitrag gezeigten Beispiele einen guten Einstieg, um seine APIs resilienter zu gestalten.

Der Objektkultur-Newsletter

Mit unserem Newsletter informieren wir Sie stets über die neuesten Blogbeiträge,
Webcasts und weiteren spannende Themen rund um die Digitalisierung.

Newsletter abonnieren