Skip to content

Commit a88197f

Browse files
Merge pull request router-for-me#4698 from router-for-me/fix/home-401-refresh-recovery
fix(auth): retry Home OAuth requests once after upstream 401
2 parents 9b11423 + 0fc0286 commit a88197f

29 files changed

Lines changed: 1386 additions & 176 deletions

internal/api/server_routes.go

Lines changed: 85 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -333,80 +333,115 @@ func (s *Server) codexAlphaSearch(c *gin.Context) {
333333
}
334334
logging.SetGinCPATraceID(c, selected.EnsureIndex())
335335

336-
headers := make(http.Header)
337-
headers.Set("Content-Type", "application/json")
338-
headers.Set("Accept", "application/json")
339-
headers.Set("Originator", "codex_cli_rs")
336+
baseHeaders := make(http.Header)
337+
baseHeaders.Set("Content-Type", "application/json")
338+
baseHeaders.Set("Accept", "application/json")
339+
baseHeaders.Set("Originator", "codex_cli_rs")
340340
for _, name := range []string{"Version", "User-Agent", "Session_id", "X-Client-Request-Id"} {
341341
if value := strings.TrimSpace(c.GetHeader(name)); value != "" {
342-
headers.Set(name, value)
342+
baseHeaders.Set(name, value)
343343
}
344344
}
345-
if accountID, ok := selected.Metadata["account_id"].(string); ok && strings.TrimSpace(accountID) != "" {
346-
headers.Set("Chatgpt-Account-Id", accountID)
347-
}
348345

349-
upstreamURL := "https://chatgpt.com/backend-api/codex/alpha/search"
350-
if selected.AuthKind() == auth.AuthKindAPIKey {
351-
baseURL := ""
352-
if selected.Attributes != nil {
353-
baseURL = strings.TrimSpace(selected.Attributes["base_url"])
346+
errMissingBaseURL := errors.New("Codex Alpha Search API key base URL unavailable")
347+
performRequest := func(current *auth.Auth) (*http.Response, error) {
348+
headers := baseHeaders.Clone()
349+
if accountID, ok := current.Metadata["account_id"].(string); ok && strings.TrimSpace(accountID) != "" {
350+
headers.Set("Chatgpt-Account-Id", accountID)
354351
}
355-
if baseURL == "" {
356-
if selection != nil {
357-
selection.End("missing_base_url")
352+
upstreamURL := "https://chatgpt.com/backend-api/codex/alpha/search"
353+
if current.AuthKind() == auth.AuthKindAPIKey {
354+
baseURL := ""
355+
if current.Attributes != nil {
356+
baseURL = strings.TrimSpace(current.Attributes["base_url"])
358357
}
359-
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Codex Alpha Search API key base URL unavailable"})
360-
return
361-
}
362-
upstreamURL = strings.TrimRight(baseURL, "/") + "/alpha/search"
363-
}
364-
req, err := s.handlers.AuthManager.NewHttpRequest(
365-
ctx, selected, http.MethodPost, upstreamURL, upstreamRequestBody, headers,
366-
)
367-
if err != nil {
368-
if selection != nil {
369-
selection.End("request_build_failed")
370-
}
371-
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
372-
return
358+
if baseURL == "" {
359+
return nil, errMissingBaseURL
360+
}
361+
upstreamURL = strings.TrimRight(baseURL, "/") + "/alpha/search"
362+
}
363+
req, errRequest := s.handlers.AuthManager.NewHttpRequest(ctx, current, http.MethodPost, upstreamURL, upstreamRequestBody, headers)
364+
if errRequest != nil {
365+
return nil, errRequest
366+
}
367+
authType, authValue := current.AccountInfo()
368+
helps.RecordAPIRequest(ctx, s.cfg, helps.UpstreamRequestLog{
369+
URL: upstreamURL,
370+
Method: http.MethodPost,
371+
Headers: req.Header.Clone(),
372+
Body: upstreamRequestBody,
373+
Provider: "codex",
374+
AuthID: current.ID,
375+
AuthLabel: current.Label,
376+
AuthType: authType,
377+
AuthValue: authValue,
378+
})
379+
return s.handlers.AuthManager.HttpRequest(ctx, current, req)
373380
}
374381

375-
var authID, authLabel, authType, authValue string
376-
if selected != nil {
377-
authID = selected.ID
378-
authLabel = selected.Label
379-
authType, authValue = selected.AccountInfo()
380-
}
381-
helpHeaders := req.Header.Clone()
382-
helps.RecordAPIRequest(ctx, s.cfg, helps.UpstreamRequestLog{
383-
URL: upstreamURL,
384-
Method: http.MethodPost,
385-
Headers: helpHeaders,
386-
Body: upstreamRequestBody,
387-
Provider: "codex",
388-
AuthID: authID,
389-
AuthLabel: authLabel,
390-
AuthType: authType,
391-
AuthValue: authValue,
392-
})
393-
394382
if errCtx := ctx.Err(); errCtx != nil {
395383
if selection != nil {
396384
selection.End("attempt_canceled")
397385
}
398386
c.JSON(http.StatusRequestTimeout, gin.H{"error": errCtx.Error()})
399387
return
400388
}
401-
resp, err := s.handlers.AuthManager.HttpRequest(ctx, selected, req)
389+
resp, err := performRequest(selected)
402390
if err != nil {
391+
if errors.Is(err, errMissingBaseURL) {
392+
if selection != nil {
393+
selection.End("missing_base_url")
394+
}
395+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
396+
return
397+
}
403398
if selection != nil {
404399
selection.End("request_failed")
405400
}
406401
helps.RecordAPIResponseError(ctx, s.cfg, err)
407402
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
408403
return
409404
}
405+
if selection != nil && resp.StatusCode == http.StatusUnauthorized {
406+
s.handlers.AuthManager.ReportHomeUnauthorized(ctx, selected, "codex", selectionModel)
407+
helps.RecordAPIResponseMetadata(ctx, s.cfg, resp.StatusCode, resp.Header.Clone())
408+
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
409+
if errClose := resp.Body.Close(); errClose != nil {
410+
log.Errorf("codex alpha search: close unauthorized response body error: %v", errClose)
411+
}
412+
refreshed, didRefresh, errRefresh := s.handlers.AuthManager.RefreshHomeSelectionAfterUnauthorized(ctx, selection, selected)
413+
if errRefresh != nil {
414+
selection.End("refresh_failed")
415+
status := http.StatusServiceUnavailable
416+
if statusError, ok := errRefresh.(interface{ StatusCode() int }); ok && statusError.StatusCode() > 0 {
417+
status = statusError.StatusCode()
418+
}
419+
c.JSON(status, gin.H{"error": errRefresh.Error()})
420+
return
421+
}
422+
if !didRefresh || refreshed == nil {
423+
selection.End("refresh_unavailable")
424+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Codex credential unauthorized"})
425+
return
426+
}
427+
selected = refreshed
428+
logging.SetGinCPATraceID(c, selected.EnsureIndex())
429+
resp, err = performRequest(selected)
430+
if err != nil {
431+
if errors.Is(err, errMissingBaseURL) {
432+
selection.End("missing_base_url")
433+
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
434+
return
435+
}
436+
selection.End("retry_failed")
437+
helps.RecordAPIResponseError(ctx, s.cfg, err)
438+
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
439+
return
440+
}
441+
if resp.StatusCode == http.StatusUnauthorized {
442+
s.handlers.AuthManager.ReportHomeUnauthorized(ctx, selected, "codex", selectionModel)
443+
}
444+
}
410445
closeResponseBody := func() error {
411446
errClose := resp.Body.Close()
412447
if errClose != nil {

internal/api/server_test.go

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ type codexSearchCaptureExecutor struct {
3838
prepareErr error
3939
httpErr error
4040
responseBody io.ReadCloser
41+
statuses []int
42+
refreshCalls int
43+
httpCalls int
4144
}
4245

4346
func (e *codexSearchCaptureExecutor) Identifier() string { return "codex" }
@@ -51,7 +54,13 @@ func (e *codexSearchCaptureExecutor) ExecuteStream(context.Context, *auth.Auth,
5154
}
5255

5356
func (e *codexSearchCaptureExecutor) Refresh(_ context.Context, a *auth.Auth) (*auth.Auth, error) {
54-
return a, nil
57+
e.refreshCalls++
58+
updated := a.Clone()
59+
if updated.Metadata == nil {
60+
updated.Metadata = make(map[string]any)
61+
}
62+
updated.Metadata["access_token"] = "refreshed-home-search-token"
63+
return updated, nil
5564
}
5665

5766
func (e *codexSearchCaptureExecutor) CountTokens(context.Context, *auth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {
@@ -115,6 +124,7 @@ func (e *codexSearchCaptureExecutor) HttpRequest(_ context.Context, selected *au
115124
}
116125
e.request = req.Clone(req.Context())
117126
e.authIDs = append(e.authIDs, selected.ID)
127+
e.httpCalls++
118128
body, err := io.ReadAll(req.Body)
119129
if err != nil {
120130
return nil, err
@@ -124,8 +134,12 @@ func (e *codexSearchCaptureExecutor) HttpRequest(_ context.Context, selected *au
124134
if responseBody == nil {
125135
responseBody = io.NopCloser(strings.NewReader(`{"results":[{"url":"https://example.com"}]}`))
126136
}
137+
statusCode := http.StatusOK
138+
if e.httpCalls <= len(e.statuses) && e.statuses[e.httpCalls-1] > 0 {
139+
statusCode = e.statuses[e.httpCalls-1]
140+
}
127141
return &http.Response{
128-
StatusCode: http.StatusOK,
142+
StatusCode: statusCode,
129143
Header: http.Header{"Content-Type": []string{"application/json"}},
130144
Body: responseBody,
131145
}, nil
@@ -310,6 +324,33 @@ func TestAuditHomeCodexSearchBodyCloseBeforeRelease(t *testing.T) {
310324
}
311325
}
312326

327+
func TestHomeCodexAlphaSearchRefreshesUnauthorizedSelectionOnce(t *testing.T) {
328+
server := newTestServer(t)
329+
dispatcher := &codexSearchHomeDispatcher{}
330+
server.handlers.AuthManager.SetConfig(&proxyconfig.Config{Home: proxyconfig.HomeConfig{Enabled: true}})
331+
server.handlers.AuthManager.PublishHomeDispatch(dispatcher, executionregistry.New(), 1)
332+
executor := &codexSearchCaptureExecutor{statuses: []int{http.StatusUnauthorized, http.StatusOK}}
333+
server.handlers.AuthManager.RegisterExecutor(executor)
334+
335+
req := httptest.NewRequest(http.MethodPost, "/v1/alpha/search", strings.NewReader(`{"id":"home-search-refresh","model":"gpt-5-codex","query":"test"}`))
336+
req.Header.Set("Authorization", "Bearer test-key")
337+
rr := httptest.NewRecorder()
338+
server.engine.ServeHTTP(rr, req)
339+
340+
if rr.Code != http.StatusOK {
341+
t.Fatalf("status = %d, want %d; body=%s", rr.Code, http.StatusOK, rr.Body.String())
342+
}
343+
if executor.refreshCalls != 1 || executor.httpCalls != 2 {
344+
t.Fatalf("refresh/http calls = %d/%d, want 1/2", executor.refreshCalls, executor.httpCalls)
345+
}
346+
if got := executor.request.Header.Get("Authorization"); got != "Bearer refreshed-home-search-token" {
347+
t.Fatalf("retry Authorization = %q, want refreshed token", got)
348+
}
349+
if got := dispatcher.calls.Load(); got != 1 {
350+
t.Fatalf("Home RPOP calls = %d, want 1", got)
351+
}
352+
}
353+
313354
func TestHomeCodexAlphaSearchEndsSelectionAcrossDirectHTTPPaths(t *testing.T) {
314355
tests := []struct {
315356
name string

internal/client/codex/live/live.go

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -263,39 +263,38 @@ func (h *Handler) Handle(c *gin.Context) {
263263
}
264264
}
265265

266-
headers := protocolHeaders(c.Request.Header)
267-
headers.Set("Content-Type", upstreamContentType)
268-
setAccountHeader(headers, selected)
269-
req, errRequest := h.authManager.NewHttpRequest(ctx, selected, http.MethodPost, upstreamCallURL, upstreamBody, headers)
270-
if errRequest != nil {
271-
if selection != nil {
272-
selection.End("request_build_failed")
273-
}
274-
c.JSON(http.StatusBadGateway, gin.H{"error": errRequest.Error()})
275-
return
266+
baseHeaders := protocolHeaders(c.Request.Header)
267+
baseHeaders.Set("Content-Type", upstreamContentType)
268+
performRequest := func(current *auth.Auth) (*http.Response, error) {
269+
headers := baseHeaders.Clone()
270+
setAccountHeader(headers, current)
271+
req, errRequest := h.authManager.NewHttpRequest(ctx, current, http.MethodPost, upstreamCallURL, upstreamBody, headers)
272+
if errRequest != nil {
273+
return nil, errRequest
274+
}
275+
authType, authValue := current.AccountInfo()
276+
helps.RecordAPIRequest(ctx, runtimeConfig, helps.UpstreamRequestLog{
277+
URL: upstreamCallURL,
278+
Method: http.MethodPost,
279+
Headers: headersForLogging(req.Header),
280+
Body: upstreamBody,
281+
Provider: "codex",
282+
AuthID: current.ID,
283+
AuthLabel: current.Label,
284+
AuthType: authType,
285+
AuthValue: authValue,
286+
})
287+
return h.authManager.HttpRequest(ctx, current, req)
276288
}
277289

278-
authType, authValue := selected.AccountInfo()
279-
helps.RecordAPIRequest(ctx, runtimeConfig, helps.UpstreamRequestLog{
280-
URL: upstreamCallURL,
281-
Method: http.MethodPost,
282-
Headers: headersForLogging(req.Header),
283-
Body: upstreamBody,
284-
Provider: "codex",
285-
AuthID: selected.ID,
286-
AuthLabel: selected.Label,
287-
AuthType: authType,
288-
AuthValue: authValue,
289-
})
290-
291290
if errContext := ctx.Err(); errContext != nil {
292291
if selection != nil {
293292
selection.End("attempt_canceled")
294293
}
295294
c.JSON(http.StatusRequestTimeout, gin.H{"error": errContext.Error()})
296295
return
297296
}
298-
resp, errRequest := h.authManager.HttpRequest(ctx, selected, req)
297+
resp, errRequest := performRequest(selected)
299298
if errRequest != nil {
300299
if selection != nil {
301300
selection.End("request_failed")
@@ -304,6 +303,37 @@ func (h *Handler) Handle(c *gin.Context) {
304303
c.JSON(http.StatusBadGateway, gin.H{"error": errRequest.Error()})
305304
return
306305
}
306+
if selection != nil && resp.StatusCode == http.StatusUnauthorized {
307+
h.authManager.ReportHomeUnauthorized(ctx, selected, "codex", model)
308+
helps.RecordAPIResponseMetadata(ctx, runtimeConfig, resp.StatusCode, callResponseHeaders(resp.Header))
309+
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
310+
if errClose := resp.Body.Close(); errClose != nil {
311+
log.Errorf("codex live: close unauthorized response body error: %v", errClose)
312+
}
313+
refreshed, didRefresh, errRefresh := h.authManager.RefreshHomeSelectionAfterUnauthorized(ctx, selection, selected)
314+
if errRefresh != nil {
315+
selection.End("refresh_failed")
316+
writeSelectionError(c, errRefresh)
317+
return
318+
}
319+
if !didRefresh || refreshed == nil {
320+
selection.End("refresh_unavailable")
321+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Codex credential unauthorized"})
322+
return
323+
}
324+
selected = refreshed
325+
logging.SetGinCPATraceID(c, selected.EnsureIndex())
326+
resp, errRequest = performRequest(selected)
327+
if errRequest != nil {
328+
selection.End("retry_failed")
329+
helps.RecordAPIResponseError(ctx, runtimeConfig, errRequest)
330+
c.JSON(http.StatusBadGateway, gin.H{"error": errRequest.Error()})
331+
return
332+
}
333+
if resp.StatusCode == http.StatusUnauthorized {
334+
h.authManager.ReportHomeUnauthorized(ctx, selected, "codex", model)
335+
}
336+
}
307337

308338
var closeResponseOnce sync.Once
309339
var closeResponseErr error

0 commit comments

Comments
 (0)