Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,11 @@ import com.netflix.spinnaker.orca.front50.spring.DependentPipelineExecutionListe
import com.netflix.spinnaker.orca.pipeline.persistence.ExecutionRepository
import com.netflix.spinnaker.orca.retrofit.RetrofitConfiguration
import com.netflix.spinnaker.orca.retrofit.logging.RetrofitSlf4jLog
import com.netflix.spinnaker.okhttp.OkHttpClientConfigurationProperties
import groovy.transform.CompileStatic
import okhttp3.OkHttpClient
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression
import org.springframework.boot.context.properties.EnableConfigurationProperties
Expand Down Expand Up @@ -59,6 +62,8 @@ import static retrofit.Endpoints.newFixedEndpoint
@ConditionalOnExpression('${front50.enabled:true}')
class Front50Configuration {

private static final Logger log = LoggerFactory.getLogger(Front50Configuration.class)

@Autowired
OkHttpClientProvider clientProvider

Expand All @@ -74,17 +79,41 @@ class Front50Configuration {
}

@Bean
Front50Service front50Service(Endpoint front50Endpoint, ObjectMapper mapper, Front50ConfigurationProperties front50ConfigurationProperties) {
OkHttpClient okHttpClient = clientProvider.getClient(new DefaultServiceEndpoint("front50", front50Endpoint.getUrl()));
okHttpClient = okHttpClient.newBuilder()
.readTimeout(front50ConfigurationProperties.okhttp.readTimeoutMs, TimeUnit.MILLISECONDS)
.writeTimeout(front50ConfigurationProperties.okhttp.writeTimeoutMs, TimeUnit.MILLISECONDS)
.connectTimeout(front50ConfigurationProperties.okhttp.connectTimeoutMs, TimeUnit.MILLISECONDS)
.build();
Front50Service front50Service(
Endpoint front50Endpoint,
ObjectMapper mapper,
Front50ConfigurationProperties front50ConfigurationProperties,
OkHttpClientConfigurationProperties okHttpClientConfigurationProperties) {

// Get base client with global configuration
OkHttpClient baseClient = clientProvider.getClient(new DefaultServiceEndpoint("front50", front50Endpoint.getUrl()))
OkHttpClient.Builder builder = baseClient.newBuilder()

// Apply global timeouts first
builder.connectTimeout(okHttpClientConfigurationProperties.getConnectTimeoutMs(), TimeUnit.MILLISECONDS)
builder.readTimeout(okHttpClientConfigurationProperties.getReadTimeoutMs(), TimeUnit.MILLISECONDS)

// Override with Front50-specific timeouts if explicitly defined
if (front50ConfigurationProperties.okhttp?.connectTimeoutMs != null) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even with capital-L long, I don't think this way of checking works, since the properties have default values. I think we gotta do the environment.containsProperty thing. There's a failing test, but I'm not sure it's failing because of this...so perhaps we need some more test coverage like WebhookConfigurationTest.

For me the code is easier to follow using local variables and then calling the builder once, like:

    long readTimeoutMs =
         (environment.containsProperty("webhook.readTimeoutMs")
                 || environment.containsProperty("webhook.read-timeout-ms"))
             ? webhookProperties.getReadTimeoutMs()
             : okHttpClientConfigurationProperties.getReadTimeoutMs();
    requestFactory.setReadTimeout(Math.toIntExact(readTimeoutMs));

log.debug("Using front50-specific connect timeout: {}ms", front50ConfigurationProperties.okhttp.connectTimeoutMs)
builder.connectTimeout(front50ConfigurationProperties.okhttp.connectTimeoutMs, TimeUnit.MILLISECONDS)
}

if (front50ConfigurationProperties.okhttp?.readTimeoutMs != null) {
log.debug("Using front50-specific read timeout: {}ms", front50ConfigurationProperties.okhttp.readTimeoutMs)
builder.readTimeout(front50ConfigurationProperties.okhttp.readTimeoutMs, TimeUnit.MILLISECONDS)
}

if (front50ConfigurationProperties.okhttp?.writeTimeoutMs != null) {
log.debug("Using front50-specific write timeout: {}ms", front50ConfigurationProperties.okhttp.writeTimeoutMs)
builder.writeTimeout(front50ConfigurationProperties.okhttp.writeTimeoutMs, TimeUnit.MILLISECONDS)
}

// Create and return the service
new RestAdapter.Builder()
.setRequestInterceptor(spinnakerRequestInterceptor)
.setEndpoint(front50Endpoint)
.setClient(new Ok3Client(okHttpClient))
.setClient(new Ok3Client(builder.build()))
.setLogLevel(retrofitLogLevel)
.setLog(new RetrofitSlf4jLog(Front50Service))
.setConverter(new JacksonConverter(mapper))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,30 @@
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;

/**
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't decide whether I think this comment is helpful enough vs. the chances of it getting stale. Since these are the higher-precedence properties, I'm pretty tempted to ditch the comment.

* Configuration properties for Front50 service.
*
* <p>These properties can be configured in your YAML configuration:
*
* <pre>
* front50:
* baseUrl: http://front50.example.com
* enabled: true
* useTriggeredByEndpoint: true
* okhttp:
* connectTimeoutMs: 10000
* readTimeoutMs: 60000
* writeTimeoutMs: 60000
* </pre>
*
* <p>If not explicitly configured, a fallback chain will be used for timeouts:
*
* <ol>
* <li>Use explicit okhttp configuration if present
* <li>Fall back to global okhttp client configuration
* <li>Use default fallback values (10s connect, 60s read/write)
* </ol>
*/
@Data
@ConfigurationProperties("front50")
public class Front50ConfigurationProperties {
Expand All @@ -34,14 +58,33 @@ public class Front50ConfigurationProperties {
*/
boolean useTriggeredByEndpoint = true;

/** HTTP client configuration for connecting to Front50 service */
OkHttpConfigurationProperties okhttp = new OkHttpConfigurationProperties();

/**
* Configuration properties for the OkHttp client connecting to Front50. These will only be used
* if explicitly set in the configuration. Otherwise, global client timeouts will be used as
* fallback.
*/
@Data
public static class OkHttpConfigurationProperties {
int readTimeoutMs = 10000;
/** Read timeout in milliseconds. Default is 120 seconds (120000ms) */
private Long readTimeoutMs = 120000L;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
private Long readTimeoutMs = 120000L;
private long readTimeoutMs = 120000L;


/** Write timeout in milliseconds. Default is 60 seconds (60000ms) */
private Long writeTimeoutMs = 60000L;

int writeTimeoutMs = 10000;
/** Connection timeout in milliseconds. Default is 5 seconds (5000ms) */
private Long connectTimeoutMs = 5000L;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
private Long connectTimeoutMs = 5000L;
private long connectTimeoutMs = 5000L;


int connectTimeoutMs = 10000;
/**
* Checks if this instance has any custom timeout configuration.
*
* @return true if any timeout is non-default, false otherwise
*/
public boolean hasCustomTimeouts() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't think we need this method.

// Compare with default values to determine if explicit config was provided
return readTimeoutMs != 120000L || writeTimeoutMs != 60000L || connectTimeoutMs != 5000L;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*
* Copyright 2024 Armory, Inc.
*
* 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.
*/

package com.netflix.spinnaker.orca.front50.config

import com.fasterxml.jackson.databind.ObjectMapper
import com.netflix.spinnaker.okhttp.OkHttpClientConfigurationProperties
import retrofit.Endpoint
import spock.lang.Specification
import spock.lang.Subject
import java.util.concurrent.TimeUnit

/**
* Tests for Front50 timeout configuration
*/
class Front50ConfigurationSpec extends Specification {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you mind converting this to java please. The less groovy code we have in the world, the better.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, let me know if it looks better now :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still looks like groovy to me...


@Subject
Front50Configuration front50Configuration = new Front50Configuration()

/**
* Verifies that the default timeout values in Front50ConfigurationProperties match
* those in OkHttpClientConfigurationProperties to ensure backward compatibility
*/
def "default timeout values should match OkHttpClientConfigurationProperties"() {
given:
OkHttpClientConfigurationProperties globalProps = new OkHttpClientConfigurationProperties()
Front50ConfigurationProperties.OkHttpConfigurationProperties front50Props =
new Front50ConfigurationProperties.OkHttpConfigurationProperties()

expect:
front50Props.connectTimeoutMs == globalProps.connectTimeoutMs
front50Props.readTimeoutMs == globalProps.readTimeoutMs
}

/**
* Verifies that the front50Service method accepts OkHttpClientConfigurationProperties
* as a parameter, which is necessary for the timeout fallback mechanism to work
*/
def "front50Service method should accept OkHttpClientConfigurationProperties parameter"() {
given:
def method = Front50Configuration.class.getDeclaredMethod(
"front50Service",
Endpoint.class,
ObjectMapper.class,
Front50ConfigurationProperties.class,
OkHttpClientConfigurationProperties.class)

expect:
method != null
}

/**
* Verifies that hasCustomTimeouts correctly identifies when custom timeouts exist
*/
def "hasCustomTimeouts should return #expected when #description"() {
given:
def props = new Front50ConfigurationProperties.OkHttpConfigurationProperties()
if (connectTimeout != null) {
props.connectTimeoutMs = connectTimeout
}
if (readTimeout != null) {
props.readTimeoutMs = readTimeout
}

expect:
props.hasCustomTimeouts() == expected

where:
description | connectTimeout | readTimeout || expected
"using default values" | 5000L | 120000L || false
"only connect timeout changed" | 10000L | 120000L || true
"only read timeout changed" | 5000L | 30000L || true
"both timeouts changed" | 10000L | 30000L || true
}

/**
* This test examines the source code of Front50Configuration to verify that
* the timeout fallback pattern is correctly implemented. This method was chosen
* because OkHttpClient.Builder is a final class and cannot be easily mocked.
*/
def "front50Service uses the correct timeout fallback pattern"() {
given:
def sourceFile = new File("/Users/shlomodaari/armory/spinnaker-oss-services/orca/orca-front50/src/main/groovy/com/netflix/spinnaker/orca/front50/config/Front50Configuration.groovy")
def sourceCode = sourceFile.exists() ? sourceFile.text : null

expect:
sourceCode != null

// First apply global timeouts
sourceCode.contains("builder.connectTimeout(okHttpClientConfigurationProperties.getConnectTimeoutMs(), TimeUnit.MILLISECONDS)")
sourceCode.contains("builder.readTimeout(okHttpClientConfigurationProperties.getReadTimeoutMs(), TimeUnit.MILLISECONDS)")

// Then conditionally override with Front50-specific timeouts if defined
sourceCode.contains("if (front50ConfigurationProperties.okhttp?.connectTimeoutMs != null) {")
sourceCode.contains("builder.connectTimeout(front50ConfigurationProperties.okhttp.connectTimeoutMs, TimeUnit.MILLISECONDS)")
sourceCode.contains("if (front50ConfigurationProperties.okhttp?.readTimeoutMs != null) {")
sourceCode.contains("builder.readTimeout(front50ConfigurationProperties.okhttp.readTimeoutMs, TimeUnit.MILLISECONDS)")
}
}
34 changes: 34 additions & 0 deletions orca-web/config/orca-local.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
server:
port: 8083

redis:
enabled: false # Disable Redis dependency for local testing

sql:
enabled: true # Use SQL backend instead
connectionPool:
jdbcUrl: jdbc:h2:mem:orca;MODE=MYSQL;DB_CLOSE_DELAY=-1
driver: org.h2.Driver

spring:
datasource:
url: jdbc:h2:mem:orca;MODE=MYSQL;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver

keiko:
queue:
memory:
enabled: true # Use in-memory queue

services:
echo:
enabled: false # Disable echo service dependency for testing
front50:
enabled: false # Disable front50 service dependency for testing
clouddriver:
enabled: false # Disable clouddriver service dependency for testing

# Debug logging for our fix
logging:
level:
com.netflix.spinnaker.orca.echo.spring.EchoNotifyingStageListener: DEBUG