diff --git a/api.go b/api.go index 38aca86..1fe1be3 100644 --- a/api.go +++ b/api.go @@ -50,6 +50,10 @@ func registerBookHandler(auth *WeWorkAuthenticator, cacheManager *cache.Cache[[] if err := makeBooking(taskCtx, auth, locationName, dateString, cacheManager); err != nil { log.Printf("Booking failed for date %q at %q: %v", dateString, locationName, err) + if errors.Is(err, ErrWeWorkRateLimited) { + http.Error(w, err.Error(), http.StatusTooManyRequests) + return + } if errors.Is(err, ErrDateInOlderThanOneMonthFuture) { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -78,9 +82,10 @@ type batchBookRequest struct { } type batchBookResult struct { - Date string `json:"date"` - Status string `json:"status"` - Error string `json:"error,omitempty"` + Date string `json:"date"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + RateLimited bool `json:"-"` } type batchBookResponse struct { @@ -123,6 +128,10 @@ func registerBatchBookHandler(auth *WeWorkAuthenticator, cacheManager *cache.Cac bearerToken, weworkLocation, err := prepareBooking(taskCtx, auth, payload.Wework, cacheManager) if err != nil { log.Printf("Preparing batch booking failed at %q: %v", payload.Wework, err) + if errors.Is(err, ErrWeWorkRateLimited) { + http.Error(w, err.Error(), http.StatusTooManyRequests) + return + } if errors.Is(err, ErrWeWorkLocationNotFound) { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -136,7 +145,9 @@ func registerBatchBookHandler(auth *WeWorkAuthenticator, cacheManager *cache.Cac response := newBatchBookResponse(payload.Wework, results) w.Header().Set("Content-Type", "application/json; charset=utf-8") - if hasBatchBookingError(results) { + if hasBatchBookingRateLimit(results) { + w.WriteHeader(http.StatusTooManyRequests) + } else if hasBatchBookingError(results) { w.WriteHeader(http.StatusInternalServerError) } else { w.WriteHeader(http.StatusOK) @@ -286,6 +297,7 @@ func runBatchBookings(ctx context.Context, token string, location WeWorkLocation if err := makeBookingRequestFunc(ctx, token, parsedDates[i], location); err != nil { results[i].Status = "error" results[i].Error = err.Error() + results[i].RateLimited = errors.Is(err, ErrWeWorkRateLimited) log.Printf("Batch booking failed for date %q at %q: %v", dates[i], location.Location.Name, err) } }(i) @@ -305,6 +317,16 @@ func hasBatchBookingError(results []batchBookResult) bool { return false } +func hasBatchBookingRateLimit(results []batchBookResult) bool { + for _, result := range results { + if result.RateLimited { + return true + } + } + + return false +} + // reformatDate validates the date string against the format "Feb 18, 2025" func reformatDate(date string) (string, error) { const layout = "Jan 2, 2006" diff --git a/api_test.go b/api_test.go index 310df03..adb101f 100644 --- a/api_test.go +++ b/api_test.go @@ -137,6 +137,13 @@ func TestNewBatchBookResponseSummarizesFullFailure(t *testing.T) { } } +func TestHasBatchBookingRateLimit(t *testing.T) { + results := []batchBookResult{{Date: "Mar 1, 2026", Status: "error", RateLimited: true}} + if !hasBatchBookingRateLimit(results) { + t.Fatal("Expected rate-limited batch result") + } +} + func TestRunBatchBookingsLimitsConcurrency(t *testing.T) { original := makeBookingRequestFunc defer func() { makeBookingRequestFunc = original }() diff --git a/weworkrequests.go b/weworkrequests.go index d194b73..48d16ce 100644 --- a/weworkrequests.go +++ b/weworkrequests.go @@ -20,6 +20,7 @@ import ( ) var ErrWeWorkLocationNotFound = errors.New("wework location not found") +var ErrWeWorkRateLimited = errors.New("wework rate limit exceeded") type WeWorkLocation struct { Reservable struct { @@ -56,7 +57,7 @@ type WeWorkProperty struct { } func FetchWeWorkLocation(ctx context.Context, token string, locationID string) (WeWorkLocation, error) { - request := resty.New().R().SetContext(ctx).SetAuthToken(token) + request := newWeWorkAPIRequest(ctx, token) var locationsResponse WeWorkLocationsResponse @@ -95,7 +96,7 @@ func FetchWeWorkLocationByName(ctx context.Context, token string, locationName s } func fetchWeWorkProperties(ctx context.Context, token string) ([]WeWorkProperty, error) { - request := resty.New().R().SetContext(ctx).SetAuthToken(token) + request := newWeWorkAPIRequest(ctx, token) var properties []WeWorkProperty @@ -338,11 +339,7 @@ func calculateUTCTime(date time.Time, localTime string, tzOffset string) (string } func makeBookingRequest(ctx context.Context, token string, date time.Time, space WeWorkLocation) error { - request := resty.New().R() - - request.SetAuthToken(token) - - request.SetContext(ctx) + request := newWeWorkAPIRequest(ctx, token) // Calculate UTC times based on local times and timezone offset // Local start time is 06:00, end time is 23:59 @@ -417,6 +414,9 @@ func weWorkRequestError(operation string, response *resty.Response, requestErr e } details := truncateForLog(response.String(), 2000) + if isWeWorkRateLimited(response) { + return fmt.Errorf("%w while %s: status=%s response=%q", ErrWeWorkRateLimited, operation, response.Status(), details) + } if requestErr != nil { return fmt.Errorf("%s: %w (status=%s response=%q)", operation, requestErr, response.Status(), details) } @@ -424,6 +424,20 @@ func weWorkRequestError(operation string, response *resty.Response, requestErr e return fmt.Errorf("%s: status=%s response=%q", operation, response.Status(), details) } +func isWeWorkRateLimited(response *resty.Response) bool { + return response != nil && response.StatusCode() == http.StatusTooManyRequests +} + +func newWeWorkAPIRequest(ctx context.Context, token string) *resty.Request { + return resty.New().R(). + SetContext(ctx). + SetAuthToken(token). + SetHeader("Accept", "application/json"). + SetHeader("Origin", "https://members.wework.com"). + SetHeader("Referer", "https://members.wework.com/workplaceone/content2/bookings/desks"). + SetHeader("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/147.0.0.0 Safari/537.36") +} + func truncateForLog(value string, limit int) string { value = strings.TrimSpace(value) if len(value) <= limit { diff --git a/weworkrequests_test.go b/weworkrequests_test.go index 95e672c..86575bd 100644 --- a/weworkrequests_test.go +++ b/weworkrequests_test.go @@ -1,9 +1,13 @@ package main import ( + "context" "encoding/json" + "net/http" "testing" "time" + + "resty.dev/v3" ) func TestParseTimezoneOffset(t *testing.T) { @@ -243,3 +247,25 @@ func TestTruncateForLog(t *testing.T) { t.Fatalf("Unexpected truncated value: %q", got) } } + +func TestIsWeWorkRateLimited(t *testing.T) { + response := &resty.Response{RawResponse: &http.Response{ + StatusCode: http.StatusTooManyRequests, + Status: "429 Too Many Requests", + }} + + if !isWeWorkRateLimited(response) { + t.Fatal("Expected rate-limited response") + } +} + +func TestNewWeWorkAPIRequestUsesBrowserHeaders(t *testing.T) { + request := newWeWorkAPIRequest(context.Background(), "token") + + if request.Header.Get("Origin") != "https://members.wework.com" { + t.Fatalf("Unexpected Origin header: %q", request.Header.Get("Origin")) + } + if request.Header.Get("Referer") == "" || request.Header.Get("User-Agent") == "" { + t.Fatal("Expected browser Referer and User-Agent headers") + } +}