package httpapi import ( "context" "errors" "log/slog" "net/http" "time" "github.com/google/uuid" "github.com/syncova/syncova/packages/audit" "github.com/syncova/syncova/packages/auth" "github.com/syncova/syncova/packages/jobs" "github.com/syncova/syncova/packages/platform/logging" "github.com/syncova/syncova/packages/recovery" "github.com/syncova/syncova/packages/repository" ) // restoreHandler bedient die Wiederherstellung (SYNCOVA_API.md §13). type restoreHandler struct { // restoreStore ist die Datenzugriffsschicht der Wiederherstellungen. restoreStore *recovery.Store // jobStore loest Backups auf ihr Repository auf. jobStore *jobs.PostgresStore // auditRecorder protokolliert Wiederherstellungen. auditRecorder audit.Recorder // targetGuard begrenzt, wohin geschrieben werden darf (Phase 19). targetGuard *recovery.TargetGuard // logger protokolliert technische Fehler. logger *slog.Logger } // restoreRequest ist der Rumpf von POST /restores und /restores/validate. type restoreRequest struct { // BackupID ist das wiederherzustellende Backup. BackupID uuid.UUID `json:"backup_id"` // TargetType benennt die Art des Ziels. TargetType string `json:"target_type"` // TargetPath ist das Zielverzeichnis. TargetPath string `json:"target_path"` // PathPrefix beschraenkt auf einen Teilbaum. PathPrefix string `json:"path_prefix,omitempty"` // OverwriteExisting erlaubt das Ueberschreiben vorhandener Daten. OverwriteExisting bool `json:"overwrite_existing,omitempty"` // ConfirmOverwrite ist die ausdrueckliche Bestaetigung des Ueberschreibens. // // Ein zweites Feld neben OverwriteExisting ist keine Umstaendlichkeit: Ein // versehentlich gesetztes Kennzeichen in einem Skript oder einer Vorlage // reicht damit nicht aus, um Daten zu vernichten. Der Wert muss den // Zielpfad wiederholen — wer ihn abtippt, hat ihn gelesen. ConfirmOverwrite string `json:"confirm_overwrite,omitempty"` // SkipPermissions verzichtet auf das Setzen der urspruenglichen Rechte. SkipPermissions bool `json:"skip_permissions,omitempty"` // SkipDeepCheck ueberspringt die Blockpruefung. // // Sie ist der eigentliche Nachweis der Wiederherstellbarkeit und kostet bei // grossen Backups Zeit. Wer sie ueberspringt, bekommt das im Bericht gesagt. SkipDeepCheck bool `json:"skip_deep_check,omitempty"` } // restoreResponse ist die Darstellung eines Wiederherstellungsauftrags. type restoreResponse struct { // ID ist der oeffentliche Bezeichner. ID uuid.UUID `json:"id"` // BackupID ist das wiederherzustellende Backup. BackupID uuid.UUID `json:"backup_id"` // TargetType benennt die Art des Ziels. TargetType string `json:"target_type"` // TargetPath ist das Zielverzeichnis. TargetPath string `json:"target_path"` // PathPrefix beschraenkt auf einen Teilbaum. PathPrefix string `json:"path_prefix,omitempty"` // Status ist der Zustand. Status string `json:"status"` // OverwriteExisting meldet das Ueberschreiben vorhandener Daten. OverwriteExisting bool `json:"overwrite_existing"` // StartedAt ist der Beginn in UTC. StartedAt *time.Time `json:"started_at,omitempty"` // CompletedAt ist das Ende in UTC. CompletedAt *time.Time `json:"completed_at,omitempty"` // DurationSeconds ist die Dauer in Sekunden. DurationSeconds float64 `json:"duration_seconds,omitempty"` // BytesRestored ist die zurueckgeschriebene Datenmenge. BytesRestored int64 `json:"bytes_restored"` // FilesRestored ist die Zahl zurueckgeschriebener Objekte. FilesRestored int64 `json:"files_restored"` // FilesSkipped ist die Zahl uebergangener Objekte. FilesSkipped int64 `json:"files_skipped"` // ErrorCode ist die Fehlerkennung. ErrorCode string `json:"error_code,omitempty"` // ErrorMessage ist die verstaendliche Fehlermeldung. ErrorMessage string `json:"error_message,omitempty"` // ValidationReport ist das Ergebnis der Vorabpruefung. ValidationReport *recovery.ValidationReport `json:"validation_report,omitempty"` // Checkpoint ist der Fortschritt der offenen Sitzung. // // Er erscheint nur bei einem unterbrochenen Auftrag: Dort ist er die // Auskunft, wie weit die Wiederherstellung gekommen ist. Checkpoint *recovery.Checkpoint `json:"checkpoint,omitempty"` // CorrelationID verbindet den Auftrag mit seinen Protokollzeilen. CorrelationID uuid.UUID `json:"correlation_id"` // CreatedAt ist der Anlagezeitpunkt in UTC. CreatedAt time.Time `json:"created_at"` } // validationResponse ist die Antwort auf POST /restores/validate. type validationResponse struct { // CanProceed meldet, ob die Wiederherstellung beginnen darf. CanProceed bool `json:"can_proceed"` // RequiresOverwriteConfirmation meldet, dass Daten ueberschrieben wuerden. RequiresOverwriteConfirmation bool `json:"requires_overwrite_confirmation"` // Summary fasst das Ergebnis in einem Satz zusammen. Summary string `json:"summary"` // Report ist der vollstaendige Pruefbericht. Report *recovery.ValidationReport `json:"report"` } // handleValidateRestore bedient POST /restores/validate. // // Der Endpunkt schreibt **nichts**. Er ist damit gefahrlos und laesst sich // jederzeit aufrufen — auch als regelmaessiger Nachweis, dass die Backups // weiterhin wiederherstellbar sind, lange bevor jemand sie braucht. func (handler *restoreHandler) handleValidateRestore(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) var restorePayload restoreRequest if decodeError := decodeJSONBody(request, &restorePayload); decodeError != nil { WriteError(responseWriter, request, requestLogger, decodeError) return } validationReport, apiError := handler.runValidation(request.Context(), restorePayload) if apiError != nil { WriteError(responseWriter, request, requestLogger, apiError) return } WriteSuccess(responseWriter, request, http.StatusOK, validationResponse{ CanProceed: validationReport.CanProceed(), RequiresOverwriteConfirmation: needsOverwriteConfirmation(validationReport), Summary: validationReport.Summary(), Report: validationReport, }) } // needsOverwriteConfirmation meldet, ob am Ziel Daten ueberschrieben wuerden. func needsOverwriteConfirmation(validationReport *recovery.ValidationReport) bool { for _, finding := range validationReport.Findings { if finding.Code == "TARGET_NOT_EMPTY" || finding.Code == "TARGET_WILL_BE_OVERWRITTEN" { return true } } return false } // runValidation fuehrt die Vorabpruefung aus. func (handler *restoreHandler) runValidation(validationContext context.Context, restorePayload restoreRequest) (*recovery.ValidationReport, *APIError) { // Das Ziel wird geprueft, **bevor** irgendetwas anderes geschieht: Wer nach // /etc schreiben darf, braucht keine Luecke mehr. Die Meldung kommt schon // aus der Vorabpruefung, damit der Betreiber sie sieht, bevor er eine // Wiederherstellung anlegt. if handler.targetGuard != nil { if guardError := handler.targetGuard.Validate(restorePayload.TargetPath); guardError != nil { return nil, NewValidationError(guardError.Error()) } } repositoryPath, backupIDInRepository, resolveError := handler.resolveBackup(validationContext, restorePayload.BackupID) if resolveError != nil { return nil, resolveError } // Schreibgeschuetzt: Eine Pruefung darf nichts anfassen und soll neben einer // laufenden Sicherung stattfinden koennen. openedRepository, openError := repository.Open(validationContext, repositoryPath, repository.OpenOptions{ReadOnly: true}, handler.logger) if openError != nil { return nil, NewServiceUnavailableError( "Das Repository des Backups ist derzeit nicht erreichbar.") } defer func() { _ = openedRepository.Close() }() validator := recovery.NewValidator(openedRepository) validationReport, validationError := validator.Validate(validationContext, recovery.ValidationRequest{ BackupID: backupIDInRepository, TargetPath: restorePayload.TargetPath, PathPrefix: restorePayload.PathPrefix, OverwriteExisting: restorePayload.OverwriteExisting, DeepChunkCheck: !restorePayload.SkipDeepCheck, }) if validationError != nil { return nil, NewInternalError(validationError) } return validationReport, nil } // resolveBackup loest ein Backup auf Repository und Kennung auf. func (handler *restoreHandler) resolveBackup(resolveContext context.Context, backupIdentifier uuid.UUID) (string, string, *APIError) { backupRecord, readError := handler.jobStore.GetBackup(resolveContext, backupIdentifier) if readError != nil { if errors.Is(readError, jobs.ErrBackupNotFound) { return "", "", NewNotFoundError("Das Backup wurde nicht gefunden.") } return "", "", NewInternalError(readError) } repositoryRecord, repositoryError := handler.jobStore.GetRepository(resolveContext, backupRecord.RepositoryID) if repositoryError != nil { return "", "", NewInternalError(repositoryError) } return repositoryRecord.Location, backupRecord.BackupIDInRepository, nil } // handleCreateRestore bedient POST /restores. // // Der Auftrag wird eingereiht, nicht ausgefuehrt: Die Wiederherstellungsschleife // holt ihn im naechsten Durchgang. Deshalb 202 statt 201 — die Daten sind noch // nicht zurueck. func (handler *restoreHandler) handleCreateRestore(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) var restorePayload restoreRequest if decodeError := decodeJSONBody(request, &restorePayload); decodeError != nil { WriteError(responseWriter, request, requestLogger, decodeError) return } if restorePayload.TargetPath == "" { WriteError(responseWriter, request, requestLogger, NewValidationError("Es wurde kein Zielverzeichnis angegeben.")) return } targetType := recovery.TargetType(restorePayload.TargetType) if targetType == "" { targetType = recovery.TargetFilesystem } if targetType != recovery.TargetFilesystem && targetType != recovery.TargetOriginalLocation { WriteError(responseWriter, request, requestLogger, NewValidationError("Die Zielart ist unbekannt. Zulaessig sind filesystem und original_location.")) return } // Die Pruefung laeuft **vor** dem Anlegen. Ein Auftrag, der von vornherein // nicht gelingen kann, soll gar nicht erst in der Warteschlange stehen. validationReport, validationAPIError := handler.runValidation(request.Context(), restorePayload) if validationAPIError != nil { WriteError(responseWriter, request, requestLogger, validationAPIError) return } if apiError := handler.checkOverwriteConfirmation(request, restorePayload, validationReport, actingUser); apiError != nil { WriteError(responseWriter, request, requestLogger, apiError) return } if !validationReport.CanProceed() { blockingFindings := validationReport.BlockingFindings() validationError := NewValidationError(blockingFindings[0].Message) validationError.Details = map[string]any{ "findings": blockingFindings, "summary": validationReport.Summary(), } WriteError(responseWriter, request, requestLogger, validationError) return } backupRecord, _ := handler.jobStore.GetBackup(request.Context(), restorePayload.BackupID) createdRestore, createError := handler.restoreStore.CreateRestore(request.Context(), recovery.CreateRequest{ BackupID: restorePayload.BackupID, SourceType: "filesystem", TargetType: targetType, TargetRef: restorePayload.TargetPath, PathPrefix: restorePayload.PathPrefix, OverwriteExisting: restorePayload.OverwriteExisting, RestorePermissions: !restorePayload.SkipPermissions, VerifyContent: true, ValidationReport: validationReport, CreatedBy: &actingUser.ID, }) if createError != nil { WriteError(responseWriter, request, requestLogger, translateRestoreError(createError)) return } // Jede Wiederherstellung wird auditiert — auch die harmlose. Sie holt Daten // zurueck, die jemand einmal fuer schuetzenswert hielt. auditDetails := map[string]any{ "target_path": restorePayload.TargetPath, "target_type": string(targetType), "overwrite": restorePayload.OverwriteExisting, "path_prefix": restorePayload.PathPrefix, "restore_id": createdRestore.ID.String(), "file_count": validationReport.FileCount, "total_bytes": validationReport.TotalBytes, "deep_checked": !restorePayload.SkipDeepCheck, } if backupRecord != nil { auditDetails["backup_in_repository"] = backupRecord.BackupIDInRepository } auditAction := audit.ActionRestoreRequested if restorePayload.OverwriteExisting { auditAction = audit.ActionRestoreOverwriteRequested } handler.recordAudit(request, actingUser, auditAction, restorePayload.BackupID, auditDetails) WriteSuccess(responseWriter, request, http.StatusAccepted, buildRestoreResponse(createdRestore, nil)) } // checkOverwriteConfirmation prueft die Bestaetigung eines Ueberschreibens. // // Zwei Huerden statt einer: die Berechtigung restores.overwrite und die // woertliche Wiederholung des Zielpfades. Ein versehentlich gesetztes // Kennzeichen in einem Skript reicht damit nicht aus, um Daten zu vernichten — // wer den Pfad abtippt, hat ihn gelesen. func (handler *restoreHandler) checkOverwriteConfirmation(request *http.Request, restorePayload restoreRequest, validationReport *recovery.ValidationReport, actingUser auth.User) *APIError { if !restorePayload.OverwriteExisting { return nil } if !userHasPermission(actingUser, "restores.overwrite") { permissionError := NewValidationError( "Fuer das Ueberschreiben vorhandener Daten fehlt die Berechtigung restores.overwrite.") permissionError.Code = ErrorCodePermissionDenied permissionError.StatusCode = http.StatusForbidden return permissionError } // Ist am Ziel nichts zu ueberschreiben, braucht es keine Bestaetigung: Der // Schalter laeuft dann ins Leere. if !needsOverwriteConfirmation(validationReport) { return nil } if restorePayload.ConfirmOverwrite != restorePayload.TargetPath { confirmationError := NewValidationError( "Diese Wiederherstellung ueberschreibt vorhandene Daten. Wiederholen Sie zur Bestaetigung " + "den Zielpfad im Feld confirm_overwrite.") confirmationError.Details = map[string]any{ "target_path": restorePayload.TargetPath, "confirm_with": restorePayload.TargetPath, } return confirmationError } logging.WithContext(request.Context(), handler.logger).Warn( "eine wiederherstellung ueberschreibt vorhandene daten", slog.String("ziel", restorePayload.TargetPath), slog.String("benutzer", actingUser.Username)) return nil } // userHasPermission prueft eine Berechtigung des angemeldeten Benutzers. func userHasPermission(actingUser auth.User, permissionName string) bool { for _, grantedPermission := range actingUser.Permissions { if grantedPermission == permissionName { return true } } return false } // handleListRestores bedient GET /restores. func (handler *restoreHandler) handleListRestores(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) requestedPage := parsePositiveInteger(request.URL.Query().Get("page"), 1) requestedPageSize := parsePositiveInteger(request.URL.Query().Get("page_size"), 50) loadedRestores, totalCount, listError := handler.restoreStore.ListRestores( request.Context(), requestedPage, requestedPageSize) if listError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(listError)) return } restoreResponses := make([]restoreResponse, 0, len(loadedRestores)) for restoreIndex := range loadedRestores { restoreResponses = append(restoreResponses, buildRestoreResponse(&loadedRestores[restoreIndex], nil)) } WritePaginatedSuccess(responseWriter, request, restoreResponses, PaginationMeta{ Page: requestedPage, PageSize: requestedPageSize, Total: int64(totalCount), }) } // handleGetRestore bedient GET /restores/{id}. func (handler *restoreHandler) handleGetRestore(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) restoreIdentifier, parseError := parseRestoreIdentifier(request) if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } loadedRestore, readError := handler.restoreStore.GetRestore(request.Context(), restoreIdentifier) if readError != nil { WriteError(responseWriter, request, requestLogger, translateRestoreError(readError)) return } // Der Pruefpunkt kommt mit, wenn eine Sitzung offen ist: Bei einem // unterbrochenen Auftrag ist er die Auskunft, wie weit es gekommen ist. activeSession, _ := handler.restoreStore.GetActiveSession(request.Context(), restoreIdentifier) var checkpoint *recovery.Checkpoint if activeSession != nil && activeSession.Checkpoint.LastCompletedPath != "" { checkpoint = &activeSession.Checkpoint } WriteSuccess(responseWriter, request, http.StatusOK, buildRestoreResponse(loadedRestore, checkpoint)) } // handleCancelRestore bedient POST /restores/{id}/cancel. func (handler *restoreHandler) handleCancelRestore(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) restoreIdentifier, parseError := parseRestoreIdentifier(request) if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } existingRestore, readError := handler.restoreStore.GetRestore(request.Context(), restoreIdentifier) if readError != nil { WriteError(responseWriter, request, requestLogger, translateRestoreError(readError)) return } if cancelError := handler.restoreStore.CancelRestore(request.Context(), restoreIdentifier, "Die Wiederherstellung wurde von "+actingUser.Username+" abgebrochen."); cancelError != nil { WriteError(responseWriter, request, requestLogger, translateRestoreError(cancelError)) return } handler.recordAudit(request, actingUser, audit.ActionRestoreCancelled, existingRestore.BackupID, map[string]any{ "restore_id": restoreIdentifier.String(), "target_path": existingRestore.TargetRef, }) cancelledRestore, _ := handler.restoreStore.GetRestore(request.Context(), restoreIdentifier) // Ein abgebrochener Lauf hinterlaesst einen Pruefpunkt. Das wird gesagt: // Sonst haelt man das Ziel fuer unberuehrt, obwohl dort bereits Daten liegen. WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{ "restore": buildRestoreResponse(cancelledRestore, nil), "message": "Der Abbruch wurde vermerkt. Bereits zurueckgeschriebene Daten bleiben am Ziel liegen; " + "der Pruefpunkt erlaubt eine Fortsetzung.", }) } // handleResumeRestore bedient POST /restores/{id}/resume. // // Der Auftrag wird erneut eingereiht und setzt am Pruefpunkt seiner Sitzung // fort. Ohne diesen Endpunkt fuehrte der Weg nur ueber die Datenbank — und ein // unterbrochener Grossrestore begaenne von vorn. func (handler *restoreHandler) handleResumeRestore(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) restoreIdentifier, parseError := parseRestoreIdentifier(request) if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } existingRestore, readError := handler.restoreStore.GetRestore(request.Context(), restoreIdentifier) if readError != nil { WriteError(responseWriter, request, requestLogger, translateRestoreError(readError)) return } // Ein ueberschreibender Auftrag bleibt ueberschreibend: Die Fortsetzung // erbt sein Kennzeichen. Sie verlangt deshalb dieselbe Berechtigung wie das // erstmalige Anlegen — sonst waere sie ein Weg daran vorbei. if existingRestore.OverwriteExisting && !userHasPermission(actingUser, "restores.overwrite") { permissionError := NewValidationError( "Dieser Auftrag ueberschreibt vorhandene Daten. Fuer die Fortsetzung fehlt die " + "Berechtigung restores.overwrite.") permissionError.Code = ErrorCodePermissionDenied permissionError.StatusCode = http.StatusForbidden WriteError(responseWriter, request, requestLogger, permissionError) return } resumedRestore, resumeError := handler.restoreStore.ResumeRestore(request.Context(), restoreIdentifier) if resumeError != nil { WriteError(responseWriter, request, requestLogger, translateRestoreError(resumeError)) return } activeSession, _ := handler.restoreStore.GetActiveSession(request.Context(), restoreIdentifier) resumePath := "" if activeSession != nil { resumePath = activeSession.Checkpoint.LastCompletedPath } handler.recordAudit(request, actingUser, audit.ActionRestoreResumed, existingRestore.BackupID, map[string]any{ "restore_id": restoreIdentifier.String(), "target_path": existingRestore.TargetRef, "resume_from": resumePath, }) var checkpoint *recovery.Checkpoint if activeSession != nil && activeSession.Checkpoint.LastCompletedPath != "" { checkpoint = &activeSession.Checkpoint } responseMessage := "Der Auftrag wurde erneut eingereiht und beginnt von vorn." if resumePath != "" { responseMessage = "Der Auftrag wurde erneut eingereiht und setzt nach " + resumePath + " fort." } WriteSuccess(responseWriter, request, http.StatusAccepted, map[string]any{ "restore": buildRestoreResponse(resumedRestore, checkpoint), "message": responseMessage, }) } // recordAudit schreibt ein Auditereignis. func (handler *restoreHandler) recordAudit(request *http.Request, actingUser auth.User, auditAction audit.Action, backupIdentifier uuid.UUID, auditDetails map[string]any) { if handler.auditRecorder == nil { return } correlationID, _ := logging.CorrelationIDFromContext(request.Context()) recordError := handler.auditRecorder.Record(request.Context(), audit.Event{ UserID: &actingUser.ID, ActorUsername: actingUser.Username, Action: auditAction, EntityType: "backup", EntityID: &backupIdentifier, Result: audit.ResultSuccess, IPAddress: clientIPAddress(request), UserAgent: request.UserAgent(), CorrelationID: correlationID, Details: auditDetails, }) if recordError != nil { logging.WithContext(request.Context(), handler.logger).Error( "das auditereignis konnte nicht geschrieben werden", slog.String("aktion", string(auditAction)), slog.String("grund", recordError.Error())) } } // buildRestoreResponse wandelt einen Auftrag in seine Darstellung. func buildRestoreResponse(sourceRestore *recovery.RestoreJob, checkpoint *recovery.Checkpoint) restoreResponse { builtResponse := restoreResponse{ ID: sourceRestore.ID, BackupID: sourceRestore.BackupID, TargetType: string(sourceRestore.TargetType), TargetPath: sourceRestore.TargetRef, PathPrefix: sourceRestore.PathPrefix, Status: string(sourceRestore.Status), OverwriteExisting: sourceRestore.OverwriteExisting, StartedAt: sourceRestore.StartedAt, CompletedAt: sourceRestore.CompletedAt, BytesRestored: sourceRestore.BytesRestored, FilesRestored: sourceRestore.FilesRestored, FilesSkipped: sourceRestore.FilesSkipped, ErrorCode: sourceRestore.ErrorCode, ErrorMessage: sourceRestore.ErrorMessage, ValidationReport: sourceRestore.ValidationReport, Checkpoint: checkpoint, CorrelationID: sourceRestore.CorrelationID, CreatedAt: sourceRestore.CreatedAt, } if sourceRestore.StartedAt != nil && sourceRestore.CompletedAt != nil { builtResponse.DurationSeconds = sourceRestore.CompletedAt.Sub(*sourceRestore.StartedAt).Seconds() } return builtResponse } // parseRestoreIdentifier liest die Auftragskennung aus dem Pfad. func parseRestoreIdentifier(request *http.Request) (uuid.UUID, *APIError) { restoreIdentifier, parseError := uuid.Parse(request.PathValue("id")) if parseError != nil { return uuid.Nil, NewBadRequestError("Die Auftragskennung ist keine gueltige UUID.") } return restoreIdentifier, nil } // translateRestoreError bildet Fehler der Fachschicht auf API-Fehler ab. func translateRestoreError(occurredError error) *APIError { switch { case errors.Is(occurredError, recovery.ErrRestoreNotFound): return NewNotFoundError("Der Wiederherstellungsauftrag wurde nicht gefunden.") case errors.Is(occurredError, recovery.ErrRestoreNotResumable): // Ein gelungener oder laufender Auftrag laesst sich nicht fortsetzen — // das ist kein Serverfehler, sondern der falsche Zustand. notResumableError := NewValidationError( "Nur ein gescheiterter oder abgebrochener Auftrag laesst sich fortsetzen.") notResumableError.Code = ErrorCodeConflict notResumableError.StatusCode = http.StatusConflict return notResumableError case errors.Is(occurredError, recovery.ErrTargetBusy): // 409 und nicht 500: Der Aufrufer hat nichts falsch gemacht, in das Ziel // wird nur bereits geschrieben. conflictError := NewValidationError( "In dieses Ziel wird bereits wiederhergestellt. Warten Sie das Ende ab.") conflictError.Code = ErrorCodeConflict conflictError.StatusCode = http.StatusConflict return conflictError default: return NewInternalError(occurredError) } }