Petit déploiement, gros problème : une simple fonction après formulaire peut échouer en production

Une fonction après formulaire peut éprouver tout le processus : état frontend, idempotence API, Stripe, e-mails, secrets, tests, Azure et rollback. Voici comment préparer ce petit déploiement sans deviner.

L’article complet est disponible ci-dessous. Activez JavaScript pour les quiz et les simulations.

Prêt pour la production, en pratique

Une petite fonction peut éprouver tout le processus de déploiement.

Une fenêtre après le formulaire, trois montants et une redirection vers le paiement. Simple à l’écran. En production, cela touche l’état frontend, l’API, l’idempotence, Stripe, les e-mails, les secrets, les tests, l’analytique et le rollback.

Certaines fonctions paraissent banales sur une maquette : une fenêtre, trois boutons, un texte court, puis une redirection vers le paiement. Affaire réglée.

Mais la production ne juge pas l’apparence dans Figma ou localement sur localhost. Elle juge si l’action fonctionne, si le backend ne révèle pas trop, si le checkout est correctement décrit, si l’e-mail part, si le nouveau binaire tourne, si la vérification est possible et si un retour existe.

Cet article traite de cette simple fonction après formulaire qui peut se désorganiser entre frontend, API, configuration, Stripe, e-mail et déploiement. Un détail en apparence ; un petit examen de maturité en pratique.

Le véritable besoin

Imaginons un formulaire de demande. Après son envoi réussi, un écran facultatif propose : « Si vous le souhaitez, offrez un café pour un retour plus rapide. » Il ne s’agit pas de l’achat d’un service, mais d’un complément volontaire, clair et sans pression.

Sur le papier, le parcours est simple :

  • le formulaire envoie le message,
  • le backend enregistre le contact,
  • le frontend affiche la fenêtre,
  • l’utilisateur la ferme ou continue vers Stripe,
  • Stripe crée le checkout,
  • le système conserve une trace,
  • puis l’utilisateur peut revenir au site.

Cette simplicité apparente facilite justement les erreurs.

Frontend

L’état de la fenêtre a besoin d’un propriétaire. Sinon, « fermer » peut ne rien changer.

Backend

Le checkout exige validation et idempotence. Un clic ne doit pas créer du désordre.

Déploiement

Un nouveau frontend avec une ancienne API n’est qu’une demi-publication. La production exige une preuve.

Les erreurs fréquentes :

  • fenêtre impossible à fermer parce que l’état est fixé en dur,
  • bouton de paiement qui attend sans fin, faute de gestion du timeout ou de l’erreur API,
  • checkout sans idempotence créant plusieurs sessions pour la même action,
  • ancien texte dans Stripe parce que seul le frontend a été publié,
  • e-mail peu lisible ou logo absent dans Gmail,
  • événement important ignoré par l’analytique,
  • smoke test limité à /health, sans contrôle du parcours utilisateur,
  • version réellement en production inconnue après publication.

La « petite fonction » cesse alors d’être petite.

Premier piège : l’interface marche en local, mais son état n’est pas maîtrisé

L’exemple le plus simple est une fenêtre impossible à fermer. Le code semble innocent :

<app-coffee-offer-modal [open]="true"></app-coffee-offer-modal>

Petit détail, gros défaut en production. Si open est fixé en dur, le composant peut émettre close, nettoyer son état et se comporter correctement ; le parent continuera à lui imposer de rester ouvert.

Le principe est simple : l’état doit appartenir au processus, pas au hasard.

isCoffeeOpen = false;
lastContactId: string | null = null;

onContactSent(contactId: string): void {
  this.lastContactId = contactId;
  this.isCoffeeOpen = true;
}

closeCoffeeOffer(): void {
  this.isCoffeeOpen = false;
}
<app-coffee-offer-modal
  *ngIf="lastContactId"
  [open]="isCoffeeOpen"
  [sourceId]="lastContactId"
  sourceType="contact"
  (close)="closeCoffeeOffer()">
</app-coffee-offer-modal>

L’exemple est modeste, mais montre qu’un frontend de production exige un propriétaire de l’état, une entrée et une sortie explicites.

Deuxième piège : le checkout ne doit pas être une redirection improvisée

Le paiement paraît simple : clic, URL renvoyée par le backend, puis le frontend exécute window.location.assign(url).

Mais une URL vide, incorrecte, extérieure à Stripe ou plusieurs clics suffisent à créer des incohérences.

private redirectToCheckout(rawUrl: string): boolean {
  try {
    const url = new URL(rawUrl, window.location.origin);
    const isStripeCheckout =
      url.protocol === 'https:' &&
      (url.hostname === 'checkout.stripe.com' || url.hostname.endsWith('.stripe.com'));

    if (!isStripeCheckout) {
      return false;
    }

    window.location.assign(url.toString());
    return true;
  } catch {
    return false;
  }
}

Ce contrôle ne constitue pas toute la sécurité du paiement, mais une couche d’hygiène importante. Le frontend ne doit pas suivre aveuglément n’importe quelle URL renvoyée par l’API.

Backend : validation, idempotence et description claire

Le backend doit valider, assurer l’idempotence et définir explicitement les actions autorisées. Cela compte particulièrement pour les paiements, même volontaires.

public sealed record CoffeeCheckoutCreateDto(
    string SourceType,
    Guid SourceId,
    int AmountPln,
    string? Email);

private static readonly int[] AllowedAmounts = [15, 25, 50];

app.MapPost("/api/v1/coffee/checkout", async (
    CoffeeCheckoutCreateDto dto,
    AppDbContext db,
    IStripeCheckoutFactory stripe,
    CancellationToken ct) =>
{
    if (!AllowedAmounts.Contains(dto.AmountPln))
    {
        return Results.ValidationProblem(new Dictionary<string, string[]>
        {
            ["amountPln"] = ["Unsupported amount."]
        });
    }

    var sourceType = dto.SourceType.Trim().ToLowerInvariant();
    if (sourceType is not ("contact" or "audit"))
    {
        return Results.ValidationProblem(new Dictionary<string, string[]>
        {
            ["sourceType"] = ["Unsupported source type."]
        });
    }

    var idempotencyKey = $"{sourceType}:{dto.SourceId}:{dto.AmountPln}";

    var existing = await db.CoffeeCheckouts
        .AsNoTracking()
        .FirstOrDefaultAsync(x => x.IdempotencyKey == idempotencyKey, ct);

    if (existing?.StripeSessionUrl is not null)
    {
        return Results.Ok(new { url = existing.StripeSessionUrl });
    }

    var checkout = await stripe.CreateSessionAsync(new StripeCoffeeSessionRequest
    {
        AmountPln = dto.AmountPln,
        Email = dto.Email,
        ProductName = "Un café pour le retour et une réponse plus rapide",
        ProductDescription =
            "Une contribution volontaire pour offrir un café pendant la préparation du retour, l’analyse de la demande et la définition des prochaines étapes. Elle ne remplace pas le devis du projet.",
        IdempotencyKey = idempotencyKey
    }, ct);

    db.CoffeeCheckouts.Add(new CoffeeCheckout
    {
        Id = Guid.NewGuid(),
        SourceType = sourceType,
        SourceId = dto.SourceId,
        AmountPln = dto.AmountPln,
        IdempotencyKey = idempotencyKey,
        StripeSessionId = checkout.SessionId,
        StripeSessionUrl = checkout.Url,
        Status = "created",
        CreatedAt = DateTimeOffset.UtcNow
    });

    await db.SaveChangesAsync(ct);

    return Results.Ok(new { url = checkout.Url });
});

Trois points essentiels :

  • les montants proviennent d’une liste autorisée, pas d’une saisie arbitraire,
  • l’origine est explicite et limitée,
  • une même action ne génère pas indéfiniment de nouvelles sessions.

Peu de code, mais une grande différence en production.

Azure : configuration hors du dépôt et feature flags

En local, il est facile d’insérer une clé dans appsettings.json, de lancer et de conclure que cela fonctionne. En production, c’est une mauvaise direction.

Clés Stripe, secrets des webhooks, mots de passe, chaînes de connexion et jetons de déploiement n’appartiennent pas au dépôt. Ils doivent être gérés dans l’environnement, un coffre ou un service de configuration adapté.

Dans Azure, un ensemble pertinent comprend :

  • Azure App Configuration pour les options et réglages opérationnels,
  • Azure Key Vault pour les secrets,
  • Managed Identity plutôt que des mots de passe permanents,
  • deployment slots pour préparer la publication.
using Azure.Identity;
using Microsoft.FeatureManagement;

var builder = WebApplication.CreateBuilder(args);
var credential = new DefaultAzureCredential();

var appConfigEndpoint = builder.Configuration["Azure:AppConfigurationEndpoint"];

if (!string.IsNullOrWhiteSpace(appConfigEndpoint))
{
    builder.Configuration.AddAzureAppConfiguration(options =>
    {
        options.Connect(new Uri(appConfigEndpoint), credential)
            .ConfigureKeyVault(kv => kv.SetCredential(credential))
            .UseFeatureFlags(flagOptions =>
            {
                flagOptions.CacheExpirationInterval = TimeSpan.FromSeconds(30);
            });
    });

    builder.Services.AddAzureAppConfiguration();
    builder.Services.AddFeatureManagement();
}

Puis l’utilisation d’un feature flag :

app.MapPost("/api/v1/coffee/checkout", async (
    CoffeeCheckoutCreateDto dto,
    IFeatureManager featureManager,
    CancellationToken ct) =>
{
    if (!await featureManager.IsEnabledAsync("CoffeeCheckout", ct))
    {
        return Results.Problem(
            title: "Feature disabled",
            detail: "Coffee checkout is currently unavailable.",
            statusCode: StatusCodes.Status503ServiceUnavailable);
    }

    // logique effective du checkout
    return Results.Ok();
});

Si quelque chose échoue après publication, on peut couper uniquement la fonction sans immédiatement revenir sur toute l’application.

Quatrième piège : déployer le frontend ne déploie pas toute la fonction

Ce type de fonctionnalité possède souvent deux parties :

  • le frontend affiche la fenêtre et appelle l’endpoint,
  • le backend crée le checkout, envoie l’e-mail et enregistre l’état.

Avec le seul frontend, le nouvel écran apparaît mais Stripe reçoit encore l’ancienne description. Avec le seul backend, la nouvelle logique existe sans être appelée.

Couche À vérifier Preuve
Frontend Présence de la nouvelle fenêtre dans les fichiers publiés Hash du build, main.*.js, test dans le navigateur
API Exécution du nouveau binaire Redémarrage du service, statut, journal de démarrage
Configuration Secrets fournis par l’environnement ou le coffre Aucun secret dans le dépôt, variables d’environnement
Stripe Description correcte dans la nouvelle session Session de checkout de test sans paiement
E-mail Rendu du logo et du contenu Message test dans Gmail
Rollback Possibilité de désactiver la fonction Feature flag ou release précédente

Le health public doit rester sobre

Un endpoint de santé est utile, mais ne doit pas devenir un rapport d’exploitation offert à Internet.

{
  "status": "OK",
  "database": "connected",
  "stripeMode": "live",
  "mailProvider": "smtp",
  "tenantCrypto": "configured",
  "pilotOwnerEmail": "..."
}

Ces détails ne sont pas nécessairement des secrets, mais facilitent la collecte d’informations. Gardez le health public sobre.

app.MapGet("/health", () => Results.Ok(new { status = "OK" }));

Le diagnostic détaillé doit être réservé aux administrateurs ou à une supervision authentifiée.

Un smoke test qui vérifie réellement quelque chose

Le smoke test ne prouve pas tout le système. Il détecte les erreurs habituelles d’une release. Ici : site, health, validation du checkout, image de l’e-mail et absence d’erreurs console.

public sealed class ProductionSmokeProbe
{
    private readonly HttpClient _http;

    public ProductionSmokeProbe(HttpClient http)
    {
        _http = http;
    }

    public async Task<SmokeProbeResult> RunAsync(Uri origin, CancellationToken ct)
    {
        var health = await _http.GetFromJsonAsync<HealthDto>(
            new Uri(origin, "/health"), ct);

        if (health?.Status != "OK")
        {
            return SmokeProbeResult.Fail("Health endpoint is not OK.");
        }

        using var invalidCheckout = await _http.PostAsJsonAsync(
            new Uri(origin, "/api/v1/coffee/checkout"),
            new { sourceType = "contact", sourceId = Guid.Empty, amountPln = 999 },
            ct);

        if (invalidCheckout.IsSuccessStatusCode)
        {
            return SmokeProbeResult.Fail("Checkout accepted invalid amount.");
        }

        using var logo = await _http.GetAsync(new Uri(origin, "/email/logo.png"), ct);
        if (!logo.IsSuccessStatusCode)
        {
            return SmokeProbeResult.Fail("Email logo asset is missing.");
        }

        return SmokeProbeResult.Ok();
    }
}

public sealed record HealthDto(string Status);

public sealed record SmokeProbeResult(bool Success, string Message)
{
    public static SmokeProbeResult Ok() => new(true, "Production smoke passed.");
    public static SmokeProbeResult Fail(string message) => new(false, message);
}

Azure Function comme sonde contrôlée

La même sonde peut tourner dans une Azure Function, manuellement après publication ou automatiquement dans le pipeline.

public sealed class ProductionSmokeFunction
{
    private readonly ProductionSmokeProbe _probe;

    public ProductionSmokeFunction(ProductionSmokeProbe probe)
    {
        _probe = probe;
    }

    [Function("production-smoke")]
    public async Task<HttpResponseData> Run(
        [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req,
        FunctionContext context)
    {
        var result = await _probe.RunAsync(
            new Uri("https://www.arccloudstudio.com"),
            context.CancellationToken);

        var response = req.CreateResponse(result.Success
            ? HttpStatusCode.OK
            : HttpStatusCode.InternalServerError);

        await response.WriteAsJsonAsync(result, context.CancellationToken);
        return response;
    }
}

Important : elle ne doit pas effectuer de vrai paiement. Elle contrôle les contrats et les protections essentielles du parcours.

Le rollback d’une petite fonction

On oublie souvent le retour arrière pour une petite fonction. C’est une erreur.

Revenir en arrière peut simplement consister à :

  • désactiver le feature flag,
  • masquer la fenêtre côté frontend,
  • bloquer la création de checkout,
  • restaurer l’ancienne description du produit,
  • passer à un traitement manuel.
public enum ReleaseDecision
{
    Continue,
    DisableFeature,
    RollbackApi,
    RollbackFrontend,
    ManualHandling
}

public static ReleaseDecision Decide(SmokeProbeResult smoke, int errorRate, bool paymentImpact)
{
    if (!smoke.Success && paymentImpact)
    {
        return ReleaseDecision.DisableFeature;
    }

    if (errorRate > 5)
    {
        return ReleaseDecision.RollbackApi;
    }

    return ReleaseDecision.Continue;
}

Azure App Service : le slot comme zone de préparation

Avec Azure App Service, un deployment slot est un bon modèle. Publiez l’API dans le slot staging, exécutez le smoke test puis effectuez le swap.

az webapp deployment slot create \
  --name arccloud-api \
  --resource-group rg-arccloud-prod \
  --slot staging

az webapp deploy \
  --name arccloud-api \
  --resource-group rg-arccloud-prod \
  --slot staging \
  --src-path ./publish.zip \
  --type zip

curl -fsS https://arccloud-api-staging.azurewebsites.net/health

az webapp deployment slot swap \
  --name arccloud-api \
  --resource-group rg-arccloud-prod \
  --slot staging \
  --target-slot production

Sa valeur est de transformer la release en transition contrôlée, plutôt qu’en saut dans l’inconnu.

Ce que le client en retire

Le client ne voit ni idempotence, ni Key Vault, ni feature flag. Il ne se préoccupe pas des slots ou de systemd sur un VPS.

Il voit plus simplement :

  • j’ai cliqué et cela a fonctionné,
  • j’ai fermé et la fenêtre a disparu,
  • j’ai payé ou ignoré sans rester bloqué,
  • j’ai reçu un message clair,
  • le site ne ressemblait pas à une expérience de test.

Toute la technologie sert à rendre ce résultat prévisible.

Liste de contrôle avant publication

Plusieurs réponses « je ne sais pas » signifient que la fonction n’est pas prête.

  • La fenêtre a-t-elle un propriétaire d’état et peut-elle se fermer ?
  • L’API valide-t-elle les entrées au lieu de faire confiance au frontend ?
  • Le checkout est-il idempotent ?
  • La description Stripe dit-elle honnêtement ce que paie l’utilisateur ?
  • Les endpoints publics évitent-ils de révéler les détails du backend ?
  • Les secrets sont-ils hors du dépôt ?
  • L’e-mail est-il correctement affiché dans Gmail ?
  • Les fichiers sont-ils légers et accessibles ?
  • GA4 voit-il les événements utiles ?
  • Une preuve confirme-t-elle la cohérence des versions frontend et API ?
  • Peut-on désactiver la fonction sans arrêter tout le système ?

Conclusion

Les pièges de production commencent souvent par une fenêtre, une redirection, une description Stripe, un e-mail, un flag ou un endpoint plutôt que par une grande architecture.

Sans processus, la fonction reste à moitié publiée : frontend neuf, API ancienne, checkout confus, fenêtre bloquée et équipe incertaine de la version en ligne.

Un déploiement réussi dépasse build passed. Il explique ce qui a changé, où sont les preuves, comment mesurer l’effet, comment réagir à l’erreur et retrouver rapidement un état stable.

C’est pourquoi une fonction simple constitue parfois le meilleur test de préparation à la production.