Repository: ovh/cds
Pull request #5582 (2020-12-07 09:43:33)

fix(api,cdn,ui): load all steps (lines count) at once
1. Description
1. Related issues
1. About tests
1. Mentions

@ovh/cds


ovh-cds: 
CDS Report build-all-cds#15051.0 ✘  
* Build
  * Build and Package All ✔ 
  * Build Debpacker ✔ 
  * Build OS-Ansible-Inventory ✔ 
  * Build SMTPMock ✔ 
  * Docs -   
  * Lint UI Translate ✔ 
  * Test CLI ✔ 
  * Test Engine ✘  
  * Test SDK ✔ 
  * Test SDK Rust ✔ 
  * Test UI ✘  

ovh-cds: 
CDS Report build-all-cds#15052.0 ✘  
* Build
  * Build and Package All ✔ 
  * Build Debpacker ✔ 
  * Build OS-Ansible-Inventory ✔ 
  * Build SMTPMock ✔ 
  * Docs -   
  * Lint UI Translate ✔ 
  * Test CLI ✔ 
  * Test Engine ✘  
  * Test SDK ✔ 
  * Test SDK Rust ✔ 
  * Test UI ✔ 

sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!

[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug.png' alt='Bug' width='16' height='16' />](https://sonarcloud.io/project/issues?id=ovh_cds&pullRequest=5582&resolved=false&types=BUG) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=ovh_cds&pullRequest=5582&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=ovh_cds&pullRequest=5582&resolved=false&types=BUG)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability.png' alt='Vulnerability' width='16' height='16' />](https://sonarcloud.io/project/issues?id=ovh_cds&pullRequest=5582&resolved=false&types=VULNERABILITY) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=ovh_cds&pullRequest=5582&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=ovh_cds&pullRequest=5582&resolved=false&types=VULNERABILITY)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot.png' alt='Security Hotspot' width='16' height='16' />](https://sonarcloud.io/project/security_hotspots?id=ovh_cds&pullRequest=5582&resolved=false&types=SECURITY_HOTSPOT) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/security_hotspots?id=ovh_cds&pullRequest=5582&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=ovh_cds&pullRequest=5582&resolved=false&types=SECURITY_HOTSPOT)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell.png' alt='Code Smell' width='16' height='16' />](https://sonarcloud.io/project/issues?id=ovh_cds&pullRequest=5582&resolved=false&types=CODE_SMELL) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=ovh_cds&pullRequest=5582&resolved=false&types=CODE_SMELL) [4 Code Smells](https://sonarcloud.io/project/issues?id=ovh_cds&pullRequest=5582&resolved=false&types=CODE_SMELL)

[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo.png' alt='No Coverage information' width='16' height='16' />](https://sonarcloud.io/component_measures?id=ovh_cds&pullRequest=5582&metric=coverage&view=list) No Coverage information  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/NoDuplicationInfo.png' alt='No Duplication information' width='16' height='16' />](https://sonarcloud.io/component_measures?id=ovh_cds&pullRequest=5582&metric=duplicated_lines_density&view=list) No Duplication information


ovh-cds: 
CDS Report build-all-cds#15055.0 ✘  
* Build
  * Build and Package All ✔ 
  * Build Debpacker ✔ 
  * Build OS-Ansible-Inventory ✔ 
  * Build SMTPMock ✔ 
  * Docs -   
  * Lint UI Translate ✔ 
  * Test CLI ✔ 
  * Test Engine ✔ 
  * Test SDK ✔ 
  * Test SDK Rust ✔ 
  * Test UI ✔ 
* Package and Analyze
  * Docker ✔ 
  * Sonar ✔ 
* Integration
  * Docker Compose Tests ✔ 
  * Standalone Tests ✘  


Patch:
diff --git a/engine/api/api_routes.go b/engine/api/api_routes.go
index 123a9b87e..27b8415be 100644
--- a/engine/api/api_routes.go
+++ b/engine/api/api_routes.go
@@ -274,6 +274,7 @@ func (api *API) InitRouter() {
 	r.Handle("/project/{key}/workflows/{permWorkflowName}/runs/{number}/nodes/{nodeRunID}/job/{runJobID}/info", Scope(sdk.AuthConsumerScopeRun), r.GET(api.getWorkflowNodeRunJobSpawnInfosHandler))
 	r.Handle("/project/{key}/workflows/{permWorkflowName}/nodes/{nodeRunID}/job/{runJobID}/service/{serviceName}/link", Scope(sdk.AuthConsumerScopeRun), r.GET(api.getWorkflowNodeRunJobServiceLinkHandler))
 	r.Handle("/project/{key}/workflows/{permWorkflowName}/nodes/{nodeRunID}/job/{runJobID}/service/{serviceName}/log", Scope(sdk.AuthConsumerScopeRun), r.GET(api.getWorkflowNodeRunJobServiceLogHandler))
+	r.Handle("/project/{key}/workflows/{permWorkflowName}/nodes/{nodeRunID}/job/{runJobID}/links", Scope(sdk.AuthConsumerScopeRun), r.GET(api.getWorkflowNodeRunJobStepLinksHandler))
 	r.Handle("/project/{key}/workflows/{permWorkflowName}/nodes/{nodeRunID}/job/{runJobID}/step/{stepOrder}/link", Scope(sdk.AuthConsumerScopeRun), r.GET(api.getWorkflowNodeRunJobStepLinkHandler))
 	r.Handle("/project/{key}/workflows/{workflowName}/log/access", ScopeNone(), r.GET(api.getWorkflowLogAccessHandler))
 	r.Handle("/project/{key}/workflows/{permWorkflowName}/nodes/{nodeRunID}/job/{runJobID}/step/{stepOrder}/log", Scope(sdk.AuthConsumerScopeRun), r.GET(api.getWorkflowNodeRunJobStepLogHandler))
diff --git a/engine/api/application_import.go b/engine/api/application_import.go
index 4855b50d1..41b9fbdcf 100644
--- a/engine/api/application_import.go
+++ b/engine/api/application_import.go
@@ -63,13 +63,13 @@ func (api *API) postApplicationImportHandler() service.Handler {
 		defer tx.Rollback() // nolint
 
 		newApp, _, msgList, globalError := application.ParseAndImport(ctx, tx, api.Cache, *proj, eapp, application.ImportOptions{Force: force}, project.DecryptWithBuiltinKey, getAPIConsumer(ctx))
-		msgListString := translate(r, msgList)
+		msgListString := translate(msgList)
 		if globalError != nil {
 			globalError = sdk.WrapError(globalError, "Unable to import application %s", eapp.Name)
 			if sdk.ErrorIsUnknown(globalError) {
 				return globalError
 			}
-			sdkErr := sdk.ExtractHTTPError(globalError, r.Header.Get("Accept-Language"))
+			sdkErr := sdk.ExtractHTTPError(globalError)
 			return service.WriteJSON(w, append(msgListString, sdkErr.Message), sdkErr.Status)
 		}
 
diff --git a/engine/api/ascode.go b/engine/api/ascode.go
index 2b7b5be83..3e10e65d3 100644
--- a/engine/api/ascode.go
+++ b/engine/api/ascode.go
@@ -206,7 +206,7 @@ func (api *API) postPerformImportAsCodeHandler() service.Handler {
 		if err := workflowtemplate.UpdateTemplateInstanceWithWorkflow(ctx, api.mustDB(), *wrkflw, *consumer, wti); err != nil {
 			return err
 		}
-		msgListString := translate(r, allMsg)
+		msgListString := translate(allMsg)
 
 		tx, err := api.mustDB().Begin()
 		if err != nil {
diff --git a/engine/api/ascode/dao.go b/engine/api/ascode/dao.go
index 7497f9b19..043594dcc 100644
--- a/engine/api/ascode/dao.go
+++ b/engine/api/ascode/dao.go
@@ -99,7 +99,7 @@ func deleteEvent(db gorp.SqlExecutor, event *sdk.AsCodeEvent) error {
 // DeleteAsCodeEventByWorkflowID removes all as_code_event from workflow_id
 func DeleteAsCodeEventByWorkflowID(db gorp.SqlExecutor, id int64) error {
 	_, err := db.Exec("DELETE FROM as_code_events WHERE workflow_id = $1", id)
-	return sdk.WrapError(err, "unable to delete as_code_events with workflow_id %s", id)
+	return sdk.WrapError(err, "unable to delete as_code_events with workflow_id %d", id)
 }
 
 func DeleteEventsPipelineOnlyFromRepoName(ctx context.Context, db gorp.SqlExecutor, fromRepository string, pipID int64, pipName string) error {
diff --git a/engine/api/environment_import.go b/engine/api/environment_import.go
index fc870218b..41f351d0d 100644
--- a/engine/api/environment_import.go
+++ b/engine/api/environment_import.go
@@ -59,13 +59,13 @@ func (api *API) postEnvironmentImportHandler() service.Handler {
 		defer tx.Rollback() // nolint
 
 		_, _, msgList, globalError := environment.ParseAndImport(ctx, tx, *proj, data, environment.ImportOptions{Force: force}, project.DecryptWithBuiltinKey, getAPIConsumer(ctx))
-		msgListString := translate(r, msgList)
+		msgListString := translate(msgList)
 		if globalError != nil {
 			globalError = sdk.WrapError(globalError, "Unable to import environment %s", data.Name)
 			if sdk.ErrorIsUnknown(globalError) {
 				return globalError
 			}
-			sdkErr := sdk.ExtractHTTPError(globalError, r.Header.Get("Accept-Language"))
+			sdkErr := sdk.ExtractHTTPError(globalError)
 			return service.WriteJSON(w, append(msgListString, sdkErr.Message), sdkErr.Status)
 		}
 
@@ -136,7 +136,7 @@ func (api *API) importNewEnvironmentHandler() service.Handler {
 		close(msgChan)
 		<-done
 
-		msgListString := translate(r, allMsg)
+		msgListString := translate(allMsg)
 
 		if err := tx.Commit(); err != nil {
 			return sdk.WithStack(err)
@@ -209,7 +209,7 @@ func (api *API) importIntoEnvironmentHandler() service.Handler {
 		close(msgChan)
 		<-done
 
-		msgListString := translate(r, allMsg)
+		msgListString := translate(allMsg)
 
 		if err := tx.Commit(); err != nil {
 			return sdk.WithStack(err)
diff --git a/engine/api/pipeline_import.go b/engine/api/pipeline_import.go
index 55d4a0714..ce4689b15 100644
--- a/engine/api/pipeline_import.go
+++ b/engine/api/pipeline_import.go
@@ -87,13 +87,13 @@ func (api *API) importPipelineHandler() service.Handler {
 
 		pip, allMsg, globalError := pipeline.ParseAndImport(ctx, tx, api.Cache, *proj, data, getAPIConsumer(ctx),
 			pipeline.ImportOptions{Force: force})
-		msgListString := translate(r, allMsg)
+		msgListString := translate(allMsg)
 		if globalError != nil {
 			globalError = sdk.WrapError(globalError, "unable to import pipeline")
 			if sdk.ErrorIsUnknown(globalError) {
 				return globalError
 			}
-			sdkErr := sdk.ExtractHTTPError(globalError, r.Header.Get("Accept-Language"))
+			sdkErr := sdk.ExtractHTTPError(globalError)
 			return service.WriteJSON(w, append(msgListString, sdkErr.Message), sdkErr.Status)
 		}
 
@@ -147,7 +147,7 @@ func (api *API) putImportPipelineHandler() service.Handler {
 		defer tx.Rollback() // nolint
 
 		pip, allMsg, err := pipeline.ParseAndImport(ctx, tx, api.Cache, *proj, data, getAPIConsumer(ctx), pipeline.ImportOptions{Force: true, PipelineName: pipelineName})
-		msgListString := translate(r, allMsg)
+		msgListString := translate(allMsg)
 		if err != nil {
 			return sdk.NewErrorWithStack(err, sdk.NewErrorFrom(sdk.ErrInvalidPipeline, "unable to parse and import pipeline"))
 		}
diff --git a/engine/api/router_util.go b/engine/api/router_util.go
index 3e740cd84..f5ac83236 100644
--- a/engine/api/router_util.go
+++ b/engine/api/router_util.go
@@ -162,11 +162,10 @@ func requestVarInt(r *http.Request, s string) (int64, error) {
 	return id, nil
 }
 
-func translate(r *http.Request, msgList []sdk.Message) []string {
-	al := r.Header.Get("Accept-Language")
+func translate(msgList []sdk.Message) []string {
 	msgListString := []string{}
 	for _, m := range msgList {
-		s := m.String(al)
+		s := m.String()
 		if s != "" {
 			msgListString = append(msgListString, s)
 		}
diff --git a/engine/api/templates.go b/engine/api/templates.go
index 72638e156..d5f833181 100644
--- a/engine/api/templates.go
+++ b/engine/api/templates.go
@@ -480,7 +480,7 @@ func (api *API) postTemplateApplyHandler() service.Handler {
 			return err
 		}
 
-		msgStrings := translate(r, msgs)
+		msgStrings := translate(msgs)
 
 		log.Debug("postTemplateApplyHandler> importing the workflow %s from template %s", wkf.Name, wt.Slug)
 
@@ -976,7 +976,7 @@ func (api *API) postTemplatePushHandler() service.Handler {
 		w.Header().Add(sdk.ResponseTemplateGroupNameHeader, wt.Group.Name)
 		w.Header().Add(sdk.ResponseTemplateSlugHeader, wt.Slug)
 
-		return service.WriteJSON(w, translate(r, msgs), http.StatusOK)
+		return service.WriteJSON(w, translate(msgs), http.StatusOK)
 	}
 }
 
diff --git a/engine/api/workflow.go b/engine/api/workflow.go
index 6fae69f9c..0391b7aee 100644
--- a/engine/api/workflow.go
+++ b/engine/api/workflow.go
@@ -199,8 +199,7 @@ func (api *API) postWorkflowRetentionPolicyDryRun() service.Handler {
 		api.GoRoutines.Exec(api.Router.Background, "workflow-retention-dryrun", func(ctx context.Context) {
 			if err := purge.ApplyRetentionPolicyOnWorkflow(ctx, api.Cache, api.mustDBWithCtx(ctx), *wf, purge.MarkAsDeleteOptions{DryRun: true}, u.AuthentifiedUser); err != nil {
 				log.ErrorWithFields(ctx, log.Fields{"stack_trace": fmt.Sprintf("%+v", err)}, "%s", err)
-				al := r.Header.Get("Accept-Language")
-				httpErr := sdk.ExtractHTTPError(err, al)
+				httpErr := sdk.ExtractHTTPError(err)
 				event.PublishWorkflowRetentionDryRun(ctx, key, name, "ERROR", httpErr.Error(), nil, 0, u.AuthentifiedUser)
 			}
 		})
diff --git a/engine/api/workflow/dao_node_run.go b/engine/api/workflow/dao_node_run.go
index b8bb6c2dd..b3a7a601c 100644
--- a/engine/api/workflow/dao_node_run.go
+++ b/engine/api/workflow/dao_node_run.go
@@ -58,18 +58,18 @@ workflow_node_run.callback
 const nodeRunTestsField string = ", workflow_node_run.tests"
 const withLightNodeRunTestsField string = ", json_build_object('ko', workflow_node_run.tests->'ko', 'ok', workflow_node_run.tests->'ok', 'skipped', workflow_node_run.tests->'skipped', 'total', workflow_node_run.tests->'total') AS tests"
 
-func LoadNodeRunIDs(db gorp.SqlExecutor, wIDs []int64) ([]sdk.WorkflowNodeRunIdentifiers, error) {
+func LoadNodeRunIDs(db gorp.SqlExecutor, wIDs []int64, status []string) ([]sdk.WorkflowNodeRunIdentifiers, error) {
 	query := `
 		SELECT workflow_run.id, workflow_run.workflow_id, workflow.name, workflow_run.num, workflow_node_run.id as node_run_id
 		FROM workflow_run
 		JOIN workflow ON workflow.id = workflow_run.workflow_id
 		JOIN workflow_node_run ON workflow_node_run.workflow_run_id = workflow_run.id
-		WHERE workflow_run.workflow_id = ANY($1)
+		WHERE workflow_run.workflow_id = ANY($1) AND workflow_node_run.status = ANY($2)
 		ORDER BY workflow_id, id, node_run_id;
 	`
 
 	var ids []sdk.WorkflowNodeRunIdentifiers
-	if _, err := db.Select(&ids, query, pq.Int64Array(wIDs)); err != nil {
+	if _, err := db.Select(&ids, query, pq.Int64Array(wIDs), pq.StringArray(status)); err != nil {
 		return nil, err
 	}
 	return ids, nil
diff --git a/engine/api/workflow/dao_run.go b/engine/api/workflow/dao_run.go
index 392c849b3..edea56d4a 100644
--- a/engine/api/workflow/dao_run.go
+++ b/engine/api/workflow/dao_run.go
@@ -54,7 +54,6 @@ type LoadRunOptions struct {
 	WithVulnerabilities     bool
 	WithDeleted             bool
 	DisableDetailledNodeRun bool
-	Language                string
 }
 
 // insertWorkflowRun inserts in table "workflow_run""
@@ -955,11 +954,7 @@ func syncNodeRuns(db gorp.SqlExecutor, wr *sdk.WorkflowRun, loadOpts LoadRunOpti
 			}
 			wnr.Coverage = cov
 		}
-		var l = loadOpts.Language
-		if l == "" {
-			l = "en"
-		}
-		wnr.Translate(l)
+		wnr.Translate()
 		wr.WorkflowNodeRuns[wnr.WorkflowNodeID] = append(wr.WorkflowNodeRuns[wnr.WorkflowNodeID], *wnr)
 	}
 
diff --git a/engine/api/workflow/execute_node_run.go b/engine/api/workflow/execute_node_run.go
index d30ba7519..9713a5091 100644
--- a/engine/api/workflow/execute_node_run.go
+++ b/engine/api/workflow/execute_node_run.go
@@ -523,7 +523,7 @@ jobLoop:
 				msg := sdk.SpawnMsg{
 					ID: sdk.MsgSpawnInfoJobError.ID,
 				}
-				msg.Args = []interface{}{sdk.ExtractHTTPError(e, "").Error()}
+				msg.Args = []interface{}{sdk.ExtractHTTPError(e).Error()}
 				wjob.SpawnInfos = append(wjob.SpawnInfos, sdk.SpawnInfo{
 					APITime:     time.Now(),
 					Message:     msg,
diff --git a/engine/api/workflow/process_node.go b/engine/api/workflow/process_node.go
index f945af74a..05d186f8b 100644
--- a/engine/api/workflow/process_node.go
+++ b/engine/api/workflow/process_node.go
@@ -36,7 +36,7 @@ func processNodeTriggers(ctx context.Context, db gorpmapper.SqlExecutorWithTx, s
 			r1, _, errPwnr := processNodeRun(ctx, db, store, proj, wr, mapNodes, &t.ChildNode, int(parentSubNumber), parentNodeRun, nil, nil)
 			if errPwnr != nil {
 				log.Error(ctx, "processWorkflowRun> Unable to process node ID=%d: %s", t.ChildNode.ID, errPwnr)
-				AddWorkflowRunInfo(wr, sdk.SpawnMsgNew(*sdk.MsgWorkflowError, sdk.ExtractHTTPError(errPwnr, "")))
+				AddWorkflowRunInfo(wr, sdk.SpawnMsgNew(*sdk.MsgWorkflowError, sdk.ExtractHTTPError(errPwnr)))
 			}
 			report.Merge(ctx, r1)
 			continue
@@ -242,7 +242,7 @@ func processNode(ctx context.Context, db gorpmapper.SqlExecutorWithTx, store cac
 
 		vcsInf, errVcs = getVCSInfos(ctx, db, store, proj.Key, vcsServer, currentJobGitValues, app.Name, app.VCSServer, app.RepositoryFullname)
 		if errVcs != nil {
-			AddWorkflowRunInfo(wr, sdk.SpawnMsgNew(*sdk.MsgWorkflowError, sdk.ExtractHTTPError(errVcs, "")))
+			AddWorkflowRunInfo(wr, sdk.SpawnMsgNew(*sdk.MsgWorkflowError, sdk.ExtractHTTPError(errVcs)))
 			return nil, false, sdk.WrapError(errVcs, "unable to get git informations")
 		}
 	}
@@ -479,7 +479,7 @@ func computePayload(n *sdk.Node, hookEvent *sdk.WorkflowNodeRunHookEvent, manual
 func computeNodeContextBuildParameters(ctx context.Context, proj sdk.Project, wr *sdk.WorkflowRun, run *sdk.WorkflowNodeRun, n *sdk.Node, runContext nodeRunContext) {
 	nodeRunParams, errParam := getNodeRunBuildParameters(ctx, proj, wr, run, runContext)
 	if errParam != nil {
-		AddWorkflowRunInfo(wr, sdk.SpawnMsgNew(*sdk.MsgWorkflowError, sdk.ExtractHTTPError(errParam, "")))
+		AddWorkflowRunInfo(wr, sdk.SpawnMsgNew(*sdk.MsgWorkflowError, sdk.ExtractHTTPError(errParam)))
 		// if there an error -> display it in workflowRunInfo and not stop the launch
 		log.Error(ctx, "processNode> getNodeRunBuildParameters failed. Project:%s [#%d.%d]%s.%d with payload %v err:%v", proj.Name, wr.Number, run.SubNumber, wr.Workflow.Name, n.ID, run.Payload, errParam)
 	}
diff --git a/engine/api/workflow_import.go b/engine/api/workflow_import.go
index 38ce57306..ef8109531 100644
--- a/engine/api/workflow_import.go
+++ b/engine/api/workflow_import.go
@@ -138,10 +138,10 @@ func (api *API) postWorkflowImportHandler() service.Handler {
 		}
 
 		wrkflw, msgList, globalError := workflow.ParseAndImport(ctx, tx, api.Cache, *proj, wf, ew, getAPIConsumer(ctx), workflow.ImportOptions{Force: force})
-		msgListString := translate(r, msgList)
+		msgListString := translate(msgList)
 		if globalError != nil {
 			if len(msgListString) != 0 {
-				sdkErr := sdk.ExtractHTTPError(globalError, r.Header.Get("Accept-Language"))
+				sdkErr := sdk.ExtractHTTPError(globalError)
 				return service.WriteJSON(w, append(msgListString, sdkErr.Message), sdkErr.Status)
 			}
 			return sdk.WrapError(globalError, "Unable to import workflow %s", ew.GetName())
@@ -222,10 +222,10 @@ func (api *API) putWorkflowImportHandler() service.Handler {
 		defer tx.Rollback() //nolint
 
 		wrkflw, msgList, globalError := workflow.ParseAndImport(ctx, tx, api.Cache, *proj, wf, ew, u, workflow.ImportOptions{Force: true, WorkflowName: wfName})
-		msgListString := translate(r, msgList)
+		msgListString := translate(msgList)
 		if globalError != nil {
 			if len(msgListString) != 0 {
-				sdkErr := sdk.ExtractHTTPError(globalError, r.Header.Get("Accept-Language"))
+				sdkErr := sdk.ExtractHTTPError(globalError)
 				return service.WriteJSON(w, append(msgListString, sdkErr.Error()), sdkErr.Status)
 			}
 			return sdk.WrapError(globalError, "unable to import workflow %s", ew.GetName())
@@ -325,7 +325,7 @@ func (api *API) postWorkflowPushHandler() service.Handler {
 			return err
 		}
 
-		msgListString := translate(r, allMsg)
+		msgListString := translate(allMsg)
 
 		if wrkflw != nil {
 			w.Header().Add(sdk.ResponseWorkflowIDHeader, fmt.Sprintf("%d", wrkflw.ID))
diff --git a/engine/api/workflow_queue.go b/engine/api/workflow_queue.go
index d1f851cbe..c6c40d20c 100644
--- a/engine/api/workflow_queue.go
+++ b/engine/api/workflow_queue.go
@@ -716,7 +716,7 @@ func (api *API) postWorkflowJobStepStatusHandler() service.Handler {
 			log.Warning(ctx, "postWorkflowJobStepStatusHandler> Unable to load workflow run for event: %v", err)
 			return nil
 		}
-		nodeRun.Translate(r.Header.Get("Accept-Language"))
+		nodeRun.Translate()
 		eventsNotifs := notification.GetUserWorkflowEvents(ctx, api.mustDB(), api.Cache, wr.Workflow.ProjectID, wr.Workflow.ProjectKey, work.Name, wr.Workflow.Notifications, nil, nodeRun)
 		event.PublishWorkflowNodeRun(context.Background(), nodeRun, wr.Workflow, eventsNotifs)
 		return nil
diff --git a/engine/api/workflow_run.go b/engine/api/workflow_run.go
index 13dbe5d36..9f81a4d61 100644
--- a/engine/api/workflow_run.go
+++ b/engine/api/workflow_run.go
@@ -47,7 +47,7 @@ func (api *API) getWorkflowsRunsAndNodesIDshandler() service.Handler {
 			return err
 		}
 
-		ids, err := workflow.LoadNodeRunIDs(api.mustDB(), p.WorkflowNames.IDs())
+		ids, err := workflow.LoadNodeRunIDs(api.mustDB(), p.WorkflowNames.IDs(), []string{sdk.StatusFail, sdk.StatusStopped, sdk.StatusSuccess})
 		if err != nil {
 			return err
 		}
@@ -277,7 +277,7 @@ func (api *API) getLatestWorkflowRunHandler() service.Handler {
 		if err != nil {
 			return sdk.WrapError(err, "Unable to load last workflow run")
 		}
-		run.Translate(r.Header.Get("Accept-Language"))
+		run.Translate()
 		return service.WriteJSON(w, run, http.StatusOK)
 	}
 }
@@ -304,7 +304,6 @@ func (api *API) getWorkflowRunHandler() service.Handler {
 				WithArtifacts:           true,
 				WithLightTests:          true,
 				DisableDetailledNodeRun: !isService && withDetailledNodeRun != "true",
-				Language:                r.Header.Get("Accept-Language"),
 			},
 		)
 		if err != nil {
@@ -325,7 +324,7 @@ func (api *API) getWorkflowRunHandler() service.Handler {
 			}
 		}
 
-		run.Translate(r.Header.Get("Accept-Language"))
+		run.Translate()
 
 		return service.WriteJSON(w, run, http.StatusOK)
 	}
@@ -806,7 +805,7 @@ func (api *API) getWorkflowNodeRunHandler() service.Handler {
 			return sdk.WrapError(err, "Unable to load last workflow run")
 		}
 
-		run.Translate(r.Header.Get("Accept-Language"))
+		run.Translate()
 		return service.WriteJSON(w, run, http.StatusOK)
 	}
 }
@@ -1275,7 +1274,7 @@ func failInitWorkflowRun(ctx context.Context, db *gorp.DbMap, wfRun *sdk.Workflo
 			wfRun.Status = sdk.StatusNeverBuilt
 		}
 	} else {
-		httpErr := sdk.ExtractHTTPError(err, "")
+		httpErr := sdk.ExtractHTTPError(err)
 		isErrWithStack := sdk.IsErrorWithStack(err)
 		fields := log.Fields{}
 		if isErrWithStack {
@@ -1471,11 +1470,10 @@ func (api *API) getWorkflowNodeRunJobSpawnInfosHandler() service.Handler {
 			return sdk.WrapError(err, "cannot load spawn infos for node run job id %d", runJobID)
 		}
 
-		l := r.Header.Get("Accept-Language")
 		for ki, info := range spawnInfos {
 			if _, ok := sdk.Messages[info.Message.ID]; ok {
 				m := sdk.NewMessage(sdk.Messages[info.Message.ID], info.Message.Args...)
-				spawnInfos[ki].UserMessage = m.String(l)
+				spawnInfos[ki].UserMessage = m.String()
 			}
 		}
 		return service.WriteJSON(w, spawnInfos, http.StatusOK)
diff --git a/engine/api/workflow_run_log.go b/engine/api/workflow_run_log.go
index ada4ca21a..dbab84fbc 100644
--- a/engine/api/workflow_run_log.go
+++ b/engine/api/workflow_run_log.go
@@ -114,12 +114,117 @@ func (api *API) getWorkflowNodeRunJobServiceLinkHandler() service.Handler {
 	}
 }
 
+func (api *API) getWorkflowNodeRunJobStepLinksHandler() service.Handler {
+	return func(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
+		return api.getWorkflowNodeRunJobLogLinksHandler(ctx, w, r, sdk.CDNTypeItemStepLog)
+	}
+}
+
 func (api *API) getWorkflowNodeRunJobStepLinkHandler() service.Handler {
 	return func(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
 		return api.getWorkflowNodeRunJobLogLinkHandler(ctx, w, r, sdk.CDNTypeItemStepLog)
 	}
 }
 
+func (api *API) getWorkflowNodeRunJobLogLinksHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, itemType sdk.CDNItemType) error {
+	vars := mux.Vars(r)
+
+	projectKey := vars["key"]
+	enabled := featureflipping.IsEnabled(ctx, gorpmapping.Mapper, api.mustDB(), "cdn-job-logs", map[string]string{
+		"project_key": projectKey,
+	})
+	if !enabled {
+		return sdk.NewErrorFrom(sdk.ErrNotFound, "cdn is not enable for project %s", projectKey)
+	}
+
+	workflowName := vars["permWorkflowName"]
+	nodeRunID, err := requestVarInt(r, "nodeRunID")
+	if err != nil {
+		return sdk.NewErrorFrom(err, "invalid node run id")
+	}
+	runJobID, err := requestVarInt(r, "runJobID")
+	if err != nil {
+		return sdk.NewErrorFrom(err, "invalid node job id")
+	}
+
+	httpURL, err := services.GetCDNPublicHTTPAdress(ctx, api.mustDB())
+	if err != nil {
+		return err
+	}
+
+	nodeRun, err := workflow.LoadNodeRun(api.mustDB(), projectKey, workflowName, nodeRunID, workflow.LoadRunOptions{DisableDetailledNodeRun: true})
+	if err != nil {
+		return sdk.WrapError(err, "cannot find nodeRun %d for workflow %s in project %s", nodeRunID, workflowName, projectKey)
+	}
+	var runJob *sdk.WorkflowNodeJobRun
+	for _, s := range nodeRun.Stages {
+		for _, rj := range s.RunJobs {
+			if rj.ID == runJobID {
+				runJob = &rj
+				break
+			}
+		}
+		if runJob != nil {
+			break
+		}
+	}
+	if runJob == nil {
+		return sdk.NewErrorFrom(sdk.ErrNotFound, "cannot find run job for id %d", runJobID)
+	}
+
+	refs := make([]sdk.CDNLogAPIRef, 0)
+	apiRef := sdk.CDNLogAPIRef{
+		ProjectKey:     projectKey,
+		WorkflowName:   workflowName,
+		WorkflowID:     nodeRun.WorkflowID,
+		RunID:          nodeRun.WorkflowRunID,
+		NodeRunName:    nodeRun.WorkflowNodeName,
+		NodeRunID:      nodeRun.ID,
+		NodeRunJobName: runJob.Job.Action.Name,
+		NodeRunJobID:   runJob.ID,
+	}
+
+	if itemType == sdk.CDNTypeItemServiceLog {
+		for _, r := range runJob.Job.Action.Requirements {
+			ref := apiRef
+			ref.RequirementServiceID = r.ID
+			ref.RequirementServiceName = r.Name
+			refs = append(refs, ref)
+		}
+	} else {
+		for _, s := range runJob.Job.StepStatus {
+			ref := apiRef
+			ref.StepName = runJob.Job.Action.Actions[int64(s.StepOrder)].Name
+			if runJob.Job.Action.Actions[int64(s.StepOrder)].StepName != "" {
+				ref.StepName = runJob.Job.Action.Actions[int64(s.StepOrder)].StepName
+			}
+			ref.StepOrder = int64(s.StepOrder)
+			refs = append(refs, ref)
+		}
+	}
+
+	datas := make([]sdk.CDNLogLinkData, 0, len(refs))
+
+	for _, r := range refs {
+		apiRefHashU, err := hashstructure.Hash(r, nil)
+		if err != nil {
+			return sdk.WithStack(err)
+		}
+		apiRefHash := strconv.FormatUint(apiRefHashU, 10)
+		datas = append(datas, sdk.CDNLogLinkData{
+			APIRef:        apiRefHash,
+			StepOrder:     r.StepOrder,
+			RequirementID: r.RequirementServiceID,
+		})
+	}
+
+	return service.WriteJSON(w, sdk.CDNLogLinks{
+		CDNURL:   httpURL,
+		ItemType: itemType,
+		Data:     datas,
+	}, http.StatusOK)
+}
+
 func (api *API) getWorkflowNodeRunJobLogLinkHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, itemType sdk.CDNItemType) error {
 	vars := mux.Vars(r)
 
diff --git a/engine/cdn/cdn_item.go b/engine/cdn/cdn_item.go
index 2705a4943..5d5ece4b0 100644
--- a/engine/cdn/cdn_item.go
+++ b/engine/cdn/cdn_item.go
@@ -124,6 +124,37 @@ type getItemLogOptions struct {
 	cacheSource string
 }
 
+func (s *Service) getItemLogLinesCount(ctx context.Context, t sdk.CDNItemType, apiRefHash string) (int64, error) {
+	it, err := item.LoadByAPIRefHashAndType(ctx, s.Mapper, s.mustDBWithCtx(ctx), apiRefHash, t)
+	if err != nil {
+		return 0, err
+	}
+
+	itemUnit, err := storage.LoadItemUnitByUnit(ctx, s.Mapper, s.mustDBWithCtx(ctx), s.Units.Buffer.ID(), it.ID)
+	if err != nil && !sdk.ErrorIs(err, sdk.ErrNotFound) {
+		return 0, err
+	}
+
+	// If item is in Buffer, get from it
+	if itemUnit != nil {
+		log.Debug("getItemLogLines> Getting logs from buffer")
+		lines, err := s.Units.Buffer.Card(*itemUnit)
+		return int64(lines), err
+	}
+
+	// Get from cache
+	if ok, _ := s.LogCache.Exist(it.ID); !ok {
+		log.Debug("getItemLogLines> Getting logs from storage and push to cache")
+		// Retrieve item and push it into the cache
+		if err := s.pushItemLogIntoCache(ctx, *it, ""); err != nil {
+			return 0, err
+		}
+	}
+
+	linesCount, err := s.LogCache.Card(it.ID)
+	return int64(linesCount), err
+}
+
 func (s *Service) getItemLogValue(ctx context.Context, t sdk.CDNItemType, apiRefHash string, opts getItemLogOptions) (*sdk.CDNItem, int64, io.ReadCloser, string, error) {
 	it, err := item.LoadByAPIRefHashAndType(ctx, s.Mapper, s.mustDBWithCtx(ctx), apiRefHash, t)
 	if err != nil {
diff --git a/engine/cdn/cdn_log_engine.go b/engine/cdn/cdn_log_engine.go
index 0bdbd7372..52f159a43 100644
--- a/engine/cdn/cdn_log_engine.go
+++ b/engine/cdn/cdn_log_engine.go
@@ -46,7 +46,7 @@ func (s *Service) waitingJobs(ctx context.Context) {
 				keyParts := strings.Split(k, ":")
 				queueIdentifier := keyParts[len(keyParts)-1]
 
-				jobQueueKey, err := s.canDequeue(queueIdentifier)
+				jobQueueKey, err := s.canDequeue(ctx, queueIdentifier)
 				if err != nil {
 					err = sdk.WrapError(err, "unable to check canDequeue %s", jobQueueKey)
 					log.ErrorWithFields(ctx, log.Fields{"stack_trace": fmt.Sprintf("%+v", err)}, "%s", err)
@@ -69,13 +69,13 @@ func (s *Service) waitingJobs(ctx context.Context) {
 
 // Run dequeue of a job log
 func (s *Service) dequeueMessages(ctx context.Context, jobLogsQueueKey string, queueIdentifier string) error {
-	log.Info(ctx, "dequeueJobMessages: Dequeue %s", jobLogsQueueKey)
+	log.Info(ctx, "dequeueJobMessages: %s BEGIN dequeue", jobLogsQueueKey)
 	var t0 = time.Now()
 	var t1 = time.Now()
 	var nbMessages int
 	defer func() {
 		delta := t1.Sub(t0)
-		log.Info(ctx, "dequeueJobMessages: processLogs[%s] - %d messages received in %v", jobLogsQueueKey, nbMessages, delta)
+		log.Info(ctx, "dequeueJobMessages: %s END dequeue - %d messages received in %v", jobLogsQueueKey, nbMessages, delta)
 	}()
 
 	defer func() {
@@ -150,9 +150,9 @@ func (s *Service) dequeueMessages(ctx context.Context, jobLogsQueueKey string, q
 }
 
 // Return queue name if jobID need to be dequeue or empty
-func (s *Service) canDequeue(jobID string) (string, error) {
+func (s *Service) canDequeue(ctx context.Context, jobID string) (string, error) {
 	jobQueueKey := cache.Key(keyJobLogQueue, jobID)
-	heatbeatKey := cache.Key(keyJobHearbeat, jobID)
+	heartbeatKey := cache.Key(keyJobHearbeat, jobID)
 
 	// Take a lock
 	lockKey := cache.Key(keyJobLock, jobID)
@@ -167,7 +167,7 @@ func (s *Service) canDequeue(jobID string) (string, error) {
 		return "", nil
 	}
 
-	exist, err := s.Cache.Exist(heatbeatKey)
+	exist, err := s.Cache.Exist(heartbeatKey)
 	if err != nil {
 		return "", err
 	}
@@ -177,7 +177,7 @@ func (s *Service) canDequeue(jobID string) (string, error) {
 	}
 
 	//hearbeat
-	heartbeatKey := cache.Key(keyJobHearbeat, jobID)
+	log.Info(ctx, "heartbeat: take job %s", jobQueueKey)
 	if err := s.Cache.SetWithTTL(heartbeatKey, true, 30); err != nil {
 		return "", err
 	}
diff --git a/engine/cdn/cdn_router.go b/engine/cdn/cdn_router.go
index 355f3a34f..848ed96c1 100644
--- a/engine/cdn/cdn_router.go
+++ b/engine/cdn/cdn_router.go
@@ -27,6 +27,7 @@ func (s *Service) initRouter(ctx context.Context) {
 	r.Handle("/bulk/item/delete", nil, r.POST(s.bulkDeleteItemsHandler))
 
 	r.Handle("/item/stream", nil, r.GET(s.getItemLogsStreamHandler, service.OverrideAuth(s.validJWTMiddleware)))
+	r.Handle("/item/{type}/lines", nil, r.GET(s.getItemsAllLogsLinesHandler, service.OverrideAuth(s.validJWTMiddleware)))
 	r.Handle("/item/{type}/{apiRef}", nil, r.GET(s.getItemHandler, service.OverrideAuth(s.itemAccessMiddleware)), r.DELETE(s.deleteItemHandler))
 	r.Handle("/item/{type}/{apiRef}/checksync", nil, r.GET(s.getItemCheckSyncHandler, service.OverrideAuth(s.itemAccessMiddleware)))
 	r.Handle("/item/{type}/{apiRef}/download", nil, r.GET(s.getItemDownloadHandler, service.OverrideAuth(s.itemAccessMiddleware)))
diff --git a/engine/cdn/item_logs_handler.go b/engine/cdn/item_logs_handler.go
index 68f894aea..3215eaf1a 100644
--- a/engine/cdn/item_logs_handler.go
+++ b/engine/cdn/item_logs_handler.go
@@ -155,6 +155,31 @@ func (s *Service) sendLogsToWSClient(ctx context.Context, wsClient websocket.Cli
 	return nil
 }
 
+func (s *Service) getItemsAllLogsLinesHandler() service.Handler {
+	return func(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
+		vars := mux.Vars(r)
+		itemType := sdk.CDNItemType(vars["type"])
+		if !itemType.IsLog() {
+			return sdk.NewErrorFrom(sdk.ErrWrongRequest, "invalid item log type")
+		}
+		refsHash := r.URL.Query()["apiRefHash"]
+
+		resp := make([]sdk.CDNLogsLines, 0)
+
+		for _, hash := range refsHash {
+			linesCount, err := s.getItemLogLinesCount(ctx, itemType, hash)
+			if err != nil && !sdk.ErrorIs(err, sdk.ErrNotFound) {
+				return err
+			}
+			if err != nil && sdk.ErrorIs(err, sdk.ErrNotFound) {
+				break
+			}
+			resp = append(resp, sdk.CDNLogsLines{APIRef: hash, LinesCount: linesCount})
+		}
+		return service.WriteJSON(w, resp, http.StatusOK)
+	}
+}
+
 func (s *Service) getItemLogsLinesHandler() service.Handler {
 	return func(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
 		vars := mux.Vars(r)
diff --git a/engine/cdn/redis/reader.go b/engine/cdn/redis/reader.go
index 23a0d21d7..f3d728a5a 100644
--- a/engine/cdn/redis/reader.go
+++ b/engine/cdn/redis/reader.go
@@ -163,6 +163,10 @@ func (r *Reader) loadMoreLines() error {
 }
 
 func (r *Reader) Read(p []byte) (n int, err error) {
+	if r.readEOF {
+		return 0, io.EOF
+	}
+
 	lengthToRead := len(p)
 
 	// If we don't have enough bytes in current buffer we will load some line from Redis
diff --git a/engine/cdn/redis/writer.go b/engine/cdn/redis/writer.go
index f502f0756..cda10e63d 100644
--- a/engine/cdn/redis/writer.go
+++ b/engine/cdn/redis/writer.go
@@ -2,6 +2,7 @@ package redis
 
 import (
 	"context"
+	"fmt"
 	"io"
 	"strconv"
 	"strings"
@@ -17,6 +18,7 @@ type Writer struct {
 	PrefixKey     string
 	currentScore  uint
 	currentBuffer []byte
+	closed        bool
 }
 
 // Add new item in cache + update last usage
@@ -30,6 +32,10 @@ func (w *Writer) add(score uint, value string) error {
 }
 
 func (w *Writer) Write(p []byte) (int, error) {
+	if w.closed {
+		return 0, fmt.Errorf("writer is closed")
+	}
+
 	// Append cnew buffer to current one
 	w.currentBuffer = append(w.currentBuffer, p...)
 
@@ -56,6 +62,7 @@ func (w *Writer) Write(p []byte) (int, error) {
 
 // Close will write the end of the buffer to store in case the last line is not ended by \n
 func (w *Writer) Close() error {
+	w.closed = true
 	if len(w.currentBuffer) > 0 {
 		if err := w.add(w.currentScore, string(w.currentBuffer)+"\n"); err != nil {
 			return err
diff --git a/engine/hatchery/swarm/swarm_util_create.go b/engine/hatchery/swarm/swarm_util_create.go
index e45662ee5..bb8a48832 100644
--- a/engine/hatchery/swarm/swarm_util_create.go
+++ b/engine/hatchery/swarm/swarm_util_create.go
@@ -136,7 +136,7 @@ checkImage:
 			next()
 			hatchery.SendSpawnInfo(ctx, h, spawnArgs.JobID, sdk.SpawnMsg{
 				ID:   sdk.MsgSpawnInfoHatcheryEndDockerPullErr.ID,
-				Args: []interface{}{h.Name(), cArgs.image, sdk.ExtractHTTPError(err, "").Error()},
+				Args: []interface{}{h.Name(), cArgs.image, sdk.ExtractHTTPError(err).Error()},
 			})
 			return sdk.WrapError(err, "Unable to pull image %s on %s", cArgs.image, dockerClient.name)
 		}
diff --git a/engine/service/http.go b/engine/service/http.go
index d65136426..6c0f7171e 100644
--- a/engine/service/http.go
+++ b/engine/service/http.go
@@ -110,8 +110,7 @@ type ErrorResponse struct {
 
 // WriteError is a helper function to return error in a language the called understand
 func WriteError(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
-	al := r.Header.Get("Accept-Language")
-	httpErr := sdk.ExtractHTTPError(err, al)
+	httpErr := sdk.ExtractHTTPError(err)
 	isErrWithStack := sdk.IsErrorWithStack(err)
 
 	fields := log.Fields{}
diff --git a/engine/worker/cmd_cds_version_set.go b/engine/worker/cmd_cds_version_set.go
index 10ba25a18..cfc6982a3 100644
--- a/engine/worker/cmd_cds_version_set.go
+++ b/engine/worker/cmd_cds_version_set.go
@@ -85,7 +85,7 @@ func cdsVersionSetCmd() func(cmd *cobra.Command, args []string) {
 
 		if err := f(); err != nil {
 			if sdk.IsErrorWithStack(err) {
-				httpErr := sdk.ExtractHTTPError(err, "")
+				httpErr := sdk.ExtractHTTPError(err)
 				sdk.Exit("%v", httpErr.Error())
 			} else {
 				sdk.Exit("%v", err)
diff --git a/engine/worker/cmd_run.go b/engine/worker/cmd_run.go
index d2670e157..968df776a 100644
--- a/engine/worker/cmd_run.go
+++ b/engine/worker/cmd_run.go
@@ -68,7 +68,7 @@ func runCmd() func(cmd *cobra.Command, args []string) {
 			fields := log.Fields{}
 			if isErrWithStack {
 				fields["stack_trace"] = fmt.Sprintf("%+v", err)
-				fields["request_id"] = sdk.ExtractHTTPError(err, "").RequestID
+				fields["request_id"] = sdk.ExtractHTTPError(err).RequestID
 			}
 			log.ErrorWithFields(ctx, fields, "%v", err)
 			time.Sleep(2 * time.Second)
diff --git a/engine/worker/internal/http_server.go b/engine/worker/internal/http_server.go
index bd10e7b5f..35f5057fe 100644
--- a/engine/worker/internal/http_server.go
+++ b/engine/worker/internal/http_server.go
@@ -99,7 +99,6 @@ func writeJSON(w http.ResponseWriter, data interface{}, status int) {
 }
 
 func writeError(w http.ResponseWriter, r *http.Request, err error) {
-	al := r.Header.Get("Accept-Language")
-	sdkErr := sdk.ExtractHTTPError(err, al)
+	sdkErr := sdk.ExtractHTTPError(err)
 	writeJSON(w, sdkErr, sdkErr.Status)
 }
diff --git a/sdk/cdn.go b/sdk/cdn.go
index dbffd807b..5defbd5ad 100644
--- a/sdk/cdn.go
+++ b/sdk/cdn.go
@@ -43,6 +43,23 @@ type CDNUnit struct {
 	Config  ServiceConfig `json:"config" db:"config"`
 }
 
+type CDNLogsLines struct {
+	APIRef     string `json:"api_ref"`
+	LinesCount int64  `json:"lines_count"`
+}
+
+type CDNLogLinks struct {
+	CDNURL   string           `json:"cdn_url,omitempty"`
+	ItemType CDNItemType      `json:"item_type"`
+	Data     []CDNLogLinkData `json:"datas"`
+}
+
+type CDNLogLinkData struct {
+	APIRef        string `json:"api_ref"`
+	StepOrder     int64  `json:"step_order"`
+	RequirementID int64  `json:"requirement_id,omitempty"`
+}
+
 type CDNLogLink struct {
 	CDNURL   string      `json:"cdn_url,omitempty"`
 	ItemType CDNItemType `json:"item_type"`
diff --git a/sdk/error.go b/sdk/error.go
index c81d06179..7ff54162e 100644
--- a/sdk/error.go
+++ b/sdk/error.go
@@ -10,7 +10,6 @@ import (
 	"strings"
 
 	"github.com/pkg/errors"
-	"golang.org/x/text/language"
 )
 
 // Existing CDS errors
@@ -386,189 +385,6 @@ var errorsAmericanEnglish = map[int]string{
 	ErrWebsocketUpgrade.ID:                              "Websocket upgrade required",
 }
 
-var errorsFrench = map[int]string{
-	ErrUnknownError.ID:                                  "erreur interne",
-	ErrActionAlreadyUpdated.ID:                          "le status de l'action a déjà été mis à jour",
-	ErrNoAction.ID:                                      "l'action n'existe pas",
-	ErrActionLoop.ID:                                    "la définition de l'action contient une boucle récursive",
-	ErrInvalidID.ID:                                     "l'ID doit être un nombre entier",
-	ErrInvalidProject.ID:                                "projet manquant",
-	ErrInvalidProjectKey.ID:                             "la clef de project doit uniquement contenir des lettres majuscules et des chiffres",
-	ErrProjectHasPipeline.ID:                            "le project contient un pipeline",
-	ErrProjectHasApplication.ID:                         "le project contient une application",
-	ErrUnauthorized.ID:                                  "authentification invalide",
-	ErrForbidden.ID:                                     "accès refusé",
-	ErrPipelineNotFound.ID:                              "le pipeline n'existe pas",
-	ErrPipelineNotAttached.ID:                           "le pipeline n'est pas lié à l'application",
-	ErrNoEnvironmentProvided.ID:                         "les pipelines de déploiement et de tests requièrent un environnement",
-	ErrEnvironmentProvided.ID:                           "une pipeline de build ne nécessite pas d'environnement",
-	ErrUnknownEnv.ID:                                    "environnement inconnu",
-	ErrEnvironmentExist.ID:                              "l'environnement existe",
-	ErrNoPipelineBuild.ID:                               "ce build n'existe pas",
-	ErrInvalidUsername.ID:                               "nom d'utilisateur invalide",
-	ErrUsernamePresent.ID:                               "le nom d'utilisateur est déjà présent",
-	ErrInvalidEmail.ID:                                  "addresse email invalide",
-	ErrGroupPresent.ID:                                  "le groupe est déjà présent",
-	ErrInvalidName.ID:                                   "le nom est invalide",
-	ErrInvalidUser.ID:                                   "mauvaise combinaison compte/mot de passe utilisateur",
-	ErrBuildArchived.ID:                                 "impossible de relancer ce build car il a été archivé",
-	ErrNoEnvironment.ID:                                 "l'environement n'existe pas",
-	ErrModelNameExist.ID:                                "le nom du modèle de worker est déjà utilisé",
-	ErrNoProject.ID:                                     "le projet n'existe pas",
-	ErrVariableExists.ID:                                "la variable existe déjà",
-	ErrInvalidGroupPattern.ID:                           "nom de groupe invalide '^[a-zA-Z0-9.-_-]{1,}$'",
-	ErrGroupExists.ID:                                   "le groupe existe déjà",
-	ErrNotEnoughAdmin.ID:                                "pas assez d'admin restant",
-	ErrInvalidProjectName.ID:                            "nom de project vide non autorisé",
-	ErrInvalidApplicationPattern.ID:                     "nom de l'application invalide '^[a-zA-Z0-9.-_-]{1,}$'",
-	ErrInvalidWorkerModelNamePattern.ID:                 "nom du worker model invalide '^[a-zA-Z0-9.-_-]{1,}$'",
-	ErrInvalidPipelinePattern.ID:                        "nom du pipeline invalide '^[a-zA-Z0-9.-_-]{1,}$'",
-	ErrNotFound.ID:                                      "la ressource n'existe pas",
-	ErrNoHook.ID:                                        "le hook n'existe pas",
-	ErrNoAttachedPipeline.ID:                            "le pipeline n'est pas lié à l'application",
-	ErrNoReposManager.ID:                                "le gestionnaire de dépôt n'existe pas",
-	ErrNoReposManagerAuth.ID:                            "connexion de CDS au gestionnaire de dépôt refusée, merci de contacter l'administrateur",
-	ErrNoReposManagerClientAuth.ID:                      "connexion au gestionnaire de dépôts refusée, merci de détacher et ré-attacher le repository manager sur votre projet CDS",
-	ErrRepoNotFound.ID:                                  "le dépôt n'existe pas",
-	ErrSecretStoreUnreachable.ID:                        "impossible de contacter vault",
-	ErrSecretKeyFetchFailed.ID:                          "erreur pendnat la récuperation de la clef de chiffrement",
-	ErrCommitsFetchFailed.ID:                            "impossible de retrouver les changements",
-	ErrInvalidGoPath.ID:                                 "le gopath n'est pas valide",
-	ErrInvalidSecretFormat.ID:                           "impossibe de dechiffrer le secret, format invalide",
-	ErrSessionNotFound.ID:                               "session invalide",
-	ErrNoPreviousSuccess.ID:                             "il n'y a aucune précédente version en succès pour ce pipeline",
-	ErrNoPermExecution.ID:                               "vous n'avez pas les droits d'éxécution",
-	ErrInvalidSecretValue.ID:                            "valeur du secret non specifiée",
-	ErrPipelineHasApplication.ID:                        "le pipeline est utilisé par une application",
-	ErrNoDirectSecretUse.ID:                             "l'utilisation du type de paramêtre 'password' est impossible",
-	ErrNoBranch.ID:                                      "la branche est introuvable dans le dépôt",
-	ErrLDAPConn.ID:                                      "erreur de connexion au serveur LDAP",
-	ErrServiceUnavailable.ID:                            "service temporairement indisponible ou en maintenance",
-	ErrParseUserNotification.ID:                         "notification non reconnue",
-	ErrNotSupportedUserNotification.ID:                  "notification non supportée",
-	ErrGroupNeedAdmin.ID:                                "il faut au moins 1 administrateur",
-	ErrGroupNeedWrite.ID:                                "il faut au moins 1 groupe avec les droits d'écriture",
-	ErrNoVariable.ID:                                    "la variable n'existe pas",
-	ErrPluginInvalid.ID:                                 "plugin non valide",
-	ErrApplicationExist.ID:                              "une application du même nom existe déjà",
-	ErrBranchNameNotProvided.ID:                         "le paramètre git.branch ou git.tag est obligatoire",
-	ErrInfiniteTriggerLoop.ID:                           "création d'une boucle de trigger infinie interdite",
-	ErrInvalidResetUser.ID:                              "mauvaise combinaison compte/mail utilisateur",
-	ErrUserConflict.ID:                                  "cet utilisateur existe deja",
-	ErrWrongRequest.ID:                                  "la requête est incorrecte",
-	ErrAlreadyExist.ID:                                  "conflit",
-	ErrInvalidType.ID:                                   "type non valide",
-	ErrParentApplicationAndPipelineMandatory.ID:         "application et pipeline parents obligatoires",
-	ErrNoParentBuildFound.ID:                            "aucun build parent n'a pu être trouvé",
-	ErrParameterExists.ID:                               "le paramètre existe déjà",
-	ErrNoHatchery.ID:                                    "La hatchery n'existe pas",
-	ErrInvalidWorkerStatus.ID:                           "Le status du worker est incorrect",
-	ErrInvalidToken.ID:                                  "Token non valide",
-	ErrAppBuildingPipelines.ID:                          "Impossible de supprimer l'application, il y a pipelines en cours",
-	ErrInvalidTimezone.ID:                               "Fuseau horaire invalide",
-	ErrEnvironmentCannotBeDeleted.ID:                    "L'environement ne peut etre supprimé. Il est encore utilisé.",
-	ErrInvalidPipeline.ID:                               "Pipeline invalide",
-	ErrKeyNotFound.ID:                                   "Clé introuvable",
-	ErrPipelineAlreadyExists.ID:                         "Le pipeline existe déjà",
-	ErrJobAlreadyBooked.ID:                              "Le job est déjà réservé",
-	ErrPipelineBuildNotFound.ID:                         "Le pipeline build n'a pu être trouvé",
-	ErrAlreadyTaken.ID:                                  "Ce job est déjà en cours de traitement par un autre worker",
-	ErrWorkflowNodeNotFound.ID:                          "Noeud de Workflow introuvable",
-	ErrWorkflowInvalidRoot.ID:                           "Racine de Workflow invalide",
-	ErrWorkflowNodeRef.ID:                               "Référence de noeud de workflow invalide",
-	ErrWorkflowInvalid.ID:                               "Workflow invalide",
-	ErrWorkflowNodeJoinNotFound.ID:                      "Jointure introuvable",
-	ErrInvalidJobRequirement.ID:                         "Pré-requis de Job invalide",
-	ErrNotImplemented.ID:                                "La fonctionnalité n'est pas implémentée",
-	ErrParameterNotExists.ID:                            "Ce paramètre n'existe pas",
-	ErrUnknownKeyType.ID:                                "Le type de clé n'est pas connu",
-	ErrInvalidKeyPattern.ID:                             "le nom de la clé doit respecter le pattern suivant; '^[a-zA-Z0-9.-_-]{1,}$'",
-	ErrWebhookConfigDoesNotMatch.ID:                     "la configuration du webhook ne correspond pas",
-	ErrPipelineUsedByWorkflow.ID:                        "Le pipeline est utilisé par un workflow",
-	ErrMethodNotAllowed.ID:                              "La méthode n'est pas autorisée",
-	ErrInvalidNodeNamePattern.ID:                        "Le nom du noeud du workflow doit respecter le pattern suivant; '^[a-zA-Z0-9.-_-]{1,}$'",
-	ErrWorkflowNodeParentNotRun.ID:                      "Il est interdit de lancer un noeuds si ses parents n'ont jamais été lancés",
-	ErrDefaultGroupPermission.ID:                        "Le groupe par défaut ne peut être utilisé qu'en lecture seule",
-	ErrLastGroupWithWriteRole.ID:                        "Le dernier groupe doit avoir les droits d'écriture",
-	ErrInvalidEmailDomain.ID:                            "Domaine invalide",
-	ErrWorkflowNodeRunJobNotFound.ID:                    "Job non trouvé",
-	ErrBuiltinKeyNotFound.ID:                            "Clé de chiffrage introuvable",
-	ErrStepNotFound.ID:                                  "Step introuvable",
-	ErrWorkerModelAlreadyBooked.ID:                      "Le modèle de worker est déjà réservé",
-	ErrConditionsNotOk.ID:                               "Impossible de démarrer ce pipeline car les conditions de lancement ne sont pas respectées",
-	ErrDownloadInvalidOS.ID:                             "OS invalide. L'OS doit être linux, darwin, freebsd ou windows",
-	ErrDownloadInvalidArch.ID:                           "Architecture invalide. L'architecture doit être 386, i386, i686, amd64, x86_64 ou arm (dépendant de l'OS)",
-	ErrDownloadInvalidName.ID:                           "Nom invalide",
-	ErrDownloadDoesNotExist.ID:                          "Le fichier n'existe pas",
-	ErrTokenNotFound.ID:                                 "Le token n'existe pas",
-	ErrWorkflowNotificationNodeRef.ID:                   "Une référence de noeud de workflow est invalide dans vos notifications (si vous souhaitez supprimer un pipeline vérifiez qu'il ne soit plus référencé dans la liste de vos notifications)",
-	ErrInvalidJobRequirementDuplicateModel.ID:           "Pré-requis de job invalides: vous ne pouvez pas séléctionnez plusieurs modèles de worker",
-	ErrInvalidJobRequirementDuplicateHostname.ID:        "Pré-requis de job invalides: vous ne pouvez pas séléctionnez plusieurs hostname",
-	ErrInvalidKeyName.ID:                                "Nom de clé invalide. Les clés d'application doivent être préfixées par 'app-', les clés d'environnement doivent être préfixées par 'env-'",
-	ErrRepoOperationTimeout.ID:                          "L'analyse du dépôt a pris trop de temps",
-	ErrInvalidGitBranch.ID:                              "Valeur git.branch invalide, vous ne pouvez pas avoir de valeur git.branch avec une string vide dans votre payload par défaut",
-	ErrInvalidFavoriteType.ID:                           "Type de favori invalide: doit être 'projet' ou 'workflow'",
-	ErrUnsupportedOSArchPlugin.ID:                       "OS/Architecture non supporté pour ce plugin",
-	ErrNoBroadcast.ID:                                   "Information invalide",
-	ErrBroadcastNotFound.ID:                             "Information non trouvée",
-	ErrInvalidPatternModel.ID:                           "Pattern de modèle de worker invalide: le nom, type et commande principale sont requis",
-	ErrWorkerModelNoAdmin.ID:                            "Accès refusé: vous n'êtes ni un administrateur CDS ni un administrateur du groupe pour lequel vous tentez de créer votre modèle",
-	ErrWorkerModelNoPattern.ID:                          "Accès refusé: vous devez obligatoirement sélectionner un pattern de script de configuration. Si vous souhaitez ajouter un pattern particulier, veuillez contacter un administrateur CDS",
-	ErrJobNotBooked.ID:                                  "Le job est déjà libéré",
-	ErrUserNotFound.ID:                                  "Utilisateur non trouvé",
-	ErrInvalidNumber.ID:                                 "Nombre non valide",
-	ErrKeyAlreadyExist.ID:                               "La clé existe déjà",
-	ErrPipelineNameImport.ID:                            "Le nom du pipeline dans le code ne correspond pas au nom du pipeline que vous voulez éditer",
-	ErrWorkflowNameImport.ID:                            "Le nom du workflow dans le code ne correspond pas au nom du workflow que vous voulez éditer",
-	ErrIconBadFormat.ID:                                 "Mauvais format d'icône, doit être une image",
-	ErrIconBadSize.ID:                                   "Taille de l'icône trop importante. (max 100Ko)",
-	ErrWorkflowConditionBadOperator.ID:                  "Opérateur de condition de lancement incorrect",
-	ErrColorBadFormat.ID:                                "Format de la couleur incorrect. Vous devez utiliser le format hexadécimal (exemple: #FFFF)",
-	ErrInvalidHookConfiguration.ID:                      "Configuration de hook invalide",
-	ErrInvalidData.ID:                                   "Impossible de valider les données",
-	ErrInvalidGroupAdmin.ID:                             "L'utilisateur n'est pas administrateur du groupe",
-	ErrInvalidGroupMember.ID:                            "L'utilisateur n'est pas membre du groupe",
-	ErrWorkflowNotGenerated.ID:                          "Le workflow n'a pas été généré par un template",
-	ErrInvalidNodeDefaultPayload.ID:                     "Le workflow est incorrect. Un payload par défaut ne peut pas être sur un pipeline autre que le premier du workflow",
-	ErrInvalidApplicationRepoStrategy.ID:                "La stratégie de dépôt (vcs) de l'application n'est pas correcte",
-	ErrWorkflowNodeRootUpdate.ID:                        "Impossible de mettre à jour ou supprimer le noeud racine du workflow",
-	ErrWorkflowAlreadyAsCode.ID:                         "Le workflow est déjà as-code ou il y a déjà une pull-request pour le transformer",
-	ErrNoDBMigrationID.ID:                               "Cet id n'existe pas dans la table gorp_migrations",
-	ErrCannotParseTemplate.ID:                           "Impossible de parser le modèle de workflow",
-	ErrGroupNotFoundInProject.ID:                        "Impossible d'ajouter ce groupe dans vos permissions de workflow car ce groupe n'est pas présent dans les permissions de votre projet",
-	ErrGroupNotFoundInWorkflow.ID:                       "Impossible d'ajouter ce groupe dans vos permissions de noeud du workflow car ce groupe n'est pas présent dans les permissions de votre workflow",
-	ErrWorkflowPermInsufficient.ID:                      "Impossible d'ajouter ce groupe dans vos permissions du workflow car ce groupe a des droits inférieurs (< RWX) à celui du workflow",
-	ErrApplicationUsedByWorkflow.ID:                     "L'application est utilisée par un workflow",
-	ErrLocked.ID:                                        "La ressource est verrouillée",
-	ErrInvalidJobRequirementWorkerModelPermission.ID:    "Pré-requis de job invalide: Modèle de worker inutilisable en raison des permissions",
-	ErrInvalidJobRequirementWorkerModelCapabilitites.ID: "Pré-requis de job invalide: Le modèle de worker ne dispose pas de binaires suffisants",
-	ErrMalformattedStep.ID:                              "Étape malformée",
-	ErrVCSUsedByApplication.ID:                          "Le gestionnaire de dépot est encore utilisé par une application",
-	ErrApplicationAsCodeOverride.ID:                     "Vous ne pouvez pas importer l'application depuis ce dépôt",
-	ErrPipelineAsCodeOverride.ID:                        "Vous ne pouvez pas importer le pipeline depuis ce dépôt",
-	ErrEnvironmentAsCodeOverride.ID:                     "Vous ne pouvez pas importer l'environment depuis ce dépôt",
-	ErrWorkflowAsCodeOverride.ID:                        "Vous ne pouvez pas importer le workflow depuis ce dépôt",
-	ErrProjectSecretDataUnknown.ID:                      "Donnée chiffrée non valide",
-	ErrApplicationMandatoryOnWorkflowAsCode.ID:          "Une application liée à un dépôt git est obligatoire à la racine du workflow",
-	ErrInvalidPayloadVariable.ID:                        "Le payload du workflow ne peut pas contenir de clés nommées cds.*",
-	ErrInvalidPassword.ID:                               "Votre valeur de type mot de passe n'est pas correct",
-	ErrRepositoryUsedByHook.ID:                          "Il y a encore un repository webhook sur ce dépôt",
-	ErrResourceNotInProject.ID:                          "La ressource n'est pas lié au projet",
-	ErrEnvironmentNotFound.ID:                           "L'environnement n'existe pas",
-	ErrIntegrationtNotFound.ID:                          "L'intégration n'existe pas",
-	ErrSignupDisabled.ID:                                "La création de compte est désactivée pour ce mode d'authentification.",
-	ErrBadBrokerConfiguration.ID:                        "Impossible de se connecter à votre intégration de type évènement. Veuillez vérifier votre configuration",
-	ErrInvalidJobRequirementNetworkAccess.ID:            "Pré-requis de job invalide: Le pré-requis network doit contenir un ':'. Exemple: golang.org:http, golang.org:443",
-	ErrWorkflowAsCodeResync.ID:                          "Impossible de resynchroniser un workflow en mode as-code",
-	ErrWorkflowNodeNameDuplicate.ID:                     "Vous ne pouvez pas avoir plusieurs fois le même nom de pipeline dans votre workflow",
-	ErrUnsupportedMediaType.ID:                          "Le format de la requête est invalide",
-	ErrNothingToPush.ID:                                 "Aucune modification à pousser",
-	ErrWorkerErrorCommand.ID:                            "Commande du worker en erreur",
-	ErrRepoAnalyzeFailed.ID:                             "L'analyse du repository a echoué",
-	ErrConflictData.ID:                                  "Donnée en conflit",
-	ErrWebsocketUpgrade.ID:                              "Websocket upgrade requis",
-}
-
 // Error type.
 type Error struct {
 	ID         int         `json:"id"`
@@ -610,24 +426,8 @@ func (e Error) printLight() string {
 	return message
 }
 
-func (e Error) Translate(al string) string {
-	acceptedLanguages, _, err := language.ParseAcceptLanguage(al)
-	if err != nil {
-		acceptedLanguages = []language.Tag{language.AmericanEnglish}
-	}
-
-	// try to get error message for accepted language and error ID, else use unknown error message
-	tag, _, _ := matcher.Match(acceptedLanguages...)
-	var msg string
-	var ok bool
-	switch tag {
-	case language.French:
-		msg, ok = errorsFrench[e.ID]
-	case language.AmericanEnglish:
-		msg, ok = errorsAmericanEnglish[e.ID]
-	default:
-		msg, ok = errorsAmericanEnglish[e.ID]
-	}
+func (e Error) Translate() string {
+	msg, ok := errorsAmericanEnglish[e.ID]
 	if !ok {
 		return errorsAmericanEnglish[ErrUnknownError.ID]
 	}
@@ -646,7 +446,7 @@ func NewErrorWithStack(root error, err error) error {
 	case Error:
 		errWithStack.httpError = e
 	default:
-		errWithStack.httpError = ExtractHTTPError(err, "")
+		errWithStack.httpError = ExtractHTTPError(err)
 	}
 
 	return errWithStack
@@ -756,7 +556,7 @@ func NewError(httpError Error, err error) error {
 	if e, ok := err.(*MultiError); ok {
 		var ss []string
 		for i := range *e {
-			ss = append(ss, ExtractHTTPError((*e)[i], "").printLight())
+			ss = append(ss, ExtractHTTPError((*e)[i]).printLight())
 		}
 		httpError.From = strings.Join(ss, ", ")
 	} else {
@@ -816,7 +616,7 @@ func WrapError(err error, format string, args ...interface{}) error {
 	if e, ok := err.(*MultiError); ok {
 		var ss []string
 		for i := range *e {
-			ss = append(ss, ExtractHTTPError((*e)[i], "").printLight())
+			ss = append(ss, ExtractHTTPError((*e)[i]).printLight())
 		}
 		httpError.From = strings.Join(ss, ", ")
 	}
@@ -848,7 +648,7 @@ func WithStack(err error) error {
 	// if it's a Error wrap it in error with stack
 	if e, ok := err.(Error); ok {
 		return errorWithStack{
-			root:      errors.New(e.Translate("")),
+			root:      errors.New(e.Translate()),
 			stack:     callers(),
 			httpError: e,
 		}
@@ -871,7 +671,7 @@ func WithData(err error, data interface{}) error {
 
 // ExtractHTTPError tries to recognize given error and return http error
 // with message in a language matching Accepted-Language.
-func ExtractHTTPError(source error, al string) Error {
+func ExtractHTTPError(source error) Error {
 	var httpError Error
 
 	// try to recognize http error from source
@@ -880,7 +680,7 @@ func ExtractHTTPError(source error, al string) Error {
 		httpError = ErrUnknownError
 		var ss []string
 		for i := range *e {
-			ss = append(ss, ExtractHTTPError((*e)[i], al).printLight())
+			ss = append(ss, ExtractHTTPError((*e)[i]).printLight())
 		}
 		httpError.Message = strings.Join(ss, ", ")
 	case errorWithStack:
@@ -899,7 +699,7 @@ func ExtractHTTPError(source error, al string) Error {
 	// if error's message is not empty do not override (custom message)
 	// else set message for given accepted languages.
 	if httpError.Message == "" {
-		httpError.Message = httpError.Translate(al)
+		httpError.Message = httpError.Translate()
 	}
 
 	return httpError
diff --git a/sdk/hatchery/starter.go b/sdk/hatchery/starter.go
index 7c3017584..70dfc8935 100644
--- a/sdk/hatchery/starter.go
+++ b/sdk/hatchery/starter.go
@@ -206,7 +206,7 @@ func spawnWorkerForJob(ctx context.Context, h Interface, j workerStarterRequest)
 		ctxSendSpawnInfo, next = telemetry.Span(ctxJob, "hatchery.QueueJobSendSpawnInfo", telemetry.Tag("status", "errSpawn"), telemetry.Tag("msg", sdk.MsgSpawnInfoHatcheryErrorSpawn.ID))
 		SendSpawnInfo(ctxSendSpawnInfo, h, j.id, sdk.SpawnMsg{
 			ID:   sdk.MsgSpawnInfoHatcheryErrorSpawn.ID,
-			Args: []interface{}{h.Service().Name, modelName, sdk.Round(time.Since(start), time.Second).String(), sdk.ExtractHTTPError(errSpawn, "").Error()},
+			Args: []interface{}{h.Service().Name, modelName, sdk.Round(time.Since(start), time.Second).String(), sdk.ExtractHTTPError(errSpawn).Error()},
 		})
 		log.Error(ctx, "hatchery %s cannot spawn worker %s for job %d: %v", h.Service().Name, modelName, j.id, errSpawn)
 		next()
diff --git a/sdk/messages.go b/sdk/messages.go
index b9ef3fd3d..dabe230e3 100644
--- a/sdk/messages.go
+++ b/sdk/messages.go
@@ -206,19 +206,8 @@ var (
 )
 
 //String returns formated string for the specified language
-func (m *Message) String(al string) string {
-	acceptedLanguages, _, err := language.ParseAcceptLanguage(al)
-	if err != nil {
-		return fmt.Sprintf(m.Format[EN], m.Args...)
-	}
-
-	t, _, _ := matcher.Match(acceptedLanguages...)
-	switch t {
-	case language.French, language.AmericanEnglish:
-		return fmt.Sprintf(m.Format[lang(t)], m.Args...)
-	default:
-		return fmt.Sprintf(m.Format[EN], m.Args...)
-	}
+func (m *Message) String() string {
+	return fmt.Sprintf(m.Format[EN], m.Args...)
 }
 
 // MessagesToError returns a translated slices of messages as an error
@@ -228,14 +217,14 @@ func MessagesToError(messages []Message) error {
 		if i != 0 {
 			s += "; "
 		}
-		s += err.String(language.AmericanEnglish.String())
+		s += err.String()
 	}
 	return errors.New(s)
 }
 
 // ErrorToMessage returns message from an error if possible
 func ErrorToMessage(err error) (Message, bool) {
-	cdsError := ExtractHTTPError(err, "EN")
+	cdsError := ExtractHTTPError(err)
 	switch cdsError.ID {
 	case ErrPipelineNotFound.ID:
 		return NewMessage(MsgWorkflowErrorBadPipelineName, cdsError.Data), true
diff --git a/sdk/repositories_operation.go b/sdk/repositories_operation.go
index 578a87801..3ccc6b8ef 100644
--- a/sdk/repositories_operation.go
+++ b/sdk/repositories_operation.go
@@ -40,7 +40,7 @@ func ToOperationError(err error) *OperationError {
 	if err == nil {
 		return nil
 	}
-	sdkError := ExtractHTTPError(err, "")
+	sdkError := ExtractHTTPError(err)
 	return &OperationError{
 		ID:         sdkError.ID,
 		Status:     sdkError.Status,
diff --git a/sdk/workflow_run.go b/sdk/workflow_run.go
index 13948b73c..c703791ff 100644
--- a/sdk/workflow_run.go
+++ b/sdk/workflow_run.go
@@ -198,11 +198,11 @@ func (w WorkflowRunVersion) IsValid() error {
 }
 
 // Translate translates messages in WorkflowNodeRun
-func (r *WorkflowRun) Translate(lang string) {
+func (r *WorkflowRun) Translate() {
 	for ki, info := range r.Infos {
 		if _, ok := Messages[info.Message.ID]; ok {
 			m := NewMessage(Messages[info.Message.ID], info.Message.Args...)
-			r.Infos[ki].UserMessage = m.String(lang)
+			r.Infos[ki].UserMessage = m.String()
 		}
 	}
 }
@@ -428,10 +428,10 @@ type WorkflowNodeTriggerRun struct {
 }
 
 // Translate translates messages in WorkflowNodeRun
-func (nr *WorkflowNodeRun) Translate(lang string) {
+func (nr *WorkflowNodeRun) Translate() {
 	for ks := range nr.Stages {
 		for kj := range nr.Stages[ks].RunJobs {
-			nr.Stages[ks].RunJobs[kj].Translate(lang)
+			nr.Stages[ks].RunJobs[kj].Translate()
 		}
 	}
 }
@@ -550,11 +550,11 @@ type WorkflowNodeJobRunBooked struct {
 }
 
 // Translate translates messages in WorkflowNodeJobRun
-func (wnjr *WorkflowNodeJobRun) Translate(lang string) {
+func (wnjr *WorkflowNodeJobRun) Translate() {
 	for ki, info := range wnjr.SpawnInfos {
 		if _, ok := Messages[info.Message.ID]; ok {
 			m := NewMessage(Messages[info.Message.ID], info.Message.Args...)
-			wnjr.SpawnInfos[ki].UserMessage = m.String(lang)
+			wnjr.SpawnInfos[ki].UserMessage = m.String()
 		}
 	}
 }
diff --git a/ui/src/app/app.component.spec.ts b/ui/src/app/app.component.spec.ts
index a2049bd8f..39339cf76 100644
--- a/ui/src/app/app.component.spec.ts
+++ b/ui/src/app/app.component.spec.ts
@@ -26,7 +26,6 @@ import { ApplicationService } from './service/application/application.service';
 import { AuthenticationService } from './service/authentication/authentication.service';
 import { BroadcastService } from './service/broadcast/broadcast.service';
 import { BroadcastStore } from './service/broadcast/broadcast.store';
-import { LanguageStore } from './service/language/language.store';
 import { NavbarService } from './service/navbar/navbar.service';
 import { NotificationService } from './service/notification/notification.service';
 import { PipelineService } from './service/pipeline/pipeline.service';
@@ -62,7 +61,6 @@ describe('App: CDS', () => {
                 ProjectStore,
                 EnvironmentService,
                 AuthenticationService,
-                LanguageStore,
                 ThemeStore,
                 NotificationService,
                 AppService,
diff --git a/ui/src/app/app.component.ts b/ui/src/app/app.component.ts
index 1021836e4..7b5c873aa 100644
--- a/ui/src/app/app.component.ts
+++ b/ui/src/app/app.component.ts
@@ -16,7 +16,6 @@ import { Subscription } from 'rxjs/Subscription';
 import * as format from 'string-format-obj';
 import { AppService } from './app.service';
 import { AuthentifiedUser } from './model/user.model';
-import { LanguageStore } from './service/language/language.store';
 import { NotificationService } from './service/notification/notification.service';
 import { HelpService, MonitoringService } from './service/services.module';
 import { ThemeStore } from './service/theme/theme.store';
@@ -58,7 +57,6 @@ export class AppComponent implements OnInit, OnDestroy {
 
     constructor(
         _translate: TranslateService,
-        private _language: LanguageStore,
         private _theme: ThemeStore,
         private _activatedRoute: ActivatedRoute,
         private _titleService: Title,
diff --git a/ui/src/app/model/pipeline.model.ts b/ui/src/app/model/pipeline.model.ts
index c4711510e..540dd2da7 100644
--- a/ui/src/app/model/pipeline.model.ts
+++ b/ui/src/app/model/pipeline.model.ts
@@ -199,6 +199,23 @@ export class CDNLogLink {
     api_ref: string;
 }
 
+export class CDNLogsLines {
+    api_ref: string
+    lines_count: number
+}
+
+export class CDNLogLinks {
+    item_type: string;
+    datas: Array<CDNLogLinkData>;
+}
+
+
+export class CDNLogLinkData {
+    api_ref: string
+    step_order: number
+    requirement_id: number
+}
+
 export class CDNLinesResponse {
     totalCount: number;
     lines: Array<CDNLine>;
diff --git a/ui/src/app/service/language/language.interceptor.ts b/ui/src/app/service/language/language.interceptor.ts
deleted file mode 100644
index 8da94f444..000000000
--- a/ui/src/app/service/language/language.interceptor.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
-import { Injectable } from '@angular/core';
-import { Observable } from 'rxjs';
-import { LanguageStore } from './../language/language.store';
-
-@Injectable()
-export class LanguageInterceptor implements HttpInterceptor {
-  languageHeader: string;
-
-  constructor(
-    private _language: LanguageStore
-  ) {
-    this.languageHeader = 'en-US';
-
-    this._language.get().subscribe(l => {
-      if (l) {
-        this.languageHeader = l;
-      }
-    });
-  }
-
-  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
-    if (req.url.indexOf('assets/i18n') !== -1) {
-      return next.handle(req.clone());
-    }
-
-    return next.handle(req.clone({
-      setHeaders: { 'Accept-Language': this.languageHeader }
-    }));
-  }
-}
diff --git a/ui/src/app/service/language/language.store.ts b/ui/src/app/service/language/language.store.ts
deleted file mode 100644
index 67822aa2e..000000000
--- a/ui/src/app/service/language/language.store.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { Injectable } from '@angular/core';
-import * as moment from 'moment';
-import { BehaviorSubject, Observable } from 'rxjs';
-
-@Injectable()
-export class LanguageStore {
-    private _language: BehaviorSubject<string> = new BehaviorSubject(null);
-
-    constructor() {
-        this._language.next('en')
-        moment.locale('en');
-    }
-
-    get() {
-        return new Observable<string>(fn => this._language.subscribe(fn));
-    }
-}
diff --git a/ui/src/app/service/services.module.ts b/ui/src/app/service/services.module.ts
index 3bf6ff6f3..f5a8dec06 100644
--- a/ui/src/app/service/services.module.ts
+++ b/ui/src/app/service/services.module.ts
@@ -34,8 +34,6 @@ import { HookService } from './hook/hook.service';
 import { ImportAsCodeService } from './import-as-code/import.service';
 import { IntegrationService } from './integration/integration.service';
 import { KeyService } from './keys/keys.service';
-import { LanguageInterceptor } from './language/language.interceptor';
-import { LanguageStore } from './language/language.store';
 import { MonitoringService } from './monitoring/monitoring.service';
 import { NavbarService } from './navbar/navbar.service';
 import { NotificationService } from './notification/notification.service';
@@ -104,7 +102,6 @@ export class ServicesModule {
                 BroadcastService,
                 BroadcastStore,
                 KeyService,
-                LanguageStore,
                 ThemeStore,
                 NavbarService,
                 NotificationService,
@@ -143,11 +140,6 @@ export class ServicesModule {
                     useClass: XSRFInterceptor,
                     multi: true
                 },
-                {
-                    provide: HTTP_INTERCEPTORS,
-                    useClass: LanguageInterceptor,
-                    multi: true
-                },
                 {
                     provide: HTTP_INTERCEPTORS,
                     useClass: LogoutInterceptor,
@@ -180,7 +172,6 @@ export {
     ImportAsCodeService,
     BroadcastStore,
     KeyService,
-    LanguageStore,
     ThemeStore,
     ParameterService,
     MonitoringService,
diff --git a/ui/src/app/service/workflow/workflow.service.ts b/ui/src/app/service/workflow/workflow.service.ts
index a4dc7c382..338b0e659 100644
--- a/ui/src/app/service/workflow/workflow.service.ts
+++ b/ui/src/app/service/workflow/workflow.service.ts
@@ -1,7 +1,16 @@
 import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
 import { Injectable } from '@angular/core';
 import { Operation } from 'app/model/operation.model';
-import { BuildResult, CDNLine, CDNLinesResponse, CDNLogLink, ServiceLog, SpawnInfo } from 'app/model/pipeline.model';
+import {
+    BuildResult,
+    CDNLine,
+    CDNLinesResponse,
+    CDNLogLink,
+    CDNLogLinks,
+    CDNLogsLines,
+    ServiceLog,
+    SpawnInfo
+} from 'app/model/pipeline.model';
 import { WorkflowRetentoinDryRunResponse } from 'app/model/purge.model';
 import { Workflow, WorkflowPull, WorkflowTriggerConditionCache } from 'app/model/workflow.model';
 import { Observable } from 'rxjs';
@@ -65,6 +74,19 @@ export class WorkflowService {
         return this._http.get<CDNLogLink>(`/project/${projectKey}/workflows/${workflowName}/nodes/${nodeRunID}/job/${jobRunID}/step/${stepOrder}/link`);
     }
 
+    getAllStepsLinks(projectKey: string, workflowName: string, nodeRunID: number, jobRunID: number): Observable<CDNLogLinks> {
+        return this._http.get<CDNLogLinks>(`/project/${projectKey}/workflows/${workflowName}/nodes/${nodeRunID}/job/${jobRunID}/links`);
+    }
+
+    getLogsLinesCount(links: CDNLogLinks, limit: number): Observable<Array<CDNLogsLines>> {
+        let params = new HttpParams();
+        links.datas.map(l => l.api_ref).forEach(ref => {
+            params = params.append('apiRefHash', ref)
+        })
+        params.append('limit', limit.toString());
+        return this._http.get<Array<CDNLogsLines>>(`./cdscdn/item/${links.item_type}/lines`, { params })
+    }
+
     getLogLines(link: CDNLogLink, params?: { [key: string]: string }): Observable<CDNLinesResponse> {
         return this._http.get(`./cdscdn/item/${link.item_type}/${link.api_ref}/lines`, { params, observe: 'response' })
             .map(res => {
diff --git a/ui/src/app/service/workflow/workflow.store.ts b/ui/src/app/service/workflow/workflow.store.ts
index ce32c4a17..ed7aee619 100644
--- a/ui/src/app/service/workflow/workflow.store.ts
+++ b/ui/src/app/service/workflow/workflow.store.ts
@@ -69,6 +69,9 @@ export class WorkflowStore {
     }
 
     setDirection(key: string, name: string, o: string) {
+        if (!key || !name) {
+            return;
+        }
         let ls = localStorage.getItem(this.WORKFLOW_ORIENTATION_KEY);
         let j = {};
         if (ls) {
diff --git a/ui/src/app/views/workflow/graph/workflow.graph.component.ts b/ui/src/app/views/workflow/graph/workflow.graph.component.ts
index 4b2a80310..c3b2f2453 100644
--- a/ui/src/app/views/workflow/graph/workflow.graph.component.ts
+++ b/ui/src/app/views/workflow/graph/workflow.graph.component.ts
@@ -41,7 +41,7 @@ export class WorkflowGraphComponent implements AfterViewInit, OnDestroy {
     @Input('direction')
     set direction(data: string) {
         this._direction = data;
-        this._workflowStore.setDirection(this.project.key, this.workflow.name, this.direction);
+        this._workflowStore.setDirection(this.project?.key, this.workflow?.name, this.direction);
         this.changeDisplay();
     }
     get direction() { return this._direction; }
@@ -87,6 +87,9 @@ export class WorkflowGraphComponent implements AfterViewInit, OnDestroy {
     }
 
     initWorkflow() {
+        if (!this.workflow) {
+            return;
+        }
         // https://github.com/cpettitt/dagre/wiki#configuring-the-layout
         this.g = new dagreD3.graphlib.Graph().setGraph({ rankdir: this.direction, nodesep: 10, ranksep: 15, edgesep: 5 });
         // Create all nodes
diff --git a/ui/src/app/views/workflow/run/node/pipeline/workflow-run-job/workflow-run-job.component.ts b/ui/src/app/views/workflow/run/node/pipeline/workflow-run-job/workflow-run-job.component.ts
index 7feb77a52..0edaffdf0 100644
--- a/ui/src/app/views/workflow/run/node/pipeline/workflow-run-job/workflow-run-job.component.ts
+++ b/ui/src/app/views/workflow/run/node/pipeline/workflow-run-job/workflow-run-job.component.ts
@@ -1,5 +1,5 @@
 import { OnDestroy, Output, ViewChild } from '@angular/core';
-import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, OnInit } from '@angular/core';
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit } from '@angular/core';
 import { Router } from '@angular/router';
 import { Store } from '@ngxs/store';
 import { CDNLine, CDNLogLink, CDNStreamFilter, PipelineStatus, SpawnInfo } from 'app/model/pipeline.model';
@@ -215,34 +215,24 @@ export class WorkflowRunJobComponent implements OnInit, OnDestroy {
             return;
         }
 
-        for (let i = 1; i < this.steps.length; i++) {
-            // We want to load initial data (first 5 and last 5 lines) for ended steps never loaded
-            if (PipelineStatus.isActive(this.nodeJobRun.job.step_status[i - 1].status)) {
-                break;
-            }
-            if (this.steps[i].link) {
-                continue;
-            }
-            if (this.steps[i].disabled) {
-                continue;
-            }
+        let links = await this._workflowService
+            .getAllStepsLinks(projectKey, workflowName, this.nodeJobRun.workflow_node_run_id, this.nodeJobRun.id).toPromise();
+        let results = await this._workflowService.getLogsLinesCount(links, this.initLoadLinesCount).toPromise();
 
-            this.steps[i].link = await this._workflowService
-                .getStepLink(projectKey, workflowName, this.nodeJobRun.workflow_node_run_id, this.nodeJobRun.id, i - 1)
-                .toPromise();
-            let results = await Promise.all([
-                this._workflowService.getLogLines(this.steps[i].link, { limit: `${this.initLoadLinesCount}` }).toPromise(),
-                this._workflowService.getLogLines(this.steps[i].link, { offset: `-${this.initLoadLinesCount}` }).toPromise()
-            ]);
-            this.steps[i].lines = results[0].lines;
-            this.steps[i].endLines = results[1].lines.filter(l => !results[0].lines.find(line => line.number === l.number));
-            this.steps[i].totalLinesCount = results[0].totalCount;
-            this.steps[i].open = true;
-            this.steps[i].loading = false;
+        if (results) {
+            results.forEach(r => {
+                let steporder = links?.datas?.find(d => d.api_ref === r.api_ref)?.step_order + 1;
+                if (!steporder) {
+                    return
+                }
+                this.steps[steporder].link = <CDNLogLink>{api_ref: r.api_ref, item_type: links.item_type}
+                this.steps[steporder].totalLinesCount = r.lines_count;
+                this.steps[steporder].open = false;
+                this.steps[steporder].loading = false;
+            })
         }
 
         this.computeStepFirstLineNumbers();
-
         this._cd.markForCheck();
     }
 
@@ -274,7 +264,7 @@ export class WorkflowRunJobComponent implements OnInit, OnDestroy {
         this._cd.markForCheck();
     }
 
-    trackStepElement(index: number, element: LogBlock) { return index; }
+    trackStepElement(index: number, _: LogBlock) { return index; }
 
     trackLineElement(index: number, element: CDNLine) { return element ? element.number : null; }
 
@@ -490,4 +480,23 @@ export class WorkflowRunJobComponent implements OnInit, OnDestroy {
             this.jobVariable.show();
         }
     }
+
+    async clickOpen(step: LogBlock) {
+        if (step?.lines?.length > 0 || step.open) {
+            step.clickOpen();
+            return
+        }
+
+        step.loading = true;
+        let results = await Promise.all([
+            this._workflowService.getLogLines(step.link, { limit: `${this.initLoadLinesCount}` }).toPromise(),
+            this._workflowService.getLogLines(step.link, { offset: `-${this.initLoadLinesCount}` }).toPromise()
+        ]);
+        step.lines = results[0].lines;
+        step.endLines = results[1].lines.filter(l => !results[0].lines.find(line => line.number === l.number));
+        step.totalLinesCount = results[0].totalCount;
+        step.open = true;
+        step.loading = false;
+        this._cd.markForCheck()
+    }
 }
diff --git a/ui/src/app/views/workflow/run/node/pipeline/workflow-run-job/workflow-run-job.html b/ui/src/app/views/workflow/run/node/pipeline/workflow-run-job/workflow-run-job.html
index a09a7abd9..dbac72cc4 100644
--- a/ui/src/app/views/workflow/run/node/pipeline/workflow-run-job/workflow-run-job.html
+++ b/ui/src/app/views/workflow/run/node/pipeline/workflow-run-job/workflow-run-job.html
@@ -17,7 +17,7 @@
     <!-- Display job steps -->
     <ng-container *ngIf="currentTabIndex === 0">
         <div class="step" *ngFor="let step of steps; trackBy: trackStepElement; let i = index">
-            <div class="line info" (click)="step.clickOpen()">
+            <div class="line info" (click)="clickOpen(step)">
                 <div class="number">
                     <i *ngIf="step.lines.length > 0" class="caret icon" [class.down]="step.open"
                         [class.right]="!step.open"></i>


Test patch:
diff --git a/engine/api/ascode_test.go b/engine/api/ascode_test.go
index 32764412a..9bdad0cac 100644
--- a/engine/api/ascode_test.go
+++ b/engine/api/ascode_test.go
@@ -48,7 +48,7 @@ func writeError(w *http.Response, err error) (*http.Response, error) {
 	body := new(bytes.Buffer)
 	enc := json.NewEncoder(body)
 	w.Body = ioutil.NopCloser(body)
-	sdkErr := sdk.ExtractHTTPError(err, "")
+	sdkErr := sdk.ExtractHTTPError(err)
 	enc.Encode(sdkErr)
 	w.StatusCode = sdkErr.Status
 	return w, sdkErr
diff --git a/engine/api/pipeline/pipeline_importer_test.go b/engine/api/pipeline/pipeline_importer_test.go
index 869e0b607..0e8777f7c 100644
--- a/engine/api/pipeline/pipeline_importer_test.go
+++ b/engine/api/pipeline/pipeline_importer_test.go
@@ -45,7 +45,7 @@ func testImportUpdate(t *testing.T, db gorp.SqlExecutor, store cache.Store, tt t
 				done <- true
 				return
 			}
-			log.Debug("[TEST] %s >>> %s", tt.name, msg.String("en"))
+			log.Debug("[TEST] %s >>> %s", tt.name, msg.String())
 		}
 	}()
 
diff --git a/engine/api/workflow/process_node_test.go b/engine/api/workflow/process_node_test.go
index 1b2516091..71e56f90f 100644
--- a/engine/api/workflow/process_node_test.go
+++ b/engine/api/workflow/process_node_test.go
@@ -2776,7 +2776,7 @@ func writeError(w *http.Response, err error) (*http.Response, error) {
 	body := new(bytes.Buffer)
 	enc := json.NewEncoder(body)
 	w.Body = ioutil.NopCloser(body)
-	sdkErr := sdk.ExtractHTTPError(err, "")
+	sdkErr := sdk.ExtractHTTPError(err)
 	_ = enc.Encode(sdkErr) // nolint
 	w.StatusCode = sdkErr.Status
 	return w, sdkErr
diff --git a/engine/api/workflowtemplate/execute_test.go b/engine/api/workflowtemplate/execute_test.go
index f317718f4..3cec9cf1d 100644
--- a/engine/api/workflowtemplate/execute_test.go
+++ b/engine/api/workflowtemplate/execute_test.go
@@ -177,7 +177,7 @@ name: Environment-[[if .id]]`)),
 
 	_, err := workflowtemplate.Parse(tmpl)
 	assert.NotNil(t, err)
-	e := sdk.ExtractHTTPError(err, "")
+	e := sdk.ExtractHTTPError(err)
 	assert.Equal(t, sdk.ErrCannotParseTemplate.ID, e.ID)
 	errs := []sdk.WorkflowTemplateError{{
 		Type:    "workflow",
diff --git a/engine/cdn/cdn_gc_test.go b/engine/cdn/cdn_gc_test.go
index eddd71827..16315fa10 100644
--- a/engine/cdn/cdn_gc_test.go
+++ b/engine/cdn/cdn_gc_test.go
@@ -209,7 +209,7 @@ func TestCleanWaitingItem(t *testing.T) {
 
 	ctx, cancel := context.WithCancel(context.TODO())
 	t.Cleanup(cancel)
-	s.Units = newRunningStorageUnits(t, m, s.DBConnectionFactory.GetDBMap(m)(), ctx, 1000)
+	s.Units = newRunningStorageUnits(t, m, s.DBConnectionFactory.GetDBMap(m)(), ctx)
 
 	it := sdk.CDNItem{
 		ID:     sdk.UUID(),
diff --git a/engine/cdn/cdn_item_test.go b/engine/cdn/cdn_item_test.go
index 8143a71c6..0bf2b78dd 100644
--- a/engine/cdn/cdn_item_test.go
+++ b/engine/cdn/cdn_item_test.go
@@ -46,7 +46,7 @@ func TestGetItemValue(t *testing.T) {
 	t.Cleanup(cancel)
 
 	cfg := test.LoadTestingConf(t, sdk.TypeCDN)
-	cdnUnits := newRunningStorageUnits(t, m, s.DBConnectionFactory.GetDBMap(m)(), ctx, 1000)
+	cdnUnits := newRunningStorageUnits(t, m, s.DBConnectionFactory.GetDBMap(m)(), ctx)
 	s.Units = cdnUnits
 	var err error
 	s.LogCache, err = lru.NewRedisLRU(db.DbMap, 1000, cfg["redisHost"], cfg["redisPassword"])
@@ -204,7 +204,7 @@ func TestGetItemValue_ThousandLines(t *testing.T) {
 
 	ctx, cancel := context.WithCancel(context.TODO())
 	t.Cleanup(cancel)
-	cdnUnits := newRunningStorageUnits(t, m, db.DbMap, ctx, 1000000)
+	cdnUnits := newRunningStorageUnits(t, m, db.DbMap, ctx)
 	s.Units = cdnUnits
 	var err error
 	s.LogCache, err = lru.NewRedisLRU(db.DbMap, 1000, cfg["redisHost"], cfg["redisPassword"])
@@ -311,7 +311,7 @@ func TestGetItemValue_Reverse(t *testing.T) {
 
 	ctx, cancel := context.WithCancel(context.TODO())
 	t.Cleanup(cancel)
-	cdnUnits := newRunningStorageUnits(t, m, db.DbMap, ctx, 1000)
+	cdnUnits := newRunningStorageUnits(t, m, db.DbMap, ctx)
 	s.Units = cdnUnits
 	var err error
 	s.LogCache, err = lru.NewRedisLRU(db.DbMap, 1000, cfg["redisHost"], cfg["redisPassword"])
@@ -421,7 +421,7 @@ func TestGetItemValue_ThousandLinesReverse(t *testing.T) {
 
 	ctx, cancel := context.WithCancel(context.TODO())
 	t.Cleanup(cancel)
-	cdnUnits := newRunningStorageUnits(t, m, db.DbMap, ctx, 200000)
+	cdnUnits := newRunningStorageUnits(t, m, db.DbMap, ctx)
 	s.Units = cdnUnits
 	var err error
 	s.LogCache, err = lru.NewRedisLRU(db.DbMap, 1000, cfg["redisHost"], cfg["redisPassword"])
diff --git a/engine/cdn/cdn_log_store_test.go b/engine/cdn/cdn_log_store_test.go
index 581e0e60a..216f7d884 100644
--- a/engine/cdn/cdn_log_store_test.go
+++ b/engine/cdn/cdn_log_store_test.go
@@ -43,7 +43,7 @@ func TestStoreNewStepLog(t *testing.T) {
 
 	ctx, cancel := context.WithCancel(context.TODO())
 	t.Cleanup(cancel)
-	cdnUnits := newRunningStorageUnits(t, m, db.DbMap, ctx, 1000)
+	cdnUnits := newRunningStorageUnits(t, m, db.DbMap, ctx)
 	s.Units = cdnUnits
 
 	hm := handledMessage{
@@ -126,7 +126,7 @@ func TestStoreLastStepLog(t *testing.T) {
 
 	ctx, cancel := context.WithCancel(context.TODO())
 	t.Cleanup(cancel)
-	cdnUnits := newRunningStorageUnits(t, m, db.DbMap, ctx, 1000)
+	cdnUnits := newRunningStorageUnits(t, m, db.DbMap, ctx)
 	s.Units = cdnUnits
 
 	hm := handledMessage{
@@ -215,7 +215,7 @@ func TestStoreNewServiceLog(t *testing.T) {
 
 	ctx, cancel := context.WithCancel(context.TODO())
 	t.Cleanup(cancel)
-	cdnUnits := newRunningStorageUnits(t, m, db.DbMap, ctx, 1000)
+	cdnUnits := newRunningStorageUnits(t, m, db.DbMap, ctx)
 	s.Units = cdnUnits
 
 	hm := handledMessage{
diff --git a/engine/cdn/cdn_log_tcp_test.go b/engine/cdn/cdn_log_tcp_test.go
index b1cf5a7c2..6314e98a1 100644
--- a/engine/cdn/cdn_log_tcp_test.go
+++ b/engine/cdn/cdn_log_tcp_test.go
@@ -285,7 +285,7 @@ func TestStoreTruncatedLogs(t *testing.T) {
 
 	ctx, ccl := context.WithCancel(context.TODO())
 	t.Cleanup(ccl)
-	cdnUnits := newRunningStorageUnits(t, m, db.DbMap, ctx, 10)
+	cdnUnits := newRunningStorageUnits(t, m, db.DbMap, ctx)
 	s.Units = cdnUnits
 
 	hm := handledMessage{
diff --git a/engine/cdn/cdn_test.go b/engine/cdn/cdn_test.go
index 30c05f3bc..24db17c48 100644
--- a/engine/cdn/cdn_test.go
+++ b/engine/cdn/cdn_test.go
@@ -103,7 +103,7 @@ var fakeAPIPrivateKey = struct {
 	key *rsa.PrivateKey
 }{}
 
-func newRunningStorageUnits(t *testing.T, m *gorpmapper.Mapper, dbMap *gorp.DbMap, ctx context.Context, maxStepSize int64) *storage.RunningStorageUnits {
+func newRunningStorageUnits(t *testing.T, m *gorpmapper.Mapper, dbMap *gorp.DbMap, ctx context.Context) *storage.RunningStorageUnits {
 	cfg := test.LoadTestingConf(t, sdk.TypeCDN)
 	tmpDir, err := ioutil.TempDir("", t.Name()+"-cdn-1-*")
 	require.NoError(t, err)
diff --git a/engine/cdn/item_handler_test.go b/engine/cdn/item_handler_test.go
index 1d0a6b3ca..709e71881 100644
--- a/engine/cdn/item_handler_test.go
+++ b/engine/cdn/item_handler_test.go
@@ -110,7 +110,7 @@ func TestGetItemLogsDownloadHandler(t *testing.T) {
 
 	ctx, cancel := context.WithCancel(context.TODO())
 	t.Cleanup(cancel)
-	s.Units = newRunningStorageUnits(t, s.Mapper, db.DbMap, ctx, 1000)
+	s.Units = newRunningStorageUnits(t, s.Mapper, db.DbMap, ctx)
 
 	hm := handledMessage{
 		Msg: hook.Message{
diff --git a/engine/cdn/item_logs_handler_test.go b/engine/cdn/item_logs_handler_test.go
index 5928730d7..a39025d81 100644
--- a/engine/cdn/item_logs_handler_test.go
+++ b/engine/cdn/item_logs_handler_test.go
@@ -4,6 +4,8 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
+	"github.com/ovh/cds/engine/cdn/item"
+	"github.com/ovh/cds/engine/cdn/storage"
 	"net/http"
 	"net/http/httptest"
 	"strconv"
@@ -26,6 +28,162 @@ import (
 	"github.com/ovh/cds/sdk/log/hook"
 )
 
+func TestGetItemsAllLogsLinesHandler(t *testing.T) {
+	projectKey := sdk.RandomString(10)
+	s, db := newTestService(t)
+
+	ctx, cancel := context.WithCancel(context.TODO())
+	t.Cleanup(cancel)
+	s.Units = newRunningStorageUnits(t, s.Mapper, db.DbMap, ctx)
+
+	// Add step 1
+	hm1 := handledMessage{
+		Msg: hook.Message{
+			Full: "this is a message",
+		},
+		IsTerminated: sdk.StatusTerminated,
+		Signature: log.Signature{
+			ProjectKey:   projectKey,
+			WorkflowID:   1,
+			WorkflowName: "MyWorkflow",
+			RunID:        1,
+			NodeRunID:    1,
+			NodeRunName:  "MyPipeline",
+			JobName:      "MyJob",
+			JobID:        1,
+			Worker: &log.SignatureWorker{
+				StepName:  "script1",
+				StepOrder: 0,
+			},
+		},
+	}
+	content := buildMessage(hm1)
+	err := s.storeLogs(context.TODO(), sdk.CDNTypeItemStepLog, hm1.Signature, hm1.IsTerminated, content)
+	require.NoError(t, err)
+	hash1 := sdk.CDNLogAPIRef{
+		ProjectKey:     hm1.Signature.ProjectKey,
+		WorkflowName:   hm1.Signature.WorkflowName,
+		WorkflowID:     hm1.Signature.WorkflowID,
+		RunID:          hm1.Signature.RunID,
+		NodeRunName:    hm1.Signature.NodeRunName,
+		NodeRunID:      hm1.Signature.NodeRunID,
+		NodeRunJobName: hm1.Signature.JobName,
+		NodeRunJobID:   hm1.Signature.JobID,
+		StepOrder:      hm1.Signature.Worker.StepOrder,
+		StepName:       hm1.Signature.Worker.StepName,
+	}
+	hashRef1, err := hash1.ToHash()
+	require.NoError(t, err)
+
+	// Add step 2
+	hm2 := hm1
+	hm2.Signature.Worker.StepOrder = 1
+	content = buildMessage(hm2)
+	err = s.storeLogs(context.TODO(), sdk.CDNTypeItemStepLog, hm2.Signature, hm2.IsTerminated, content)
+	require.NoError(t, err)
+	hash2 := sdk.CDNLogAPIRef{
+		ProjectKey:     hm2.Signature.ProjectKey,
+		WorkflowName:   hm2.Signature.WorkflowName,
+		WorkflowID:     hm2.Signature.WorkflowID,
+		RunID:          hm2.Signature.RunID,
+		NodeRunName:    hm2.Signature.NodeRunName,
+		NodeRunID:      hm2.Signature.NodeRunID,
+		NodeRunJobName: hm2.Signature.JobName,
+		NodeRunJobID:   hm2.Signature.JobID,
+		StepOrder:      hm2.Signature.Worker.StepOrder,
+		StepName:       hm2.Signature.Worker.StepName,
+	}
+	hashRef2, err := hash2.ToHash()
+	require.NoError(t, err)
+
+	// Add step 3
+	hm3 := hm1
+	hm3.Signature.Worker.StepOrder = 2
+	hm3.Msg.Full = "First Line"
+	hm3.IsTerminated = false
+	content = buildMessage(hm3)
+	err = s.storeLogs(context.TODO(), sdk.CDNTypeItemStepLog, hm3.Signature, hm3.IsTerminated, content)
+	require.NoError(t, err)
+	hash3 := sdk.CDNLogAPIRef{
+		ProjectKey:     hm3.Signature.ProjectKey,
+		WorkflowName:   hm3.Signature.WorkflowName,
+		WorkflowID:     hm3.Signature.WorkflowID,
+		RunID:          hm3.Signature.RunID,
+		NodeRunName:    hm3.Signature.NodeRunName,
+		NodeRunID:      hm3.Signature.NodeRunID,
+		NodeRunJobName: hm3.Signature.JobName,
+		NodeRunJobID:   hm3.Signature.JobID,
+		StepOrder:      hm3.Signature.Worker.StepOrder,
+		StepName:       hm3.Signature.Worker.StepName,
+	}
+
+	hm4 := hm3
+	hm4.Msg.Full = "Second Line"
+	hm4.IsTerminated = true
+	content = buildMessage(hm4)
+	err = s.storeLogs(context.TODO(), sdk.CDNTypeItemStepLog, hm4.Signature, hm4.IsTerminated, content)
+	require.NoError(t, err)
+
+	hashRef3, err := hash3.ToHash()
+	require.NoError(t, err)
+
+	it, err := item.LoadByAPIRefHashAndType(ctx, s.Mapper, s.mustDBWithCtx(ctx), hashRef3, sdk.CDNTypeItemStepLog)
+	require.NoError(t, err)
+
+	unit, err := storage.LoadUnitByName(ctx, s.Mapper, s.mustDBWithCtx(ctx), s.Units.Storages[0].Name())
+	require.NoError(t, err)
+	cpt := 0
+	for {
+		cpt++
+		_, err = storage.LoadItemUnitByUnit(ctx, s.Mapper, s.mustDBWithCtx(ctx), unit.ID, it.ID)
+		if sdk.ErrorIs(err, sdk.ErrNotFound) {
+			if cpt == 10 {
+				t.Fail()
+			}
+			time.Sleep(250 * time.Millisecond)
+			continue
+		}
+		if err != nil {
+			t.Fail()
+		}
+		break
+	}
+
+	signer, err := authentication.NewSigner("cdn-test", test.SigningKey)
+	require.NoError(t, err)
+	s.Common.ParsedAPIPublicKey = signer.GetVerifyKey()
+	jwtToken := jwt.NewWithClaims(jwt.SigningMethodRS512, sdk.AuthSessionJWTClaims{
+		ID: sdk.UUID(),
+		StandardClaims: jwt.StandardClaims{
+			Issuer:    "test",
+			Subject:   sdk.UUID(),
+			Id:        sdk.UUID(),
+			IssuedAt:  time.Now().Unix(),
+			ExpiresAt: time.Now().Add(time.Minute).Unix(),
+		},
+	})
+	jwtTokenRaw, err := signer.SignJWT(jwtToken)
+	require.NoError(t, err)
+
+	uri := fmt.Sprintf("%s?apiRefHash=%s&apiRefHash=%s&apiRefHash=%s", s.Router.GetRoute("GET", s.getItemsAllLogsLinesHandler, map[string]string{
+		"type": string(sdk.CDNTypeItemStepLog),
+	}), hashRef1, hashRef2, hashRef3)
+	require.NotEmpty(t, uri)
+	req := assets.NewJWTAuthentifiedRequest(t, jwtTokenRaw, "GET", uri, nil)
+	rec := httptest.NewRecorder()
+	s.Router.Mux.ServeHTTP(rec, req)
+	require.Equal(t, 200, rec.Code)
+
+	var logslines []sdk.CDNLogsLines
+	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &logslines))
+	require.Len(t, logslines, 3)
+
+	require.Equal(t, int64(1), logslines[0].LinesCount)
+	require.Equal(t, int64(1), logslines[1].LinesCount)
+	require.Equal(t, int64(2), logslines[2].LinesCount)
+
+}
+
 func TestGetItemLogsLinesHandler(t *testing.T) {
 	projectKey := sdk.RandomString(10)
 
@@ -38,7 +196,7 @@ func TestGetItemLogsLinesHandler(t *testing.T) {
 
 	ctx, cancel := context.WithCancel(context.TODO())
 	t.Cleanup(cancel)
-	s.Units = newRunningStorageUnits(t, s.Mapper, db.DbMap, ctx, 1000)
+	s.Units = newRunningStorageUnits(t, s.Mapper, db.DbMap, ctx)
 
 	hm := handledMessage{
 		Msg: hook.Message{
@@ -133,7 +291,7 @@ func TestGetItemLogsStreamHandler(t *testing.T) {
 
 	ctx, cancel := context.WithCancel(context.TODO())
 	t.Cleanup(cancel)
-	s.Units = newRunningStorageUnits(t, s.Mapper, db.DbMap, ctx, 1000)
+	s.Units = newRunningStorageUnits(t, s.Mapper, db.DbMap, ctx)
 
 	signature := log.Signature{
 		ProjectKey:   projectKey,
diff --git a/engine/cdn/redis/reader_test.go b/engine/cdn/redis/reader_test.go
new file mode 100644
index 000000000..035b20ba0
--- /dev/null
+++ b/engine/cdn/redis/reader_test.go
@@ -0,0 +1,58 @@
+package redis_test
+
+import (
+	"io"
+	"strings"
+	"testing"
+
+	"github.com/ovh/cds/engine/cache"
+	"github.com/ovh/cds/engine/cdn/redis"
+	"github.com/ovh/cds/engine/test"
+	"github.com/ovh/cds/sdk"
+	"github.com/stretchr/testify/require"
+)
+
+func TestReader_EOF(t *testing.T) {
+	cfg := test.LoadTestingConf(t, sdk.TypeCDN)
+
+	c, err := cache.New(cfg["redisHost"], cfg["redisPassword"], -1)
+	require.NoError(t, err)
+
+	cacheKey := cache.Key("test:cdn:item")
+
+	itemID := sdk.RandomString(10)
+
+	w := &redis.Writer{
+		Store:     c,
+		ItemID:    itemID,
+		PrefixKey: cacheKey,
+	}
+
+	r := &redis.Reader{
+		Store:     c,
+		ItemID:    itemID,
+		PrefixKey: cacheKey,
+		Format:    sdk.CDNReaderFormatJSON,
+	}
+
+	t.Logf("writings logs for fake item with id: %s", itemID)
+
+	// Insert lines in redis
+	_, err = io.Copy(w, strings.NewReader("this is the first line\nthis is the second line\n"))
+	require.NoError(t, err)
+
+	// Read all lines without closing the reader
+	buf1 := new(strings.Builder)
+	_, err = io.Copy(buf1, r)
+	require.NoError(t, err)
+	require.Equal(t, "[{\"number\":0,\"value\":\"this is the first line\\n\"},{\"number\":1,\"value\":\"this is the second line\\n\"}]", buf1.String())
+
+	// Insert a new line in redis
+	_, err = io.Copy(w, strings.NewReader("this is the third line\n"))
+	require.NoError(t, err)
+
+	// Try to read again from same reader should return an error
+	buf := make([]byte, 1)
+	_, err = r.Read(buf)
+	require.Error(t, err, "read should return EOF")
+}
diff --git a/engine/cdn/redis/writer_test.go b/engine/cdn/redis/writer_test.go
new file mode 100644
index 000000000..946a990ab
--- /dev/null
+++ b/engine/cdn/redis/writer_test.go
@@ -0,0 +1,36 @@
+package redis_test
+
+import (
+	"io"
+	"strings"
+	"testing"
+
+	"github.com/ovh/cds/engine/cache"
+	"github.com/ovh/cds/engine/cdn/redis"
+	"github.com/ovh/cds/engine/test"
+	"github.com/ovh/cds/sdk"
+	"github.com/stretchr/testify/require"
+)
+
+func TestWriter_Closed(t *testing.T) {
+	cfg := test.LoadTestingConf(t, sdk.TypeCDN)
+	c, err := cache.New(cfg["redisHost"], cfg["redisPassword"], -1)
+	require.NoError(t, err)
+
+	cacheKey := cache.Key("test:cdn:item")
+	itemID := sdk.RandomString(10)
+
+	w := &redis.Writer{
+		Store:     c,
+		ItemID:    itemID,
+		PrefixKey: cacheKey,
+	}
+
+	_, err = io.Copy(w, strings.NewReader("this is the first line\nthis is the second line\n"))
+	require.NoError(t, err)
+	require.NoError(t, w.Close())
+
+	_, err = io.Copy(w, strings.NewReader("this is the third line\n"))
+	require.Error(t, err)
+	require.Equal(t, "writer is closed", err.Error())
+}
diff --git a/sdk/error_test.go b/sdk/error_test.go
index 6ce03563f..233a5abdb 100644
--- a/sdk/error_test.go
+++ b/sdk/error_test.go
@@ -69,12 +69,12 @@ func TestNewError(t *testing.T) {
 	// print the error stack trace
 	t.Logf("%+v\n", err)
 
-	httpErr := ExtractHTTPError(err, "fr")
+	httpErr := ExtractHTTPError(err)
 
 	// print the http error
 	t.Log(httpErr)
 
-	assert.Equal(t, "la requête est incorrecte (from: this is an error generated from vendor)", httpErr.Error())
+	assert.Equal(t, "wrong request (from: this is an error generated from vendor)", httpErr.Error())
 }
 
 func TestWrapError(t *testing.T) {
@@ -118,23 +118,23 @@ func TestCause(t *testing.T) {
 func TestNewAdvancedError(t *testing.T) {
 	err := NewErrorFrom(ErrWrongRequest, "this is an error generated from vendor")
 	assert.Equal(t, "TestNewAdvancedError: wrong request (from: this is an error generated from vendor)", err.Error())
-	httpErr := ExtractHTTPError(err, "fr")
-	assert.Equal(t, "la requête est incorrecte (from: this is an error generated from vendor)", httpErr.Error())
+	httpErr := ExtractHTTPError(err)
+	assert.Equal(t, "wrong request (from: this is an error generated from vendor)", httpErr.Error())
 
 	err = WrapError(err, "Something no visible for http error")
 	assert.Equal(t, "TestNewAdvancedError: wrong request (from: this is an error generated from vendor) (caused by: Something no visible for http error: this is an error generated from vendor)", err.Error())
-	httpErr = ExtractHTTPError(err, "fr")
-	assert.Equal(t, "la requête est incorrecte (from: this is an error generated from vendor)", httpErr.Error())
+	httpErr = ExtractHTTPError(err)
+	assert.Equal(t, "wrong request (from: this is an error generated from vendor)", httpErr.Error())
 
 	err = NewError(ErrAlreadyTaken, err)
 	assert.Equal(t, "TestNewAdvancedError: This job is already taken by another worker (from: this is an error generated from vendor) (caused by: Something no visible for http error: this is an error generated from vendor)", err.Error())
-	httpErr = ExtractHTTPError(err, "fr")
-	assert.Equal(t, "Ce job est déjà en cours de traitement par un autre worker (from: this is an error generated from vendor)", httpErr.Error())
+	httpErr = ExtractHTTPError(err)
+	assert.Equal(t, "This job is already taken by another worker (from: this is an error generated from vendor)", httpErr.Error())
 
 	err = NewErrorWithStack(err, NewErrorFrom(ErrNotFound, "can't found this"))
 	assert.Equal(t, "TestNewAdvancedError: resource not found (from: can't found this) (caused by: Something no visible for http error: this is an error generated from vendor)", err.Error())
-	httpErr = ExtractHTTPError(err, "fr")
-	assert.Equal(t, "la ressource n'existe pas (from: can't found this)", httpErr.Error())
+	httpErr = ExtractHTTPError(err)
+	assert.Equal(t, "resource not found (from: can't found this)", httpErr.Error())
 }
 
 func TestMultiError(t *testing.T) {
@@ -150,23 +150,23 @@ func TestMultiError(t *testing.T) {
 	mError.Append(errFourth)
 
 	assert.Equal(t, "TestMultiError: internal server error (caused by: my first error), TestMultiError: wrong request (from: my second error), TestMultiError: internal server error (caused by: hidden info: my third error), TestMultiError: already exists (from: my fourth error)", mError.Error())
-	httpErr := ExtractHTTPError(mError, "fr")
-	assert.Equal(t, "erreur interne, la requête est incorrecte: my second error, erreur interne, conflit: my fourth error", httpErr.Error())
+	httpErr := ExtractHTTPError(mError)
+	assert.Equal(t, "internal server error, wrong request: my second error, internal server error, already exists: my fourth error", httpErr.Error())
 
 	wrappedErr := NewError(ErrWrongRequest, mError)
 	assert.Equal(t, "TestMultiError: wrong request (from: internal server error, wrong request: my second error, internal server error, already exists: my fourth error) (caused by: TestMultiError: internal server error (caused by: my first error), TestMultiError: wrong request (from: my second error), TestMultiError: internal server error (caused by: hidden info: my third error), TestMultiError: already exists (from: my fourth error))", wrappedErr.Error())
-	httpErr = ExtractHTTPError(wrappedErr, "fr")
-	assert.Equal(t, "la requête est incorrecte (from: internal server error, wrong request: my second error, internal server error, already exists: my fourth error)", httpErr.Error())
+	httpErr = ExtractHTTPError(wrappedErr)
+	assert.Equal(t, "wrong request (from: internal server error, wrong request: my second error, internal server error, already exists: my fourth error)", httpErr.Error())
 
 	stackErr := WithStack(mError)
 	assert.Equal(t, "TestMultiError: internal server error (from: internal server error, wrong request: my second error, internal server error, already exists: my fourth error) (caused by: TestMultiError: internal server error (caused by: my first error), TestMultiError: wrong request (from: my second error), TestMultiError: internal server error (caused by: hidden info: my third error), TestMultiError: already exists (from: my fourth error))", stackErr.Error())
-	httpErr = ExtractHTTPError(stackErr, "fr")
-	assert.Equal(t, "erreur interne (from: internal server error, wrong request: my second error, internal server error, already exists: my fourth error)", httpErr.Error())
+	httpErr = ExtractHTTPError(stackErr)
+	assert.Equal(t, "internal server error (from: internal server error, wrong request: my second error, internal server error, already exists: my fourth error)", httpErr.Error())
 
 	stackErr = WrapError(mError, "more info")
 	assert.Equal(t, "TestMultiError: internal server error (from: internal server error, wrong request: my second error, internal server error, already exists: my fourth error) (caused by: more info: TestMultiError: internal server error (caused by: my first error), TestMultiError: wrong request (from: my second error), TestMultiError: internal server error (caused by: hidden info: my third error), TestMultiError: already exists (from: my fourth error))", stackErr.Error())
-	httpErr = ExtractHTTPError(stackErr, "fr")
-	assert.Equal(t, "erreur interne (from: internal server error, wrong request: my second error, internal server error, already exists: my fourth error)", httpErr.Error())
+	httpErr = ExtractHTTPError(stackErr)
+	assert.Equal(t, "internal server error (from: internal server error, wrong request: my second error, internal server error, already exists: my fourth error)", httpErr.Error())
 }
 
 func Test_cause(t *testing.T) {