diff --git a/services/distribution/pom.xml b/services/distribution/pom.xml
--- a/services/distribution/pom.xml
+++ b/services/distribution/pom.xml
@@ -78,6 +78,11 @@
       <groupId>org.apache.commons</groupId>
       <version>3.2</version>
     </dependency>
+    <dependency>
+      <artifactId>spring-retry</artifactId>
+      <groupId>org.springframework.retry</groupId>
+      <version>1.3.0</version>
+    </dependency>
   </dependencies>

 </project>
\ No newline at end of file
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/config/DistributionServiceConfig.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/config/DistributionServiceConfig.java
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/config/DistributionServiceConfig.java
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/config/DistributionServiceConfig.java
@@ -394,6 +394,7 @@ public static class ObjectStore {
     private Integer port;
     private String bucket;
     private Boolean setPublicReadAclOnPutObject;
+    private Integer maxNumberOfFailedOperations;

     public String getAccessKey() {
       return accessKey;
@@ -442,5 +443,13 @@ public Boolean isSetPublicReadAclOnPutObject() {
     public void setSetPublicReadAclOnPutObject(Boolean setPublicReadAclOnPutObject) {
       this.setPublicReadAclOnPutObject = setPublicReadAclOnPutObject;
     }
+
+    public Integer getMaxNumberOfFailedOperations() {
+      return maxNumberOfFailedOperations;
+    }
+
+    public void setMaxNumberOfFailedOperations(Integer maxNumberOfFailedOperations) {
+      this.maxNumberOfFailedOperations = maxNumberOfFailedOperations;
+    }
   }
 }
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/objectstore/FailedObjectStoreOperationsCounter.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/objectstore/FailedObjectStoreOperationsCounter.java
new file mode 100644
--- /dev/null
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/objectstore/FailedObjectStoreOperationsCounter.java
@@ -0,0 +1,58 @@
+/*-
+ * ---license-start
+ * Corona-Warn-App
+ * ---
+ * Copyright (C) 2020 SAP SE and all other contributors
+ * ---
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ---license-end
+ */
+
+package app.coronawarn.server.services.distribution.objectstore;
+
+import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
+import app.coronawarn.server.services.distribution.objectstore.client.ObjectStoreOperationFailedException;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+/**
+ * Maintains the count and maximum number of failed object store operations in a thread-safe manner.
+ */
+@Component
+public class FailedObjectStoreOperationsCounter {
+
+  private static final Logger logger = LoggerFactory.getLogger(FailedObjectStoreOperationsCounter.class);
+
+  private final int maxNumberOfFailedOperations;
+  private final AtomicInteger failedOperationsCounter = new AtomicInteger(0);
+
+  public FailedObjectStoreOperationsCounter(DistributionServiceConfig distributionServiceConfig) {
+    maxNumberOfFailedOperations = distributionServiceConfig.getObjectStore().getMaxNumberOfFailedOperations();
+  }
+
+  /**
+   * Increments the internal failed operations counter and rethrows the specified exception if the configured maximum
+   * number of failed object store operation was exceeded.
+   *
+   * @param cause The {@link ObjectStoreOperationFailedException} that is associated with the failed operation.
+   */
+  public void incrementAndCheckThreshold(ObjectStoreOperationFailedException cause) {
+    logger.error("Object store operation failed.", cause);
+    if (failedOperationsCounter.incrementAndGet() > maxNumberOfFailedOperations) {
+      logger.error("Number of failed object store operations exceeded threshold of {}.", maxNumberOfFailedOperations);
+      throw cause;
+    }
+  }
+}
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/objectstore/S3Publisher.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/objectstore/S3Publisher.java
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/objectstore/S3Publisher.java
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/objectstore/S3Publisher.java
@@ -21,11 +21,13 @@
 package app.coronawarn.server.services.distribution.objectstore;

 import app.coronawarn.server.services.distribution.assembly.component.CwaApiStructureProvider;
+import app.coronawarn.server.services.distribution.objectstore.client.ObjectStoreOperationFailedException;
 import app.coronawarn.server.services.distribution.objectstore.publish.LocalFile;
 import app.coronawarn.server.services.distribution.objectstore.publish.PublishFileSet;
 import app.coronawarn.server.services.distribution.objectstore.publish.PublishedFileSet;
 import java.io.IOException;
 import java.nio.file.Path;
+import java.util.List;
 import java.util.stream.Collectors;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -48,18 +50,29 @@ public class S3Publisher {

   private static final Logger logger = LoggerFactory.getLogger(S3Publisher.class);

-  /** The default CWA root folder, which contains all CWA related files. */
+  /**
+   * The default CWA root folder, which contains all CWA related files.
+   */
   private static final String CWA_S3_ROOT = CwaApiStructureProvider.VERSION_DIRECTORY;

-  /** root folder for the upload on the local disk. */
   private final Path root;
+  private final ObjectStoreAccess objectStoreAccess;
+  private final FailedObjectStoreOperationsCounter failedOperationsCounter;

-  /** access to the object store. */
-  private final ObjectStoreAccess access;
-
-  public S3Publisher(Path root, ObjectStoreAccess access) {
+  /**
+   * Creates an {@link S3Publisher} instance that attempts to publish the files at the specified location to an object
+   * store. Object store operations are performed through the specified {@link ObjectStoreAccess} instance.
+   *
+   * @param root                    The path of the directory that shall be published.
+   * @param objectStoreAccess       The {@link ObjectStoreAccess} used to communicate with the object store.
+   * @param failedOperationsCounter The {@link FailedObjectStoreOperationsCounter} that is used to monitor the number of
+   *                                failed operations.
+   */
+  public S3Publisher(Path root, ObjectStoreAccess objectStoreAccess,
+      FailedObjectStoreOperationsCounter failedOperationsCounter) {
     this.root = root;
-    this.access = access;
+    this.objectStoreAccess = objectStoreAccess;
+    this.failedOperationsCounter = failedOperationsCounter;
   }

   /**
@@ -68,18 +81,29 @@ public S3Publisher(Path root, ObjectStoreAccess access) {
    * @throws IOException in case there were problems reading files from the disk.
    */
   public void publish() throws IOException {
-    var published = new PublishedFileSet(access.getObjectsWithPrefix(CWA_S3_ROOT));
-    var toPublish = new PublishFileSet(root);
+    PublishedFileSet published;
+    List<LocalFile> toPublish = new PublishFileSet(root).getFiles();
+    List<LocalFile> diff;

-    var diff = toPublish
-        .getFiles()
-        .stream()
-        .filter(published::isNotYetPublished)
-        .collect(Collectors.toList());
+    try {
+      published = new PublishedFileSet(objectStoreAccess.getObjectsWithPrefix(CWA_S3_ROOT));
+      diff = toPublish
+          .stream()
+          .filter(published::isNotYetPublished)
+          .collect(Collectors.toList());
+    } catch (ObjectStoreOperationFailedException e) {
+      failedOperationsCounter.incrementAndCheckThreshold(e);
+      // failed to retrieve existing files; publish everything
+      diff = toPublish;
+    }

     logger.info("Beginning upload... ");
     for (LocalFile file : diff) {
-      this.access.putObject(file);
+      try {
+        this.objectStoreAccess.putObject(file);
+      } catch (ObjectStoreOperationFailedException e) {
+        failedOperationsCounter.incrementAndCheckThreshold(e);
+      }
     }
     logger.info("Upload completed.");
   }
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/objectstore/S3RetentionPolicy.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/objectstore/S3RetentionPolicy.java
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/objectstore/S3RetentionPolicy.java
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/objectstore/S3RetentionPolicy.java
@@ -22,6 +22,7 @@

 import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
 import app.coronawarn.server.services.distribution.config.DistributionServiceConfig.Api;
+import app.coronawarn.server.services.distribution.objectstore.client.ObjectStoreOperationFailedException;
 import app.coronawarn.server.services.distribution.objectstore.client.S3Object;
 import java.time.LocalDate;
 import java.time.ZoneOffset;
@@ -39,10 +40,16 @@ public class S3RetentionPolicy {

   private final ObjectStoreAccess objectStoreAccess;
   private final Api api;
+  private final FailedObjectStoreOperationsCounter failedObjectStoreOperationsCounter;

-  public S3RetentionPolicy(ObjectStoreAccess objectStoreAccess, DistributionServiceConfig distributionServiceConfig) {
+  /**
+   * Creates an {@link S3RetentionPolicy} instance with the specified parameters.
+   */
+  public S3RetentionPolicy(ObjectStoreAccess objectStoreAccess, DistributionServiceConfig distributionServiceConfig,
+      FailedObjectStoreOperationsCounter failedOperationsCounter) {
     this.objectStoreAccess = objectStoreAccess;
     this.api = distributionServiceConfig.getApi();
+    this.failedObjectStoreOperationsCounter = failedOperationsCounter;
   }

   /**
@@ -77,6 +84,10 @@ public void applyRetentionPolicy(int retentionDays) {
    * @param diagnosisKey the  diagnosis key, that should be deleted.
    */
   public void deleteDiagnosisKey(S3Object diagnosisKey) {
-    objectStoreAccess.deleteObjectsWithPrefix(diagnosisKey.getObjectName());
+    try {
+      objectStoreAccess.deleteObjectsWithPrefix(diagnosisKey.getObjectName());
+    } catch (ObjectStoreOperationFailedException e) {
+      failedObjectStoreOperationsCounter.incrementAndCheckThreshold(e);
+    }
   }
 }
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/objectstore/client/ObjectStoreClientConfig.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/objectstore/client/ObjectStoreClientConfig.java
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/objectstore/client/ObjectStoreClientConfig.java
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/objectstore/client/ObjectStoreClientConfig.java
@@ -25,6 +25,7 @@
 import java.net.URI;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.retry.annotation.EnableRetry;
 import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
 import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
 import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
@@ -35,6 +36,7 @@
  * Manages the instantiation of the {@link ObjectStoreClient} bean.
  */
 @Configuration
+@EnableRetry
 public class ObjectStoreClientConfig {

   private static final Region DEFAULT_REGION = Region.EU_CENTRAL_1;
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/objectstore/client/S3ClientWrapper.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/objectstore/client/S3ClientWrapper.java
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/objectstore/client/S3ClientWrapper.java
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/objectstore/client/S3ClientWrapper.java
@@ -28,6 +28,10 @@
 import java.util.Map;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.springframework.retry.annotation.Backoff;
+import org.springframework.retry.annotation.Recover;
+import org.springframework.retry.annotation.Retryable;
+import org.springframework.retry.support.RetrySynchronizationManager;
 import software.amazon.awssdk.core.exception.SdkException;
 import software.amazon.awssdk.core.sync.RequestBody;
 import software.amazon.awssdk.services.s3.S3Client;
@@ -67,18 +71,29 @@ public boolean bucketExists(String bucketName) {
   }

   @Override
+  @Retryable(
+      value = SdkException.class,
+      maxAttemptsExpression = "${services.distribution.objectstore.retry-attempts}",
+      backoff = @Backoff(delayExpression = "${services.distribution.objectstore.retry-backoff}"))
   public List<S3Object> getObjects(String bucket, String prefix) {
-    try {
-      ListObjectsV2Response response =
-          s3Client.listObjectsV2(ListObjectsV2Request.builder().prefix(prefix).bucket(bucket).build());
-      return response.contents().stream().map(S3ClientWrapper::buildS3Object).collect(toList());
-    } catch (SdkException e) {
-      throw new ObjectStoreOperationFailedException("Failed to upload object to object store", e);
-    }
+    logRetryStatus("object download");
+    ListObjectsV2Response response =
+        s3Client.listObjectsV2(ListObjectsV2Request.builder().prefix(prefix).bucket(bucket).build());
+    return response.contents().stream().map(S3ClientWrapper::buildS3Object).collect(toList());
+  }
+
+  @Recover
+  public List<S3Object> skipReadOperation(Throwable cause) {
+    throw new ObjectStoreOperationFailedException("Failed to get objects from object store", cause);
   }

   @Override
+  @Retryable(
+      value = SdkException.class,
+      maxAttemptsExpression = "${services.distribution.objectstore.retry-attempts}",
+      backoff = @Backoff(delayExpression = "${services.distribution.objectstore.retry-backoff}"))
   public void putObject(String bucket, String objectName, Path filePath, Map<HeaderKey, String> headers) {
+    logRetryStatus("object upload");
     RequestBody bodyFile = RequestBody.fromFile(filePath);

     var requestBuilder = PutObjectRequest.builder().bucket(bucket).key(objectName);
@@ -89,40 +104,46 @@ public void putObject(String bucket, String objectName, Path filePath, Map<Heade
       requestBuilder.cacheControl(headers.get(HeaderKey.CACHE_CONTROL));
     }

-    try {
-      s3Client.putObject(requestBuilder.build(), bodyFile);
-    } catch (SdkException e) {
-      throw new ObjectStoreOperationFailedException("Failed to upload object to object store", e);
-    }
+    s3Client.putObject(requestBuilder.build(), bodyFile);
   }

   @Override
+  @Retryable(value = {SdkException.class, ObjectStoreOperationFailedException.class},
+      maxAttemptsExpression = "${services.distribution.objectstore.retry-attempts}",
+      backoff = @Backoff(delayExpression = "${services.distribution.objectstore.retry-backoff}"))
   public void removeObjects(String bucket, List<String> objectNames) {
     if (objectNames.isEmpty()) {
       return;
     }
+    logRetryStatus("object deletion");

     Collection<ObjectIdentifier> identifiers = objectNames.stream()
         .map(key -> ObjectIdentifier.builder().key(key).build()).collect(toList());

-    try {
-      DeleteObjectsResponse response = s3Client.deleteObjects(
-          DeleteObjectsRequest.builder()
-              .bucket(bucket)
-              .delete(Delete.builder().objects(identifiers).build()).build());
-
-      if (response.hasErrors()) {
-        String errMessage = "Failed to remove objects from object store.";
-        logger.error("{} {}", errMessage, response.errors());
-        throw new ObjectStoreOperationFailedException(errMessage);
-      }
-    } catch (SdkException e) {
-      throw new ObjectStoreOperationFailedException("Failed to remove objects from object store.", e);
+    DeleteObjectsResponse response = s3Client.deleteObjects(
+        DeleteObjectsRequest.builder()
+            .bucket(bucket)
+            .delete(Delete.builder().objects(identifiers).build()).build());
+
+    if (response.hasErrors()) {
+      throw new ObjectStoreOperationFailedException("Failed to remove objects from object store.");
     }
   }

+  @Recover
+  private void skipModifyingOperation(Throwable cause) {
+    throw new ObjectStoreOperationFailedException("Failed to modify objects on object store.", cause);
+  }
+
   private static S3Object buildS3Object(software.amazon.awssdk.services.s3.model.S3Object s3Object) {
     String etag = s3Object.eTag().replaceAll("\"", "");
     return new S3Object(s3Object.key(), etag);
   }
+
+  private void logRetryStatus(String action) {
+    int retryCount = RetrySynchronizationManager.getContext().getRetryCount();
+    if (retryCount > 0) {
+      logger.warn("Retrying {} after {} failed attempt(s).", action, retryCount);
+    }
+  }
 }
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/runner/S3Distribution.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/runner/S3Distribution.java
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/runner/S3Distribution.java
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/runner/S3Distribution.java
@@ -21,6 +21,7 @@
 package app.coronawarn.server.services.distribution.runner;

 import app.coronawarn.server.services.distribution.assembly.component.OutputDirectoryProvider;
+import app.coronawarn.server.services.distribution.objectstore.FailedObjectStoreOperationsCounter;
 import app.coronawarn.server.services.distribution.objectstore.ObjectStoreAccess;
 import app.coronawarn.server.services.distribution.objectstore.S3Publisher;
 import app.coronawarn.server.services.distribution.objectstore.client.ObjectStoreOperationFailedException;
@@ -43,19 +44,21 @@ public class S3Distribution implements ApplicationRunner {
   private static final Logger logger = LoggerFactory.getLogger(S3Distribution.class);

   private final OutputDirectoryProvider outputDirectoryProvider;
-
   private final ObjectStoreAccess objectStoreAccess;
+  private final FailedObjectStoreOperationsCounter failedOperationsCounter;

-  S3Distribution(OutputDirectoryProvider outputDirectoryProvider, ObjectStoreAccess objectStoreAccess) {
+  S3Distribution(OutputDirectoryProvider outputDirectoryProvider, ObjectStoreAccess objectStoreAccess,
+      FailedObjectStoreOperationsCounter failedOperationsCounter) {
     this.outputDirectoryProvider = outputDirectoryProvider;
     this.objectStoreAccess = objectStoreAccess;
+    this.failedOperationsCounter = failedOperationsCounter;
   }

   @Override
   public void run(ApplicationArguments args) {
     try {
       Path pathToDistribute = outputDirectoryProvider.getFileOnDisk().toPath().toAbsolutePath();
-      S3Publisher s3Publisher = new S3Publisher(pathToDistribute, objectStoreAccess);
+      S3Publisher s3Publisher = new S3Publisher(pathToDistribute, objectStoreAccess, failedOperationsCounter);

       s3Publisher.publish();
       logger.info("Data pushed to Object Store successfully.");
diff --git a/services/distribution/src/main/resources/application.yaml b/services/distribution/src/main/resources/application.yaml
--- a/services/distribution/src/main/resources/application.yaml
+++ b/services/distribution/src/main/resources/application.yaml
@@ -47,6 +47,9 @@ services:
       bucket: ${CWA_OBJECTSTORE_BUCKET:cwa}
       port: ${CWA_OBJECTSTORE_PORT:8003}
       set-public-read-acl-on-put-object: true
+      retry-attempts: 3
+      retry-backoff: 2000
+      max-number-of-failed-operations: 5

 spring:
   main:

diff --git a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/appconfig/ApplicationVersionConfigurationProviderMasterFileTest.java b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/appconfig/ApplicationVersionConfigurationProviderMasterFileTest.java
--- a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/appconfig/ApplicationVersionConfigurationProviderMasterFileTest.java
+++ b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/appconfig/ApplicationVersionConfigurationProviderMasterFileTest.java
@@ -23,7 +23,6 @@
 import static org.assertj.core.api.Assertions.assertThat;

 import app.coronawarn.server.services.distribution.assembly.appconfig.validation.ApplicationVersionConfigurationValidator;
-import app.coronawarn.server.services.distribution.assembly.appconfig.validation.ExposureConfigurationValidator;
 import app.coronawarn.server.services.distribution.assembly.appconfig.validation.ValidationResult;
 import org.junit.jupiter.api.Test;

diff --git a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/objectstore/FailedObjectStoreOperationsCounterTest.java b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/objectstore/FailedObjectStoreOperationsCounterTest.java
new file mode 100644
--- /dev/null
+++ b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/objectstore/FailedObjectStoreOperationsCounterTest.java
@@ -0,0 +1,58 @@
+/*-
+ * ---license-start
+ * Corona-Warn-App
+ * ---
+ * Copyright (C) 2020 SAP SE and all other contributors
+ * ---
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ---license-end
+ */
+
+package app.coronawarn.server.services.distribution.objectstore;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
+import app.coronawarn.server.services.distribution.objectstore.client.ObjectStoreOperationFailedException;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.test.context.ConfigFileApplicationContextInitializer;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+
+@ExtendWith(SpringExtension.class)
+@ContextConfiguration(classes = {
+    FailedObjectStoreOperationsCounter.class}, initializers = ConfigFileApplicationContextInitializer.class)
+@EnableConfigurationProperties(value = DistributionServiceConfig.class)
+class FailedObjectStoreOperationsCounterTest {
+
+  @Autowired
+  private DistributionServiceConfig distributionServiceConfig;
+
+  @Autowired
+  private FailedObjectStoreOperationsCounter failedObjectStoreOperationsCounter;
+
+  @Test
+  void shouldThrowOnSixthAttempt() {
+    var exception = new ObjectStoreOperationFailedException("mock");
+    for (int i = 0; i < distributionServiceConfig.getObjectStore().getMaxNumberOfFailedOperations(); i++) {
+      assertThatCode(() -> failedObjectStoreOperationsCounter.incrementAndCheckThreshold(exception))
+          .doesNotThrowAnyException();
+    }
+    assertThatExceptionOfType(ObjectStoreOperationFailedException.class)
+        .isThrownBy(() -> failedObjectStoreOperationsCounter.incrementAndCheckThreshold(exception));
+  }
+}
diff --git a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/objectstore/ObjectStoreAccessTest.java b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/objectstore/ObjectStoreAccessTest.java
--- a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/objectstore/ObjectStoreAccessTest.java
+++ b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/objectstore/ObjectStoreAccessTest.java
@@ -24,7 +24,8 @@
 import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.when;

-import app.coronawarn.server.services.distribution.Application;
+import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
+import app.coronawarn.server.services.distribution.objectstore.client.ObjectStoreClientConfig;
 import app.coronawarn.server.services.distribution.objectstore.publish.LocalFile;
 import app.coronawarn.server.services.distribution.objectstore.publish.LocalGenericFile;
 import java.io.IOException;
@@ -36,14 +37,14 @@
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.context.ConfigFileApplicationContextInitializer;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.core.io.ResourceLoader;
-import org.springframework.test.context.ContextConfiguration;
 import org.springframework.test.context.junit.jupiter.SpringExtension;

 @ExtendWith(SpringExtension.class)
-@ContextConfiguration(classes = {Application.class},
-    initializers = ConfigFileApplicationContextInitializer.class)
+@SpringBootTest(classes = {ObjectStoreAccess.class, ObjectStoreClientConfig.class})
+@EnableConfigurationProperties(value = DistributionServiceConfig.class)
 @Tag("s3-integration")
 class ObjectStoreAccessTest {

diff --git a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/objectstore/S3PublisherIntegrationTest.java b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/objectstore/S3PublisherIntegrationTest.java
--- a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/objectstore/S3PublisherIntegrationTest.java
+++ b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/objectstore/S3PublisherIntegrationTest.java
@@ -21,6 +21,7 @@
 package app.coronawarn.server.services.distribution.objectstore;

 import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;

 import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
 import app.coronawarn.server.services.distribution.objectstore.client.ObjectStoreClientConfig;
@@ -35,14 +36,12 @@
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
-import org.springframework.boot.test.context.ConfigFileApplicationContextInitializer;
+import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.core.io.ResourceLoader;
-import org.springframework.test.context.ContextConfiguration;
 import org.springframework.test.context.junit.jupiter.SpringExtension;

 @ExtendWith(SpringExtension.class)
-@ContextConfiguration(classes = {ObjectStoreAccess.class, ObjectStoreClientConfig.class},
-    initializers = ConfigFileApplicationContextInitializer.class)
+@SpringBootTest(classes = {ObjectStoreAccess.class, ObjectStoreClientConfig.class})
 @EnableConfigurationProperties(value = DistributionServiceConfig.class)
 @Tag("s3-integration")
 class S3PublisherIntegrationTest {
@@ -57,7 +56,8 @@ class S3PublisherIntegrationTest {

   @Test
   void publishTestFolderOk() throws IOException {
-    S3Publisher publisher = new S3Publisher(getFolderAsPath(rootTestFolder), objectStoreAccess);
+    S3Publisher publisher = new S3Publisher(
+        getFolderAsPath(rootTestFolder), objectStoreAccess, mock(FailedObjectStoreOperationsCounter.class));

     publisher.publish();
     List<S3Object> s3Objects = objectStoreAccess.getObjectsWithPrefix("version");
diff --git a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/objectstore/S3PublisherTest.java b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/objectstore/S3PublisherTest.java
--- a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/objectstore/S3PublisherTest.java
+++ b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/objectstore/S3PublisherTest.java
@@ -21,6 +21,7 @@
 package app.coronawarn.server.services.distribution.objectstore;

 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -115,6 +116,6 @@ private List<S3Object> twoIdenticalOneOtherOneChange() {

   private S3Publisher createTestPublisher() throws IOException {
     var publishPath = resourceLoader.getResource(PUBLISHING_PATH).getFile().toPath();
-    return new S3Publisher(publishPath, objectStoreAccess);
+    return new S3Publisher(publishPath, objectStoreAccess, mock(FailedObjectStoreOperationsCounter.class));
   }
 }
diff --git a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/objectstore/S3RetentionPolicyTest.java b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/objectstore/S3RetentionPolicyTest.java
--- a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/objectstore/S3RetentionPolicyTest.java
+++ b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/objectstore/S3RetentionPolicyTest.java
@@ -23,12 +23,15 @@
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;

 import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
 import app.coronawarn.server.services.distribution.config.DistributionServiceConfig.ObjectStore;
+import app.coronawarn.server.services.distribution.objectstore.client.ObjectStoreOperationFailedException;
 import app.coronawarn.server.services.distribution.objectstore.client.S3Object;
 import java.time.LocalDate;
 import java.time.ZoneOffset;
@@ -44,17 +47,21 @@

 @EnableConfigurationProperties(value = DistributionServiceConfig.class)
 @ExtendWith(SpringExtension.class)
-@ContextConfiguration(classes = {S3RetentionPolicy.class, ObjectStore.class},
+@ContextConfiguration(classes = {S3RetentionPolicy.class, ObjectStore.class, FailedObjectStoreOperationsCounter.class},
     initializers = ConfigFileApplicationContextInitializer.class)
 class S3RetentionPolicyTest {

   @MockBean
   private ObjectStoreAccess objectStoreAccess;

+  @MockBean
+  private FailedObjectStoreOperationsCounter failedObjectStoreOperationsCounter;
+
   @Autowired
   private S3RetentionPolicy s3RetentionPolicy;

-  @Autowired DistributionServiceConfig distributionServiceConfig;
+  @Autowired
+  private DistributionServiceConfig distributionServiceConfig;

   @Test
   void shouldDeleteOldFiles() {
@@ -83,6 +90,16 @@ void shouldNotDeleteFilesIfAllAreValid() {
     verify(objectStoreAccess, never()).deleteObjectsWithPrefix(any());
   }

+  @Test
+  void deleteDiagnosisKeysUpdatesFailedOperationCounter() {
+    doThrow(ObjectStoreOperationFailedException.class).when(objectStoreAccess).deleteObjectsWithPrefix(any());
+
+    s3RetentionPolicy.deleteDiagnosisKey(new S3Object("foo"));
+
+    verify(failedObjectStoreOperationsCounter, times(1))
+        .incrementAndCheckThreshold(any(ObjectStoreOperationFailedException.class));
+  }
+
   private String generateFileName(LocalDate date) {
     var api = distributionServiceConfig.getApi();

diff --git a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/objectstore/client/S3ClientWrapperTest.java b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/objectstore/client/S3ClientWrapperTest.java
--- a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/objectstore/client/S3ClientWrapperTest.java
+++ b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/objectstore/client/S3ClientWrapperTest.java
@@ -27,10 +27,12 @@
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.atLeastOnce;
-import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;

+import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
 import app.coronawarn.server.services.distribution.objectstore.client.ObjectStoreClient.HeaderKey;
 import java.nio.file.Path;
 import java.util.List;
@@ -44,6 +46,15 @@
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
 import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.test.context.ConfigFileApplicationContextInitializer;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.retry.annotation.EnableRetry;
+import org.springframework.test.context.ContextConfiguration;
 import org.springframework.test.context.junit.jupiter.SpringExtension;
 import software.amazon.awssdk.core.exception.SdkClientException;
 import software.amazon.awssdk.core.exception.SdkException;
@@ -62,19 +73,36 @@
 import software.amazon.awssdk.utils.builder.SdkBuilder;

 @ExtendWith(SpringExtension.class)
+@ContextConfiguration(initializers = ConfigFileApplicationContextInitializer.class)
+@EnableConfigurationProperties(value = DistributionServiceConfig.class)
 class S3ClientWrapperTest {

   private static final String VALID_BUCKET_NAME = "myBucket";
   private static final String VALID_PREFIX = "prefix";
   private static final String VALID_NAME = "object key";

+  @Value("${services.distribution.objectstore.retry-attempts}")
+  private int configuredNumberOfRetries;
+
+  @MockBean
   private S3Client s3Client;
-  private S3ClientWrapper s3ClientWrapper;
+
+  @Autowired
+  private ObjectStoreClient s3ClientWrapper;
+
+  @Configuration
+  @EnableRetry
+  public static class RetryS3ClientConfig {
+
+    @Bean
+    public ObjectStoreClient createS3ClientWrapper(S3Client s3Client) {
+      return new S3ClientWrapper(s3Client);
+    }
+  }

   @BeforeEach
   public void setUpMocks() {
-    s3Client = mock(S3Client.class);
-    s3ClientWrapper = new S3ClientWrapper(s3Client);
+    reset(s3Client);
   }

   @Test
@@ -156,6 +184,16 @@ void getObjectsThrowsObjectStoreOperationFailedExceptionIfClientThrows(Class<Exc
         .isThrownBy(() -> s3ClientWrapper.getObjects(VALID_BUCKET_NAME, VALID_PREFIX));
   }

+  @ParameterizedTest
+  @ValueSource(classes = {NoSuchBucketException.class, S3Exception.class, SdkClientException.class, SdkException.class})
+  void shouldRetryGettingObjectsAndThenThrow(Class<Exception> cause) {
+    when(s3Client.listObjectsV2(any(ListObjectsV2Request.class))).thenThrow(cause);
+    assertThatExceptionOfType(ObjectStoreOperationFailedException.class)
+        .isThrownBy(() -> s3ClientWrapper.getObjects(VALID_BUCKET_NAME, VALID_PREFIX));
+
+    verify(s3Client, times(configuredNumberOfRetries)).listObjectsV2(any(ListObjectsV2Request.class));
+  }
+
   @Test
   void testPutObjectForNoHeaders() {
     s3ClientWrapper.putObject(VALID_BUCKET_NAME, VALID_NAME, Path.of(""), emptyMap());
@@ -193,6 +231,16 @@ void putObjectsThrowsObjectStoreOperationFailedExceptionIfClientThrows(Class<Exc
         .isThrownBy(() -> s3ClientWrapper.putObject(VALID_BUCKET_NAME, VALID_PREFIX, Path.of(""), emptyMap()));
   }

+  @ParameterizedTest
+  @ValueSource(classes = {NoSuchBucketException.class, S3Exception.class, SdkClientException.class, SdkException.class})
+  void shouldRetryUploadingObjectAndThenThrow(Class<Exception> cause) {
+    when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))).thenThrow(cause);
+    assertThatExceptionOfType(ObjectStoreOperationFailedException.class)
+        .isThrownBy(() -> s3ClientWrapper.putObject(VALID_BUCKET_NAME, VALID_PREFIX, Path.of(""), emptyMap()));
+
+    verify(s3Client, times(configuredNumberOfRetries)).putObject(any(PutObjectRequest.class), any(RequestBody.class));
+  }
+
   @Test
   void testRemoveObjects() {
     when(s3Client.deleteObjects(any(DeleteObjectsRequest.class))).thenReturn(DeleteObjectsResponse.builder().build());
@@ -227,4 +275,14 @@ void removeObjectsThrowsObjectStoreOperationFailedExceptionIfClientThrows(Class<
     assertThatExceptionOfType(ObjectStoreOperationFailedException.class)
         .isThrownBy(() -> s3ClientWrapper.removeObjects(VALID_BUCKET_NAME, List.of(VALID_NAME)));
   }
+
+  @ParameterizedTest
+  @ValueSource(classes = {NoSuchBucketException.class, S3Exception.class, SdkClientException.class, SdkException.class})
+  void shouldRetryRemovingObjectAndThenThrow(Class<Exception> cause) {
+    when(s3Client.deleteObjects(any(DeleteObjectsRequest.class))).thenThrow(cause);
+    assertThatExceptionOfType(ObjectStoreOperationFailedException.class)
+        .isThrownBy(() -> s3ClientWrapper.removeObjects(VALID_BUCKET_NAME, List.of(VALID_NAME)));
+
+    verify(s3Client, times(configuredNumberOfRetries)).deleteObjects(any(DeleteObjectsRequest.class));
+  }
 }
diff --git a/services/distribution/src/test/resources/application.yaml b/services/distribution/src/test/resources/application.yaml
--- a/services/distribution/src/test/resources/application.yaml
+++ b/services/distribution/src/test/resources/application.yaml
@@ -46,6 +46,9 @@ services:
       bucket: ${CWA_OBJECTSTORE_BUCKET:cwa}
       port: ${CWA_OBJECTSTORE_PORT:8003}
       set-public-read-acl-on-put-object: true
+      retry-attempts: 3
+      retry-backoff: 1
+      max-number-of-failed-operations: 5
 spring:
   main:
     banner-mode: off

S3Publisher: Resilience
The S3Publisher/ObjectStoreAccess is currently lacking resilience features, like re-trys and error handling.


diff --git a/common/protocols/src/main/proto/app/coronawarn/server/common/protocols/internal/app_config.proto b/common/protocols/src/main/proto/app/coronawarn/server/common/protocols/internal/app_config.proto
--- a/common/protocols/src/main/proto/app/coronawarn/server/common/protocols/internal/app_config.proto
+++ b/common/protocols/src/main/proto/app/coronawarn/server/common/protocols/internal/app_config.proto
@@ -12,4 +12,11 @@ message ApplicationConfiguration {
   app.coronawarn.server.common.protocols.internal.RiskScoreClassification riskScoreClasses = 2;

   app.coronawarn.server.common.protocols.internal.RiskScoreParameters exposureConfig = 3;
+
+  AttenuationDurationThresholds attenuationDurationThresholds = 4;
+}
+
+message AttenuationDurationThresholds {
+  int32 lower = 1;
+  int32 upper = 2;
 }
\ No newline at end of file
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/ApplicationConfigurationValidator.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/ApplicationConfigurationValidator.java
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/ApplicationConfigurationValidator.java
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/ApplicationConfigurationValidator.java
@@ -20,6 +20,11 @@

 package app.coronawarn.server.services.distribution.assembly.appconfig.validation;

+import static app.coronawarn.server.services.distribution.assembly.appconfig.validation.GeneralValidationError.ErrorType.MIN_GREATER_THAN_MAX;
+import static app.coronawarn.server.services.distribution.assembly.appconfig.validation.GeneralValidationError.ErrorType.VALUE_OUT_OF_BOUNDS;
+import static app.coronawarn.server.services.distribution.assembly.appconfig.validation.ParameterSpec.ATTENUATION_DURATION_THRESHOLD_MAX;
+import static app.coronawarn.server.services.distribution.assembly.appconfig.validation.ParameterSpec.ATTENUATION_DURATION_THRESHOLD_MIN;
+
 import app.coronawarn.server.common.protocols.internal.ApplicationConfiguration;
 import app.coronawarn.server.common.protocols.internal.RiskScoreClassification;
 import app.coronawarn.server.common.protocols.internal.RiskScoreParameters;
@@ -46,6 +51,7 @@ public ValidationResult validate() {
     this.errors = new ValidationResult();

     validateMinRisk();
+    validateAttenuationDurationThresholds();

     ValidationResult exposureResult = new ExposureConfigurationValidator(config.getExposureConfig()).validate();
     ValidationResult riskScoreResult = new RiskScoreClassificationValidator(config.getRiskScoreClasses()).validate();
@@ -60,4 +66,25 @@ private void validateMinRisk() {
       this.errors.add(new MinimumRiskLevelValidationError(minLevel));
     }
   }
+
+  private void validateAttenuationDurationThresholds() {
+    int lower = config.getAttenuationDurationThresholds().getLower();
+    int upper = config.getAttenuationDurationThresholds().getUpper();
+
+    checkThresholdBound("lower", lower);
+    checkThresholdBound("upper", upper);
+
+    if (lower > upper) {
+      String parameters = "attenuationDurationThreshold.lower, attenuationDurationThreshold.upper";
+      String values = lower + ", " + upper;
+      this.errors.add(new GeneralValidationError(parameters, values, MIN_GREATER_THAN_MAX));
+    }
+  }
+
+  private void checkThresholdBound(String boundLabel, int boundValue) {
+    if (boundValue < ATTENUATION_DURATION_THRESHOLD_MIN || boundValue > ATTENUATION_DURATION_THRESHOLD_MAX) {
+      this.errors.add(
+          new GeneralValidationError("attenuationDurationThreshold." + boundLabel, boundValue, VALUE_OUT_OF_BOUNDS));
+    }
+  }
 }
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/RiskScoreClassificationValidationError.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/GeneralValidationError.java
similarity index 82%
rename from services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/RiskScoreClassificationValidationError.java
rename to services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/GeneralValidationError.java
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/RiskScoreClassificationValidationError.java
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/GeneralValidationError.java
@@ -22,23 +22,21 @@

 import java.util.Objects;

-public class RiskScoreClassificationValidationError implements ValidationError {
+public class GeneralValidationError implements ValidationError {

   private final String errorSource;
-
   private final Object value;
-
   private final ErrorType reason;

   /**
-   * Creates a {@link RiskScoreClassificationValidationError} that stores the specified validation error source,
+   * Creates a {@link GeneralValidationError} that stores the specified validation error source,
    * erroneous value and a reason for the error to occur.
    *
    * @param errorSource A label that describes the property associated with this validation error.
    * @param value       The value that caused the validation error.
    * @param reason      A validation error specifier.
    */
-  public RiskScoreClassificationValidationError(String errorSource, Object value, ErrorType reason) {
+  public GeneralValidationError(String errorSource, Object value, ErrorType reason) {
     this.errorSource = errorSource;
     this.value = value;
     this.reason = reason;
@@ -46,7 +44,7 @@ public RiskScoreClassificationValidationError(String errorSource, Object value,

   @Override
   public String toString() {
-    return "RiskScoreClassificationValidationError{"
+    return "GeneralValidationError{"
         + "errorType=" + reason
         + ", parameter='" + errorSource + '\''
         + ", givenValue=" + value
@@ -61,7 +59,7 @@ public boolean equals(Object o) {
     if (o == null || getClass() != o.getClass()) {
       return false;
     }
-    RiskScoreClassificationValidationError that = (RiskScoreClassificationValidationError) o;
+    GeneralValidationError that = (GeneralValidationError) o;
     return Objects.equals(errorSource, that.errorSource)
         && Objects.equals(value, that.value)
         && Objects.equals(reason, that.reason);
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/ParameterSpec.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/ParameterSpec.java
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/ParameterSpec.java
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/ParameterSpec.java
@@ -53,4 +53,14 @@ private ParameterSpec() {
    * The allowed maximum value for a risk score.
    */
   public static final int RISK_SCORE_MAX = 4096;
+
+  /**
+   * The allowed minimum value for an attenuation threshold.
+   */
+  public static final int ATTENUATION_DURATION_THRESHOLD_MIN = 0;
+
+  /**
+   * The allowed maximum value for an attenuation threshold.
+   */
+  public static final int ATTENUATION_DURATION_THRESHOLD_MAX = 100;
 }
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/RiskScoreClassificationValidator.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/RiskScoreClassificationValidator.java
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/RiskScoreClassificationValidator.java
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/RiskScoreClassificationValidator.java
@@ -20,11 +20,11 @@

 package app.coronawarn.server.services.distribution.assembly.appconfig.validation;

-import static app.coronawarn.server.services.distribution.assembly.appconfig.validation.RiskScoreClassificationValidationError.ErrorType.BLANK_LABEL;
-import static app.coronawarn.server.services.distribution.assembly.appconfig.validation.RiskScoreClassificationValidationError.ErrorType.INVALID_PARTITIONING;
-import static app.coronawarn.server.services.distribution.assembly.appconfig.validation.RiskScoreClassificationValidationError.ErrorType.INVALID_URL;
-import static app.coronawarn.server.services.distribution.assembly.appconfig.validation.RiskScoreClassificationValidationError.ErrorType.MIN_GREATER_THAN_MAX;
-import static app.coronawarn.server.services.distribution.assembly.appconfig.validation.RiskScoreClassificationValidationError.ErrorType.VALUE_OUT_OF_BOUNDS;
+import static app.coronawarn.server.services.distribution.assembly.appconfig.validation.GeneralValidationError.ErrorType.BLANK_LABEL;
+import static app.coronawarn.server.services.distribution.assembly.appconfig.validation.GeneralValidationError.ErrorType.INVALID_PARTITIONING;
+import static app.coronawarn.server.services.distribution.assembly.appconfig.validation.GeneralValidationError.ErrorType.INVALID_URL;
+import static app.coronawarn.server.services.distribution.assembly.appconfig.validation.GeneralValidationError.ErrorType.MIN_GREATER_THAN_MAX;
+import static app.coronawarn.server.services.distribution.assembly.appconfig.validation.GeneralValidationError.ErrorType.VALUE_OUT_OF_BOUNDS;

 import app.coronawarn.server.common.protocols.internal.RiskScoreClass;
 import app.coronawarn.server.common.protocols.internal.RiskScoreClassification;
@@ -69,7 +69,7 @@ private void validateValues() {
       validateUrl(riskScoreClass.getUrl());

       if (minRiskLevel > maxRiskLevel) {
-        errors.add(new RiskScoreClassificationValidationError(
+        errors.add(new GeneralValidationError(
             "minRiskLevel, maxRiskLevel", minRiskLevel + ", " + maxRiskLevel, MIN_GREATER_THAN_MAX));
       }
     }
@@ -77,13 +77,13 @@ private void validateValues() {

   private void validateLabel(String label) {
     if (label.isBlank()) {
-      errors.add(new RiskScoreClassificationValidationError("label", label, BLANK_LABEL));
+      errors.add(new GeneralValidationError("label", label, BLANK_LABEL));
     }
   }

   private void validateRiskScoreValueBounds(int value) {
     if (!RiskScoreValidator.isInBounds(value)) {
-      errors.add(new RiskScoreClassificationValidationError("minRiskLevel/maxRiskLevel", value, VALUE_OUT_OF_BOUNDS));
+      errors.add(new GeneralValidationError("minRiskLevel/maxRiskLevel", value, VALUE_OUT_OF_BOUNDS));
     }
   }

@@ -91,7 +91,7 @@ private void validateUrl(String url) {
     try {
       new URL(url.trim());
     } catch (MalformedURLException e) {
-      errors.add(new RiskScoreClassificationValidationError("url", url, INVALID_URL));
+      errors.add(new GeneralValidationError("url", url, INVALID_URL));
     }
   }

@@ -101,7 +101,7 @@ private void validateValueRangeCoverage() {
         .sum();

     if (partitionSum != ParameterSpec.RISK_SCORE_MAX + 1) {
-      errors.add(new RiskScoreClassificationValidationError("covered value range", partitionSum, INVALID_PARTITIONING));
+      errors.add(new GeneralValidationError("covered value range", partitionSum, INVALID_PARTITIONING));
     }
   }
 }
diff --git a/services/distribution/src/main/resources/master-config/app-config.yaml b/services/distribution/src/main/resources/master-config/app-config.yaml
--- a/services/distribution/src/main/resources/master-config/app-config.yaml
+++ b/services/distribution/src/main/resources/master-config/app-config.yaml
@@ -9,5 +9,8 @@
 # Change this file with caution!

 min-risk-score: 90
+attenuationDurationThresholds:
+  lower: 50
+  upper: 70
 risk-score-classes: !include risk-score-classification.yaml
 exposure-config: !include exposure-config.yaml
\ No newline at end of file

diff --git a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/ApplicationConfigurationValidatorTest.java b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/ApplicationConfigurationValidatorTest.java
--- a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/ApplicationConfigurationValidatorTest.java
+++ b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/ApplicationConfigurationValidatorTest.java
@@ -20,16 +20,26 @@

 package app.coronawarn.server.services.distribution.assembly.appconfig.validation;

+import static app.coronawarn.server.services.distribution.assembly.appconfig.validation.GeneralValidationError.ErrorType.MIN_GREATER_THAN_MAX;
+import static app.coronawarn.server.services.distribution.assembly.appconfig.validation.GeneralValidationError.ErrorType.VALUE_OUT_OF_BOUNDS;
+import static app.coronawarn.server.services.distribution.assembly.appconfig.validation.ParameterSpec.ATTENUATION_DURATION_THRESHOLD_MAX;
+import static app.coronawarn.server.services.distribution.assembly.appconfig.validation.ParameterSpec.ATTENUATION_DURATION_THRESHOLD_MIN;
+import static app.coronawarn.server.services.distribution.assembly.appconfig.validation.RiskScoreClassificationValidatorTest.MINIMAL_RISK_SCORE_CLASSIFICATION;
+import static app.coronawarn.server.services.distribution.assembly.appconfig.validation.RiskScoreClassificationValidatorTest.buildExpectedResult;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;

+import app.coronawarn.server.common.protocols.internal.ApplicationConfiguration;
+import app.coronawarn.server.common.protocols.internal.AttenuationDurationThresholds;
 import app.coronawarn.server.services.distribution.assembly.appconfig.ApplicationConfigurationProvider;
+import app.coronawarn.server.services.distribution.assembly.appconfig.ExposureConfigurationProvider;
 import app.coronawarn.server.services.distribution.assembly.appconfig.UnableToLoadFileException;
 import java.util.stream.Stream;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;

 class ApplicationConfigurationValidatorTest {

@@ -49,6 +59,43 @@ void negative(TestWithExpectedResult test) throws UnableToLoadFileException {
     assertThat(getResultForTest(test)).isEqualTo(test.result);
   }

+  @ParameterizedTest
+  @ValueSource(ints = {ATTENUATION_DURATION_THRESHOLD_MIN - 1, ATTENUATION_DURATION_THRESHOLD_MAX + 1})
+  void negativeForAttenuationDurationThresholdOutOfBounds(int invalidThresholdValue) throws Exception {
+    ApplicationConfigurationValidator validator = getValidatorForAttenuationDurationThreshold(
+        invalidThresholdValue, invalidThresholdValue);
+
+    ValidationResult expectedResult = buildExpectedResult(
+        new GeneralValidationError("attenuationDurationThreshold.upper", invalidThresholdValue, VALUE_OUT_OF_BOUNDS),
+        new GeneralValidationError("attenuationDurationThreshold.lower", invalidThresholdValue, VALUE_OUT_OF_BOUNDS));
+
+    assertThat(validator.validate()).isEqualTo(expectedResult);
+  }
+
+  @Test
+  void negativeForUpperAttenuationDurationThresholdLesserThanLower() throws Exception {
+    ApplicationConfigurationValidator validator = getValidatorForAttenuationDurationThreshold(
+        ATTENUATION_DURATION_THRESHOLD_MAX, ATTENUATION_DURATION_THRESHOLD_MIN);
+
+    ValidationResult expectedResult = buildExpectedResult(
+        new GeneralValidationError("attenuationDurationThreshold.lower, attenuationDurationThreshold.upper",
+            (ATTENUATION_DURATION_THRESHOLD_MAX + ", " + ATTENUATION_DURATION_THRESHOLD_MIN), MIN_GREATER_THAN_MAX));
+
+    assertThat(validator.validate()).isEqualTo(expectedResult);
+  }
+
+  private ApplicationConfigurationValidator getValidatorForAttenuationDurationThreshold(int lower, int upper)
+      throws Exception {
+    ApplicationConfiguration appConfig = ApplicationConfiguration.newBuilder()
+        .setMinRiskScore(100)
+        .setRiskScoreClasses(MINIMAL_RISK_SCORE_CLASSIFICATION)
+        .setExposureConfig(ExposureConfigurationProvider.readFile("configtests/exposure-config_ok.yaml"))
+        .setAttenuationDurationThresholds(AttenuationDurationThresholds.newBuilder()
+            .setLower(lower)
+            .setUpper(upper)).build();
+    return new ApplicationConfigurationValidator(appConfig);
+  }
+
   @Test
   void circular() {
     assertThatThrownBy(() -> {
diff --git a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/RiskScoreClassificationValidatorTest.java b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/RiskScoreClassificationValidatorTest.java
--- a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/RiskScoreClassificationValidatorTest.java
+++ b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/RiskScoreClassificationValidatorTest.java
@@ -20,14 +20,14 @@

 package app.coronawarn.server.services.distribution.assembly.appconfig.validation;

-import static app.coronawarn.server.services.distribution.assembly.appconfig.validation.RiskScoreClassificationValidationError.ErrorType.INVALID_PARTITIONING;
-import static app.coronawarn.server.services.distribution.assembly.appconfig.validation.RiskScoreClassificationValidationError.ErrorType.MIN_GREATER_THAN_MAX;
+import static app.coronawarn.server.services.distribution.assembly.appconfig.validation.GeneralValidationError.ErrorType.INVALID_PARTITIONING;
+import static app.coronawarn.server.services.distribution.assembly.appconfig.validation.GeneralValidationError.ErrorType.MIN_GREATER_THAN_MAX;
 import static java.util.Arrays.asList;
 import static org.assertj.core.api.Assertions.assertThat;

 import app.coronawarn.server.common.protocols.internal.RiskScoreClass;
 import app.coronawarn.server.common.protocols.internal.RiskScoreClassification;
-import app.coronawarn.server.services.distribution.assembly.appconfig.validation.RiskScoreClassificationValidationError.ErrorType;
+import app.coronawarn.server.services.distribution.assembly.appconfig.validation.GeneralValidationError.ErrorType;
 import java.util.Arrays;
 import java.util.stream.Stream;
 import org.junit.jupiter.api.Test;
@@ -42,6 +42,9 @@ class RiskScoreClassificationValidatorTest {
   private final static String VALID_LABEL = "myLabel";
   private final static String VALID_URL = "https://www.my.url";

+  public final static RiskScoreClassification MINIMAL_RISK_SCORE_CLASSIFICATION =
+      buildClassification(buildRiskClass(VALID_LABEL, 0, MAX_SCORE, VALID_URL));
+
   @ParameterizedTest
   @ValueSource(strings = {"", " "})
   void failsForBlankLabels(String invalidLabel) {
@@ -134,8 +137,7 @@ void doesNotFailForValidClassification(RiskScoreClassification validClassificati

   private static Stream<Arguments> createValidClassifications() {
     return Stream.of(
-        // valid url
-        buildClassification(buildRiskClass(VALID_LABEL, 0, MAX_SCORE, VALID_URL)),
+        MINIMAL_RISK_SCORE_CLASSIFICATION,
         // [0:MAX_SCORE/2][MAX_SCORE/2:MAX_SCORE]
         buildClassification(
             buildRiskClass(VALID_LABEL, 0, MAX_SCORE / 2, VALID_URL),
@@ -148,8 +150,8 @@ private static Stream<Arguments> createValidClassifications() {
     ).map(Arguments::of);
   }

-  private static RiskScoreClassificationValidationError buildError(String parameter, Object value, ErrorType reason) {
-    return new RiskScoreClassificationValidationError(parameter, value, reason);
+  private static GeneralValidationError buildError(String parameter, Object value, ErrorType reason) {
+    return new GeneralValidationError(parameter, value, reason);
   }

   private static RiskScoreClassificationValidator buildValidator(RiskScoreClass... riskScoreClasses) {
@@ -164,7 +166,7 @@ private static RiskScoreClass buildRiskClass(String label, int min, int max, Str
     return RiskScoreClass.newBuilder().setLabel(label).setMin(min).setMax(max).setUrl(url).build();
   }

-  private static ValidationResult buildExpectedResult(RiskScoreClassificationValidationError... errors) {
+  public static ValidationResult buildExpectedResult(GeneralValidationError... errors) {
     var validationResult = new ValidationResult();
     Arrays.stream(errors).forEach(validationResult::add);
     return validationResult;
diff --git a/services/distribution/src/test/resources/configtests/app-config_ok.yaml b/services/distribution/src/test/resources/configtests/app-config_ok.yaml
--- a/services/distribution/src/test/resources/configtests/app-config_ok.yaml
+++ b/services/distribution/src/test/resources/configtests/app-config_ok.yaml
@@ -1,3 +1,6 @@
 min-risk-score: 100
+attenuationDurationThresholds:
+  lower: 50
+  upper: 70
 risk-score-classes: !include risk-score-class_ok.yaml
 exposure-config: !include exposure-config_ok.yaml
\ No newline at end of file

Application Config: Add new Parameter attenuationDurationThresholds
Add a new parameter to the application configuration, called "attenuationDurationTresholds". With the help of this parameter, we will be able to calculate the overall risk factor much better.

Default values are: X=50, Y=70. There will be call with the RKI today night in which the values might be adjusted.

```yaml
attenuation-duration-thresholds:
  lower: 50
  upper: 70
```

Are there any known constraints that define the validity of X/Y values?
Should be:
- type: integer
- 0 to 100 (upper bound might change though, but use 100 for now)
- x must by lower than y -> so would also be nice to make that more explicit, by calling them lower and upper. (edited description of ticket)


diff --git a/common/protocols/src/main/proto/app/coronawarn/server/common/protocols/internal/app_config.proto b/common/protocols/src/main/proto/app/coronawarn/server/common/protocols/internal/app_config.proto
--- a/common/protocols/src/main/proto/app/coronawarn/server/common/protocols/internal/app_config.proto
+++ b/common/protocols/src/main/proto/app/coronawarn/server/common/protocols/internal/app_config.proto
@@ -4,6 +4,7 @@ option java_package = "app.coronawarn.server.common.protocols.internal";
 option java_multiple_files = true;
 import "app/coronawarn/server/common/protocols/internal/risk_score_classification.proto";
 import "app/coronawarn/server/common/protocols/internal/risk_score_parameters.proto";
+import "app/coronawarn/server/common/protocols/internal/app_version_config.proto";

 message ApplicationConfiguration {

@@ -14,6 +15,8 @@ message ApplicationConfiguration {
   app.coronawarn.server.common.protocols.internal.RiskScoreParameters exposureConfig = 3;

   AttenuationDurationThresholds attenuationDurationThresholds = 4;
+
+  app.coronawarn.server.common.protocols.internal.ApplicationVersionConfiguration appVersion = 5;
 }

 message AttenuationDurationThresholds {
diff --git a/common/protocols/src/main/proto/app/coronawarn/server/common/protocols/internal/app_version_config.proto b/common/protocols/src/main/proto/app/coronawarn/server/common/protocols/internal/app_version_config.proto
new file mode 100644
--- /dev/null
+++ b/common/protocols/src/main/proto/app/coronawarn/server/common/protocols/internal/app_version_config.proto
@@ -0,0 +1,20 @@
+syntax = "proto3";
+package app.coronawarn.server.common.protocols.internal;
+option java_package = "app.coronawarn.server.common.protocols.internal";
+option java_multiple_files = true;
+
+message ApplicationVersionConfiguration {
+  ApplicationVersionInfo ios = 1;
+  ApplicationVersionInfo android = 2;
+}
+
+message ApplicationVersionInfo {
+  SemanticVersion latest = 1;
+  SemanticVersion min = 2;
+}
+
+message SemanticVersion {
+  uint32 major = 1;
+  uint32 minor = 2;
+  uint32 patch = 3;
+}
\ No newline at end of file
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/ApplicationVersionConfigurationProvider.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/ApplicationVersionConfigurationProvider.java
new file mode 100644
--- /dev/null
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/ApplicationVersionConfigurationProvider.java
@@ -0,0 +1,56 @@
+/*
+ * ---license-start
+ * Corona-Warn-App
+ * ---
+ * Copyright (C) 2020 SAP SE and all other contributors
+ * ---
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ---license-end
+ */
+
+package app.coronawarn.server.services.distribution.assembly.appconfig;
+
+import app.coronawarn.server.common.protocols.internal.ApplicationVersionConfiguration;
+
+/**
+ * Provides the mobile app version configuration based on a file in the file system.<br> The existing file must be a
+ * valid YAML file, and must match the specification of the proto file app_version_config.proto.
+ */
+public class ApplicationVersionConfigurationProvider {
+
+  /**
+   * The location of the app version config master file.
+   */
+  public static final String MASTER_FILE = "master-config/app-version-config.yaml";
+
+  /**
+   * Fetches the master configuration as an ApplicationVersionConfig instance.
+   *
+   * @return the mobile app version configuration as ApplicationVersionConfig
+   * @throws UnableToLoadFileException when the file/transformation did not succeed
+   */
+  public static ApplicationVersionConfiguration readMasterFile() throws UnableToLoadFileException {
+    return readFile(MASTER_FILE);
+  }
+
+  /**
+   * Fetches an app version configuration file based on the given path. The path must be available in the classloader.
+   *
+   * @param path the path, e.g. folder/my-app-version-config.yaml
+   * @return the ApplicationVersionConfig
+   * @throws UnableToLoadFileException when the file/transformation did not succeed
+   */
+  public static ApplicationVersionConfiguration readFile(String path) throws UnableToLoadFileException {
+    return YamlLoader.loadYamlIntoProtobufBuilder(path, ApplicationVersionConfiguration.Builder.class).build();
+  }
+}
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/YamlLoader.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/YamlLoader.java
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/YamlLoader.java
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/YamlLoader.java
@@ -50,8 +50,8 @@ public static <T extends Message.Builder> T loadYamlIntoProtobufBuilder(String p
     // no setters for generated message classes available
     yaml.setBeanAccess(BeanAccess.FIELD);

-    Resource riskScoreParametersResource = new ClassPathResource(path);
-    try (InputStream inputStream = riskScoreParametersResource.getInputStream()) {
+    Resource configurationResource = new ClassPathResource(path);
+    try (InputStream inputStream = configurationResource.getInputStream()) {
       T loaded = yaml.loadAs(inputStream, builderType);
       if (loaded == null) {
         throw new UnableToLoadFileException(path);
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/ApplicationVersionConfigurationValidator.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/ApplicationVersionConfigurationValidator.java
new file mode 100644
--- /dev/null
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/ApplicationVersionConfigurationValidator.java
@@ -0,0 +1,81 @@
+/*
+ * ---license-start
+ * Corona-Warn-App
+ * ---
+ * Copyright (C) 2020 SAP SE and all other contributors
+ * ---
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ---license-end
+ */
+
+package app.coronawarn.server.services.distribution.assembly.appconfig.validation;
+
+import static app.coronawarn.server.services.distribution.assembly.appconfig.validation.GeneralValidationError.ErrorType.MIN_GREATER_THAN_MAX;
+
+import app.coronawarn.server.common.protocols.internal.ApplicationVersionConfiguration;
+import app.coronawarn.server.common.protocols.internal.ApplicationVersionInfo;
+import app.coronawarn.server.common.protocols.internal.SemanticVersion;
+
+public class ApplicationVersionConfigurationValidator extends ConfigurationValidator {
+
+  private final ApplicationVersionConfiguration config;
+
+  public ApplicationVersionConfigurationValidator(ApplicationVersionConfiguration config) {
+    this.config = config;
+  }
+
+  @Override
+  public ValidationResult validate() {
+    this.errors = new ValidationResult();
+    validateApplicationVersionInfo("ios", config.getIos());
+    validateApplicationVersionInfo("android", config.getAndroid());
+    return this.errors;
+  }
+
+  private void validateApplicationVersionInfo(String name, ApplicationVersionInfo appVersionInfo) {
+    SemanticVersion minVersion = appVersionInfo.getMin();
+    ComparisonResult comparisonResult = compare(appVersionInfo.getLatest(), minVersion);
+    if (ComparisonResult.LOWER.equals(comparisonResult)) {
+      this.errors.add(new GeneralValidationError(name + ": latest/min",
+          minVersion.getMajor() + "." + minVersion.getMinor() + "." + minVersion.getPatch(), MIN_GREATER_THAN_MAX));
+    }
+  }
+
+  private ComparisonResult compare(SemanticVersion left, SemanticVersion right) {
+    if (left.getMajor() < right.getMajor()) {
+      return ComparisonResult.LOWER;
+    }
+    if (left.getMajor() > right.getMajor()) {
+      return ComparisonResult.HIGHER;
+    }
+    if (left.getMinor() < right.getMinor()) {
+      return ComparisonResult.LOWER;
+    }
+    if (left.getMinor() > right.getMinor()) {
+      return ComparisonResult.HIGHER;
+    }
+    if (left.getPatch() < right.getPatch()) {
+      return ComparisonResult.LOWER;
+    }
+    if (left.getPatch() > right.getPatch()) {
+      return ComparisonResult.HIGHER;
+    }
+    return ComparisonResult.EQUAL;
+  }
+
+  private enum ComparisonResult {
+    LOWER,
+    EQUAL,
+    HIGHER
+  }
+}
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/GeneralValidationError.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/GeneralValidationError.java
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/GeneralValidationError.java
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/GeneralValidationError.java
@@ -44,7 +44,7 @@ public GeneralValidationError(String errorSource, Object value, ErrorType reason

   @Override
   public String toString() {
-    return "GeneralValidationError{"
+    return "RiskScoreClassificationValidationError{"
         + "errorType=" + reason
         + ", parameter='" + errorSource + '\''
         + ", givenValue=" + value
diff --git a/services/distribution/src/main/resources/master-config/app-config.yaml b/services/distribution/src/main/resources/master-config/app-config.yaml
--- a/services/distribution/src/main/resources/master-config/app-config.yaml
+++ b/services/distribution/src/main/resources/master-config/app-config.yaml
@@ -13,4 +13,5 @@ attenuationDurationThresholds:
   lower: 50
   upper: 70
 risk-score-classes: !include risk-score-classification.yaml
-exposure-config: !include exposure-config.yaml
\ No newline at end of file
+exposure-config: !include exposure-config.yaml
+app-version: !include app-version-config.yaml
\ No newline at end of file
diff --git a/services/distribution/src/main/resources/master-config/app-version-config.yaml b/services/distribution/src/main/resources/master-config/app-version-config.yaml
new file mode 100644
--- /dev/null
+++ b/services/distribution/src/main/resources/master-config/app-version-config.yaml
@@ -0,0 +1,25 @@
+# This is the Application Version Configuration master file which contains information for clients
+# about the latest and minimum supported mobile app versions on Android and iOS.
+#
+# The latest version must not be lower than the min version.
+#
+# Change this file with caution!
+
+ios:
+  latest:
+    major: 1
+    minor: 5
+    patch: 2
+  min:
+    major: 1
+    minor: 2
+    patch: 2
+android:
+  latest:
+    major: 1
+    minor: 3
+    patch: 2
+  min:
+    major: 1
+    minor: 1
+    patch: 2
\ No newline at end of file

diff --git a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/appconfig/ApplicationVersionConfigurationProviderMasterFileTest.java b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/appconfig/ApplicationVersionConfigurationProviderMasterFileTest.java
new file mode 100644
--- /dev/null
+++ b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/appconfig/ApplicationVersionConfigurationProviderMasterFileTest.java
@@ -0,0 +1,49 @@
+/*-
+ * ---license-start
+ * Corona-Warn-App
+ * ---
+ * Copyright (C) 2020 SAP SE and all other contributors
+ * ---
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ---license-end
+ */
+
+package app.coronawarn.server.services.distribution.assembly.appconfig;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import app.coronawarn.server.services.distribution.assembly.appconfig.validation.ApplicationVersionConfigurationValidator;
+import app.coronawarn.server.services.distribution.assembly.appconfig.validation.ExposureConfigurationValidator;
+import app.coronawarn.server.services.distribution.assembly.appconfig.validation.ValidationResult;
+import org.junit.jupiter.api.Test;
+
+/**
+ * This test will verify that the provided Exposure Configuration master file is syntactically
+ * correct and according to spec.
+ * <p>
+ * There should never be any deployment when this test is failing.
+ */
+class ApplicationVersionConfigurationProviderMasterFileTest {
+
+  private static final ValidationResult SUCCESS = new ValidationResult();
+
+  @Test
+  void testMasterFile() throws UnableToLoadFileException {
+    var config = ApplicationVersionConfigurationProvider.readMasterFile();
+
+    var validator = new ApplicationVersionConfigurationValidator(config);
+    ValidationResult result = validator.validate();
+
+    assertThat(result).isEqualTo(SUCCESS);
+  }
+}
diff --git a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/appconfig/ApplicationVersionConfigurationProviderTest.java b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/appconfig/ApplicationVersionConfigurationProviderTest.java
new file mode 100644
--- /dev/null
+++ b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/appconfig/ApplicationVersionConfigurationProviderTest.java
@@ -0,0 +1,59 @@
+/*
+ * ---license-start
+ * Corona-Warn-App
+ * ---
+ * Copyright (C) 2020 SAP SE and all other contributors
+ * ---
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ---license-end
+ */
+
+package app.coronawarn.server.services.distribution.assembly.appconfig;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.catchThrowable;
+
+import app.coronawarn.server.common.protocols.internal.ApplicationVersionConfiguration;
+import org.junit.jupiter.api.Test;
+
+public class ApplicationVersionConfigurationProviderTest {
+
+  @Test
+  void okFile() throws UnableToLoadFileException {
+    ApplicationVersionConfiguration result =
+        ApplicationVersionConfigurationProvider.readFile("app-version/all_ok.yaml");
+
+    assertThat(result).withFailMessage("File is null, indicating loading failed").isNotNull();
+  }
+
+  @Test
+  void wrongFile() {
+    assertUnableToLoadFile("app-version/wrong_file.yaml");
+  }
+
+  @Test
+  void brokenSyntax() {
+    assertUnableToLoadFile("app-version/broken_syntax.yaml");
+  }
+
+  @Test
+  void doesNotExist() {
+    assertUnableToLoadFile("file_does_not_exist_anywhere.yaml");
+  }
+
+  public static void assertUnableToLoadFile(String s) {
+    assertThat(catchThrowable(() ->
+        ApplicationVersionConfigurationProvider.readFile(s)))
+        .isInstanceOf(UnableToLoadFileException.class);
+  }
+}
diff --git a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/ApplicationVersionConfigurationValidatorTest.java b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/ApplicationVersionConfigurationValidatorTest.java
new file mode 100644
--- /dev/null
+++ b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/ApplicationVersionConfigurationValidatorTest.java
@@ -0,0 +1,61 @@
+/*
+ * ---license-start
+ * Corona-Warn-App
+ * ---
+ * Copyright (C) 2020 SAP SE and all other contributors
+ * ---
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ---license-end
+ */
+
+package app.coronawarn.server.services.distribution.assembly.appconfig.validation;
+
+import static app.coronawarn.server.services.distribution.assembly.appconfig.validation.RiskScoreClassificationValidatorTest.buildError;
+import static app.coronawarn.server.services.distribution.assembly.appconfig.validation.RiskScoreClassificationValidatorTest.buildExpectedResult;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import app.coronawarn.server.common.protocols.internal.ApplicationVersionConfiguration;
+import app.coronawarn.server.services.distribution.assembly.appconfig.ApplicationVersionConfigurationProvider;
+import app.coronawarn.server.services.distribution.assembly.appconfig.UnableToLoadFileException;
+import app.coronawarn.server.services.distribution.assembly.appconfig.validation.GeneralValidationError.ErrorType;
+import org.junit.jupiter.api.Test;
+
+public class ApplicationVersionConfigurationValidatorTest {
+
+  private static final ValidationResult SUCCESS = new ValidationResult();
+
+  @Test
+  void succeedsIfLatestEqualsMin() throws UnableToLoadFileException {
+    ApplicationVersionConfiguration config = ApplicationVersionConfigurationProvider
+        .readFile("app-version/latest-equals-min.yaml");
+    var validator = new ApplicationVersionConfigurationValidator(config);
+    assertThat(validator.validate()).isEqualTo(SUCCESS);
+  }
+
+  @Test
+  void succeedsIfLatestHigherThanMin() throws UnableToLoadFileException {
+    ApplicationVersionConfiguration config = ApplicationVersionConfigurationProvider
+        .readFile("app-version/latest-higher-than-min.yaml");
+    var validator = new ApplicationVersionConfigurationValidator(config);
+    assertThat(validator.validate()).isEqualTo(SUCCESS);
+  }
+
+  @Test
+  void failsIfLatestLowerThanMin() throws UnableToLoadFileException {
+    ApplicationVersionConfiguration config = ApplicationVersionConfigurationProvider
+        .readFile("app-version/latest-lower-than-min.yaml");
+    var validator = new ApplicationVersionConfigurationValidator(config);
+    assertThat(validator.validate())
+        .isEqualTo(buildExpectedResult(buildError("ios: latest/min", "1.2.2", ErrorType.MIN_GREATER_THAN_MAX)));
+  }
+}
diff --git a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/RiskScoreClassificationValidatorTest.java b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/RiskScoreClassificationValidatorTest.java
--- a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/RiskScoreClassificationValidatorTest.java
+++ b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/appconfig/validation/RiskScoreClassificationValidatorTest.java
@@ -150,7 +150,7 @@ private static Stream<Arguments> createValidClassifications() {
     ).map(Arguments::of);
   }

-  private static GeneralValidationError buildError(String parameter, Object value, ErrorType reason) {
+  public static GeneralValidationError buildError(String parameter, Object value, ErrorType reason) {
     return new GeneralValidationError(parameter, value, reason);
   }

diff --git a/services/distribution/src/test/resources/app-version/all_ok.yaml b/services/distribution/src/test/resources/app-version/all_ok.yaml
new file mode 100644
--- /dev/null
+++ b/services/distribution/src/test/resources/app-version/all_ok.yaml
@@ -0,0 +1,18 @@
+ios:
+  latest:
+    major: 1
+    minor: 5
+    patch: 2
+  min:
+    major: 1
+    minor: 2
+    patch: 2
+android:
+  latest:
+    major: 1
+    minor: 3
+    patch: 2
+  min:
+    major: 1
+    minor: 1
+    patch: 2
diff --git a/services/distribution/src/test/resources/app-version/broken_syntax.yaml b/services/distribution/src/test/resources/app-version/broken_syntax.yaml
new file mode 100644
--- /dev/null
+++ b/services/distribution/src/test/resources/app-version/broken_syntax.yaml
@@ -0,0 +1,18 @@
+ios:
+  latest:
+      major: 1 # wrong syntax, indent too high
+    minor: 5
+    patch: 2
+  min:
+    major: 1
+    minor: 2
+    patch: 2
+android:
+  latest:
+    major: 1
+    minor: 3
+    patch: 2
+  min:
+    major: 1
+    minor: 1
+    patch: 2
diff --git a/services/distribution/src/test/resources/app-version/empty.yaml b/services/distribution/src/test/resources/app-version/empty.yaml
new file mode 100644
--- /dev/null
+++ b/services/distribution/src/test/resources/app-version/empty.yaml
@@ -0,0 +1 @@
+# empty file
\ No newline at end of file
diff --git a/services/distribution/src/test/resources/app-version/latest-equals-min.yaml b/services/distribution/src/test/resources/app-version/latest-equals-min.yaml
new file mode 100644
--- /dev/null
+++ b/services/distribution/src/test/resources/app-version/latest-equals-min.yaml
@@ -0,0 +1,18 @@
+ios:
+  latest:
+    major: 1
+    minor: 2
+    patch: 2
+  min:
+    major: 1
+    minor: 2
+    patch: 2
+android:
+  latest:
+    major: 1
+    minor: 3
+    patch: 2
+  min:
+    major: 1
+    minor: 3
+    patch: 2
diff --git a/services/distribution/src/test/resources/app-version/latest-higher-than-min.yaml b/services/distribution/src/test/resources/app-version/latest-higher-than-min.yaml
new file mode 100644
--- /dev/null
+++ b/services/distribution/src/test/resources/app-version/latest-higher-than-min.yaml
@@ -0,0 +1,18 @@
+ios:
+  latest:
+    major: 1
+    minor: 2
+    patch: 3
+  min:
+    major: 1
+    minor: 2
+    patch: 2
+android:
+  latest:
+    major: 1
+    minor: 3
+    patch: 2
+  min:
+    major: 1
+    minor: 3
+    patch: 2
diff --git a/services/distribution/src/test/resources/app-version/latest-lower-than-min.yaml b/services/distribution/src/test/resources/app-version/latest-lower-than-min.yaml
new file mode 100644
--- /dev/null
+++ b/services/distribution/src/test/resources/app-version/latest-lower-than-min.yaml
@@ -0,0 +1,18 @@
+ios:
+  latest:
+    major: 1
+    minor: 2
+    patch: 1
+  min:
+    major: 1
+    minor: 2
+    patch: 2
+android:
+  latest:
+    major: 1
+    minor: 3
+    patch: 2
+  min:
+    major: 1
+    minor: 3
+    patch: 2
diff --git a/services/distribution/src/test/resources/app-version/wrong_file.yaml b/services/distribution/src/test/resources/app-version/wrong_file.yaml
new file mode 100644
--- /dev/null
+++ b/services/distribution/src/test/resources/app-version/wrong_file.yaml
@@ -0,0 +1,20 @@
+ID: com.acme.mta.sample
+version: 1.0.1
+modules:
+  - name: pricing-ui
+    type: nodejs
+    path: ./ui
+    requires:
+      - name: thedatabase
+
+  - name: pricing-backend
+    type: java
+    path: ./backend
+    provides:
+      - name: price_opt
+        properties:
+          protocol: http
+          uri: myhost.mydomain
+resources:
+  - name: thedatabase
+    type: com.sap.xs.hdi-container
\ No newline at end of file
diff --git a/services/distribution/src/test/resources/configtests/app-config_ok.yaml b/services/distribution/src/test/resources/configtests/app-config_ok.yaml
--- a/services/distribution/src/test/resources/configtests/app-config_ok.yaml
+++ b/services/distribution/src/test/resources/configtests/app-config_ok.yaml
@@ -3,4 +3,5 @@ attenuationDurationThresholds:
   lower: 50
   upper: 70
 risk-score-classes: !include risk-score-class_ok.yaml
-exposure-config: !include exposure-config_ok.yaml
\ No newline at end of file
+exposure-config: !include exposure-config_ok.yaml
+app-version: !include app-version-config_ok.yaml
\ No newline at end of file
diff --git a/services/distribution/src/test/resources/configtests/app-version-config_ok.yaml b/services/distribution/src/test/resources/configtests/app-version-config_ok.yaml
new file mode 100644
--- /dev/null
+++ b/services/distribution/src/test/resources/configtests/app-version-config_ok.yaml
@@ -0,0 +1,18 @@
+ios:
+  latest:
+    major: 1
+    minor: 5
+    patch: 2
+  min:
+    major: 1
+    minor: 2
+    patch: 2
+android:
+  latest:
+    major: 1
+    minor: 3
+    patch: 2
+  min:
+    major: 1
+    minor: 1
+    patch: 2

Application Configuration: Add version update support
We would like to keep the mobile application informed about new releases/current release of the app. This information can then be presented to the user, in order to ask for applying updates.

We would like to add new fields to the application config (example values):

```yaml
app-version:
    ios:
        latest: 1.5.2
        min: 1.2.2
    android:
        latest: 1.3.2
        min: 1.1.2
```

After downloading the config, the app checks whether the signature can be verified. If that is the case, it will get the current & min values from the config based on the target platform.

This information can now be used to prompt users for updating the app. The values for the versions must follow semantic versioning.


diff --git a/common/persistence/src/main/java/app/coronawarn/server/common/persistence/service/DiagnosisKeyService.java b/common/persistence/src/main/java/app/coronawarn/server/common/persistence/service/DiagnosisKeyService.java
--- a/common/persistence/src/main/java/app/coronawarn/server/common/persistence/service/DiagnosisKeyService.java
+++ b/common/persistence/src/main/java/app/coronawarn/server/common/persistence/service/DiagnosisKeyService.java
@@ -32,7 +32,6 @@
 import javax.validation.ConstraintViolation;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.data.domain.Sort;
 import org.springframework.data.domain.Sort.Direction;
 import org.springframework.stereotype.Component;
@@ -44,7 +43,6 @@ public class DiagnosisKeyService {
   private static final Logger logger = LoggerFactory.getLogger(DiagnosisKeyService.class);
   private final DiagnosisKeyRepository keyRepository;

-  @Autowired
   public DiagnosisKeyService(DiagnosisKeyRepository keyRepository) {
     this.keyRepository = keyRepository;
   }
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/component/DiagnosisKeysStructureProvider.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/component/DiagnosisKeysStructureProvider.java
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/component/DiagnosisKeysStructureProvider.java
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/component/DiagnosisKeysStructureProvider.java
@@ -30,7 +30,6 @@
 import java.util.Collection;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Component;

 /**
@@ -42,8 +41,7 @@ public class DiagnosisKeysStructureProvider {
   private static final Logger logger = LoggerFactory
       .getLogger(DiagnosisKeysStructureProvider.class);

-  @Autowired
-  private DiagnosisKeyBundler diagnosisKeyBundler;
+  private final DiagnosisKeyBundler diagnosisKeyBundler;
   private final DiagnosisKeyService diagnosisKeyService;
   private final CryptoProvider cryptoProvider;
   private final DistributionServiceConfig distributionServiceConfig;
@@ -52,10 +50,11 @@ public class DiagnosisKeysStructureProvider {
    * Creates a new DiagnosisKeysStructureProvider.
    */
   DiagnosisKeysStructureProvider(DiagnosisKeyService diagnosisKeyService, CryptoProvider cryptoProvider,
-      DistributionServiceConfig distributionServiceConfig) {
+      DistributionServiceConfig distributionServiceConfig, DiagnosisKeyBundler diagnosisKeyBundler) {
     this.diagnosisKeyService = diagnosisKeyService;
     this.cryptoProvider = cryptoProvider;
     this.distributionServiceConfig = distributionServiceConfig;
+    this.diagnosisKeyBundler = diagnosisKeyBundler;
   }

   /**
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/objectstore/S3RetentionPolicy.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/objectstore/S3RetentionPolicy.java
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/objectstore/S3RetentionPolicy.java
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/objectstore/S3RetentionPolicy.java
@@ -29,7 +29,6 @@
 import java.util.List;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
-import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Component;

 /**
@@ -41,7 +40,6 @@ public class S3RetentionPolicy {
   private final ObjectStoreAccess objectStoreAccess;
   private final Api api;

-  @Autowired
   public S3RetentionPolicy(ObjectStoreAccess objectStoreAccess, DistributionServiceConfig distributionServiceConfig) {
     this.objectStoreAccess = objectStoreAccess;
     this.api = distributionServiceConfig.getApi();
diff --git a/services/submission/src/main/java/app/coronawarn/server/services/submission/controller/SubmissionController.java b/services/submission/src/main/java/app/coronawarn/server/services/submission/controller/SubmissionController.java
--- a/services/submission/src/main/java/app/coronawarn/server/services/submission/controller/SubmissionController.java
+++ b/services/submission/src/main/java/app/coronawarn/server/services/submission/controller/SubmissionController.java
@@ -36,7 +36,6 @@
 import org.apache.commons.math3.distribution.PoissonDistribution;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
 import org.springframework.util.StopWatch;
@@ -67,7 +66,6 @@ public class SubmissionController {
   private final Integer retentionDays;
   private Double fakeDelay;

-  @Autowired
   SubmissionController(DiagnosisKeyService diagnosisKeyService, TanVerifier tanVerifier,
       SubmissionServiceConfig submissionServiceConfig) {
     this.diagnosisKeyService = diagnosisKeyService;
diff --git a/services/submission/src/main/java/app/coronawarn/server/services/submission/verification/TanVerifier.java b/services/submission/src/main/java/app/coronawarn/server/services/submission/verification/TanVerifier.java
--- a/services/submission/src/main/java/app/coronawarn/server/services/submission/verification/TanVerifier.java
+++ b/services/submission/src/main/java/app/coronawarn/server/services/submission/verification/TanVerifier.java
@@ -23,7 +23,6 @@
 import feign.FeignException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.web.client.RestClientException;

@@ -41,7 +40,6 @@ public class TanVerifier {
    *
    * @param verificationServerClient The REST client to communicate with the verification server
    */
-  @Autowired
   public TanVerifier(VerificationServerClient verificationServerClient) {
     this.verificationServerClient = verificationServerClient;
   }

diff --git a/services/submission/src/test/java/app/coronawarn/server/services/submission/controller/RequestExecutor.java b/services/submission/src/test/java/app/coronawarn/server/services/submission/controller/RequestExecutor.java
--- a/services/submission/src/test/java/app/coronawarn/server/services/submission/controller/RequestExecutor.java
+++ b/services/submission/src/test/java/app/coronawarn/server/services/submission/controller/RequestExecutor.java
@@ -29,7 +29,6 @@
 import java.time.Instant;
 import java.time.LocalDate;
 import java.util.Collection;
-import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.test.web.client.TestRestTemplate;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.HttpMethod;
@@ -50,7 +49,6 @@ public class RequestExecutor {
   private static final URI SUBMISSION_URL = URI.create("/version/v1/diagnosis-keys");
   private final TestRestTemplate testRestTemplate;

-  @Autowired
   public RequestExecutor(TestRestTemplate testRestTemplate) {
     this.testRestTemplate = testRestTemplate;
   }

Use Implicit Constructor Injection over Explicit
The `@Autowired` annotation for Spring components can be omitted for constructors in case there is only one constructor present on the class. Therefore, remove all the unnecessary annotation for those cases.

I guess it should be doable for me to get started