Activer les nouvelles tentatives d'exécution des fonctions pilotées par des événements

Ce document explique comment activer la répétition des tentatives de fonctions basées sur des événements. La répétition des tentatives automatique n'est pas disponible pour les fonctions HTTP.

Sémantique d'une répétition de tentative

Cloud Functions garantit l'exécution de type "au moins une fois" d'une fonction basée sur des événements pour chaque événement émis par une source d'événement. Par défaut, si un appel de fonction se termine par une erreur, la fonction n'est plus appelée et l'événement est supprimé. Lorsque vous activez les nouvelles tentatives sur une fonction basée sur des événements, Cloud Functions tente d'appeler la fonction défaillante jusqu'à ce qu'elle aboutisse, ou que la fenêtre de nouvelle tentative arrive à expiration.

Pour les fonctions de 2e génération, cette fenêtre de nouvelle tentative expire au bout de 24 heures. Pour les fonctions de 1re génération, elle expire au bout de 7 jours. Cloud Functions tente d'appeler les fonctions basées sur des événements qui ont été récemment définies en appliquant une stratégie d'intervalle exponentiel entre les tentatives, cet intervalle étant croissant et allant de 10 à 600 secondes. Cette stratégie est appliquée aux nouvelles fonctions la première fois que vous les déployez. Elle n'est pas appliquée rétroactivement aux fonctions existantes qui ont été déployées avant les modifications décrites dans cette note de version, même si vous redéployez les fonctions.

Lorsque la répétition des tentatives n'est pas activée pour une fonction, ce qui est la valeur par défaut, la fonction indique toujours qu'elle a bien été exécutée, et les codes de réponse 200 OK peuvent apparaître dans ses journaux. Cela se produit même si la fonction a rencontré une erreur. Pour indiquer clairement lorsque votre fonction rencontre une erreur, veillez à signaler les erreurs de manière appropriée.

Pourquoi les fonctions basées sur des événements échouent-elles ?

Il peut arriver qu'une fonction se ferme prématurément en raison d'une erreur interne. Par défaut, cette fonction peut être relancée automatiquement ou non.

Le plus souvent, une fonction basée sur des événements peut échouer en raison d'erreurs générées dans le code même de la fonction. Les raisons peuvent être les suivantes :

  • La fonction contient un bug et l’environnement d'exécution renvoie une exception.
  • La fonction ne peut pas atteindre de point de terminaison de service, ou bien elle dépasse le délai en essayant d'y parvenir.
  • La fonction renvoie intentionnellement une exception (par exemple, lorsqu'un paramètre échoue à la validation).
  • Une fonction Node.js renvoie une promesse refusée ou transmet à un rappel une valeur qui n'est pas null.

Dans tous les cas énoncés ci-dessus, la fonction cesse d'être exécutée par défaut et l'événement est supprimé. Pour relancer la fonction en cas d'erreur, vous pouvez modifier la stratégie de nouvelles tentatives par défaut en définissant la propriété "Réessayer après échec". L'événement est alors relancé de façon répétée jusqu'à ce que la fonction se termine ou que le délai avant expiration des nouvelles tentatives soit écoulé.

Activer ou désactiver les nouvelles tentatives

Pour activer ou désactiver la répétition des tentatives, vous pouvez utiliser l'outil de ligne de commande gcloud ou la console Google Cloud. La répétition de tentatives est désactivée par défaut.

Configurer les nouvelles tentatives à partir de l'outil de ligne de commande gcloud

Pour activer la répétition des tentatives via l'outil de ligne de commande gcloud, incluez l'option --retry lors du déploiement de votre fonction :

gcloud functions deploy FUNCTION_NAME --retry FLAGS...

Pour désactiver la répétition des tentatives, redéployez la fonction sans l'option --retry :

gcloud functions deploy FUNCTION_NAME FLAGS...

Configurer les nouvelles tentatives à partir de la console Google Cloud

Si vous créez une fonction :

  1. Dans l'écran Créer une fonction, sélectionnez Ajouter un déclencheur, puis choisissez le type d'événement devant servir de déclencheur pour votre fonction.
  2. Dans le volet Déclencheur Eventarc, cochez la case Réessayer après échec pour activer les nouvelles tentatives.

Si vous mettez à jour une fonction existante :

  1. Sur la page Présentation de Cloud Functions, cliquez sur le nom de la fonction que vous mettez à jour afin d'afficher l'écran associé Informations sur la fonction, puis sélectionnez Modifier dans la barre de menu pour afficher les volets HTTPS et Déclencheur Eventarc.
  2. Dans le volet Déclencheur Eventarc, cliquez sur l'icône Modifier pour modifier les paramètres du déclencheur.
  3. Dans le volet Déclencheur Eventarc, cochez ou décochez la case Réessayer après échec pour activer ou désactiver les nouvelles tentatives.

Bonnes pratiques

Cette section décrit les bonnes pratiques relatives à l'utilisation de la répétition des tentatives.

Utiliser la répétition pour faire face aux erreurs temporaires

Votre fonction est relancée en continu tant que son exécution n'est pas réussie. Vous devez donc éliminer de votre code les erreurs permanentes telles que les bugs par le biais de tests. Ce n'est qu'après cette étape que vous pourrez activer la répétition des tentatives. Les tentatives sont particulièrement utiles pour gérer les échecs intermittents/temporaires qui présentent une probabilité élevée de résolution à mesure des nouvelles tentatives, par exemple lorsqu'un point de terminaison de service ou un délai d’inactivité est instable.

Définir une condition de fin pour éviter les boucles infinies de répétition de tentatives

Il est recommandé de protéger votre fonction contre les boucles continues lors de l'utilisation de la répétition des tentatives. Pour ce faire, incluez une condition de fin bien définie avant le début du traitement de la fonction. Notez que cette technique ne fonctionne que si votre fonction démarre correctement et qu'elle est en mesure d'évaluer la condition de fin.

Une approche simple, mais efficace, consiste à ignorer les événements dont l'horodatage est antérieur à une certaine période. Cela permet d'éviter des exécutions excessives lorsque les échecs sont persistants ou plus longs que prévu.

Par exemple, l'extrait de code suivant supprime tous les événements de plus de dix secondes :

Node.js

/**
 * Background Cloud Function that only executes within
 * a certain time period after the triggering event
 *
 * @param {object} event The Cloud Functions event.
 * @param {function} callback The callback function.
 */
exports.avoidInfiniteRetries = (event, callback) => {
  const eventAge = Date.now() - Date.parse(event.timestamp);
  const eventMaxAge = 10000;

  // Ignore events that are too old
  if (eventAge > eventMaxAge) {
    console.log(`Dropping event ${event} with age ${eventAge} ms.`);
    callback();
    return;
  }

  // Do what the function is supposed to do
  console.log(`Processing event ${event} with age ${eventAge} ms.`);

  // Retry failed function executions
  const failed = false;
  if (failed) {
    callback('some error');
  } else {
    callback();
  }
};

Python

from datetime import datetime, timezone

# The 'python-dateutil' package must be included in requirements.txt.
from dateutil import parser

def avoid_infinite_retries(data, context):
    """Background Cloud Function that only executes within a certain
    time period after the triggering event.

    Args:
        data (dict): The event payload.
        context (google.cloud.functions.Context): The event metadata.
    Returns:
        None; output is written to Stackdriver Logging
    """

    timestamp = context.timestamp

    event_time = parser.parse(timestamp)
    event_age = (datetime.now(timezone.utc) - event_time).total_seconds()
    event_age_ms = event_age * 1000

    # Ignore events that are too old
    max_age_ms = 10000
    if event_age_ms > max_age_ms:
        print(f"Dropped {context.event_id} (age {event_age_ms}ms)")
        return "Timeout"

    # Do what the function is supposed to do
    print(f"Processed {context.event_id} (age {event_age_ms}ms)")
    return  # To retry the execution, raise an exception here

Go


// Package tips contains tips for writing Cloud Functions in Go.
package tips

import (
	"context"
	"fmt"
	"log"
	"time"

	"cloud.google.com/go/functions/metadata"
)

// PubSubMessage is the payload of a Pub/Sub event.
// See the documentation for more details:
// https://cloud.google.com/pubsub/docs/reference/rest/v1/PubsubMessage
type PubSubMessage struct {
	Data []byte `json:"data"`
}

// FiniteRetryPubSub demonstrates how to avoid inifinite retries.
func FiniteRetryPubSub(ctx context.Context, m PubSubMessage) error {
	meta, err := metadata.FromContext(ctx)
	if err != nil {
		// Assume an error on the function invoker and try again.
		return fmt.Errorf("metadata.FromContext: %w", err)
	}

	// Ignore events that are too old.
	expiration := meta.Timestamp.Add(10 * time.Second)
	if time.Now().After(expiration) {
		log.Printf("event timeout: halting retries for expired event '%q'", meta.EventID)
		return nil
	}

	// Add your message processing logic.
	return processTheMessage(m)
}

Java


import com.google.cloud.functions.BackgroundFunction;
import com.google.cloud.functions.Context;
import com.google.gson.Gson;
import functions.eventpojos.PubsubMessage;
import java.time.Duration;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.logging.Logger;

public class RetryTimeout implements BackgroundFunction<PubsubMessage> {
  private static final Logger logger = Logger.getLogger(RetryTimeout.class.getName());
  private static final long MAX_EVENT_AGE = 10_000;

  // Use Gson (https://github.com/google/gson) to parse JSON content.
  private static final Gson gson = new Gson();

  /**
   * Background Cloud Function that only executes within
   * a certain time period after the triggering event
   */
  @Override
  public void accept(PubsubMessage message, Context context) {
    ZonedDateTime utcNow = ZonedDateTime.now(ZoneOffset.UTC);
    ZonedDateTime timestamp = ZonedDateTime.parse(context.timestamp());

    long eventAge = Duration.between(timestamp, utcNow).toMillis();

    // Ignore events that are too old
    if (eventAge > MAX_EVENT_AGE) {
      logger.info(String.format("Dropping event with timestamp %s.", timestamp));
      return;
    }

    // Process events that are recent enough
    // To retry this invocation, throw an exception here
    logger.info(String.format("Processing event with timestamp %s.", timestamp));
  }
}

C#

using CloudNative.CloudEvents;
using Google.Cloud.Functions.Framework;
using Google.Events.Protobuf.Cloud.PubSub.V1;
using Microsoft.Extensions.Logging;
using System;
using System.Threading;
using System.Threading.Tasks;

namespace TimeBoundedRetries;

public class Function : ICloudEventFunction<MessagePublishedData>
{
    private static readonly TimeSpan MaxEventAge = TimeSpan.FromSeconds(10);
    private readonly ILogger _logger;

    // Note: for additional testability, use an injectable clock abstraction.
    public Function(ILogger<Function> logger) =>
        _logger = logger;

    public Task HandleAsync(CloudEvent cloudEvent, MessagePublishedData data, CancellationToken cancellationToken)
    {
        string textData = data.Message.TextData;

        DateTimeOffset utcNow = DateTimeOffset.UtcNow;

        // Every PubSub CloudEvent will contain a timestamp.
        DateTimeOffset timestamp = cloudEvent.Time.Value;
        DateTimeOffset expiry = timestamp + MaxEventAge;

        // Ignore events that are too old.
        if (utcNow > expiry)
        {
            _logger.LogInformation("Dropping PubSub message '{text}'", textData);
            return Task.CompletedTask;
        }

        // Process events that are recent enough.
        // If this processing throws an exception, the message will be retried until either
        // processing succeeds or the event becomes too old and is dropped by the code above.
        _logger.LogInformation("Processing PubSub message '{text}'", textData);
        return Task.CompletedTask;
    }
}

Ruby

require "functions_framework"

FunctionsFramework.cloud_event "avoid_infinite_retries" do |event|
  # Use the event timestamp to determine the event age.
  event_age_secs = Time.now - event.time.to_time
  event_age_ms = (event_age_secs * 1000).to_i

  max_age_ms = 10_000
  if event_age_ms > max_age_ms
    # Ignore events that are too old.
    logger.info "Dropped #{event.id} (age #{event_age_ms}ms)"

  else
    # Do what the function is supposed to do.
    logger.info "Handling #{event.id} (age #{event_age_ms}ms)..."
    failed = true

    # Raise an exception to signal failure and trigger a retry.
    raise "I failed!" if failed
  end
end

PHP


/**
 * This function shows an example method for avoiding infinite retries in
 * Google Cloud Functions. By default, functions configured to automatically
 * retry execution on failure will be retried indefinitely - causing an
 * infinite loop. To avoid this, we stop retrying executions (by not throwing
 * exceptions) for any events that are older than a predefined threshold.
 */

use Google\CloudFunctions\CloudEvent;

function avoidInfiniteRetries(CloudEvent $event): void
{
    $log = fopen(getenv('LOGGER_OUTPUT') ?: 'php://stderr', 'wb');

    $eventId = $event->getId();

    // The maximum age of events to process.
    $maxAge = 10; // 10 seconds

    // The age of the event being processed.
    $eventAge = time() - strtotime($event->getTime());

    // Ignore events that are too old
    if ($eventAge > $maxAge) {
        fwrite($log, 'Dropping event ' . $eventId . ' with age ' . $eventAge . ' seconds' . PHP_EOL);
        return;
    }

    // Do what the function is supposed to do
    fwrite($log, 'Processing event: ' . $eventId . ' with age ' . $eventAge . ' seconds' . PHP_EOL);

    // infinite_retries failed function executions
    $failed = true;
    if ($failed) {
        throw new Exception('Event ' . $eventId . ' failed; retrying...');
    }
}

Distinguer les erreurs récupérables des erreurs fatales

Si la répétition de tentatives est activée pour votre fonction, toute erreur non gérée déclenche une nouvelle tentative. Assurez-vous que votre code capture toutes les erreurs qui ne doivent pas entraîner de nouvelle tentative.

Node.js

/**
 * Background Cloud Function that demonstrates
 * how to toggle retries using a promise
 *
 * @param {object} event The Cloud Functions event.
 * @param {object} event.data Data included with the event.
 * @param {object} event.data.retry User-supplied parameter that tells the function whether to retry.
 */
exports.retryPromise = event => {
  const tryAgain = !!event.data.retry;

  if (tryAgain) {
    throw new Error('Retrying...');
  } else {
    console.error('Not retrying...');
    return Promise.resolve();
  }
};

/**
 * Background Cloud Function that demonstrates
 * how to toggle retries using a callback
 *
 * @param {object} event The Cloud Functions event.
 * @param {object} event.data Data included with the event.
 * @param {object} event.data.retry User-supplied parameter that tells the function whether to retry.
 * @param {function} callback The callback function.
 */
exports.retryCallback = (event, callback) => {
  const tryAgain = !!event.data.retry;
  const err = new Error('Error!');

  if (tryAgain) {
    console.error('Retrying:', err);
    callback(err);
  } else {
    console.error('Not retrying:', err);
    callback();
  }
};

Python

from google.cloud import error_reporting

error_client = error_reporting.Client()

def retry_or_not(data, context):
    """Background Cloud Function that demonstrates how to toggle retries.

    Args:
        data (dict): The event payload.
        context (google.cloud.functions.Context): The event metadata.
    Returns:
        None; output is written to Stackdriver Logging
    """

    # Retry based on a user-defined parameter
    try_again = data.data.get("retry") is not None

    try:
        raise RuntimeError("I failed you")
    except RuntimeError:
        error_client.report_exception()
        if try_again:
            raise  # Raise the exception and try again
        else:
            pass  # Swallow the exception and don't retry

Go


// Package tips contains tips for writing Cloud Functions in Go.
package tips

import (
	"context"
	"errors"
	"log"
)

// PubSubMessage is the payload of a Pub/Sub event.
// See the documentation for more details:
// https://cloud.google.com/pubsub/docs/reference/rest/v1/PubsubMessage
type PubSubMessage struct {
	Data []byte `json:"data"`
}

// RetryPubSub demonstrates how to toggle using retries.
func RetryPubSub(ctx context.Context, m PubSubMessage) error {
	name := string(m.Data)
	if name == "" {
		name = "World"
	}

	// A misconfigured client will stay broken until the function is redeployed.
	client, err := MisconfiguredDataClient()
	if err != nil {
		log.Printf("MisconfiguredDataClient (retry denied):  %v", err)
		// A nil return indicates that the function does not need a retry.
		return nil
	}

	// Runtime error might be resolved with a new attempt.
	if err = FailedWriteOperation(client, name); err != nil {
		log.Printf("FailedWriteOperation (retry expected): %v", err)
		// A non-nil return indicates that a retry is needed.
		return err
	}

	return nil
}

Java


import com.google.cloud.functions.BackgroundFunction;
import com.google.cloud.functions.Context;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import functions.eventpojos.PubsubMessage;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.logging.Logger;

public class RetryPubSub implements BackgroundFunction<PubsubMessage> {
  private static final Logger logger = Logger.getLogger(RetryPubSub.class.getName());

  // Use Gson (https://github.com/google/gson) to parse JSON content.
  private static final Gson gson = new Gson();

  @Override
  public void accept(PubsubMessage message, Context context) {
    String bodyJson = new String(
        Base64.getDecoder().decode(message.getData()), StandardCharsets.UTF_8);
    JsonElement bodyElement = gson.fromJson(bodyJson, JsonElement.class);

    // Get the value of the "retry" JSON parameter, if one exists
    boolean retry = false;
    if (bodyElement != null && bodyElement.isJsonObject()) {
      JsonObject body = bodyElement.getAsJsonObject();

      if (body.has("retry") && body.get("retry").getAsBoolean()) {
        retry = true;
      }
    }

    // Retry if appropriate
    if (retry) {
      // Throwing an exception causes the execution to be retried
      throw new RuntimeException("Retrying...");
    } else {
      logger.info("Not retrying...");
    }
  }
}

C#

using CloudNative.CloudEvents;
using Google.Cloud.Functions.Framework;
using Google.Events.Protobuf.Cloud.PubSub.V1;
using Microsoft.Extensions.Logging;
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

namespace Retry;

public class Function : ICloudEventFunction<MessagePublishedData>
{
    private readonly ILogger _logger;

    public Function(ILogger<Function> logger) =>
        _logger = logger;

    public Task HandleAsync(CloudEvent cloudEvent, MessagePublishedData data, CancellationToken cancellationToken)
    {
        bool retry = false;
        string text = data.Message?.TextData;

        // Get the value of the "retry" JSON parameter, if one exists.
        if (!string.IsNullOrEmpty(text))
        {
            JsonElement element = JsonSerializer.Deserialize<JsonElement>(data.Message.TextData);

            retry = element.TryGetProperty("retry", out var property) &&
                property.ValueKind == JsonValueKind.True;
        }

        // Throwing an exception causes the execution to be retried.
        if (retry)
        {
            throw new InvalidOperationException("Retrying...");
        }
        else
        {
            _logger.LogInformation("Not retrying...");
        }
        return Task.CompletedTask;
    }
}

Ruby

require "functions_framework"

FunctionsFramework.cloud_event "retry_or_not" do |event|
  try_again = event.data["retry"]

  begin
    # Simulate a failure
    raise "I failed!"
  rescue RuntimeError => e
    logger.warn "Caught an error: #{e}"
    if try_again
      # Raise an exception to return a 500 and trigger a retry.
      logger.info "Trying again..."
      raise ex
    else
      # Return normally to end processing of this event.
      logger.info "Giving up."
    end
  end
end

PHP


use Google\CloudFunctions\CloudEvent;

function tipsRetry(CloudEvent $event): void
{
    $cloudEventData = $event->getData();
    $pubSubData = $cloudEventData['message']['data'];

    $json = json_decode(base64_decode($pubSubData), true);

    // Determine whether to retry the invocation based on a parameter
    $tryAgain = $json['some_parameter'];

    if ($tryAgain) {
        /**
         * Functions with automatic retries enabled should throw exceptions to
         * indicate intermittent failures that a retry might fix. In this
         * case, a thrown exception will cause the original function
         * invocation to be re-sent.
         */
        throw new Exception('Intermittent failure occurred; retrying...');
    }

    /**
     * If a function with retries enabled encounters a non-retriable
     * failure, it should return *without* throwing an exception.
     */
    $log = fopen(getenv('LOGGER_OUTPUT') ?: 'php://stderr', 'wb');
    fwrite($log, 'Not retrying' . PHP_EOL);
}

Rendre les fonctions déclenchées par des événements idempotentes

Les fonctions basées sur des événements qui peuvent être relancées doivent être idempotentes. Voici quelques consignes générales pour créer une telle fonction :

  • De nombreuses API externes (telles que Stripe) vous permettent de fournir une clé d'idempotence en tant que paramètre. Si vous utilisez une telle API, vous devez utiliser l'ID d'événement comme clé d'idempotence.
  • L'idempotence fonctionne bien avec une livraison de type "au moins une fois", car elle permet de répéter la tentative en toute sécurité. Une bonne pratique pour écrire du code fiable consiste donc à combiner l'idempotence à la répétition des tentatives.
  • Assurez-vous que votre code est idempotent en interne. Exemple :
    • Assurez-vous que les mutations peuvent se produire plus d'une fois sans en changer le résultat.
    • Interrogez l'état de la base de données dans une transaction avant de muter l'état.
    • Assurez-vous que tous les effets secondaires sont eux-mêmes idempotents.
  • Imposez un contrôle transactionnel en dehors de la fonction, indépendamment du code. Par exemple, conservez l'état quelque part en notant qu'un ID d'événement donné a déjà été traité.
  • Gérez les appels de fonction doubles hors bande. Par exemple, mettez en place un processus de nettoyage distinct qui se lance après les appels de fonction doubles.

Configurer la stratégie de nouvelle tentative

En fonction des besoins de votre fonction Cloud, vous pouvez configurer directement la stratégie de nouvelle tentative. Cela vous permet de configurer n'importe quelle combinaison des éléments suivants :

  • Réduire la fenêtre de nouvelle tentative de sept jours une durée qui peut aller jusqu'à 10 minutes seulement.
  • Modifier le temps d'intervalle minimal et maximal pour la stratégie de nouvelle tentative avec intervalle exponentiel entre les tentatives.
  • Modifier la stratégie de nouvelle tentative pour réessayer immédiatement.
  • Configurer une file d'attente de lettres mortes.
  • Définir un nombre maximal et minimal de tentatives de livraison.

Pour configurer la stratégie de nouvelle tentative, procédez comme suit :

  1. Écrivez une fonction HTTP.
  2. Utilisez l'API Pub/Sub pour créer un abonnement Pub/Sub, en spécifiant l'URL de la fonction en tant que cible.

Consultez la documentation Pub/Sub sur la gestion des échecs pour en savoir plus sur la configuration directe de Pub/Sub.

Étapes suivantes